Skip to content

add per-user favorites - #750

Merged
ajslater merged 22 commits into
developfrom
claude/jovial-lederberg-d3818b
May 9, 2026
Merged

ajslater merged 22 commits into
developfrom
claude/jovial-lederberg-d3818b

Conversation

@ajslater

@ajslater ajslater commented May 9, 2026

Copy link
Copy Markdown
Owner

Summary

Per-user favorites feature spanning every browseable group (Publisher, Imprint, Series, Volume, Folder, Story Arc) plus Comic. Lets users star items, filter the browser to a "favorites only" navigation tree, sort the table view by favorite, and surface a Favorites preview tile on OPDS v2 start pages.

What's in here

Backend

  • New Favorite(user, group, target_id, created_at) model with a unique-together composite index that doubles as the lookup key for the filter, the API, and the per-row table-view annotation.
  • Cascade cleanup: post_delete signals on each of the seven target models drop dangling favorites; a nightly JanitorCleanupFavoritesTask (also exposed in the admin Jobs tab) backstops any non-ORM deletes.
  • HTTP API at /api/v3/favorites/:
    • GET /api/v3/favorites/{p:[…], i:[…], s:[…], v:[…], f:[…], a:[…], c:[…]} for the requesting user.
    • PUT|DELETE /api/v3/favorites/<group>/<int:target_id>/ — idempotent toggle, ACL-respected (404 hides existence of inaccessible rows from PUT).
  • Transitive browser filter: starring an ancestor lights up its full subtree (so navigation works); starring a descendant keeps every ancestor reachable. Implemented as a per-request OR over only the groups the user has actually favorited under, so empty groups don't drag in folders/story_arc_numbers m2m JOINs on every favorite-filtered request. comic_filter_uses_m2m now mirrors active groups so Comic queries skip .distinct() when no m2m clause is in play.
  • Table column + sort key: favorite is the first editable column in the table-view registry and the first sort key wired through the _ANNOTATED_ORDER_FIELDS path. Annotation uses Case(When(pk__in=Subquery(...)), ...) so SQLite materializes the favorited-id set once per request.
  • OPDS v2 start page: Favorites preview tile appears only when Favorite.objects.filter(user=request.user).exists(). The favorite filter passthrough ?filters=%7B%22favorite%22%3A%20true%7D works on every existing OPDS v2 feed without further wiring.
  • Single source of truth for the model→group-letter map: FAVORITE_MODEL_GROUP_CODES / FAVORITE_GROUP_CODE_MODELS in codex.models.favorite. Filter view, columns, API, signals, and cleanup all import from there instead of carrying near-duplicate copies.

Frontend

  • useFavoritesStore Pinia store with per-group Set state, hydrate() on login, and optimistic toggle() with rollback on error.
  • FavoriteToggle.vue star component used in three mount sites: browser cards (top-right, stacked below the child-count badge), reader header, table-view favorite column, and the metadata dialog control row.
  • "Favorites Only" filter item in the existing browser filter dropdown, between bookmark choices and dynamic submenus, with v-dividers on each side and the star tinting primary when active.
  • Auth flow wires hydrate() on login and clear() on logout so a different account signing in next doesn't see the previous user's stars.

Migrations

All four schema changes (table-view fields, Favorite model, SettingsBrowserFilters.favorite, LibrarianStatus JRF status) are folded into a single 0042_browser_table_view_and_favorites.py so a fresh install runs one migration with the final shape.

Test plan

  • Star a Publisher / Series / Volume / Folder / Story Arc / Comic via the card hover star, table cell, metadata dialog, and reader header — confirm persistence across reload and across different mount sites.
  • Toggle "Favorites Only" in the filter dropdown — confirm starring a Publisher reveals its full Imprints/Series/Volumes/Comics subtree, and starring a Comic keeps every ancestor navigable down to the comic.
  • Add the favorite column in table view via the column picker; click the header to sort; shift-click for multi-sort tail.
  • Hit OPDS v2 root (/opds/v2.0/) and confirm the "Favorites" preview tile appears once any favorite exists, disappears at zero.
  • Delete a favorited target via admin or filesystem; confirm the star clears (signal cleanup).
  • Run Cleanup Orphan Favorites from the admin Jobs tab; confirm the JRF status surfaces and dangling rows are removed.
  • Log out / log in as a different user — confirm previous account's stars are gone.
  • Run a sample browse with "Favorites Only" on while no folder or arc favorites exist — verify the SQL plan does NOT include folders__in or story_arc_numbers__story_arc__in m2m JOINs (perf optimization sanity check).

🤖 Generated with Claude Code

ajslater and others added 22 commits May 9, 2026 00:40
Phase 1 of the favorites feature — see tasks/favorites/00-plan.md for
the full design. Adds a single Favorite table keyed by
(user, group, target_id) where group is the same single-letter code
used in browser URLs (p|i|s|v|f|a|c). The composite unique constraint
doubles as the lookup index for both the upcoming "favorites only"
filter and the per-row Exists() annotation in table view.

Authenticated users only — favorites are persistent intent and don't
need the session fallback Bookmark uses for in-flight reading state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of the favorites feature.

Primary path: a `post_delete` handler in `codex/signals/django_signals.py`
catches deletes of any of the 7 favorite-able target models (Publisher,
Imprint, Series, Volume, Folder, StoryArc, Comic) and drops matching
rows from the `Favorite` table. A module-level map populated in
`connect_signals()` translates model class to single-letter group code.

Backstop: `JanitorCleanupFavoritesTask` runs nightly alongside the
existing orphan-bookmark/sessions/settings sweeps, deleting favorites
whose `target_id` no longer matches a real row. Catches paths that
bypass Django ORM signals (raw-SQL migrations, etc.).

Test coverage extended with `test_target_delete_cascades_via_signal`,
including a distractor row to confirm the (group, target_id) match is
exact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of the favorites feature.

  PUT    /api/v3/favorites/<group>/<target_id>/  201 new / 200 existing
  DELETE /api/v3/favorites/<group>/<target_id>/  204 always (idempotent)
  GET    /api/v3/favorites/                      {group: [ids...]}

Authenticated only - permission_classes pinned to IsAuthenticated so
neither an enabled-non-users install nor an anonymous session can
mutate or list favorites.

Detail PUT performs an ACL check via get_acl_filter(model, user) and
returns 404 (not 403) when the target is hidden, so an attacker can't
probe the existence of a row they aren't allowed to see.

GET emits a complete dict keyed by every group code (with empty arrays
where appropriate) so the frontend store can hydrate per-group Sets
without a second roundtrip per group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the favorites feature.

`SettingsBrowserFilters.favorite` (BooleanField, default False) joins
the existing per-user filter row, with a scalar branch added to
`_save_browser_filters` so the boolean isn't list-coerced through the
existing pk-list path. The serializer accepts `filters.favorite` as a
boolean.

Migration 0044 adds the column; it also incorporates the JRF
librarian-status choice deferred from Phase 2's signal/cron landing.

`BrowserFilterView.get_favorite_filter(model)` returns
`Q(pk__in=Favorite.objects.filter(user, group).values("target_id"))`
when the filter is set, and a no-op Q for unauthenticated users or
models that aren't favorite-able. The chain in `_get_query_filters`
gets one new line right after the bookmark filter.

Tests cover settings round-trip, default false, and a direct-ORM
proof that the subquery narrows a Series queryset to only favorited
rows. Full suite stays green at 209/209.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of the favorites feature.

`BROWSER_TABLE_COLUMNS` gains a `favorite` entry — the first editable
column in the registry. Sort key is `favorite`, edit widget is
`checkbox`. The order-by enum (`BROWSER_ORDER_BY_CHOICES` and the
matching `SettingsBrowser.order_by` choices via migration 0045)
gets the new key so users can sort by favorited state.

`favorite_annotation_for(model, user)` in
`codex/views/browser/columns.py` builds the queryset annotation:

  Exists(Favorite.objects.filter(user, group, target_id=OuterRef('pk')))

for authenticated users on a favorite-able model, a `Value(False)`
constant for anonymous sessions, or an empty dict for models outside
the favorite group map. The alias is just `favorite` — no Comic-field
collision.

`BrowserView._add_table_view_favorite_annotation` runs the annotator
unconditionally on model (groups + Comic) and unconditionally on
column selection — the per-row Exists is cheap and being always-on
lets sort-by-favorite work without gating on which columns the user
toggled. It runs right before the existing Comic-gated sort
annotators in `_get_common_queryset` so `add_order_by` sees the
alias.

Test suite extends the registry-invariant test to assert favorite is
the *only* editable column, plus new annotation-shape tests covering
authenticated, anonymous, and unmapped-model cases. 212/212 backend
tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6 of the favorites feature.

The favorite browser filter from Phase 4 already flows through OPDS
unchanged (BrowserSettingsFilterInputSerializer is shared), so any
client can request /api/v3/<group>/<page> with
?filters={"favorite": true} and the existing pipeline narrows the
feed.

The new visible bit is a "Favorites" preview LinkGroup on the v2
start page, conditionally appended in
OPDS2FeedGroupsView.get_ordered_groups when the requesting user has
at least one favorite. Empty-favorite users (and anonymous sessions)
see the unchanged Keep Reading / Latest Unread / Oldest Unread set.

FavoriteFilters.ONLY constant added in codex/views/opds/const.py for
symmetry with BookmarkFilters. OPDS v1 is symmetric but uses a
different facet structure; deferred to a follow-up if v1 clients
need the surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phases 1-6 done end-to-end on this branch. The handoff captures the
REST/filter/column/OPDS contracts the frontend will consume, plus
notes on judgement calls (404-on-hidden-target, always-on table
annotation, scalar-filter coercion fix, etc.) so the next agent can
dive into Vue without re-deriving the design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 added the model field and serializer entry but missed the
``_DEFAULT_FILTERS`` const, so the regenerated ``browser-defaults.json``
omitted the key and the frontend's initial ``state.settings.filters``
held an undefined favorite. Mirror the bookmark scalar entry so the
default round-trips through the build-choices script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-user favorites cache fed by ``GET /api/v3/favorites/`` and
flipped via ``PUT|DELETE /api/v3/favorites/<group>/<pk>/``. The
store keeps a Set per group code (p|i|s|v|f|a|c) so the
``isFavorite(group, pk)`` getter is constant-time, and ``toggle``
optimistically flips the local Set with rollback on API error so
the UI never lies about persisted state.

App-boot wiring: the existing ``user`` watcher in App.vue gains a
``hydrate()`` call so the cache is warm before browser cards
render their stars. Logout clears the cache so a different account
signing in next doesn't see the previous user's favorites.

Vitest covers hydrate (full + partial payload + error), toggle
both directions, optimistic rollback for both verbs, unknown
group, and clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A single ``FavoriteToggle.vue`` component (props: group, pk) drives
every mount site. It reads ``isFavorite`` from the favorites store,
fires ``toggle`` on click, and stays disabled for anonymous
sessions (the API rejects them with 403).

Mount sites:

- Browser cards: peer of the existing select-many checkbox so the
  lit star sits outside the controls' opacity-fade subtree and
  stays visible when set, only fading in on hover when off. Hidden
  for multi-pk aggregate cards (folder rollups, mixed-publisher
  story arcs) since favorites are per-row.
- Reader header: alongside the metadata dialog button, scoped to
  the current comic.
- Table view ``favorite`` column: the ``v-else-if`` branch in
  ``browser-table-cell.vue`` renders the toggle inline; the cell
  resolves ``(group, pk)`` from ``row.group``/``row.ids[0]`` for
  group rows and ``row.pk`` for comic rows.

Filter UI: a "Show Favorites Only" prepend-item in the existing
``BrowserFilterBySelect`` menu writes ``filters.favorite`` into
the browser store, with the icon flipping between outlined and
filled to mirror state. ``isFiltersClearable`` and the
``onClear`` fallback now know about the favorite filter so the
clear-all path resets it alongside bookmark and the dynamic list
filters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the table-view bullet placement; both surfaces landed in
the same release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Treat "favorites only" as a navigation tree: a row passes if it is
itself favorited, an ancestor of a favorite (so descending into a
starred Publisher reveals its Imprints / Series / Volumes / Comics),
or a descendant (so favoriting a Comic keeps every ancestor visible
all the way back to the root publisher list). Previously the filter
was a flat ``pk__in=<my favorites for this group>``, which made
favoriting a Publisher show the Publisher row but break navigation
into it — the Series list narrowed to favorited Series only, of
which there were none.

Each OR clause traces ``rel_prefix + comic_field`` to a per-group
favorited-id subquery. ``folders`` is the m2m of every ancestor
folder for a comic (the importer adds the full path chain), so a
favorited folder lights up every descendant folder + comic in one
clause; ``story_arc_numbers__story_arc`` is the analogous m2m to
StoryArc. A trailing self-clause (``pk__in=<my favorites for this
model>``) covers the "favorited group with no comics yet" edge
and lets the planner skip the Comic join when the row matches by
identity alone.

The filter always pulls in ``folders`` and
``story_arc_numbers__story_arc`` m2m joins on Comic, so
``comic_filter_uses_m2m`` learns to flag the favorite filter so
the Comic queryset gets ``.distinct()`` to dedup.

New ``FavoriteFilterTransitivityTestCase`` exercises both
directions end-to-end through the browser endpoint with two
parallel hierarchies (P1/I1/S1/V1/C1 vs. P2/I2/S2/V2/C2):

- Favoriting P1 → I1, S1, V1, C1 all surface in their lists; P2's
  branch is filtered out.
- Favoriting C1 → P1, I1, S1, V1 all remain navigable.
- Favoriting S1 → P2's subtree stays out, C1 still reachable.
- Filter off → both branches show (sanity check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The transitive favorite filter computed its rel from
``self.rel_prefix``, which is cached against the BROWSE model (set
once via ``self.model``). The browser pipeline runs sub-queries on
Comic during a group browse — annotation rebuilds, intersection
calculations — and applying the cached browse-model rel there
produced ``comic__pk`` against Comic and a ``Cannot resolve
keyword 'comic' into field`` FieldError as soon as a Folder browse
exercised the path.

Compute ``rel`` per-call from the queryset's actual model via
``self.get_rel_prefix(model)`` — same pattern as
``get_bm_rel(model)`` and ``get_acl_filter(model, user)``.

Pinned with a new test that walks an F1/F2/F3 folder chain where
F2 has no direct comic children: favoriting F1 must surface F2 in
the inner-folder list (drives the regression that exposed the
original bug, and confirms ``Folder.comic`` traverses the
``Comic.folders`` m2m as expected for empty-intermediate cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three feedback-driven adjustments:

- **Card star**: stack the toggle below the existing childCount badge
  in the top-right (the previous shared ``top: 0; right: 0`` slot
  was occluding the count circle on group cards).
- **Filter menu**: move "Favorites Only" out of the prepend-item
  slot and into the append-item slot, between the bookmark choices
  and the dynamic filter sub-menus, with ``v-divider``s on each
  side. Title text stays left-justified per v-list-item default;
  the star moves from ``prepend-icon`` to ``append-icon`` so it
  renders on the right. The lit state tints the icon primary so a
  user can read the row's state at a glance.
- **Metadata dialog**: add the toggle to the metadata-controls row
  alongside Download / Mark Read / Read so the star is reachable
  from the per-item detail view, not just the card grid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes to keep ``order_by=favorite`` from KeyError-ing on
``_ORDER_AGGREGATE_FUNCS``:

1. Add ``favorite`` to ``_ANNOTATED_ORDER_FIELDS``. The favorite
   column carries its own ``Exists`` annotation rather than an
   aggregate, so ``annotate_order_value`` should reference it via
   ``F("favorite")`` (matching ``bookmark_updated_at`` /
   ``sort_name``) instead of dispatching to the per-column
   aggregate map.
2. Annotate the favorite Exists *before* ``annotate_order_aggregates``
   in ``_get_common_queryset``. Django resolves ``F()`` targets at
   ``annotate()`` call time, not at SQL compile time — adding the
   ordering annotation first raises ``Cannot resolve keyword
   'favorite' into field`` because the named annotation isn't on
   the queryset yet.
3. Fall back to ``sort_name`` for the cover-pk subquery's
   ``order_by`` when the outer key is ``favorite``. The cover
   subquery is a fresh, correlated Comic queryset that doesn't
   carry the outer view's annotations, mirroring the existing
   m2m_columns fallback in ``_normalize_comic_order_key``.

Multi-sort extras: ``_extra_group_special`` now returns
``F("favorite")`` for the same reason — direct annotation, no
aggregation. Combined with ``sort_name`` into a small
``_EXTRA_GROUP_F_KEYS`` set so the dispatch stays under
``ruff PLR0911``'s return-count limit.

Pinned with ``test_table_view_sort_by_favorite_does_not_crash``
walking every group level under table view with
``order_by=favorite, order_reverse=true``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three intertwined changes that share a single common-const refactor:

* Move the model→group-letter dispatch into ``codex.models.favorite``
  as ``FAVORITE_MODEL_GROUP_CODES`` (and the reverse map
  ``FAVORITE_GROUP_CODE_MODELS``). Four near-identical copies (filter,
  columns, view, signal/cleanup) now consume the single source of
  truth, so adding an eighth group code in the future is a one-line
  change instead of a four-place sweep.

* ``BrowserFilterView.get_favorite_filter`` only emits OR clauses for
  groups the current user actually has favorites under. Two new
  ``cached_property``s — ``_active_favorite_group_codes`` (one
  ``DISTINCT`` query per request) and ``_favorite_subqueries``
  (per-group ``target_id`` subquery, materialized once) — drive both
  the per-clause skip and the m2m-JOIN gating in
  ``comic_filter_uses_m2m``. The previous Q always carried
  ``folders__in`` and ``story_arc_numbers__story_arc__in`` clauses,
  forcing m2m JOINs on every favorite-filtered Comic browse even when
  the user had only favorited a publisher / series. Now those JOINs
  only land when ``"f"`` or ``"a"`` is in the active set, and Comic
  queries skip ``.distinct()`` when no m2m clause is in play.

* ``favorite_annotation_for`` switches from ``Exists`` (correlated
  per-row subquery) to ``Case(When(pk__in=Subquery(...)), ...)``.
  SQLite materializes the favorited-id subquery once and runs a
  hash-set membership check per outer row, which is cheaper than the
  Exists's per-row indexed lookup. The unique index on
  ``(user, group, target_id)`` covers both forms, but the IN form
  saves a JOIN per row and lets the planner cache the subquery
  result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``JanitorCleanupFavoritesTask`` already runs nightly via
``_NIGHTLY_TASK_CLASSES``, but the admin Jobs UI had no surface for
the on-demand variant. Mirror ``cleanup_bookmarks``: register
``cleanup_favorites`` in ``_TASK_MAP`` so the POST endpoint can
queue the task, add the matching entry to ``codex.choices.jobs``
under the existing Cleanup section, and include the ``JRF`` status
code in ``_JANITOR_NIGHTLY_STATUSES`` so the Run Nightly Maintenance
button surfaces the favorite-cleanup substatus alongside the rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-release consolidation. Both feature tracks (browser table view
and per-user favorites) ship in the same release, and the four
migrations chained 0042 → 0043 → 0044 → 0045 walked the same
``SettingsBrowser.order_by`` choice list twice (0042 and 0045)
along with three separate ``ALTER TABLE`` blocks for additions to
``SettingsBrowserFilters``, ``LibrarianStatus``, and the new
``codex_favorite``. Folding everything into 0042 means a fresh
install applies one migration with the final shape directly,
including the post-table-view + favorite ``order_by`` choice set,
the ``Favorite`` model, the ``SettingsBrowserFilters.favorite``
boolean, and the ``JRF`` librarian-status entry.

The 0042 migration name is preserved (not renamed) since 0042 has
not shipped to a tagged release; deployments still on the
in-flight branch will need a fresh DB or a manual
``django_migrations`` adjustment to pick up the merged operations.

``manage.py makemigrations --dry-run --check`` reports no drift;
the full backend test suite (218 tests) passes against the
consolidated schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ajslater
ajslater merged commit 2349194 into develop May 9, 2026
3 checks passed
@ajslater
ajslater deleted the claude/jovial-lederberg-d3818b branch May 11, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant