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>
168 lines
5.2 KiB
Go
168 lines
5.2 KiB
Go
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})
|
||
}
|