feat(15-02): add Posts admin with mlmarkdown and FormatHTML
YAML-driven Posts screen stores translatable markdown and regenerates content_html in the plugin. Publish writes without access_publish are refused; list and form queries stay on the principal without access_other_posts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
29
controllers/admin_registry.go
Normal file
29
controllers/admin_registry.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// AdminControllers is the plugin's admin controller list. app returns the
|
||||
// booted application; it is asked on every call, so a controller never keeps
|
||||
// a database handle of its own.
|
||||
func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
return []pact.AdminController{
|
||||
postsAdminController{app: app},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
var _ pact.AdminController = postsAdminController{}
|
||||
var _ pact.AdminPermissioned = postsAdminController{}
|
||||
var _ pact.AdminRecordSource = postsAdminController{}
|
||||
var _ pact.ListExtendQuery = postsAdminController{}
|
||||
var _ pact.FormExtendQuery = postsAdminController{}
|
||||
var _ pact.ListRowStates = postsAdminController{}
|
||||
var _ pact.FilterOptions = postsAdminController{}
|
||||
var _ pact.FormBeforeCreate = postsAdminController{}
|
||||
var _ pact.FormBeforeUpdate = postsAdminController{}
|
||||
var _ cabana.FieldRelationProvider = postsAdminController{}
|
||||
}
|
||||
208
controllers/posts.go
Normal file
208
controllers/posts.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PostsControllerID is the cabana controller id of the Posts screen.
|
||||
PostsControllerID = "golem15.journal.posts"
|
||||
|
||||
// PermissionAccessPosts is Posts::$requiredPermissions.
|
||||
PermissionAccessPosts = "golem15.journal.access_posts"
|
||||
// PermissionAccessOtherPosts lets an administrator see every author's posts.
|
||||
PermissionAccessOtherPosts = "golem15.journal.access_other_posts"
|
||||
// PermissionAccessPublish is required to persist published=true or published_at.
|
||||
PermissionAccessPublish = "golem15.journal.access_publish"
|
||||
)
|
||||
|
||||
type postsAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (postsAdminController) ID() string { return PostsControllerID }
|
||||
|
||||
func (postsAdminController) ModelName() string { return `Golem15\Journal\Models\Post` }
|
||||
|
||||
func (postsAdminController) ConfigDir() string { return "controllers/posts" }
|
||||
|
||||
func (postsAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionAccessPosts}
|
||||
}
|
||||
|
||||
func (postsAdminController) NewRecord() any { return &models.Post{} }
|
||||
|
||||
func (c postsAdminController) application() *backpack.App {
|
||||
if c.app == nil {
|
||||
return nil
|
||||
}
|
||||
return c.app()
|
||||
}
|
||||
|
||||
func (c postsAdminController) pool() *gorm.DB {
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
|
||||
func (c postsAdminController) db(ctx context.Context) *gorm.DB {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||
return tx
|
||||
}
|
||||
return c.pool()
|
||||
}
|
||||
|
||||
func (postsAdminController) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
return restrictToOwnPosts(ctx, db)
|
||||
}
|
||||
|
||||
func (postsAdminController) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
return restrictToOwnPosts(ctx, db)
|
||||
}
|
||||
|
||||
func restrictToOwnPosts(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
principal, ok := bouncer.User(ctx)
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
if cabana.Allows(principal, []string{PermissionAccessOtherPosts}) {
|
||||
return db
|
||||
}
|
||||
return db.Where("user_id = ?", principal.ID)
|
||||
}
|
||||
|
||||
func (postsAdminController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
|
||||
out := make([][]pact.RowState, len(records))
|
||||
for i, record := range records {
|
||||
post, ok := record.(*models.Post)
|
||||
if !ok || post == nil {
|
||||
return nil, errors.New("posts admin: list record is not a post")
|
||||
}
|
||||
if !post.Published {
|
||||
out[i] = append(out[i], pact.RowStateDisabled)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c postsAdminController) FilterOptions(scope string) []pact.Option {
|
||||
if scope != models.FilterCategories {
|
||||
return nil
|
||||
}
|
||||
db := c.pool()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var cats []models.Category
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&cats).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]pact.Option, 0, len(cats))
|
||||
for _, cat := range cats {
|
||||
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(cat.ID), 10), Label: cat.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (postsAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
return []cabana.FieldRelationContract{
|
||||
{
|
||||
Field: "user",
|
||||
Kind: "belongsTo",
|
||||
NewRelated: func() any { return &cabana.BackendUser{} },
|
||||
ForeignKey: "user_id",
|
||||
LabelColumn: "login",
|
||||
WritableForeignKey: true,
|
||||
},
|
||||
{
|
||||
Field: "categories",
|
||||
Kind: "belongsToMany",
|
||||
NewRelated: func() any { return &models.Category{} },
|
||||
NewPivot: func() any { return &models.PostCategory{} },
|
||||
ParentForeignKey: "post_id",
|
||||
RelatedForeignKey: "category_id",
|
||||
LabelColumn: "name",
|
||||
},
|
||||
{
|
||||
Field: "tags",
|
||||
Kind: "belongsToMany",
|
||||
NewRelated: func() any { return &models.Tag{} },
|
||||
NewPivot: func() any { return &models.PostTag{} },
|
||||
ParentForeignKey: "post_id",
|
||||
RelatedForeignKey: "tag_id",
|
||||
LabelColumn: "name",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c postsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
post, ok := model.(*models.Post)
|
||||
if !ok || post == nil {
|
||||
return errors.New("posts admin: form model is not a post")
|
||||
}
|
||||
if post.UserID == nil || *post.UserID == 0 {
|
||||
principal, ok := bouncer.User(ctx)
|
||||
if ok && principal != nil && principal.Backend {
|
||||
id := principal.ID
|
||||
post.UserID = &id
|
||||
}
|
||||
}
|
||||
return preparePostWrite(ctx, post)
|
||||
}
|
||||
|
||||
func (c postsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
post, ok := model.(*models.Post)
|
||||
if !ok || post == nil {
|
||||
return errors.New("posts admin: form model is not a post")
|
||||
}
|
||||
return preparePostWrite(ctx, post)
|
||||
}
|
||||
|
||||
func preparePostWrite(ctx context.Context, post *models.Post) error {
|
||||
if err := refuseUnpublishedWrite(ctx, post); err != nil {
|
||||
return err
|
||||
}
|
||||
if post.Published && post.PublishedAt == nil {
|
||||
return &cabana.ValidationError{Details: map[string]any{
|
||||
"published_at": []string{"golem15.journal::lang.post.published_validation"},
|
||||
}}
|
||||
}
|
||||
html, err := classes.FormatHTML(post.Content)
|
||||
if err != nil {
|
||||
return &cabana.ValidationError{Details: map[string]any{
|
||||
"content": []string{err.Error()},
|
||||
}}
|
||||
}
|
||||
post.ContentHTML = html
|
||||
return nil
|
||||
}
|
||||
|
||||
func refuseUnpublishedWrite(ctx context.Context, post *models.Post) error {
|
||||
if !post.Published && post.PublishedAt == nil {
|
||||
return nil
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if cabana.Allows(principal, []string{PermissionAccessPublish}) {
|
||||
return nil
|
||||
}
|
||||
return &cabana.ForbiddenError{Message: "golem15.journal::lang.journal.access_publish"}
|
||||
}
|
||||
14
controllers/posts/config_filter.yaml
Normal file
14
controllers/posts/config_filter.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
scopes:
|
||||
category:
|
||||
label: golem15.journal::lang.posts.filter_category
|
||||
modelClass: Golem15\Journal\Models\Category
|
||||
nameFrom: name
|
||||
scope: FilterCategories
|
||||
published:
|
||||
label: golem15.journal::lang.posts.filter_published
|
||||
type: switch
|
||||
column: published
|
||||
published_date:
|
||||
label: golem15.journal::lang.posts.filter_date
|
||||
type: daterange
|
||||
column: created_at
|
||||
19
controllers/posts/config_form.yaml
Normal file
19
controllers/posts/config_form.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: golem15.journal::lang.journal.create_post
|
||||
form: ~/plugins/golem15/journal/models/post/fields.yaml
|
||||
modelClass: Golem15\Journal\Models\Post
|
||||
defaultRedirect: golem15/journal/posts
|
||||
|
||||
create:
|
||||
redirect: golem15/journal/posts/update/:id
|
||||
redirectClose: golem15/journal/posts
|
||||
|
||||
update:
|
||||
redirect: golem15/journal/posts
|
||||
redirectClose: golem15/journal/posts
|
||||
|
||||
messages:
|
||||
create: golem15.journal::lang.posts.new_post
|
||||
update: backend::lang.messages.form.update
|
||||
saved: backend::lang.messages.form.saved
|
||||
deleteConfirm: golem15.journal::lang.post.delete_confirm
|
||||
deleted: backend::lang.messages.form.deleted
|
||||
26
controllers/posts/config_list.yaml
Normal file
26
controllers/posts/config_list.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
list: ~/plugins/golem15/journal/models/post/columns.yaml
|
||||
modelClass: Golem15\Journal\Models\Post
|
||||
title: golem15.journal::lang.post.label_plural
|
||||
recordUrl: golem15/journal/posts/update/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 25
|
||||
showCheckboxes: true
|
||||
showSetup: true
|
||||
showSorting: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
defaultSort:
|
||||
column: published_at
|
||||
direction: desc
|
||||
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
create: golem15.journal::lang.posts.new_post
|
||||
empty: backend::lang.list.no_records
|
||||
deleteConfirm: golem15.journal::lang.post.delete_confirm
|
||||
deleted: backend::lang.messages.list.deleted
|
||||
rowStateDisabled: golem15.journal::lang.journal.chart_drafts
|
||||
Reference in New Issue
Block a user