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