feat: add quiz, question, answer and attempt models with migrations

This commit is contained in:
Jakub Zych
2026-10-02 14:04:36 +02:00
parent a01a75d49b
commit ed167d8450
10 changed files with 683 additions and 5 deletions

36
models/answer.go Normal file
View File

@@ -0,0 +1,36 @@
// Code generated by summer make. DO NOT EDIT.
package models
import "time"
// Answer is one choice of a single or multiple question.
type Answer struct {
ID uint `gorm:"column:id;primaryKey"`
QuestionID uint `gorm:"column:question_id"`
Text string `gorm:"column:text"`
IsCorrect bool `gorm:"column:is_correct"`
Explanation *string `gorm:"column:explanation"`
SortOrder int `gorm:"column:sort_order"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
Question *Question `gorm:"foreignKey:QuestionID"`
}
// TableName is the plugin-prefixed table.
func (Answer) TableName() string { return "golem15_quizzes_answers" }
// Fillable lists the columns mass assignment may set.
func (Answer) Fillable() []string {
return []string{"question_id", "text", "is_correct", "explanation", "sort_order"}
}
// Rules are checked by the admin API on every save.
func (Answer) Rules() map[string]string {
return map[string]string{
"question_id": "required|integer|min:1",
"text": "required",
"is_correct": "boolean",
"sort_order": "nullable|integer",
}
}

28
models/attempt.go Normal file
View File

@@ -0,0 +1,28 @@
// Code generated by summer make. DO NOT EDIT.
package models
import "time"
// Attempt is one user's run through a quiz. 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"`
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"`
}
// TableName is the plugin-prefixed table.
func (Attempt) TableName() string { return "golem15_quizzes_attempts" }
// Fillable is empty: attempts are written by the API, never by mass
// assignment.
func (Attempt) Fillable() []string { return []string{} }

30
models/attempt_answer.go Normal file
View File

@@ -0,0 +1,30 @@
// Code generated by summer make. DO NOT EDIT.
package models
import (
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// AttemptAnswer is the user's answer to one question of an attempt. There is
// one row per attempt and question; answering again overwrites it.
type AttemptAnswer struct {
ID uint `gorm:"column:id;primaryKey"`
AttemptID uint `gorm:"column:attempt_id"`
QuestionID uint `gorm:"column:question_id"`
AnswerIDs lagoon.Jsonable[[]uint] `gorm:"column:answer_ids"`
SelfCheckKnown *bool `gorm:"column:self_check_known"`
IsCorrect bool `gorm:"column:is_correct"`
Points float64 `gorm:"column:points"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
// TableName is the plugin-prefixed table.
func (AttemptAnswer) TableName() string { return "golem15_quizzes_attempt_answers" }
// Fillable is empty: answers are written by the API, never by mass
// assignment.
func (AttemptAnswer) Fillable() []string { return []string{} }

146
models/question.go Normal file
View File

@@ -0,0 +1,146 @@
// Code generated by summer make. DO NOT EDIT.
package models
import (
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Question types.
const (
TypeSingle = "single"
TypeMultiple = "multiple"
TypeSelfCheck = "self_check"
)
// QuestionTypes lists the valid values of Question.Type.
var QuestionTypes = []string{TypeSingle, TypeMultiple, TypeSelfCheck}
// Question is one question of a quiz. A self_check question is a flashcard:
// the user reveals AnswerText and marks whether they knew it.
type Question struct {
ID uint `gorm:"column:id;primaryKey"`
QuizID uint `gorm:"column:quiz_id"`
Text string `gorm:"column:text"`
Type string `gorm:"column:type"`
Category *string `gorm:"column:category"`
AnswerText *string `gorm:"column:answer_text"`
Explanation *string `gorm:"column:explanation"`
SortOrder int `gorm:"column:sort_order"`
Points int `gorm:"column:points"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
Quiz *Quiz `gorm:"foreignKey:QuizID"`
Answers []Answer `gorm:"foreignKey:QuestionID"`
}
// TableName is the plugin-prefixed table.
func (Question) TableName() string { return "golem15_quizzes_questions" }
// Fillable lists the columns mass assignment may set.
func (Question) Fillable() []string {
return []string{"quiz_id", "text", "type", "category", "answer_text", "explanation", "sort_order", "points"}
}
// Rules are checked by the admin API on every save.
func (Question) Rules() map[string]string {
return map[string]string{
"quiz_id": "required|integer|min:1",
"text": "required",
"type": "required|in:single,multiple,self_check",
"category": "nullable|max:255",
"sort_order": "nullable|integer",
"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
}

104
models/quiz.go Normal file
View File

@@ -0,0 +1,104 @@
// Code generated by summer make. DO NOT EDIT.
package models
import (
"strings"
"time"
"unicode"
"gorm.io/gorm"
)
// Quiz is a set of questions a signed-in user can attempt. Context is a
// free-form link to a record of another plugin, such as "acme.shop:course:12".
type Quiz struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Description *string `gorm:"column:description"`
Context *string `gorm:"column:context"`
IsPublished bool `gorm:"column:is_published"`
TimeLimitSeconds *int `gorm:"column:time_limit_seconds"`
PassingScore int `gorm:"column:passing_score"`
ShuffleQuestions bool `gorm:"column:shuffle_questions"`
RoundSize *int `gorm:"column:round_size"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
Questions []Question `gorm:"foreignKey:QuizID"`
}
// TableName is the plugin-prefixed table.
func (Quiz) TableName() string { return "golem15_quizzes_quizzes" }
// Fillable lists the columns mass assignment may set.
func (Quiz) Fillable() []string {
return []string{
"title", "slug", "description", "context", "is_published",
"time_limit_seconds", "passing_score", "shuffle_questions", "round_size",
}
}
// Rules are checked by the admin API on every save; unique ignores the quiz
// being updated.
func (Quiz) Rules() map[string]string {
return map[string]string{
"title": "required|max:255",
"slug": "required|max:255|unique:golem15_quizzes_quizzes",
"context": "nullable|max:255",
"passing_score": "required|integer|between:0,100",
"time_limit_seconds": "nullable|integer|min:1",
"round_size": "nullable|integer|min:1",
}
}
// BeforeValidate fills the slug from the title before the admin form
// validates, so the unique rule sees the final slug.
func (q *Quiz) BeforeValidate(*gorm.DB) error {
q.fillSlug()
return nil
}
// BeforeSave fills the slug from the title on every other write path.
func (q *Quiz) BeforeSave(*gorm.DB) error {
q.fillSlug()
return nil
}
func (q *Quiz) fillSlug() {
if strings.TrimSpace(q.Slug) == "" {
q.Slug = Slugify(q.Title)
} else {
q.Slug = Slugify(q.Slug)
}
}
var transliterations = map[rune]string{
'ą': "a", 'ć': "c", 'ę': "e", 'ł': "l", 'ń': "n", 'ó': "o", 'ś': "s", 'ź': "z", 'ż': "z",
'ä': "a", 'ö': "o", 'ü': "u", 'ß': "ss", 'á': "a", 'é': "e", 'í': "i", 'ú': "u",
'č': "c", 'ř': "r", 'š': "s", 'ž': "z", 'ě': "e", 'ý': "y", 'à': "a", 'è': "e",
}
// Slugify lowercases s, transliterates common Latin diacritics and joins the
// remaining letters and digits with single hyphens.
func Slugify(s string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
if t, ok := transliterations[r]; ok {
b.WriteString(t)
dash = false
continue
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
continue
}
if (unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r)) && b.Len() > 0 && !dash {
b.WriteByte('-')
dash = true
}
}
return strings.TrimSuffix(b.String(), "-")
}

5
models/util.go Normal file
View File

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