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>
This commit is contained in:
167
internal/handler/attempt.go
Normal file
167
internal/handler/attempt.go
Normal 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})
|
||||
}
|
||||
Reference in New Issue
Block a user