Files
sm-quizzes-plugin/models/filters.go

227 lines
5.9 KiB
Go

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
}