diff --git a/README.md b/README.md index 1a08c99..44cd48b 100644 --- a/README.md +++ b/README.md @@ -290,14 +290,16 @@ The material body (POST and PUT), unknown keys are a 422: ```json {"course_id": 7, "meeting_id": 2, "title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> …", "source": null, "url": null, - "published_at": "2026-10-16T20:00:00+02:00", "tags": ["Globalizacja", "Zjazd 2"]} + "published_at": "2026-10-16T20:00:00+02:00", "tags": ["Globalizacja"]} ``` - `type`: `pdf`, `notatka`, `link`, `inne`. `body` is Markdown, rendered by the student app (headings start at h2 there; the title is the page's h1). - `course_id`: null or an existing course. `body`, `source` and `url` are replaced as sent (absent means null). - `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). +- `tags`: absent keeps the tags; otherwise the list of names replaces them, and unknown tags are created (slug from the name, Polish letters folded). Tags hold topics only: the zjazd is `meeting_id`, never a tag (the student app's Zjazd filter and badge read `meeting_id`). + +The one-way migration `20261108140000_move_zjazd_tags_to_meetings` moved the old zjazd tags onto `meeting_id`: a material tagged `zjazd-N` (slug matching `^zjazd-[0-9]{1,6}$`) with no meeting got meeting number N (the lowest when it had several), an existing `meeting_id` was kept, and every such tag was deleted with its links; a slug such as `zjazd-3-24-10` is a topic and stays. A tag naming a missing meeting, a tag disagreeing with `meeting_id` and a material with several zjazd tags are logged at Warn under `jz.bm`. Its Rollback does nothing: the only way back is the `pg_dump --data-only -t jz_bm_tags -t jz_bm_material_tag` taken before `migrate`. - `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`. @@ -358,7 +360,7 @@ Controllers (admin API path `/api/v1/jz/bm/`), 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. 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`). +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` and the zjazd (`meeting`, by number). 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. diff --git a/management_token_test.go b/management_token_test.go index c9dc925..e80d26e 100644 --- a/management_token_test.go +++ b/management_token_test.go @@ -139,7 +139,7 @@ RETURNING id`).Scan(&groupID).Error; err != nil { } idPath := func(id any) string { return "/api/v1/bm/materials/" + strconv.Itoa(int(id.(float64))) } - note := map[string]any{"title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> single place", "tags": []string{"Globalizacja", "Zjazd 2"}} + note := map[string]any{"title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> single place", "tags": []string{"Globalizacja", "Teoria 2"}} rec, out := send(http.MethodPost, "/api/v1/bm/materials", note) if rec.Code != http.StatusCreated || out["created"] != true { t.Fatalf("create = %d %s", rec.Code, rec.Body.String()) @@ -148,7 +148,7 @@ RETURNING id`).Scan(&groupID).Error; err != nil { if first["published_at"] == nil { t.Error("a new material without published_at is not published") } - if tags, _ := first["tags"].([]any); len(tags) != 2 || tags[1].(map[string]any)["slug"] != "zjazd-2" { + if tags, _ := first["tags"].([]any); len(tags) != 2 || tags[1].(map[string]any)["slug"] != "teoria-2" { t.Errorf("tags = %v", first["tags"]) } diff --git a/mcp_manage.go b/mcp_manage.go index e538bec..3267e79 100644 --- a/mcp_manage.go +++ b/mcp_manage.go @@ -207,7 +207,7 @@ type saveMaterialInput struct { 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"` + Tags *[]string `json:"tags,omitempty" jsonschema:"topic tag names (1 to 100 characters each); absent keeps the tags, a list replaces them; tags hold topics only, the zjazd is set with meeting_id, never with a tag"` 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"` @@ -224,7 +224,7 @@ type updateMaterialInput struct { 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"` + Tags *[]string `json:"tags,omitempty" jsonschema:"topic tag names (1 to 100 characters each); absent keeps the tags, a list replaces them; tags hold topics only, the zjazd is set with meeting_id, never with a tag"` 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"` diff --git a/models/materials/columns.yaml b/models/materials/columns.yaml index 5418c40..baf6fd5 100644 --- a/models/materials/columns.yaml +++ b/models/materials/columns.yaml @@ -11,6 +11,10 @@ columns: label: jz.bm::lang.common.course relation: course select: name + meeting: + label: jz.bm::lang.common.meeting + relation: meeting + select: number published_at: label: jz.bm::lang.materials.published_at created_at: