Files
summercms/modules/cabana/reorder.go
Jakub Zych 23f33c3911 feat(17-01): tree reorder page and singleton admin controllers
- cabana: config_reorder.yaml (title, nameFrom, modelClass, parentColumn,
  sortColumn, maxDepth, partition) with fail-loud boot checks and the
  built-in reorder toolbar button; list schema carries reorder
- GET/POST {controller}/reorder: scoped set (ListExtendQuery plus the
  partition) locked FOR UPDATE, 409 reorder_stale on any set difference,
  422 for duplicates, foreign parents, cycles and maxDepth, both columns
  written without model hooks
- pact.AdminSingleton: GET {controller}/singleton (first or create, form
  scope checked), form schema singleton, list/create/bulk/delete 404,
  no config_list.yaml needed
- swag, inventories, token classes, CSRF walk, conformance cases, backend
  lang reorder.stale and load_failed, reorder and singleton smoke tests, docs
2026-10-12 15:35:43 +02:00

526 lines
18 KiB
Go

package cabana
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"path"
"reflect"
"sort"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// msgReorderStale is the phrase key of the 409 a reorder answers when the
// records changed since the tree was loaded (backend::lang).
const msgReorderStale = "backend::lang.reorder.stale"
// maxReorderPartitions caps the partition choices a reorder page offers.
const maxReorderPartitions = 500
// reorderDocument is config_reorder.yaml (D-09): WinterCMS's
// reorder_config.yaml keys title, nameFrom and modelClass, plus the parent
// and sort columns, the depth limit and an optional partition.
type reorderDocument struct {
Title string `yaml:"title"`
NameFrom string `yaml:"nameFrom"`
ModelClass string `yaml:"modelClass"`
ParentColumn string `yaml:"parentColumn"`
SortColumn string `yaml:"sortColumn"`
MaxDepth int `yaml:"maxDepth"`
Partition *reorderPartitionY `yaml:"partition"`
}
type reorderPartitionY struct {
Column string `yaml:"column"`
Relation string `yaml:"relation"`
NameFrom string `yaml:"nameFrom"`
Label string `yaml:"label"`
}
// compiledReorder is a controller's compiled config_reorder.yaml.
type compiledReorder struct {
title string
nameFrom string
parentColumn string
sortColumn string
maxDepth int
partition *compiledPartition
}
type compiledPartition struct {
column string
relation string
label string
labelCol string
fieldRel *CompiledFieldRelation
}
// ListReorder is the list schema's `reorder` object: the controller has a
// tree reorder page (config_reorder.yaml). Partition is set when the tree is
// reordered within one value of a partition column, such as one menu.
type ListReorder struct {
Title string `json:"title"`
Partition *ListReorderPartition `json:"partition,omitempty"`
}
// ListReorderPartition names the partition column and its label.
type ListReorderPartition struct {
Column string `json:"column"`
Label string `json:"label,omitempty"`
}
// compileReorder reads the controller's optional config_reorder.yaml. Every
// unknown key, a column that is not a column of the controller's record, a
// partition relation that is not a belongsTo field relation of the form over
// the partition column, and a negative maxDepth fail boot. It also enforces
// that toolbar.buttons lists reorder only on a controller that has the file.
func compileReorder(pluginID string, cc *CompiledController, fsys fs.FS) error {
ctl := cc.Controller
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
cfgPath := path.Join(dir, "config_reorder.yaml")
listHasReorder := cc.List != nil && hasToolbarButton(cc.List, "reorder")
if _, err := fs.Stat(fsys, cfgPath); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return bootErr(pluginID, ctl.ID(), cfgPath, err)
}
if listHasReorder {
return bootErr(pluginID, ctl.ID(), "config_list.yaml", fmt.Errorf("toolbar.buttons: reorder needs config_reorder.yaml"))
}
return nil
}
raw, err := readAsset(fsys, cfgPath)
if err != nil {
return bootErr(pluginID, ctl.ID(), cfgPath, err)
}
var doc reorderDocument
if err := decodeStrict(raw, &doc); err != nil {
return bootErr(pluginID, ctl.ID(), cfgPath, err)
}
fail := func(err error) error { return bootErr(pluginID, ctl.ID(), cfgPath, err) }
if doc.ModelClass != "" && doc.ModelClass != ctl.ModelName() {
return fail(fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return fail(fmt.Errorf("a reorder page needs an AdminRecordSource"))
}
if cc.List == nil {
return fail(fmt.Errorf("a reorder page needs config_list.yaml"))
}
cols := modelColumns(src.NewRecord())
out := &compiledReorder{
title: doc.Title,
nameFrom: doc.NameFrom,
parentColumn: doc.ParentColumn,
sortColumn: doc.SortColumn,
maxDepth: doc.MaxDepth,
}
if out.parentColumn == "" {
out.parentColumn = "parent_id"
}
if out.sortColumn == "" {
out.sortColumn = "sort_order"
}
if doc.MaxDepth < 0 {
return fail(fmt.Errorf("maxDepth must be 0 (unlimited) or more"))
}
if !identifier(out.nameFrom) {
return fail(fmt.Errorf("nameFrom %q is not a column name", out.nameFrom))
}
for key, column := range map[string]string{"nameFrom": out.nameFrom, "parentColumn": out.parentColumn, "sortColumn": out.sortColumn} {
if _, ok := cols[column]; !ok || !identifier(column) {
return fail(fmt.Errorf("%s %s is not a column of the record", key, column))
}
}
if p := doc.Partition; p != nil {
if _, ok := cols[p.Column]; !ok || !identifier(p.Column) {
return fail(fmt.Errorf("partition.column %s is not a column of the record", p.Column))
}
fr, ok := cc.FieldRelations[p.Relation]
if !ok || fr.Contract.Kind != relationKindBelongsTo || fr.Contract.ForeignKey != p.Column {
return fail(fmt.Errorf("partition.relation %s must be a belongsTo relation field of the form over %s", p.Relation, p.Column))
}
labelCol := fr.LabelColumn
if p.NameFrom != "" {
if !identifier(p.NameFrom) {
return fail(fmt.Errorf("partition.nameFrom %q is not a column name", p.NameFrom))
}
if _, ok := modelColumns(fr.Contract.NewRelated())[p.NameFrom]; !ok {
return fail(fmt.Errorf("partition.nameFrom %s is not a column of the related record", p.NameFrom))
}
labelCol = p.NameFrom
}
out.partition = &compiledPartition{column: p.Column, relation: p.Relation, label: p.Label, labelCol: labelCol, fieldRel: fr}
}
cc.reorder = out
reorder := &ListReorder{Title: out.title}
if out.partition != nil {
reorder.Partition = &ListReorderPartition{Column: out.partition.column, Label: out.partition.label}
}
cc.List.Reorder = reorder
return nil
}
// AdminReorderPartitionOption is one partition a reorder page offers.
type AdminReorderPartitionOption struct {
Value uint `json:"value"`
Label string `json:"label"`
}
// AdminReorderPartition is the partition of a reorder tree: its label, the
// partitions the administrator may choose and the one shown (null when there
// is none to choose).
type AdminReorderPartition struct {
Label string `json:"label"`
Options []AdminReorderPartitionOption `json:"options"`
Value *uint `json:"value"`
}
// AdminReorderNode is one record of a reorder tree.
type AdminReorderNode struct {
ID uint `json:"id"`
ParentID *uint `json:"parent_id"`
SortOrder int64 `json:"sort_order"`
Name string `json:"name"`
}
// AdminReorderTree is the data of the reorder routes: the page title, the
// partition (null without one) and every record of the scoped set, ordered by
// the sort column and then the id.
type AdminReorderTree struct {
Title string `json:"title"`
Partition *AdminReorderPartition `json:"partition"`
Nodes []AdminReorderNode `json:"nodes"`
}
// AdminReorderNodeInput is one node of a reorder save: the record id, its new
// parent (null at the top) and its new sort value.
type AdminReorderNodeInput struct {
ID uint64 `json:"id"`
ParentID *uint64 `json:"parent_id"`
SortOrder int64 `json:"sort_order"`
}
// AdminReorderRequest is the body of POST .../reorder: the partition the tree
// belongs to (required when the page has one) and every node of the scoped
// set, exactly once.
type AdminReorderRequest struct {
Partition *uint64 `json:"partition"`
Nodes []AdminReorderNodeInput `json:"nodes"`
}
// reorderStale is the 409 of a reorder save whose node set no longer equals
// the locked scoped set.
type reorderStale struct{}
func (reorderStale) Error() string { return "cabana: reorder set changed" }
// reorderGet serves GET {vendor}/{plugin}/{controller}/reorder?partition=<id>.
func (s *service) reorderGet(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.reorder == nil || cc.singleton {
writeNotFound(w, r)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var partition *uint64
if raw := strings.TrimSpace(r.URL.Query().Get("partition")); raw != "" {
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil || n == 0 {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", map[string]any{"partition": []string{"The partition field must be an id."}})
return
}
partition = &n
}
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator()))
var tree AdminReorderTree
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
tree, err = s.reorderTree(withTx(ctx, tx), tx, cc, partition, false)
return err
})
if err != nil {
s.writeReorderError(w, r, cc, err)
return
}
WriteData(w, http.StatusOK, tree, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
})
}
// reorderPost serves POST {vendor}/{plugin}/{controller}/reorder: one
// transaction locks the scoped set (ListExtendQuery plus the partition),
// requires the submitted ids to equal it exactly (409 reorder_stale
// otherwise), refuses a duplicate id, a parent outside the set, a cycle and a
// depth above maxDepth (422), then writes the parent and sort columns of
// every node and answers the new tree.
func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.reorder == nil || cc.singleton {
writeNotFound(w, r)
return
}
var in AdminReorderRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
writeCRUDError(w, err)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator()))
var tree AdminReorderTree
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := withTx(ctx, tx)
if cc.reorder.partition != nil && in.Partition == nil {
return &ValidationError{Details: map[string]any{"partition": []string{"The partition field is required."}}}
}
locked, err := s.reorderTree(txCtx, tx, cc, in.Partition, true)
if err != nil {
return err
}
if err := checkReorderNodes(cc.reorder, locked.Nodes, in.Nodes); err != nil {
return err
}
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
pk := primaryColumn(proto)
for _, node := range in.Nodes {
var parent any
if node.ParentID != nil {
parent = castPK(proto, uint(*node.ParentID))
}
// UpdateColumns writes exactly the two columns and runs no
// model hooks, so a save hook that renumbers sort values
// cannot undo the new order.
err := tx.WithContext(txCtx).Model(cc.Controller.(pact.AdminRecordSource).NewRecord()).
Where(clause.Eq{Column: clause.Column{Name: pk}, Value: castPK(proto, uint(node.ID))}).
UpdateColumns(map[string]any{cc.reorder.parentColumn: parent, cc.reorder.sortColumn: node.SortOrder}).Error
if err != nil {
return lifecycleFailure(cc, err)
}
}
tree, err = s.reorderTree(txCtx, tx, cc, in.Partition, false)
return err
})
if err != nil {
s.writeReorderError(w, r, cc, err)
return
}
WriteData(w, http.StatusOK, tree, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
})
}
func (s *service) writeReorderError(w http.ResponseWriter, r *http.Request, cc *CompiledController, err error) {
var stale reorderStale
if errors.As(err, &stale) {
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator()))
WriteError(w, http.StatusConflict, "reorder_stale", translateKey(ctx, s.translator(), msgReorderStale))
return
}
var ve *ValidationError
var missing recordNotFound
if !errors.As(err, &ve) && !errors.As(err, &missing) {
slog.Error("cabana: reorder failed", "controller", controllerID(cc), "error", err)
}
writeCRUDError(w, err)
}
// reorderTree loads the scoped set of a reorder page: the records the list
// scope (ListExtendQuery) returns within the partition, ordered by the sort
// column and then the primary key; lock adds FOR UPDATE. A partition that is
// not among the offered ones is not found; without one requested, the first
// offered partition is shown.
func (s *service) reorderTree(ctx context.Context, tx *gorm.DB, cc *CompiledController, requested *uint64, lock bool) (AdminReorderTree, error) {
ro := cc.reorder
tr := s.translator()
tree := AdminReorderTree{Title: translateKey(ctx, tr, ro.title), Nodes: []AdminReorderNode{}}
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
q := tx.WithContext(ctx).Model(proto)
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
if next := ext.ListExtendQuery(ctx, q); next != nil {
q = next
}
}
table := tableName(proto)
if p := ro.partition; p != nil {
options, err := reorderPartitions(ctx, tx, cc, p)
if err != nil {
return AdminReorderTree{}, err
}
part := &AdminReorderPartition{Label: translateKey(ctx, tr, p.label), Options: options}
tree.Partition = part
switch {
case requested != nil:
found := false
for _, opt := range options {
found = found || uint64(opt.Value) == *requested
}
if !found {
return AdminReorderTree{}, recordNotFound{}
}
v := uint(*requested)
part.Value = &v
case len(options) > 0:
v := options[0].Value
part.Value = &v
default:
return tree, nil
}
q = q.Where(clause.Eq{Column: clause.Column{Table: table, Name: p.column}, Value: *part.Value})
}
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
pk := primaryColumn(proto)
holder := reflect.New(reflect.SliceOf(reflect.TypeOf(proto).Elem()))
err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: ro.sortColumn}}).
Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}).
Find(holder.Interface()).Error
if err != nil {
return AdminReorderTree{}, lifecycleFailure(cc, err)
}
rows := holder.Elem()
for i := 0; i < rows.Len(); i++ {
row := rows.Index(i)
node := AdminReorderNode{ID: uintOf(fieldByColumn(row, pk))}
if parent := uintOf(fieldByColumn(row, ro.parentColumn)); parent != 0 {
node.ParentID = &parent
}
node.SortOrder = intOf(fieldByColumn(row, ro.sortColumn))
if f := fieldByColumn(row, ro.nameFrom); f.IsValid() && f.CanInterface() {
if v := derefValue(f.Interface()); v != nil {
node.Name = fmt.Sprint(v)
}
}
tree.Nodes = append(tree.Nodes, node)
}
return tree, nil
}
// reorderPartitions lists the partitions the administrator may choose: the
// related records the partition relation field offers (its
// RelationExtendOptionsQuery scope), ordered by label then id.
func reorderPartitions(ctx context.Context, tx *gorm.DB, cc *CompiledController, p *compiledPartition) ([]AdminReorderPartitionOption, error) {
related := p.fieldRel.Contract.NewRelated()
table := tableName(related)
pk := primaryColumn(related)
q := scopedRelationQuery(ctx, tx, cc, p.relation, p.fieldRel)
label := labelExpression(q, table, p.labelCol)
rows := []AdminReorderPartitionOption{}
err := q.Select(quotedIdent(q, table) + "." + quotedIdent(q, pk) + " AS value, " + label + " AS label").
Order(label).
Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}).
Limit(maxReorderPartitions).
Scan(&rows).Error
if err != nil {
return nil, lifecycleFailure(cc, err)
}
return rows, nil
}
// checkReorderNodes compares a submitted tree with the locked set: a
// duplicate id is a 422, any difference between the two id sets a 409, and a
// parent outside the set, a cycle or a depth above maxDepth a 422.
func checkReorderNodes(ro *compiledReorder, locked []AdminReorderNode, nodes []AdminReorderNodeInput) error {
invalid := func(msg string) error {
return &ValidationError{Details: map[string]any{"nodes": []string{msg}}}
}
parents := make(map[uint64]uint64, len(nodes))
for _, node := range nodes {
if node.ID == 0 {
return invalid("The nodes field must list record ids.")
}
if _, dup := parents[node.ID]; dup {
return invalid("The nodes field lists a record twice.")
}
parent := uint64(0)
if node.ParentID != nil {
parent = *node.ParentID
}
parents[node.ID] = parent
}
if len(parents) != len(locked) {
return reorderStale{}
}
for _, node := range locked {
if _, ok := parents[uint64(node.ID)]; !ok {
return reorderStale{}
}
}
ids := make([]uint64, 0, len(parents))
for id, parent := range parents {
ids = append(ids, id)
if parent == 0 {
continue
}
if parent == id {
return invalid("A record cannot be its own parent.")
}
if _, ok := parents[parent]; !ok {
return invalid("A parent is not one of the reordered records.")
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
depth := 1
for cur := parents[id]; cur != 0; cur = parents[cur] {
depth++
if depth > len(parents) {
return invalid("The tree has a cycle.")
}
}
if ro.maxDepth > 0 && depth > ro.maxDepth {
return invalid(fmt.Sprintf("The tree may be at most %d levels deep.", ro.maxDepth))
}
}
return nil
}
// uintOf reads an unsigned id from a struct field (a pointer is
// dereferenced; nil and non-numbers are 0).
func uintOf(f reflect.Value) uint {
if !f.IsValid() || !f.CanInterface() {
return 0
}
v := reflect.ValueOf(derefValue(f.Interface()))
switch v.Kind() {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return uint(v.Uint())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if v.Int() > 0 {
return uint(v.Int())
}
}
return 0
}
// intOf reads an integer from a struct field (a pointer is dereferenced; nil
// and non-numbers are 0).
func intOf(f reflect.Value) int64 {
if !f.IsValid() || !f.CanInterface() {
return 0
}
v := reflect.ValueOf(derefValue(f.Interface()))
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return int64(v.Uint())
}
return 0
}