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:
@@ -49,8 +49,11 @@ type rosterPerson struct {
|
||||
Slug string `gorm:"column:slug"`
|
||||
// Permissions is the permission editor's storage: a JSON object of code
|
||||
// to value, or NULL.
|
||||
Permissions *string `gorm:"column:permissions"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
Permissions *string `gorm:"column:permissions"`
|
||||
// OrganisationID is a protected fill key: only the team relation field,
|
||||
// whose contract sets WritableForeignKey, writes it.
|
||||
OrganisationID *uint `gorm:"column:organisation_id"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (rosterPerson) TableName() string { return "roster_people" }
|
||||
@@ -62,6 +65,42 @@ func (rosterPerson) Rules() map[string]string {
|
||||
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
|
||||
}
|
||||
|
||||
// FilterScopes and FilterScope: the tagged filter keeps the people who carry
|
||||
// one tag. Its choices come from the controller, which can read the tags.
|
||||
func (rosterPerson) FilterScopes() []string { return []string{"tagged"} }
|
||||
|
||||
func (rosterPerson) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||
if db == nil || name != "tagged" {
|
||||
return db
|
||||
}
|
||||
return db.Where("roster_people.id IN (SELECT person_id FROM roster_person_tags WHERE tag_id = ?)", value)
|
||||
}
|
||||
|
||||
// rosterTeam is the belongsTo target of the team field.
|
||||
type rosterTeam struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Tenant string `gorm:"column:tenant"`
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
func (rosterTeam) TableName() string { return "roster_teams" }
|
||||
|
||||
// rosterTag is the belongsToMany target of the tags field. The tag named
|
||||
// staff is locked for an administrator without acme.roster.manage.
|
||||
type rosterTag struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
func (rosterTag) TableName() string { return "roster_tags" }
|
||||
|
||||
type rosterPersonTag struct {
|
||||
PersonID uint `gorm:"column:person_id;primaryKey"`
|
||||
TagID uint `gorm:"column:tag_id;primaryKey"`
|
||||
}
|
||||
|
||||
func (rosterPersonTag) TableName() string { return "roster_person_tags" }
|
||||
|
||||
// rosterHash is the fixture's stand-in for a password hash.
|
||||
func rosterHash(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
@@ -156,6 +195,12 @@ func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput {
|
||||
type rosterPlugin struct {
|
||||
spy *rosterSpy
|
||||
fsys fs.FS
|
||||
// db is the handle the controller reads filter choices and locked tags
|
||||
// with outside a transaction.
|
||||
db *gorm.DB
|
||||
// relations, when set, rewrites the controller's relation contracts
|
||||
// (boot tests).
|
||||
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
|
||||
}
|
||||
|
||||
func (rosterPlugin) ID() string { return "acme.roster" }
|
||||
@@ -163,7 +208,7 @@ func (rosterPlugin) Requires() []string { return nil }
|
||||
func (rosterPlugin) Register(*backpack.App) error { return nil }
|
||||
func (rosterPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p rosterPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{rosterController{spy: p.spy}}
|
||||
return []pact.AdminController{rosterController{spy: p.spy, db: p.db, relations: p.relations}}
|
||||
}
|
||||
func (rosterPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
|
||||
@@ -188,7 +233,74 @@ func (rosterPlugin) LangFS() fs.FS {
|
||||
return out
|
||||
}
|
||||
|
||||
type rosterController struct{ spy *rosterSpy }
|
||||
type rosterController struct {
|
||||
spy *rosterSpy
|
||||
db *gorm.DB
|
||||
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
|
||||
}
|
||||
|
||||
// AdminFieldRelations: team writes the protected organisation_id through an
|
||||
// explicit opt-in; tags is a plain belongsToMany.
|
||||
func (c rosterController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
out := []cabana.FieldRelationContract{{
|
||||
Field: "team", Kind: "belongsTo", NewRelated: func() any { return &rosterTeam{} },
|
||||
ForeignKey: "organisation_id", WritableForeignKey: true,
|
||||
}, {
|
||||
Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &rosterTag{} },
|
||||
NewPivot: func() any { return &rosterPersonTag{} }, ParentForeignKey: "person_id", RelatedForeignKey: "tag_id",
|
||||
}}
|
||||
if c.relations != nil {
|
||||
out = c.relations(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RelationExtendOptionsQuery offers only the acme tenant's teams.
|
||||
func (rosterController) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB {
|
||||
if field == "team" {
|
||||
return db.Where("tenant = ?", "acme")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// handle is the save's transaction when there is one, else the plugin's
|
||||
// database handle.
|
||||
func (c rosterController) handle(ctx context.Context) *gorm.DB {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||
return tx
|
||||
}
|
||||
return c.db.WithContext(ctx)
|
||||
}
|
||||
|
||||
// AdminRelationLocks locks the staff tag for an administrator without
|
||||
// acme.roster.manage.
|
||||
func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if field != "tags" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
|
||||
return cabana.RelationLock{}, nil
|
||||
}
|
||||
var ids []uint
|
||||
if err := c.handle(ctx).Model(&rosterTag{}).Where("name = ?", "staff").Pluck("id", &ids).Error; err != nil {
|
||||
return cabana.RelationLock{}, err
|
||||
}
|
||||
return cabana.RelationLock{IDs: ids, Message: "acme.roster::lang.people.tag_locked"}, nil
|
||||
}
|
||||
|
||||
// FilterOptions serves the tagged filter's choices from the database.
|
||||
func (c rosterController) FilterOptions(scope string) []pact.Option {
|
||||
if scope != "tagged" || c.db == nil {
|
||||
return nil
|
||||
}
|
||||
var tags []rosterTag
|
||||
if err := c.db.Order("name").Find(&tags).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]pact.Option, len(tags))
|
||||
for i, tag := range tags {
|
||||
out[i] = pact.Option{Value: fmt.Sprint(tag.ID), Label: tag.Name}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (rosterController) ID() string { return "acme.roster.people" }
|
||||
func (rosterController) ModelName() string { return "Person" }
|
||||
@@ -496,9 +608,16 @@ type rosterEnv struct {
|
||||
}
|
||||
|
||||
func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
|
||||
t.Helper()
|
||||
return newRosterEnvWith(t, nil)
|
||||
}
|
||||
|
||||
// newRosterEnvWith is newRosterEnv with the plugin adjusted by configure
|
||||
// before it is assembled.
|
||||
func newRosterEnvWith(t *testing.T, configure func(*rosterPlugin)) (*rosterEnv, *gorm.DB) {
|
||||
t.Helper()
|
||||
gdb := adminGorm(t)
|
||||
models := []any{&rosterPerson{}}
|
||||
models := []any{&rosterPerson{}, &rosterTeam{}, &rosterTag{}, &rosterPersonTag{}}
|
||||
if err := gdb.Migrator().DropTable(models...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -537,7 +656,11 @@ func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spy := &rosterSpy{}
|
||||
plugins := []party.Plugin{rosterPlugin{spy: spy}}
|
||||
plugin := rosterPlugin{spy: spy, db: gdb}
|
||||
if configure != nil {
|
||||
configure(&plugin)
|
||||
}
|
||||
plugins := []party.Plugin{plugin}
|
||||
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -591,12 +714,18 @@ func rosterTree(t *testing.T, replace map[string]string) fstest.MapFS {
|
||||
|
||||
// rosterBoot activates the roster plugin over fsys and returns the boot error.
|
||||
func rosterBoot(t *testing.T, fsys fs.FS) error {
|
||||
t.Helper()
|
||||
return rosterBootWith(t, rosterPlugin{spy: &rosterSpy{}, fsys: fsys})
|
||||
}
|
||||
|
||||
// rosterBootWith activates one roster plugin value and returns the boot error.
|
||||
func rosterBootWith(t *testing.T, plugin rosterPlugin) error {
|
||||
t.Helper()
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
|
||||
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{plugin})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user