feat: announce material revisions on materials:{id}
- every revision bump (API writes and /bmadmin saves) enqueues
updated.jz.bm.material with {material_id, revision} in a savepoint
- the materials namespace authorizer allows a subscribe only to a
visible material the user may manage
- api.ManagesMaterials and api.CanWatchMaterial
This commit is contained in:
@@ -78,6 +78,9 @@ type Handlers struct {
|
||||
// means a search (q) answers 503 and the syncs after a write are
|
||||
// skipped.
|
||||
MaterialIndex *classes.MaterialIndexer
|
||||
// Revisions publishes the live heads-up after a write that bumped a
|
||||
// material's revision (D-20). Nil means no live event.
|
||||
Revisions *classes.MaterialRevisionNotifier
|
||||
}
|
||||
|
||||
func (h *Handlers) now() time.Time {
|
||||
|
||||
@@ -170,6 +170,12 @@ func replaceMaterialShares(tx *gorm.DB, materialID uint, userIDs []uint) error {
|
||||
|
||||
// managesMaterials tells whether the user holds jz.bm.access_materials.
|
||||
func (h *Handlers) managesMaterials(ctx context.Context, db *gorm.DB, userID uint) (bool, error) {
|
||||
return ManagesMaterials(ctx, db, userID)
|
||||
}
|
||||
|
||||
// ManagesMaterials tells whether the user holds jz.bm.access_materials. The
|
||||
// realtime connection-token guard admits such users (D-20).
|
||||
func ManagesMaterials(ctx context.Context, db *gorm.DB, userID uint) (bool, error) {
|
||||
principal, err := userclasses.PrincipalByID(ctx, db, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -177,6 +183,26 @@ func (h *Handlers) managesMaterials(ctx context.Context, db *gorm.DB, userID uin
|
||||
return principal != nil && cabana.Allows(principal, []string{controllers.PermMaterials}), nil
|
||||
}
|
||||
|
||||
// CanWatchMaterial tells whether the user may follow material id's live
|
||||
// revision channel (D-20): the material is visible to them at now and they
|
||||
// may manage it (canManageMaterial). It reads current state on every call;
|
||||
// a material they cannot see is false without an error.
|
||||
func CanWatchMaterial(ctx context.Context, db *gorm.DB, now time.Time, userID, id uint) (bool, error) {
|
||||
var m models.Material
|
||||
res := visibleMaterials(db.WithContext(ctx), now, userID).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return false, nil
|
||||
}
|
||||
manager, err := ManagesMaterials(ctx, db, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return canManageMaterial(m, userID, manager), nil
|
||||
}
|
||||
|
||||
// canManageMaterial tells whether a material manager may change the
|
||||
// material's access: it is theirs, or it has no author yet (the first change
|
||||
// makes them its author).
|
||||
|
||||
@@ -27,10 +27,18 @@ func staleRevision(sent *uint, stored uint) bool {
|
||||
|
||||
// revise bumps the revision of material id inside tx when its content
|
||||
// differs from before (models.ReviseMaterial). Every content writer of the
|
||||
// API calls it after its write.
|
||||
// API calls it after its write. A bump also enqueues the live heads-up on
|
||||
// tx (D-20), published only when the write commits; its failure never fails
|
||||
// the write.
|
||||
func (h *Handlers) revise(ctx context.Context, tx *gorm.DB, id uint, before string) error {
|
||||
_, _, err := models.ReviseMaterial(tx.WithContext(ctx), id, before)
|
||||
return err
|
||||
rev, bumped, err := models.ReviseMaterial(tx.WithContext(ctx), id, before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bumped {
|
||||
h.Revisions.Revised(ctx, tx, id, rev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeConflict answers 409 to a write that sent a stale revision, with the
|
||||
|
||||
@@ -50,6 +50,29 @@ func installedMaterialIndex() MaterialIndex {
|
||||
return h.ix
|
||||
}
|
||||
|
||||
// MaterialRevisionNotifier publishes the live heads-up after an admin save
|
||||
// bumped a material's revision (classes.MaterialRevisionNotifier). It
|
||||
// enqueues on tx and never fails the save.
|
||||
type MaterialRevisionNotifier interface {
|
||||
Revised(ctx context.Context, tx *gorm.DB, id, revision uint)
|
||||
}
|
||||
|
||||
type materialRevisionNotifierHolder struct{ n MaterialRevisionNotifier }
|
||||
|
||||
var materialRevisionNotifier atomic.Value
|
||||
|
||||
// UseMaterialRevisionNotifier installs the notifier the materials admin
|
||||
// calls after a save bumped the revision; nil turns it off. Boot installs
|
||||
// it, as UseMaterialIndex.
|
||||
func UseMaterialRevisionNotifier(n MaterialRevisionNotifier) {
|
||||
materialRevisionNotifier.Store(materialRevisionNotifierHolder{n: n})
|
||||
}
|
||||
|
||||
func installedMaterialRevisionNotifier() MaterialRevisionNotifier {
|
||||
h, _ := materialRevisionNotifier.Load().(materialRevisionNotifierHolder)
|
||||
return h.n
|
||||
}
|
||||
|
||||
// FormAfterCreate queues the file text job in the save transaction (the
|
||||
// form's deferred files commit in the same one) and syncs the new material
|
||||
// once it commits.
|
||||
@@ -88,7 +111,8 @@ func (c materialsAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
}
|
||||
|
||||
// FormAfterUpdate bumps the revision when the save changed the material's
|
||||
// content, then queues the file text job in the save transaction and syncs
|
||||
// content, and then enqueues the live heads-up on the save transaction
|
||||
// (D-20), then queues the file text job in the save transaction and syncs
|
||||
// the material once it commits. The compare runs here, not in
|
||||
// FormBeforeUpdate, because the course, the zjazd and the tags are final
|
||||
// only after the row write and the pivot sync. A revise error fails the save
|
||||
@@ -102,9 +126,13 @@ func (materialsAdmin) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
}
|
||||
if pending, found := pendingRevisions.LoadAndDelete(m.ID); found {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||
if _, _, err := models.ReviseMaterial(tx, m.ID, pending.(string)); err != nil {
|
||||
rev, bumped, err := models.ReviseMaterial(tx, m.ID, pending.(string))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n := installedMaterialRevisionNotifier(); bumped && n != nil {
|
||||
n.Revised(ctx, tx, m.ID, rev)
|
||||
}
|
||||
}
|
||||
}
|
||||
return materialAfterCommit(ctx, model, false)
|
||||
|
||||
@@ -36,6 +36,14 @@ func (r *recordingIndex) QueueExtract(_ context.Context, tx *gorm.DB, id uint) {
|
||||
|
||||
func itoa(id uint) string { return strconv.FormatUint(uint64(id), 10) }
|
||||
|
||||
// recordingNotifier records the live revision events of the materials
|
||||
// admin.
|
||||
type recordingNotifier struct{ calls []string }
|
||||
|
||||
func (r *recordingNotifier) Revised(_ context.Context, _ *gorm.DB, id, revision uint) {
|
||||
r.calls = append(r.calls, itoa(id)+"@"+itoa(revision))
|
||||
}
|
||||
|
||||
func TestMaterialsAdminHooks(t *testing.T) {
|
||||
t.Cleanup(func() { UseMaterialIndex(nil) })
|
||||
var c materialsAdmin
|
||||
@@ -95,8 +103,13 @@ func TestMaterialsAdminHooks(t *testing.T) {
|
||||
// store no snapshot, a model that is not a material is an error, and the
|
||||
// embedded date check still runs first.
|
||||
func TestMaterialsAdminRevision(t *testing.T) {
|
||||
t.Cleanup(func() { UseMaterialIndex(nil) })
|
||||
t.Cleanup(func() {
|
||||
UseMaterialIndex(nil)
|
||||
UseMaterialRevisionNotifier(nil)
|
||||
})
|
||||
UseMaterialIndex(nil)
|
||||
notifier := &recordingNotifier{}
|
||||
UseMaterialRevisionNotifier(notifier)
|
||||
var c materialsAdmin
|
||||
ctx := context.Background()
|
||||
m := &models.Material{ID: 41, Title: "Notatka", Type: "notatka"}
|
||||
@@ -110,6 +123,18 @@ func TestMaterialsAdminRevision(t *testing.T) {
|
||||
if err := c.FormAfterUpdate(ctx, m); err != nil {
|
||||
t.Errorf("after update without a transaction: %v", err)
|
||||
}
|
||||
// Outside a write nothing is revised, so nothing is announced.
|
||||
if len(notifier.calls) != 0 {
|
||||
t.Errorf("notifier called outside a transaction: %v", notifier.calls)
|
||||
}
|
||||
// Turning the notifier off is safe.
|
||||
UseMaterialRevisionNotifier(nil)
|
||||
if n := installedMaterialRevisionNotifier(); n != nil {
|
||||
t.Errorf("notifier after UseMaterialRevisionNotifier(nil) = %v, want nil", n)
|
||||
}
|
||||
if err := c.FormAfterUpdate(ctx, m); err != nil {
|
||||
t.Errorf("after update without a notifier: %v", err)
|
||||
}
|
||||
|
||||
if err := c.FormBeforeUpdate(ctx, &models.Course{ID: 41}); err == nil {
|
||||
t.Error("before update with a course: no error")
|
||||
|
||||
Reference in New Issue
Block a user