diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 6fced6e..1416fc1 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -5,11 +5,16 @@ on: branches: - master - dev + tags: + - '*' jobs: build-image: runs-on: ubuntu-latest + env: + IMAGE_NAME: ghcr.io/voidcontests/api + steps: - name: Checkout code uses: actions/checkout@v4 @@ -22,9 +27,8 @@ jobs: password: ${{ secrets.GHCR_TOKEN }} - name: Build and push image - run: | - SHORT_SHA=${GITHUB_SHA::7} - docker build -t ghcr.io/voidcontests/api:$SHORT_SHA \ - -t ghcr.io/voidcontests/api:latest . - docker push ghcr.io/voidcontests/api:$SHORT_SHA - docker push ghcr.io/voidcontests/api:latest + run: curl -sSL https://raw.githubusercontent.com/voidcontests/infra/refs/heads/master/push-image.sh | bash -s -- $IMAGE_NAME + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF: ${{ github.ref }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} diff --git a/Dockerfile b/Dockerfile index 3ef11f1..45227bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,11 @@ RUN go mod download COPY . . RUN go build -a -ldflags "-w -s \ - -X github.com/voidcontests/api/internal/version.GIT_COMMIT=$(git rev-parse --short HEAD) \ - -X github.com/voidcontests/api/internal/version.GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD)" \ + -X github.com/voidcontests/api/internal/version.Commit=$(git rev-parse --short HEAD) \ + -X github.com/voidcontests/api/internal/version.Branch=$(git rev-parse --abbrev-ref HEAD)" \ -o build/api ./cmd/api -# Lightweight docker container with binaries only +# lightweight docker container with binaries only FROM alpine:latest WORKDIR /app diff --git a/build.sh b/build.sh deleted file mode 100755 index a91d05d..0000000 --- a/build.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash - -# colors -bold='\033[0;1m' -italic='\033[0;3m' -underl='\033[0;4m' -red='\033[0;31m' -green='\033[0;32m' -blue='\033[0;34m' -yellow='\033[0;33m' -normal='\033[0m' - -out="build/server" - -help() { - echo -e "${underl}Usage:${normal}\n" - echo -e " ${bold}$0${normal} [${underl}command${normal}]\n" - echo -e "Here is a list of available commands\n" - echo -e " ${bold}deploy${normal} [${underl}branch${normal}] Run deploy script from current or provided branch" - echo -e " ${bold}image${normal} [push] Build docker image and (optional) push to container registry" - echo -e " ${bold}run${normal} [${underl}env${normal}] Run binary with provided environment (local - default)" - echo -e " ${bold}help${normal} Print this help messages to standard output" -} - -build_executable() { - echo "Building executable..." - go build -o ${out} cmd/server/main.go - - echo -e "Server successfully built into ${bold}\`${out}\`${normal}" -} - -if [ "$1" == "image" ]; then - GIT_COMMIT=$(git rev-parse --short HEAD) - - echo -e "Building a docker image from commit ${bold}$GIT_COMMIT${normal}" - docker build -t jus1d/void-server:latest --platform linux/amd64 . - - if [ "$2" == "push" ]; then - docker tag jus1d/void-server:latest jus1d/void-server:$GIT_COMMIT - - tags=("$GIT_COMMIT" "latest") - for tag in "${tags[@]}"; do - echo -e "Pushing ${bold}jus1d/void-server:$tag${normal} to hub" - docker push jus1d/void-server:"$tag" - done - - echo "Built docker image was successfully pushed to dockerhub" - fi -elif [ "$1" == "deploy" ]; then - if [ -n "$2" ]; then - git checkout "$2" - fi - - echo -e "Deploying ${bold}voidcontests/server${normal} from ${bold}$(git rev-parse --abbrev-ref HEAD)${normal} branch" - - echo "Pulling latest image..." - docker pull jus1d/void-server:latest - - echo "Stopping docker compose..." - docker compose down - - echo "Starting docker compose..." - docker compose up -d - - echo "Server running" -elif [ "$1" == "run" ]; then - build_executable - - if [ -n "$2" ]; then - env="$2" - else - env="local" - fi - - if [ $env == "local" ]; then - echo "Start docker containers with environment" - docker compose -f ./docker-compose.local.yaml up -d - fi - - CONFIG_PATH="./config/${env}.yaml" ./build/server - - echo "Shutting down environment containers" - docker compose down -elif [ "$1" == "help" ]; then - help -else - build -fi diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml deleted file mode 100644 index c67960a..0000000 --- a/docker-compose.local.yaml +++ /dev/null @@ -1,21 +0,0 @@ -services: - postgres: - container_name: void-postgres - image: postgres:latest - restart: unless-stopped - volumes: - - ./.db/postgres/data:/var/lib/postgresql/data - environment: - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - ports: - - "5432:5432" - - redis: - container_name: void-redis - image: redis:latest - restart: unless-stopped - command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] - environment: - REDIS_PASSWORD: ${REDIS_PASSWORD} - ports: - - "6379:6379" diff --git a/docker-compose.yaml b/docker-compose.yaml index d17335a..3d3392a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,7 +4,7 @@ services: container_name: void-api restart: unless-stopped environment: - CONFIG_PATH: ./config/dev.yaml + CONFIG_PATH: ${CONFIG_PATH} ports: - "5919:5919" volumes: diff --git a/internal/app/handler/account.go b/internal/app/handler/account.go index 89d363a..7978b9c 100644 --- a/internal/app/handler/account.go +++ b/internal/app/handler/account.go @@ -84,7 +84,7 @@ func (h *Handler) GetAccount(c echo.Context) error { user, err := h.repo.User.GetByID(ctx, claims.UserID) if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "user not found") + return Error(http.StatusUnauthorized, "invalid or expired token") } if err != nil { return fmt.Errorf("%s: can't get user: %v", op, err) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 7be6625..aadfcbc 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -106,16 +106,18 @@ func (h *Handler) GetContestByID(c echo.Context) error { for i := range n { cdetailed.Problems[i] = response.ContestProblemListItem{ - ID: problems[i].ID, - Charcode: problems[i].Charcode, - ContestID: contest.ID, + ID: problems[i].ID, + Charcode: problems[i].Charcode, Writer: response.User{ ID: problems[i].WriterID, Username: problems[i].WriterUsername, }, - Title: problems[i].Title, - Difficulty: problems[i].Difficulty, - CreatedAt: problems[i].CreatedAt, + Title: problems[i].Title, + Difficulty: problems[i].Difficulty, + TimeLimitMS: problems[i].TimeLimitMS, + MemoryLimitMB: problems[i].MemoryLimitMB, + Checker: problems[i].Checker, + CreatedAt: problems[i].CreatedAt, } } @@ -135,6 +137,11 @@ func (h *Handler) GetContestByID(c echo.Context) error { cdetailed.IsParticipant = true + _, deadline := AllowSubmitAt(contest, entry) + if contest.StartTime.Before(time.Now()) { + cdetailed.SubmissionDeadline = &deadline + } + statuses, err := h.repo.Submission.GetProblemStatuses(ctx, entry.ID) if err != nil { return fmt.Errorf("%s: can't get submissions: %v", op, err) diff --git a/internal/app/handler/dto/request/request.go b/internal/app/handler/dto/request/request.go index 74317a4..c0bbd7a 100644 --- a/internal/app/handler/dto/request/request.go +++ b/internal/app/handler/dto/request/request.go @@ -28,18 +28,16 @@ type CreateContestRequest struct { } type CreateProblemRequest struct { - Title string `json:"title" required:"true"` - Kind string `json:"kind" required:"true"` - Statement string `json:"statement" required:"true"` - Difficulty string `json:"difficulty" required:"true"` - TimeLimitMS int `json:"time_limit_ms"` - TestCases []models.TestCaseDTO `json:"test_cases"` - Answer string `json:"answer"` + Title string `json:"title" required:"true"` + Statement string `json:"statement" required:"true"` + Difficulty string `json:"difficulty" required:"true"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` + Checker string `json:"checker"` + TestCases []models.TestCaseDTO `json:"test_cases"` } type CreateSubmissionRequest struct { - ProblemKind string `json:"problem_kind" required:"true"` - Answer string `json:"answer"` - Code string `json:"code"` - Language string `json:"language"` + Code string `json:"code"` + Language string `json:"language"` } diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index a7a1105..be5ab0c 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -43,19 +43,20 @@ type User struct { } type ContestDetailed struct { - ID int32 `json:"id"` - Creator User `json:"creator"` - Title string `json:"title"` - Description string `json:"description"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - DurationMins int32 `json:"duration_mins"` - MaxEntries int32 `json:"max_entries,omitempty"` - Participants int32 `json:"participants"` - AllowLateJoin bool `json:"allow_late_join"` - IsParticipant bool `json:"is_participant,omitempty"` - Problems []ContestProblemListItem `json:"problems"` - CreatedAt time.Time `json:"created_at"` + ID int32 `json:"id"` + Creator User `json:"creator"` + Title string `json:"title"` + Description string `json:"description"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + DurationMins int32 `json:"duration_mins"` + MaxEntries int32 `json:"max_entries,omitempty"` + Participants int32 `json:"participants"` + AllowLateJoin bool `json:"allow_late_join"` + IsParticipant bool `json:"is_participant,omitempty"` + SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` + Problems []ContestProblemListItem `json:"problems"` + CreatedAt time.Time `json:"created_at"` } type ContestListItem struct { @@ -73,9 +74,8 @@ type ContestListItem struct { type Submission struct { ID int32 `json:"id"` ProblemID int32 `json:"problem_id"` - ProblemKind string `json:"problem_kind"` + Status string `json:"status"` Verdict string `json:"verdict"` - Answer string `json:"answer,omitempty"` Code string `json:"code,omitempty"` Language string `json:"language,omitempty"` TestingReport *TestingReport `json:"testing_report,omitempty"` @@ -83,62 +83,72 @@ type Submission struct { } type TestingReport struct { - Passed int `json:"passed"` - Total int `json:"total"` - Stderr string `json:"stderr,omitempty"` - FailedTest *FailedTest `json:"failed_test,omitempty"` + ID int32 `json:"id"` + PassedTestsCount int32 `json:"passed_tests_count"` + TotalTestsCount int32 `json:"total_tests_count"` + FailedTest *Test `json:"failed_test,omitempty"` + Stderr string `json:"stderr"` + CreatedAt time.Time `json:"created_at"` } -type FailedTest struct { +type Test struct { Input string `json:"input"` ExpectedOutput string `json:"expected_output"` ActualOutput string `json:"actual_output"` } type ContestProblemDetailed struct { - ID int32 `json:"id"` - Charcode string `json:"charcode"` - ContestID int32 `json:"contest_id"` - Writer User `json:"writer"` - Kind string `json:"kind"` - Title string `json:"title"` - Statement string `json:"statement"` - Examples []TC `json:"examples,omitempty"` - Difficulty string `json:"difficulty"` - Status string `json:"status,omitempty"` - TimeLimitMS int32 `json:"time_limit_ms"` - CreatedAt time.Time `json:"created_at"` + ID int32 `json:"id"` + Charcode string `json:"charcode"` + ContestID int32 `json:"contest_id"` + Writer User `json:"writer"` + Title string `json:"title"` + Statement string `json:"statement"` + Examples []TC `json:"examples,omitempty"` + Difficulty string `json:"difficulty"` + Status string `json:"status,omitempty"` + TimeLimitMS int32 `json:"time_limit_ms"` + MemoryLimitMB int32 `json:"memory_limit_mb"` + Checker string `json:"checker"` + SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` + CreatedAt time.Time `json:"created_at"` } type ContestProblemListItem struct { - ID int32 `json:"id"` - Charcode string `json:"charcode"` - ContestID int32 `json:"contest_id"` - Writer User `json:"writer"` - Title string `json:"title"` - Difficulty string `json:"difficulty"` - Status string `json:"status,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID int32 `json:"id"` + Charcode string `json:"charcode"` + Writer User `json:"writer"` + Title string `json:"title"` + Difficulty string `json:"difficulty"` + Status string `json:"status,omitempty"` + TimeLimitMS int32 `json:"time_limit_ms"` + MemoryLimitMB int32 `json:"memory_limit_mb"` + Checker string `json:"checker"` + CreatedAt time.Time `json:"created_at"` } type ProblemDetailed struct { - ID int32 `json:"id"` - Writer User `json:"writer"` - Kind string `json:"kind"` - Title string `json:"title"` - Statement string `json:"statement"` - Examples []TC `json:"examples,omitempty"` - Difficulty string `json:"difficulty"` - TimeLimitMS int32 `json:"time_limit_ms"` - CreatedAt time.Time `json:"created_at"` + ID int32 `json:"id"` + Writer User `json:"writer"` + Title string `json:"title"` + Statement string `json:"statement"` + Examples []TC `json:"examples,omitempty"` + Difficulty string `json:"difficulty"` + TimeLimitMS int32 `json:"time_limit_ms"` + MemoryLimitMB int32 `json:"memory_limit_mb"` + Checker string `json:"checker"` + CreatedAt time.Time `json:"created_at"` } type ProblemListItem struct { - ID int32 `json:"id"` - Writer User `json:"writer"` - Title string `json:"title"` - Difficulty string `json:"difficulty"` - CreatedAt time.Time `json:"created_at"` + ID int32 `json:"id"` + Writer User `json:"writer"` + Title string `json:"title"` + Difficulty string `json:"difficulty"` + TimeLimitMS int32 `json:"time_limit_ms"` + MemoryLimitMB int32 `json:"memory_limit_mb"` + Checker string `json:"checker"` + CreatedAt time.Time `json:"created_at"` } type TC struct { diff --git a/internal/app/handler/problem.go b/internal/app/handler/problem.go index 6921266..aa9dceb 100644 --- a/internal/app/handler/problem.go +++ b/internal/app/handler/problem.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" @@ -45,25 +46,34 @@ func (h *Handler) CreateProblem(c echo.Context) error { } } - var problemID int32 - if body.Kind == models.TextAnswerProblem { - problemID, err = h.repo.Problem.Create(ctx, models.TextAnswerProblem, claims.UserID, body.Title, body.Statement, body.Difficulty, body.Answer, 0) - } else if body.Kind == models.CodingProblem { - examplesCount := 0 - for i := range body.TestCases { - if body.TestCases[i].IsExample { - examplesCount++ - } + if body.TimeLimitMS < 500 || body.TimeLimitMS > 10000 { + return Error(http.StatusBadRequest, "time_limit_ms must be between 500 and 10000") + } + + if body.MemoryLimitMB < 16 || body.MemoryLimitMB > 512 { + return Error(http.StatusBadRequest, "memory_limit_mb must be between 16 and 512") + } + + // TODO: Remove examples as database entity + // Forbid to create more examples than 3 + examplesCount := 0 + for i := range body.TestCases { + if body.TestCases[i].IsExample { + examplesCount++ + } - if examplesCount > 3 && body.TestCases[i].IsExample { - body.TestCases[i].IsExample = false - } + if examplesCount > 3 && body.TestCases[i].IsExample { + body.TestCases[i].IsExample = false } - problemID, err = h.repo.Problem.CreateWithTCs(ctx, models.CodingProblem, claims.UserID, body.Title, body.Statement, body.Difficulty, "", body.TimeLimitMS, body.TestCases) - } else { - return Error(http.StatusBadRequest, "unknown problem kind") } + checker := body.Checker + if checker == "" { + checker = "tokens" + } + + problemID, err := h.repo.Problem.CreateWithTCs(ctx, claims.UserID, body.Title, body.Statement, body.Difficulty, body.TimeLimitMS, body.MemoryLimitMB, checker, body.TestCases) + if err != nil { return fmt.Errorf("%s: can't create problem: %v", op, err) } @@ -98,10 +108,13 @@ func (h *Handler) GetCreatedProblems(c echo.Context) error { problems := make([]response.ProblemListItem, n, n) for i, p := range ps { problems[i] = response.ProblemListItem{ - ID: p.ID, - Title: p.Title, - Difficulty: p.Difficulty, - CreatedAt: p.CreatedAt, + ID: p.ID, + Title: p.Title, + Difficulty: p.Difficulty, + CreatedAt: p.CreatedAt, + TimeLimitMS: p.TimeLimitMS, + MemoryLimitMB: p.MemoryLimitMB, + Checker: p.Checker, Writer: response.User{ ID: p.WriterID, Username: p.WriterUsername, @@ -138,6 +151,19 @@ func (h *Handler) GetContestProblem(c echo.Context) error { } charcode = strings.ToUpper(charcode) + contest, err := h.repo.Contest.GetByID(ctx, int32(contestID)) + if errors.Is(err, pgx.ErrNoRows) { + return Error(http.StatusNotFound, "contest not found") + } + if err != nil { + return err + } + + now := time.Now() + if contest.StartTime.After(now) { + return Error(http.StatusForbidden, "contest not started yet") + } + entry, err := h.repo.Entry.Get(ctx, int32(contestID), claims.UserID) if errors.Is(err, pgx.ErrNoRows) { return Error(http.StatusForbidden, "no entry") @@ -174,23 +200,29 @@ func (h *Handler) GetContestProblem(c echo.Context) error { } pdetailed := response.ContestProblemDetailed{ - ID: p.ID, - Charcode: p.Charcode, - ContestID: int32(contestID), - Kind: p.Kind, - Title: p.Title, - Statement: p.Statement, - Examples: examples, - Difficulty: p.Difficulty, - Status: status, - CreatedAt: p.CreatedAt, - TimeLimitMS: p.TimeLimitMS, + ID: p.ID, + Charcode: p.Charcode, + ContestID: int32(contestID), + Title: p.Title, + Statement: p.Statement, + Examples: examples, + Difficulty: p.Difficulty, + Status: status, + CreatedAt: p.CreatedAt, + TimeLimitMS: p.TimeLimitMS, + MemoryLimitMB: p.MemoryLimitMB, + Checker: p.Checker, Writer: response.User{ ID: p.WriterID, Username: p.WriterUsername, }, } + _, deadline := AllowSubmitAt(contest, entry) + if contest.StartTime.Before(time.Now()) { + pdetailed.SubmissionDeadline = &deadline + } + return c.JSON(http.StatusOK, pdetailed) } @@ -232,14 +264,15 @@ func (h *Handler) GetProblemByID(c echo.Context) error { } pdetailed := response.ProblemDetailed{ - ID: problem.ID, - Kind: problem.Kind, - Title: problem.Title, - Statement: problem.Statement, - Examples: examples, - Difficulty: problem.Difficulty, - CreatedAt: problem.CreatedAt, - TimeLimitMS: problem.TimeLimitMS, + ID: problem.ID, + Title: problem.Title, + Statement: problem.Statement, + Examples: examples, + Difficulty: problem.Difficulty, + CreatedAt: problem.CreatedAt, + TimeLimitMS: problem.TimeLimitMS, + MemoryLimitMB: problem.MemoryLimitMB, + Checker: problem.Checker, Writer: response.User{ ID: problem.WriterID, Username: problem.WriterUsername, diff --git a/internal/app/handler/submission.go b/internal/app/handler/submission.go index 873b41b..14cf7bc 100644 --- a/internal/app/handler/submission.go +++ b/internal/app/handler/submission.go @@ -13,7 +13,7 @@ import ( "github.com/voidcontests/api/internal/app/handler/dto/response" "github.com/voidcontests/api/internal/lib/logger/sl" "github.com/voidcontests/api/internal/storage/models" - "github.com/voidcontests/api/internal/storage/repository/postgres/submission" + "github.com/voidcontests/api/internal/storage/models/status" "github.com/voidcontests/api/pkg/requestid" "github.com/voidcontests/api/pkg/validate" ) @@ -50,15 +50,6 @@ func (h *Handler) CreateSubmission(c echo.Context) error { return err } - if contest.StartTime.After(time.Now()) { - return Error(http.StatusForbidden, "contest is not started yet") - } - - // TODO: maybe allow to submit solutions after end time if `contest.keep_as_training` is enabled - if contest.EndTime.Before(time.Now()) { - return Error(http.StatusForbidden, "contest alreay ended") - } - entry, err := h.repo.Entry.Get(ctx, int32(contestID), claims.UserID) if errors.Is(err, pgx.ErrNoRows) { log.Debug("trying to create submission without entry") @@ -69,6 +60,12 @@ func (h *Handler) CreateSubmission(c echo.Context) error { return err } + now := time.Now() + earliest, deadline := AllowSubmitAt(contest, entry) + if earliest.After(now) || deadline.Before(now) { + return Error(http.StatusForbidden, "submission window is currently closed") + } + problem, err := h.repo.Problem.Get(ctx, int32(contestID), charcode) if errors.Is(err, pgx.ErrNoRows) { return Error(http.StatusNotFound, "problem not found") @@ -78,76 +75,43 @@ func (h *Handler) CreateSubmission(c echo.Context) error { return err } - if body.ProblemKind == models.TextAnswerProblem { - var verdict string - if problem.Answer != body.Answer { - verdict = submission.VerdictWrongAnswer - } else { - verdict = submission.VerdictOK - } - - s, err := h.repo.Submission.Create(ctx, entry.ID, problem.ID, verdict, body.Answer, "", "", 0, "") - if err != nil { - log.Error("can't create submission", sl.Err(err)) - return err - } - - return c.JSON(http.StatusCreated, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - ProblemKind: s.ProblemKind, - Verdict: string(s.Verdict), - Answer: body.Answer, - CreatedAt: s.CreatedAt, - }) - } else if body.ProblemKind == models.CodingProblem { - tcs, err := h.repo.Problem.GetTestCases(ctx, problem.ID) - if err != nil { - log.Error("can't get test cases for problem", sl.Err(err)) - return err - } - - rtcs := make([]models.TestCaseDTO, len(tcs)) - for i := range rtcs { - rtcs[i].Input = tcs[i].Input - rtcs[i].Output = tcs[i].Output - } - - s, err := h.repo.Submission.Create(ctx, entry.ID, problem.ID, submission.VerdictPending, "", body.Code, body.Language, 0, "") - if err != nil { - log.Error("can't create submission", sl.Err(err)) - return err - } + s, err := h.repo.Submission.Create(ctx, entry.ID, problem.ID, body.Code, body.Language) + if err != nil { + log.Error("can't create submission", sl.Err(err)) + return err + } - if err := h.broker.PublishSubmission(ctx, s); err != nil { - log.Error("can't publish submission", sl.Err(err)) - // NOTE: should we return error to user? - } + // TODO: create initial testing report in database - return c.JSON(http.StatusCreated, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - ProblemKind: s.ProblemKind, - Verdict: submission.VerdictPending, - CreatedAt: s.CreatedAt, - }) + if err := h.broker.PublishSubmission(ctx, s); err != nil { + log.Error("can't publish submission", sl.Err(err)) + // TODO: if we can't push submission into execution queue, try to save it to local memory, and try to push later (?) + // - but is it really needed, after some time? + return err } - return Error(http.StatusBadRequest, "unknown problem kind") + return c.JSON(http.StatusCreated, response.Submission{ + ID: s.ID, + ProblemID: s.ProblemID, + Status: s.Status, + Verdict: s.Verdict, + CreatedAt: s.CreatedAt, + }) } func (h *Handler) GetSubmissionByID(c echo.Context) error { log := slog.With(slog.String("op", "handler.GetSubmissionByID"), slog.String("request_id", requestid.Get(c))) ctx := c.Request().Context() - claims, _ := ExtractClaims(c) + // TODO: check if submission is submitted by request initiator + _, _ = ExtractClaims(c) submissionID, ok := ExtractParamInt(c, "sid") if !ok { return Error(http.StatusBadRequest, "submission ID should be an integer") } - s, err := h.repo.Submission.GetByID(ctx, claims.UserID, int32(submissionID)) + s, err := h.repo.Submission.GetByID(ctx, int32(submissionID)) if errors.Is(err, pgx.ErrNoRows) { return Error(http.StatusNotFound, "submission not found") } @@ -156,75 +120,68 @@ func (h *Handler) GetSubmissionByID(c echo.Context) error { return err } - if s.ProblemKind == models.TextAnswerProblem { + if s.Status != status.Success { return c.JSON(http.StatusOK, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - ProblemKind: s.ProblemKind, - Verdict: s.Verdict, - Answer: s.Answer, - CreatedAt: s.CreatedAt, + ID: s.ID, + ProblemID: s.ProblemID, + Status: s.Status, + Verdict: s.Verdict, + Code: s.Code, + Language: s.Language, + CreatedAt: s.CreatedAt, }) + } - ttc, err := h.repo.Submission.CountTestsForProblem(ctx, s.ProblemID) + tr, err := h.repo.Submission.GetTestingReport(ctx, s.ID) if err != nil { - log.Error("can't get total tests count", sl.Err(err)) + log.Error("can't get testing report", sl.Err(err)) return err } - switch s.Verdict { - case submission.VerdictRunning, submission.VerdictPending: - return c.JSON(http.StatusOK, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - ProblemKind: s.ProblemKind, - Verdict: s.Verdict, - Code: s.Code, - Language: s.Language, - CreatedAt: s.CreatedAt, - }) - } - - failedTest, err := h.repo.Submission.GetFailedTest(ctx, s.ID) - // TODO: check if submission.Passed == submission.Total - if errors.Is(err, pgx.ErrNoRows) { + if tr.FirstFailedTestID == nil { return c.JSON(http.StatusOK, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - ProblemKind: s.ProblemKind, - Verdict: s.Verdict, - Code: s.Code, - Language: s.Language, + ID: s.ID, + ProblemID: s.ProblemID, + Status: s.Status, + Verdict: s.Verdict, + Code: s.Code, + Language: s.Language, TestingReport: &response.TestingReport{ - Passed: int(s.PassedTestsCount), - Total: int(ttc), - Stderr: s.Stderr, + ID: tr.ID, + PassedTestsCount: tr.PassedTestsCount, + TotalTestsCount: tr.TotalTestsCount, + Stderr: tr.Stderr, + CreatedAt: tr.CreatedAt, }, CreatedAt: s.CreatedAt, }) } + + ftc, err := h.repo.Problem.GetTestCaseByID(ctx, *tr.FirstFailedTestID) if err != nil { - log.Error("can't get submissions", sl.Err(err)) + log.Error("can't get test case", sl.Err(err)) return err } return c.JSON(http.StatusOK, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - ProblemKind: s.ProblemKind, - Verdict: s.Verdict, - Code: s.Code, - Language: s.Language, + ID: s.ID, + ProblemID: s.ProblemID, + Status: s.Status, + Verdict: s.Verdict, + Code: s.Code, + Language: s.Language, TestingReport: &response.TestingReport{ - Passed: int(s.PassedTestsCount), - Total: int(ttc), - Stderr: s.Stderr, - FailedTest: &response.FailedTest{ - Input: failedTest.Input, - ExpectedOutput: failedTest.ExpectedOutput, - ActualOutput: failedTest.ActualOutput, + ID: tr.ID, + PassedTestsCount: tr.PassedTestsCount, + TotalTestsCount: tr.TotalTestsCount, + FailedTest: &response.Test{ + Input: ftc.Input, + ExpectedOutput: ftc.Output, + ActualOutput: *tr.FirstFailedTestOutput, }, + Stderr: tr.Stderr, + CreatedAt: tr.CreatedAt, }, CreatedAt: s.CreatedAt, }) @@ -276,11 +233,11 @@ func (h *Handler) GetSubmissions(c echo.Context) error { items := make([]response.Submission, n, n) for i, submission := range submissions { items[i] = response.Submission{ - ID: submission.ID, - ProblemID: submission.ProblemID, - ProblemKind: submission.ProblemKind, - Verdict: submission.Verdict, - CreatedAt: submission.CreatedAt, + ID: submission.ID, + ProblemID: submission.ProblemID, + Status: submission.Status, + Verdict: submission.Verdict, + CreatedAt: submission.CreatedAt, } } @@ -295,3 +252,23 @@ func (h *Handler) GetSubmissions(c echo.Context) error { Items: items, }) } + +func AllowSubmitAt(contest models.Contest, entry models.Entry) (earliest time.Time, deadline time.Time) { + if contest.DurationMins == 0 { + return contest.StartTime, contest.EndTime + } + + earliest = entry.CreatedAt + if contest.StartTime.After(earliest) { + earliest = contest.StartTime + } + + personalDeadline := earliest.Add(time.Duration(contest.DurationMins) * time.Minute) + if personalDeadline.Before(contest.EndTime) { + deadline = personalDeadline + } else { + deadline = contest.EndTime + } + + return earliest, deadline +} diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index c65898e..ccefcbf 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -9,11 +9,6 @@ const ( RoleBanned = "banned" ) -const ( - TextAnswerProblem = "text_answer_problem" - CodingProblem = "coding_problem" -) - type User struct { ID int32 `db:"id"` Username string `db:"username"` @@ -49,20 +44,21 @@ type Contest struct { type Problem struct { ID int32 `db:"id"` Charcode string `db:"charcode"` - Kind string `db:"kind"` WriterID int32 `db:"writer_id"` WriterUsername string `db:"writer_username"` Title string `db:"title"` Statement string `db:"statement"` Difficulty string `db:"difficulty"` - Answer string `db:"answer"` TimeLimitMS int32 `db:"time_limit_ms"` + MemoryLimitMB int32 `db:"memory_limit_mb"` + Checker string `db:"checker"` CreatedAt time.Time `db:"created_at"` } type TestCase struct { ID int32 `db:"id"` ProblemID int32 `db:"problem_id"` + Ordinal int32 `db:"ordinal"` Input string `db:"input"` Output string `db:"output"` IsExample bool `db:"is_example"` @@ -82,17 +78,25 @@ type Entry struct { } type Submission struct { - ID int32 `db:"id"` - EntryID int32 `db:"entry_id"` - ProblemID int32 `db:"problem_id"` - ProblemKind string `db:"problem_kind"` - Verdict string `db:"verdict"` - Answer string `db:"answer"` - Code string `db:"code"` - Language string `db:"language"` - PassedTestsCount int32 `db:"passed_tests_count"` - Stderr string `db:"stderr"` - CreatedAt time.Time `db:"created_at"` + ID int32 `db:"id"` + EntryID int32 `db:"entry_id"` + ProblemID int32 `db:"problem_id"` + Status string `db:"status"` + Verdict string `db:"verdict"` + Code string `db:"code"` + Language string `db:"language"` + CreatedAt time.Time `db:"created_at"` +} + +type TestingReport struct { + ID int32 `db:"id"` + SubmissionID int32 `db:"submission_id"` + PassedTestsCount int32 `db:"passed_tests_count"` + TotalTestsCount int32 `db:"total_tests_count"` + FirstFailedTestID *int32 `db:"first_failed_test_id"` + FirstFailedTestOutput *string `db:"first_failed_test_output"` + Stderr string `db:"stderr"` + CreatedAt time.Time `db:"created_at"` } type LeaderboardEntry struct { diff --git a/internal/storage/models/status/status.go b/internal/storage/models/status/status.go new file mode 100644 index 0000000..ae62e11 --- /dev/null +++ b/internal/storage/models/status/status.go @@ -0,0 +1,8 @@ +package status + +const ( + Pending = "pending" + Judging = "judging" + Success = "success" + Failed = "failed" +) diff --git a/internal/storage/models/verdict/verdict.go b/internal/storage/models/verdict/verdict.go new file mode 100644 index 0000000..6802b35 --- /dev/null +++ b/internal/storage/models/verdict/verdict.go @@ -0,0 +1,12 @@ +package verdict + +const ( + NJ = "not_judged" + OK = "ok" + RE = "runtime_error" + CE = "compilation_error" + IE = "internal_error" + WA = "wrong_answer" + PE = "presentation_error" + TLE = "time_limit_exceeded" +) diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index a11cca7..329d58f 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -108,7 +108,7 @@ func (p *Postgres) GetProblemset(ctx context.Context, contestID int32) ([]models var problems []models.Problem for rows.Next() { var problem models.Problem - if err := rows.Scan(&problem.Charcode, &problem.ID, &problem.Kind, &problem.WriterID, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.Answer, &problem.TimeLimitMS, &problem.CreatedAt, &problem.WriterUsername); err != nil { + if err := rows.Scan(&problem.Charcode, &problem.ID, &problem.WriterID, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, &problem.WriterUsername); err != nil { return nil, err } problems = append(problems, problem) diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index 6031a1a..c0a886c 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -3,7 +3,6 @@ package problem import ( "context" "fmt" - "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -18,7 +17,7 @@ func New(pool *pgxpool.Pool) *Postgres { return &Postgres{pool} } -func (p *Postgres) CreateWithTCs(ctx context.Context, kind string, writerID int32, title, statement, difficulty, answer string, timeLimitMS int, tcs []models.TestCaseDTO) (int32, error) { +func (p *Postgres) CreateWithTCs(ctx context.Context, writerID int32, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int32, error) { tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return 0, fmt.Errorf("tx begin failed: %w", err) @@ -27,21 +26,21 @@ func (p *Postgres) CreateWithTCs(ctx context.Context, kind string, writerID int3 var problemID int32 err = tx.QueryRow(ctx, ` - INSERT INTO problems (kind, writer_id, title, statement, difficulty, answer, time_limit_ms) + INSERT INTO problems (writer_id, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id - `, kind, writerID, title, statement, difficulty, answer, timeLimitMS).Scan(&problemID) + `, writerID, title, statement, difficulty, timeLimitMS, memoryLimitMB, checker).Scan(&problemID) if err != nil { return 0, fmt.Errorf("insert problem failed: %w", err) } if len(tcs) > 0 { batch := &pgx.Batch{} - for _, tc := range tcs { + for i, tc := range tcs { batch.Queue(` - INSERT INTO test_cases (problem_id, input, output, is_example) - VALUES ($1, $2, $3, $4) - `, problemID, tc.Input, tc.Output, tc.IsExample) + INSERT INTO test_cases (problem_id, ordinal, input, output, is_example) + VALUES ($1, $2, $3, $4, $5) + `, problemID, i+1, tc.Input, tc.Output, tc.IsExample) } br := tx.SendBatch(ctx, batch) @@ -65,15 +64,6 @@ func (p *Postgres) CreateWithTCs(ctx context.Context, kind string, writerID int3 return problemID, nil } -func (p *Postgres) Create(ctx context.Context, kind string, writerID int32, title, statement, difficulty, answer string, timeLimitMS int32) (int32, error) { - var id int32 - query := `INSERT INTO problems (kind, writer_id, title, statement, difficulty, answer, time_limit_ms) - VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id` - - err := p.pool.QueryRow(ctx, query, kind, writerID, title, statement, difficulty, answer, timeLimitMS).Scan(&id) - return id, err -} - func (p *Postgres) Get(ctx context.Context, contestID int32, charcode string) (models.Problem, error) { query := `SELECT p.*, cp.charcode, u.username AS writer_username FROM problems p @@ -85,8 +75,8 @@ func (p *Postgres) Get(ctx context.Context, contestID int32, charcode string) (m var problem models.Problem err := row.Scan( - &problem.ID, &problem.Kind, &problem.WriterID, &problem.Title, &problem.Statement, - &problem.Difficulty, &problem.Answer, &problem.TimeLimitMS, &problem.CreatedAt, + &problem.ID, &problem.WriterID, &problem.Title, &problem.Statement, + &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, &problem.Charcode, &problem.WriterUsername, ) @@ -95,8 +85,8 @@ func (p *Postgres) Get(ctx context.Context, contestID int32, charcode string) (m func (p *Postgres) GetByID(ctx context.Context, problemID int32) (models.Problem, error) { query := `SELECT - p.id, p.kind, p.writer_id, p.title, p.statement, - p.difficulty, p.answer, p.time_limit_ms, p.created_at, + p.id, p.writer_id, p.title, p.statement, + p.difficulty, p.time_limit_ms, p.memory_limit_mb, p.checker, p.created_at, u.username AS writer_username FROM problems p JOIN users u ON u.id = p.writer_id @@ -106,16 +96,17 @@ func (p *Postgres) GetByID(ctx context.Context, problemID int32) (models.Problem var problem models.Problem err := row.Scan( - &problem.ID, &problem.Kind, &problem.WriterID, &problem.Title, &problem.Statement, - &problem.Difficulty, &problem.Answer, &problem.TimeLimitMS, &problem.CreatedAt, + &problem.ID, &problem.WriterID, &problem.Title, &problem.Statement, + &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, &problem.WriterUsername, ) return problem, err } -func (p *Postgres) GetTestCases(ctx context.Context, problemID int32) ([]models.TestCase, error) { - query := `SELECT id, problem_id, input, output, is_example FROM test_cases WHERE problem_id = $1` +func (p *Postgres) GetExampleCases(ctx context.Context, problemID int32) ([]models.TestCase, error) { + query := `SELECT id, problem_id, ordinal, input, output, is_example FROM test_cases WHERE problem_id = $1 AND is_example = true` + rows, err := p.pool.Query(ctx, query, problemID) if err != nil { return nil, err @@ -125,34 +116,33 @@ func (p *Postgres) GetTestCases(ctx context.Context, problemID int32) ([]models. tcs := make([]models.TestCase, 0) for rows.Next() { var tc models.TestCase - if err := rows.Scan(&tc.ID, &tc.ProblemID, &tc.Input, &tc.Output, &tc.IsExample); err != nil { + if err := rows.Scan(&tc.ID, &tc.ProblemID, &tc.Ordinal, &tc.Input, &tc.Output, &tc.IsExample); err != nil { return nil, err } tcs = append(tcs, tc) } - return tcs, nil + return tcs, rows.Err() } -func (p *Postgres) GetExampleCases(ctx context.Context, problemID int32) ([]models.TestCase, error) { - query := `SELECT * FROM test_cases WHERE problem_id = $1 AND is_example = true` +func (p *Postgres) GetTestCaseByID(ctx context.Context, testCaseID int32) (models.TestCase, error) { + query := `SELECT id, problem_id, ordinal, input, output, is_example FROM test_cases WHERE id = $1` + + var tc models.TestCase + err := p.pool.QueryRow(ctx, query, testCaseID).Scan( + &tc.ID, + &tc.ProblemID, + &tc.Ordinal, + &tc.Input, + &tc.Output, + &tc.IsExample, + ) - rows, err := p.pool.Query(ctx, query, problemID) if err != nil { - return nil, err - } - defer rows.Close() - - tcs := make([]models.TestCase, 0) - for rows.Next() { - var tc models.TestCase - if err := rows.Scan(&tc.ID, &tc.ProblemID, &tc.Input, &tc.Output, &tc.IsExample); err != nil { - return nil, err - } - tcs = append(tcs, tc) + return models.TestCase{}, fmt.Errorf("failed to get test case by ID: %w", err) } - return tcs, rows.Err() + return tc, nil } func (p *Postgres) GetAll(ctx context.Context) ([]models.Problem, error) { @@ -168,8 +158,8 @@ func (p *Postgres) GetAll(ctx context.Context) ([]models.Problem, error) { for rows.Next() { var p models.Problem if err := rows.Scan( - &p.ID, &p.Kind, &p.WriterID, &p.Title, &p.Statement, &p.Difficulty, - &p.Answer, &p.TimeLimitMS, &p.CreatedAt, &p.WriterUsername, + &p.ID, &p.WriterID, &p.Title, &p.Statement, &p.Difficulty, + &p.TimeLimitMS, &p.MemoryLimitMB, &p.Checker, &p.CreatedAt, &p.WriterUsername, ); err != nil { return nil, err } @@ -207,8 +197,8 @@ func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int32, limit, o for rows.Next() { var p models.Problem if err := rows.Scan( - &p.ID, &p.Kind, &p.WriterID, &p.Title, &p.Statement, &p.Difficulty, - &p.Answer, &p.TimeLimitMS, &p.CreatedAt, &p.WriterUsername, + &p.ID, &p.WriterID, &p.Title, &p.Statement, &p.Difficulty, + &p.TimeLimitMS, &p.MemoryLimitMB, &p.Checker, &p.CreatedAt, &p.WriterUsername, ); err != nil { rows.Close() br.Close() @@ -229,15 +219,3 @@ func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int32, limit, o return problems, total, nil } - -func (p *Postgres) IsTitleOccupied(ctx context.Context, title string) (bool, error) { - query := `SELECT COUNT(*) FROM problems WHERE LOWER(title) = $1` - - var count int - err := p.pool.QueryRow(ctx, query, strings.ToLower(title)).Scan(&count) - if err != nil { - return false, err - } - - return count > 0, nil -} diff --git a/internal/storage/repository/postgres/submission/submission.go b/internal/storage/repository/postgres/submission/submission.go index e8db9fa..6a9f9df 100644 --- a/internal/storage/repository/postgres/submission/submission.go +++ b/internal/storage/repository/postgres/submission/submission.go @@ -5,20 +5,11 @@ import ( "database/sql" "fmt" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/voidcontests/api/internal/storage/models" ) const ( - VerdictPending = "pending" - VerdictRunning = "running" - VerdictOK = "ok" - VerdictWrongAnswer = "wrong_answer" - VerdictRuntimeError = "runtime_error" - VerdictCompilationError = "compilation_error" - VerdictTimeLimitExceeded = "time_limit_exceeded" - defaultLimit = 100 ) @@ -30,53 +21,26 @@ func New(pool *pgxpool.Pool) *Postgres { return &Postgres{pool} } -func (p *Postgres) Create(ctx context.Context, entryID, problemID int32, verdict, answer, code, language string, passedTestsCount int32, stderr string) (models.Submission, error) { - query := ` - INSERT INTO submissions (entry_id, problem_id, verdict, answer, code, language, passed_tests_count, stderr) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, entry_id, problem_id, - (SELECT kind FROM problems WHERE id = $2) AS problem_kind, - verdict, answer, code, language, passed_tests_count, stderr, created_at - ` +func (p *Postgres) Create(ctx context.Context, entryID int32, problemID int32, code string, language string) (models.Submission, error) { + query := `INSERT INTO submissions (entry_id, problem_id, code, language) + VALUES ($1, $2, $3, $4) + RETURNING id, entry_id, problem_id, status, verdict, code, language, created_at` var submission models.Submission - err := p.pool.QueryRow(ctx, query, entryID, problemID, verdict, answer, code, language, passedTestsCount, stderr).Scan( + err := p.pool.QueryRow(ctx, query, entryID, problemID, code, language).Scan( &submission.ID, &submission.EntryID, &submission.ProblemID, - &submission.ProblemKind, + &submission.Status, &submission.Verdict, - &submission.Answer, &submission.Code, &submission.Language, - &submission.PassedTestsCount, - &submission.Stderr, &submission.CreatedAt, ) return submission, err } -func (p *Postgres) CountTestsForProblem(ctx context.Context, problemID int32) (int32, error) { - var count int32 - err := p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM test_cases WHERE problem_id = $1`, problemID).Scan(&count) - return count, err -} - -func (p *Postgres) GetFailedTest(ctx context.Context, submissionID int32) (models.FailedTest, error) { - query := `SELECT id, submission_id, input, expected_output, actual_output, created_at FROM failed_tests WHERE submission_id = $1` - var ft models.FailedTest - err := p.pool.QueryRow(ctx, query, submissionID).Scan( - &ft.ID, - &ft.SubmissionID, - &ft.Input, - &ft.ExpectedOutput, - &ft.ActualOutput, - &ft.CreatedAt, - ) - return ft, err -} - func (p *Postgres) GetProblemStatus(ctx context.Context, entryID int32, problemID int32) (string, error) { query := ` SELECT @@ -145,29 +109,19 @@ func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int32) (map[i return statuses, nil } -func (p *Postgres) GetByID(ctx context.Context, userID, submissionID int32) (models.Submission, error) { - query := ` - SELECT s.id, s.entry_id, s.problem_id, p.kind AS problem_kind, s.verdict, - s.answer, s.code, s.language, s.passed_tests_count, s.stderr, s.created_at - FROM submissions s - JOIN problems p ON p.id = s.problem_id - JOIN entries e ON s.entry_id = e.id - JOIN users u ON e.user_id = u.id - WHERE s.id = $1 AND u.id = $2 - ` +func (p *Postgres) GetByID(ctx context.Context, submissionID int32) (models.Submission, error) { + query := `SELECT s.id, s.entry_id, s.problem_id, s.status, s.verdict, s.code, s.language, s.created_at + FROM submissions s WHERE s.id = $1` var s models.Submission - err := p.pool.QueryRow(ctx, query, submissionID, userID).Scan( + err := p.pool.QueryRow(ctx, query, submissionID).Scan( &s.ID, &s.EntryID, &s.ProblemID, - &s.ProblemKind, + &s.Status, &s.Verdict, - &s.Answer, &s.Code, &s.Language, - &s.PassedTestsCount, - &s.Stderr, &s.CreatedAt, ) @@ -179,33 +133,21 @@ func (p *Postgres) ListByProblem(ctx context.Context, entryID int32, charcode st limit = defaultLimit } - batch := &pgx.Batch{} - batch.Queue(` - SELECT s.id, s.entry_id, s.problem_id, p.kind AS problem_kind, s.verdict, - s.answer, s.code, s.language, s.passed_tests_count, s.stderr, s.created_at + query := ` + SELECT s.id, s.entry_id, s.problem_id, s.status, s.verdict, s.code, s.language, s.created_at, COUNT(*) OVER() as total_count FROM submissions s JOIN problems p ON p.id = s.problem_id JOIN entries e ON s.entry_id = e.id JOIN contest_problems cp ON cp.contest_id = e.contest_id AND cp.problem_id = s.problem_id WHERE s.entry_id = $1 AND cp.charcode = $2 - ORDER BY s.created_at DESC LIMIT $3 OFFSET $4 - `, entryID, charcode, limit, offset) + ORDER BY s.created_at DESC + LIMIT $3 OFFSET $4` - batch.Queue(` - SELECT COUNT(*) - FROM submissions s - JOIN entries e ON s.entry_id = e.id - JOIN contest_problems cp ON cp.contest_id = e.contest_id AND cp.problem_id = s.problem_id - WHERE s.entry_id = $1 AND cp.charcode = $2 - `, entryID, charcode) - - br := p.pool.SendBatch(ctx, batch) - defer br.Close() - - rows, err := br.Query() + rows, err := p.pool.Query(ctx, query, entryID, charcode, limit, offset) if err != nil { return nil, 0, fmt.Errorf("query rows failed: %w", err) } + defer rows.Close() items = make([]models.Submission, 0) for rows.Next() { @@ -214,29 +156,44 @@ func (p *Postgres) ListByProblem(ctx context.Context, entryID int32, charcode st &s.ID, &s.EntryID, &s.ProblemID, - &s.ProblemKind, + &s.Status, &s.Verdict, - &s.Answer, &s.Code, &s.Language, - &s.PassedTestsCount, - &s.Stderr, &s.CreatedAt, + &total, ); err != nil { - rows.Close() return nil, 0, fmt.Errorf("row scan failed: %w", err) } items = append(items, s) } if err := rows.Err(); err != nil { - rows.Close() return nil, 0, fmt.Errorf("row iteration error: %w", err) } - rows.Close() - if err := br.QueryRow().Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count query failed: %w", err) + return items, total, nil +} + +func (p *Postgres) GetTestingReport(ctx context.Context, submissionID int32) (models.TestingReport, error) { + query := `SELECT id, submission_id, passed_tests_count, total_tests_count, + first_failed_test_id, first_failed_test_output, stderr, created_at + FROM testing_reports WHERE submission_id = $1` + + var report models.TestingReport + err := p.pool.QueryRow(ctx, query, submissionID).Scan( + &report.ID, + &report.SubmissionID, + &report.PassedTestsCount, + &report.TotalTestsCount, + &report.FirstFailedTestID, + &report.FirstFailedTestOutput, + &report.Stderr, + &report.CreatedAt, + ) + + if err != nil { + return models.TestingReport{}, err } - return items, total, nil + return report, nil } diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index e02dd20..c8eda6a 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -54,15 +54,13 @@ type Contest interface { } type Problem interface { - CreateWithTCs(ctx context.Context, kind string, writerID int32, title, statement, difficulty, answer string, timeLimitMS int, tcs []models.TestCaseDTO) (int32, error) - Create(ctx context.Context, kind string, writerID int32, title, statement, difficulty, answer string, timeLimitMS int32) (int32, error) + CreateWithTCs(ctx context.Context, writerID int32, title string, statement string, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int32, error) Get(ctx context.Context, contestID int32, charcode string) (models.Problem, error) GetByID(ctx context.Context, problemID int32) (models.Problem, error) - GetTestCases(ctx context.Context, problemID int32) ([]models.TestCase, error) GetExampleCases(ctx context.Context, problemID int32) ([]models.TestCase, error) + GetTestCaseByID(ctx context.Context, testCaseID int32) (models.TestCase, error) GetAll(ctx context.Context) ([]models.Problem, error) GetWithWriterID(ctx context.Context, writerID int32, limit, offset int) (problems []models.Problem, total int, err error) - IsTitleOccupied(ctx context.Context, title string) (bool, error) } type Entry interface { @@ -71,11 +69,10 @@ type Entry interface { } type Submission interface { - Create(ctx context.Context, entryID, problemID int32, verdict, answer, code, language string, passedTestsCount int32, stderr string) (models.Submission, error) - CountTestsForProblem(ctx context.Context, problemID int32) (int32, error) - GetFailedTest(ctx context.Context, submissionID int32) (models.FailedTest, error) + Create(ctx context.Context, entryID int32, problemID int32, code string, language string) (models.Submission, error) GetProblemStatus(ctx context.Context, entryID int32, problemID int32) (string, error) GetProblemStatuses(ctx context.Context, entryID int32) (map[int32]string, error) - GetByID(ctx context.Context, userID, submissionID int32) (models.Submission, error) + GetByID(ctx context.Context, submissionID int32) (models.Submission, error) ListByProblem(ctx context.Context, entryID int32, charcode string, limit int, offset int) (items []models.Submission, total int, err error) + GetTestingReport(ctx context.Context, submissionID int32) (models.TestingReport, error) } diff --git a/internal/version/version.go b/internal/version/version.go index b2ce0d9..ad1dbce 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,8 +2,8 @@ package version import "log/slog" -var GIT_COMMIT string -var GIT_BRANCH string +var Commit string +var Branch string -var CommitAttr = slog.Attr{Key: "commit", Value: slog.StringValue(GIT_COMMIT)} -var BranchAttr = slog.Attr{Key: "branch", Value: slog.StringValue(GIT_BRANCH)} +var CommitAttr = slog.Attr{Key: "commit", Value: slog.StringValue(Commit)} +var BranchAttr = slog.Attr{Key: "branch", Value: slog.StringValue(Branch)} diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql deleted file mode 100644 index 4e501b4..0000000 --- a/migrations/000001_init.down.sql +++ /dev/null @@ -1,12 +0,0 @@ -DROP TABLE IF EXISTS failed_tests; -DROP TABLE IF EXISTS submissions; -DROP TABLE IF EXISTS entries; -DROP TABLE IF EXISTS contest_problems; -DROP TABLE IF EXISTS test_cases; -DROP TABLE IF EXISTS problems; -DROP TABLE IF EXISTS contests; -DROP TABLE IF EXISTS users; -DROP TABLE IF EXISTS roles; - -DROP TYPE IF EXISTS verdict; -DROP TYPE IF EXISTS problem_kind; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql deleted file mode 100644 index a5aa855..0000000 --- a/migrations/000001_init.up.sql +++ /dev/null @@ -1,99 +0,0 @@ -CREATE TABLE roles ( - id SERIAL PRIMARY KEY, - name VARCHAR(20) UNIQUE NOT NULL, - created_problems_limit INTEGER NOT NULL, - created_contests_limit INTEGER NOT NULL, - is_default BOOLEAN DEFAULT false NOT NULL, - created_at TIMESTAMP DEFAULT now() NOT NULL -); - -INSERT INTO roles (name, created_problems_limit, created_contests_limit, is_default) VALUES - ('admin', -1, -1, false), - ('unlimited', -1, -1, false), - ('limited', 10, 2, true), - ('banned', 0, 0, false); - -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, - created_at TIMESTAMP DEFAULT now() NOT NULL -); - -CREATE TYPE problem_kind AS ENUM ('text_answer_problem', 'coding_problem'); -CREATE TYPE verdict AS ENUM ( - 'pending', 'running', 'ok', 'wrong_answer', - 'runtime_error', 'compilation_error', 'time_limit_exceeded' -); - -CREATE TABLE contests ( - id SERIAL PRIMARY KEY, - creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title VARCHAR(64) NOT NULL, - description VARCHAR(300) DEFAULT '' NOT NULL, - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - duration_mins INTEGER NOT NULL CHECK (duration_mins >= 0), - max_entries INTEGER DEFAULT 0 NOT NULL CHECK (max_entries >= 0), - allow_late_join BOOLEAN DEFAULT true NOT NULL, - created_at TIMESTAMP DEFAULT now() NOT NULL -); - -CREATE TABLE problems ( - id SERIAL PRIMARY KEY, - kind problem_kind NOT NULL, - writer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title VARCHAR(64) NOT NULL, - statement TEXT DEFAULT '' NOT NULL, - difficulty VARCHAR(10) NOT NULL CHECK (difficulty IN ('easy', 'medium', 'hard')), - answer TEXT NOT NULL, - time_limit_ms INTEGER DEFAULT 5000 NOT NULL CHECK (time_limit_ms >= 0), - created_at TIMESTAMP DEFAULT now() NOT NULL -); - -CREATE TABLE test_cases ( - id SERIAL PRIMARY KEY, - problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE, - input TEXT NOT NULL, - output TEXT NOT NULL, - is_example BOOLEAN DEFAULT false NOT NULL -); - -CREATE TABLE contest_problems ( - contest_id INTEGER NOT NULL REFERENCES contests(id) ON DELETE CASCADE, - problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE, - charcode VARCHAR(2) NOT NULL, - PRIMARY KEY (contest_id, problem_id), - UNIQUE (contest_id, charcode) -); - -CREATE TABLE entries ( - id SERIAL PRIMARY KEY, - contest_id INTEGER NOT NULL REFERENCES contests(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at TIMESTAMP DEFAULT now() NOT NULL, - UNIQUE (contest_id, user_id) -); - -CREATE TABLE submissions ( - id SERIAL PRIMARY KEY, - entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE, - verdict verdict NOT NULL, - answer TEXT NOT NULL, - code TEXT NOT NULL, - language VARCHAR(20) NOT NULL, - passed_tests_count INTEGER DEFAULT 0 NOT NULL CHECK (passed_tests_count >= 0), - stderr TEXT NOT NULL, - created_at TIMESTAMP DEFAULT now() NOT NULL -); - -CREATE TABLE failed_tests ( - id SERIAL PRIMARY KEY, - submission_id INTEGER NOT NULL REFERENCES submissions(id) ON DELETE CASCADE, - input TEXT NOT NULL, - expected_output TEXT NOT NULL, - actual_output TEXT NOT NULL, - created_at TIMESTAMP DEFAULT now() NOT NULL -); diff --git a/pkg/requestlog/requestlog.go b/pkg/requestlog/requestlog.go index d20f93e..93226a8 100644 --- a/pkg/requestlog/requestlog.go +++ b/pkg/requestlog/requestlog.go @@ -17,7 +17,6 @@ func Completed(next echo.HandlerFunc) echo.HandlerFunc { } start := time.Now() - err := next(c) status := c.Response().Status @@ -29,6 +28,10 @@ func Completed(next echo.HandlerFunc) echo.HandlerFunc { } } + if c.Path() == "/api/healthcheck" && status == 200 { + return err + } + slog.Info("request completed", slog.String("id", requestid.Get(c)), slog.String("method", c.Request().Method),