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

View File

@@ -1 +1,97 @@
{}
plugin:
name: BM studies
description: Courses, timetable, deadlines, readings and materials of the BM master's programme.
permissions:
access_courses: Manage courses and readings
access_schedule: Manage meetings, class sessions and deadlines
access_materials: Manage materials and tags
navigation:
courses: Courses
readings: Readings
meetings: Meetings
sessions: Class sessions
deadlines: Deadlines
materials: Materials
tags: Tags
common:
title: Title
description: Description
notes: Notes
course: Course
source: Source
url: URL
sort_order: Sort order
created_at: Created
none: — none —
date_comment: A date in the YYYY-MM-DD form, such as 2026-10-03.
datetime_comment: Date and time in Polish time, such as 2026-10-03 08:00.
forms:
wyklad: Lecture
cwiczenia: Tutorial
konwersatorium: Conversatorium
seminarium: Seminar
inne: Other
courses:
title: Courses
form: Course
name: Name
form_type: Form
code: Code
lecturer: Lecturer
semester: Semester
ects: ECTS
assessment: Assessment
assessment_comment: Such as egzamin or zaliczenie na ocenę.
elective_group: Elective group
elective_group_comment: Courses with the same value are alternatives to choose from, such as specjalnosc.
syllabus_url: Syllabus URL
meetings:
title: Meetings
form: Meeting
number: Meeting number
starts_on: From
ends_on: To
sessions:
title: Class sessions
form: Class session
meeting: Meeting
starts_at: Starts
ends_at: Ends
room: Room
kind: Kind
is_online: Online
deadlines:
title: Deadlines
form: Deadline
due_at: Due
kind: Kind
deadline_kinds:
zaliczenie: Credit
egzamin: Exam
praca: Paper
organizacyjne: Administrative
inne: Other
readings:
title: Readings
form: Reading
author: Author
is_required: Required
materials:
title: Materials
form: Material
type: Type
body: Body (Markdown)
body_comment: A note in Markdown.
published_at: Published
published_at_comment: Leave empty for a draft that students do not see. Such as 2026-10-03 08:00.
tags: Tags
material_types:
pdf: PDF
notatka: Note
link: Link
inne: Other
tags:
title: Tags
form: Tag
name: Name
slug: Slug

97
lang/pl/lang.yaml Normal file
View File

@@ -0,0 +1,97 @@
plugin:
name: Studia BM
description: Przedmioty, plan zajęć, terminarz, lektury i materiały studiów magisterskich BM.
permissions:
access_courses: Zarządzanie przedmiotami i lekturami
access_schedule: Zarządzanie zjazdami, zajęciami i terminarzem
access_materials: Zarządzanie materiałami i tagami
navigation:
courses: Przedmioty
readings: Lektury
meetings: Zjazdy
sessions: Zajęcia
deadlines: Terminarz
materials: Materiały
tags: Tagi
common:
title: Tytuł
description: Opis
notes: Uwagi
course: Przedmiot
source: Źródło
url: Adres URL
sort_order: Kolejność
created_at: Utworzono
none: — brak —
date_comment: Data w formacie RRRR-MM-DD, np. 2026-10-03.
datetime_comment: Data i godzina czasu polskiego, np. 2026-10-03 08:00.
forms:
wyklad: Wykład
cwiczenia: Ćwiczenia
konwersatorium: Konwersatorium
seminarium: Seminarium
inne: Inne
courses:
title: Przedmioty
form: Przedmiot
name: Nazwa
form_type: Forma zajęć
code: Kod
lecturer: Prowadzący
semester: Semestr
ects: ECTS
assessment: Forma zaliczenia
assessment_comment: Np. egzamin, zaliczenie na ocenę.
elective_group: Grupa do wyboru
elective_group_comment: Przedmioty o tej samej wartości są alternatywami do wyboru, np. specjalnosc.
syllabus_url: Sylabus (URL)
meetings:
title: Zjazdy
form: Zjazd
number: Numer zjazdu
starts_on: Od
ends_on: Do
sessions:
title: Zajęcia
form: Zajęcia
meeting: Zjazd
starts_at: Początek
ends_at: Koniec
room: Sala
kind: Rodzaj
is_online: Zdalnie
deadlines:
title: Terminarz
form: Termin
due_at: Termin
kind: Rodzaj
deadline_kinds:
zaliczenie: Zaliczenie
egzamin: Egzamin
praca: Praca
organizacyjne: Organizacyjne
inne: Inne
readings:
title: Lektury
form: Lektura
author: Autor
is_required: Obowiązkowa
materials:
title: Materiały
form: Materiał
type: Typ
body: Treść (Markdown)
body_comment: Notatka w Markdownie.
published_at: Opublikowano
published_at_comment: Puste pole to szkic, niewidoczny dla studentów. Np. 2026-10-03 08:00.
tags: Tagi
material_types:
pdf: PDF
notatka: Notatka
link: Link
inne: Inne
tags:
title: Tagi
form: Tag
name: Nazwa
slug: Slug

View File

@@ -0,0 +1,16 @@
columns:
starts_at:
label: jz.bm::lang.sessions.starts_at
ends_at:
label: jz.bm::lang.sessions.ends_at
course:
label: jz.bm::lang.common.course
relation: course
select: name
searchable: true
room:
label: jz.bm::lang.sessions.room
searchable: true
is_online:
label: jz.bm::lang.sessions.is_online
type: switch

View File

@@ -0,0 +1,41 @@
fields:
course:
label: jz.bm::lang.common.course
type: relation
nameFrom: name
span: left
required: true
meeting:
label: jz.bm::lang.sessions.meeting
type: relation
nameFrom: number
span: right
emptyOption: jz.bm::lang.common.none
starts_at:
label: jz.bm::lang.sessions.starts_at
type: text
span: left
required: true
comment: jz.bm::lang.common.datetime_comment
ends_at:
label: jz.bm::lang.sessions.ends_at
type: text
span: right
required: true
comment: jz.bm::lang.common.datetime_comment
room:
label: jz.bm::lang.sessions.room
type: text
span: left
kind:
label: jz.bm::lang.sessions.kind
type: text
span: right
is_online:
label: jz.bm::lang.sessions.is_online
type: switch
span: left
notes:
label: jz.bm::lang.common.notes
type: textarea
size: small

View File

@@ -0,0 +1,17 @@
columns:
name:
label: jz.bm::lang.courses.name
searchable: true
form:
label: jz.bm::lang.courses.form_type
semester:
label: jz.bm::lang.courses.semester
lecturer:
label: jz.bm::lang.courses.lecturer
searchable: true
ects:
label: jz.bm::lang.courses.ects
elective_group:
label: jz.bm::lang.courses.elective_group
sort_order:
label: jz.bm::lang.common.sort_order

View File

@@ -0,0 +1,58 @@
fields:
name:
label: jz.bm::lang.courses.name
type: text
span: left
required: true
form:
label: jz.bm::lang.courses.form_type
type: dropdown
span: right
required: true
options:
wyklad: jz.bm::lang.forms.wyklad
cwiczenia: jz.bm::lang.forms.cwiczenia
konwersatorium: jz.bm::lang.forms.konwersatorium
seminarium: jz.bm::lang.forms.seminarium
inne: jz.bm::lang.forms.inne
semester:
label: jz.bm::lang.courses.semester
type: number
span: left
required: true
default: 1
code:
label: jz.bm::lang.courses.code
type: text
span: right
lecturer:
label: jz.bm::lang.courses.lecturer
type: text
span: left
ects:
label: jz.bm::lang.courses.ects
type: number
span: right
assessment:
label: jz.bm::lang.courses.assessment
type: text
span: left
comment: jz.bm::lang.courses.assessment_comment
elective_group:
label: jz.bm::lang.courses.elective_group
type: text
span: right
comment: jz.bm::lang.courses.elective_group_comment
sort_order:
label: jz.bm::lang.common.sort_order
type: number
span: left
default: 0
syllabus_url:
label: jz.bm::lang.courses.syllabus_url
type: text
span: right
description:
label: jz.bm::lang.common.description
type: textarea
size: large

View File

@@ -0,0 +1,12 @@
columns:
due_at:
label: jz.bm::lang.deadlines.due_at
title:
label: jz.bm::lang.common.title
searchable: true
kind:
label: jz.bm::lang.deadlines.kind
course:
label: jz.bm::lang.common.course
relation: course
select: name

View File

@@ -0,0 +1,33 @@
fields:
title:
label: jz.bm::lang.common.title
type: text
span: left
required: true
due_at:
label: jz.bm::lang.deadlines.due_at
type: text
span: right
required: true
comment: jz.bm::lang.common.datetime_comment
kind:
label: jz.bm::lang.deadlines.kind
type: dropdown
span: left
required: true
options:
zaliczenie: jz.bm::lang.deadline_kinds.zaliczenie
egzamin: jz.bm::lang.deadline_kinds.egzamin
praca: jz.bm::lang.deadline_kinds.praca
organizacyjne: jz.bm::lang.deadline_kinds.organizacyjne
inne: jz.bm::lang.deadline_kinds.inne
course:
label: jz.bm::lang.common.course
type: relation
nameFrom: name
span: right
emptyOption: jz.bm::lang.common.none
description:
label: jz.bm::lang.common.description
type: textarea
size: small

View File

@@ -0,0 +1,15 @@
columns:
title:
label: jz.bm::lang.common.title
searchable: true
type:
label: jz.bm::lang.materials.type
course:
label: jz.bm::lang.common.course
relation: course
select: name
published_at:
label: jz.bm::lang.materials.published_at
created_at:
label: jz.bm::lang.common.created_at
type: datetime

View File

@@ -0,0 +1,46 @@
# Attached files (system_files, field "files") have no form widget yet; see
# the TODO on materialsAdmin.AdminFieldRelations.
fields:
title:
label: jz.bm::lang.common.title
type: text
span: left
required: true
type:
label: jz.bm::lang.materials.type
type: dropdown
span: right
required: true
options:
pdf: jz.bm::lang.material_types.pdf
notatka: jz.bm::lang.material_types.notatka
link: jz.bm::lang.material_types.link
inne: jz.bm::lang.material_types.inne
course:
label: jz.bm::lang.common.course
type: relation
nameFrom: name
span: left
emptyOption: jz.bm::lang.common.none
published_at:
label: jz.bm::lang.materials.published_at
type: text
span: right
comment: jz.bm::lang.materials.published_at_comment
source:
label: jz.bm::lang.common.source
type: text
span: left
url:
label: jz.bm::lang.common.url
type: text
span: right
tags:
label: jz.bm::lang.materials.tags
type: relation
nameFrom: name
body:
label: jz.bm::lang.materials.body
type: textarea
size: giant
comment: jz.bm::lang.materials.body_comment

View File

@@ -0,0 +1,11 @@
columns:
number:
label: jz.bm::lang.meetings.number
starts_on:
label: jz.bm::lang.meetings.starts_on
ends_on:
label: jz.bm::lang.meetings.ends_on
notes:
label: jz.bm::lang.common.notes
searchable: true
sortable: false

View File

@@ -0,0 +1,22 @@
fields:
number:
label: jz.bm::lang.meetings.number
type: number
span: left
required: true
starts_on:
label: jz.bm::lang.meetings.starts_on
type: text
span: left
required: true
comment: jz.bm::lang.common.date_comment
ends_on:
label: jz.bm::lang.meetings.ends_on
type: text
span: right
required: true
comment: jz.bm::lang.common.date_comment
notes:
label: jz.bm::lang.common.notes
type: textarea
size: small

View File

@@ -0,0 +1,17 @@
columns:
title:
label: jz.bm::lang.common.title
searchable: true
author:
label: jz.bm::lang.readings.author
searchable: true
course:
label: jz.bm::lang.common.course
relation: course
select: name
searchable: true
is_required:
label: jz.bm::lang.readings.is_required
type: switch
sort_order:
label: jz.bm::lang.common.sort_order

View File

@@ -0,0 +1,33 @@
fields:
course:
label: jz.bm::lang.common.course
type: relation
nameFrom: name
span: left
required: true
is_required:
label: jz.bm::lang.readings.is_required
type: switch
span: right
title:
label: jz.bm::lang.common.title
type: text
span: left
required: true
author:
label: jz.bm::lang.readings.author
type: text
span: right
source:
label: jz.bm::lang.common.source
type: text
span: left
url:
label: jz.bm::lang.common.url
type: text
span: right
sort_order:
label: jz.bm::lang.common.sort_order
type: number
span: left
default: 0

7
models/tags/columns.yaml Normal file
View File

@@ -0,0 +1,7 @@
columns:
name:
label: jz.bm::lang.tags.name
searchable: true
slug:
label: jz.bm::lang.tags.slug
searchable: true

11
models/tags/fields.yaml Normal file
View File

@@ -0,0 +1,11 @@
fields:
name:
label: jz.bm::lang.tags.name
type: text
span: left
required: true
slug:
label: jz.bm::lang.tags.slug
type: text
span: right
required: true

View File

@@ -8,6 +8,7 @@ import (
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"github.com/go-gormigrate/gormigrate/v2"
)
@@ -20,6 +21,9 @@ var (
_ pact.HasCommands = (*Plugin)(nil)
_ pact.HasJobs = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
)
//go:embed config
@@ -31,14 +35,32 @@ var langFS embed.FS
//go:embed views/mail
var mailFS embed.FS
// Plugin is the jz.bm plugin.
type Plugin struct{}
//go:embed controllers/*/*.yaml models/*/*.yaml
var adminFS embed.FS
func (p *Plugin) ID() string { return "jz.bm" }
func (p *Plugin) Requires() []string { return nil }
// 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
}
func (p *Plugin) Register(*backpack.App) error { return nil }
func (p *Plugin) Boot(*backpack.App) error { return nil }
func (p *Plugin) ID() string { return "jz.bm" }
// Requires golem15.user: its users table holds the students, and its
// jwt.auth middleware guards the API.
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.
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
return nil
}
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
@@ -49,12 +71,49 @@ 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) Commands() []bonfire.Command { return generatedCommands() }
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}
// Commands returns the generated commands.
func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() }
// 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"},
},
}}
}
func init() {
party.Register(&Plugin{})
}

View File

@@ -5,15 +5,39 @@ package bm
import (
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"git.golem15.com/jakub/sm-bm-plugin/models"
"git.golem15.com/jakub/sm-bm-plugin/updates"
"github.com/go-gormigrate/gormigrate/v2"
)
func generatedModels() []any {
return nil
return []any{
&models.ClassSession{},
&models.Course{},
&models.Deadline{},
&models.Material{},
&models.MaterialTag{},
&models.Meeting{},
&models.Reading{},
&models.ReadingProgress{},
&models.Tag{},
}
}
func generatedMigrations() []*gormigrate.Migration {
return nil
return []*gormigrate.Migration{
updates.CreateMeetings(),
updates.CreateCourses(),
updates.CreateClassSessions(),
updates.CreateDeadlines(),
updates.CreateReadings(),
updates.CreateReadingProgress(),
updates.CreateTags(),
updates.CreateMaterials(),
updates.CreateMaterialTag(),
updates.AddElectiveGroupToCourses(),
}
}
func generatedCommands() []bonfire.Command {
@@ -25,5 +49,13 @@ func generatedJobs() []pact.Job {
}
func generatedAdminControllers() []pact.AdminController {
return nil
return []pact.AdminController{
controllers.ClassSessionsController(),
controllers.CoursesController(),
controllers.DeadlinesController(),
controllers.MaterialsController(),
controllers.MeetingsController(),
controllers.ReadingsController(),
controllers.TagsController(),
}
}

43
updates/updates_test.go Normal file
View File

@@ -0,0 +1,43 @@
package updates_test
import (
"os"
"regexp"
"slices"
"strings"
"testing"
bm "git.golem15.com/jakub/sm-bm-plugin"
)
var migrationFile = regexp.MustCompile(`^(\d{14})_[a-z0-9_]+\.go$`)
// The set must run in ID order (foreign keys point at earlier tables), and
// every migration file is named after its ID.
func TestMigrationOrder(t *testing.T) {
var ids []string
for _, m := range (&bm.Plugin{}).Migrations() {
if m.Migrate == nil || m.Rollback == nil {
t.Errorf("migration %s has no Migrate or Rollback", m.ID)
}
ids = append(ids, m.ID)
}
if !slices.IsSorted(ids) {
t.Errorf("migration IDs are not ascending: %v", ids)
}
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
var files []string
for _, e := range entries {
name := e.Name()
if e.IsDir() || !migrationFile.MatchString(name) {
continue
}
files = append(files, strings.TrimSuffix(name, ".go"))
}
if !slices.Equal(files, ids) {
t.Errorf("migration files %v, want one per ID %v", files, ids)
}
}