feat: add quiz, question, answer and attempt models with migrations
This commit is contained in:
36
models/answer.go
Normal file
36
models/answer.go
Normal 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
28
models/attempt.go
Normal 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
30
models/attempt_answer.go
Normal 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
146
models/question.go
Normal 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
104
models/quiz.go
Normal 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
5
models/util.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
import "strconv"
|
||||
|
||||
func uintString(n uint) string { return strconv.FormatUint(uint64(n), 10) }
|
||||
Reference in New Issue
Block a user