feat: manage material visibility and shares
The management save/update body takes is_private, author_user_id and
share_user_ids (absent keeps each; a new material gets the caller as
author), and the managed material shows them. New routes PATCH
/materials/{id}/visibility, PUT /materials/{id}/shares and GET /users,
with the MCP tools bm_manage_set_material_visibility,
bm_manage_set_material_shares and bm_manage_list_users. The /bmadmin
form gets the private switch, the author picker and shared_with.
This commit is contained in:
47
README.md
47
README.md
@@ -20,7 +20,8 @@ Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/W
|
||||
- A subscribable calendar feed (`/_bm/calendar/<token>.ics`) of the class sessions, meetings and deadlines, see [Calendar feed](#calendar-feed).
|
||||
- Courses that share an `elective_group` are alternatives ("do wyboru"), such as the specialisation courses.
|
||||
- A material without `published_at` is a draft; a material whose `published_at` lies in the future is not shown yet.
|
||||
- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress and ownership rows; deleting a user deletes the user's calendar feed and checklists; deleting a checklist deletes its items; deleting a meeting, course, reading, deadline or material unlinks the checklists and items that point at it.
|
||||
- A material may be private (`is_private`): only its author (`author_user_id`) and the users it is shared with (`jz_bm_material_shares`) see it, see [Private materials](#private-materials). Existing materials are public and have no author.
|
||||
- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress and ownership rows; deleting a user deletes the user's calendar feed and checklists; deleting a checklist deletes its items; deleting a meeting, course, reading, deadline or material unlinks the checklists and items that point at it; deleting a material or a user deletes their `jz_bm_material_shares` rows, and deleting a material's author clears `author_user_id`.
|
||||
- Admin screens for every model in a "Studia BM" navigation group, guarded by three permissions.
|
||||
- `bm:seed`: an idempotent upsert of meetings, courses, class sessions, deadlines and readings from a JSON file, all or nothing, with `--prune` to delete what the file no longer lists and `--dry-run` to preview.
|
||||
|
||||
@@ -45,8 +46,8 @@ Every route except the calendar feed itself needs `Authorization: Bearer <golem1
|
||||
| DELETE | `/_bm/api/v1/readings/{id}/progress` | | Clears the mark (idempotent) |
|
||||
| PUT | `/_bm/api/v1/readings/{id}/owned` | | Marks the reading owned (idempotent) |
|
||||
| DELETE | `/_bm/api/v1/readings/{id}/owned` | | Clears the owned mark (idempotent) |
|
||||
| GET | `/_bm/api/v1/materials` | `course`, `meeting`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of published materials |
|
||||
| GET | `/_bm/api/v1/materials/{id}` | | One published material with body, tags and files |
|
||||
| GET | `/_bm/api/v1/materials` | `course`, `meeting`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of the published materials the caller may see |
|
||||
| GET | `/_bm/api/v1/materials/{id}` | | One published material the caller may see, with body, tags, files and `can_manage` |
|
||||
| GET | `/_bm/api/v1/checklists` | `meeting` (ID) | The caller's checklists with progress, without items |
|
||||
| POST | `/_bm/api/v1/checklists` | | Creates a checklist for the caller; 201 |
|
||||
| GET | `/_bm/api/v1/checklists/{id}` | | One of the caller's checklists with progress and items |
|
||||
@@ -61,6 +62,8 @@ Every route except the calendar feed itself needs `Authorization: Bearer <golem1
|
||||
| PUT | `/_bm/api/v1/calendar-feed` | | Creates the feed when missing, applies the settings in the body |
|
||||
| POST | `/_bm/api/v1/calendar-feed/rotate` | | A new token (the old URL stops working); 404 without a feed; `throttle:10,1` |
|
||||
| DELETE | `/_bm/api/v1/calendar-feed` | | Deletes the feed; 204, also when there was none |
|
||||
| GET | `/_bm/api/v1/materials/{id}/access` | | The material's visibility, shares and the users to pick from; the author only, see [Private materials](#private-materials) |
|
||||
| PUT | `/_bm/api/v1/materials/{id}/access` | | Sets the visibility and the shares; the author only |
|
||||
|
||||
### Shared shapes
|
||||
|
||||
@@ -100,14 +103,14 @@ A reading (`read_at` is the caller's, or null; `meeting_id` is the zjazd the rea
|
||||
"is_required": true, "sort_order": 1, "read_at": "2026-10-02T21:15:04+02:00"}
|
||||
```
|
||||
|
||||
A material in a list (`type` is one of `pdf`, `notatka`, `link`, `inne`; `files_count` counts public files; `meeting_id` is the zjazd the material is meant for, null when none):
|
||||
A material in a list (`type` is one of `pdf`, `notatka`, `link`, `inne`; `files_count` counts public files; `meeting_id` is the zjazd the material is meant for, null when none; `is_private` is true for a private material, which only its author and the users it is shared with get):
|
||||
|
||||
```json
|
||||
{"id": 1, "course_id": 1, "course": {…}, "meeting_id": null, "title": "…", "type": "notatka", "source": null, "url": null,
|
||||
"published_at": "2026-10-01T12:00:00+02:00", "tags": [{"id": 1, "name": "Geopolityka", "slug": "geopolityka"}], "files_count": 1}
|
||||
"published_at": "2026-10-01T12:00:00+02:00", "tags": [{"id": 1, "name": "Geopolityka", "slug": "geopolityka"}], "files_count": 1, "is_private": false}
|
||||
```
|
||||
|
||||
A course with counters (`readings_read` is the caller's; `materials` counts published materials):
|
||||
A course with counters (`readings_read` is the caller's; `materials` counts the published materials the caller may see):
|
||||
|
||||
```json
|
||||
{"id": 1, "code": null, "name": "…", "form": "wyklad", "lecturer": "…", "semester": 1, "ects": 8,
|
||||
@@ -178,13 +181,31 @@ Bodies are JSON objects of columns checked like the management API: keys outside
|
||||
{"data": [<material>, …], "meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}}
|
||||
```
|
||||
|
||||
Newest publication first. `meeting` keeps the materials assigned to that zjazd (meeting id); like every filter it applies to published materials only. The detail adds the Markdown body and the public files; `url` starts with `storage.uploads.public_path_prefix`:
|
||||
Newest publication first. `meeting` keeps the materials assigned to that zjazd (meeting id); like every filter it applies to published materials only. The detail adds the Markdown body and the public files; `url` starts with `storage.uploads.public_path_prefix`. `can_manage` is true when the caller may change the material's access (see below):
|
||||
|
||||
```json
|
||||
{"data": {…material…, "body": "# …",
|
||||
"files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}]}}
|
||||
"files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}],
|
||||
"can_manage": false}}
|
||||
```
|
||||
|
||||
### Private materials
|
||||
|
||||
A private material (`is_private`) is visible only to its author (`author_user_id`) and to the users it is shared with (`jz_bm_material_shares`); a public one to every student. The rule is one SQL fragment (`visibleMaterialSQL` in `controllers/api/material_access.go`) applied to every student read: `/materials`, `/materials/{id}`, the materials of `/courses/{id}`, the `materials` counters of `/courses` and `/courses/{id}`, the material linked from a checklist item, and through the token twins the student MCP tools. For anyone else a private material is a 404 and is not counted.
|
||||
|
||||
`can_manage` is true when the caller holds `jz.bm.access_materials` and is the material's author, or the material has no author yet. Such a caller uses the access routes (jwt only, no token twin; tokens and MCP use the management API):
|
||||
|
||||
```json
|
||||
GET /_bm/api/v1/materials/{id}/access
|
||||
{"data": {"material_id": 4, "is_private": true, "author_user_id": 1,
|
||||
"shares": [{"id": 2, "name": "Anna Nowak", "email": "anna@example.test"}],
|
||||
"users": [{"id": 2, "name": "Anna Nowak", "email": "anna@example.test"}, …]}}
|
||||
```
|
||||
|
||||
`users` is every user but the caller, by name; `name` is "name surname", else the email. `PUT` takes `{"is_private": true, "user_ids": [2, 5]}`: an absent key keeps the stored value, and `user_ids` (null meaning none) replaces the shares. Every id must be an existing user, at most 200 (else a 422 on `user_ids`); an unknown key such as `author_user_id` is a 422; the body is capped at 16 KB. Anyone else, including another manager when the material has an author, gets a 404, never a 403. **The claim rule:** the first access change of a material with no author makes the caller its author, so a private material is never invisible to everyone. `/bmadmin` can reassign the author.
|
||||
|
||||
The files of a private material are still public storage URLs: the API never shows them to a user who cannot see the material, but a leaked or guessed URL still downloads.
|
||||
|
||||
### Calendar feed
|
||||
|
||||
The calendar-feed routes answer the caller's feed:
|
||||
@@ -231,6 +252,9 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim
|
||||
| POST | `/api/v1/bm/materials/{id}/files/inline` | write, materials | JSON `{"name": "notatki.pdf", "mime": "application/pdf", "content_base64": "…"}` (`mime` optional; unknown keys are a 422): one file of at most 3000000 bytes decoded (`MaterialInlineMaxBytes`, so it fits under the MCP endpoint's 4 MiB request cap), stored like a multipart file; a sent `mime` must match the stored content type. Larger content is a 422 on `content_base64` that points at the upload URL. 201 with the material as GET shows it plus a top-level `warning`; 413 over the body cap (`body.limit`, 4300000). |
|
||||
| POST | `/api/v1/bm/materials/{id}/files/upload-url` | write, materials | 201 `{"data": {"upload_url", "expires_at", "max_bytes", "accept", "curl_example", "warning"}}`: a signed, single-use URL for one file of this material by the caller, valid 10 minutes, at most 50 MiB (`max_bytes` 52428800). 503 when `app.key` or the cache is missing. See [Signed uploads](#signed-uploads). |
|
||||
| DELETE | `/api/v1/bm/materials/{id}/files/{file}` | write, materials | 204: the file row, then its blob and thumbnails; 404 when the material or a file of it is unknown. |
|
||||
| PATCH | `/api/v1/bm/materials/{id}/visibility` | write, materials | `{"is_private": true}` (required): 200 with the material; a material with no author gets the caller as author; 404 when unknown. |
|
||||
| PUT | `/api/v1/bm/materials/{id}/shares` | write, materials | `{"user_ids": [2, 5]}` (the key is required, `null` or `[]` removes every share): the list replaces the shares; 422 on `user_ids` for an unknown user or more than 200; the claim rule as above; body capped at 16 KB. |
|
||||
| GET | `/api/v1/bm/users` | read, materials | `[{"id", "name", "email"}, …]` of every user, to pick an author or shares. |
|
||||
| GET | `/api/v1/bm/{records}` | read, see below | Every row of the table, filtered by `?column=value` on the columns listed below (`null` matches NULL). |
|
||||
| GET | `/api/v1/bm/{records}/{id}` | read | One row; 404 when unknown. |
|
||||
| POST | `/api/v1/bm/{records}` | write | 201 with the new row. |
|
||||
@@ -263,6 +287,7 @@ The material body (POST and PUT), unknown keys are a 422:
|
||||
- `meeting_id`: absent keeps the stored meeting (so scripts that never send it keep working), `null` clears it, otherwise an existing meeting (else a 422 on `meeting_id`). The managed material JSON shows `meeting_id`, and the material then lists under that zjazd in `GET /_bm/api/v1/materials?meeting=`.
|
||||
- `published_at`: absent publishes a new material now and keeps an existing one as it is; `null` makes it a draft; otherwise a time as in the seed file.
|
||||
- `tags`: absent keeps the tags; otherwise the list of names replaces them, and unknown tags are created (slug from the name, Polish letters folded).
|
||||
- `is_private`: absent keeps it (a new material is public). `author_user_id`: absent keeps it (a new material gets the token user as author), `null` clears it, otherwise an existing user (else a 422 on `author_user_id`). `share_user_ids`: absent keeps the shares; otherwise the list of user ids replaces them (else a 422 on `share_user_ids`, as for the access routes). The managed material JSON shows `is_private`, `author_user_id`, `author` (`{"id", "name", "email"}` or null) and `shares` (a list, never null).
|
||||
- Files go through `POST /materials/{id}/files`, `/files/inline` or a signed upload. All three use one helper, `storeMaterialFile`, which applies the 20-file cap, stores through `attach.Store` (the default document and image extensions, content type sniffed from the bytes, a random disk name; `file_name` keeps the client's name) and binds the file to the material; a refused or failed store deletes the blobs it wrote. The file `url` is under `storage.uploads.public_path_prefix`, served by the application when `storage.uploads.serve` is true (the app's `config/storage.yaml` sets it): GET only, `is_public` rows only, anything but a raster image as `Content-Disposition: attachment`.
|
||||
|
||||
### Signed uploads
|
||||
@@ -295,11 +320,11 @@ The route is throttled per client IP (`throttle:jz.bm.upload-ip`, 10 a minute) a
|
||||
| class sessions | `bm_manage_list_class_sessions`, `_show_class_session`, `_create_class_session`, `_update_class_session`, `_delete_class_session` | `jz.bm.access_schedule` |
|
||||
| readings | `bm_manage_list_readings`, `_show_reading`, `_create_reading`, `_update_reading`, `_delete_reading` | `jz.bm.access_courses` |
|
||||
| deadlines | `bm_manage_list_deadlines`, `_show_deadline`, `_create_deadline`, `_update_deadline`, `_delete_deadline` | `jz.bm.access_schedule` |
|
||||
| materials | `bm_manage_list_materials`, `_show_material`, `_save_material` (upsert on title and course), `_update_material`, `_delete_material` | `jz.bm.access_materials` |
|
||||
| materials | `bm_manage_list_materials`, `_show_material`, `_save_material` (upsert on title and course), `_update_material`, `_delete_material`, `_set_material_visibility`, `_set_material_shares`, `_list_users` | `jz.bm.access_materials` |
|
||||
|
||||
| material files | `bm_manage_attach_material_file` (base64, at most 3000000 bytes), `_create_material_upload` (signed one-time URL, then curl), `_detach_material_file` (destructive) | `jz.bm.access_materials` |
|
||||
|
||||
The file tools return metadata and links, never file contents, and their descriptions carry the public-file warning. `POST /seed` and `GET /me` are not tools. Courses and meetings have no delete tool, because the route has none. The material save and update tools require `body`, `source` and `url` (each may be `null`), because the route replaces them as sent; their descriptions tell the agent to read the material with `include_body` first to keep its body.
|
||||
The file tools return metadata and links, never file contents, and their descriptions carry the public-file warning. `POST /seed` and `GET /me` are not tools. Courses and meetings have no delete tool, because the route has none. The material save and update tools require `body`, `source` and `url` (each may be `null`), because the route replaces them as sent; their descriptions tell the agent to read the material with `include_body` first to keep its body. They also take `is_private`, `author_user_id` and `share_user_ids` (absent keeps each). `bm_manage_set_material_visibility` and `bm_manage_set_material_shares` change only the visibility or the shares (ids from `bm_manage_list_users`), with the claim rule of the access routes. The student tools `bm_materials`, `bm_material` and `bm_course` show only the materials the token's user may see.
|
||||
|
||||
**The MCP view.** Only an answer to an MCP tool call (a request `pier` dispatched in-process, carrying the `api_token` dispatch marker, which an HTTP request can never carry) is changed; plain HTTP answers are byte for byte as before.
|
||||
|
||||
@@ -321,7 +346,7 @@ Controllers (admin API path `<backend.uri>/api/v1/jz/bm/<name>`), all in the "St
|
||||
| `jz.bm.meetings`, `jz.bm.class_sessions`, `jz.bm.deadlines` | `jz.bm.access_schedule` |
|
||||
| `jz.bm.materials`, `jz.bm.tags` | `jz.bm.access_materials` |
|
||||
|
||||
Courses, meetings, sessions and deadlines are picked through relation fields; a material's tags through a belongsToMany relation field. Dates and times are edited with date pickers (`type: datepicker`); the admin shows times in the browser's time zone and stores them in UTC. Labels are in Polish (`lang/pl`) and English (`lang/en`).
|
||||
Courses, meetings, sessions and deadlines are picked through relation fields; a material's tags through a belongsToMany relation field. The material form has the `is_private` switch, the author picker (`author`, a belongsTo of `users` on `author_user_id`, by email) and `shared_with` (a belongsToMany of `users` through `jz_bm_material_shares`, by email); the list shows `is_private`. Dates and times are edited with date pickers (`type: datepicker`); the admin shows times in the browser's time zone and stores them in UTC. Labels are in Polish (`lang/pl`) and English (`lang/en`).
|
||||
|
||||
A material's files are uploaded in its form (`type: fileupload` on the attachMany `files`, public, up to 20 files of 50 MB). The course form embeds relation managers for its readings and class sessions, which create, edit and delete them, also before the course is first saved: `course_id` on both tables is nullable for that, the pending rows are bound to the form session and get the course on its first save, and `deferred:purge` deletes abandoned ones. The student API leaves out sessions and readings without a course. A class session added from the course form has no meeting; set it on the class sessions screen.
|
||||
|
||||
|
||||
@@ -136,7 +136,8 @@ func (h *Handlers) ApplySeed(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// managedMaterialJSON is a material as the management API shows it: drafts
|
||||
// too, so published_at may be null.
|
||||
// too, so published_at may be null, and private ones with their author and
|
||||
// the users they are shared with.
|
||||
type managedMaterialJSON struct {
|
||||
ID uint `json:"id"`
|
||||
CourseID *uint `json:"course_id"`
|
||||
@@ -149,12 +150,54 @@ type managedMaterialJSON struct {
|
||||
URL *string `json:"url"`
|
||||
PublishedAt *string `json:"published_at"`
|
||||
Tags []tagJSON `json:"tags"`
|
||||
|
||||
IsPrivate bool `json:"is_private"`
|
||||
AuthorUserID *uint `json:"author_user_id"`
|
||||
Author *userRefJSON `json:"author"`
|
||||
Shares []userRefJSON `json:"shares"`
|
||||
}
|
||||
|
||||
func newManagedMaterialJSON(m models.Material) managedMaterialJSON {
|
||||
// materialPeople are the authors and shares of a batch of materials, as
|
||||
// loadMaterialPeople reads them.
|
||||
type materialPeople struct {
|
||||
authors map[uint]userRefJSON
|
||||
shares map[uint][]userRefJSON
|
||||
}
|
||||
|
||||
// loadMaterialPeople reads the authors and the shares of rows in two
|
||||
// queries plus one for the shared users.
|
||||
func loadMaterialPeople(db *gorm.DB, rows ...models.Material) (materialPeople, error) {
|
||||
var authorIDs, materialIDs []uint
|
||||
for _, m := range rows {
|
||||
materialIDs = append(materialIDs, m.ID)
|
||||
if m.AuthorUserID != nil {
|
||||
authorIDs = append(authorIDs, *m.AuthorUserID)
|
||||
}
|
||||
}
|
||||
authors, err := userRefsByID(db, authorIDs)
|
||||
if err != nil {
|
||||
return materialPeople{}, err
|
||||
}
|
||||
shares, err := materialShareRefs(db, materialIDs)
|
||||
if err != nil {
|
||||
return materialPeople{}, err
|
||||
}
|
||||
return materialPeople{authors: authors, shares: shares}, nil
|
||||
}
|
||||
|
||||
func newManagedMaterialJSON(m models.Material, people materialPeople) managedMaterialJSON {
|
||||
out := managedMaterialJSON{
|
||||
ID: m.ID, CourseID: m.CourseID, Course: newCourseRef(m.Course), MeetingID: m.MeetingID, Title: m.Title, Type: m.Type,
|
||||
Body: m.Body, Source: m.Source, URL: m.URL, Tags: newTagsJSON(m.Tags),
|
||||
IsPrivate: m.IsPrivate, AuthorUserID: m.AuthorUserID, Shares: people.shares[m.ID],
|
||||
}
|
||||
if m.AuthorUserID != nil {
|
||||
if a, ok := people.authors[*m.AuthorUserID]; ok {
|
||||
out.Author = &a
|
||||
}
|
||||
}
|
||||
if out.Shares == nil {
|
||||
out.Shares = []userRefJSON{}
|
||||
}
|
||||
if m.PublishedAt != nil {
|
||||
s := models.FormatDateTime(*m.PublishedAt)
|
||||
@@ -176,9 +219,14 @@ func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
people, err := loadMaterialPeople(db, rows...)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := make([]any, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
out = append(out, managedMaterialView(newManagedMaterialJSON(m), viaMCP(r), includeBody(r)))
|
||||
out = append(out, managedMaterialView(newManagedMaterialJSON(m, people), viaMCP(r), includeBody(r)))
|
||||
}
|
||||
writeData(w, out)
|
||||
}
|
||||
@@ -194,6 +242,10 @@ type materialInput struct {
|
||||
URL *string `json:"url"`
|
||||
PublishedAt *string `json:"published_at"`
|
||||
Tags *[]string `json:"tags"`
|
||||
|
||||
IsPrivate *bool `json:"is_private"`
|
||||
AuthorUserID *uint `json:"author_user_id"`
|
||||
ShareUserIDs *[]uint `json:"share_user_ids"`
|
||||
}
|
||||
|
||||
// materialDraft is a decoded and validated material body, ready to apply.
|
||||
@@ -206,8 +258,13 @@ type materialDraft struct {
|
||||
// one: absent keeps the stored meeting, so scripts that never send it
|
||||
// keep working.
|
||||
hasMeetingID bool
|
||||
publishedAt *time.Time
|
||||
tags []models.Tag
|
||||
// hasAuthor tells a sent author_user_id (null included) from an absent
|
||||
// one.
|
||||
hasAuthor bool
|
||||
publishedAt *time.Time
|
||||
tags []models.Tag
|
||||
// shares is the checked share_user_ids, when sent.
|
||||
shares []uint
|
||||
}
|
||||
|
||||
// readMaterial decodes and validates the body of POST /materials and
|
||||
@@ -229,6 +286,7 @@ func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm
|
||||
_ = json.Unmarshal(raw, &keys)
|
||||
_, d.hasPublishedAt = keys["published_at"]
|
||||
_, d.hasMeetingID = keys["meeting_id"]
|
||||
_, d.hasAuthor = keys["author_user_id"]
|
||||
|
||||
in := &d.in
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
@@ -285,6 +343,27 @@ func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm
|
||||
errs["meeting_id"] = []string{"The selected meeting_id is invalid."}
|
||||
}
|
||||
}
|
||||
if in.AuthorUserID != nil {
|
||||
found, err := userRefsByID(db, []uint{*in.AuthorUserID})
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return d, false
|
||||
}
|
||||
if _, ok := found[*in.AuthorUserID]; !ok {
|
||||
errs["author_user_id"] = []string{"The selected author_user_id is invalid."}
|
||||
}
|
||||
}
|
||||
if in.ShareUserIDs != nil {
|
||||
clean, msg, err := cleanUserIDs(db, *in.ShareUserIDs)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return d, false
|
||||
}
|
||||
if msg != "" {
|
||||
errs["share_user_ids"] = []string{msg}
|
||||
}
|
||||
d.shares = clean
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs)
|
||||
return d, false
|
||||
@@ -306,14 +385,27 @@ func (d materialDraft) identity(q *gorm.DB) *gorm.DB {
|
||||
// a new material now and keeps an existing one as it is; null makes a
|
||||
// draft; otherwise the sent time. meeting_id: absent keeps it; null clears
|
||||
// it. tags: absent keeps them; otherwise the list replaces them, and unknown
|
||||
// tags are created.
|
||||
func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now time.Time) error {
|
||||
// tags are created. is_private: absent keeps it (a new material is public).
|
||||
// author_user_id: absent keeps it (a new material gets callerID); null
|
||||
// clears it. share_user_ids: absent keeps the shares; otherwise the list
|
||||
// replaces them.
|
||||
func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now time.Time, callerID uint) error {
|
||||
in := d.in
|
||||
m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type
|
||||
m.Body, m.Source, m.URL = in.Body, in.Source, in.URL
|
||||
if d.hasMeetingID {
|
||||
m.MeetingID = in.MeetingID
|
||||
}
|
||||
if in.IsPrivate != nil {
|
||||
m.IsPrivate = *in.IsPrivate
|
||||
}
|
||||
switch {
|
||||
case d.hasAuthor:
|
||||
m.AuthorUserID = in.AuthorUserID
|
||||
case created && callerID != 0:
|
||||
id := callerID
|
||||
m.AuthorUserID = &id
|
||||
}
|
||||
switch {
|
||||
case d.hasPublishedAt:
|
||||
m.PublishedAt = d.publishedAt
|
||||
@@ -323,6 +415,11 @@ func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now
|
||||
if err := tx.Omit("Course", "Meeting", "Tags").Save(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if in.ShareUserIDs != nil {
|
||||
if err := replaceMaterialShares(tx, m.ID, d.shares); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if in.Tags == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -357,7 +454,12 @@ func writeManaged(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint,
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"data": managedMaterialView(newManagedMaterialJSON(out), viaMCP(r), false)}
|
||||
people, err := loadMaterialPeople(db, out)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"data": managedMaterialView(newManagedMaterialJSON(out, people), viaMCP(r), false)}
|
||||
for k, v := range extra {
|
||||
payload[k] = v
|
||||
}
|
||||
@@ -370,6 +472,10 @@ func writeManaged(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint,
|
||||
// materialDraft.apply for published_at and tags. A renamed material is a
|
||||
// new one here: PUT /materials/{id} renames.
|
||||
func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
callerID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -386,7 +492,7 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
return res.Error
|
||||
}
|
||||
created = res.RowsAffected == 0
|
||||
return d.apply(tx, &m, created, h.now())
|
||||
return d.apply(tx, &m, created, h.now(), callerID)
|
||||
})
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
@@ -404,6 +510,10 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
// another material already has is a 422, because POST would then be
|
||||
// ambiguous. An unknown id is a 404.
|
||||
func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
callerID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -436,7 +546,7 @@ func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
taken = true
|
||||
return nil
|
||||
}
|
||||
return d.apply(tx, &m, false, h.now())
|
||||
return d.apply(tx, &m, false, h.now(), callerID)
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
@@ -497,7 +607,12 @@ func (h *Handlers) writeMaterialDetailWith(w http.ResponseWriter, r *http.Reques
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := managedMaterialDetailJSON{managedMaterialJSON: newManagedMaterialJSON(m), Files: make([]fileJSON, 0, len(files))}
|
||||
people, err := loadMaterialPeople(db, m)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := managedMaterialDetailJSON{managedMaterialJSON: newManagedMaterialJSON(m, people), Files: make([]fileJSON, 0, len(files))}
|
||||
for i := range files {
|
||||
f := &files[i]
|
||||
if !f.Public() {
|
||||
@@ -574,3 +689,121 @@ func slugify(name string) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// managedAccessChange loads the material of r by id and, unless decode
|
||||
// refuses the body, applies the access change it returns inside a
|
||||
// transaction with the claim rule (a material with no author gets the
|
||||
// caller). It answers 404, 422 or 500 itself, and on success the material as
|
||||
// writeManaged shows it, so under MCP the body is never echoed.
|
||||
func (h *Handlers) managedAccessChange(w http.ResponseWriter, r *http.Request, decode func(db *gorm.DB, raw []byte) (isPrivate *bool, setShares bool, shares []uint, ok bool)) {
|
||||
callerID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := surf.IntParam(r, "id")
|
||||
if !ok {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
var m models.Material
|
||||
res := db.Where("id = ?", id).Limit(1).Find(&m)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
raw, ok := body(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
isPrivate, setShares, shares, ok := decode(db, raw)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
|
||||
return applyMaterialAccess(tx, &m, callerID, isPrivate, setShares, shares)
|
||||
})
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeManaged(w, r, db, m.ID, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// SetMaterialVisibility answers PATCH /materials/{id}/visibility, body
|
||||
// {"is_private": bool}: private means only the author and the users it is
|
||||
// shared with see the material. A material with no author gets the caller
|
||||
// as author. An unknown id is a 404.
|
||||
func (h *Handlers) SetMaterialVisibility(w http.ResponseWriter, r *http.Request) {
|
||||
h.managedAccessChange(w, r, func(_ *gorm.DB, raw []byte) (*bool, bool, []uint, bool) {
|
||||
var in struct {
|
||||
IsPrivate *bool `json:"is_private"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&in); err != nil {
|
||||
writeInvalid(w, "invalid JSON: "+err.Error())
|
||||
return nil, false, nil, false
|
||||
}
|
||||
if in.IsPrivate == nil {
|
||||
writeValidation(w, map[string][]string{"is_private": {"The is_private field is required and must be true or false."}})
|
||||
return nil, false, nil, false
|
||||
}
|
||||
return in.IsPrivate, false, nil, true
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaterialShares answers PUT /materials/{id}/shares, body
|
||||
// {"user_ids": [id]}: the list (null meaning none) replaces the users the
|
||||
// material is shared with. The claim rule is as for SetMaterialVisibility.
|
||||
func (h *Handlers) SetMaterialShares(w http.ResponseWriter, r *http.Request) {
|
||||
h.managedAccessChange(w, r, func(db *gorm.DB, raw []byte) (*bool, bool, []uint, bool) {
|
||||
var in struct {
|
||||
UserIDs []uint `json:"user_ids"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&in); err != nil {
|
||||
writeInvalid(w, "invalid JSON: "+err.Error())
|
||||
return nil, false, nil, false
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
_ = json.Unmarshal(raw, &keys)
|
||||
if _, ok := keys["user_ids"]; !ok {
|
||||
writeValidation(w, map[string][]string{"user_ids": {"The user_ids field is required: a list of user ids, empty for none."}})
|
||||
return nil, false, nil, false
|
||||
}
|
||||
clean, msg, err := cleanUserIDs(db, in.UserIDs)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return nil, false, nil, false
|
||||
}
|
||||
if msg != "" {
|
||||
writeValidation(w, map[string][]string{"user_ids": {msg}})
|
||||
return nil, false, nil, false
|
||||
}
|
||||
return nil, true, clean, true
|
||||
})
|
||||
}
|
||||
|
||||
// ManagedUsers answers GET /users: the id, display name and email of every
|
||||
// user, to pick a material's author or shares.
|
||||
func (h *Handlers) ManagedUsers(w http.ResponseWriter, r *http.Request) {
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
users, err := listUserRefs(db, 0)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeData(w, users)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
@@ -29,8 +30,11 @@ func (materialsAdmin) RequiredPermissions() []string {
|
||||
return []string{PermMaterials}
|
||||
}
|
||||
|
||||
// AdminFieldRelations binds the course and meeting pickers and the tags. The files field
|
||||
// is bound by models.Material.AttachRelations.
|
||||
// AdminFieldRelations binds the course and meeting pickers, the tags, the
|
||||
// author picker and the users a private material is shared with. The
|
||||
// author column is author_user_id, not user_id, which the backend refuses
|
||||
// to fill from a body. The files field is bound by
|
||||
// models.Material.AttachRelations.
|
||||
func (materialsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
return []cabana.FieldRelationContract{
|
||||
courseRelation("course_id"),
|
||||
@@ -44,6 +48,22 @@ func (materialsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
RelatedForeignKey: "tag_id",
|
||||
LabelColumn: "name",
|
||||
},
|
||||
{
|
||||
Field: "author",
|
||||
Kind: "belongsTo",
|
||||
NewRelated: func() any { return &usermodels.User{} },
|
||||
ForeignKey: "author_user_id",
|
||||
LabelColumn: "email",
|
||||
},
|
||||
{
|
||||
Field: "shared_with",
|
||||
Kind: "belongsToMany",
|
||||
NewRelated: func() any { return &usermodels.User{} },
|
||||
NewPivot: func() any { return &models.MaterialShare{} },
|
||||
ParentForeignKey: "material_id",
|
||||
RelatedForeignKey: "user_id",
|
||||
LabelColumn: "email",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,12 @@ materials:
|
||||
files: Files
|
||||
published_at_comment: Leave empty for a draft that students do not see.
|
||||
tags: Tags
|
||||
is_private: Private
|
||||
is_private_comment: Only the author and the users it is shared with see a private material.
|
||||
author: Author
|
||||
author_comment: An author with the materials permission manages access in the student app.
|
||||
shared_with: Shared with
|
||||
shared_with_comment: Users who see a private material.
|
||||
material_types:
|
||||
pdf: PDF
|
||||
notatka: Note
|
||||
|
||||
@@ -91,6 +91,12 @@ materials:
|
||||
files: Pliki
|
||||
published_at_comment: Puste pole to szkic, niewidoczny dla studentów.
|
||||
tags: Tagi
|
||||
is_private: Prywatny
|
||||
is_private_comment: Prywatny materiał widzą tylko autor i osoby, którym go udostępniono.
|
||||
author: Autor
|
||||
author_comment: Autor z uprawnieniem do materiałów zarządza dostępem w aplikacji studenta.
|
||||
shared_with: Udostępniono
|
||||
shared_with_comment: Osoby, które widzą prywatny materiał.
|
||||
material_types:
|
||||
pdf: PDF
|
||||
notatka: Notatka
|
||||
|
||||
@@ -205,6 +205,52 @@ RETURNING id`).Scan(&groupID).Error; err != nil {
|
||||
if rec, _ := send(http.MethodGet, idPath(first["id"]), nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("show after delete = %d", rec.Code)
|
||||
}
|
||||
|
||||
// Visibility, author and shares: a new material is public, with the
|
||||
// token user as author and no shares.
|
||||
rec, out = send(http.MethodPost, "/api/v1/bm/materials", map[string]any{"title": "Notatki prywatne", "type": "notatka", "body": nil, "source": nil, "url": nil})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create for access = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
own := data(out)
|
||||
if own["is_private"] != false || own["author_user_id"] != float64(userID) {
|
||||
t.Errorf("new material access = is_private %v author %v, want false and %d", own["is_private"], own["author_user_id"], userID)
|
||||
}
|
||||
if shares, ok := own["shares"].([]any); !ok || len(shares) != 0 {
|
||||
t.Errorf("new material shares = %v, want []", own["shares"])
|
||||
}
|
||||
if rec, _ := send(http.MethodPut, idPath(own["id"]), map[string]any{"title": "Notatki prywatne", "type": "notatka", "body": nil, "source": nil, "url": nil, "share_user_ids": []uint{999999}}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "share_user_ids") {
|
||||
t.Errorf("unknown share user = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec, out = send(http.MethodPatch, idPath(own["id"])+"/visibility", map[string]any{"is_private": true})
|
||||
if rec.Code != http.StatusOK || data(out)["is_private"] != true {
|
||||
t.Fatalf("visibility = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var otherID uint
|
||||
if err := db.Raw(`
|
||||
INSERT INTO users (email, password, must_change_password)
|
||||
VALUES ('bm-materials-reader@example.test', '', false)
|
||||
RETURNING id`).Scan(&otherID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, out = send(http.MethodPut, idPath(own["id"])+"/shares", map[string]any{"user_ids": []uint{otherID}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("shares = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if shares, _ := data(out)["shares"].([]any); len(shares) != 1 || shares[0].(map[string]any)["id"] != float64(otherID) {
|
||||
t.Errorf("shares = %v, want the reader", data(out)["shares"])
|
||||
}
|
||||
rec, out = send(http.MethodGet, "/api/v1/bm/users", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("users = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
seen := map[float64]bool{}
|
||||
for _, u := range out["data"].([]any) {
|
||||
seen[u.(map[string]any)["id"].(float64)] = true
|
||||
}
|
||||
if !seen[float64(userID)] || !seen[float64(otherID)] {
|
||||
t.Errorf("users = %v, want both users", out["data"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagementMaterialFiles uploads files to a material through the
|
||||
|
||||
@@ -198,29 +198,47 @@ type showManagedMaterialInput struct {
|
||||
// and url have no omitempty: the route replaces them as sent, so the agent
|
||||
// must state each (null clears it).
|
||||
type saveMaterialInput struct {
|
||||
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id; together with title it identifies the material (absent or null: no course)"`
|
||||
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; absent keeps the stored one, null clears it"`
|
||||
Title string `json:"title" jsonschema:"title, 1 to 255 characters"`
|
||||
Type string `json:"type" jsonschema:"one of: pdf, notatka, link, inne"`
|
||||
Body *string `json:"body" jsonschema:"Markdown body, or null for none; replaced as sent"`
|
||||
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
|
||||
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
|
||||
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent publishes a new material now and keeps an existing one as it is; null makes a draft"`
|
||||
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
|
||||
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id; together with title it identifies the material (absent or null: no course)"`
|
||||
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; absent keeps the stored one, null clears it"`
|
||||
Title string `json:"title" jsonschema:"title, 1 to 255 characters"`
|
||||
Type string `json:"type" jsonschema:"one of: pdf, notatka, link, inne"`
|
||||
Body *string `json:"body" jsonschema:"Markdown body, or null for none; replaced as sent"`
|
||||
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
|
||||
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
|
||||
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent publishes a new material now and keeps an existing one as it is; null makes a draft"`
|
||||
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
|
||||
IsPrivate *bool `json:"is_private,omitempty" jsonschema:"true makes the material private: only its author and the users it is shared with see it; absent keeps it, a new material is public"`
|
||||
AuthorUserID *uint `json:"author_user_id,omitempty" jsonschema:"the author's user id from bm_manage_list_users; absent keeps it, a new material gets the caller as author; null clears it"`
|
||||
ShareUserIDs *[]uint `json:"share_user_ids,omitempty" jsonschema:"user ids from bm_manage_list_users the material is shared with (at most 200); absent keeps the shares, a list replaces them"`
|
||||
}
|
||||
|
||||
// updateMaterialInput is the body of bm_manage_update_material.
|
||||
type updateMaterialInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the material id"`
|
||||
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id (absent or null: no course)"`
|
||||
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; absent keeps the stored one, null clears it"`
|
||||
Title string `json:"title" jsonschema:"title, 1 to 255 characters"`
|
||||
Type string `json:"type" jsonschema:"one of: pdf, notatka, link, inne"`
|
||||
Body *string `json:"body" jsonschema:"Markdown body, or null for none; replaced as sent"`
|
||||
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
|
||||
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
|
||||
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent keeps it; null makes a draft"`
|
||||
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
|
||||
ID uint `json:"id" path:"id" jsonschema:"the material id"`
|
||||
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id (absent or null: no course)"`
|
||||
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing meeting (zjazd) id; absent keeps the stored one, null clears it"`
|
||||
Title string `json:"title" jsonschema:"title, 1 to 255 characters"`
|
||||
Type string `json:"type" jsonschema:"one of: pdf, notatka, link, inne"`
|
||||
Body *string `json:"body" jsonschema:"Markdown body, or null for none; replaced as sent"`
|
||||
Source *string `json:"source" jsonschema:"source, at most 500 characters, or null; replaced as sent"`
|
||||
URL *string `json:"url" jsonschema:"link, at most 2048 characters, or null; replaced as sent"`
|
||||
PublishedAt *string `json:"published_at,omitempty" jsonschema:"publication time: RFC 3339, or Europe/Warsaw wall clock such as 2026-10-16 20:00; absent keeps it; null makes a draft"`
|
||||
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names (1 to 100 characters each); absent keeps the tags, a list replaces them"`
|
||||
IsPrivate *bool `json:"is_private,omitempty" jsonschema:"true makes the material private: only its author and the users it is shared with see it; absent keeps it, a new material is public"`
|
||||
AuthorUserID *uint `json:"author_user_id,omitempty" jsonschema:"the author's user id from bm_manage_list_users; absent keeps it, a new material gets the caller as author; null clears it"`
|
||||
ShareUserIDs *[]uint `json:"share_user_ids,omitempty" jsonschema:"user ids from bm_manage_list_users the material is shared with (at most 200); absent keeps the shares, a list replaces them"`
|
||||
}
|
||||
|
||||
// materialVisibilityInput is the body of bm_manage_set_material_visibility.
|
||||
type materialVisibilityInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the material id"`
|
||||
IsPrivate bool `json:"is_private" jsonschema:"true: only the author and the users it is shared with see it; false: every student sees it"`
|
||||
}
|
||||
|
||||
// materialSharesInput is the body of bm_manage_set_material_shares.
|
||||
type materialSharesInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the material id"`
|
||||
UserIDs []uint `json:"user_ids" jsonschema:"user ids from bm_manage_list_users, at most 200; the list replaces the shares, an empty list removes them"`
|
||||
}
|
||||
|
||||
// manageTools are the tools over the management API (/api/v1/bm): typed
|
||||
@@ -315,13 +333,22 @@ func manageTools() []pact.MCPTool {
|
||||
"Returns one material, draft or not, with its public files (id, absolute link, name, size and type); 404 when unknown. "+MCPMaterialPrivacy+readMaterials,
|
||||
g, "/materials/{id}", showManagedMaterialInput{}),
|
||||
tool("manage_save_material", "Save a material",
|
||||
"Creates a material, or updates the one with the same title and course (answer created true or false); a renamed material is a new one here, use bm_manage_update_material to rename."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
|
||||
"Creates a material, or updates the one with the same title and course (answer created true or false); a renamed material is a new one here, use bm_manage_update_material to rename. is_private, author_user_id and share_user_ids set its visibility, author and shares (a new material is public, with the caller as author)."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
|
||||
po, "/materials", saveMaterialInput{}),
|
||||
tool("manage_update_material", "Update a material",
|
||||
"Updates one material, so its title or course can change; 404 when unknown, 422 when another material has the same title and course."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
|
||||
"Updates one material, so its title or course can change; 404 when unknown, 422 when another material has the same title and course. is_private, author_user_id and share_user_ids change its visibility, author and shares when sent."+replacedAsSent+" "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
|
||||
pu, "/materials/{id}", updateMaterialInput{}),
|
||||
tool("manage_delete_material", "Delete a material",
|
||||
"Deletes one material with all its files; 404 when unknown."+deleteForever+" "+MCPMaterialPrivacy+writeMaterials,
|
||||
d, "/materials/{id}", idInput{}),
|
||||
tool("manage_set_material_visibility", "Set a material's visibility",
|
||||
"Makes a material private or public: private means only its author and the users it is shared with see it in the student app and the student tools, public means every student; a material without an author gets the caller as author; 404 when unknown. "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
|
||||
pa, "/materials/{id}/visibility", materialVisibilityInput{}),
|
||||
tool("manage_set_material_shares", "Share a material with users",
|
||||
"Sets the users a material is shared with: the list replaces them (an empty list removes them); shares matter only while the material is private; ids come from bm_manage_list_users; a material without an author gets the caller as author; 404 when unknown. "+MCPMaterialPrivacy+invalidAnswer+writeMaterials,
|
||||
pu, "/materials/{id}/shares", materialSharesInput{}),
|
||||
tool("manage_list_users", "List users (manage)",
|
||||
"Lists the id, display name and email of every user, to pick a material's author or the users it is shared with."+readMaterials,
|
||||
g, "/users", noInput{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func studentTools() []pact.MCPTool {
|
||||
{
|
||||
Name: "course",
|
||||
Title: "BM course",
|
||||
Description: "Returns one course with its description, syllabus link, counters, class sessions, readings (with the student's read and owned marks) and published materials." + needsScopeRead,
|
||||
Description: "Returns one course with its description, syllabus link, counters, class sessions, readings (with the student's read and owned marks) and the published materials the student may see (public ones and private ones they wrote or that are shared with them)." + needsScopeRead,
|
||||
Guard: "user",
|
||||
Method: http.MethodGet,
|
||||
Path: TokenPrefix + "/courses/{id}",
|
||||
@@ -175,7 +175,7 @@ func studentTools() []pact.MCPTool {
|
||||
{
|
||||
Name: "materials",
|
||||
Title: "BM materials",
|
||||
Description: "Lists a page of published materials, newest first, with title, type, course, zjazd, source, link, tags and file count, plus the page meta. " + MCPMaterialPrivacy + needsScopeRead,
|
||||
Description: "Lists a page of the published materials the student may see (public ones and private ones they wrote or that are shared with them), newest first, with title, type, course, zjazd, source, link, tags and file count, plus the page meta. " + MCPMaterialPrivacy + needsScopeRead,
|
||||
Guard: "user",
|
||||
Method: http.MethodGet,
|
||||
Path: TokenPrefix + "/materials",
|
||||
@@ -184,7 +184,7 @@ func studentTools() []pact.MCPTool {
|
||||
{
|
||||
Name: "material",
|
||||
Title: "BM material",
|
||||
Description: "Returns one published material with its tags and its files (id, absolute link, name, size and type). " + MCPMaterialPrivacy + needsScopeRead,
|
||||
Description: "Returns one published material the student may see (public ones and private ones they wrote or that are shared with them) with its tags and its files (id, absolute link, name, size and type). " + MCPMaterialPrivacy + needsScopeRead,
|
||||
Guard: "user",
|
||||
Method: http.MethodGet,
|
||||
Path: TokenPrefix + "/materials/{id}",
|
||||
|
||||
33
mcp_test.go
33
mcp_test.go
@@ -79,7 +79,10 @@ var pinnedTools = []string{
|
||||
"bm_manage_list_materials",
|
||||
"bm_manage_list_meetings",
|
||||
"bm_manage_list_readings",
|
||||
"bm_manage_list_users",
|
||||
"bm_manage_save_material",
|
||||
"bm_manage_set_material_shares",
|
||||
"bm_manage_set_material_visibility",
|
||||
"bm_manage_show_class_session",
|
||||
"bm_manage_show_course",
|
||||
"bm_manage_show_deadline",
|
||||
@@ -234,7 +237,8 @@ type recordModel interface {
|
||||
}
|
||||
|
||||
// manageTables maps a management path segment to its model (nil for
|
||||
// materials), its permission code and whether the route can delete.
|
||||
// materials and users), its permission code and whether the route can
|
||||
// delete.
|
||||
var manageTables = map[string]struct {
|
||||
model recordModel
|
||||
perm string
|
||||
@@ -246,10 +250,18 @@ var manageTables = map[string]struct {
|
||||
"readings": {models.Reading{}, "jz.bm.access_courses", true},
|
||||
"deadlines": {models.Deadline{}, "jz.bm.access_schedule", true},
|
||||
"materials": {nil, "jz.bm.access_materials", true},
|
||||
"users": {nil, "jz.bm.access_materials", false},
|
||||
}
|
||||
|
||||
// materialBodyKeys are the keys POST and PUT /materials read (materialInput).
|
||||
var materialBodyKeys = []string{"course_id", "meeting_id", "title", "type", "body", "source", "url", "published_at", "tags"}
|
||||
var materialBodyKeys = []string{"course_id", "meeting_id", "title", "type", "body", "source", "url", "published_at", "tags", "is_private", "author_user_id", "share_user_ids"}
|
||||
|
||||
// accessBodyKeys are the body keys of each material access tool, exactly,
|
||||
// and whether each is optional.
|
||||
var accessBodyKeys = map[string]map[string]bool{
|
||||
"manage_set_material_visibility": {"is_private": false},
|
||||
"manage_set_material_shares": {"user_ids": false},
|
||||
}
|
||||
|
||||
// bodyFields returns the JSON keys of a tool input's body fields and
|
||||
// whether each is optional (omitempty).
|
||||
@@ -330,6 +342,15 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if segment == "materials" && (strings.HasSuffix(rest, "/visibility") || strings.HasSuffix(rest, "/shares")) {
|
||||
if want, ok := accessBodyKeys[tool.Name]; !ok || !reflect.DeepEqual(fields, want) {
|
||||
t.Errorf("%s: body keys %v, want %v", tool.Name, fields, accessBodyKeys[tool.Name])
|
||||
}
|
||||
if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) {
|
||||
t.Errorf("%s: description lacks the material privacy sentence", tool.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if segment == "materials" {
|
||||
if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) {
|
||||
t.Errorf("%s: description lacks the material privacy sentence", tool.Name)
|
||||
@@ -351,6 +372,9 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if table.model == nil {
|
||||
continue
|
||||
}
|
||||
fillable := table.model.Fillable()
|
||||
for key := range fields {
|
||||
if !slices.Contains(fillable, key) {
|
||||
@@ -387,8 +411,9 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
|
||||
}
|
||||
|
||||
// manageToolCount is the number of management tools: 28 record and
|
||||
// material tools plus the material file tools.
|
||||
const manageToolCount = 31
|
||||
// material tools, the material file tools, the two material access tools
|
||||
// and the user list.
|
||||
const manageToolCount = 34
|
||||
|
||||
// fileBodyKeys are the body keys of each material file tool, exactly.
|
||||
var fileBodyKeys = map[string]map[string]bool{
|
||||
|
||||
@@ -4,6 +4,9 @@ columns:
|
||||
searchable: true
|
||||
type:
|
||||
label: jz.bm::lang.materials.type
|
||||
is_private:
|
||||
label: jz.bm::lang.materials.is_private
|
||||
type: switch
|
||||
course:
|
||||
label: jz.bm::lang.common.course
|
||||
relation: course
|
||||
|
||||
@@ -28,6 +28,18 @@ fields:
|
||||
firstDay: 1
|
||||
span: right
|
||||
comment: jz.bm::lang.materials.published_at_comment
|
||||
is_private:
|
||||
label: jz.bm::lang.materials.is_private
|
||||
type: switch
|
||||
span: left
|
||||
comment: jz.bm::lang.materials.is_private_comment
|
||||
author:
|
||||
label: jz.bm::lang.materials.author
|
||||
type: relation
|
||||
nameFrom: email
|
||||
span: right
|
||||
emptyOption: jz.bm::lang.common.none
|
||||
comment: jz.bm::lang.materials.author_comment
|
||||
source:
|
||||
label: jz.bm::lang.common.source
|
||||
type: text
|
||||
@@ -47,6 +59,11 @@ fields:
|
||||
label: jz.bm::lang.materials.tags
|
||||
type: relation
|
||||
nameFrom: name
|
||||
shared_with:
|
||||
label: jz.bm::lang.materials.shared_with
|
||||
type: relation
|
||||
nameFrom: email
|
||||
comment: jz.bm::lang.materials.shared_with_comment
|
||||
files:
|
||||
label: jz.bm::lang.materials.files
|
||||
type: fileupload
|
||||
|
||||
@@ -166,6 +166,9 @@ func TestRoutes(t *testing.T) {
|
||||
"POST /api/v1/bm/materials/{id}/files/inline",
|
||||
"POST /api/v1/bm/materials/{id}/files/upload-url",
|
||||
"DELETE /api/v1/bm/materials/{id}/files/{file}",
|
||||
"PATCH /api/v1/bm/materials/{id}/visibility",
|
||||
"PUT /api/v1/bm/materials/{id}/shares",
|
||||
"GET /api/v1/bm/users",
|
||||
"GET /api/v1/bm/courses",
|
||||
"GET /api/v1/bm/courses/{id}",
|
||||
"POST /api/v1/bm/courses",
|
||||
|
||||
@@ -184,7 +184,8 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group(TokenPrefix, surf.Use("user.api_token", bucketToken, MiddlewareMCPLinks, "locale.from-principal"), func(g pact.Router) {
|
||||
mountStudent(g, h, []string{"user.scope:read"}, []string{"user.scope:write"})
|
||||
})
|
||||
// The management API: what bm:seed does, plus materials,
|
||||
// The management API: what bm:seed does, plus materials (with their
|
||||
// visibility and shares) and the user list to pick shares from,
|
||||
// behind the application-wide user API token. Scope limits delegation;
|
||||
// user.permission checks the owner's current group permissions.
|
||||
// jz.bm.mcp-links makes the links of an MCP answer absolute.
|
||||
@@ -208,6 +209,11 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Delete("/materials/{id}/files/{file}", h.DetachMaterialFile, "user.scope:write", "user.permission:"+controllers.PermMaterials)
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Where("file", "[0-9]+")
|
||||
g.Patch("/materials/{id}/visibility", h.SetMaterialVisibility, "user.scope:write", "user.permission:"+controllers.PermMaterials)
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Put("/materials/{id}/shares", h.SetMaterialShares, "user.scope:write", "user.permission:"+controllers.PermMaterials, "body.limit:16384")
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Get("/users", h.ManagedUsers, "user.scope:read", "user.permission:"+controllers.PermMaterials)
|
||||
mountRecords(g, h, "/courses", controllers.PermCourses, api.Record[models.Course, *models.Course]{
|
||||
Filters: []string{"semester", "form", "code"}, Order: "semester, sort_order, id",
|
||||
}, false)
|
||||
|
||||
Reference in New Issue
Block a user