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:
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user