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
This commit is contained in:
Jakub Zych
2026-10-05 10:58:38 +02:00
parent f50d9b8f10
commit df5cace852
39 changed files with 1115 additions and 84 deletions

View File

@@ -10,6 +10,7 @@ import (
"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
@@ -21,6 +22,26 @@ type Member struct {
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" }
@@ -34,8 +55,11 @@ func (Member) Rules() map[string]string {
}
// MembersController is an admin controller whose form has fields that are
// not columns and rules of its own.
type MembersController struct{}
// 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{}
@@ -46,6 +70,8 @@ var (
_ pact.FormBeforeUpdate = MembersController{}
_ cabana.PermissionEditorProvider = MembersController{}
_ cabana.FieldRelationProvider = MembersController{}
_ cabana.RelationLockProvider = MembersController{}
)
func (MembersController) ID() string { return "acme.roster.members" }
@@ -128,6 +154,43 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
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))
@@ -157,6 +220,15 @@ func Example_formSeams() {
_ = 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
@@ -166,4 +238,7 @@ func Example_formSeams() {
// posts.publish false
// reports.export true
// {"posts.edit":1,"posts.publish":-1} 2
// team belongsTo true
// groups belongsToMany false
// 0 <nil>
}