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."""