|
5 | 5 | from ninja import Schema |
6 | 6 |
|
7 | 7 | from apps.content.models import Asset, StatusChoice |
8 | | -from apps.content.services.recommendations import get_similar_asset_ids, hydrate_visible_assets_in_order |
9 | | -from apps.core.ninja_utils.errors import NinjaErrorResponse |
| 8 | +from apps.content.services.recommendations import ( |
| 9 | + get_personalized_asset_ids, |
| 10 | + get_similar_asset_ids, |
| 11 | + get_trending_asset_ids, |
| 12 | + hydrate_visible_assets_in_order, |
| 13 | + list_active_editorial_recommendations, |
| 14 | +) |
| 15 | +from apps.core.ninja_utils.errors import ItqanError, NinjaErrorResponse |
10 | 16 | from apps.core.ninja_utils.request import Request |
11 | 17 | from apps.core.ninja_utils.router import ItqanRouter |
12 | 18 | from apps.core.ninja_utils.tags import NinjaTag |
@@ -84,3 +90,79 @@ def get_similar_recommendations(request: Request, asset_id: int): |
84 | 90 |
|
85 | 91 | similar_ids = get_similar_asset_ids(asset_id) |
86 | 92 | return hydrate_visible_assets_in_order(similar_ids) |
| 93 | + |
| 94 | + |
| 95 | +@router.get( |
| 96 | + "recommendations/trending/", |
| 97 | + response={200: list[RecommendedAssetOut]}, |
| 98 | +) |
| 99 | +@track_usage(entity_type="recommendation_trending") |
| 100 | +def get_trending_recommendations(request: Request, category: str | None = None): |
| 101 | + """ |
| 102 | + Currently popular content, ranked by recent usage weighted by event kind (a |
| 103 | + download counts for more than a view). Precomputed periodically via Celery beat |
| 104 | + (see apps.content.services.recommendations); an empty/not-yet-computed cache |
| 105 | + simply yields an empty list, same as /similar/ treats "no matches". |
| 106 | +
|
| 107 | + `category` optionally scopes the leaderboard to one CategoryChoice value (e.g. |
| 108 | + "recitation"); an unrecognised value behaves like "nothing trending yet" (empty |
| 109 | + list) rather than a 400, since that's cheap to tell apart from a client bug by |
| 110 | + just looking at the response. |
| 111 | + """ |
| 112 | + trending_ids = get_trending_asset_ids(category=category) |
| 113 | + return hydrate_visible_assets_in_order(trending_ids) |
| 114 | + |
| 115 | + |
| 116 | +@router.get( |
| 117 | + "recommendations/personalized/", |
| 118 | + response={ |
| 119 | + 200: list[RecommendedAssetOut], |
| 120 | + 401: NinjaErrorResponse[Literal["authentication_required"]], |
| 121 | + }, |
| 122 | +) |
| 123 | +@track_usage(entity_type="recommendation_personalized") |
| 124 | +def get_personalized_recommendations(request: Request): |
| 125 | + """ |
| 126 | + Suggestions based on the authenticated user's own download/view history (see |
| 127 | + compute_personalized_recommendations). Falls back to global trending when the |
| 128 | + user has no precomputed personalized data yet -- new account, no history since the |
| 129 | + last nightly run, or a history too narrow to score any candidates all count as "no |
| 130 | + personalized data", a valid non-error outcome, not "nothing to recommend at all". |
| 131 | +
|
| 132 | + Requires authentication (unlike similar/trending/editorial, which are pure |
| 133 | + discovery metadata): personalized results are tied to a specific user's history. |
| 134 | + """ |
| 135 | + user = getattr(request, "user", None) |
| 136 | + if not (user and user.is_authenticated): |
| 137 | + raise ItqanError( |
| 138 | + "authentication_required", |
| 139 | + _("You must be signed in to get personalized recommendations."), |
| 140 | + status_code=401, |
| 141 | + ) |
| 142 | + |
| 143 | + personalized_ids = get_personalized_asset_ids(user.id) |
| 144 | + if not personalized_ids: |
| 145 | + personalized_ids = get_trending_asset_ids() |
| 146 | + return hydrate_visible_assets_in_order(personalized_ids) |
| 147 | + |
| 148 | + |
| 149 | +class EditorialRecommendationOut(Schema): |
| 150 | + id: int |
| 151 | + title: str |
| 152 | + description: str |
| 153 | + assets: list[RecommendedAssetOut] |
| 154 | + |
| 155 | + |
| 156 | +@router.get( |
| 157 | + "recommendations/editorial/", |
| 158 | + response={200: list[EditorialRecommendationOut]}, |
| 159 | +) |
| 160 | +@track_usage(entity_type="recommendation_editorial") |
| 161 | +def get_editorial_recommendations(request: Request): |
| 162 | + """ |
| 163 | + Admin-curated featured collections (e.g. seasonal spotlights) currently in their |
| 164 | + active window, newest first. Unlike similar/trending/personalized this reads |
| 165 | + straight from the DB -- editorial collections change rarely and the query is |
| 166 | + already small and indexed, so there's no precompute/cache step. |
| 167 | + """ |
| 168 | + return list_active_editorial_recommendations() |
0 commit comments