From 803dd19981fc97275622f8eb792a189911c91ac7 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Mon, 19 Oct 2026 19:00:02 -0700 Subject: [PATCH] Import and show series years, status and summary Series gains start_year, end_year, status (choices from comicbox's SeriesStatusEnum) and summary, read from comicbox's series block. The metadata pane shows the year range and status after the series name and the series summary below the comic's. The tag editor gets a MetronInfo-only Start Year beside Volume Count. The importer's single count slot per collection becomes a tuple of attributes. Comics of one series in a batch now merge per attribute (earliest start, latest end, largest count, first status and text). The old max_none merge for counts never ran: it looked proposals up with an unhashable list and suppress(Exception) hid the TypeError, so disagreeing comics resolved by set order. Series.summary collides with the comic summary annotation on collection panes, so that field now takes the conflict_ prefix path. Needs the comicbox release that ships SeriesStatusEnum; the pin is not bumped yet. Co-Authored-By: Claude Opus 5.5 --- NEWS.md | 6 + codex/choices/tagging.py | 13 +- codex/librarian/scribe/importer/const.py | 26 ++- .../librarian/scribe/importer/create/const.py | 8 +- .../scribe/importer/create/foreign_keys.py | 4 +- .../scribe/importer/read/foreign_keys.py | 72 +++++-- codex/migrations/0058_series_fields.py | 55 +++++ codex/models/choices.py | 8 +- codex/models/collections.py | 18 +- codex/serializers/browser/metadata.py | 4 + codex/util.py | 10 + codex/views/browser/metadata/annotate.py | 12 +- codex/views/browser/metadata/const.py | 14 +- .../metadata/edit-mode/edit-panel.vue | 37 +++- .../src/components/metadata/metadata-body.vue | 5 + .../components/metadata/metadata-header.vue | 14 ++ .../unit/edit-panel-metron-fields.test.js | 42 +++- frontend/tests/unit/metadata-header.test.js | 31 ++- tests/importer/test_basic.py | 8 +- tests/importer/test_series_attrs.py | 190 ++++++++++++++++++ tests/importer/test_update_all.py | 9 + tests/test_metadata_series_fields.py | 109 ++++++++++ tests/test_tag_write_date.py | 10 + 23 files changed, 663 insertions(+), 42 deletions(-) create mode 100644 codex/migrations/0058_series_fields.py create mode 100644 tests/importer/test_series_attrs.py create mode 100644 tests/test_metadata_series_fields.py diff --git a/NEWS.md b/NEWS.md index 625a8bdda..d6db613cd 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,12 @@ width: 128px; border-radius: 128px; " /> +## v2.5.6 + +- Features + - Series years, status and summary in the metadata pane. + - Tag editor edits the MetronInfo series start year. + ## v2.5.5 - Features diff --git a/codex/choices/tagging.py b/codex/choices/tagging.py index abd9f8993..8048dd149 100644 --- a/codex/choices/tagging.py +++ b/codex/choices/tagging.py @@ -156,16 +156,17 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType # Editor sub-fields comicbox stores only for some formats and which its # transform specs don't surface at the SPECS_TO top level (the # hand-maintained support data): MetronInfo's Series carries a volume -# count, only MetronInfo's CommunityRating persists a rating count, and -# only MetronInfo encodes a reprint's volume number (in Reprints) and -# language (in Series/AlternativeNames). Whether a reprint row is one of -# the series' other names is MetronInfo's too, and for the same reason: -# ComicInfo has no reprint tag at all, since comicbox 5 reads its -# AlternateSeries and AlternateNumber as a story arc. +# count and a start year, only MetronInfo's CommunityRating persists a +# rating count, and only MetronInfo encodes a reprint's volume number (in +# Reprints) and language (in Series/AlternativeNames). Whether a reprint +# row is one of the series' other names is MetronInfo's too, and for the +# same reason: ComicInfo has no reprint tag at all, since comicbox 5 reads +# its AlternateSeries and AlternateNumber as a story arc. _EXTRA_FORMAT_FIELDS: MappingProxyType[str, tuple[str, ...]] = MappingProxyType( { "METRON_INFO": ( "volume_count", + "start_year", "community_rating_count", "reprint_volume", "reprint_language", diff --git a/codex/librarian/scribe/importer/const.py b/codex/librarian/scribe/importer/const.py index da89fff74..4808a38b1 100644 --- a/codex/librarian/scribe/importer/const.py +++ b/codex/librarian/scribe/importer/const.py @@ -56,6 +56,10 @@ PARENT_FOLDER_FIELD_NAME = "parent_folder" VOLUME_COUNT_FIELD_NAME = "volume_count" ISSUE_COUNT_FIELD_NAME = "issue_count" +START_YEAR_FIELD_NAME = "start_year" +END_YEAR_FIELD_NAME = "end_year" +STATUS_FIELD_NAME = "status" +SUMMARY_FIELD_NAME = "summary" PATH_FIELD_NAME = "path" IDENTIFIERS_FIELD_NAME = "identifiers" CREDIT_PRIMARY_FIELD_NAME = "primary" @@ -159,14 +163,20 @@ def _metron_fts_values(values: tuple) -> tuple: ####### # M2M # ####### -COLLECTION_MODEL_COUNT_FIELDS: MappingProxyType[ - type[BrowserCollectionModel], str | None +COLLECTION_MODEL_ATTR_FIELDS: MappingProxyType[ + type[BrowserCollectionModel], tuple[str, ...] ] = MappingProxyType( { - Publisher: None, - Imprint: None, - Series: VOLUME_COUNT_FIELD_NAME, - Volume: ISSUE_COUNT_FIELD_NAME, + Publisher: (), + Imprint: (), + Series: ( + VOLUME_COUNT_FIELD_NAME, + START_YEAR_FIELD_NAME, + END_YEAR_FIELD_NAME, + STATUS_FIELD_NAME, + SUMMARY_FIELD_NAME, + ), + Volume: (ISSUE_COUNT_FIELD_NAME,), } ) COMIC_M2M_FIELDS: tuple[ManyToManyField, ...] = cast( @@ -282,7 +292,7 @@ def _metron_fts_values(values: tuple) -> tuple: NAME_FIELD_NAME, ), _IDENTIFIER_RELS, - VOLUME_COUNT_FIELD_NAME, + *COLLECTION_MODEL_ATTR_FIELDS[Series], ), Volume: ( ( @@ -293,7 +303,7 @@ def _metron_fts_values(values: tuple) -> tuple: NUMBER_TO_FIELD_NAME, ), "", - ISSUE_COUNT_FIELD_NAME, + *COLLECTION_MODEL_ATTR_FIELDS[Volume], ), Folder: ( (PATH_FIELD_NAME,), diff --git a/codex/librarian/scribe/importer/create/const.py b/codex/librarian/scribe/importer/create/const.py index 48f9c10e0..0fd4a448a 100644 --- a/codex/librarian/scribe/importer/create/const.py +++ b/codex/librarian/scribe/importer/create/const.py @@ -4,6 +4,7 @@ from types import MappingProxyType from codex.librarian.scribe.importer.const import ( + COLLECTION_MODEL_ATTR_FIELDS, CREDIT_PERSON_FIELD_NAME, CREDIT_PRIMARY_FIELD_NAME, CREDIT_ROLE_FIELD_NAME, @@ -28,7 +29,6 @@ REPRINT_VOLUME_NUMBER_FIELD_NAME, SERIES_FIELD_NAME, STORY_ARC_FIELD_NAME, - VOLUME_COUNT_FIELD_NAME, ) from codex.models import ( Credit, @@ -68,6 +68,7 @@ { Universe: frozenset({"designation"}), Identifier: frozenset({"id_type", "key", "url"}), + Series: DEFAULT_NON_NULL_CHARFIELD_NAMES | {"status", "summary"}, Comic: frozenset({"path"}), Volume: frozenset({}), } @@ -104,7 +105,10 @@ IMPRINT_FIELD_NAME: Imprint, NAME_FIELD_NAME: None, }, - {IDENTIFIER_FIELD_NAME: Identifier, VOLUME_COUNT_FIELD_NAME: None}, + { + IDENTIFIER_FIELD_NAME: Identifier, + **dict.fromkeys(COLLECTION_MODEL_ATTR_FIELDS[Series]), + }, ), Volume: ( { diff --git a/codex/librarian/scribe/importer/create/foreign_keys.py b/codex/librarian/scribe/importer/create/foreign_keys.py index b4b386166..aaf6076dd 100644 --- a/codex/librarian/scribe/importer/create/foreign_keys.py +++ b/codex/librarian/scribe/importer/create/foreign_keys.py @@ -8,7 +8,7 @@ from itertools import chain from codex.librarian.scribe.importer.const import ( - COLLECTION_MODEL_COUNT_FIELDS, + COLLECTION_MODEL_ATTR_FIELDS, COMPLEX_M2M_MODELS, CREATE_FKS, FTS_CREATED_M2MS, @@ -93,7 +93,7 @@ def _get_create_update_args( value = values_tuple[index] arg_map = key_args if index < num_keys else update_args if field_model and value is not None: - if field_model in COLLECTION_MODEL_COUNT_FIELDS and index < num_keys: + if field_model in COLLECTION_MODEL_ATTR_FIELDS and index < num_keys: key = tuple(values_tuple[: index + 1]) elif isinstance(value, tuple): key = value diff --git a/codex/librarian/scribe/importer/read/foreign_keys.py b/codex/librarian/scribe/importer/read/foreign_keys.py index 21f39764b..1f38ec802 100644 --- a/codex/librarian/scribe/importer/read/foreign_keys.py +++ b/codex/librarian/scribe/importer/read/foreign_keys.py @@ -1,7 +1,7 @@ """Aggregate Browser Collection Trees.""" from collections.abc import Mapping -from contextlib import suppress +from types import MappingProxyType from comicbox.enums.comicbox import IdSources from comicbox.formats.comicbox.schema import ( @@ -20,9 +20,13 @@ from codex.librarian.scribe.importer.const import ( COLLECTION_FIELD_NAMES, COLLECTION_FIELD_NAMES_SET, - COLLECTION_MODEL_COUNT_FIELDS, + COLLECTION_MODEL_ATTR_FIELDS, + END_YEAR_FIELD_NAME, LINK_FKS, QUERY_MODELS, + START_YEAR_FIELD_NAME, + STATUS_FIELD_NAME, + SUMMARY_FIELD_NAME, ) from codex.librarian.scribe.importer.query import QueryForeignKeysImporter from codex.librarian.scribe.importer.read.const import ( @@ -38,11 +42,22 @@ to_codex_id_type, to_comicbox_id_type, ) -from codex.util import max_none +from codex.util import first_none, max_none, min_none _MINIMAL_KEYS = frozenset( {"file_type", PAGE_COUNT_KEY, "path"} | COLLECTION_FIELD_NAMES_SET ) +# How comics of one collection in an import batch combine an attribute. +# Counts and the end year take the largest, the start year the earliest +# and text the first comic's. +_ATTR_MERGE = MappingProxyType( + { + START_YEAR_FIELD_NAME: min_none, + END_YEAR_FIELD_NAME: max_none, + STATUS_FIELD_NAME: first_none, + SUMMARY_FIELD_NAME: first_none, + } +) class AggregateForeignKeyMetadataImporter(QueryForeignKeysImporter): @@ -131,6 +146,41 @@ def _set_simple_fk(self, related_field: Field, value) -> tuple: return () return (value,), None + def _merge_collection_attrs( + self, + model: type[BrowserCollectionModel], + key_values: tuple, + collection: Mapping, + ) -> tuple: + """ + Merge one comic's collection attributes into the batch's so far. + + Every comic proposes its own extra values for its collection. The + query phase settles several proposals by set order, so the ones + already proposed are rewritten to carry the merged attributes too + and all proposals agree. + """ + attr_names = COLLECTION_MODEL_ATTR_FIELDS[model] + values = [] + for attr_name in attr_names: + field = model._meta.get_field(attr_name) + value = field.get_prep_value(collection.get(attr_name)) + values.append(None if value == "" else value) + proposed = self.metadata[QUERY_MODELS].get(model, {}).get(key_values) + if not proposed: + return tuple(values) + num_attrs = len(attr_names) + for extra_values in proposed: + old_values = extra_values[-num_attrs:] + for index, attr_name in enumerate(attr_names): + merge = _ATTR_MERGE.get(attr_name, max_none) + values[index] = merge(old_values[index], values[index]) + merged = tuple(values) + self.metadata[QUERY_MODELS][model][key_values] = { + (*extra_values[:-num_attrs], *merged) for extra_values in proposed + } + return merged + def _set_collection_tree_node( self, model: type[BrowserCollectionModel], @@ -153,16 +203,12 @@ def _set_collection_tree_node( else: identifier_tuple = self.get_identifier_tuple(model, collection) extra_vals.append(identifier_tuple) - count_key = COLLECTION_MODEL_COUNT_FIELDS[model] - if count_key: - count = collection.get(count_key) - with suppress(Exception): - old_count_val = ( - self.metadata[QUERY_MODELS].get(model, {}).get(collection_list) - ) - count = max_none(old_count_val, count) - extra_vals.append(count) - return tuple(collection_list), frozenset((tuple(extra_vals),)) + key_values = tuple(collection_list) + if COLLECTION_MODEL_ATTR_FIELDS[model]: + extra_vals.extend( + self._merge_collection_attrs(model, key_values, collection) + ) + return key_values, frozenset((tuple(extra_vals),)) def get_fk_metadata(self, md, path) -> None: """Aggregate Simple Foreign Keys.""" diff --git a/codex/migrations/0058_series_fields.py b/codex/migrations/0058_series_fields.py new file mode 100644 index 000000000..f91744035 --- /dev/null +++ b/codex/migrations/0058_series_fields.py @@ -0,0 +1,55 @@ +"""Generated by Django 6.1.2 on 2026-10-20 01:44.""" + +from django.db import migrations, models + +import codex.models.fields + + +class Migration(migrations.Migration): + """ + Give series a year range, a publication status and a summary. + + Comicbox's series carries them (status is its SeriesStatusEnum, from + Metron) and the importer merges them per series. Additive and + reversible: the years are nullable and the text columns default empty. + Existing rows stay empty until their comics are re-imported. + """ + + dependencies = [ + ("codex", "0057_remove_pinned_settings"), + ] + + operations = [ + migrations.AddField( + model_name="series", + name="end_year", + field=codex.models.fields.CoercingPositiveSmallIntegerField(null=True), + ), + migrations.AddField( + model_name="series", + name="start_year", + field=codex.models.fields.CoercingPositiveSmallIntegerField(null=True), + ), + migrations.AddField( + model_name="series", + name="status", + field=models.CharField( + blank=True, + choices=[ + ("Ongoing", "Ongoing"), + ("Cancelled", "Cancelled"), + ("Completed", "Completed"), + ("Hiatus", "Hiatus"), + ], + default="", + max_length=9, + ), + ), + migrations.AddField( + model_name="series", + name="summary", + field=codex.models.fields.CleaningTextField( + blank=True, db_collation="nocase", default="" + ), + ), + ] diff --git a/codex/models/choices.py b/codex/models/choices.py index 46557bc91..a4a09a1b0 100644 --- a/codex/models/choices.py +++ b/codex/models/choices.py @@ -4,7 +4,12 @@ from enum import Enum, _EnumDict from typing import cast -from comicbox.enums.comicbox import FileTypeEnum, MangaEnum, ReadingDirectionEnum +from comicbox.enums.comicbox import ( + FileTypeEnum, + MangaEnum, + ReadingDirectionEnum, + SeriesStatusEnum, +) from django.db.models import Choices, TextChoices from django.db.models.enums import ChoicesType @@ -53,3 +58,4 @@ def max_choices_len(choices: type[Choices]) -> int: FileTypeChoices = text_choices_from_enum(FileTypeEnum) MangaChoices = text_choices_from_enum(MangaEnum) ReadingDirectionChoices = text_choices_from_enum(ReadingDirectionEnum) +SeriesStatusChoices = text_choices_from_enum(SeriesStatusEnum) diff --git a/codex/models/collections.py b/codex/models/collections.py index de87300cb..a9f304f76 100644 --- a/codex/models/collections.py +++ b/codex/models/collections.py @@ -2,10 +2,15 @@ from typing import override -from django.db.models import CASCADE, SET_DEFAULT, ForeignKey, Index, Q +from django.db.models import CASCADE, SET_DEFAULT, CharField, ForeignKey, Index, Q from codex.models.base import MAX_NAME_LEN, BaseModel -from codex.models.fields import CleaningCharField, CoercingPositiveSmallIntegerField +from codex.models.choices import SeriesStatusChoices, max_choices_len +from codex.models.fields import ( + CleaningCharField, + CleaningTextField, + CoercingPositiveSmallIntegerField, +) from codex.models.identifier import Identifier from codex.models.paths import CustomCover, WatchedPath from codex.models.util import get_sort_name @@ -122,6 +127,15 @@ class Series(IdentifiedBrowserCollectionModel): publisher = ForeignKey(Publisher, on_delete=CASCADE) imprint = ForeignKey(Imprint, on_delete=CASCADE) volume_count = CoercingPositiveSmallIntegerField(null=True) + start_year = CoercingPositiveSmallIntegerField(null=True) + end_year = CoercingPositiveSmallIntegerField(null=True) + status = CharField( + blank=True, + choices=SeriesStatusChoices.choices, + default="", + max_length=max_choices_len(SeriesStatusChoices), + ) + summary = CleaningTextField(blank=True, default="", db_collation="nocase") class Meta(IdentifiedBrowserCollectionModel.Meta): """Constraints.""" diff --git a/codex/serializers/browser/metadata.py b/codex/serializers/browser/metadata.py index 0f77020e6..dcfc24401 100644 --- a/codex/serializers/browser/metadata.py +++ b/codex/serializers/browser/metadata.py @@ -177,6 +177,10 @@ class MetadataSerializer(BrowserAggregateSerializerMixin, ComicSerializer): file_name = CharField(read_only=True) parent_folder_id = IntegerField(read_only=True, required=False) series_volume_count = IntegerField(read_only=True) + series_start_year = IntegerField(read_only=True) + series_end_year = IntegerField(read_only=True) + series_status = CharField(read_only=True) + series_summary = CharField(read_only=True) volume_issue_count = IntegerField(read_only=True) # cover_pk and cover_custom_pk are annotated on collection queryset by diff --git a/codex/util.py b/codex/util.py index f4a0c897a..64b0556d4 100644 --- a/codex/util.py +++ b/codex/util.py @@ -53,6 +53,16 @@ def max_none(*args): return max((x for x in args if x is not None), default=None) +def min_none(*args): + """None aware math.min.""" + return min((x for x in args if x is not None), default=None) + + +def first_none(*args): + """None aware coalesce: the first argument that isn't None.""" + return next((x for x in args if x is not None), None) + + def mapping_to_dict(data) -> dict | set | frozenset | tuple | list: """Convert nested Mapping objects to dicts.""" if isinstance(data, Mapping): diff --git a/codex/views/browser/metadata/annotate.py b/codex/views/browser/metadata/annotate.py index 95f5e9fec..9b6d5baf8 100644 --- a/codex/views/browser/metadata/annotate.py +++ b/codex/views/browser/metadata/annotate.py @@ -1,6 +1,9 @@ """Metadata Annotations.""" -from django.db.models import Count, IntegerField, Sum, Value +from typing import cast + +from django.db.models import Count, IntegerField, Model, Sum, Value +from django.db.models.constants import LOOKUP_SEP from django.db.models.query import QuerySet from codex.models import Comic @@ -85,7 +88,12 @@ def _fetch_intersecting_values( def _value_output_field(field: str): if field.endswith("count"): return IntegerField() - return Comic._meta.get_field(field) + # Related values like series__start_year live on the related model. + model: type[Model] = Comic + *relations, field_name = field.split(LOOKUP_SEP) + for relation in relations: + model = cast("type[Model]", model._meta.get_field(relation).related_model) + return model._meta.get_field(field_name) def _annotate_value_constants( self, qs: QuerySet, single_value_specs, row diff --git a/codex/views/browser/metadata/const.py b/codex/views/browser/metadata/const.py index 3296189ba..8a71eccf0 100644 --- a/codex/views/browser/metadata/const.py +++ b/codex/views/browser/metadata/const.py @@ -20,11 +20,14 @@ ############ # Annotate # ############ +# Comic value fields that collection models carry too (Series.summary is the +# series' own), so collection panes annotate them under a prefix. COMIC_VALUE_FIELDS_CONFLICTING = frozenset( { "created_at", "name", "path", + "summary", "updated_at", } ) @@ -40,7 +43,16 @@ for field in Comic._meta.get_fields() if not field.is_relation and field.name not in _DISABLED_VALUE_FIELD_NAMES ) -COMIC_RELATED_VALUE_FIELDS = frozenset({"series__volume_count", "volume__issue_count"}) +COMIC_RELATED_VALUE_FIELDS = frozenset( + { + "series__volume_count", + "series__start_year", + "series__end_year", + "series__status", + "series__summary", + "volume__issue_count", + } +) SUM_FIELDS = frozenset({"page_count", "size"}) ######### diff --git a/frontend/src/components/metadata/edit-mode/edit-panel.vue b/frontend/src/components/metadata/edit-mode/edit-panel.vue index ceae18111..921dd4c4d 100644 --- a/frontend/src/components/metadata/edit-mode/edit-panel.vue +++ b/frontend/src/components/metadata/edit-mode/edit-panel.vue @@ -259,6 +259,31 @@ +
+ + + +
+
diff --git a/frontend/src/components/metadata/metadata-header.vue b/frontend/src/components/metadata/metadata-header.vue index 9632226f2..a5e4801e3 100644 --- a/frontend/src/components/metadata/metadata-header.vue +++ b/frontend/src/components/metadata/metadata-header.vue @@ -24,6 +24,8 @@ collection="series" :highlight="'series' === md.collection" /> + + { expect(metron.vm.isFieldDisabled("country")).toBe(true); }); }); + +describe("EditPanel — series start year", () => { + test("it seeds from the series and renders its own input", async () => { + const wrapper = await mountPanel({ md: { seriesStartYear: 1963 } }); + expect(wrapper.vm.patch.start_year).toBe(1963); + const labels = wrapper.findAll("label").map((el) => el.text()); + expect(labels).toContain("Start Year"); + }); + + test("it rides the series object with the volume count", async () => { + const wrapper = await mountPanel({ + md: { seriesList: [{ name: "S", ids: [1] }], seriesVolumeCount: 3 }, + }); + await setField(wrapper, "start_year", "1963"); + expect(wrapper.vm.buildPatch().patch.series).toEqual({ + name: "S", + volume_count: 3, + start_year: 1963, + }); + }); + + test("clearing it drops it from the series object", async () => { + const wrapper = await mountPanel({ + md: { seriesList: [{ name: "S", ids: [1] }], seriesStartYear: 1963 }, + }); + wrapper.vm.toggleClear("start_year"); + await flushPromises(); + expect(wrapper.vm.buildPatch().patch.series).toEqual({ name: "S" }); + }); + + test("it is METRON_INFO-only", async () => { + const comicInfo = await mountPanel({ formats: ["COMIC_INFO"] }); + const metron = await mountPanel({ formats: ["METRON_INFO"] }); + expect(comicInfo.vm.isFieldDisabled("start_year")).toBe(true); + expect(metron.vm.isFieldDisabled("start_year")).toBe(false); + }); +}); diff --git a/frontend/tests/unit/metadata-header.test.js b/frontend/tests/unit/metadata-header.test.js index 7221d95f6..a7cf7eb6d 100644 --- a/frontend/tests/unit/metadata-header.test.js +++ b/frontend/tests/unit/metadata-header.test.js @@ -18,7 +18,7 @@ import MetadataHeader from "@/components/metadata/metadata-header.vue"; import vuetify from "@/plugins/vuetify"; import { useMetadataStore } from "@/stores/metadata"; -function mountHeader({ multiSelect = false, editing = false } = {}) { +function mountHeader({ multiSelect = false, editing = false, md = {} } = {}) { const pinia = createTestingPinia({ initialState: { browser: { settings: { search: "" } } }, }); @@ -28,6 +28,7 @@ function mountHeader({ multiSelect = false, editing = false } = {}) { collection: "comics", ids: [1, 2, 3], pageCount: 3, + ...md, }; return mount(MetadataHeader, { global: { @@ -65,4 +66,32 @@ describe("MetadataHeader — controls toolbar gate", () => { }); }); +const seriesYears = (md) => mountHeader({ md }).vm.seriesYears; + +describe("MetadataHeader — series years", () => { + test("shows a closed range", () => { + expect(seriesYears({ seriesStartYear: 1963, seriesEndYear: 1996 })).toBe( + "(1963–1996)", + ); + }); + + test("leaves an ongoing range open", () => { + expect(seriesYears({ seriesStartYear: 1963 })).toBe("(1963– )"); + }); + + test("collapses a single-year run", () => { + expect(seriesYears({ seriesStartYear: 1963, seriesEndYear: 1963 })).toBe( + "(1963)", + ); + }); + + test("shows an end year with no known start", () => { + expect(seriesYears({ seriesEndYear: 1996 })).toBe("(–1996)"); + }); + + test("shows nothing when neither year is known", () => { + expect(seriesYears({})).toBe(""); + }); +}); + export default {}; diff --git a/tests/importer/test_basic.py b/tests/importer/test_basic.py index acbafd697..a90270262 100644 --- a/tests/importer/test_basic.py +++ b/tests/importer/test_basic.py @@ -143,7 +143,9 @@ "TestImprint", "Captain Science", ): { - (None, None), + # identifier, volume_count, start_year, end_year, status, + # summary + (None, None, None, None, None, None), } }, SeriesGroup: {("science comics",): set()}, @@ -418,6 +420,10 @@ "Captain Science", None, None, + None, + None, + None, + None, ) }, Volume: { diff --git a/tests/importer/test_series_attrs.py b/tests/importer/test_series_attrs.py new file mode 100644 index 000000000..260356f53 --- /dev/null +++ b/tests/importer/test_series_attrs.py @@ -0,0 +1,190 @@ +""" +Comics of one series combine their series attributes in an import batch. + +Every comic proposes its own values for its series row. Within a batch +they merge per attribute: the earliest start year, the latest end year, +the largest count and the first comic's status and text. Against the +stored row a +later batch's values win and missing ones leave it alone. +""" + +import shutil +import zipfile +from pathlib import Path +from threading import Event, Lock +from typing import Final, override + +from django.core.cache import cache +from django.test import TestCase +from loguru import logger + +from codex.librarian.mp_queue import LIBRARIAN_QUEUE +from codex.librarian.scribe.importer.const import ( + CREATE_FKS, + EXTRACTED, + FIS, + UPDATE_FKS, +) +from codex.librarian.scribe.importer.importer import ComicImporter +from codex.librarian.scribe.importer.tasks import ImportTask +from codex.models import Series, Volume +from codex.models.library import Library +from codex.startup import codex_init +from tests.tmp_dirs import tmp_dir + +TMP_DIR: Final = tmp_dir(Path(__file__).stem) +_VOLUME_NUMBER: Final = 1963 +_EARLY: Final = 1963 +_MIDDLE: Final = 1970 +_LATE: Final = 1996 +_FEW: Final = 3 +_MANY: Final = 7 +_SUMMARY_B: Final = "The second comic's series summary." +_SUMMARY_C: Final = "The third comic's series summary." +_ONGOING: Final = "Ongoing" +_COMPLETED: Final = "Completed" + + +_ARCHIVE_YAML: Final = f"""comicbox: + publisher: + name: P + series: + name: S + start_year: {_EARLY} + end_year: {_LATE} + status: completed + summary: {_SUMMARY_B} +""" + + +def _md(series: dict | None = None, volume: dict | None = None) -> dict: + """Comicbox metadata for one comic of series S.""" + return { + "publisher": {"name": "P"}, + "series": {"name": "S", **(series or {})}, + "volume": {"number": _VOLUME_NUMBER, **(volume or {})}, + } + + +class TestSeriesAttrs(TestCase): + """Series attributes merge in a batch and update the stored row.""" + + @override + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cache.clear() + codex_init() + TMP_DIR.mkdir(exist_ok=True, parents=True) + + @override + @classmethod + def tearDownClass(cls): + try: + shutil.rmtree(TMP_DIR, ignore_errors=True) + finally: + super().tearDownClass() + + @override + def setUp(self): + cache.clear() + self.library = Library.objects.create(path=TMP_DIR) # pyright: ignore[reportUninitializedInstanceVariable] + + def _importer(self, paths) -> ComicImporter: + task = ImportTask(library_id=self.library.pk, files_modified=frozenset(paths)) + return ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event()) + + @staticmethod + def _create_collections(importer: ComicImporter) -> None: + importer.metadata[CREATE_FKS] = {} + importer.metadata[UPDATE_FKS] = {} + importer.query_all_missing_models() + importer.create_all_fks() + importer.update_all_fks() + + def _import(self, *mds: dict) -> None: + """Aggregate, query and create the collections for one batch.""" + paths = {str(TMP_DIR / f"c{index}.cbz"): md for index, md in enumerate(mds)} + importer = self._importer(paths) + importer.metadata[FIS] = {} + importer.metadata[EXTRACTED] = paths + importer.aggregate_metadata() + self._create_collections(importer) + + def test_archive_series_attrs(self) -> None: + """The attributes come through from an archive's comicbox metadata.""" + path = TMP_DIR / "archive.cbz" + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("comicbox.yaml", _ARCHIVE_YAML) + zf.writestr("page1.jpg", b"\xff\xd8\xff\xe0not-a-real-jpeg") + importer = self._importer({str(path)}) + importer.read() + self._create_collections(importer) + + series = Series.objects.get(name="S") + assert series.start_year == _EARLY + assert series.end_year == _LATE + assert series.status == _COMPLETED + assert series.summary == _SUMMARY_B + + def test_batch_merges_series_attrs(self) -> None: + """Earliest start, latest end, largest count, first status and text.""" + self._import( + _md({"start_year": _MIDDLE, "volume_count": _MANY}), + _md( + { + "start_year": _EARLY, + "end_year": _LATE, + "status": _ONGOING, + "summary": _SUMMARY_B, + }, + {"issue_count": _MANY}, + ), + _md( + { + "end_year": _MIDDLE, + "volume_count": _FEW, + "status": _COMPLETED, + "summary": _SUMMARY_C, + }, + {"issue_count": _FEW}, + ), + ) + + series = Series.objects.get(name="S") + assert series.start_year == _EARLY + assert series.end_year == _LATE + assert series.volume_count == _MANY + assert series.status == _ONGOING + assert series.summary == _SUMMARY_B + assert Volume.objects.get(series=series).issue_count == _MANY + + def test_missing_attrs_create_empty(self) -> None: + """A series with no attributes gets nulls and empty text.""" + self._import(_md()) + + series = Series.objects.get(name="S") + assert series.start_year is None + assert series.end_year is None + assert series.status == "" + assert series.summary == "" + + def test_later_batch_updates_stored_series(self) -> None: + """A later batch's values replace stored ones; missing ones don't.""" + self._import( + _md( + { + "start_year": _EARLY, + "end_year": _MIDDLE, + "status": _ONGOING, + "summary": _SUMMARY_B, + } + ) + ) + self._import(_md({"end_year": _LATE, "status": _COMPLETED})) + + series = Series.objects.get(name="S") + assert series.start_year == _EARLY + assert series.end_year == _LATE + assert series.status == _COMPLETED + assert series.summary == _SUMMARY_B diff --git a/tests/importer/test_update_all.py b/tests/importer/test_update_all.py index 22c3f1731..21fb76317 100644 --- a/tests/importer/test_update_all.py +++ b/tests/importer/test_update_all.py @@ -225,6 +225,10 @@ ( ("comicvine", "series", "333"), None, + None, + None, + None, + None, ), } }, @@ -498,6 +502,11 @@ "Captain Science", ("comicvine", "series", "333"), None, + None, + None, + # The stored status and summary stand. + "", + "", ) }, Character: {("Captain Science", ("metron", "character", "123"))}, diff --git a/tests/test_metadata_series_fields.py b/tests/test_metadata_series_fields.py new file mode 100644 index 000000000..784f11a2d --- /dev/null +++ b/tests/test_metadata_series_fields.py @@ -0,0 +1,109 @@ +"""The metadata pane carries the series' year range, status and summary.""" + +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.metadata_series_fields") +_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 +_HTTP_OK: Final = 200 +_START_YEAR: Final = 1963 +_END_YEAR: Final = 1996 +_STATUS: Final = "Completed" +_SUMMARY: Final = "A friendly neighborhood series." +_COMIC_SUMMARY: Final = "One issue of it." + + +def _v4(response): + """Unwrap the v4 ``{data, meta, errors}`` envelope and return ``data``.""" + body = response.json() + if isinstance(body, dict) and "data" in body and "meta" in body: + return body["data"] + return body + + +class MetadataSeriesFieldsTestCase(TestCase): + """The series attributes reach the metadata payload.""" + + @override + def setUp(self) -> None: + """Seed one comic in a series with every attribute set.""" + init_admin_flags() + TMP_DIR.mkdir(exist_ok=True, parents=True) + library = Library.objects.create(path=str(TMP_DIR)) + publisher = Publisher.objects.create(name="Pub") + imprint = Imprint.objects.create(name="Imp", publisher=publisher) + self.series = Series.objects.create( # pyright: ignore[reportUninitializedInstanceVariable] + name="Ser", + imprint=imprint, + publisher=publisher, + start_year=_START_YEAR, + end_year=_END_YEAR, + status=_STATUS, + summary=_SUMMARY, + ) + volume = Volume.objects.create( + name="1963", series=self.series, imprint=imprint, publisher=publisher + ) + path = TMP_DIR / "c1.cbz" + path.touch() + self.comic = Comic.objects.create( # pyright: ignore[reportUninitializedInstanceVariable] + library=library, + path=path, + issue_number=1, + name="C1", + publisher=publisher, + imprint=imprint, + series=self.series, + volume=volume, + size=42, + summary=_COMIC_SUMMARY, + ) + user = User.objects.create_user( + username="series_reader", password=_TEST_PASSWORD, is_staff=True + ) + self.client = Client() + self.client.force_login(user) + + @override + def tearDown(self) -> None: + """Remove the temp comic tree.""" + shutil.rmtree(TMP_DIR, ignore_errors=True) + + def _get_metadata(self, collection: str, pk: int) -> dict: + response = self.client.get(f"/api/v4/browse/{collection}/{pk}/metadata") + assert response.status_code == _HTTP_OK, response.content + return _v4(response) + + def test_comic_metadata_has_series_attrs(self) -> None: + data = self._get_metadata("comics", self.comic.pk) + assert data["seriesStartYear"] == _START_YEAR + assert data["seriesEndYear"] == _END_YEAR + assert data["seriesStatus"] == _STATUS + assert data["seriesSummary"] == _SUMMARY + + def test_series_metadata_has_series_attrs(self) -> None: + """ + A collection's pane reads the attributes through its comics. + + The comics' own summary keeps its key there too, though Series now + has a summary column of its own. + """ + data = self._get_metadata("series", self.series.pk) + assert data["seriesStartYear"] == _START_YEAR + assert data["seriesEndYear"] == _END_YEAR + assert data["seriesStatus"] == _STATUS + assert data["seriesSummary"] == _SUMMARY + assert data["summary"] == _COMIC_SUMMARY + + def test_unknown_years_are_omitted(self) -> None: + Series.objects.filter(pk=self.series.pk).update(start_year=None, end_year=None) + data = self._get_metadata("comics", self.comic.pk) + assert "seriesStartYear" not in data + assert "seriesEndYear" not in data diff --git a/tests/test_tag_write_date.py b/tests/test_tag_write_date.py index 2058757a1..adb323d24 100644 --- a/tests/test_tag_write_date.py +++ b/tests/test_tag_write_date.py @@ -39,6 +39,7 @@ _ISSUE_NUMBER: Final = "1" _ALTERNATIVE_ISSUE: Final = MappingProxyType({"number": 5, "suffix": "AU"}) _COLLECTION_TITLE: Final = "The Dark Phoenix Saga" +_START_YEAR: Final = 1963 def _double(stub: object) -> Any: @@ -105,6 +106,7 @@ def test_format_exclusive_fields(self) -> None: "collection_title", "alternative_issue_number", "alternative_issue_suffix", + "start_year", } assert metron_only <= metron assert not (metron_only & comic_info) @@ -206,3 +208,11 @@ def test_write_collection_title(self) -> None: sub_md = self._write_metron({"collection_title": _COLLECTION_TITLE}) assert sub_md.get("collection_title") == _COLLECTION_TITLE + + def test_write_series_start_year(self) -> None: + """The editor's Start Year rides the series object into MetronInfo.""" + sub_md = self._write_metron( + {"series": {"name": "S", "start_year": _START_YEAR}} + ) + + assert sub_md.get("series", {}).get("start_year") == _START_YEAR