diff --git a/NEWS.md b/NEWS.md index 55b789a90..4f9616a2c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,6 +10,8 @@ border-radius: 128px; - Features - Series years, status and summary in the metadata pane. + - Filter by series years and status. + - Search series years, status and summary. - Online tagging saves series years, status and summary. - Browser title shows series years and a Tags button. - Tag editor edits series years, status and summary. End year, status and diff --git a/codex/choices/search.py b/codex/choices/search.py index cd1f1ceee..237cbac27 100644 --- a/codex/choices/search.py +++ b/codex/choices/search.py @@ -14,6 +14,8 @@ "month", "year", "page_count", + "series_end_year", + "series_start_year", "size", "decade", "volume", @@ -114,7 +116,15 @@ def _get_fieldmap_values(*args) -> tuple: "review": (), "scan_info": ("scan",), "series": (), + "series_end_year": _get_fieldmap_values("series_end_year", "end_year"), "series_groups": _get_fieldmap_values("series_groups"), + "series_start_year": _get_fieldmap_values("series_start_year", "start_year"), + # Hand-written: the generator would strip the final "s" into a + # bogus singular. + "series_status": ("series-status", "seriesstatus", "status"), + "series_summary": _get_fieldmap_values( + "series_summary", "series_desc", "series_description" + ), "size": (), "sources": _get_fieldmap_values("sources", "id_sources"), "stories": ("story",), diff --git a/codex/librarian/scribe/importer/const.py b/codex/librarian/scribe/importer/const.py index 68c0437b9..9495c997f 100644 --- a/codex/librarian/scribe/importer/const.py +++ b/codex/librarian/scribe/importer/const.py @@ -211,6 +211,8 @@ def _metron_fts_values(values: tuple) -> tuple: REPRINT_LANGUAGE_FIELD_NAME = "language" # Reprints index only their series names, under their own ComicFTS column. ALTERNATE_SERIES_FTS_FIELD_NAME = "alternate_series" +# The linked series' summary, indexed on each of its comics. +SERIES_SUMMARY_FTS_FIELD_NAME = "series_summary" UNIVERSES_FIELD_NAME = "universes" NAME_FIELD_NAME = "name" NUMBER_TO_FIELD_NAME = "number_to" diff --git a/codex/librarian/scribe/importer/create/foreign_keys.py b/codex/librarian/scribe/importer/create/foreign_keys.py index aaf6076dd..196cc517b 100644 --- a/codex/librarian/scribe/importer/create/foreign_keys.py +++ b/codex/librarian/scribe/importer/create/foreign_keys.py @@ -14,6 +14,8 @@ FTS_CREATED_M2MS, FTS_UPDATED_M2MS, MODEL_REL_MAP, + SERIES_SUMMARY_FTS_FIELD_NAME, + SUMMARY_FIELD_NAME, TOTAL, UPDATE_FKS, ) @@ -35,7 +37,7 @@ ) from codex.librarian.status import Status from codex.models.base import BaseModel -from codex.models.collections import Folder +from codex.models.collections import Folder, Series from codex.models.named import Universe @@ -213,6 +215,14 @@ def bulk_create_all_models(self, status) -> int: ) return count + @staticmethod + def _series_summary_changed(obj: BaseModel, update_args: Mapping) -> bool: + """Whether a series update changes the summary its comics index.""" + return ( + isinstance(obj, Series) + and update_args.get(SUMMARY_FIELD_NAME, obj.summary) != obj.summary + ) + def _bulk_update_models(self, model: type[BaseModel], status) -> int: count = 0 update_tuples: list[tuple] | None = self.metadata[UPDATE_FKS].pop(model, None) @@ -226,6 +236,7 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int: update_objs = [] update_tuples = sorted(update_tuples, key=str) fts_update_pks = set() + series_summary_pks = set() for values_tuple in update_tuples: key_args, update_args = self._get_create_update_args( model, @@ -235,6 +246,10 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int: parent_pk_maps, ) obj = model.objects.get(**key_args) + # The importer prunes unchanged series links, so a summary + # change has to re-index every comic of the series. + if self._series_summary_changed(obj, update_args): + series_summary_pks.add(obj.pk) for key, value in update_args.items(): setattr(obj, key, value) obj.presave() @@ -251,6 +266,11 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int: if fts_update_pks: field_name = model.__name__.lower() + "s" self.metadata[FTS_UPDATED_M2MS][field_name] = fts_update_pks + if series_summary_pks: + # Merge: the per-comic phases run once per chunk. + self.metadata[FTS_UPDATED_M2MS].setdefault( + SERIES_SUMMARY_FTS_FIELD_NAME, set() + ).update(series_summary_pks) update_fields = tuple(update_args_map.keys()) if model in COLLECTION_BASE_MODELS: diff --git a/codex/librarian/scribe/importer/create/link_fks.py b/codex/librarian/scribe/importer/create/link_fks.py index d14c60c49..e1dbaa10f 100644 --- a/codex/librarian/scribe/importer/create/link_fks.py +++ b/codex/librarian/scribe/importer/create/link_fks.py @@ -11,10 +11,11 @@ LINK_FKS, PARENT_FOLDER_FIELD_NAME, PROTAGONIST_FIELD_MODEL_MAP, + SERIES_SUMMARY_FTS_FIELD_NAME, VOLUME_FIELD_NAME, ) from codex.librarian.scribe.importer.link import LinkComicsImporter -from codex.models import Folder +from codex.models import Folder, Series from codex.models.comic import Comic from codex.settings import IMPORTER_LINK_FK_BATCH_SIZE @@ -161,6 +162,10 @@ def _get_comic_simple_fk_links( field_name, (values[-1],), ) + if isinstance(instance, Series): + self.add_links_to_fts( + subkey, SERIES_SUMMARY_FTS_FIELD_NAME, (instance.summary,) + ) def get_comic_fk_links( self, subkey: str | int, path: str, *, for_create: bool diff --git a/codex/librarian/scribe/importer/search/sync_m2m.py b/codex/librarian/scribe/importer/search/sync_m2m.py index d31c6d407..78a4605ff 100644 --- a/codex/librarian/scribe/importer/search/sync_m2m.py +++ b/codex/librarian/scribe/importer/search/sync_m2m.py @@ -1,10 +1,11 @@ """Update fts fields for updated foreign keys with non key search values.""" from itertools import batched +from types import MappingProxyType from typing import TYPE_CHECKING from django.db import transaction -from django.db.models.expressions import Value +from django.db.models.expressions import F, Value from django.db.models.functions.datetime import Now from django.db.models.functions.text import Concat from loguru import logger @@ -12,7 +13,10 @@ if TYPE_CHECKING: from collections.abc import Collection -from codex.librarian.scribe.importer.const import FTS_UPDATED_M2MS +from codex.librarian.scribe.importer.const import ( + FTS_UPDATED_M2MS, + SERIES_SUMMARY_FTS_FIELD_NAME, +) from codex.librarian.scribe.importer.finish import FinishImporter from codex.models.comic import ComicFTS from codex.models.functions import GroupConcat @@ -21,6 +25,16 @@ # default. ``ComicFTS`` carries ~28 columns; 500 rows x 28 cols is a # safe ceiling that still amortizes per-statement overhead. _FTS_BATCH_SIZE = 500 +# FTS columns that index a plain attribute of an FK, keyed by the column: +# (filter on the FK's updated pks, the attribute's value). No GROUP_CONCAT. +_FK_ATTR_FTS_MAP = MappingProxyType( + { + SERIES_SUMMARY_FTS_FIELD_NAME: ( + "comic__series__in", + F("comic__series__summary"), + ), + } +) class SearchIndexSyncManyToManyImporter(FinishImporter): @@ -73,8 +87,11 @@ def _gather_m2m_field_values( for field_name in field_names: if self.abort_event.is_set(): return per_comic - rel = f"comic__{field_name}__in" - fts_value = self._get_fts_m2m_concat(field_name) + fk_attr = _FK_ATTR_FTS_MAP.get(field_name) + rel, fts_value = fk_attr or ( + f"comic__{field_name}__in", + self._get_fts_m2m_concat(field_name), + ) model_pks = self.metadata[FTS_UPDATED_M2MS].pop(field_name) self.log.debug( f"Preparing {len(model_pks)} search entries for {field_name} updates." @@ -86,7 +103,9 @@ def _gather_m2m_field_values( .values_list("pk", "fts_value") ) for pk, raw_value in rows: - value = (raw_value or "").strip(",") + value = raw_value or "" + if not fk_attr: + value = value.strip(",") per_comic.setdefault(pk, {})[field_name] = value return per_comic diff --git a/codex/librarian/scribe/search/const.py b/codex/librarian/scribe/search/const.py index 812d40e84..1dfbede7a 100644 --- a/codex/librarian/scribe/search/const.py +++ b/codex/librarian/scribe/search/const.py @@ -11,6 +11,7 @@ "publisher", "imprint", "series", + "series_summary", "age_rating_tagged", "age_rating_metron", "country", diff --git a/codex/librarian/scribe/search/prepare.py b/codex/librarian/scribe/search/prepare.py index 449007b72..96f4a4259 100644 --- a/codex/librarian/scribe/search/prepare.py +++ b/codex/librarian/scribe/search/prepare.py @@ -24,6 +24,7 @@ "fts_publisher", "fts_imprint", "fts_series", + "fts_series_summary", "fts_age_rating_tagged", "fts_age_rating_metron", "fts_original_format", diff --git a/codex/librarian/scribe/search/sync.py b/codex/librarian/scribe/search/sync.py index 559b3c1fd..2a57b3a3d 100644 --- a/codex/librarian/scribe/search/sync.py +++ b/codex/librarian/scribe/search/sync.py @@ -55,6 +55,8 @@ # The second traverses the AgeRating.metron FK into AgeRatingMetron. "fts_age_rating_tagged": F("age_rating__name"), "fts_age_rating_metron": F("age_rating__metron__name"), + # Series.summary is the one non-name FK value in the index. + "fts_series_summary": F("series__summary"), } ) # Alias → FK-traversal path. The alias becomes the FTS column name diff --git a/codex/migrations/0059_series_filters_and_fts_summary.py b/codex/migrations/0059_series_filters_and_fts_summary.py new file mode 100644 index 000000000..f3401e492 --- /dev/null +++ b/codex/migrations/0059_series_filters_and_fts_summary.py @@ -0,0 +1,72 @@ +""" +Series filters and a searchable series summary. + +The browser filters gain the series start year, end year and status, so +the per-user filter settings get a column for each. + +The FTS5 rebuild adds the ``series_summary`` column. ``ComicFTS`` is +unmanaged, so makemigrations never sees it — the column list lives in +SQL and has to be dropped and recreated by hand, as 0049 did for +``alternate_series``. The table comes back empty; the librarian's +regular ``SearchIndexSyncTask`` repopulates it. +""" + +from django.db import migrations, models + +# 0049's column list, for the reverse migration. +_OLD_FTS_SQL = ( + "CREATE VIRTUAL TABLE codex_comicfts USING fts5(" + "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, " + "publisher, imprint, series, name, collection_title, " + "age_rating_tagged, age_rating_metron, country, language, " + "original_format, review, scan_info, summary, tagger, " + "alternate_series, characters, " + "credits, genres, locations, roles, series_groups, stories, " + "story_arcs, tags, teams, universes, sources" + ")" +) +# 0049's column list plus ``series_summary``. +_NEW_FTS_SQL = ( + "CREATE VIRTUAL TABLE codex_comicfts USING fts5(" + "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, " + "publisher, imprint, series, name, collection_title, " + "age_rating_tagged, age_rating_metron, country, language, " + "original_format, review, scan_info, summary, series_summary, tagger, " + "alternate_series, characters, " + "credits, genres, locations, roles, series_groups, stories, " + "story_arcs, tags, teams, universes, sources" + ")" +) +_DROP_FTS_SQL = "DROP TABLE IF EXISTS codex_comicfts;" + + +class Migration(migrations.Migration): + """Series filter columns and FTS series_summary.""" + + dependencies = [ + ("codex", "0058_series_fields"), + ] + + operations = [ + migrations.AddField( + model_name="settingsbrowserfilters", + name="series_end_year", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="settingsbrowserfilters", + name="series_start_year", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="settingsbrowserfilters", + name="series_status", + field=models.JSONField(default=list), + ), + # ComicFTS is unmanaged; its Django state tracks only (comic, + # created_at, updated_at). The FTS columns are a SQL-level concern — + # no state_operations are needed, only a raw DROP + CREATE. Unlike + # 0049, the reverse restores the previous table rather than none. + migrations.RunSQL(sql=_DROP_FTS_SQL, reverse_sql=_OLD_FTS_SQL), + migrations.RunSQL(sql=_NEW_FTS_SQL, reverse_sql=_DROP_FTS_SQL), + ] diff --git a/codex/models/comic.py b/codex/models/comic.py index 5b68b9ee3..c55661b5e 100644 --- a/codex/models/comic.py +++ b/codex/models/comic.py @@ -440,6 +440,7 @@ class ComicFTS(BaseModel): name = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) review = TextField(db_collation="nocase") summary = TextField(db_collation="nocase") + series_summary = TextField(db_collation="nocase") # FK collections publisher = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) imprint = CharField(db_collation="nocase", max_length=MAX_NAME_LEN) diff --git a/codex/models/settings.py b/codex/models/settings.py index 7b776414b..d0f8406f5 100644 --- a/codex/models/settings.py +++ b/codex/models/settings.py @@ -238,7 +238,10 @@ class SettingsBrowserFilters(BaseModel): original_format = JSONField(default=list) reading_direction = JSONField(default=list) reprints = JSONField(default=list) + series_end_year = JSONField(default=list) series_groups = JSONField(default=list) + series_start_year = JSONField(default=list) + series_status = JSONField(default=list) stories = JSONField(default=list) story_arcs = JSONField(default=list) tagger = JSONField(default=list) @@ -267,7 +270,10 @@ class SettingsBrowserFilters(BaseModel): "original_format", "reading_direction", "reprints", + "series_end_year", "series_groups", + "series_start_year", + "series_status", "stories", "story_arcs", "tagger", diff --git a/codex/serializers/browser/choices.py b/codex/serializers/browser/choices.py index c7504a739..84d5baf9f 100644 --- a/codex/serializers/browser/choices.py +++ b/codex/serializers/browser/choices.py @@ -23,6 +23,7 @@ VuetifyFileTypeChoiceField, VuetifyReadingDirectionChoiceField, VuetifyReadOnlyListField, + VuetifySeriesStatusChoiceField, ) from codex.serializers.models.pycountry import CountrySerializer, LanguageSerializer @@ -46,7 +47,10 @@ class BrowserFilterChoicesSerializer(Serializer): original_format = BooleanField(read_only=True) reading_direction = BooleanField(read_only=True) reprints = BooleanField(read_only=True) + series_end_year = BooleanField(read_only=True) series_groups = BooleanField(read_only=True) + series_start_year = BooleanField(read_only=True) + series_status = BooleanField(read_only=True) stories = BooleanField(read_only=True) story_arcs = BooleanField(read_only=True) tagger = BooleanField(read_only=True) @@ -81,7 +85,10 @@ class BrowserSettingsFilterSerializer(Serializer): child=VuetifyReadingDirectionChoiceField ) reprints = VuetifyReadOnlyListField() + series_end_year = VuetifyReadOnlyListField() series_groups = VuetifyReadOnlyListField() + series_start_year = VuetifyReadOnlyListField() + series_status = VuetifyReadOnlyListField(child=VuetifySeriesStatusChoiceField) stories = VuetifyReadOnlyListField() story_arcs = VuetifyReadOnlyListField() tagger = VuetifyReadOnlyListField() @@ -141,6 +148,12 @@ class BrowserChoicesCharPkSerializer(BrowserChoicesIntegerPkSerializer): pk = VuetifyCharField(read_only=True) +class BrowserChoicesSeriesStatusPkSerializer(BrowserChoicesIntegerPkSerializer): + """Status strings, but the "None" row keeps the int null code.""" + + pk = VuetifySeriesStatusChoiceField(read_only=True) + + class BrowserChoicesDecimalPkSerializer(BrowserChoicesIntegerPkSerializer): """Named Model Serailizer with pk = char hack for languages & countries.""" @@ -157,10 +170,20 @@ class BrowserChoicesDecimalPkSerializer(BrowserChoicesIntegerPkSerializer): "file_type": BrowserChoicesCharPkSerializer, "language": LanguageSerializer, "reprints": BrowserChoicesReprintPkSerializer, + "series_status": BrowserChoicesSeriesStatusPkSerializer, "universe": BrowserChoicesUniversePkSerializer, } ) -_LIST_FIELDS = frozenset({"decade", "monochrome", "reading_direction", "year"}) +_LIST_FIELDS = frozenset( + { + "decade", + "monochrome", + "reading_direction", + "series_end_year", + "series_start_year", + "year", + } +) # ``BrowserSettingsFilterSerializer().get_fields()`` walks DRF's # metaclass and instantiates a serializer just to look up four field # representations. The fields are class-level definitions, so resolve diff --git a/codex/serializers/browser/filters.py b/codex/serializers/browser/filters.py index 233011d8a..f08ab817e 100644 --- a/codex/serializers/browser/filters.py +++ b/codex/serializers/browser/filters.py @@ -12,6 +12,7 @@ VuetifyFileTypeChoiceField, VuetifyListField, VuetifyReadingDirectionChoiceField, + VuetifySeriesStatusChoiceField, ) @@ -39,7 +40,10 @@ class BrowserSettingsFilterInputSerializer(Serializer): original_format = VuetifyListField() reading_direction = VuetifyListField(child=VuetifyReadingDirectionChoiceField) reprints = VuetifyListField() + series_end_year = VuetifyListField() series_groups = VuetifyListField() + series_start_year = VuetifyListField() + series_status = VuetifyListField(child=VuetifySeriesStatusChoiceField) stories = VuetifyListField() story_arcs = VuetifyListField() tagger = VuetifyListField() diff --git a/codex/serializers/fields/vuetify.py b/codex/serializers/fields/vuetify.py index 73150460d..3f9be6e35 100644 --- a/codex/serializers/fields/vuetify.py +++ b/codex/serializers/fields/vuetify.py @@ -14,7 +14,11 @@ ) from codex.choices.browser import VUETIFY_NULL_CODE -from codex.models.choices import FileTypeChoices, ReadingDirectionChoices +from codex.models.choices import ( + FileTypeChoices, + ReadingDirectionChoices, + SeriesStatusChoices, +) from codex.serializers.fields.base import CodexChoiceField @@ -47,6 +51,25 @@ class VuetifyReadingDirectionChoiceField(VuetifyNullCodeFieldMixin, CodexChoiceF class_choices = ReadingDirectionChoices.values +class VuetifySeriesStatusChoiceField(VuetifyNullCodeFieldMixin, CodexChoiceField): # pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override] + """ + Series Status Choice Field. + + Its null code is the "None" choice for a blank status. It converts + before the choice check, which would reject it, and serializes back + to the int the frontend recognizes. + """ + + class_choices = SeriesStatusChoices.values + + @override + def to_internal_value(self, data) -> str | None: # pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override] + """Convert the null code to None, otherwise validate the choice.""" + if data in (VUETIFY_NULL_CODE, str(VUETIFY_NULL_CODE)): + return None + return super().to_internal_value(data) + + class VuetifyDecimalField(VuetifyNullCodeFieldMixin, DecimalField): # pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override] """Float Field with null code conversion.""" diff --git a/codex/user_data/schema.sql b/codex/user_data/schema.sql index 52e00a1b7..ff76bcbf7 100644 --- a/codex/user_data/schema.sql +++ b/codex/user_data/schema.sql @@ -126,7 +126,10 @@ CREATE TABLE IF NOT EXISTS settings_filters ( original_format TEXT NOT NULL DEFAULT '[]', reading_direction TEXT NOT NULL DEFAULT '[]', reprints TEXT NOT NULL DEFAULT '[]', + series_end_year TEXT NOT NULL DEFAULT '[]', series_groups TEXT NOT NULL DEFAULT '[]', + series_start_year TEXT NOT NULL DEFAULT '[]', + series_status TEXT NOT NULL DEFAULT '[]', stories TEXT NOT NULL DEFAULT '[]', story_arcs TEXT NOT NULL DEFAULT '[]', tagger TEXT NOT NULL DEFAULT '[]', diff --git a/codex/views/admin/tagwrite.py b/codex/views/admin/tagwrite.py index 082c3bb55..090087870 100644 --- a/codex/views/admin/tagwrite.py +++ b/codex/views/admin/tagwrite.py @@ -21,7 +21,7 @@ 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.models.comic import Comic, ComicFTS from codex.serializers.admin.tagging import ( SeriesAttrsRequestSerializer, TagWriteRequestSerializer, @@ -288,4 +288,11 @@ def post(self, request): series_pk = series_pks[0] Series.objects.filter(pk=series_pk).update(**attrs, updated_at=timezone.now()) + if "summary" in attrs: + # Search reads the summary from each comic's index row. Rewrite + # just that column in place: bumping the comics' updated_at for + # a full re-index would churn every comic of the series. + ComicFTS.objects.filter(comic__series_id=series_pk).update( + series_summary=attrs["summary"] + ) return Response({"detail": "Series details saved.", "series": series_pk}) diff --git a/codex/views/browser/choices.py b/codex/views/browser/choices.py index b39158df7..f2bc8abda 100644 --- a/codex/views/browser/choices.py +++ b/codex/views/browser/choices.py @@ -5,7 +5,7 @@ from typing import Any, cast, override from caseconverter import snakecase -from django.db.models import Exists, F, QuerySet +from django.db.models import Exists, F, Q, QuerySet from drf_spectacular.utils import extend_schema from rest_framework.response import Response from rest_framework.serializers import BaseSerializer @@ -73,6 +73,22 @@ IdentifierSource: "identifier__", } ) +# Series attributes, reached across the series FK. They aren't Comic fields, +# so ``get_rel_and_model`` can't look them up on the model. +_SERIES_ATTR_FIELD_RELS = MappingProxyType( + { + "series_end_year": "series__end_year", + "series_start_year": "series__start_year", + "series_status": "series__status", + } +) +# Plain columns whose "None" is a blank string, never NULL. +_BLANK_IS_NULL_RELS = frozenset({"series__status"}) +# Plain fields that offer a "None" choice, so they take the m2m +# availability rule: two distinct values, or a value and a "None". +_NULL_CHOICE_FIELDS = frozenset({"series_status"}) +# Plain fields whose choices are serialized as {pk, name} rows. +_NAMED_ROW_CHOICE_FIELDS = frozenset({"community_rating", "file_type", "series_status"}) _NULL_NAMED_ROW = MappingProxyType({"pk": VUETIFY_NULL_CODE, "name": DUMMY_NULL_NAME}) _NULL_NAMED_ROW_ITERABLE = (_NULL_NAMED_ROW,) # We only need to know whether an m2m relation has two or more distinct @@ -87,9 +103,25 @@ class BrowserChoicesViewBase(BrowserFilterView): TARGET: str = "choices" @staticmethod - def get_field_choices_query(comic_qs, field_name): + def present(comic_qs: QuerySet, rel: str) -> QuerySet: + """Comics with a value for the relation.""" + comic_qs = comic_qs.filter(**{f"{rel}__isnull": False}) + if rel in _BLANK_IS_NULL_RELS: + comic_qs = comic_qs.exclude(**{rel: ""}) + return comic_qs + + @staticmethod + def absent(comic_qs: QuerySet, rel: str) -> QuerySet: + """Comics without a value for the relation, the "None" choice.""" + query = Q(**{f"{rel}__isnull": True}) + if rel in _BLANK_IS_NULL_RELS: + query |= Q(**{rel: ""}) + return comic_qs.filter(query) + + @classmethod + def get_field_choices_query(cls, comic_qs, rel): """Get distinct values for the field.""" - return comic_qs.exclude(**{f"{field_name}__isnull": True}).distinct() + return cls.present(comic_qs, rel).distinct() def get_m2m_field_query(self, model, comic_qs: QuerySet): """Get distinct m2m value objects for the relation.""" @@ -97,17 +129,19 @@ def get_m2m_field_query(self, model, comic_qs: QuerySet): m2m_filter = {f"{back_rel}comic__in": comic_qs} return model.objects.filter(**m2m_filter).distinct() - @staticmethod - def does_m2m_null_exist(comic_qs, rel): - """Get if comics exist in the filter without values exists for an m2m field.""" + @classmethod + def does_null_exist(cls, comic_qs, rel): + """Get if comics exist in the filter without values for the field.""" # Detect if there are null choices. Regretably with another query. - return comic_qs.filter(**{f"{rel}__isnull": True}).exists() + return cls.absent(comic_qs, rel).exists() def get_rel_and_model(self, field_name) -> tuple: """Return the relation and model for the field name.""" rel_and_model = _FIELD_TO_REL_MODEL_MAP.get(field_name) if rel_and_model: rel, model = rel_and_model + elif rel := _SERIES_ATTR_FIELD_RELS.get(field_name): + model = None else: remote_field = getattr( Comic._meta.get_field(field_name), "remote_field", None @@ -135,8 +169,8 @@ class BrowserChoicesAvailableView(BrowserChoicesViewBase): serializer_class: type[BaseSerializer] | None = BrowserFilterChoicesSerializer - @staticmethod - def _has_two_distinct_m2m_values(comic_qs: QuerySet, rel: str) -> bool: + @classmethod + def _has_two_distinct_m2m_values(cls, comic_qs: QuerySet, rel: str) -> bool: """ Return True iff ``rel`` resolves to >= 2 distinct non-null values. @@ -148,7 +182,7 @@ def _has_two_distinct_m2m_values(comic_qs: QuerySet, rel: str) -> bool: rows scanned. """ first_two = list( - comic_qs.filter(**{f"{rel}__isnull": False}) + cls.present(comic_qs, rel) .values_list(rel, flat=True) .distinct()[:_M2M_DISTINCT_PROBE_LIMIT] ) @@ -190,22 +224,21 @@ def _build_field_probes( early_data[field_name] = True continue rel, m2m_model = self.get_rel_and_model(field_name) - if m2m_model is None: + if m2m_model is None and field_name not in _NULL_CHOICE_FIELDS: # FK on Comic: legacy semantic is "any non-null value exists". - aggregates[field_name] = Exists( - qs.filter(**{f"{field_name}__isnull": False}).values("pk")[:1] - ) + aggregates[field_name] = Exists(self.present(qs, rel).values("pk")[:1]) else: - # m2m / m2m-through: legacy semantic is "(distinct rels >= 2) + # m2m / m2m-through and plain fields with a "None" choice: + # legacy semantic is "(distinct rels >= 2) # OR (>= 1 rel AND a null sibling)". Decompose into two # cheap EXISTS booleans; the rarer "1 rel, 0 null" case # falls back to a per-field distinct-count probe below. m2m_specs[field_name] = rel aggregates[f"_has_{field_name}"] = Exists( - qs.filter(**{f"{rel}__isnull": False}).values("pk")[:1] + self.present(qs, rel).values("pk")[:1] ) aggregates[f"_null_{field_name}"] = Exists( - qs.filter(**{f"{rel}__isnull": True}).values("pk")[:1] + self.absent(qs, rel).values("pk")[:1] ) return early_data, aggregates, m2m_specs @@ -287,7 +320,7 @@ def _get_m2m_field_choices(self, model, comic_qs, rel): qs = qs.values(*values) # Add null if it exists - if self.does_m2m_null_exist(comic_qs, rel): + if self.does_null_exist(comic_qs, rel): iterables.append(_NULL_NAMED_ROW_ITERABLE) iterables.append(qs) @@ -309,11 +342,13 @@ def get_object(self) -> dict[str, Any]: # pyright: ignore[reportIncompatibleMet if m2m_model: choices = self._get_m2m_field_choices(m2m_model, qs, rel) else: - choices = self.get_field_choices_query(qs, field_name) - choices = choices.values_list(field_name, flat=True) + choices = self.get_field_choices_query(qs, rel) + choices = choices.values_list(rel, flat=True) - if field_name in ("community_rating", "file_type"): + if field_name in _NAMED_ROW_CHOICE_FIELDS: choices = tuple({"pk": choice, "name": choice} for choice in choices) + if field_name in _NULL_CHOICE_FIELDS and self.does_null_exist(qs, rel): + choices = (_NULL_NAMED_ROW, *choices) return { "field_name": field_name, diff --git a/codex/views/browser/const.py b/codex/views/browser/const.py index 29f64ab5d..a9933c077 100644 --- a/codex/views/browser/const.py +++ b/codex/views/browser/const.py @@ -17,7 +17,10 @@ "original_format", "reading_direction", "reprints", + "series_end_year", "series_groups", + "series_start_year", + "series_status", "stories", "story_arcs", "tagger", diff --git a/codex/views/browser/filters/field.py b/codex/views/browser/filters/field.py index 8a3b88867..ffc120a03 100644 --- a/codex/views/browser/filters/field.py +++ b/codex/views/browser/filters/field.py @@ -24,9 +24,15 @@ # via the ``age_rating__metron`` chain. "age_rating_tagged": "age_rating", "age_rating_metron": "age_rating__metron", + # Series attributes filter every comic of the series. + "series_end_year": "series__end_year", + "series_start_year": "series__start_year", + "series_status": "series__status", } ) _FILTER_ATTRIBUTES: frozenset[str] = frozenset(BROWSER_FILTER_KEYS) +# Filters whose "None" choice is a blank string column, never NULL. +_FILTER_BLANK_NULL_FIELDS: frozenset[str] = frozenset({"series_status"}) class ComicFieldFilterView(CollectionFilterView): @@ -45,6 +51,14 @@ def _is_multivalued(comic_rel: str) -> bool: break return False + @staticmethod + def _null_q(field: str, rel: str) -> Q: + """Match the "None" choice for a filter field.""" + query = Q(**{f"{rel}__isnull": True}) + if field in _FILTER_BLANK_NULL_FIELDS: + query |= Q(**{rel: ""}) + return query + @classmethod def _filter_by_comic_field(cls, field, rel_prefix, filter_list) -> Q: """Filter by a comic any2many attribute.""" @@ -74,14 +88,14 @@ def _filter_by_comic_field(cls, field, rel_prefix, filter_list) -> Q: # publishers browse over 18k comics: 52ms -> 32ms). inner = Q() if match_null: - inner |= Q(**{f"{comic_rel}__isnull": True}) + inner |= cls._null_q(field, comic_rel) if non_null_filter_list: inner |= Q(**{f"{comic_rel}__in": non_null_filter_list}) comic_pks = Comic.objects.filter(inner).values("pk") return Q(**{f"{rel_prefix}pk__in": comic_pks}) if match_null: - filter_query |= Q(**{f"{rel}__isnull": True}) + filter_query |= cls._null_q(field, rel) if non_null_filter_list: filter_query |= Q(**{f"{rel}__in": non_null_filter_list}) return filter_query diff --git a/codex/views/browser/filters/filter.py b/codex/views/browser/filters/filter.py index b9bea4194..0b565ee6d 100644 --- a/codex/views/browser/filters/filter.py +++ b/codex/views/browser/filters/filter.py @@ -16,7 +16,8 @@ # on Comic. Field-list mirrors ``codex.views.browser.const.BROWSER_FILTER_KEYS`` # minus the local-column / FK keys (year, decade, country, language, # community_rating, monochrome, original_format, file_type, reading_direction, -# tagger, age_rating_metron, age_rating_tagged). +# tagger, age_rating_metron, age_rating_tagged, series_end_year, +# series_start_year, series_status). _M2M_FILTER_KEYS: Final[frozenset[str]] = frozenset( { "characters", diff --git a/codex/views/browser/filters/search/field/column.py b/codex/views/browser/filters/search/field/column.py index 7f8b47489..21a4c255a 100644 --- a/codex/views/browser/filters/search/field/column.py +++ b/codex/views/browser/filters/search/field/column.py @@ -5,8 +5,11 @@ from django.db.models import CharField from django.db.models.fields.related import ForeignKey, ManyToManyField +from codex.models.collections import Series from codex.models.comic import Comic +# Series attributes searched on every comic of the series. +_SERIES_ATTR_FIELDS = ("end_year", "start_year", "status", "summary") _FIELD_TO_REL_SPAN_MAP = MappingProxyType( { "alternate_series": "reprints__series_name", @@ -15,6 +18,7 @@ "identifiers": "identifiers__key", "sources": "identifiers__source__name", "story_arcs": "story_arc_number__story_arc__name", + **{f"series_{name}": f"series__{name}" for name in _SERIES_ATTR_FIELDS}, } ) _FIELD_TYPE_MAP = MappingProxyType( @@ -24,6 +28,10 @@ for field in Comic._meta.get_fields() if field.concrete # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute] }, + **{ + f"series_{name}": Series._meta.get_field(name).__class__ + for name in _SERIES_ATTR_FIELDS + }, "role": ManyToManyField, "issue": CharField, } diff --git a/codex/views/browser/filters/search/parse.py b/codex/views/browser/filters/search/parse.py index 0318914e2..51a2c4562 100644 --- a/codex/views/browser/filters/search/parse.py +++ b/codex/views/browser/filters/search/parse.py @@ -48,6 +48,9 @@ "path", "page_count", "reading_direction", + "series_end_year", + "series_start_year", + "series_status", "size", "updated_at", "volume", diff --git a/frontend/src/components/browser/toolbars/top/filter-choice-list.vue b/frontend/src/components/browser/toolbars/top/filter-choice-list.vue index 403a5d8b2..f2d3f6c18 100644 --- a/frontend/src/components/browser/toolbars/top/filter-choice-list.vue +++ b/frontend/src/components/browser/toolbars/top/filter-choice-list.vue @@ -41,7 +41,12 @@ import { mapActions, mapState } from "pinia"; import { toVuetifyItems } from "@/vuetify-items"; import { useBrowserStore } from "@/stores/browser"; -const NUMERIC_FILTERS = new Set(["decade", "year"]); +const NUMERIC_FILTERS = new Set([ + "decade", + "seriesEndYear", + "seriesStartYear", + "year", +]); export default { name: "BrowserFilterChoiceList", diff --git a/tests/importer/test_basic.py b/tests/importer/test_basic.py index a90270262..a9f90e41e 100644 --- a/tests/importer/test_basic.py +++ b/tests/importer/test_basic.py @@ -637,6 +637,7 @@ "review": ("It wasn't all bad.",), "scan_info": ("Photocopied",), "series": ("Captain Science",), + "series_summary": ("",), "summary": ("Captain Science's many scientific adventures",), "age_rating_tagged": ("Everyone",), "tagger": ("comicbox dev",), @@ -668,6 +669,7 @@ "review": ("It wasn't all bad.",), "scan_info": ("Photocopied",), "series": ("Captain Science",), + "series_summary": ("",), "series_groups": ("science comics",), "sources": ("comicvine", "metron"), "stories": ("The Beginning",), @@ -709,6 +711,7 @@ "review": ("It wasn't all bad.",), "scan_info": ("Photocopied",), "series": ("Captain Science",), + "series_summary": ("",), "series_groups": ("science comics",), "sources": ("comicvine", "metron"), "stories": ("The Beginning",), @@ -749,6 +752,7 @@ "review": ("It wasn't all bad.",), "scan_info": ("Photocopied",), "series": ("Captain Science",), + "series_summary": ("",), "series_groups": ("science comics",), "sources": ("comicvine", "metron"), "stories": ("The Beginning",), diff --git a/tests/test_admin_series_attrs.py b/tests/test_admin_series_attrs.py index 09cf7cdf1..065f35636 100644 --- a/tests/test_admin_series_attrs.py +++ b/tests/test_admin_series_attrs.py @@ -7,6 +7,7 @@ from django.test import Client, TestCase from codex.models import Comic, Imprint, Library, Publisher, Series, Volume +from codex.models.comic import ComicFTS from codex.startup import init_admin_flags from tests.tmp_dirs import tmp_dir @@ -148,3 +149,25 @@ def test_non_admin_is_forbidden(self) -> None: response = self._post("comics", [self.comic.pk], status="Completed") assert response.status_code == _HTTP_FORBIDDEN assert Series.objects.get(pk=self.series.pk).status == "" + + def _series_summary_column(self, comic: Comic) -> str: + return ComicFTS.objects.get(comic=comic).series_summary + + def _index_comics(self) -> None: + for comic in (self.comic, self.other_comic): + ComicFTS.objects.create(comic=comic, series_summary="old") + + def test_summary_updates_the_search_index(self) -> None: + """The series' comics are searchable by the new summary at once.""" + self._index_comics() + response = self._post("comics", [self.comic.pk], summary=_SUMMARY) + assert response.status_code == _HTTP_OK, response.content + assert self._series_summary_column(self.comic) == _SUMMARY + assert self._series_summary_column(self.other_comic) == "old" + + def test_other_attrs_leave_the_search_index(self) -> None: + """Only a summary edit touches the index.""" + self._index_comics() + response = self._post("comics", [self.comic.pk], status="Completed") + assert response.status_code == _HTTP_OK, response.content + assert self._series_summary_column(self.comic) == "old" diff --git a/tests/test_browser_series_filters.py b/tests/test_browser_series_filters.py new file mode 100644 index 000000000..552b3b3b5 --- /dev/null +++ b/tests/test_browser_series_filters.py @@ -0,0 +1,207 @@ +""" +The series start year, end year and status browser filters. + +They filter across the ``Comic.series`` FK rather than a Comic column, and +a blank status is ``""`` rather than NULL, so the "None" status choice has +its own path through availability, choices and filtering. +""" + +import json +import shutil +from typing import Final, override + +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import Client, TestCase + +from codex.choices.browser import DUMMY_NULL_NAME, VUETIFY_NULL_CODE +from codex.models import Comic, Imprint, Library, Publisher, Series, Volume +from codex.startup import init_admin_flags +from tests.tmp_dirs import tmp_dir + +_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 +_HTTP_OK: Final = 200 +_HTTP_BAD_REQUEST: Final = 400 +TMP_DIR = tmp_dir("codex.tests.browser_series_filters") +_SETTINGS_URL: Final = "/api/v4/browse/publishers/settings" +_STATUS: Final = "Completed" + + +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 _SeriesFiltersFixtureTestCase(TestCase): + """Two series under one publisher: one with every attribute, one bare.""" + + @override + def setUp(self) -> None: + cache.clear() + 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 = Series.objects.create( # pyright: ignore[reportUninitializedInstanceVariable] + name="Ser", + imprint=self.imprint, + publisher=self.publisher, + start_year=1963, + end_year=1996, + status=_STATUS, + summary="A friendly neighborhood series.", + ) + self.other = Series.objects.create( # pyright: ignore[reportUninitializedInstanceVariable] + name="Other", + imprint=self.imprint, + publisher=self.publisher, + start_year=2001, + ) + self._create_comic("C1", self.series) + self._create_comic("C2", self.other) + user = User.objects.create_user( + username="series_filters_test", password=_TEST_PASSWORD + ) + self.client = Client() + self.client.force_login(user) + + @override + def tearDown(self) -> None: + shutil.rmtree(TMP_DIR, ignore_errors=True) + + def _create_comic(self, name: str, series: Series) -> Comic: + volume, _ = Volume.objects.get_or_create( + name=2024, series=series, imprint=self.imprint, publisher=self.publisher + ) + path = TMP_DIR / f"{name.lower()}.cbz" + path.touch() + return Comic.objects.create( + library=self.library, + path=path, + issue_number=1, + name=name, + publisher=self.publisher, + imprint=self.imprint, + series=series, + volume=volume, + size=42, + year=2024, + page_count=20, + ) + + def _browse(self, url: str) -> dict: + response = self.client.get(url) + assert response.status_code == _HTTP_OK, response.content + return _v4(response) + + def _patch_filters(self, filters: dict): + response = self.client.patch( + _SETTINGS_URL, + data=json.dumps({"filters": filters}), + content_type="application/json", + ) + cache.clear() + return response + + def _filter(self, filters: dict) -> None: + response = self._patch_filters(filters) + assert response.status_code == _HTTP_OK, response.content + + def _availability(self) -> dict: + cache.clear() + return self._browse(f"/api/v4/browse/publishers/{self.publisher.pk}/choices") + + def _choices(self, field_name: str) -> list: + url = f"/api/v4/browse/publishers/{self.publisher.pk}/choices/{field_name}" + return self._browse(url)["choices"] + + def _series_row_names(self) -> list[str]: + body = self._browse(f"/api/v4/browse/publishers/{self.publisher.pk}?page=1") + return sorted(card["name"] for card in body["collections"]) + + +class BrowserSeriesFilterChoicesTestCase(_SeriesFiltersFixtureTestCase): + """Which filter sub-menus show, and what they offer.""" + + def test_available_with_values(self) -> None: + """Years need one value; status a value plus a blank.""" + body = self._availability() + assert body["seriesStartYear"] is True, body + assert body["seriesEndYear"] is True, body + assert body["seriesStatus"] is True, body + + def test_unavailable_without_values(self) -> None: + """Blank series offer none of the three.""" + Series.objects.update(start_year=None, end_year=None, status="") + body = self._availability() + assert body["seriesStartYear"] is False, body + assert body["seriesEndYear"] is False, body + assert body["seriesStatus"] is False, body + + def test_status_with_one_value_and_no_blank(self) -> None: + """One status and no blank leaves nothing to choose between.""" + Series.objects.update(status=_STATUS) + body = self._availability() + assert body["seriesStatus"] is False, body + + def test_year_choices(self) -> None: + """Year choices are bare values with no null row.""" + assert sorted(self._choices("seriesStartYear")) == [1963, 2001] + assert self._choices("seriesEndYear") == [1996] + + def test_status_choices(self) -> None: + """The blank status becomes the "None" row with the int null code.""" + choices = self._choices("seriesStatus") + assert choices == [ + {"pk": VUETIFY_NULL_CODE, "name": DUMMY_NULL_NAME}, + {"pk": _STATUS, "name": _STATUS}, + ], choices + + +class BrowserSeriesFilterTestCase(_SeriesFiltersFixtureTestCase): + """The filters narrow the browse.""" + + def test_status_filter(self) -> None: + """A status selects its series.""" + self._filter({"seriesStatus": [_STATUS]}) + assert self._series_row_names() == ["Ser"] + + def test_status_none_filter(self) -> None: + """The "None" status selects series with a blank status.""" + self._filter({"seriesStatus": [VUETIFY_NULL_CODE]}) + assert self._series_row_names() == ["Other"] + + def test_start_year_filter(self) -> None: + """Year filters match any selected year.""" + self._filter({"seriesStartYear": [2001]}) + assert self._series_row_names() == ["Other"] + self._filter({"seriesStartYear": [1963, 2001]}) + assert self._series_row_names() == ["Other", "Ser"] + + def test_end_year_filter(self) -> None: + """The end year filter skips series without one.""" + self._filter({"seriesEndYear": [1996]}) + assert self._series_row_names() == ["Ser"] + + def test_filter_on_series_browse(self) -> None: + """Inside a series the filter applies to its comics.""" + url = f"/api/v4/browse/series/{self.series.pk}?page=1" + self._filter({"seriesStartYear": [1963]}) + assert [book["name"] for book in self._browse(url)["books"]] == ["C1"] + self._filter({"seriesStartYear": [2001]}) + assert self._browse(url)["books"] == [] + + def test_invalid_status_refused(self) -> None: + """Only real statuses and the null code validate.""" + response = self._patch_filters({"seriesStatus": ["Finished"]}) + assert response.status_code == _HTTP_BAD_REQUEST, response.content + + def test_status_settings_round_trip(self) -> None: + """The null code and a status survive saving and reloading.""" + self._filter({"seriesStatus": [VUETIFY_NULL_CODE, _STATUS]}) + body = self._browse(_SETTINGS_URL) + assert body["filters"]["seriesStatus"] == [VUETIFY_NULL_CODE, _STATUS], body diff --git a/tests/test_search_series_fields.py b/tests/test_search_series_fields.py new file mode 100644 index 000000000..d81c9ff69 --- /dev/null +++ b/tests/test_search_series_fields.py @@ -0,0 +1,230 @@ +""" +Series start year, end year, status and summary are searchable. + +The years and status are ORM columns reached across the series FK, like +``year:``. The summary is indexed into its own ``ComicFTS.series_summary`` +column so bare search terms find it. + +Three code paths write that column: the importer builds FTS entries inline +for the comics it links, its foreign-key re-sync rewrites every comic of a +series whose summary changed (the inline path skips comics whose series +link didn't change), and :class:`SearchIndexerSync` rebuilds it from the +database. Each is asserted here. +""" + +import shutil +import zipfile +from datetime import UTC, datetime +from http import HTTPStatus +from pathlib import Path +from threading import Event, Lock +from typing import Final, override +from urllib.parse import urlencode + +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import Client, TestCase +from loguru import logger + +from codex.librarian.mp_queue import LIBRARIAN_QUEUE +from codex.librarian.scribe.importer.importer import ComicImporter +from codex.librarian.scribe.importer.tasks import ImportTask +from codex.librarian.scribe.search.sync import SearchIndexerSync +from codex.models import Comic, Publisher, Series +from codex.models.comic import ComicFTS +from codex.models.library import Library +from codex.startup import codex_init +from tests.importer.test_basic import run_full_import +from tests.tmp_dirs import tmp_dir + +TMP_DIR: Final = tmp_dir(Path(__file__).stem) +_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105 +_ALPHA_SUMMARY: Final = "A rodeo of heroes." +# Comfortably past any index watermark the run just wrote. +_FUTURE: Final = datetime(2100, 1, 1, tzinfo=UTC) + + +def _yaml(series: str, extra: str = "") -> str: + return f"""comicbox: + publisher: + name: P + series: + name: {series} +{extra}""" + + +_ALPHA_YAML: Final = _yaml( + "Alpha", + f""" start_year: 1963 + end_year: 1996 + status: completed + summary: {_ALPHA_SUMMARY} +""", +) +_BETA_YAML: Final = _yaml("Beta", " start_year: 2001\n") + + +class _SeriesSearchTestCase(TestCase): + """Import comics from archives carrying series attributes.""" + + @override + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cache.clear() + codex_init() + + @override + @classmethod + def tearDownClass(cls): + try: + shutil.rmtree(TMP_DIR, ignore_errors=True) + finally: + super().tearDownClass() + + @override + def setUp(self): + cache.clear() + TMP_DIR.mkdir(exist_ok=True, parents=True) + self.library = Library.objects.create(path=TMP_DIR) # pyright: ignore[reportUninitializedInstanceVariable] + user = User.objects.create_user( + username="series_search_test", password=_TEST_PASSWORD + ) + self.client = Client() + self.client.force_login(user) + + @override + def tearDown(self): + shutil.rmtree(TMP_DIR, ignore_errors=True) + cache.clear() + + @staticmethod + def _write(name: str, yaml: str) -> str: + path = TMP_DIR / name + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("comicbox.yaml", yaml) + zf.writestr("page1.jpg", b"\xff\xd8\xff\xe0not-a-real-jpeg") + return str(path) + + def _import(self, *paths: str) -> None: + # A same-size rewrite within the same second looks unchanged to the + # mtime prefilter, so always re-read. + task = ImportTask( + library_id=self.library.pk, + files_modified=frozenset(paths), + check_metadata_mtime=False, + ) + run_full_import(ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event())) + + @staticmethod + def _series_summary_column(path: str) -> str: + return ComicFTS.objects.get(comic__path=path).series_summary + + def _browse(self, url: str, query: str) -> dict: + params = urlencode({"page": 1, "q": query}) + response = self.client.get(f"{url}?{params}") + assert response.status_code == HTTPStatus.OK, response.content + return response.json()["data"] + + +class SeriesFieldsSearchTestCase(_SeriesSearchTestCase): + """Two series, one with every attribute set and one with only a start.""" + + @override + def setUp(self): + super().setUp() + self.alpha_path = self._write("alpha.cbz", _ALPHA_YAML) # pyright: ignore[reportUninitializedInstanceVariable] + self.beta_path = self._write("beta.cbz", _BETA_YAML) # pyright: ignore[reportUninitializedInstanceVariable] + self._import(self.alpha_path, self.beta_path) + self.publisher = Publisher.objects.get(name="P") # pyright: ignore[reportUninitializedInstanceVariable] + + def _series_names(self, query: str) -> list[str]: + """Search the publisher's series cards: the ``series__`` strip.""" + body = self._browse(f"/api/v4/browse/publishers/{self.publisher.pk}", query) + return sorted(card["name"] for card in body["collections"]) + + def _publisher_names(self, query: str) -> list[str]: + """Search the publisher cards: the ``comic__`` prefix.""" + body = self._browse("/api/v4/browse/publishers", query) + return [card["name"] for card in body["collections"]] + + def _book_pks(self, path: str, query: str) -> list[int]: + """Search the comic's series' comics: no prefix.""" + comic = Comic.objects.get(path=path) + body = self._browse(f"/api/v4/browse/series/{comic.series_id}", query) # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute] + return [book["pk"] for book in body["books"]] + + def test_import_indexes_series_summary(self) -> None: + """The inline path writes each comic's series summary, blank or not.""" + assert self._series_summary_column(self.alpha_path) == _ALPHA_SUMMARY + assert self._series_summary_column(self.beta_path) == "" + + def test_bare_term_finds_series_summary(self) -> None: + """A bare token matches the series summary column.""" + assert self._series_names("rodeo") == ["Alpha"] + assert self._series_names("Zzyzx") == [] + + def test_field_aliases_find_series_summary(self) -> None: + """Every alias resolves to the series summary, not the comic's.""" + for alias in ("series_summary", "series_desc", "series_description"): + assert self._series_names(f"{alias}:rodeo") == ["Alpha"], alias + assert self._series_names("summary:rodeo") == [] + + def test_series_columns(self) -> None: + """Years and status search like ``year:`` across the series FK.""" + hits = ( + "start_year:1963", + "start_year:1960..1970", + "end_year:<2000", + "status:completed", + "status:comp*", + ) + for query in hits: + assert self._series_names(query) == ["Alpha"], query + assert self._series_names("start_year:>1990") == ["Beta"] + assert self._series_names("status:ongoing") == [] + + def test_series_columns_on_publishers(self) -> None: + """Collection browses above the series reach it through its comics.""" + assert self._publisher_names("start_year:1963") == ["P"] + assert self._publisher_names("status:completed") == ["P"] + assert self._publisher_names("start_year:1900") == [] + + def test_series_columns_on_comics(self) -> None: + """A series' comics match on their own series' attributes.""" + alpha_pk = Comic.objects.get(path=self.alpha_path).pk + beta_pk = Comic.objects.get(path=self.beta_path).pk + assert self._book_pks(self.alpha_path, "status:completed") == [alpha_pk] + assert self._book_pks(self.beta_path, "status:completed") == [] + assert self._book_pks(self.beta_path, "start_year:2001") == [beta_pk] + + def test_search_index_sync_populates_series_summary(self) -> None: + """A full rebuild fills the column; an incremental sync updates it.""" + indexer = SearchIndexerSync(logger, LIBRARIAN_QUEUE, Lock(), Event()) + indexer.update_search_index(rebuild=True) + assert self._series_summary_column(self.alpha_path) == _ALPHA_SUMMARY + assert self._series_names("rodeo") == ["Alpha"] + + Series.objects.filter(name="Alpha").update(summary="A lasso.") + # ``updated_at`` is auto_now, so ``.update()`` is the only way to + # push the comic past the index watermark without a save(). + Comic.objects.filter(path=self.alpha_path).update(updated_at=_FUTURE) + indexer.update_search_index(rebuild=False) + assert self._series_summary_column(self.alpha_path) == "A lasso." + + +class SeriesSummaryResyncTestCase(_SeriesSearchTestCase): + """A summary change re-indexes comics the import didn't touch.""" + + def test_summary_change_reindexes_series_comics(self) -> None: + """Re-importing one comic updates its sibling's index row too.""" + a_path = self._write("a.cbz", _yaml("Shared", " summary: one\n")) + b_path = self._write("b.cbz", _yaml("Shared")) + self._import(a_path, b_path) + assert self._series_summary_column(b_path) == "one" + + self._write("a.cbz", _yaml("Shared", " summary: two\n")) + self._import(a_path) + assert Series.objects.get(name="Shared").summary == "two" + assert self._series_summary_column(a_path) == "two" + assert self._series_summary_column(b_path) == "two"