Compare commits

..

1 commit

Author SHA1 Message Date
01ed1de8c3 feat: UploadProblem 2025-04-13 11:54:11 +05:00
15 changed files with 154 additions and 232 deletions

2
.gitmodules vendored
View file

@ -1,3 +1,3 @@
[submodule "proto"] [submodule "proto"]
path = contracts path = proto
url = https://git.sch9.ru/new_gate/contracts url = https://git.sch9.ru/new_gate/contracts

View file

@ -1,7 +1,7 @@
tag = latest tag = latest
gen: gen:
@oapi-codegen --config=config.yaml ./contracts/tester/v1/openapi.yaml @oapi-codegen --config=config.yaml ./proto/tester/v1/openapi.yaml
dev: gen dev: gen
@go run main.go @go run main.go
build: gen build: gen

108
README.md
View file

@ -1,108 +0,0 @@
# ms-tester
`ms-tester` is a microservice designed for managing programming competitions. It provides backend functionality for handling problems, contests, participants, and their submissions. The service is developed in Go. PostgreSQL serves as the relational database. Pandoc is used to convert problem statements from LaTeX to HTML.
For understanding the architecture, see the [documentation](https://git.sch9.ru/new_gate/docs).
### Prerequisites
Before you begin, ensure you have the following dependencies installed:
* **Docker** and **Docker Compose**: To run PostgreSQL, Pandoc.
* **Goose**: For applying database migrations (`go install github.com/pressly/goose/v3/cmd/goose@latest`).
* **oapi-codegen**: For generating OpenAPI code (`go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest`).
### 1. Running Dependencies
You can run PostgreSQL and Pandoc using Docker Compose, for example:
```yaml
version: '3.8'
services:
pandoc:
image: pandoc/latex
ports:
- "4000:3030" # Exposes Pandoc server on port 4000 locally
command: "server" # Runs Pandoc in server mode
postgres:
image: postgres:14.1-alpine # Uses PostgreSQL 14.1 Alpine image
restart: always # Ensures the container restarts if it stops
environment:
POSTGRES_USER: postgres # Default user
POSTGRES_PASSWORD: supersecretpassword # Default password (change for production!)
POSTGRES_DB: postgres # Default database name
ports:
- '5432:5432' # Exposes PostgreSQL on the standard port 5432
volumes:
- ./postgres-data:/var/lib/postgresql/data # Persists database data locally
healthcheck:
test: pg_isready -U postgres -d postgres # Command to check if PostgreSQL is ready
interval: 10s # Check every 10 seconds
timeout: 3s # Wait 3 seconds for the check to respond
retries: 5 # Try 5 times before marking as unhealthy
volumes:
postgres-data: # Defines the named volume for data persistence
```
Start the services in detached mode:
```bash
docker-compose up -d
```
### 2. Configuration
The application uses environment variables for configuration. Create a `.env` file in the project root. The minimum required variables are:
```dotenv
# Environment type (development or production)
ENV=dev # or prod
# Address of the running Pandoc service
PANDOC=http://localhost:4000
# Address and port where the ms-tester service will listen
ADDRESS=localhost:8080
# PostgreSQL connection string (Data Source Name)
# Format: postgres://user:password@host:port/database?sslmode=disable
POSTGRES_DSN=postgres://username:supersecretpassword@localhost:5432/db_name?sslmode=disable
# Secret key for signing and verifying JWT tokens
JWT_SECRET=your_super_secret_jwt_key
```
**Important:** Replace `supersecretpassword` and `your_super_secret_jwt_key` with secure, unique values, especially for a production environment.
### 3. Database Migrations
The project uses `goose` to manage the database schema.
1. Ensure `goose` is installed:
```bash
go install github.com/pressly/goose/v3/cmd/goose@latest
```
2. Apply the migrations to the running PostgreSQL database. Make sure the connection string in the command matches the `POSTGRES_DSN` from your `.env` file:
```bash
goose -dir ./migrations postgres "postgres://postgres:supersecretpassword@localhost:5432/postgres?sslmode=disable" up
```
### 4. OpenAPI Code Generation
The project uses OpenAPI to define its API. Go code for handlers and models is generated based on this specification using `oapi-codegen`.
Run the generation command:
```bash
make gen
```
### 5. Running the Application
Start the `ms-tester` service:
```bash
go run ./main.go
```
After starting, the service will be available at the address specified in the `ADDRESS` variable in your `.env` file (e.g., `http://localhost:8080`).

View file

@ -2,4 +2,4 @@ package: testerv1
generate: generate:
fiber-server: true fiber-server: true
models: true models: true
output: ./contracts/tester/v1/tester.go output: ./proto/tester/v1/tester.go

View file

@ -1,7 +1,7 @@
package tester package tester
import ( import (
testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1" testerv1 "git.sch9.ru/new_gate/ms-tester/proto/tester/v1"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@ -20,6 +20,7 @@ type Handlers interface {
DeleteProblem(c *fiber.Ctx, id int32) error DeleteProblem(c *fiber.Ctx, id int32) error
GetProblem(c *fiber.Ctx, id int32) error GetProblem(c *fiber.Ctx, id int32) error
UpdateProblem(c *fiber.Ctx, id int32) error UpdateProblem(c *fiber.Ctx, id int32) error
UploadProblem(c *fiber.Ctx, id int32) error
ListSolutions(c *fiber.Ctx, params testerv1.ListSolutionsParams) error ListSolutions(c *fiber.Ctx, params testerv1.ListSolutionsParams) error
CreateSolution(c *fiber.Ctx, params testerv1.CreateSolutionParams) error CreateSolution(c *fiber.Ctx, params testerv1.CreateSolutionParams) error
GetSolution(c *fiber.Ctx, id int32) error GetSolution(c *fiber.Ctx, id int32) error

View file

@ -1,12 +1,13 @@
package rest package rest
import ( import (
testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1" "io"
"git.sch9.ru/new_gate/ms-tester/internal/models" "git.sch9.ru/new_gate/ms-tester/internal/models"
"git.sch9.ru/new_gate/ms-tester/internal/tester" "git.sch9.ru/new_gate/ms-tester/internal/tester"
"git.sch9.ru/new_gate/ms-tester/pkg" "git.sch9.ru/new_gate/ms-tester/pkg"
testerv1 "git.sch9.ru/new_gate/ms-tester/proto/tester/v1"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"io"
) )
type TesterHandlers struct { type TesterHandlers struct {
@ -99,15 +100,6 @@ func (h *TesterHandlers) GetContest(c *fiber.Ctx, id int32) error {
return c.SendStatus(pkg.ToREST(err)) return c.SendStatus(pkg.ToREST(err))
} }
var participantId int32 = 2
solutions, err := h.contestsUC.ReadBestSolutions(c.Context(), id, participantId)
m := make(map[int32]*models.Solution)
for i := 0; i < len(solutions); i++ {
m[solutions[i].TaskPosition] = solutions[i]
}
resp := testerv1.GetContestResponse{ resp := testerv1.GetContestResponse{
Contest: C2C(*contest), Contest: C2C(*contest),
Tasks: make([]struct { Tasks: make([]struct {
@ -117,15 +109,11 @@ func (h *TesterHandlers) GetContest(c *fiber.Ctx, id int32) error {
} }
for i, task := range tasks { for i, task := range tasks {
solution := testerv1.Solution{}
if sol, ok := m[task.Position]; ok {
solution = S2S(*sol)
}
resp.Tasks[i] = struct { resp.Tasks[i] = struct {
Solution testerv1.Solution `json:"solution"` Solution testerv1.Solution `json:"solution"`
Task testerv1.TasksListItem `json:"task"` Task testerv1.TasksListItem `json:"task"`
}{ }{
Solution: solution, Solution: testerv1.Solution{},
Task: TLI2TLI(*task), Task: TLI2TLI(*task),
} }
} }
@ -253,6 +241,23 @@ func (h *TesterHandlers) UpdateProblem(c *fiber.Ctx, id int32) error {
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusOK)
} }
func (h *TesterHandlers) UploadProblem(c *fiber.Ctx, id int32) error {
var req testerv1.UploadProblemRequest
err := c.BodyParser(&req)
if err != nil {
return err
}
data, err := req.Archive.Bytes()
if err != nil {
return err
}
if err = h.problemsUC.UploadProblem(c.Context(), id, data); err != nil {
return err
}
return nil
}
func (h *TesterHandlers) UpdateContest(c *fiber.Ctx, id int32) error { func (h *TesterHandlers) UpdateContest(c *fiber.Ctx, id int32) error {
var req testerv1.UpdateContestRequest var req testerv1.UpdateContestRequest
err := c.BodyParser(&req) err := c.BodyParser(&req)

View file

@ -49,5 +49,4 @@ type ContestRepository interface {
ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error)
ReadTask(ctx context.Context, id int32) (*models.Task, error) ReadTask(ctx context.Context, id int32) (*models.Task, error)
ReadMonitor(ctx context.Context, id int32) (*models.Monitor, error) ReadMonitor(ctx context.Context, id int32) (*models.Monitor, error)
ReadBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.Solution, error)
} }

View file

@ -626,65 +626,3 @@ func (r *ContestRepository) ReadMonitor(ctx context.Context, contestId int32) (*
return &monitor, nil return &monitor, nil
} }
const (
// state=5 - AC
readBestSolutions = `
WITH contest_tasks AS (
SELECT t.id AS task_id,
t.position AS task_position,
t.contest_id,
t.problem_id,
t.created_at,
t.updated_at,
p.title AS task_title,
c.title AS contest_title
FROM tasks t
LEFT JOIN problems p ON p.id = t.problem_id
LEFT JOIN contests c ON c.id = t.contest_id
WHERE t.contest_id = ?
),
best_solutions AS (
SELECT DISTINCT ON (s.task_id)
*
FROM solutions s
WHERE s.participant_id = ?
ORDER BY s.task_id, s.score DESC, s.created_at DESC
)
SELECT
s.id,
s.participant_id,
p.name AS participant_name,
s.solution,
s.state,
s.score,
s.penalty,
s.time_stat,
s.memory_stat,
s.language,
ct.task_id,
ct.task_position,
ct.task_title,
ct.contest_id,
ct.contest_title,
s.updated_at,
s.created_at
FROM contest_tasks ct
LEFT JOIN best_solutions s ON s.task_id = ct.task_id
LEFT JOIN participants p ON p.id = s.participant_id WHERE s.id IS NOT NULL
ORDER BY ct.task_position
`
)
func (r *ContestRepository) ReadBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.Solution, error) {
const op = "ContestRepository.ReadBestSolutions"
var solutions []*models.Solution
query := r.db.Rebind(readBestSolutions)
err := r.db.SelectContext(ctx, &solutions, query, contestId, participantId)
if err != nil {
return nil, handlePgErr(err, op)
}
return solutions, nil
}

View file

@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"git.sch9.ru/new_gate/ms-tester/internal/models" "git.sch9.ru/new_gate/ms-tester/internal/models"
"git.sch9.ru/new_gate/ms-tester/internal/tester" "git.sch9.ru/new_gate/ms-tester/internal/tester"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"

View file

@ -2,6 +2,7 @@ package tester
import ( import (
"context" "context"
"git.sch9.ru/new_gate/ms-tester/internal/models" "git.sch9.ru/new_gate/ms-tester/internal/models"
) )
@ -11,6 +12,7 @@ type ProblemUseCase interface {
DeleteProblem(ctx context.Context, id int32) error DeleteProblem(ctx context.Context, id int32) error
ListProblems(ctx context.Context, filter models.ProblemsFilter) (*models.ProblemsList, error) ListProblems(ctx context.Context, filter models.ProblemsFilter) (*models.ProblemsList, error)
UpdateProblem(ctx context.Context, id int32, problem models.ProblemUpdate) error UpdateProblem(ctx context.Context, id int32, problem models.ProblemUpdate) error
UploadProblem(ctx context.Context, id int32, archive []byte) error
} }
type ContestUseCase interface { type ContestUseCase interface {
@ -31,5 +33,4 @@ type ContestUseCase interface {
ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error)
ReadTask(ctx context.Context, id int32) (*models.Task, error) ReadTask(ctx context.Context, id int32) (*models.Task, error)
ReadMonitor(ctx context.Context, id int32) (*models.Monitor, error) ReadMonitor(ctx context.Context, id int32) (*models.Monitor, error)
ReadBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.Solution, error)
} }

View file

@ -85,7 +85,3 @@ func (uc *ContestUseCase) ReadTask(ctx context.Context, id int32) (*models.Task,
func (uc *ContestUseCase) ReadMonitor(ctx context.Context, contestId int32) (*models.Monitor, error) { func (uc *ContestUseCase) ReadMonitor(ctx context.Context, contestId int32) (*models.Monitor, error) {
return uc.contestRepo.ReadMonitor(ctx, contestId) return uc.contestRepo.ReadMonitor(ctx, contestId)
} }
func (uc *ContestUseCase) ReadBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.Solution, error) {
return uc.contestRepo.ReadBestSolutions(ctx, contestId, participantId)
}

View file

@ -1,14 +1,19 @@
package usecase package usecase
import ( import (
"archive/zip"
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"strings"
"git.sch9.ru/new_gate/ms-tester/internal/models" "git.sch9.ru/new_gate/ms-tester/internal/models"
"git.sch9.ru/new_gate/ms-tester/internal/tester" "git.sch9.ru/new_gate/ms-tester/internal/tester"
"git.sch9.ru/new_gate/ms-tester/pkg" "git.sch9.ru/new_gate/ms-tester/pkg"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"strings"
) )
type ProblemUseCase struct { type ProblemUseCase struct {
@ -115,6 +120,116 @@ func (u *ProblemUseCase) UpdateProblem(ctx context.Context, id int32, problemUpd
return nil return nil
} }
type ProblemProperties struct {
Title string `json:"name"`
TimeLimit int32 `json:"timeLimit"`
MemoryLimit int32 `json:"memoryLimit"`
}
func (u *ProblemUseCase) UploadProblem(ctx context.Context, id int32, data []byte) error {
locale := "russian"
defaultLocale := "english"
var localeProblem, defaultProblem string
var localeProperties, defaultProperties ProblemProperties
r := bytes.NewReader(data)
rc, err := zip.NewReader(r, int64(r.Len()))
if err != nil {
return err
}
testsZipBuf := new(bytes.Buffer)
w := zip.NewWriter(testsZipBuf)
for _, f := range rc.File {
if f.FileInfo().IsDir() {
continue
}
if f.Name == fmt.Sprintf("statements/%s/problem.tex", locale) {
localeProblem, err = readProblem(f)
if err != nil {
return err
}
}
if f.Name == fmt.Sprintf("statements/%s/problem.tex", defaultLocale) {
defaultProblem, err = readProblem(f)
if err != nil {
return err
}
}
if f.Name == fmt.Sprintf("statements/%s/problem-properties.json", locale) {
localeProperties, err = readProperties(f)
if err != nil {
return err
}
}
if f.Name == fmt.Sprintf("statements/%s/problem-properties.json", defaultLocale) {
defaultProperties, err = readProperties(f)
if err != nil {
return err
}
}
if strings.HasPrefix(f.Name, "tests/") {
if err := w.Copy(f); err != nil {
return err
}
}
}
if err := w.Close(); err != nil {
return err
}
// testsZipBuf contains test files; this is for s3
localeProperties.MemoryLimit /= 1024 * 1024
defaultProperties.MemoryLimit /= 1024 * 1024
var problemUpdate models.ProblemUpdate
if localeProblem != "" {
problemUpdate.Legend = &localeProblem
problemUpdate.Title = &localeProperties.Title
problemUpdate.TimeLimit = &localeProperties.TimeLimit
problemUpdate.MemoryLimit = &localeProperties.MemoryLimit
} else {
problemUpdate.Legend = &defaultProblem
problemUpdate.Title = &defaultProperties.Title
problemUpdate.TimeLimit = &defaultProperties.TimeLimit
problemUpdate.MemoryLimit = &defaultProperties.MemoryLimit
}
if err := u.UpdateProblem(ctx, id, problemUpdate); err != nil {
return err
}
return nil
}
func readProblem(f *zip.File) (string, error) {
rc, err := f.Open()
if err != nil {
return "", err
}
defer rc.Close()
problemData, err := io.ReadAll(rc)
if err != nil {
return "", err
}
return string(problemData), nil
}
func readProperties(f *zip.File) (ProblemProperties, error) {
rc, err := f.Open()
if err != nil {
return ProblemProperties{}, err
}
defer rc.Close()
var properties ProblemProperties
if err := json.NewDecoder(rc).Decode(&properties); err != nil {
return ProblemProperties{}, err
}
return properties, nil
}
func isEmpty(p models.ProblemUpdate) bool { func isEmpty(p models.ProblemUpdate) bool {
return p.Title == nil && return p.Title == nil &&
p.Legend == nil && p.Legend == nil &&

View file

@ -3,11 +3,11 @@ package main
import ( import (
"fmt" "fmt"
"git.sch9.ru/new_gate/ms-tester/config" "git.sch9.ru/new_gate/ms-tester/config"
testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1"
"git.sch9.ru/new_gate/ms-tester/internal/tester/delivery/rest" "git.sch9.ru/new_gate/ms-tester/internal/tester/delivery/rest"
problemsRepository "git.sch9.ru/new_gate/ms-tester/internal/tester/repository" problemsRepository "git.sch9.ru/new_gate/ms-tester/internal/tester/repository"
testerUseCase "git.sch9.ru/new_gate/ms-tester/internal/tester/usecase" testerUseCase "git.sch9.ru/new_gate/ms-tester/internal/tester/usecase"
"git.sch9.ru/new_gate/ms-tester/pkg" "git.sch9.ru/new_gate/ms-tester/pkg"
testerv1 "git.sch9.ru/new_gate/ms-tester/proto/tester/v1"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
fiberlogger "github.com/gofiber/fiber/v2/middleware/logger" fiberlogger "github.com/gofiber/fiber/v2/middleware/logger"
"github.com/ilyakaznacheev/cleanenv" "github.com/ilyakaznacheev/cleanenv"

View file

@ -9,24 +9,6 @@ BEGIN
END; END;
$$; $$;
CREATE FUNCTION check_max_tasks() RETURNS TRIGGER
LANGUAGE plpgsql AS
$$
DECLARE
max_on_contest_tasks_amount integer := 50;
BEGIN
IF (
SELECT count(*) FROM tasks
WHERE contest_id = NEW.contest_id
) >= (
max_on_contest_tasks_amount
) THEN
RAISE EXCEPTION 'Exceeded max tasks for this contest';
END IF;
RETURN NEW;
END;
$$;
CREATE TABLE IF NOT EXISTS problems CREATE TABLE IF NOT EXISTS problems
( (
id serial NOT NULL, id serial NOT NULL,
@ -91,11 +73,6 @@ CREATE TABLE IF NOT EXISTS tasks
CHECK (position >= 0) CHECK (position >= 0)
); );
CREATE TRIGGER max_tasks_on_contest_check
BEFORE INSERT ON tasks
FOR EACH STATEMENT
EXECUTE FUNCTION check_max_tasks();
CREATE TRIGGER on_tasks_update CREATE TRIGGER on_tasks_update
BEFORE UPDATE BEFORE UPDATE
ON tasks ON tasks
@ -121,7 +98,6 @@ CREATE TRIGGER on_participants_update
FOR EACH ROW FOR EACH ROW
EXECUTE PROCEDURE updated_at_update(); EXECUTE PROCEDURE updated_at_update();
CREATE TABLE IF NOT EXISTS solutions CREATE TABLE IF NOT EXISTS solutions
( (
id serial NOT NULL, id serial NOT NULL,
@ -149,17 +125,15 @@ EXECUTE PROCEDURE updated_at_update();
-- +goose Down -- +goose Down
-- +goose StatementBegin -- +goose StatementBegin
DROP TRIGGER IF EXISTS on_solutions_update ON solutions;
DROP TABLE IF EXISTS solutions;
DROP TRIGGER IF EXISTS on_participants_update ON participants;
DROP TABLE IF EXISTS participants;
DROP TRIGGER IF EXISTS on_tasks_update ON tasks;
DROP TRIGGER IF EXISTS max_tasks_on_contest_check ON tasks;
DROP TABLE IF EXISTS tasks;
DROP TRIGGER IF EXISTS on_problems_update ON problems; DROP TRIGGER IF EXISTS on_problems_update ON problems;
DROP TABLE IF EXISTS problems; DROP TABLE IF EXISTS problems;
DROP TRIGGER IF EXISTS on_contests_update ON contests; DROP TRIGGER IF EXISTS on_contests_update ON contests;
DROP TABLE IF EXISTS contests; DROP TABLE IF EXISTS contests;
DROP FUNCTION IF EXISTS updated_at_update(); DROP TRIGGER IF EXISTS on_tasks_update ON tasks;
DROP FUNCTION IF EXISTS check_max_tasks(); DROP TABLE IF EXISTS tasks;
-- +goose StatementEnd DROP TRIGGER IF EXISTS on_participants_update ON participants;
DROP TABLE IF EXISTS participants;
DROP TRIGGER IF EXISTS on_solutions_update ON solutions;
DROP TABLE IF EXISTS solutions;
DROP FUNCTION updated_at_update();
-- +goose StatementEnd

View file