feat(attempts): прохождение тестов + автогрейд single/multi
Some checks failed
CI / test (push) Failing after 10s
Build and Deploy / build-and-deploy (push) Successful in 25s

AttemptRepository:
- Start: проверка max_attempts (учитывает уже использованные с этого
  user_id или public_token_id), вставка in_progress'а;
- Get/ListByUser/ListByTest: чтение с per-attempt scope;
- SubmitAndGrade: транзакционно сохраняет ответы в attempt_answers
  (JSONB payload + correct + score), считает итог:
    single — 1 правильный → points за вопрос, иначе 0;
    multi  — set ответов == set is_correct=TRUE → points, иначе 0
             (частичные баллы не делаем в MVP);
    text   — correct=NULL и score=NULL, ждут ручной оценки HR'ом.
  max_score = SUM(points) по всем вопросам (не только отвеченным).
  passed = NULL если у теста нет passing_score; иначе процент vs порог.
  status: graded если все автогрейд'ятся; submitted если есть text.

AttemptHandler:
- POST /tests/{id}/attempts — Start (X-User-Id из portal-gateway).
  Не-владелец стартует только если is_published=true.
- GET  /attempts/{id} — Get с проверкой «я респондент / я владелец теста».
- POST /attempts/{id}/submit — Submit (только свою попытку).
- GET  /attempts — ListMine.
- GET  /tests/{id}/attempts — ListByTest (только для владельца).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-25 23:00:38 +03:00
parent 5ab6cc95cd
commit 4f9b1b1491
3 changed files with 596 additions and 5 deletions

167
internal/handler/attempt.go Normal file
View File

@@ -0,0 +1,167 @@
package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
"learning-service/internal/model"
"learning-service/internal/repository"
)
type AttemptHandler struct {
repo *repository.AttemptRepository
testRepo *repository.TestRepository
}
func NewAttemptHandler(repo *repository.AttemptRepository, testRepo *repository.TestRepository) *AttemptHandler {
return &AttemptHandler{repo: repo, testRepo: testRepo}
}
// Start — POST /tests/{id}/attempts. Создаёт попытку прохождения для
// текущего юзера. Респондент берётся из X-User-Id (заголовок ставит
// portal-gateway). Для public-tokens — отдельный handler в public.go.
func (h *AttemptHandler) Start(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
testID, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid test id")
return
}
// Проверим, что тест опубликован — иначе только владелец может стартовать
// (для preview). MVP: для не-владельца требуем is_published.
t, err := h.testRepo.Get(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
if !t.IsPublished && t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "test is not published")
return
}
att, err := h.repo.Start(r.Context(), repository.StartParams{
TestID: testID,
UserID: &uid,
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
})
if err != nil {
writeRepoError(w, r, err, "start attempt")
return
}
writeJSON(w, http.StatusCreated, att)
}
// Get — GET /attempts/{id}. Возвращает попытку. Доступно: владелец попытки
// (user_id), владелец теста (HR-просмотр), или admin (через bypass на портале).
func (h *AttemptHandler) Get(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
a, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get attempt")
return
}
// Доступ: я респондент или я владелец теста.
if a.UserID == nil || *a.UserID != uid {
t, err := h.testRepo.Get(r.Context(), a.TestID)
if err != nil || t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "forbidden")
return
}
}
writeJSON(w, http.StatusOK, a)
}
// Submit — POST /attempts/{id}/submit. Применяет ответы и считает score.
// После submit'а попытка переходит в submitted (если есть text-вопросы)
// или сразу в graded. Идемпотентно НЕ делаем — повторный submit вернёт
// ошибку «уже submitted».
func (h *AttemptHandler) Submit(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get attempt")
return
}
if existing.UserID == nil || *existing.UserID != uid {
writeError(w, http.StatusForbidden, "not your attempt")
return
}
var req model.SubmitAttemptRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
updated, err := h.repo.SubmitAndGrade(r.Context(), id, req)
if err != nil {
writeRepoError(w, r, err, "submit attempt")
return
}
writeJSON(w, http.StatusOK, updated)
}
// ListMine — GET /attempts?mine=true. История моих попыток.
func (h *AttemptHandler) ListMine(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
items, err := h.repo.ListByUser(r.Context(), uid)
if err != nil {
writeRepoError(w, r, err, "list my attempts")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// ListByTest — GET /tests/{id}/attempts. Список попыток для HR (владелец теста).
func (h *AttemptHandler) ListByTest(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
testID, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid test id")
return
}
t, err := h.testRepo.Get(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
if t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can view attempts")
return
}
items, err := h.repo.ListByTest(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "list test attempts")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}