feat(12.2-03): defer relation work on unsaved records and add child file routes

- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
This commit is contained in:
Jakub Zych
2026-10-02 19:08:16 +02:00
parent afb05b6ee4
commit fe9e8baaf1
16 changed files with 3643 additions and 436 deletions

View File

@@ -539,13 +539,14 @@ func AdminDelete() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param search query string false "Search term over the panel's searchable columns"
// @Param sort query string false "Sort column (a sortable panel column)"
// @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page"
// @Param per_page query integer false "Records per page (1-100, default 20)"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -563,13 +564,14 @@ func AdminRelationLinked() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param search query string false "Search term over the panel's searchable columns"
// @Param sort query string false "Sort column (a sortable panel column)"
// @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page"
// @Param per_page query integer false "Records per page (1-100, default 20)"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -588,9 +590,10 @@ func AdminRelationCandidates() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param body body AdminRelationLinkRequest true "Related record ids and optional pivot form values"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -609,9 +612,10 @@ func AdminRelationLink() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -631,9 +635,11 @@ func AdminRelationUnlink() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param body body AdminRecord true "Field values of the manage form keyed by field name"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Param X-Child-Session-Key header string false "Child form session key: the save attaches the child files uploaded under it"
// @Success 201 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -653,9 +659,10 @@ func AdminRelationChildCreate() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -675,10 +682,12 @@ func AdminRelationChildShow() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id"
// @Param body body AdminRecord true "Field values of the manage form keyed by field name"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Param X-Child-Session-Key header string false "Child form session key: the save attaches the child files uploaded under it"
// @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -699,9 +708,10 @@ func AdminRelationChildUpdate() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -721,9 +731,10 @@ func AdminRelationChildDelete() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Success 200 {object} Envelope[AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -743,10 +754,11 @@ func AdminRelationPivotShow() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id"
// @Param body body AdminRecord true "Pivot form values keyed by field name"
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
// @Success 200 {object} Envelope[AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
@@ -756,6 +768,194 @@ func AdminRelationPivotShow() {}
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [put]
func AdminRelationPivotUpdate() {}
// AdminRelationChildFileList documents the file list of a relation child
// form's fileupload field.
//
// @Summary List the files of a related record's fileupload field
// @Description The child-form counterpart of the record file list: the files attached to the related record minus the X-Child-Session-Key session's pending removals, plus its pending uploads. The related record is scoped to the owner like the child show route; child 0 needs the create toolbar button and the child key, a saved child the update button or a view form (403 otherwise).
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id (0 for the child being created)"
// @Param field path string true "fileupload field of the relation's manage form"
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
// @Success 200 {object} Envelope[[]FileItem]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field} [get]
func AdminRelationChildFileList() {}
// AdminRelationChildFileUpload documents an upload to a relation child
// form's fileupload field.
//
// @Summary Upload a file to a related record's fileupload field
// @Description Stores one multipart file_data part and binds it to the X-Child-Session-Key session; the child's create or update save with the same key attaches it. Limits and errors as on the record upload route. Writes to a saved child need the update toolbar button (403 otherwise).
// @Tags admin
// @Accept multipart/form-data
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id (0 for the child being created)"
// @Param field path string true "fileupload field of the relation's manage form"
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
// @Param X-Child-Session-Key header string true "Child form session key (32-128 characters of A-Z a-z 0-9 _ -)"
// @Param file_data formData file true "The file"
// @Success 201 {object} Envelope[FileItem]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field} [post]
func AdminRelationChildFileUpload() {}
// AdminRelationChildFileUpdate documents the caption route of a relation
// child form's fileupload field.
//
// @Summary Save a related record file's title and description
// @Description As the record caption route, for a file of the related record or of the child session. The field must declare useCaption (403 otherwise).
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id (0 for the child being created)"
// @Param field path string true "fileupload field of the relation's manage form"
// @Param file path integer true "File id"
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
// @Param body body AdminFileCaptionRequest true "Title and description"
// @Success 200 {object} Envelope[FileItem]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file} [put]
func AdminRelationChildFileUpdate() {}
// AdminRelationChildFileRemove documents the removal of a file from a
// relation child form's fileupload field.
//
// @Summary Remove a related record's file
// @Description Removing an attached file is deferred to the child's next save with the same X-Child-Session-Key; removing a pending upload deletes it at once.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id (0 for the child being created)"
// @Param field path string true "fileupload field of the relation's manage form"
// @Param file path integer true "File id"
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
// @Param X-Child-Session-Key header string true "Child form session key (32-128 characters of A-Z a-z 0-9 _ -)"
// @Success 200 {object} Envelope[FileMutationResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file} [delete]
func AdminRelationChildFileRemove() {}
// AdminRelationChildFileReorder documents the reorder route of a relation
// child form's attachMany field.
//
// @Summary Reorder a related record's files
// @Description As the record reorder route: ids must be exactly the field's visible files. attachMany only, otherwise 403.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id (0 for the child being created)"
// @Param field path string true "fileupload field of the relation's manage form"
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
// @Param body body AdminIDsRequest true "File ids in the new order"
// @Success 200 {object} Envelope[[]FileItem]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder [post]
func AdminRelationChildFileReorder() {}
// AdminRelationChildFileDownload documents the download of a related
// record's protected file.
//
// @Summary Download a related record's protected file
// @Description As the record download route, for a protected file of the related record or of the child session, with the same headers.
// @Tags admin
// @Produce octet-stream
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id (0 for the child being created)"
// @Param field path string true "fileupload field of the relation's manage form"
// @Param file path integer true "File id"
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
// @Success 200 {file} file
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download [get]
func AdminRelationChildFileDownload() {}
// AdminRelationChildFileThumb documents the thumbnail of a related
// record's protected image.
//
// @Summary Thumbnail of a related record's protected image
// @Description As the record thumb route, for a protected image of the related record or of the child session.
// @Tags admin
// @Produce octet-stream
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param name path string true "Relation name"
// @Param child path integer true "Related record id (0 for the child being created)"
// @Param field path string true "fileupload field of the relation's manage form"
// @Param file path integer true "File id"
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
// @Success 200 {file} file
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb [get]
func AdminRelationChildFileThumb() {}
// FileMutationResult is the payload of a file removal: the number of files
// removed (always 1 on success).
type FileMutationResult struct {