feat: add admin screens, permissions and navigation

Seven admin controllers in a "Studia BM" navigation group, guarded by
jz.bm.access_courses, jz.bm.access_schedule and jz.bm.access_materials,
with Polish and English labels. Date and time fields are checked by the
models before a save and answered as 422 on the field.
This commit is contained in:
Jakub Zych
2026-10-02 14:26:26 +02:00
parent 1046237771
commit 577fa14d3d
42 changed files with 1234 additions and 11 deletions

35
controllers/admin_test.go Normal file
View File

@@ -0,0 +1,35 @@
package controllers
import (
"context"
"errors"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
func TestTemporalHooksAnswerOnTheField(t *testing.T) {
var hooks temporalHooks
bad := &models.ClassSession{StartsAt: "2026-10-03 08:00", EndsAt: "wtorek"}
for name, fn := range map[string]func(context.Context, any) error{
"create": hooks.FormBeforeCreate,
"update": hooks.FormBeforeUpdate,
} {
err := fn(context.Background(), bad)
var ve *cabana.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("%s: err = %v, want a cabana.ValidationError", name, err)
}
if _, ok := ve.Details["ends_at"]; !ok {
t.Errorf("%s: details %v name no ends_at", name, ve.Details)
}
}
good := &models.ClassSession{StartsAt: "2026-10-03 08:00", EndsAt: "2026-10-03 09:30"}
if err := hooks.FormBeforeCreate(context.Background(), good); err != nil {
t.Errorf("valid session: %v", err)
}
if err := hooks.FormBeforeUpdate(context.Background(), &models.Tag{}); err != nil {
t.Errorf("model without dates: %v", err)
}
}

View File

@@ -0,0 +1,47 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
var (
_ pact.AdminController = classSessionsAdmin{}
_ pact.AdminRecordSource = classSessionsAdmin{}
_ pact.AdminPermissioned = classSessionsAdmin{}
)
// classSessionsAdmin is the admin controller for class sessions (zajęcia).
type classSessionsAdmin struct{ temporalHooks }
func (classSessionsAdmin) ID() string { return "jz.bm.class_sessions" }
func (classSessionsAdmin) ModelName() string { return "ClassSession" }
func (classSessionsAdmin) ConfigDir() string { return "controllers/class_sessions" }
// NewRecord returns the model the generic admin handlers query and fill.
func (classSessionsAdmin) NewRecord() any { return &models.ClassSession{} }
// RequiredPermissions is checked before any schema or record is served.
func (classSessionsAdmin) RequiredPermissions() []string {
return []string{PermSchedule}
}
// AdminFieldRelations binds the course and meeting pickers.
func (classSessionsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{
courseRelation("course_id"),
{
Field: "meeting",
Kind: "belongsTo",
NewRelated: func() any { return &models.Meeting{} },
ForeignKey: "meeting_id",
LabelColumn: "number",
},
}
}
// ClassSessionsController returns the jz.bm.class_sessions admin controller.
func ClassSessionsController() pact.AdminController { return classSessionsAdmin{} }

View File

@@ -0,0 +1,12 @@
name: jz.bm::lang.sessions.form
form: ~/plugins/jz/bm/models/class_sessions/fields.yaml
modelClass: ClassSession
defaultRedirect: jz/bm/class_sessions
create:
redirect: jz/bm/class_sessions/update/:id
redirectClose: jz/bm/class_sessions
update:
redirect: jz/bm/class_sessions
redirectClose: jz/bm/class_sessions

View File

@@ -0,0 +1,15 @@
list: ~/plugins/jz/bm/models/class_sessions/columns.yaml
modelClass: ClassSession
title: jz.bm::lang.sessions.title
recordUrl: jz/bm/class_sessions/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: starts_at
direction: asc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

38
controllers/courses.go Normal file
View File

@@ -0,0 +1,38 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
var (
_ pact.AdminController = coursesAdmin{}
_ pact.AdminRecordSource = coursesAdmin{}
_ pact.AdminPermissioned = coursesAdmin{}
)
// coursesAdmin is the admin controller for courses (przedmioty).
type coursesAdmin struct{}
func (coursesAdmin) ID() string { return "jz.bm.courses" }
func (coursesAdmin) ModelName() string { return "Course" }
func (coursesAdmin) ConfigDir() string { return "controllers/courses" }
// NewRecord returns the model the generic admin handlers query and fill.
func (coursesAdmin) NewRecord() any { return &models.Course{} }
// RequiredPermissions is checked before any schema or record is served.
func (coursesAdmin) RequiredPermissions() []string {
return []string{PermCourses}
}
// TODO: requires SummerCMS change. The course form should embed relation
// managers for its readings and class sessions, but cabana relation managers
// only link and unlink belongsToMany rows through a pivot; hasMany children
// cannot be listed or edited from the parent form. Readings and class
// sessions have their own screens, filtered by course.
// CoursesController returns the jz.bm.courses admin controller.
func CoursesController() pact.AdminController { return coursesAdmin{} }

View File

@@ -0,0 +1,12 @@
name: jz.bm::lang.courses.form
form: ~/plugins/jz/bm/models/courses/fields.yaml
modelClass: Course
defaultRedirect: jz/bm/courses
create:
redirect: jz/bm/courses/update/:id
redirectClose: jz/bm/courses
update:
redirect: jz/bm/courses
redirectClose: jz/bm/courses

View File

@@ -0,0 +1,15 @@
list: ~/plugins/jz/bm/models/courses/columns.yaml
modelClass: Course
title: jz.bm::lang.courses.title
recordUrl: jz/bm/courses/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: sort_order
direction: asc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

38
controllers/deadlines.go Normal file
View File

@@ -0,0 +1,38 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
var (
_ pact.AdminController = deadlinesAdmin{}
_ pact.AdminRecordSource = deadlinesAdmin{}
_ pact.AdminPermissioned = deadlinesAdmin{}
)
// deadlinesAdmin is the admin controller for deadlines (terminarz).
type deadlinesAdmin struct{ temporalHooks }
func (deadlinesAdmin) ID() string { return "jz.bm.deadlines" }
func (deadlinesAdmin) ModelName() string { return "Deadline" }
func (deadlinesAdmin) ConfigDir() string { return "controllers/deadlines" }
// NewRecord returns the model the generic admin handlers query and fill.
func (deadlinesAdmin) NewRecord() any { return &models.Deadline{} }
// RequiredPermissions is checked before any schema or record is served.
func (deadlinesAdmin) RequiredPermissions() []string {
return []string{PermSchedule}
}
// AdminFieldRelations binds the course picker.
func (deadlinesAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{courseRelation("course_id")}
}
// DeadlinesController returns the jz.bm.deadlines admin controller.
func DeadlinesController() pact.AdminController { return deadlinesAdmin{} }

View File

@@ -0,0 +1,12 @@
name: jz.bm::lang.deadlines.form
form: ~/plugins/jz/bm/models/deadlines/fields.yaml
modelClass: Deadline
defaultRedirect: jz/bm/deadlines
create:
redirect: jz/bm/deadlines/update/:id
redirectClose: jz/bm/deadlines
update:
redirect: jz/bm/deadlines
redirectClose: jz/bm/deadlines

View File

@@ -0,0 +1,15 @@
list: ~/plugins/jz/bm/models/deadlines/columns.yaml
modelClass: Deadline
title: jz.bm::lang.deadlines.title
recordUrl: jz/bm/deadlines/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: due_at
direction: asc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

55
controllers/hooks.go Normal file
View File

@@ -0,0 +1,55 @@
package controllers
import (
"context"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
// Permission codes of the plugin's admin screens.
const (
PermCourses = "jz.bm.access_courses"
PermSchedule = "jz.bm.access_schedule"
PermMaterials = "jz.bm.access_materials"
)
// temporalHooks checks a model's Date and DateTime columns before every admin
// save and answers a bad value as a 422 on its field. lagoon.Validate has no
// date rules, so the model checks them itself (models.TemporalValidator).
type temporalHooks struct{}
func (temporalHooks) FormBeforeCreate(_ context.Context, model any) error {
return checkTemporal(model)
}
func (temporalHooks) FormBeforeUpdate(_ context.Context, model any) error {
return checkTemporal(model)
}
func checkTemporal(model any) error {
v, ok := model.(models.TemporalValidator)
if !ok {
return nil
}
msgs := v.TemporalErrors()
if len(msgs) == 0 {
return nil
}
details := make(map[string]any, len(msgs))
for field, m := range msgs {
details[field] = m
}
return &cabana.ValidationError{Details: details}
}
// courseRelation is the belongsTo picker of a course_id column.
func courseRelation(foreignKey string) cabana.FieldRelationContract {
return cabana.FieldRelationContract{
Field: "course",
Kind: "belongsTo",
NewRelated: func() any { return &models.Course{} },
ForeignKey: foreignKey,
LabelColumn: "name",
}
}

53
controllers/materials.go Normal file
View File

@@ -0,0 +1,53 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
var (
_ pact.AdminController = materialsAdmin{}
_ pact.AdminRecordSource = materialsAdmin{}
_ pact.AdminPermissioned = materialsAdmin{}
)
// materialsAdmin is the admin controller for materials.
type materialsAdmin struct{ temporalHooks }
func (materialsAdmin) ID() string { return "jz.bm.materials" }
func (materialsAdmin) ModelName() string { return "Material" }
func (materialsAdmin) ConfigDir() string { return "controllers/materials" }
// NewRecord returns the model the generic admin handlers query and fill.
func (materialsAdmin) NewRecord() any { return &models.Material{} }
// RequiredPermissions is checked before any schema or record is served.
func (materialsAdmin) RequiredPermissions() []string {
return []string{PermMaterials}
}
// AdminFieldRelations binds the course picker and the tags.
//
// TODO: requires SummerCMS change. Materials carry their files as
// system_files attachments (field "files"), but cabana has no file upload
// form widget, so files are attached outside the admin form for now.
func (materialsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{
courseRelation("course_id"),
{
Field: "tags",
Kind: "belongsToMany",
NewRelated: func() any { return &models.Tag{} },
NewPivot: func() any { return &models.MaterialTag{} },
ParentForeignKey: "material_id",
RelatedForeignKey: "tag_id",
LabelColumn: "name",
},
}
}
// MaterialsController returns the jz.bm.materials admin controller.
func MaterialsController() pact.AdminController { return materialsAdmin{} }

View File

@@ -0,0 +1,12 @@
name: jz.bm::lang.materials.form
form: ~/plugins/jz/bm/models/materials/fields.yaml
modelClass: Material
defaultRedirect: jz/bm/materials
create:
redirect: jz/bm/materials/update/:id
redirectClose: jz/bm/materials
update:
redirect: jz/bm/materials
redirectClose: jz/bm/materials

View File

@@ -0,0 +1,15 @@
list: ~/plugins/jz/bm/models/materials/columns.yaml
modelClass: Material
title: jz.bm::lang.materials.title
recordUrl: jz/bm/materials/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: created_at
direction: desc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

32
controllers/meetings.go Normal file
View File

@@ -0,0 +1,32 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
var (
_ pact.AdminController = meetingsAdmin{}
_ pact.AdminRecordSource = meetingsAdmin{}
_ pact.AdminPermissioned = meetingsAdmin{}
)
// meetingsAdmin is the admin controller for meetings (zjazdy).
type meetingsAdmin struct{ temporalHooks }
func (meetingsAdmin) ID() string { return "jz.bm.meetings" }
func (meetingsAdmin) ModelName() string { return "Meeting" }
func (meetingsAdmin) ConfigDir() string { return "controllers/meetings" }
// NewRecord returns the model the generic admin handlers query and fill.
func (meetingsAdmin) NewRecord() any { return &models.Meeting{} }
// RequiredPermissions is checked before any schema or record is served.
func (meetingsAdmin) RequiredPermissions() []string {
return []string{PermSchedule}
}
// MeetingsController returns the jz.bm.meetings admin controller.
func MeetingsController() pact.AdminController { return meetingsAdmin{} }

View File

@@ -0,0 +1,12 @@
name: jz.bm::lang.meetings.form
form: ~/plugins/jz/bm/models/meetings/fields.yaml
modelClass: Meeting
defaultRedirect: jz/bm/meetings
create:
redirect: jz/bm/meetings/update/:id
redirectClose: jz/bm/meetings
update:
redirect: jz/bm/meetings
redirectClose: jz/bm/meetings

View File

@@ -0,0 +1,15 @@
list: ~/plugins/jz/bm/models/meetings/columns.yaml
modelClass: Meeting
title: jz.bm::lang.meetings.title
recordUrl: jz/bm/meetings/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: number
direction: asc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

38
controllers/readings.go Normal file
View File

@@ -0,0 +1,38 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
var (
_ pact.AdminController = readingsAdmin{}
_ pact.AdminRecordSource = readingsAdmin{}
_ pact.AdminPermissioned = readingsAdmin{}
)
// readingsAdmin is the admin controller for readings (lektury).
type readingsAdmin struct{}
func (readingsAdmin) ID() string { return "jz.bm.readings" }
func (readingsAdmin) ModelName() string { return "Reading" }
func (readingsAdmin) ConfigDir() string { return "controllers/readings" }
// NewRecord returns the model the generic admin handlers query and fill.
func (readingsAdmin) NewRecord() any { return &models.Reading{} }
// RequiredPermissions is checked before any schema or record is served.
func (readingsAdmin) RequiredPermissions() []string {
return []string{PermCourses}
}
// AdminFieldRelations binds the course picker.
func (readingsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{courseRelation("course_id")}
}
// ReadingsController returns the jz.bm.readings admin controller.
func ReadingsController() pact.AdminController { return readingsAdmin{} }

View File

@@ -0,0 +1,12 @@
name: jz.bm::lang.readings.form
form: ~/plugins/jz/bm/models/readings/fields.yaml
modelClass: Reading
defaultRedirect: jz/bm/readings
create:
redirect: jz/bm/readings/update/:id
redirectClose: jz/bm/readings
update:
redirect: jz/bm/readings
redirectClose: jz/bm/readings

View File

@@ -0,0 +1,15 @@
list: ~/plugins/jz/bm/models/readings/columns.yaml
modelClass: Reading
title: jz.bm::lang.readings.title
recordUrl: jz/bm/readings/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: sort_order
direction: asc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

32
controllers/tags.go Normal file
View File

@@ -0,0 +1,32 @@
// Code generated by summer make. DO NOT EDIT.
package controllers
import (
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
var (
_ pact.AdminController = tagsAdmin{}
_ pact.AdminRecordSource = tagsAdmin{}
_ pact.AdminPermissioned = tagsAdmin{}
)
// tagsAdmin is the admin controller for material tags.
type tagsAdmin struct{}
func (tagsAdmin) ID() string { return "jz.bm.tags" }
func (tagsAdmin) ModelName() string { return "Tag" }
func (tagsAdmin) ConfigDir() string { return "controllers/tags" }
// NewRecord returns the model the generic admin handlers query and fill.
func (tagsAdmin) NewRecord() any { return &models.Tag{} }
// RequiredPermissions is checked before any schema or record is served.
func (tagsAdmin) RequiredPermissions() []string {
return []string{PermMaterials}
}
// TagsController returns the jz.bm.tags admin controller.
func TagsController() pact.AdminController { return tagsAdmin{} }

View File

@@ -0,0 +1,12 @@
name: jz.bm::lang.tags.form
form: ~/plugins/jz/bm/models/tags/fields.yaml
modelClass: Tag
defaultRedirect: jz/bm/tags
create:
redirect: jz/bm/tags/update/:id
redirectClose: jz/bm/tags
update:
redirect: jz/bm/tags
redirectClose: jz/bm/tags

View File

@@ -0,0 +1,15 @@
list: ~/plugins/jz/bm/models/tags/columns.yaml
modelClass: Tag
title: jz.bm::lang.tags.title
recordUrl: jz/bm/tags/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: name
direction: asc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt