Skip to content

Commit a6f29d6

Browse files
Feat/content recommendation engine part2 (#459)
* feat(content): add trending, personalized, and editorial recommendations (steps 2-4 of #226) Completes the Content Recommendation Engine (issue #226) on top of step 1 (similar-content, #458): - trending: recent UsageEvent popularity (downloads > api access > views), precomputed every 15 minutes into a global + per-category Redis leaderboard. GET /recommendations/trending/?category= - personalized: scores the catalog against a user's own recent usage history using the same facet weights as similar-content, precomputed nightly at 2:15am, falling back to trending when a user has no precomputed data yet. GET /recommendations/personalized/ (requires an API key) - editorial: new EditorialRecommendation/EditorialRecommendationAsset models for admin-curated collections (e.g. seasonal spotlights) with an active window (starts_at/ends_at) and ordered assets, exposed in Django admin. GET /recommendations/editorial/ (read straight from the DB, no cache) Shared scoring helpers were factored out of the similar-content implementation (facet indexing, pair scoring) so trending/personalized reuse the same candidate-generation approach rather than duplicating it. 38 new tests across service and public-endpoint layers; full apps/content and apps/usage_tracking suites pass with zero regressions (905 passed, 6 skipped). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(content): resolve redis test clients from django-redis and fix import order/formatting Same issue as #458: hardcoding host="localhost" for the Redis test client only works when pytest runs natively, not inside the django container CI uses (Redis lives on host "redis" there). Applied the same fix -- reuse get_redis_connection("default").connection_pool.connection_kwargs -- to all four files affected (similar, trending, personalized, service tests). Also fixes isort import ordering (redis vs model_bakery/django/oauth2_provider) in the same four files, and a black formatting fix in test_recommendations_service.py that pre-commit would have caught. * fix(content): resolve redis test client host from REDIS_URL, not django-redis Same correction as feat/content-recommendation-engine: get_redis_connection always raises NotImplementedError under dev/CI settings, since config/settings/development.py forces CACHES to LocMemCache regardless of REDIS_URL. Resolve host/port from REDIS_URL directly via decouple.config() instead, matching base.py's own resolution and working in both native runs (localhost) and CI's docker-compose network (redis). * fix(content): resolve migration conflict, missing translation, and import formatting - Add merge migration for the two independent content leaf nodes created by merging staging (asset-versioning: 0051_assetversion_content_edited) and this branch's own editorial recommendations migration (0050_editorialrecommendation_editorialrecommendationasset_and_more) -- migrate/makemigrations --check both failed without it. - Add the missing Arabic translation for "You must be signed in to get personalized recommendations." (personalized endpoint's 401 message) -- the string was added to code but never localized, which fails the extendedmakemessages --check CI step. - Let isort/black reformat an import in recommendations.py that pre-commit would have caught. Verified end-to-end against the actual CI Docker stack (docker-compose.local.yml): migrate, makemigrations --check, extendedmakemessages --check, full apps/content + apps/usage_tracking suite (941 passed, 6 skipped), and pre-commit run --all-files all pass clean. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b0750e6 commit a6f29d6

14 files changed

Lines changed: 1141 additions & 42 deletions

apps/content/admin.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
AssetVersion,
3131
CategoryChoice,
3232
ContentIssueReport,
33+
EditorialRecommendation,
34+
EditorialRecommendationAsset,
3335
Qiraah,
3436
RecitationAyahTiming,
3537
RecitationFolder,
@@ -943,3 +945,38 @@ def _bulk_update_status(self, request, queryset, new_status, label):
943945
for item in changing:
944946
send_issue_status_update_email.delay(item["id"], item["status"], new_status)
945947
self.message_user(request, f"Marked {count} reports as {label}.")
948+
949+
950+
class EditorialRecommendationAssetInline(admin.TabularInline):
951+
"""Ordered assets within an editorial recommendation collection."""
952+
953+
model = EditorialRecommendationAsset
954+
extra = 1
955+
fields = ["asset", "position"]
956+
raw_id_fields = ["asset"]
957+
ordering = ["position"]
958+
959+
960+
@admin.register(EditorialRecommendation)
961+
class EditorialRecommendationAdmin(admin.ModelAdmin):
962+
list_display = ["id", "title", "is_active", "starts_at", "ends_at", "assets_count", "created_at"]
963+
list_filter = ["is_active"]
964+
search_fields = ["title_en", "title_ar", "description_en", "description_ar"]
965+
readonly_fields = ["created_at", "updated_at"]
966+
inlines = [EditorialRecommendationAssetInline]
967+
968+
fieldsets = (
969+
(
970+
"Basic Information",
971+
{"fields": ("title_en", "title_ar", "description_en", "description_ar", "is_active")},
972+
),
973+
("Active Window", {"fields": ("starts_at", "ends_at")}),
974+
(
975+
"Timestamps",
976+
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
977+
),
978+
)
979+
980+
@admin.display(description="Assets")
981+
def assets_count(self, obj: EditorialRecommendation) -> int:
982+
return obj.recommendation_assets.count() if obj.pk else 0

apps/content/api/public/recommendations.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
from ninja import Schema
66

77
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
1016
from apps.core.ninja_utils.request import Request
1117
from apps.core.ninja_utils.router import ItqanRouter
1218
from apps.core.ninja_utils.tags import NinjaTag
@@ -84,3 +90,79 @@ def get_similar_recommendations(request: Request, asset_id: int):
8490

8591
similar_ids = get_similar_asset_ids(asset_id)
8692
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()
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Generated by Django 5.2.14 on 2026-08-23 16:56
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('content', '0049_recitation_track_folder_constraints'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='EditorialRecommendation',
16+
fields=[
17+
('id', models.AutoField(help_text='Unique identifier for this record', primary_key=True, serialize=False)),
18+
('created_at', models.DateTimeField(auto_now_add=True, help_text='Timestamp when this record was created')),
19+
('updated_at', models.DateTimeField(auto_now=True, help_text='Timestamp when this record was last updated')),
20+
('title', models.CharField(help_text="Collection title, e.g. 'Ramadan Recitations'", max_length=255)),
21+
('title_en', models.CharField(help_text="Collection title, e.g. 'Ramadan Recitations'", max_length=255, null=True)),
22+
('title_ar', models.CharField(help_text="Collection title, e.g. 'Ramadan Recitations'", max_length=255, null=True)),
23+
('description', models.TextField(blank=True, default='')),
24+
('description_en', models.TextField(blank=True, default='', null=True)),
25+
('description_ar', models.TextField(blank=True, default='', null=True)),
26+
('is_active', models.BooleanField(default=True, help_text='Whether this collection is eligible to be served')),
27+
('starts_at', models.DateTimeField(blank=True, help_text='Optional start of the active window', null=True)),
28+
('ends_at', models.DateTimeField(blank=True, help_text='Optional end of the active window', null=True)),
29+
],
30+
options={
31+
'ordering': ['-created_at'],
32+
},
33+
),
34+
migrations.CreateModel(
35+
name='EditorialRecommendationAsset',
36+
fields=[
37+
('id', models.AutoField(help_text='Unique identifier for this record', primary_key=True, serialize=False)),
38+
('created_at', models.DateTimeField(auto_now_add=True, help_text='Timestamp when this record was created')),
39+
('updated_at', models.DateTimeField(auto_now=True, help_text='Timestamp when this record was last updated')),
40+
('position', models.PositiveIntegerField(default=1, help_text='Display order within the collection')),
41+
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='editorial_recommendation_assets', to='content.asset')),
42+
('recommendation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recommendation_assets', to='content.editorialrecommendation')),
43+
],
44+
options={
45+
'ordering': ['position'],
46+
'unique_together': {('recommendation', 'asset')},
47+
},
48+
),
49+
migrations.AddField(
50+
model_name='editorialrecommendation',
51+
name='assets',
52+
field=models.ManyToManyField(related_name='editorial_recommendations', through='content.EditorialRecommendationAsset', to='content.asset'),
53+
),
54+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Generated by Django 5.2.14 on 2026-08-26 14:01
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('content', '0050_editorialrecommendation_editorialrecommendationasset_and_more'),
10+
('content', '0051_assetversion_content_edited'),
11+
]
12+
13+
operations = [
14+
]

apps/content/models.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,3 +922,53 @@ def clean(self):
922922
def save(self, *args, **kwargs) -> None:
923923
self.full_clean()
924924
super().save(*args, **kwargs)
925+
926+
927+
class EditorialRecommendation(BaseModel):
928+
"""Admin-curated collection of assets for a featured spotlight (e.g. Ramadan, Hajj).
929+
930+
Served by GET /recommendations/editorial/ (apps.content.services.recommendations)
931+
while ``is_active`` and within [starts_at, ends_at] -- both bounds are optional, so
932+
a collection can be always-on or scheduled ahead of time and left to auto-expire.
933+
"""
934+
935+
title = models.CharField(max_length=255, help_text="Collection title, e.g. 'Ramadan Recitations'")
936+
description = models.TextField(blank=True, default="")
937+
is_active = models.BooleanField(default=True, help_text="Whether this collection is eligible to be served")
938+
starts_at = models.DateTimeField(null=True, blank=True, help_text="Optional start of the active window")
939+
ends_at = models.DateTimeField(null=True, blank=True, help_text="Optional end of the active window")
940+
941+
assets = models.ManyToManyField(
942+
Asset,
943+
through="EditorialRecommendationAsset",
944+
related_name="editorial_recommendations",
945+
)
946+
947+
class Meta:
948+
ordering = ["-created_at"]
949+
950+
def __str__(self) -> str:
951+
return f"EditorialRecommendation({self.title})"
952+
953+
954+
class EditorialRecommendationAsset(BaseModel):
955+
"""One asset's position within an EditorialRecommendation collection."""
956+
957+
recommendation = models.ForeignKey(
958+
EditorialRecommendation,
959+
on_delete=models.CASCADE,
960+
related_name="recommendation_assets",
961+
)
962+
asset = models.ForeignKey(
963+
Asset,
964+
on_delete=models.CASCADE,
965+
related_name="editorial_recommendation_assets",
966+
)
967+
position = models.PositiveIntegerField(default=1, help_text="Display order within the collection")
968+
969+
class Meta:
970+
unique_together = [["recommendation", "asset"]]
971+
ordering = ["position"]
972+
973+
def __str__(self) -> str:
974+
return f"EditorialRecommendationAsset(recommendation={self.recommendation_id}, asset={self.asset_id})"

0 commit comments

Comments
 (0)