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