From e71215d9ac48eec6a8a938ff8b07bfb74a92c530 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Mon, 19 Oct 2026 20:33:20 -0700 Subject: [PATCH] Tag editor edits series end year, status and summary in the database No format codex writes can hold a series' end year, status or summary: MetronInfo has only StartYear and ComicInfo none of them, and only comicbox's own comicbox.json/yaml carries them, which codex won't embed in comics. So the tag editor saves these three straight to the Series row through a new admin endpoint, POST /admin/series-attrs, and says so under the inputs: they are saved in Codex only, not written to the comic files. Re-imports leave them standing, since the importer ignores a series attribute a file doesn't carry. The inputs seed from the series and no write format gates them. They lock, with the reason shown, unless exactly one series is being edited and its name is unchanged, because the values land on one Series row and a rename would leave them on the row the comics are moving off. Only changed attributes are sent; a clear sends null or "". A save with nothing else skips the preflight and the tag write, and on the browser route the page reloads so the title's series years refresh. The status choices derive from SeriesStatusChoices into series-statuses.json. NEWS also gains the line PR 983 lacked for the online tag match picker. Co-Authored-By: Claude Opus 5.5 --- NEWS.md | 4 +- codex/choices/choices_to_json.py | 2 + codex/choices/tagging.py | 8 + codex/serializers/admin/tagging.py | 22 ++ codex/urls/api/v4/admin.py | 6 + codex/views/admin/tagwrite.py | 45 ++++- .../metadata/edit-mode/edit-panel.vue | 190 ++++++++++++++++-- .../unit/edit-panel-series-attrs.test.js | 175 ++++++++++++++++ tests/importer/test_series_attrs.py | 13 ++ tests/test_admin_series_attrs.py | 150 ++++++++++++++ tests/test_tag_write_date.py | 12 +- 11 files changed, 605 insertions(+), 22 deletions(-) create mode 100644 frontend/tests/unit/edit-panel-series-attrs.test.js create mode 100644 tests/test_admin_series_attrs.py diff --git a/NEWS.md b/NEWS.md index 3cd1de184..b1a55e080 100644 --- a/NEWS.md +++ b/NEWS.md @@ -11,7 +11,9 @@ border-radius: 128px; - Features - Series years, status and summary in the metadata pane. - Browser title shows series years and a Tags button. - - Tag editor edits the MetronInfo series start year. + - Tag editor edits series years, status and summary. End year, status and + summary are saved in Codex only. + - Online tag match picker shows series and issue details. ## v2.5.5 diff --git a/codex/choices/choices_to_json.py b/codex/choices/choices_to_json.py index 788d6d991..775545013 100755 --- a/codex/choices/choices_to_json.py +++ b/codex/choices/choices_to_json.py @@ -33,6 +33,7 @@ IDENTIFIER_TYPE_BY_CODEX_NAME, IDENTIFIER_TYPES, LANGUAGES, + SERIES_STATUSES, ) _DEFAULTS = MappingProxyType( @@ -81,6 +82,7 @@ "reader-defaults.json": READER_DEFAULTS, "reader-map.json": READER_CHOICES, "search-map.json": SEARCH_FIELDS, + "series-statuses.json": SERIES_STATUSES, "tagging-estimate.json": TAGGING_ESTIMATE, } ) diff --git a/codex/choices/tagging.py b/codex/choices/tagging.py index 8048dd149..c8e0d6ce0 100644 --- a/codex/choices/tagging.py +++ b/codex/choices/tagging.py @@ -27,6 +27,7 @@ from comicbox.formats.metron_info.transform import MetronInfoTransform from codex.choices.reader import READER_CHOICES +from codex.models.choices import SeriesStatusChoices from codex.models.identifier import IdentifierType, to_comicbox_id_type # comicbox transform per codex write-format id. @@ -84,6 +85,13 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType {id_type.value: to_comicbox_id_type(id_type.value) for id_type in IdentifierType} ) +# A series' publication status (Ongoing, Completed, ...). No format codex +# writes holds it, so the editor saves it to the database only and offers +# it under every format. +SERIES_STATUSES = _vuetify_choices( + (str(label), str(value)) for value, label in SeriesStatusChoices.choices +) + # --------------------------------------------------------------------------- # Per-format field support, derived from the comicbox transforms diff --git a/codex/serializers/admin/tagging.py b/codex/serializers/admin/tagging.py index 2af06f1bd..524f2bfae 100644 --- a/codex/serializers/admin/tagging.py +++ b/codex/serializers/admin/tagging.py @@ -17,6 +17,7 @@ from codex.librarian.onlinetag.credential_validator import KNOWN_SOURCES from codex.models import ComicboxTaggingDefaults +from codex.models.choices import SeriesStatusChoices from codex.serializers.models.base import BaseModelSerializer @@ -55,6 +56,27 @@ class TagWriteRequestSerializer(Serializer): rename = BooleanField(required=False, default=None) +class SeriesAttrsRequestSerializer(Serializer): + """ + A series' Codex-only attributes, edited from the tag editor. + + No format codex writes holds a series' end year, status or summary, so + these go straight to the Series row. Only the keys sent change; null or + "" clears one. + """ + + collection = CharField() + pks = ListField(child=CharField()) + # MetronInfo's StartYear bounds, so the pair stays comparable. + end_year = IntegerField( + required=False, allow_null=True, min_value=1000, max_value=9999 + ) + status = ChoiceField( + choices=SeriesStatusChoices.choices, required=False, allow_blank=True + ) + summary = CharField(required=False, allow_blank=True) + + class OnlineTagStartSerializer(Serializer): """Serializer for starting an online tagging session.""" diff --git a/codex/urls/api/v4/admin.py b/codex/urls/api/v4/admin.py index c902d33be..017044a9b 100644 --- a/codex/urls/api/v4/admin.py +++ b/codex/urls/api/v4/admin.py @@ -55,6 +55,7 @@ from codex.views.admin.tagging_validate import AdminTaggingValidateView from codex.views.admin.tagwrite import ( AdminParseIdentifierURLView, + AdminSeriesAttrsView, AdminTagWritePreflightView, AdminTagWriteView, ) @@ -323,6 +324,11 @@ AdminTagWriteView.as_view(), name="tag_write", ), + path( + "series-attrs", + AdminSeriesAttrsView.as_view(), + name="series_attrs", + ), path( "folders", AdminFolderListView.as_view(), diff --git a/codex/views/admin/tagwrite.py b/codex/views/admin/tagwrite.py index 188497d57..082c3bb55 100644 --- a/codex/views/admin/tagwrite.py +++ b/codex/views/admin/tagwrite.py @@ -4,8 +4,9 @@ from functools import cached_property from pathlib import Path from types import MappingProxyType -from typing import override +from typing import Final, override +from django.utils import timezone from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.status import HTTP_202_ACCEPTED @@ -19,8 +20,12 @@ ) from codex.librarian.scribe.tasks import BulkTagWriteTask from codex.models.admin import ComicboxTaggingDefaults +from codex.models.collections import Series from codex.models.comic import Comic -from codex.serializers.admin.tagging import TagWriteRequestSerializer +from codex.serializers.admin.tagging import ( + SeriesAttrsRequestSerializer, + TagWriteRequestSerializer, +) from codex.views.admin.auth import AdminAPIView from codex.views.admin.identifier_parse import parse_identifier_url from codex.views.browser.filters.filter import BrowserFilterView @@ -28,6 +33,8 @@ #: Cap on how many per-comic rename previews the preflight builds — each opens #: an archive, so a huge multi-selection can't stall the request. _FILENAME_PREVIEW_LIMIT = 100 +#: Series attributes the tag editor saves to the database only. +_SERIES_ATTRS: Final = ("end_year", "status", "summary") class FilteredComicPksView(BrowserFilterView): @@ -248,3 +255,37 @@ def post(self, request): }, status=HTTP_202_ACCEPTED, ) + + +class AdminSeriesAttrsView(FilteredComicPksView): + """ + POST a series' end year, status or summary straight to the database. + + No format codex writes can hold them, so unlike a tag write nothing + touches the comic files. Re-imports leave the values standing, because + the importer ignores a series attribute a file doesn't carry. + """ + + def post(self, request): + """Update the one series the selected comics belong to.""" + serializer = SeriesAttrsRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + attrs = {key: data[key] for key in _SERIES_ATTRS if key in data} + if not attrs: + return Response({"detail": "No series attributes sent."}, status=400) + + comic_pks = self.resolve_comic_pks(data["collection"], data["pks"]) + if not comic_pks: + return Response({"detail": "No comics matched."}, status=400) + series_pks = tuple( + Series.objects.filter(comic__pk__in=comic_pks) + .values_list("pk", flat=True) + .distinct() + ) + if len(series_pks) != 1: + return Response({"detail": "Edit one series at a time."}, status=400) + + series_pk = series_pks[0] + Series.objects.filter(pk=series_pk).update(**attrs, updated_at=timezone.now()) + return Response({"detail": "Series details saved.", "series": series_pk}) diff --git a/frontend/src/components/metadata/edit-mode/edit-panel.vue b/frontend/src/components/metadata/edit-mode/edit-panel.vue index 921dd4c4d..7a777422a 100644 --- a/frontend/src/components/metadata/edit-mode/edit-panel.vue +++ b/frontend/src/components/metadata/edit-mode/edit-panel.vue @@ -285,6 +285,80 @@ +
+
+ + + +
+
+ + + +
+
+
+ + + +
0 || + deleteKeys.length > 0 || + this.renameFile + ); + }, buildPatch() { // Returns { patch, deleteKeys }. A merge write can only add or replace // values — comicbox prunes empty patch values on schema load — so @@ -2575,6 +2711,11 @@ export default { const { patch, deleteKeys } = this.hasChanges ? this.buildPatch() : { patch: {}, deleteKeys: [] }; + if (!this.writesFiles(patch, deleteKeys)) { + // Only database-only series attributes changed: nothing to preflight. + await this.doSave(); + return; + } try { const response = await HTTP.post("/admin/tag-write/preflight", { collection: this.book.collection, @@ -2615,27 +2756,40 @@ export default { const { patch, deleteKeys } = this.hasChanges ? this.buildPatch() : { patch: {}, deleteKeys: [] }; + const seriesAttrs = this.hasChanges ? this.buildSeriesAttrs() : {}; + const target = { collection: this.book.collection, pks: pks.map(String) }; try { - const payload = { - collection: this.book.collection, - pks: pks.map(String), - patch: JSON.stringify(patch), - deleteKeys, - mode: "update", - formats, - rename: this.renameFile, - }; - if (this.confirmInfo.needConversion > 0) { - payload.deleteOriginal = this.confirmDeleteOriginal; + const messages = []; + if (Object.keys(seriesAttrs).length > 0) { + await HTTP.post("/admin/series-attrs", { ...target, ...seriesAttrs }); + messages.push("Series details saved."); + if (this.$route?.name === "browser") { + // The title shows the series years; no librarian event follows. + useBrowserStore().loadBrowserPage(getTimestamp()); + } } - const response = await HTTP.post("/admin/tag-write", payload); - const skipped = response.data?.skipped || 0; - let message = "Tag write queued."; - if (skipped > 0) { - message += ` ${skipped} comic${skipped === 1 ? "" : "s"} in read-only libraries skipped.`; + if (this.writesFiles(patch, deleteKeys)) { + const payload = { + ...target, + patch: JSON.stringify(patch), + deleteKeys, + mode: "update", + formats, + rename: this.renameFile, + }; + if (this.confirmInfo.needConversion > 0) { + payload.deleteOriginal = this.confirmDeleteOriginal; + } + const response = await HTTP.post("/admin/tag-write", payload); + const skipped = response.data?.skipped || 0; + let message = "Tag write queued."; + if (skipped > 0) { + message += ` ${skipped} comic${skipped === 1 ? "" : "s"} in read-only libraries skipped.`; + } + messages.push(message); } - useCommonStore().setSuccess(message); + useCommonStore().setSuccess(messages.join(" ")); this.$emit("saved"); } catch (error) { useCommonStore().setErrors(error); diff --git a/frontend/tests/unit/edit-panel-series-attrs.test.js b/frontend/tests/unit/edit-panel-series-attrs.test.js new file mode 100644 index 000000000..810ab3b4b --- /dev/null +++ b/frontend/tests/unit/edit-panel-series-attrs.test.js @@ -0,0 +1,175 @@ +/* + * Tests for the tag editor's database-only series attributes. + * + * No format codex writes can hold a series' end year, status or summary, + * so the editor saves them straight to the database: + * - They seed from the series, under any write format. + * - They lock, with a visible reason, unless exactly one series is being + * edited and its name is unchanged. + * - A visible hint warns they are not written to the comic files. + * - Only changed ones are sent, to /admin/series-attrs, and never ride + * the comicbox patch; a save with nothing else skips the tag write. + */ +import { createTestingPinia } from "@pinia/testing"; +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("@/api/v4/base", () => ({ + HTTP: { get: vi.fn(), post: vi.fn(), delete: vi.fn() }, +})); + +import { HTTP } from "@/api/v4/base"; +import EditPanel from "@/components/metadata/edit-mode/edit-panel.vue"; +import vuetify from "@/plugins/vuetify"; +import { useBrowserStore } from "@/stores/browser"; + +const ONE_SERIES = Object.freeze([{ name: "S", ids: [1] }]); +const SERIES_MD = Object.freeze({ + seriesList: ONE_SERIES, + seriesStartYear: 1963, + seriesEndYear: 1996, + seriesStatus: "Completed", + seriesSummary: "A run.", +}); +const WARNING = + "End year, status and series summary are saved in Codex only, " + + "not written to the comic files."; + +async function mountPanel({ + formats = ["COMIC_INFO"], + md = SERIES_MD, + routeName = "reader", +} = {}) { + const pinia = createTestingPinia({ + initialState: { + metadata: { md: { ...md } }, + admin: { taggingDefaults: { defaultFormats: formats } }, + browser: { settings: { twentyFourHourTime: false } }, + }, + }); + const wrapper = mount(EditPanel, { + props: { book: { pk: 1, collection: "series", ids: [1] } }, + global: { + plugins: [pinia, vuetify], + mocks: { $route: { name: routeName } }, + }, + }); + await flushPromises(); + return wrapper; +} + +async function setField(wrapper, field, value) { + wrapper.vm.patch[field] = value; + wrapper.vm.onFieldInput(field); + await flushPromises(); +} + +const postedUrls = () => vi.mocked(HTTP.post).mock.calls.map(([url]) => url); + +beforeEach(() => { + vi.mocked(HTTP.post).mockReset(); + vi.mocked(HTTP.post).mockResolvedValue({ data: {} }); +}); + +describe("EditPanel — series attributes seed and render", () => { + test("they seed from the series and render their own inputs", async () => { + const wrapper = await mountPanel(); + expect(wrapper.vm.patch.end_year).toBe(1996); + expect(wrapper.vm.patch.series_status).toBe("Completed"); + expect(wrapper.vm.patch.series_summary).toBe("A run."); + const labels = wrapper.findAll("label").map((el) => el.text()); + expect(labels).toEqual( + expect.arrayContaining(["End Year", "Status", "Series Summary"]), + ); + }); + + test("no write format gates them", async () => { + const wrapper = await mountPanel({ formats: ["COMIC_INFO"] }); + for (const field of ["end_year", "series_status", "series_summary"]) { + expect(wrapper.vm.isFieldDisabled(field)).toBe(false); + } + }); + + test("the hint warns they stay out of the comic files", async () => { + const wrapper = await mountPanel(); + expect(wrapper.vm.seriesAttrsHint).toBe(WARNING); + expect(wrapper.text()).toContain(WARNING); + }); +}); + +describe("EditPanel — series attributes lock", () => { + test("two series lock them and say why", async () => { + const wrapper = await mountPanel({ + md: { + seriesList: [ + { name: "S", ids: [1] }, + { name: "T", ids: [2] }, + ], + }, + }); + expect(wrapper.vm.isFieldDisabled("end_year")).toBe(true); + expect(wrapper.vm.seriesAttrsHint).toBe( + `Edit one series at a time. ${WARNING}`, + ); + }); + + test("renaming the series locks them until it is saved", async () => { + const wrapper = await mountPanel(); + await setField(wrapper, "series", "Renamed"); + expect(wrapper.vm.isFieldDisabled("series_status")).toBe(true); + expect(wrapper.vm.seriesAttrsLockReason).toBe( + "Save the series change first.", + ); + }); +}); + +describe("EditPanel — series attributes save", () => { + test("only changed attributes are sent, never in the patch", async () => { + const wrapper = await mountPanel(); + await setField(wrapper, "end_year", "1998"); + expect(wrapper.vm.buildSeriesAttrs()).toEqual({ endYear: 1998 }); + const { patch, deleteKeys } = wrapper.vm.buildPatch(); + expect(patch).toEqual({}); + expect(deleteKeys).toEqual([]); + }); + + test("clears send empty values", async () => { + const wrapper = await mountPanel(); + for (const field of ["end_year", "series_status", "series_summary"]) { + wrapper.vm.toggleClear(field); + } + await flushPromises(); + expect(wrapper.vm.buildSeriesAttrs()).toEqual({ + endYear: null, + status: "", + summary: "", + }); + }); + + test("a database-only save skips the preflight and the tag write", async () => { + const wrapper = await mountPanel(); + await setField(wrapper, "series_status", "Ongoing"); + await wrapper.vm.preSave(); + expect(postedUrls()).toEqual(["/admin/series-attrs"]); + expect(HTTP.post).toHaveBeenCalledWith("/admin/series-attrs", { + collection: "series", + pks: ["1"], + status: "Ongoing", + }); + }); + + test("a save with tag edits too posts both", async () => { + const wrapper = await mountPanel({ md: { ...SERIES_MD, summary: "x" } }); + await setField(wrapper, "series_summary", "A longer run."); + await setField(wrapper, "summary", "y"); + await wrapper.vm.doSave(); + expect(postedUrls()).toEqual(["/admin/series-attrs", "/admin/tag-write"]); + }); + + test("the browser page reloads so the title years refresh", async () => { + const wrapper = await mountPanel({ routeName: "browser" }); + await setField(wrapper, "end_year", "1998"); + await wrapper.vm.doSave(); + expect(useBrowserStore().loadBrowserPage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/importer/test_series_attrs.py b/tests/importer/test_series_attrs.py index 260356f53..465c0a1e4 100644 --- a/tests/importer/test_series_attrs.py +++ b/tests/importer/test_series_attrs.py @@ -188,3 +188,16 @@ def test_later_batch_updates_stored_series(self) -> None: assert series.end_year == _LATE assert series.status == _COMPLETED assert series.summary == _SUMMARY_B + + def test_reimport_keeps_database_only_attrs(self) -> None: + """Values the tag editor saved to the database survive a re-import.""" + self._import(_md({"start_year": _EARLY})) + Series.objects.filter(name="S").update( + end_year=_LATE, status=_COMPLETED, summary=_SUMMARY_C + ) + self._import(_md({"start_year": _EARLY})) + + series = Series.objects.get(name="S") + assert series.end_year == _LATE + assert series.status == _COMPLETED + assert series.summary == _SUMMARY_C diff --git a/tests/test_admin_series_attrs.py b/tests/test_admin_series_attrs.py new file mode 100644 index 000000000..09cf7cdf1 --- /dev/null +++ b/tests/test_admin_series_attrs.py @@ -0,0 +1,150 @@ +"""The tag editor saves a series' end year, status and summary to the database.""" + +import shutil +from typing import Final, override + +from django.contrib.auth.models import User +from django.test import Client, TestCase + +from codex.models import Comic, Imprint, Library, Publisher, Series, Volume +from codex.startup import init_admin_flags +from tests.tmp_dirs import tmp_dir + +TMP_DIR = tmp_dir("codex.tests.admin_series_attrs") +_URL: Final = "/api/v4/admin/series-attrs" +_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 +_HTTP_OK: Final = 200 +_HTTP_BAD_REQUEST: Final = 400 +_HTTP_FORBIDDEN: Final = 403 +_START_YEAR: Final = 1963 +_END_YEAR: Final = 1996 +_NEW_END_YEAR: Final = 1998 +_SUMMARY: Final = "A friendly neighborhood series." + + +class AdminSeriesAttrsTestCase(TestCase): + """POST /admin/series-attrs updates the one series the comics share.""" + + @override + def setUp(self) -> None: + """Seed two series with one comic each.""" + init_admin_flags() + TMP_DIR.mkdir(exist_ok=True, parents=True) + self.library = Library.objects.create(path=str(TMP_DIR)) # pyright: ignore[reportUninitializedInstanceVariable] + self.publisher = Publisher.objects.create(name="Pub") # pyright: ignore[reportUninitializedInstanceVariable] + self.imprint = Imprint.objects.create(name="Imp", publisher=self.publisher) # pyright: ignore[reportUninitializedInstanceVariable] + self.series = self._series("Ser", start_year=_START_YEAR, end_year=_END_YEAR) # pyright: ignore[reportUninitializedInstanceVariable] + self.other = self._series("Other") # pyright: ignore[reportUninitializedInstanceVariable] + self.comic = self._comic(self.series, "c1.cbz") # pyright: ignore[reportUninitializedInstanceVariable] + self.other_comic = self._comic(self.other, "c2.cbz") # pyright: ignore[reportUninitializedInstanceVariable] + admin = User.objects.create_user( + username="series_admin", password=_TEST_PASSWORD, is_staff=True + ) + self.client = Client() + self.client.force_login(admin) + + @override + def tearDown(self) -> None: + """Remove the temp comic tree.""" + shutil.rmtree(TMP_DIR, ignore_errors=True) + + def _series(self, name: str, **attrs) -> Series: + return Series.objects.create( + name=name, imprint=self.imprint, publisher=self.publisher, **attrs + ) + + def _comic(self, series: Series, filename: str) -> Comic: + volume = Volume.objects.create( + name="1963", series=series, imprint=self.imprint, publisher=self.publisher + ) + path = TMP_DIR / filename + path.touch() + return Comic.objects.create( + library=self.library, + path=path, + issue_number=1, + name=filename, + publisher=self.publisher, + imprint=self.imprint, + series=series, + volume=volume, + size=42, + ) + + def _post(self, collection: str, pks, **attrs): + data = {"collection": collection, "pks": [str(pk) for pk in pks], **attrs} + return self.client.post(_URL, data, content_type="application/json") + + def test_sets_attrs_on_a_comics_series(self) -> None: + before = Series.objects.get(pk=self.series.pk).updated_at + response = self._post( + "comics", + [self.comic.pk], + endYear=_NEW_END_YEAR, + status="Completed", + summary=_SUMMARY, + ) + assert response.status_code == _HTTP_OK, response.content + series = Series.objects.get(pk=self.series.pk) + assert series.end_year == _NEW_END_YEAR + assert series.status == "Completed" + assert series.summary == _SUMMARY + assert series.start_year == _START_YEAR + assert series.updated_at > before + assert Series.objects.get(pk=self.other.pk).status == "" + + def test_series_collection_targets_its_own_row(self) -> None: + response = self._post("series", [self.series.pk], status="Hiatus") + assert response.status_code == _HTTP_OK, response.content + assert Series.objects.get(pk=self.series.pk).status == "Hiatus" + + def test_only_sent_attrs_change(self) -> None: + Series.objects.filter(pk=self.series.pk).update( + status="Ongoing", summary=_SUMMARY + ) + response = self._post("comics", [self.comic.pk], status="Completed") + assert response.status_code == _HTTP_OK, response.content + series = Series.objects.get(pk=self.series.pk) + assert series.status == "Completed" + assert series.summary == _SUMMARY + assert series.end_year == _END_YEAR + + def test_empty_values_clear(self) -> None: + Series.objects.filter(pk=self.series.pk).update( + status="Ongoing", summary=_SUMMARY + ) + response = self._post( + "comics", [self.comic.pk], endYear=None, status="", summary="" + ) + assert response.status_code == _HTTP_OK, response.content + series = Series.objects.get(pk=self.series.pk) + assert series.end_year is None + assert series.status == "" + assert series.summary == "" + + def test_two_series_are_refused(self) -> None: + response = self._post( + "comics", [self.comic.pk, self.other_comic.pk], status="Completed" + ) + assert response.status_code == _HTTP_BAD_REQUEST + assert Series.objects.get(pk=self.series.pk).status == "" + + def test_nothing_to_save_is_refused(self) -> None: + response = self._post("comics", [self.comic.pk]) + assert response.status_code == _HTTP_BAD_REQUEST + + def test_unknown_status_is_refused(self) -> None: + response = self._post("comics", [self.comic.pk], status="Finished") + assert response.status_code == _HTTP_BAD_REQUEST + assert Series.objects.get(pk=self.series.pk).status == "" + + def test_out_of_range_year_is_refused(self) -> None: + response = self._post("comics", [self.comic.pk], endYear=99) + assert response.status_code == _HTTP_BAD_REQUEST + + def test_non_admin_is_forbidden(self) -> None: + user = User.objects.create_user(username="reader", password=_TEST_PASSWORD) + self.client.force_login(user) + response = self._post("comics", [self.comic.pk], status="Completed") + assert response.status_code == _HTTP_FORBIDDEN + assert Series.objects.get(pk=self.series.pk).status == "" diff --git a/tests/test_tag_write_date.py b/tests/test_tag_write_date.py index adb323d24..17351f1a1 100644 --- a/tests/test_tag_write_date.py +++ b/tests/test_tag_write_date.py @@ -26,10 +26,11 @@ from django.test import TestCase from loguru import logger -from codex.choices.tagging import FORMAT_FIELD_SUPPORT +from codex.choices.tagging import FORMAT_FIELD_SUPPORT, SERIES_STATUSES from codex.librarian.scribe.tag_writer import TagWriter from codex.librarian.scribe.tasks import BulkTagWriteTask from codex.models import Comic, Imprint, Library, Publisher, Series, Volume +from codex.models.choices import SeriesStatusChoices from tests.tmp_dirs import tmp_dir _TMP_DIR: Final = tmp_dir("codex.tests.tagwritedate") @@ -118,6 +119,15 @@ def test_format_exclusive_fields(self) -> None: # its own. assert "reading_direction" in comic_info + def test_database_only_series_fields_are_no_format_field(self) -> None: + # No format holds them; the editor saves them to the database. + for fields in FORMAT_FIELD_SUPPORT.values(): + assert not {"end_year", "series_status", "series_summary"} & set(fields) + + def test_series_statuses_follow_the_model_choices(self) -> None: + values = [choice["value"] for choice in SERIES_STATUSES] + assert values == list(SeriesStatusChoices.values) + class TagWriteDateTestCase(TestCase): """Composite tag-editor keys write through to the archive."""