Files
sm-bm-plugin/plugin.go
Jakub Zych 4ffce6e6ac feat: search materials through beachcomber
- Material is a beachcomber Searchable (index jz_bm_materials) in the
  hand-written models/material_search.go; jz.bm requires summercms v0.6.4
- classes.MaterialIndexer asks Typesense for up to 250 candidates with
  body highlights; the app-wide Gate is on only for jz.bm-marked contexts
- GET /_bm/api/v1/materials takes q: candidates are re-gated, counted and
  paged in SQL in rank order, each row with at most one plain snippet
- search failure answers 503 Search is unavailable.; without q the list
  is unchanged
2026-11-08 19:45:46 +01:00

178 lines
6.8 KiB
Go

package bm
import (
"context"
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/jakub/sm-bm-plugin/classes"
"git.golem15.com/jakub/sm-bm-plugin/console"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var (
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
_ pact.HasMailTemplates = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
_ pact.HasJobs = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
_ pact.HasMiddleware = (*Plugin)(nil)
)
//go:embed config
var configFS embed.FS
//go:embed lang
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 jz.bm plugin: the study dashboard backend of the BM
// (Bezpieczeństwo międzynarodowe) master's programme.
type Plugin struct {
app *backpack.App
// publicFS is the tree of the student app. Nil means the embedded
// public/ directory; tests plant their own.
publicFS fs.FS
// realtime is the app's lighthouse service, built at Boot. Nil when
// Boot did not run, as in tests that build routes on a bare plugin.
realtime *lighthouse.Service
// chatMember is the chat-group check of the realtime access guard; nil
// means classes.IsChatMember. Tests inject their own.
chatMember classes.MemberFunc
// materialIndex searches the materials index and keeps it current. Nil
// when Boot did not run: then search answers 503 and syncs are skipped.
materialIndex *classes.MaterialIndexer
}
func (p *Plugin) ID() string { return "jz.bm" }
// Requires golem15.user: its users table holds the students, and its
// jwt.auth and user.api_token middleware guard the APIs.
func (p *Plugin) Requires() []string { return []string{"golem15.user"} }
func (p *Plugin) Register(app *backpack.App) error {
p.app = app
return nil
}
// Boot keeps the application, so route handlers and commands can reach its
// services, such as the database, when they run, and wires the realtime
// service, the materials search and, when golem15.chat is active, the BM
// chat context. The
// management API uses the shared user.api_token middleware owned by
// golem15.user.
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
if app != nil {
if err := p.wireRealtime(app); err != nil {
return err
}
if err := p.wireSearch(app); err != nil {
return err
}
if err := p.registerChat(app); err != nil {
return err
}
}
return nil
}
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
func (p *Plugin) MailTemplates() []string { return nil }
func (p *Plugin) MailLayouts() map[string]string { return nil }
func (p *Plugin) Models() []any { return generatedModels() }
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}
// Commands returns the generated commands, bm:seed, bm:chat-sync and
// websockets:health. API tokens are managed application-wide by
// golem15.user.
func (p *Plugin) Commands() []bonfire.Command {
cmds := append(generatedCommands(), console.SeedCommand(p.withDB))
cmds = append(cmds, console.ChatSyncCommand(p.withDB, func() (string, string) {
return p.chatGroup(), p.chatModeratorGroup()
}))
return append(cmds, centrifugo.Commands(p.app)...)
}
// AdminFS is the admin YAML the controllers read.
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// Permissions replaces registerPermissions().
func (p *Plugin) Permissions() []pact.Permission {
tab := "jz.bm::lang.plugin.name"
return []pact.Permission{
{Code: controllers.PermCourses, Tab: tab, Label: "jz.bm::lang.permissions.access_courses"},
{Code: controllers.PermSchedule, Tab: tab, Label: "jz.bm::lang.permissions.access_schedule"},
{Code: controllers.PermMaterials, Tab: tab, Label: "jz.bm::lang.permissions.access_materials"},
}
}
// Navigation replaces registerNavigation(): one "Studia BM" group with a
// side item per screen.
func (p *Plugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "bm",
Label: "jz.bm::lang.plugin.name",
Icon: "icon-graduation-cap",
Permissions: []string{controllers.PermCourses, controllers.PermSchedule, controllers.PermMaterials},
Order: 300,
Controller: "jz.bm.courses",
SideMenu: []pact.NavigationItem{
{Code: "courses", Label: "jz.bm::lang.navigation.courses", Icon: "icon-book", Permissions: []string{controllers.PermCourses}, Controller: "jz.bm.courses"},
{Code: "readings", Label: "jz.bm::lang.navigation.readings", Icon: "icon-bookmark", Permissions: []string{controllers.PermCourses}, Controller: "jz.bm.readings"},
{Code: "meetings", Label: "jz.bm::lang.navigation.meetings", Icon: "icon-calendar", Permissions: []string{controllers.PermSchedule}, Controller: "jz.bm.meetings"},
{Code: "sessions", Label: "jz.bm::lang.navigation.sessions", Icon: "icon-clock-o", Permissions: []string{controllers.PermSchedule}, Controller: "jz.bm.class_sessions"},
{Code: "deadlines", Label: "jz.bm::lang.navigation.deadlines", Icon: "icon-flag", Permissions: []string{controllers.PermSchedule}, Controller: "jz.bm.deadlines"},
{Code: "materials", Label: "jz.bm::lang.navigation.materials", Icon: "icon-file-text-o", Permissions: []string{controllers.PermMaterials}, Controller: "jz.bm.materials"},
{Code: "tags", Label: "jz.bm::lang.navigation.tags", Icon: "icon-tags", Permissions: []string{controllers.PermMaterials}, Controller: "jz.bm.tags"},
},
}}
}
// withDB runs fn with the application's database: the one the serve command
// published, or, when a console command runs, one opened from the config for
// the duration of fn.
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
return fn(gdb)
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
if err != nil {
return err
}
defer sqlDB.Close()
return fn(gdb)
}
func init() {
party.Register(&Plugin{})
}