Files
summercms/modules/cabana/example_form_seams_test.go
Jakub Zych df5cace852 feat(12.1-02): writable foreign keys, locked relation options, invisible columns
- FieldRelationContract.WritableForeignKey makes a belongsTo field over a
  protected foreign key writable; the protected key list is unchanged
- cabana.RelationLockProvider names related ids an administrator may not add
  or remove: options and labels carry locked, and a create or update that
  changes the locked subset is 403 before any row is written
- columns.yaml invisible keeps a column searchable and out of the rows
- a controller implementing pact.FilterOptions serves a scope filter's
  choices before the model
- SPA: locked chips and options in RelationField, DataTable skips invisible
  columns
- README, docs, OpenAPI document, TS types and dist updated
2026-10-05 10:58:38 +02:00

245 lines
9.1 KiB
Go

package cabana_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Member is the model behind the acme.roster members controller. Password
// holds a hash; the form never reads or writes the column itself.
type Member struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Slug string `gorm:"column:slug"`
Password string `gorm:"column:password" json:"-"`
// Permissions is a JSON object of permission code to value.
Permissions string `gorm:"column:permissions"`
// OrganisationID is a protected column: the form writes it only through
// the team relation field.
OrganisationID *uint `gorm:"column:organisation_id"`
}
// Team is what a member belongs to; Group is what a member is put into.
type Team struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
type Group struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Code string `gorm:"column:code"`
}
type MemberGroup struct {
MemberID uint `gorm:"column:member_id;primaryKey"`
GroupID uint `gorm:"column:group_id;primaryKey"`
}
func (Member) TableName() string { return "acme_roster_members" }
// Fillable lists the columns the admin form may write.
func (Member) Fillable() []string { return []string{"name", "slug"} }
// Rules are the model's own rules, used wherever the controller sets none.
func (Member) Rules() map[string]string {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
// MembersController is an admin controller whose form has fields that are
// not columns and rules of its own. DB is the application's database handle,
// for reads outside a save.
type MembersController struct {
DB *gorm.DB
}
var (
_ pact.AdminController = MembersController{}
_ pact.AdminRecordSource = MembersController{}
_ pact.FormVirtualFields = MembersController{}
_ pact.FormRules = MembersController{}
_ pact.FormBeforeCreate = MembersController{}
_ pact.FormBeforeUpdate = MembersController{}
_ cabana.PermissionEditorProvider = MembersController{}
_ cabana.FieldRelationProvider = MembersController{}
_ cabana.RelationLockProvider = MembersController{}
)
func (MembersController) ID() string { return "acme.roster.members" }
func (MembersController) ModelName() string { return "Member" }
func (MembersController) ConfigDir() string { return "controllers/members" }
func (MembersController) NewRecord() any { return &Member{} }
// FormVirtualFields names the fields of fields.yaml that are not columns of
// the form. cabana never fills or returns them.
func (MembersController) FormVirtualFields() []string {
return []string{"password", "password_confirmation", "notify"}
}
// FormRules replaces the model's rules for admin saves: a create needs a
// password, an update takes one only when the administrator types it.
func (MembersController) FormRules(_ context.Context, op string) map[string]string {
rules := map[string]string{"name": "required"}
if op == "create" {
rules["password"] = "required|between:8,255|confirmed"
} else {
rules["password"] = "nullable|between:8,255|confirmed"
}
return rules
}
// FormBeforeCreate reads the submitted virtual values, which have passed the
// rules by now, and stores what the model needs.
func (MembersController) FormBeforeCreate(ctx context.Context, model any) error {
values, _ := cabana.VirtualFieldsFromContext(ctx)
member := model.(*Member)
if plain, ok := values["password"].(string); ok && plain != "" {
member.Password = hashPassword(plain)
}
if notify, _ := values["notify"].(bool); notify {
// Queue the welcome message here.
}
return nil
}
// FormBeforeUpdate changes the password only when one was submitted.
func (MembersController) FormBeforeUpdate(ctx context.Context, model any) error {
values, _ := cabana.VirtualFieldsFromContext(ctx)
if plain, ok := values["password"].(string); ok && plain != "" {
model.(*Member).Password = hashPassword(plain)
}
return nil
}
// AdminPermissionOptions lists the permissions the `type: permissioneditor`
// field offers, in display order. The principal on ctx decides what is locked.
func (MembersController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
principal, _ := bouncer.User(ctx)
mayExport := cabana.Allows(principal, []string{"acme.roster.manage"})
return []cabana.PermissionOption{
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports", Locked: !mayExport},
}, nil
}
// AdminPermissionValues reads the permissions stored on the record.
func (MembersController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
values := map[string]int{}
if raw := record.(*Member).Permissions; raw != "" {
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
}
return values, nil
}
// AdminSetPermissionValues stores the checked set on the model. The save
// writes the row afterwards, in the same transaction.
func (MembersController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
raw, err := json.Marshal(values)
if err != nil {
return err
}
record.(*Member).Permissions = string(raw)
return nil
}
// AdminFieldRelations binds the form's two relation fields. organisation_id
// is a protected column, so the team field would be read-only; the contract
// opts in to writing it through this field.
func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{
Field: "team",
Kind: "belongsTo",
NewRelated: func() any { return &Team{} },
ForeignKey: "organisation_id",
WritableForeignKey: true,
}, {
Field: "groups",
Kind: "belongsToMany",
NewRelated: func() any { return &Group{} },
NewPivot: func() any { return &MemberGroup{} },
ParentForeignKey: "member_id",
RelatedForeignKey: "group_id",
}}
}
// AdminRelationLocks names the groups an administrator without
// acme.roster.manage may not put a member into or take a member out of. Inside
// a save the ids are read with the save's transaction.
func (c MembersController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
principal, _ := bouncer.User(ctx)
if field != "groups" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
db := c.DB
if tx, ok := cabana.TxFromContext(ctx); ok {
db = tx
}
lock := cabana.RelationLock{Message: "acme.roster::lang.members.group_locked"}
err := db.WithContext(ctx).Model(&Group{}).Where("code = ?", "staff").Pluck("id", &lock.IDs).Error
return lock, err
}
// hashPassword stands in for the application's password hasher.
func hashPassword(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
// Example_formSeams shows what the controller declares. Outside a save there
// are no submitted values, so the hook stores nothing.
func Example_formSeams() {
ctl := MembersController{}
ctx := context.Background()
fmt.Println(ctl.FormVirtualFields())
fmt.Println("create:", ctl.FormRules(ctx, "create")["password"])
fmt.Println("update:", ctl.FormRules(ctx, "update")["password"])
member := &Member{Name: "Ada"}
_, inSave := cabana.VirtualFieldsFromContext(ctx)
err := ctl.FormBeforeCreate(ctx, member)
fmt.Println(inSave, err, member.Password == "")
// The permission editor: three options, the last one locked for an
// administrator without acme.roster.manage (here: nobody is signed in).
options, _ := ctl.AdminPermissionOptions(ctx, "permissions")
for _, option := range options {
fmt.Println(option.Code, option.Locked)
}
_ = ctl.AdminSetPermissionValues(ctx, "permissions", member, map[string]int{"posts.edit": 1, "posts.publish": -1})
values, _ := ctl.AdminPermissionValues(ctx, "permissions", member)
fmt.Println(member.Permissions, len(values))
// The relation fields: team writes a protected key, groups has locks.
for _, contract := range ctl.AdminFieldRelations() {
fmt.Println(contract.Field, contract.Kind, contract.WritableForeignKey)
}
// The team field has no locks; the groups field would read them from
// the database.
lock, err := ctl.AdminRelationLocks(ctx, "team")
fmt.Println(len(lock.IDs), err)
// Output:
// [password password_confirmation notify]
// create: required|between:8,255|confirmed
// update: nullable|between:8,255|confirmed
// false <nil> true
// posts.edit false
// posts.publish false
// reports.export true
// {"posts.edit":1,"posts.publish":-1} 2
// team belongsTo true
// groups belongsToMany false
// 0 <nil>
}