From 793cc4aac017e5f9413c1a367a810f5a33f5d7bf Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 14:17:43 +0200 Subject: [PATCH] feat: add admin controllers, permissions and navigation for quizzes and attempts --- admin_postgres_test.go | 158 ++++++++++++++++ admin_test.go | 68 +++++++ controllers/answers.go | 46 +++++ controllers/answers/config_filter.yaml | 10 + controllers/answers/config_form.yaml | 12 ++ controllers/answers/config_list.yaml | 15 ++ controllers/attempts.go | 33 ++++ controllers/attempts/config_filter.yaml | 10 + controllers/attempts/config_list.yaml | 13 ++ controllers/questions.go | 46 +++++ controllers/questions/config_filter.yaml | 16 ++ controllers/questions/config_form.yaml | 12 ++ controllers/questions/config_list.yaml | 15 ++ controllers/quizzes.go | 37 ++++ controllers/quizzes/config_filter.yaml | 5 + controllers/quizzes/config_form.yaml | 12 ++ controllers/quizzes/config_list.yaml | 14 ++ lang/en/lang.yaml | 52 ++++++ lang/pl/lang.yaml | 52 ++++++ models/answers/columns.yaml | 14 ++ models/answers/fields.yaml | 23 +++ models/attempt.go | 31 ++-- models/attempts/columns.yaml | 25 +++ models/filters.go | 226 +++++++++++++++++++++++ models/question.go | 95 +--------- models/questions/columns.yaml | 18 ++ models/questions/fields.yaml | 45 +++++ models/quizzes/columns.yaml | 16 ++ models/quizzes/fields.yaml | 44 +++++ models/util.go | 5 - plugin.go | 46 ++++- registry.gen.go | 8 +- 32 files changed, 1108 insertions(+), 114 deletions(-) create mode 100644 admin_postgres_test.go create mode 100644 admin_test.go create mode 100644 controllers/answers.go create mode 100644 controllers/answers/config_filter.yaml create mode 100644 controllers/answers/config_form.yaml create mode 100644 controllers/answers/config_list.yaml create mode 100644 controllers/attempts.go create mode 100644 controllers/attempts/config_filter.yaml create mode 100644 controllers/attempts/config_list.yaml create mode 100644 controllers/questions.go create mode 100644 controllers/questions/config_filter.yaml create mode 100644 controllers/questions/config_form.yaml create mode 100644 controllers/questions/config_list.yaml create mode 100644 controllers/quizzes.go create mode 100644 controllers/quizzes/config_filter.yaml create mode 100644 controllers/quizzes/config_form.yaml create mode 100644 controllers/quizzes/config_list.yaml create mode 100644 models/answers/columns.yaml create mode 100644 models/answers/fields.yaml create mode 100644 models/attempts/columns.yaml create mode 100644 models/filters.go create mode 100644 models/questions/columns.yaml create mode 100644 models/questions/fields.yaml create mode 100644 models/quizzes/columns.yaml create mode 100644 models/quizzes/fields.yaml delete mode 100644 models/util.go diff --git a/admin_postgres_test.go b/admin_postgres_test.go new file mode 100644 index 0000000..fc32e1b --- /dev/null +++ b/admin_postgres_test.go @@ -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) + } +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..be602fb --- /dev/null +++ b/admin_test.go @@ -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) + } +} diff --git a/controllers/answers.go b/controllers/answers.go new file mode 100644 index 0000000..d9a3460 --- /dev/null +++ b/controllers/answers.go @@ -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{} } diff --git a/controllers/answers/config_filter.yaml b/controllers/answers/config_filter.yaml new file mode 100644 index 0000000..ba2ad3c --- /dev/null +++ b/controllers/answers/config_filter.yaml @@ -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 diff --git a/controllers/answers/config_form.yaml b/controllers/answers/config_form.yaml new file mode 100644 index 0000000..faa6640 --- /dev/null +++ b/controllers/answers/config_form.yaml @@ -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 diff --git a/controllers/answers/config_list.yaml b/controllers/answers/config_list.yaml new file mode 100644 index 0000000..8df8299 --- /dev/null +++ b/controllers/answers/config_list.yaml @@ -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 diff --git a/controllers/attempts.go b/controllers/attempts.go new file mode 100644 index 0000000..a77f90c --- /dev/null +++ b/controllers/attempts.go @@ -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{} } diff --git a/controllers/attempts/config_filter.yaml b/controllers/attempts/config_filter.yaml new file mode 100644 index 0000000..f237771 --- /dev/null +++ b/controllers/attempts/config_filter.yaml @@ -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 diff --git a/controllers/attempts/config_list.yaml b/controllers/attempts/config_list.yaml new file mode 100644 index 0000000..e1380aa --- /dev/null +++ b/controllers/attempts/config_list.yaml @@ -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 diff --git a/controllers/questions.go b/controllers/questions.go new file mode 100644 index 0000000..9ad3489 --- /dev/null +++ b/controllers/questions.go @@ -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{} } diff --git a/controllers/questions/config_filter.yaml b/controllers/questions/config_filter.yaml new file mode 100644 index 0000000..bd7b818 --- /dev/null +++ b/controllers/questions/config_filter.yaml @@ -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 diff --git a/controllers/questions/config_form.yaml b/controllers/questions/config_form.yaml new file mode 100644 index 0000000..158691c --- /dev/null +++ b/controllers/questions/config_form.yaml @@ -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 diff --git a/controllers/questions/config_list.yaml b/controllers/questions/config_list.yaml new file mode 100644 index 0000000..8031970 --- /dev/null +++ b/controllers/questions/config_list.yaml @@ -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 diff --git a/controllers/quizzes.go b/controllers/quizzes.go new file mode 100644 index 0000000..dd0798e --- /dev/null +++ b/controllers/quizzes.go @@ -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{} } diff --git a/controllers/quizzes/config_filter.yaml b/controllers/quizzes/config_filter.yaml new file mode 100644 index 0000000..93728ff --- /dev/null +++ b/controllers/quizzes/config_filter.yaml @@ -0,0 +1,5 @@ +scopes: + is_published: + label: golem15.quizzes::lang.quizzes.is_published + type: switch + column: is_published diff --git a/controllers/quizzes/config_form.yaml b/controllers/quizzes/config_form.yaml new file mode 100644 index 0000000..cd9dbe5 --- /dev/null +++ b/controllers/quizzes/config_form.yaml @@ -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 diff --git a/controllers/quizzes/config_list.yaml b/controllers/quizzes/config_list.yaml new file mode 100644 index 0000000..86920d0 --- /dev/null +++ b/controllers/quizzes/config_list.yaml @@ -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 diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 646ae29..5dcaede 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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 (%) diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 37c1428..65ec15b 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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 (%) diff --git a/models/answers/columns.yaml b/models/answers/columns.yaml new file mode 100644 index 0000000..9e9c434 --- /dev/null +++ b/models/answers/columns.yaml @@ -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 diff --git a/models/answers/fields.yaml b/models/answers/fields.yaml new file mode 100644 index 0000000..f61ce74 --- /dev/null +++ b/models/answers/fields.yaml @@ -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 diff --git a/models/attempt.go b/models/attempt.go index 3560e57..f96d2b1 100644 --- a/models/attempt.go +++ b/models/attempt.go @@ -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. diff --git a/models/attempts/columns.yaml b/models/attempts/columns.yaml new file mode 100644 index 0000000..4c2859c --- /dev/null +++ b/models/attempts/columns.yaml @@ -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 diff --git a/models/filters.go b/models/filters.go new file mode 100644 index 0000000..62b5253 --- /dev/null +++ b/models/filters.go @@ -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_". +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 +} diff --git a/models/question.go b/models/question.go index d05ac90..c8a3272 100644 --- a/models/question.go +++ b/models/question.go @@ -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 -} diff --git a/models/questions/columns.yaml b/models/questions/columns.yaml new file mode 100644 index 0000000..529702f --- /dev/null +++ b/models/questions/columns.yaml @@ -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 diff --git a/models/questions/fields.yaml b/models/questions/fields.yaml new file mode 100644 index 0000000..a7a990b --- /dev/null +++ b/models/questions/fields.yaml @@ -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 diff --git a/models/quizzes/columns.yaml b/models/quizzes/columns.yaml new file mode 100644 index 0000000..ef3bd18 --- /dev/null +++ b/models/quizzes/columns.yaml @@ -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 diff --git a/models/quizzes/fields.yaml b/models/quizzes/fields.yaml new file mode 100644 index 0000000..4dcf2e2 --- /dev/null +++ b/models/quizzes/fields.yaml @@ -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 diff --git a/models/util.go b/models/util.go deleted file mode 100644 index 585b7ed..0000000 --- a/models/util.go +++ /dev/null @@ -1,5 +0,0 @@ -package models - -import "strconv" - -func uintString(n uint) string { return strconv.FormatUint(uint64(n), 10) } diff --git a/plugin.go b/plugin.go index cbf4fbc..73f60fb 100644 --- a/plugin.go +++ b/plugin.go @@ -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{}) } diff --git a/registry.gen.go b/registry.gen.go index c0cc1c8..a126a5e 100644 --- a/registry.gen.go +++ b/registry.gen.go @@ -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(), + } }