Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions codex/views/browser/annotate/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,23 +288,32 @@ def _comic_order_value(self):

def _group_m2m_order_value(self, qs):
"""Group-row M2M order_value, falling back to ``sort_name``."""
isort_expr = m2m_intersection_sort_expr(qs.model, self.order_key)
if isort_expr is not None:
return qs, isort_expr
# Intersection sort matches the table-view cell display. Cover
# view's caption can't render M2M intersection and the rule
# would leave most cards with a NULL sort key, so card mode
# falls back to sort_name.
if self.params.get("view_mode") == "table":
isort_expr = m2m_intersection_sort_expr(qs.model, self.order_key)
if isort_expr is not None:
return qs, isort_expr
if qs.model is Volume:
qs = qs.alias(sort_name=F("name"))
return qs, F("sort_name")

def _group_scalar_order_value(self, qs):
"""Group-row scalar / FK-name order_value (intersection-aware)."""
# Display uses intersection — a value renders only when every
# child comic agrees — so the sort key matches that rule.
# Falls back to the legacy aggregate path when the column /
# group model isn't wired so adding a new registry scalar
# doesn't silently regress its sort.
isort_expr = scalar_intersection_sort_expr(qs.model, self.order_key)
if isort_expr is not None:
return isort_expr
"""Group-row scalar / FK-name order_value."""
# Table view uses intersection so the sort key matches the
# displayed cell (blank when children disagree). Cover view's
# card caption shows order_value directly — intersection there
# would blank the caption for any group with mixed children,
# which is the regression we're avoiding. Falls back to the
# legacy aggregate path when the column / group model isn't
# wired so adding a new registry scalar doesn't silently
# regress its sort.
if self.params.get("view_mode") == "table":
isort_expr = scalar_intersection_sort_expr(qs.model, self.order_key)
if isort_expr is not None:
return isort_expr
agg_func = _ORDER_AGGREGATE_FUNCS[self.order_key]
agg_func = self.order_agg_func if agg_func == Min else agg_func
field = self.rel_prefix + comic_order_path(self.order_key)
Expand Down
66 changes: 66 additions & 0 deletions tests/test_browser_ordering.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,72 @@ def test_year_sort_matches_intersection_display(self) -> None:
# intersection-sort key is NULL.
assert names == ["Beta", "Alpha", "Mixed"], names

def test_cover_mode_group_order_value_uses_aggregate(self) -> None:
"""
Cover view's group order_value is an aggregate, not an intersection.

Regression: the table-view PR routed group-row scalar sort
through ``scalar_intersection_sort_expr`` for every view mode.
That's correct for table view (sort matches the intersection
cell display) but blanks the order_value caption beneath
cover-mode cards for any group whose children disagree on
the sorted field. Series with mixed years rendered the card
caption as null; the user reported the same for Publish Date
across publishers, series, and folders.

Setup matches ``test_year_sort_matches_intersection_display``
— Alpha (clean 2020), Beta (single 2021), Mixed (2018 + 2024)
— but the request uses ``viewMode=cover``. Every group row
must come back with a non-null ``orderValue`` so the card
caption renders.
"""
library = Library.objects.first()
publisher = Publisher.objects.get(name="ZZ Press")
imprint = Imprint.objects.get(name="ZZ Imprint")
series_mixed = Series.objects.create(
name="Mixed", imprint=imprint, publisher=publisher
)
volume_mixed = Volume.objects.create(
name="2030",
series=series_mixed,
imprint=imprint,
publisher=publisher,
)
for issue, year in ((1, 2018), (2, 2024)):
path = TMP_DIR / f"mixed-{issue}.cbz"
path.touch()
Comic.objects.create(
library=library,
path=path,
issue_number=issue,
name=f"mixed-{issue}",
publisher=publisher,
imprint=imprint,
series=series_mixed,
volume=volume_mixed,
size=100 + issue,
year=year,
page_count=10 + issue,
)

url = f"/api/v3/p/{publisher.pk}/1"
self._patch_settings(
{"viewMode": "cover", "orderBy": "year", "orderReverse": False}
)
response = self.client.get(url)
assert response.status_code == _HTTP_OK, response.content
groups = response.json().get("groups", [])
by_name = {g["name"]: g for g in groups}
# Aggregate (Min year asc): Mixed=2018, Alpha=2020, Beta=2021.
# None must be null — the user-visible regression was a null
# caption on Mixed (children disagree). The serializer emits
# ``order_value`` as a CharField so values come back stringified.
assert by_name["Alpha"]["orderValue"] == "2020"
assert by_name["Beta"]["orderValue"] == "2021"
assert by_name["Mixed"]["orderValue"] == "2018", (
f"Mixed orderValue should be Min(year)=2018, got {by_name['Mixed']['orderValue']!r}"
)

def test_primary_sort_by_year_on_group_rows(self) -> None:
"""Min aggregate over a direct integer field (``year``) sorts correctly."""
# Alpha has two comics, both year=2020 → Min year = 2020.
Expand Down