feat: add admin controllers, permissions and navigation for quizzes and attempts

This commit is contained in:
Jakub Zych
2026-10-02 14:17:43 +02:00
parent effe640549
commit 793cc4aac0
32 changed files with 1108 additions and 114 deletions

158
admin_postgres_test.go Normal file
View File

@@ -0,0 +1,158 @@
package quizzes_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/surf"
"gorm.io/gorm"
)
const adminPrefix = "/backend/api/v1"
// adminToken creates a backend user whose role grants permissions and
// signs them in through the admin API.
func adminToken(t *testing.T, gdb *gorm.DB, h http.Handler, permissions ...string) string {
t.Helper()
suffix := fmt.Sprint(time.Now().UnixNano())
login, password := "quizzes-"+suffix, "secret-"+suffix
hash, err := bouncer.HashPassword(4, password)
must(t, err)
grants := map[string]int{}
for _, p := range permissions {
grants[p] = 1
}
raw, _ := json.Marshal(grants)
var roleID uint
must(t, gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
"Role "+suffix, "role-"+suffix, string(raw)).Scan(&roleID).Error)
must(t, gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
login, login+"@example.test", hash, roleID).Error)
body, _ := json.Marshal(map[string]string{"login": login, "password": password})
req := httptest.NewRequest(http.MethodPost, adminPrefix+"/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("admin login = %d %s", rec.Code, rec.Body.String())
}
var out struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
must(t, json.Unmarshal(rec.Body.Bytes(), &out))
return out.Data.AccessToken
}
func adminSend(t *testing.T, h http.Handler, method, path, token string, body any) (int, string) {
t.Helper()
var reader *bytes.Reader
if body != nil {
raw, _ := json.Marshal(body)
reader = bytes.NewReader(raw)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, adminPrefix+path, reader)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept-Language", "pl")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}
func TestAdminAgainstDatabase(t *testing.T) {
app, plugins, gdb := migrated(t)
h, err := surf.Assemble(app, plugins)
must(t, err)
f := seedQuiz(t, gdb, "admin", nil)
other := seedQuiz(t, gdb, "drugi", func(q *models.Quiz) { q.Title = "Drugi" })
must(t, gdb.Model(&models.Question{}).Where("id = ?", other.selfQ.ID).Update("category", "Teoria stosunków").Error)
u, _ := newUser(t, gdb, "ala@example.com")
must(t, gdb.Create(&models.Attempt{QuizID: f.quiz.ID, UserID: u.ID, StartedAt: time.Now()}).Error)
editor := adminToken(t, gdb, h, "golem15.quizzes.access_quizzes")
viewer := adminToken(t, gdb, h, "golem15.quizzes.access_attempts")
// Questions: filter by quiz and by a category with spaces and diacritics.
code, body := adminSend(t, h, "GET", fmt.Sprintf("/golem15/quizzes/questions?filter[quiz]=%d", f.quiz.ID), editor, nil)
if code != 200 || strings.Count(body, `"quiz_id"`) > 0 && !strings.Contains(body, "Stolica Polski") {
t.Fatalf("questions filtered by quiz = %d %s", code, body)
}
var list struct {
Data []map[string]any `json:"data"`
}
must(t, json.Unmarshal([]byte(body), &list))
if len(list.Data) != 3 {
t.Errorf("questions of one quiz = %d rows: %s", len(list.Data), body)
}
code, body = adminSend(t, h, "GET", "/golem15/quizzes/questions/filters/category/options", editor, nil)
if code != 200 || !strings.Contains(body, "Teoria stosunków") {
t.Fatalf("category options = %d %s", code, body)
}
enc := models.EncodeCategoryValue("Teoria stosunków")
code, body = adminSend(t, h, "GET", "/golem15/quizzes/questions?filter[category]="+url.QueryEscape(enc), editor, nil)
must(t, json.Unmarshal([]byte(body), &list))
if code != 200 || len(list.Data) != 1 {
t.Errorf("questions in 'Teoria stosunków' = %d %s", code, body)
}
code, body = adminSend(t, h, "GET", "/golem15/quizzes/questions/filters/quiz/options", editor, nil)
if code != 200 || !strings.Contains(body, "Drugi") {
t.Errorf("quiz options = %d %s", code, body)
}
code, body = adminSend(t, h, "GET", fmt.Sprintf("/golem15/quizzes/answers?filter[quiz]=%d", other.quiz.ID), editor, nil)
must(t, json.Unmarshal([]byte(body), &list))
if code != 200 || len(list.Data) != 5 {
t.Errorf("answers of one quiz = %d %s", code, body)
}
// Creating a quiz fills the slug from the title; a duplicate is a 422.
code, body = adminSend(t, h, "POST", "/golem15/quizzes/quizzes", editor, map[string]any{"title": "Nowy Quiz Łódź", "passing_score": 60})
if code != 201 && code != 200 {
t.Fatalf("create quiz = %d %s", code, body)
}
var q models.Quiz
must(t, gdb.Where("title = ?", "Nowy Quiz Łódź").Take(&q).Error)
if q.Slug != "nowy-quiz-lodz" || q.PassingScore != 60 {
t.Errorf("created quiz = %+v", q)
}
code, body = adminSend(t, h, "POST", "/golem15/quizzes/quizzes", editor, map[string]any{"title": "Nowy quiz łódź", "passing_score": 60})
if code != 422 || !strings.Contains(body, "slug") {
t.Errorf("duplicate slug = %d %s", code, body)
}
code, body = adminSend(t, h, "POST", "/golem15/quizzes/questions", editor, map[string]any{"quiz": q.ID, "text": "Q?", "type": "essay", "points": 1})
if code != 422 || !strings.Contains(body, "type") {
t.Errorf("question with an unknown type = %d %s", code, body)
}
// Attempts are read-only and need their own permission.
if code, _ := adminSend(t, h, "GET", "/golem15/quizzes/attempts", editor, nil); code != 403 {
t.Errorf("attempts for a quiz editor = %d, want 403", code)
}
code, body = adminSend(t, h, "GET", "/golem15/quizzes/attempts", viewer, nil)
if code != 200 || !strings.Contains(body, "ala@example.com") {
t.Errorf("attempts list = %d %s", code, body)
}
code, body = adminSend(t, h, "GET", fmt.Sprintf("/golem15/quizzes/attempts?filter[quiz]=%d", other.quiz.ID), viewer, nil)
must(t, json.Unmarshal([]byte(body), &list))
if code != 200 || len(list.Data) != 0 {
t.Errorf("attempts of a quiz without attempts = %d %s", code, body)
}
if code, body := adminSend(t, h, "POST", "/golem15/quizzes/attempts", viewer, map[string]any{}); code == 200 || code == 201 {
t.Errorf("creating an attempt through the admin = %d %s", code, body)
}
if code, _ := adminSend(t, h, "GET", "/golem15/quizzes/quizzes", viewer, nil); code != 403 {
t.Errorf("quizzes for an attempts viewer = %d, want 403", code)
}
}

68
admin_test.go Normal file
View File

@@ -0,0 +1,68 @@
package quizzes_test
import (
"slices"
"testing"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
func TestAdminCompiles(t *testing.T) {
app, plugins := activate(t, nil, nil)
routes, err := cabana.Activate(app, plugins)
if err != nil {
t.Fatalf("cabana.Activate: %v", err)
}
if routes == nil {
t.Fatal("no admin routes")
}
p := quizzesPlugin(t, plugins)
fsys := p.(pact.AdminAssets).AdminFS()
var ids []string
for _, ctl := range p.(pact.HasAdminControllers).AdminControllers() {
ids = append(ids, ctl.ID())
if _, err := cabana.CompileList(p.ID(), ctl, fsys); err != nil {
t.Errorf("%s list: %v", ctl.ID(), err)
}
_, formErr := cabana.CompileForm(p.ID(), ctl, fsys)
if ctl.ID() == "golem15.quizzes.attempts" {
if formErr == nil {
t.Error("attempts has a form; it must stay read-only")
}
} else if formErr != nil {
t.Errorf("%s form: %v", ctl.ID(), formErr)
}
}
slices.Sort(ids)
want := []string{"golem15.quizzes.answers", "golem15.quizzes.attempts", "golem15.quizzes.questions", "golem15.quizzes.quizzes"}
if !slices.Equal(ids, want) {
t.Errorf("controllers = %v, want %v", ids, want)
}
editor := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"golem15.quizzes.access_quizzes": true}}
for _, ctl := range p.(pact.HasAdminControllers).AdminControllers() {
perms := ctl.(pact.AdminPermissioned).RequiredPermissions()
allowed := cabana.Allows(editor, perms)
if wantAllowed := ctl.ID() != "golem15.quizzes.attempts"; allowed != wantAllowed {
t.Errorf("%s allowed for a quiz editor = %v, want %v", ctl.ID(), allowed, wantAllowed)
}
}
nav := p.(pact.HasNavigation).Navigation()
if len(nav) != 1 || nav[0].Label != "golem15.quizzes::lang.plugin.name" || len(nav[0].SideMenu) != 4 {
t.Errorf("navigation = %+v", nav)
}
}
func TestQuestionFilterScopes(t *testing.T) {
q := models.Question{}
if got := q.FilterOptions(models.ScopeByType); len(got) != 3 {
t.Errorf("type options = %v", got)
}
if got := q.FilterScope("unknown", nil, "x"); got != nil {
t.Errorf("FilterScope on a nil db = %v", got)
}
}

46
controllers/answers.go Normal file
View File

@@ -0,0 +1,46 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
var (
_ pact.AdminController = answersAdmin{}
_ pact.AdminRecordSource = answersAdmin{}
_ pact.AdminPermissioned = answersAdmin{}
_ cabana.FieldRelationProvider = answersAdmin{}
)
// answersAdmin lists and edits the answers of single and multiple
// questions; the list filters by quiz.
type answersAdmin struct{}
func (answersAdmin) ID() string { return "golem15.quizzes.answers" }
func (answersAdmin) ModelName() string { return "Answer" }
func (answersAdmin) ConfigDir() string { return "controllers/answers" }
// NewRecord returns the model the generic admin handlers query and fill.
func (answersAdmin) NewRecord() any { return &models.Answer{} }
// RequiredPermissions is checked before any schema or record is served.
func (answersAdmin) RequiredPermissions() []string {
return []string{"golem15.quizzes.access_quizzes"}
}
// AdminFieldRelations binds the question picker to question_id.
func (answersAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{
Field: "question",
Kind: "belongsTo",
NewRelated: func() any { return &models.Question{} },
ForeignKey: "question_id",
LabelColumn: "text",
}}
}
// AnswersController returns the golem15.quizzes.answers admin controller.
func AnswersController() pact.AdminController { return answersAdmin{} }

View File

@@ -0,0 +1,10 @@
scopes:
quiz:
label: golem15.quizzes::lang.questions.quiz
modelClass: Quiz
nameFrom: title
scope: filterByQuiz
is_correct:
label: golem15.quizzes::lang.answers.is_correct
type: switch
column: is_correct

View File

@@ -0,0 +1,12 @@
name: golem15.quizzes::lang.answers.answer
form: ~/plugins/golem15/quizzes/models/answers/fields.yaml
modelClass: Answer
defaultRedirect: golem15/quizzes/answers
create:
redirect: golem15/quizzes/answers/update/:id
redirectClose: golem15/quizzes/answers
update:
redirect: golem15/quizzes/answers
redirectClose: golem15/quizzes/answers

View File

@@ -0,0 +1,15 @@
list: ~/plugins/golem15/quizzes/models/answers/columns.yaml
modelClass: Answer
title: golem15.quizzes::lang.answers.title
recordUrl: golem15/quizzes/answers/update/:id
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: sort_order
direction: asc
filter: config_filter.yaml
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

33
controllers/attempts.go Normal file
View File

@@ -0,0 +1,33 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
var (
_ pact.AdminController = attemptsAdmin{}
_ pact.AdminRecordSource = attemptsAdmin{}
_ pact.AdminPermissioned = attemptsAdmin{}
)
// attemptsAdmin is a read-only list of attempts: it has no form, so the
// admin API refuses every create, update and delete.
type attemptsAdmin struct{}
func (attemptsAdmin) ID() string { return "golem15.quizzes.attempts" }
func (attemptsAdmin) ModelName() string { return "Attempt" }
func (attemptsAdmin) ConfigDir() string { return "controllers/attempts" }
// NewRecord returns the model the generic admin handlers query.
func (attemptsAdmin) NewRecord() any { return &models.Attempt{} }
// RequiredPermissions is checked before any schema or record is served.
func (attemptsAdmin) RequiredPermissions() []string {
return []string{"golem15.quizzes.access_attempts"}
}
// AttemptsController returns the golem15.quizzes.attempts admin controller.
func AttemptsController() pact.AdminController { return attemptsAdmin{} }

View File

@@ -0,0 +1,10 @@
scopes:
quiz:
label: golem15.quizzes::lang.attempts.quiz
modelClass: Quiz
nameFrom: title
scope: filterByQuiz
started_at:
label: golem15.quizzes::lang.attempts.started_at
type: daterange
column: started_at

View File

@@ -0,0 +1,13 @@
list: ~/plugins/golem15/quizzes/models/attempts/columns.yaml
modelClass: Attempt
title: golem15.quizzes::lang.attempts.title
recordsPerPage: 50
perPageOptions: [20, 50, 100]
defaultSort:
column: started_at
direction: desc
filter: config_filter.yaml
toolbar:
buttons: []
search:
prompt: backend::lang.list.search_prompt

46
controllers/questions.go Normal file
View File

@@ -0,0 +1,46 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
var (
_ pact.AdminController = questionsAdmin{}
_ pact.AdminRecordSource = questionsAdmin{}
_ pact.AdminPermissioned = questionsAdmin{}
_ cabana.FieldRelationProvider = questionsAdmin{}
)
// questionsAdmin lists and edits questions of every quiz; the list filters
// by quiz, category and type.
type questionsAdmin struct{}
func (questionsAdmin) ID() string { return "golem15.quizzes.questions" }
func (questionsAdmin) ModelName() string { return "Question" }
func (questionsAdmin) ConfigDir() string { return "controllers/questions" }
// NewRecord returns the model the generic admin handlers query and fill.
func (questionsAdmin) NewRecord() any { return &models.Question{} }
// RequiredPermissions is checked before any schema or record is served.
func (questionsAdmin) RequiredPermissions() []string {
return []string{"golem15.quizzes.access_quizzes"}
}
// AdminFieldRelations binds the quiz picker to quiz_id.
func (questionsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{
Field: "quiz",
Kind: "belongsTo",
NewRelated: func() any { return &models.Quiz{} },
ForeignKey: "quiz_id",
LabelColumn: "title",
}}
}
// QuestionsController returns the golem15.quizzes.questions admin controller.
func QuestionsController() pact.AdminController { return questionsAdmin{} }

View File

@@ -0,0 +1,16 @@
scopes:
quiz:
label: golem15.quizzes::lang.questions.quiz
modelClass: Quiz
nameFrom: title
scope: filterByQuiz
category:
label: golem15.quizzes::lang.questions.category
modelClass: Question
nameFrom: category
scope: filterByCategory
type:
label: golem15.quizzes::lang.questions.type
modelClass: Question
nameFrom: type
scope: filterByType

View File

@@ -0,0 +1,12 @@
name: golem15.quizzes::lang.questions.question
form: ~/plugins/golem15/quizzes/models/questions/fields.yaml
modelClass: Question
defaultRedirect: golem15/quizzes/questions
create:
redirect: golem15/quizzes/questions/update/:id
redirectClose: golem15/quizzes/questions
update:
redirect: golem15/quizzes/questions
redirectClose: golem15/quizzes/questions

View File

@@ -0,0 +1,15 @@
list: ~/plugins/golem15/quizzes/models/questions/columns.yaml
modelClass: Question
title: golem15.quizzes::lang.questions.title
recordUrl: golem15/quizzes/questions/update/:id
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: sort_order
direction: asc
filter: config_filter.yaml
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

37
controllers/quizzes.go Normal file
View File

@@ -0,0 +1,37 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
var (
_ pact.AdminController = quizzesAdmin{}
_ pact.AdminRecordSource = quizzesAdmin{}
_ pact.AdminPermissioned = quizzesAdmin{}
)
// quizzesAdmin lists and edits quizzes. Questions have their own controller
// filtered by quiz.
//
// TODO: requires SummerCMS change. cabana's relation manager only links and
// unlinks belongsToMany records through a pivot; it cannot create or edit
// hasMany children inline, so the quiz form cannot embed its questions.
type quizzesAdmin struct{}
func (quizzesAdmin) ID() string { return "golem15.quizzes.quizzes" }
func (quizzesAdmin) ModelName() string { return "Quiz" }
func (quizzesAdmin) ConfigDir() string { return "controllers/quizzes" }
// NewRecord returns the model the generic admin handlers query and fill.
func (quizzesAdmin) NewRecord() any { return &models.Quiz{} }
// RequiredPermissions is checked before any schema or record is served.
func (quizzesAdmin) RequiredPermissions() []string {
return []string{"golem15.quizzes.access_quizzes"}
}
// QuizzesController returns the golem15.quizzes.quizzes admin controller.
func QuizzesController() pact.AdminController { return quizzesAdmin{} }

View File

@@ -0,0 +1,5 @@
scopes:
is_published:
label: golem15.quizzes::lang.quizzes.is_published
type: switch
column: is_published

View File

@@ -0,0 +1,12 @@
name: golem15.quizzes::lang.quizzes.quiz
form: ~/plugins/golem15/quizzes/models/quizzes/fields.yaml
modelClass: Quiz
defaultRedirect: golem15/quizzes/quizzes
create:
redirect: golem15/quizzes/quizzes/update/:id
redirectClose: golem15/quizzes/quizzes
update:
redirect: golem15/quizzes/quizzes
redirectClose: golem15/quizzes/quizzes

View File

@@ -0,0 +1,14 @@
list: ~/plugins/golem15/quizzes/models/quizzes/columns.yaml
modelClass: Quiz
title: golem15.quizzes::lang.quizzes.title
recordUrl: golem15/quizzes/quizzes/update/:id
recordsPerPage: 20
showCheckboxes: true
defaultSort:
column: title
direction: asc
filter: config_filter.yaml
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

View File

@@ -12,3 +12,55 @@ api:
single_one_answer: Choose exactly one answer for this question.
known_required: The known field is required for this question.
unknown_category: The quiz has no questions in this category.
permissions:
access_quizzes: Manage quizzes, questions and answers
access_attempts: View quiz attempts
common:
sort_order: Order
updated_at: Updated
quizzes:
title: Quizzes
quiz: Quiz
title_field: Title
slug: Slug
slug_comment: Leave empty to build it from the title.
description: Description
context: Context
context_comment: Optional link to another plugin's record, such as vendor.plugin:course:12.
is_published: Published
passing_score: Passing score (%)
time_limit_seconds: Time limit (seconds)
time_limit_comment: Leave empty for no limit.
round_size: Round size
round_size_comment: Clients may present the questions in rounds of this many.
shuffle_questions: Shuffle questions
questions:
title: Questions
question: Question
quiz: Quiz
text: Question
type: Type
type_single: Single choice
type_multiple: Multiple choice
type_self_check: Self-check (flashcard)
category: Category
points: Points
answer_text: Model answer
answer_text_comment: Revealed for self-check questions.
explanation: Explanation
answers:
title: Answers
answer: Answer
question: Question
text: Answer
is_correct: Correct
explanation: Explanation
attempts:
title: Attempts
quiz: Quiz
user: User
started_at: Started
completed_at: Completed
score_points: Points
max_points: Max points
score_percent: Score (%)

View File

@@ -12,3 +12,55 @@ api:
single_one_answer: Wybierz dokładnie jedną odpowiedź.
known_required: Zaznacz, czy znasz odpowiedź.
unknown_category: Quiz nie ma pytań w tej kategorii.
permissions:
access_quizzes: Zarządzanie quizami, pytaniami i odpowiedziami
access_attempts: Podgląd podejść do quizów
common:
sort_order: Kolejność
updated_at: Zaktualizowano
quizzes:
title: Quizy
quiz: Quiz
title_field: Tytuł
slug: Slug
slug_comment: Zostaw puste, aby utworzyć z tytułu.
description: Opis
context: Kontekst
context_comment: Opcjonalne powiązanie z rekordem innej wtyczki, np. vendor.plugin:course:12.
is_published: Opublikowany
passing_score: Próg zaliczenia (%)
time_limit_seconds: Limit czasu (sekundy)
time_limit_comment: Zostaw puste, aby nie ograniczać czasu.
round_size: Wielkość rundy
round_size_comment: Klient może pokazywać pytania w rundach po tyle pytań.
shuffle_questions: Losowa kolejność pytań
questions:
title: Pytania
question: Pytanie
quiz: Quiz
text: Pytanie
type: Typ
type_single: Jednokrotny wybór
type_multiple: Wielokrotny wybór
type_self_check: Fiszka (samoocena)
category: Kategoria
points: Punkty
answer_text: Wzorcowa odpowiedź
answer_text_comment: Pokazywana w fiszkach po odsłonięciu.
explanation: Wyjaśnienie
answers:
title: Odpowiedzi
answer: Odpowiedź
question: Pytanie
text: Odpowiedź
is_correct: Poprawna
explanation: Wyjaśnienie
attempts:
title: Podejścia
quiz: Quiz
user: Użytkownik
started_at: Rozpoczęto
completed_at: Zakończono
score_points: Punkty
max_points: Maks. punktów
score_percent: Wynik (%)

View File

@@ -0,0 +1,14 @@
columns:
question:
label: golem15.quizzes::lang.answers.question
relation: question
select: text
searchable: true
text:
label: golem15.quizzes::lang.answers.text
searchable: true
is_correct:
label: golem15.quizzes::lang.answers.is_correct
type: switch
sort_order:
label: golem15.quizzes::lang.common.sort_order

View File

@@ -0,0 +1,23 @@
fields:
question:
label: golem15.quizzes::lang.answers.question
type: relation
nameFrom: text
required: true
text:
label: golem15.quizzes::lang.answers.text
type: textarea
size: tiny
required: true
is_correct:
label: golem15.quizzes::lang.answers.is_correct
type: switch
span: left
sort_order:
label: golem15.quizzes::lang.common.sort_order
type: number
span: right
explanation:
label: golem15.quizzes::lang.answers.explanation
type: textarea
size: small

View File

@@ -2,24 +2,29 @@
package models
import "time"
import (
"time"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
)
// Attempt is one user's run through a quiz, or through one category of its
// questions when Category is set. The score columns are set when the attempt
// is finished.
type Attempt struct {
ID uint `gorm:"column:id;primaryKey"`
QuizID uint `gorm:"column:quiz_id"`
UserID uint `gorm:"column:user_id"`
Category *string `gorm:"column:category"`
StartedAt time.Time `gorm:"column:started_at"`
CompletedAt *time.Time `gorm:"column:completed_at"`
ScorePoints float64 `gorm:"column:score_points"`
MaxPoints int `gorm:"column:max_points"`
ScorePercent *float64 `gorm:"column:score_percent"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
Quiz *Quiz `gorm:"foreignKey:QuizID"`
ID uint `gorm:"column:id;primaryKey"`
QuizID uint `gorm:"column:quiz_id"`
UserID uint `gorm:"column:user_id"`
Category *string `gorm:"column:category"`
StartedAt time.Time `gorm:"column:started_at"`
CompletedAt *time.Time `gorm:"column:completed_at"`
ScorePoints float64 `gorm:"column:score_points"`
MaxPoints int `gorm:"column:max_points"`
ScorePercent *float64 `gorm:"column:score_percent"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
Quiz *Quiz `gorm:"foreignKey:QuizID"`
User *usermodels.User `gorm:"foreignKey:UserID"`
}
// TableName is the plugin-prefixed table.

View File

@@ -0,0 +1,25 @@
columns:
quiz:
label: golem15.quizzes::lang.attempts.quiz
relation: quiz
select: title
searchable: true
user:
label: golem15.quizzes::lang.attempts.user
relation: user
select: email
searchable: true
category:
label: golem15.quizzes::lang.questions.category
started_at:
label: golem15.quizzes::lang.attempts.started_at
type: datetime
completed_at:
label: golem15.quizzes::lang.attempts.completed_at
type: datetime
score_points:
label: golem15.quizzes::lang.attempts.score_points
max_points:
label: golem15.quizzes::lang.attempts.max_points
score_percent:
label: golem15.quizzes::lang.attempts.score_percent

226
models/filters.go Normal file
View File

@@ -0,0 +1,226 @@
package models
import (
"encoding/hex"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// FilterDB returns the database the admin filter choices are read from. The
// plugin sets it at boot.
//
// TODO: requires SummerCMS change. pact.FilterOptions receives neither a
// context nor a database handle, so a model whose filter choices come from
// the database needs this package-level hook.
var FilterDB func() *gorm.DB
// Filter scopes of the admin lists.
const (
ScopeByQuiz = "filterByQuiz"
ScopeByCategory = "filterByCategory"
ScopeByType = "filterByType"
)
var (
_ pact.FilterScope = Question{}
_ pact.FilterOptions = Question{}
_ pact.FilterScope = Answer{}
_ pact.FilterOptions = Answer{}
_ pact.FilterScope = Attempt{}
_ pact.FilterOptions = Attempt{}
)
// FilterScopes lists the admin list scopes of questions.
func (Question) FilterScopes() []string { return []string{ScopeByQuiz, ScopeByCategory, ScopeByType} }
// FilterScope narrows the questions list to the chosen quizzes, categories
// or types.
func (Question) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
values := filterValues(value)
if db == nil || len(values) == 0 {
return db
}
switch name {
case ScopeByQuiz:
return db.Where("golem15_quizzes_questions.quiz_id IN ?", ids(values))
case ScopeByCategory:
cats := make([]string, 0, len(values))
for _, v := range values {
if c, ok := DecodeCategoryValue(v); ok {
cats = append(cats, c)
}
}
if len(cats) == 0 {
return db.Where("1 = 0")
}
return db.Where("golem15_quizzes_questions.category IN ?", cats)
case ScopeByType:
return db.Where("golem15_quizzes_questions.type IN ?", values)
}
return db
}
// FilterOptions serves the quizzes, the distinct categories and the types.
func (Question) FilterOptions(scope string) []pact.Option {
switch scope {
case ScopeByQuiz:
return quizOptions()
case ScopeByCategory:
return categoryOptions()
case ScopeByType:
return []pact.Option{
{Value: TypeSingle, Label: "golem15.quizzes::lang.questions.type_single"},
{Value: TypeMultiple, Label: "golem15.quizzes::lang.questions.type_multiple"},
{Value: TypeSelfCheck, Label: "golem15.quizzes::lang.questions.type_self_check"},
}
}
return nil
}
// FilterScopes lists the admin list scopes of answers.
func (Answer) FilterScopes() []string { return []string{ScopeByQuiz} }
// FilterScope narrows the answers list to the questions of the chosen
// quizzes.
func (Answer) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
values := filterValues(value)
if db == nil || len(values) == 0 || name != ScopeByQuiz {
return db
}
return db.Where("golem15_quizzes_answers.question_id IN (SELECT id FROM golem15_quizzes_questions WHERE quiz_id IN ?)", ids(values))
}
// FilterOptions serves the quizzes.
func (Answer) FilterOptions(scope string) []pact.Option {
if scope == ScopeByQuiz {
return quizOptions()
}
return nil
}
// FilterScopes lists the admin list scopes of attempts.
func (Attempt) FilterScopes() []string { return []string{ScopeByQuiz} }
// FilterScope narrows the attempts list to the chosen quizzes.
func (Attempt) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
values := filterValues(value)
if db == nil || len(values) == 0 || name != ScopeByQuiz {
return db
}
return db.Where("golem15_quizzes_attempts.quiz_id IN ?", ids(values))
}
// FilterOptions serves the quizzes.
func (Attempt) FilterOptions(scope string) []pact.Option {
if scope == ScopeByQuiz {
return quizOptions()
}
return nil
}
func filterDB() *gorm.DB {
if FilterDB == nil {
return nil
}
return FilterDB()
}
func quizOptions() []pact.Option {
db := filterDB()
if db == nil {
return nil
}
var quizzes []Quiz
if err := db.Select("id", "title").Order("title").Find(&quizzes).Error; err != nil {
return nil
}
out := make([]pact.Option, 0, len(quizzes))
for _, q := range quizzes {
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(q.ID), 10), Label: q.Title})
}
return out
}
func categoryOptions() []pact.Option {
db := filterDB()
if db == nil {
return nil
}
var cats []string
if err := db.Model(&Question{}).Where("category IS NOT NULL AND category <> ''").
Distinct("category").Order("category").Pluck("category", &cats).Error; err != nil {
return nil
}
out := make([]pact.Option, 0, len(cats))
for _, c := range cats {
out = append(out, pact.Option{Value: EncodeCategoryValue(c), Label: c})
}
return out
}
// EncodeCategoryValue turns a category into a filter value the admin list
// accepts.
//
// TODO: requires SummerCMS change. cabana passes a scope filter value only
// when it is an integer or an ASCII identifier, so a category with spaces or
// diacritics ("Teoria stosunków") travels hex-encoded as "c_<hex>".
func EncodeCategoryValue(category string) string {
return "c_" + hex.EncodeToString([]byte(category))
}
// DecodeCategoryValue reverses EncodeCategoryValue.
func DecodeCategoryValue(v string) (string, bool) {
raw, ok := strings.CutPrefix(v, "c_")
if !ok {
return "", false
}
b, err := hex.DecodeString(raw)
if err != nil {
return "", false
}
return string(b), true
}
func filterValues(value any) []string {
switch v := value.(type) {
case string:
if v == "" {
return nil
}
return []string{v}
case []string:
return v
case int64:
return []string{strconv.FormatInt(v, 10)}
case int:
return []string{strconv.Itoa(v)}
case []any:
out := make([]string, 0, len(v))
for _, x := range v {
switch s := x.(type) {
case string:
if s != "" {
out = append(out, s)
}
case float64:
out = append(out, strconv.FormatFloat(s, 'f', -1, 64))
}
}
return out
}
return nil
}
// ids keeps the numeric values; an empty result matches nothing.
func ids(values []string) []int64 {
out := []int64{0}
for _, v := range values {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
out = append(out, n)
}
}
return out
}

View File

@@ -2,12 +2,7 @@
package models
import (
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
import "time"
// Question types.
const (
@@ -56,91 +51,3 @@ func (Question) Rules() map[string]string {
"points": "required|integer|min:0",
}
}
// FilterDB returns the database the admin filter choices are read from. The
// plugin sets it at boot.
//
// TODO: requires SummerCMS change. pact.FilterOptions receives neither a
// context nor a database handle, so a model whose filter choices come from
// the database needs this package-level hook.
var FilterDB func() *gorm.DB
// Filter scopes of the questions admin list.
const (
ScopeByQuiz = "filterByQuiz"
ScopeByCategory = "filterByCategory"
)
// FilterScopes lists the admin list scopes the model answers.
func (Question) FilterScopes() []string { return []string{ScopeByQuiz, ScopeByCategory} }
// FilterScope narrows the admin list to the chosen quizzes or categories.
func (Question) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
values := filterValues(value)
if len(values) == 0 {
return db
}
switch name {
case ScopeByQuiz:
return db.Where("golem15_quizzes_questions.quiz_id IN ?", values)
case ScopeByCategory:
return db.Where("golem15_quizzes_questions.category IN ?", values)
}
return db
}
// FilterOptions serves the quizzes and the distinct categories as choices.
func (Question) FilterOptions(scope string) []pact.Option {
if FilterDB == nil {
return nil
}
db := FilterDB()
if db == nil {
return nil
}
var out []pact.Option
switch scope {
case ScopeByQuiz:
var quizzes []Quiz
if err := db.Select("id", "title").Order("title").Find(&quizzes).Error; err != nil {
return nil
}
for _, q := range quizzes {
out = append(out, pact.Option{Value: uintString(q.ID), Label: q.Title})
}
case ScopeByCategory:
var cats []string
if err := db.Model(&Question{}).Where("category IS NOT NULL AND category <> ''").
Distinct("category").Order("category").Pluck("category", &cats).Error; err != nil {
return nil
}
for _, c := range cats {
out = append(out, pact.Option{Value: c, Label: c})
}
}
return out
}
func filterValues(value any) []string {
switch v := value.(type) {
case string:
if v == "" {
return nil
}
return []string{v}
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, x := range v {
if s, ok := x.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
return nil
}

View File

@@ -0,0 +1,18 @@
columns:
quiz:
label: golem15.quizzes::lang.questions.quiz
relation: quiz
select: title
searchable: true
text:
label: golem15.quizzes::lang.questions.text
searchable: true
type:
label: golem15.quizzes::lang.questions.type
category:
label: golem15.quizzes::lang.questions.category
searchable: true
points:
label: golem15.quizzes::lang.questions.points
sort_order:
label: golem15.quizzes::lang.common.sort_order

View File

@@ -0,0 +1,45 @@
fields:
quiz:
label: golem15.quizzes::lang.questions.quiz
type: relation
nameFrom: title
span: left
required: true
type:
label: golem15.quizzes::lang.questions.type
type: dropdown
span: right
required: true
default: single
options:
single: golem15.quizzes::lang.questions.type_single
multiple: golem15.quizzes::lang.questions.type_multiple
self_check: golem15.quizzes::lang.questions.type_self_check
text:
label: golem15.quizzes::lang.questions.text
type: textarea
size: small
required: true
category:
label: golem15.quizzes::lang.questions.category
type: text
span: left
points:
label: golem15.quizzes::lang.questions.points
type: number
span: right
default: 1
required: true
answer_text:
label: golem15.quizzes::lang.questions.answer_text
type: textarea
size: small
comment: golem15.quizzes::lang.questions.answer_text_comment
explanation:
label: golem15.quizzes::lang.questions.explanation
type: textarea
size: small
sort_order:
label: golem15.quizzes::lang.common.sort_order
type: number
span: left

View File

@@ -0,0 +1,16 @@
columns:
title:
label: golem15.quizzes::lang.quizzes.title_field
searchable: true
slug:
label: golem15.quizzes::lang.quizzes.slug
searchable: true
context:
label: golem15.quizzes::lang.quizzes.context
searchable: true
is_published:
label: golem15.quizzes::lang.quizzes.is_published
type: switch
updated_at:
label: golem15.quizzes::lang.common.updated_at
type: datetime

View File

@@ -0,0 +1,44 @@
fields:
title:
label: golem15.quizzes::lang.quizzes.title_field
type: text
span: left
required: true
slug:
label: golem15.quizzes::lang.quizzes.slug
type: text
span: right
comment: golem15.quizzes::lang.quizzes.slug_comment
description:
label: golem15.quizzes::lang.quizzes.description
type: textarea
size: small
context:
label: golem15.quizzes::lang.quizzes.context
type: text
span: left
comment: golem15.quizzes::lang.quizzes.context_comment
is_published:
label: golem15.quizzes::lang.quizzes.is_published
type: switch
span: right
passing_score:
label: golem15.quizzes::lang.quizzes.passing_score
type: number
span: left
default: 50
required: true
time_limit_seconds:
label: golem15.quizzes::lang.quizzes.time_limit_seconds
type: number
span: right
comment: golem15.quizzes::lang.quizzes.time_limit_comment
round_size:
label: golem15.quizzes::lang.quizzes.round_size
type: number
span: left
comment: golem15.quizzes::lang.quizzes.round_size_comment
shuffle_questions:
label: golem15.quizzes::lang.quizzes.shuffle_questions
type: switch
span: right

View File

@@ -1,5 +0,0 @@
package models
import "strconv"
func uintString(n uint) string { return strconv.FormatUint(uint64(n), 10) }

View File

@@ -4,11 +4,13 @@ import (
"embed"
"io/fs"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var (
@@ -20,6 +22,9 @@ var (
_ pact.HasCommands = (*Plugin)(nil)
_ pact.HasJobs = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
)
//go:embed config
@@ -31,6 +36,9 @@ var langFS embed.FS
//go:embed views/mail
var mailFS embed.FS
//go:embed controllers/*/*.yaml models/*/*.yaml
var adminFS embed.FS
// Plugin is the golem15.quizzes plugin.
type Plugin struct {
app *backpack.App
@@ -49,9 +57,17 @@ func (p *Plugin) Register(app *backpack.App) error {
return nil
}
// Boot keeps the application.
// Boot keeps the application and points the admin filter choices at its
// database.
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
models.FilterDB = func() *gorm.DB {
if app == nil {
return nil
}
gdb, _ := app.Lookup[*gorm.DB]()
return gdb
}
return nil
}
@@ -70,6 +86,34 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}
// AdminFS is the admin YAML the controllers read.
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// Permissions declares the admin permissions.
func (p *Plugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "golem15.quizzes.access_quizzes", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_quizzes"},
{Code: "golem15.quizzes.access_attempts", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_attempts"},
}
}
// Navigation declares the "Quizzes" admin menu with its side menu.
func (p *Plugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "quizzes",
Label: "golem15.quizzes::lang.plugin.name",
Icon: "icon-question-circle",
Permissions: []string{"golem15.quizzes.access_quizzes", "golem15.quizzes.access_attempts"},
Controller: "golem15.quizzes.quizzes",
SideMenu: []pact.NavigationItem{
{Code: "quizzes", Label: "golem15.quizzes::lang.quizzes.title", Icon: "icon-list", Controller: "golem15.quizzes.quizzes", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "questions", Label: "golem15.quizzes::lang.questions.title", Icon: "icon-question", Controller: "golem15.quizzes.questions", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "answers", Label: "golem15.quizzes::lang.answers.title", Icon: "icon-check-square", Controller: "golem15.quizzes.answers", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "attempts", Label: "golem15.quizzes::lang.attempts.title", Icon: "icon-bar-chart", Controller: "golem15.quizzes.attempts", Permissions: []string{"golem15.quizzes.access_attempts"}},
},
}}
}
func init() {
party.Register(&Plugin{})
}

View File

@@ -3,6 +3,7 @@
package quizzes
import (
"git.golem15.com/golem15/sm-quizzes-plugin/controllers"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/sm-quizzes-plugin/updates"
"git.golem15.com/golem15/summercms/modules/bonfire"
@@ -36,5 +37,10 @@ func generatedJobs() []pact.Job {
}
func generatedAdminControllers() []pact.AdminController {
return nil
return []pact.AdminController{
controllers.AnswersController(),
controllers.AttemptsController(),
controllers.QuestionsController(),
controllers.QuizzesController(),
}
}