- every revision bump (API writes and /bmadmin saves) enqueues
updated.jz.bm.material with {material_id, revision} in a savepoint
- the materials namespace authorizer allows a subscribe only to a
visible material the user may manage
- api.ManagesMaterials and api.CanWatchMaterial
274 lines
10 KiB
Go
274 lines
10 KiB
Go
package bm
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"git.golem15.com/golem15/summercms/modules/conga"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"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"
|
|
"gocloud.dev/blob"
|
|
"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)
|
|
_ pact.HasSchedule = (*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
|
|
// watchMaterial is the materials channel check of the realtime
|
|
// authorizer; nil means api.CanWatchMaterial at time.Now. Tests inject
|
|
// their own.
|
|
watchMaterial func(ctx context.Context, db *gorm.DB, userID, id uint) (bool, error)
|
|
// materialManager is the material-manager check of the realtime access
|
|
// guard; nil means api.ManagesMaterials. Tests inject their own.
|
|
materialManager func(ctx context.Context, db *gorm.DB, userID uint) (bool, error)
|
|
// 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() }
|
|
|
|
// Jobs returns the generated jobs and jz.bm.extract_material_text, which
|
|
// extracts the text of attached PDFs for search: three attempts, two
|
|
// minutes each.
|
|
func (p *Plugin) Jobs() []pact.Job {
|
|
timeout := conga.Timeout(2 * time.Minute)
|
|
return append(generatedJobs(), conga.Job(p.extractMaterialText, conga.MaxAttempts(3), timeout))
|
|
}
|
|
|
|
func (p *Plugin) AdminControllers() []pact.AdminController {
|
|
return generatedAdminControllers()
|
|
}
|
|
|
|
// Commands returns the generated commands, bm:seed, bm:chat-sync,
|
|
// bm:materials-reindex 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()
|
|
}))
|
|
cmds = append(cmds, console.MaterialsReindexCommand(console.MaterialsReindexDeps{DB: p.publishedDB, Index: p.materialIndex, Backfill: p.backfillFileTexts}))
|
|
return append(cmds, centrifugo.Commands(p.app)...)
|
|
}
|
|
|
|
// Schedule runs bm:materials-reindex every night (it exits 0 when search
|
|
// is not configured). The index drifts without it: a course rename or a
|
|
// meeting renumbering changes documents no material write syncs, a failed
|
|
// sync is only logged, and a Typesense restart can lose data (RESEARCH
|
|
// Pitfall 6).
|
|
func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
|
return []pact.ScheduledCommand{
|
|
{Command: console.MaterialsReindexName, Args: []string{"--if-configured"}, Cadence: pact.DailyAt(4, 15)},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// publishedDB returns the application's database published on the app: the
|
|
// one serve published or, when a console command runs, one opened from the
|
|
// config and published, because beachcomber syncs nothing without a
|
|
// published database. done closes a pool this call opened and is a no-op
|
|
// otherwise.
|
|
func (p *Plugin) publishedDB(ctx context.Context) (*gorm.DB, func(), error) {
|
|
if p.app == nil {
|
|
return nil, nil, errors.New("jz.bm: app is nil")
|
|
}
|
|
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
|
|
return gdb, func() {}, nil
|
|
}
|
|
if lagoon.DSN(p.app.Config) == "" {
|
|
return nil, nil, errors.New("jz.bm: database is not configured (set SUMMER_DATABASE__DSN)")
|
|
}
|
|
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := lagoon.Publish(p.app, sqlDB, gdb); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, nil, err
|
|
}
|
|
return gdb, func() { _ = sqlDB.Close() }, nil
|
|
}
|
|
|
|
// backfillFileTexts extracts the text of material id's PDFs that have none
|
|
// yet, for bm:materials-reindex (D-04: PDFs attached before file search).
|
|
func (p *Plugin) backfillFileTexts(ctx context.Context, db *gorm.DB, id uint) error {
|
|
bucket, err := p.uploadsBucket(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = classes.EnsureFileTexts(ctx, db, bucket, classes.DefaultPDFText(), id)
|
|
return err
|
|
}
|
|
|
|
// uploadsBucket returns the uploads bucket published on the app: the one
|
|
// serve published or, when a console command runs, one opened from
|
|
// storage.uploads.bucket_url and published. It is an error when no bucket
|
|
// is configured.
|
|
func (p *Plugin) uploadsBucket(ctx context.Context) (*blob.Bucket, error) {
|
|
if p.app == nil {
|
|
return nil, errors.New("jz.bm: app is nil")
|
|
}
|
|
if b, ok := p.app.Lookup[*blob.Bucket](); ok && b != nil {
|
|
return b, nil
|
|
}
|
|
b, err := attach.OpenBucket(ctx, p.app.Config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jz.bm: the uploads bucket is not configured: %w", err)
|
|
}
|
|
if err := attach.Publish(p.app, b); err != nil {
|
|
_ = b.Close()
|
|
if pub, ok := p.app.Lookup[*blob.Bucket](); ok && pub != nil {
|
|
return pub, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func init() {
|
|
party.Register(&Plugin{})
|
|
}
|