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 }