Files
learning/internal/handler/attempt.go
Ilya 4f9b1b1491
Some checks failed
CI / test (push) Failing after 10s
Build and Deploy / build-and-deploy (push) Successful in 25s
feat(attempts): прохождение тестов + автогрейд single/multi
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>
2026-05-25 23:00:38 +03:00

168 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}