add per-user favorites - #750
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.post_deletesignals on each of the seven target models drop dangling favorites; a nightlyJanitorCleanupFavoritesTask(also exposed in the admin Jobs tab) backstops any non-ORM deletes./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).folders/story_arc_numbersm2m JOINs on every favorite-filtered request.comic_filter_uses_m2mnow mirrors active groups so Comic queries skip.distinct()when no m2m clause is in play.favoriteis the first editable column in the table-view registry and the first sort key wired through the_ANNOTATED_ORDER_FIELDSpath. Annotation usesCase(When(pk__in=Subquery(...)), ...)so SQLite materializes the favorited-id set once per request.Favoritespreview tile appears only whenFavorite.objects.filter(user=request.user).exists(). The favorite filter passthrough?filters=%7B%22favorite%22%3A%20true%7Dworks on every existing OPDS v2 feed without further wiring.FAVORITE_MODEL_GROUP_CODES/FAVORITE_GROUP_CODE_MODELSincodex.models.favorite. Filter view, columns, API, signals, and cleanup all import from there instead of carrying near-duplicate copies.Frontend
useFavoritesStorePinia store with per-groupSetstate,hydrate()on login, and optimistictoggle()with rollback on error.FavoriteToggle.vuestar 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.v-dividers on each side and the star tinting primary when active.hydrate()on login andclear()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,
Favoritemodel,SettingsBrowserFilters.favorite,LibrarianStatusJRF status) are folded into a single0042_browser_table_view_and_favorites.pyso a fresh install runs one migration with the final shape.Test plan
favoritecolumn in table view via the column picker; click the header to sort; shift-click for multi-sort tail./opds/v2.0/) and confirm the "Favorites" preview tile appears once any favorite exists, disappears at zero.Cleanup Orphan Favoritesfrom the admin Jobs tab; confirm the JRF status surfaces and dangling rows are removed.folders__inorstory_arc_numbers__story_arc__inm2m JOINs (perf optimization sanity check).🤖 Generated with Claude Code