feat: add admin controllers, permissions and navigation for quizzes and attempts
This commit is contained in:
158
admin_postgres_test.go
Normal file
158
admin_postgres_test.go
Normal 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
68
admin_test.go
Normal 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
46
controllers/answers.go
Normal 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{} }
|
||||
10
controllers/answers/config_filter.yaml
Normal file
10
controllers/answers/config_filter.yaml
Normal 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
|
||||
12
controllers/answers/config_form.yaml
Normal file
12
controllers/answers/config_form.yaml
Normal 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
|
||||
15
controllers/answers/config_list.yaml
Normal file
15
controllers/answers/config_list.yaml
Normal 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
33
controllers/attempts.go
Normal 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{} }
|
||||
10
controllers/attempts/config_filter.yaml
Normal file
10
controllers/attempts/config_filter.yaml
Normal 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
|
||||
13
controllers/attempts/config_list.yaml
Normal file
13
controllers/attempts/config_list.yaml
Normal 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
46
controllers/questions.go
Normal 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{} }
|
||||
16
controllers/questions/config_filter.yaml
Normal file
16
controllers/questions/config_filter.yaml
Normal 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
|
||||
12
controllers/questions/config_form.yaml
Normal file
12
controllers/questions/config_form.yaml
Normal 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
|
||||
15
controllers/questions/config_list.yaml
Normal file
15
controllers/questions/config_list.yaml
Normal 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
37
controllers/quizzes.go
Normal 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{} }
|
||||
5
controllers/quizzes/config_filter.yaml
Normal file
5
controllers/quizzes/config_filter.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
scopes:
|
||||
is_published:
|
||||
label: golem15.quizzes::lang.quizzes.is_published
|
||||
type: switch
|
||||
column: is_published
|
||||
12
controllers/quizzes/config_form.yaml
Normal file
12
controllers/quizzes/config_form.yaml
Normal 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
|
||||
14
controllers/quizzes/config_list.yaml
Normal file
14
controllers/quizzes/config_list.yaml
Normal 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
|
||||
@@ -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 (%)
|
||||
|
||||
@@ -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 (%)
|
||||
|
||||
14
models/answers/columns.yaml
Normal file
14
models/answers/columns.yaml
Normal 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
|
||||
23
models/answers/fields.yaml
Normal file
23
models/answers/fields.yaml
Normal 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
|
||||
@@ -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.
|
||||
|
||||
25
models/attempts/columns.yaml
Normal file
25
models/attempts/columns.yaml
Normal 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
226
models/filters.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
18
models/questions/columns.yaml
Normal file
18
models/questions/columns.yaml
Normal 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
|
||||
45
models/questions/fields.yaml
Normal file
45
models/questions/fields.yaml
Normal 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
|
||||
16
models/quizzes/columns.yaml
Normal file
16
models/quizzes/columns.yaml
Normal 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
|
||||
44
models/quizzes/fields.yaml
Normal file
44
models/quizzes/fields.yaml
Normal 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
|
||||
@@ -1,5 +0,0 @@
|
||||
package models
|
||||
|
||||
import "strconv"
|
||||
|
||||
func uintString(n uint) string { return strconv.FormatUint(uint64(n), 10) }
|
||||
46
plugin.go
46
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{})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user