diff --git a/Makefile b/Makefile index d48f6a7..0efa033 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,9 @@ test_ci: up: docker compose --file compose.dev.yaml up --remove-orphans +up-all: + docker compose --file compose.dev.yaml --profile taskrunner up --remove-orphans + down: docker compose --file compose.dev.yaml down --remove-orphans diff --git a/featureflags/__init__.py b/featureflags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/featureflags/data.py b/featureflags/data.py deleted file mode 100644 index 041ecb4..0000000 --- a/featureflags/data.py +++ /dev/null @@ -1,45 +0,0 @@ -from . import models - - -class FeatureFlag: - @staticmethod - def playlist_updates_schedule() -> str | None: - return models.FeatureFlag.objects.get_value("playlist_updates_schedule") # pyright: ignore[reportAttributeAccessIssue] - - @staticmethod - def event_updates_schedule() -> str | None: - return models.FeatureFlag.objects.get_value("event_updates_schedule") # pyright: ignore[reportAttributeAccessIssue] - - @staticmethod - def events_enabled_for_user(user_id: str) -> bool: - events_enabled_for_users: list[str] | None = models.FeatureFlag.objects.get_value( # pyright: ignore[reportAttributeAccessIssue] - "events_enabled_for_spotify_user_ids" - ) - return events_enabled_for_users is not None and user_id in events_enabled_for_users - - @staticmethod - async def aevents_enabled_for_user(user_id: str) -> bool: - events_enabled_for_users: list[str] | None = await models.FeatureFlag.objects.aget_value( # pyright: ignore[reportAttributeAccessIssue] - "events_enabled_for_spotify_user_ids" - ) - return events_enabled_for_users is not None and user_id in events_enabled_for_users - - @staticmethod - def event_sources_fetching_concurrency_limit() -> int | None: - return models.FeatureFlag.objects.get_value("event_sources_fetching_concurrency_limit") # pyright: ignore[reportAttributeAccessIssue] - - @staticmethod - async def aevent_sources_fetching_concurrency_limit() -> int | None: - return await models.FeatureFlag.objects.aget_value("event_sources_fetching_concurrency_limit") # pyright: ignore[reportAttributeAccessIssue] - - @staticmethod - def event_fetching_concurrency_limit() -> int | None: - return models.FeatureFlag.objects.get_value("event_fetching_concurrency_limit") # pyright: ignore[reportAttributeAccessIssue] - - @staticmethod - async def aevent_fetching_concurrency_limit() -> int | None: - return await models.FeatureFlag.objects.aget_value("event_fetching_concurrency_limit") # pyright: ignore[reportAttributeAccessIssue] - - @staticmethod - async def resolve_songkick_urls() -> bool: - return await models.FeatureFlag.objects.aget_value("resolve_songkick_urls") is True # pyright: ignore[reportAttributeAccessIssue] diff --git a/featureflags/management/__init__.py b/featureflags/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/featureflags/management/commands/__init__.py b/featureflags/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/featureflags/management/commands/create_feature_flag.py b/featureflags/management/commands/create_feature_flag.py deleted file mode 100644 index 3813307..0000000 --- a/featureflags/management/commands/create_feature_flag.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -import logging -from typing import Any - -from django.core.management import CommandError -from django.core.management.base import BaseCommand - -from featureflags.models import FeatureFlag - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - help = "Create a new feature flag" - - def add_arguments(self, parser: Any) -> None: - parser.add_argument("--name", type=str, help="The name of the feature flag") - parser.add_argument("--value", type=str, help="The value of the feature flag") - - def handle(self, *_: Any, **options: str) -> None: - name = options.get("name") - value = options.get("value") - - if not name: - raise CommandError("You must provide a feature flag name") - - if not value: - raise CommandError("You must provide a feature flag value") - - FeatureFlag.objects.create(name=name, value=json.loads(value)) diff --git a/featureflags/migrations/0001_initial.py b/featureflags/migrations/0001_initial.py deleted file mode 100644 index 474de86..0000000 --- a/featureflags/migrations/0001_initial.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.1.6 on 2025-06-06 17:30 - -import uuid - -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="FeatureFlag", - fields=[ - ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=250, unique=True)), - ("value", models.JSONField(null=True)), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/featureflags/migrations/__init__.py b/featureflags/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/featureflags/models.py b/featureflags/models.py deleted file mode 100644 index c664505..0000000 --- a/featureflags/models.py +++ /dev/null @@ -1,41 +0,0 @@ -import uuid -from typing import Any - -from django.db import models - - -class BaseModel(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - abstract = True - - -class FeatureFlagManager(models.Manager["FeatureFlag"]): - def get_value(self, name: str) -> Any | None: - try: - f = self.get(name=name) - except FeatureFlag.DoesNotExist: - return None - else: - return f.value - - async def aget_value(self, name: str) -> Any | None: - try: - f = await self.aget(name=name) - except FeatureFlag.DoesNotExist: - return None - else: - return f.value - - -class FeatureFlag(BaseModel): - name = models.CharField(max_length=250, unique=True) - value = models.JSONField(null=True) - - objects = FeatureFlagManager() - - def __str__(self) -> str: - return f"" diff --git a/mottle/settings.py b/mottle/settings.py index 5433932..9779ea3 100644 --- a/mottle/settings.py +++ b/mottle/settings.py @@ -35,7 +35,6 @@ "django.contrib.messages", "django.contrib.staticfiles", "web", - "featureflags", "urlshortener", "django_hosts", "django_htmx", @@ -303,11 +302,15 @@ PROXY_URL = env.str("PROXY_URL", None) +EVENTS_ENABLED = env.bool("EVENTS_ENABLED", True) +EVENT_SOURCES_FETCH_CONCURRENCY_LIMIT = env.int("EVENT_SOURCES_FETCH_CONCURRENCY_LIMIT", 100) +EVENTS_FETCH_CONCURRENCY_LIMIT = env.int("EVENTS_FETCH_CONCURRENCY_LIMIT", 100) EVENT_ARTIST_NAME_MATCH_THRESHOLD = env.int("EVENT_ARTIST_NAME_MATCH_THRESHOLD", 85) +RESOLVE_SONGKICK_URLS = env.bool("RESOLVE_SONGKICK_URLS", False) GEODJANGO_SRID = 4326 SCHEDULE = { - "PLAYLIST_UPDATES": env.str("SCHEDULE_PLAYLIST_UPDATES", ""), - "EVENT_UPDATES": env.str("SCHEDULE_EVENT_UPDATES", ""), + "PLAYLIST_UPDATES": env.str("SCHEDULE_PLAYLIST_UPDATES", None), + "EVENT_UPDATES": env.str("SCHEDULE_EVENT_UPDATES", None), } diff --git a/pyproject.toml b/pyproject.toml index 87ae38a..82e71a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ [dependency-groups] dev = [ "mypy==1.18.2", - "django-stubs==5.2.7", + "django-stubs==5.2.8", "lxml-stubs==0.5.1", "pytest==9.0.1", "pytest-asyncio==1.3.0", diff --git a/taskrunner/schedules.py b/taskrunner/schedules.py index a384d40..f2677d8 100644 --- a/taskrunner/schedules.py +++ b/taskrunner/schedules.py @@ -3,20 +3,17 @@ from typing import cast from croniter.croniter import croniter +from django.conf import settings from django_q.models import Schedule from django_q.utils import localtime -from featureflags.data import FeatureFlag - logger = logging.getLogger(__name__) PLAYLIST_UPDATES_NAME = "playlist_updates" PLAYLIST_UPDATES_FUNC = "web.tasks.check_playlists_for_updates" -PLAYLIST_UPDATES_SCHEDULE = FeatureFlag.playlist_updates_schedule() EVENT_UPDATES_NAME = "event_updates" EVENT_UPDATES_FUNC = "web.tasks.check_artists_for_event_updates" -EVENT_UPDATES_SCHEDULE = FeatureFlag.event_updates_schedule() def get_next_run(cron_schedule: str) -> datetime: @@ -24,32 +21,36 @@ def get_next_run(cron_schedule: str) -> datetime: def playlist_updates() -> None: - if PLAYLIST_UPDATES_SCHEDULE is None: - raise RuntimeError("playlist_updates schedule is not set") + schedule = settings.SCHEDULE.get("PLAYLIST_UPDATES") + if schedule is None: + logger.warning("PLAYLIST_UPDATES schedule is not set. Skipping task creation") + return Schedule.objects.update_or_create( name=PLAYLIST_UPDATES_NAME, defaults={ "func": PLAYLIST_UPDATES_FUNC, "schedule_type": Schedule.CRON, - "cron": PLAYLIST_UPDATES_SCHEDULE, + "cron": schedule, "cluster": "long_running", - "next_run": get_next_run(PLAYLIST_UPDATES_SCHEDULE), + "next_run": get_next_run(schedule), }, ) def event_updates() -> None: - if EVENT_UPDATES_SCHEDULE is None: - raise RuntimeError("event_updates schedule is not set") + schedule = settings.SCHEDULE.get("EVENT_UPDATES") + if schedule is None: + logger.warning("EVENT_UPDATES schedule is not set. Skipping task creation") + return Schedule.objects.update_or_create( name=EVENT_UPDATES_NAME, defaults={ "func": EVENT_UPDATES_FUNC, "schedule_type": Schedule.CRON, - "cron": EVENT_UPDATES_SCHEDULE, + "cron": schedule, "cluster": "long_running", - "next_run": get_next_run(EVENT_UPDATES_SCHEDULE), + "next_run": get_next_run(schedule), }, ) diff --git a/uv.lock b/uv.lock index 89faa84..7433ab9 100644 --- a/uv.lock +++ b/uv.lock @@ -356,7 +356,7 @@ wheels = [ [[package]] name = "django-stubs" -version = "5.2.7" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -364,22 +364,22 @@ dependencies = [ { name = "types-pyyaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/a8/bc8c55212978f1e666486b60a4bfb0bc3a066de8212fa7389ff0f3dca639/django_stubs-5.2.7.tar.gz", hash = "sha256:2a07e47a8a867836a763c6bba8bf3775847b4fd9555bfa940360e32d0ee384a1", size = 257339, upload-time = "2025-10-08T08:01:18.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/75/97626224fd8f1787bb6f7f06944efcfddd5da7764bf741cf7f59d102f4a0/django_stubs-5.2.8.tar.gz", hash = "sha256:9bba597c9a8ed8c025cae4696803d5c8be1cf55bfc7648a084cbf864187e2f8b", size = 257709, upload-time = "2025-12-01T08:13:09.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/66/1c8063eee88a943f01d073dbbbda34ed093bf6e19738178506a66abbd5ad/django_stubs-5.2.7-py3-none-any.whl", hash = "sha256:2864e74b56ead866ff1365a051f24d852f6ed02238959664f558a6c9601c95bf", size = 507733, upload-time = "2025-10-08T08:01:16.172Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3f/7c9543ad5ade5ce1d33d187a3abd82164570314ebee72c6206ab5c044ebf/django_stubs-5.2.8-py3-none-any.whl", hash = "sha256:a3c63119fd7062ac63d58869698d07c9e5ec0561295c4e700317c54e8d26716c", size = 508136, upload-time = "2025-12-01T08:13:07.963Z" }, ] [[package]] name = "django-stubs-ext" -version = "5.2.7" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/6f/a0bab0e6a7676ab3ca02d51b459444e9bd6dd747e3a43b9c24cae6d0a1c6/django_stubs_ext-5.2.7.tar.gz", hash = "sha256:b690655bd4cb8a44ae57abb314e0995dc90414280db8f26fff0cb9fb367d1cac", size = 6524, upload-time = "2025-10-08T08:00:38.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/a2/d67f4a5200ff7626b104eddceaf529761cba4ed318a73ffdb0677551be73/django_stubs_ext-5.2.8.tar.gz", hash = "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b", size = 6487, upload-time = "2025-12-01T08:12:37.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/c9/60445606e26706d3fccadf3b80ee1a9f32c1012683ff2ada7580937b2da9/django_stubs_ext-5.2.7-py3-none-any.whl", hash = "sha256:0466a7132587d49c5bbe12082ac9824d117a0dedcad5d0ada75a6e0d3aca6f60", size = 9979, upload-time = "2025-10-08T08:00:37.499Z" }, + { url = "https://files.pythonhosted.org/packages/da/2d/cb0151b780c3730cf0f2c0fcb1b065a5e88f877cf7a9217483c375353af1/django_stubs_ext-5.2.8-py3-none-any.whl", hash = "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", size = 9949, upload-time = "2025-12-01T08:12:36.397Z" }, ] [[package]] @@ -716,7 +716,7 @@ debug = [ { name = "pdbpp", specifier = "==0.11.7" }, ] dev = [ - { name = "django-stubs", specifier = "==5.2.7" }, + { name = "django-stubs", specifier = "==5.2.8" }, { name = "lxml-stubs", specifier = "==0.5.1" }, { name = "minify-html", specifier = ">=0.18.1" }, { name = "mypy", specifier = "==1.18.2" }, diff --git a/web/events/data.py b/web/events/data.py index 145df15..9c6a1f7 100644 --- a/web/events/data.py +++ b/web/events/data.py @@ -5,9 +5,9 @@ from datetime import date, datetime from typing import Any, Optional +from django.conf import settings from sentry_sdk import capture_exception, capture_message -from featureflags.data import FeatureFlag from urlshortener.models import ShortURL from .constants import ( @@ -515,7 +515,7 @@ async def extract_songkick_event(event_data: dict[str, Any]) -> Event: urls = [u.split("?")[0] for u in urls] - if await FeatureFlag.resolve_songkick_urls(): + if settings.RESOLVE_SONGKICK_URLS: calls = [asend_get_request(async_songkick_client, u, redirect_url=True, raise_for_lte_300=False) for u in urls] results = await asyncio.gather(*calls, return_exceptions=True) diff --git a/web/management/commands/get_event_updates.py b/web/management/commands/get_event_updates.py index e34426e..2444cef 100644 --- a/web/management/commands/get_event_updates.py +++ b/web/management/commands/get_event_updates.py @@ -3,11 +3,11 @@ from typing import Any, cast from asgiref.sync import async_to_sync +from django.conf import settings from django.core.management import CommandError from django.core.management.base import BaseCommand from tekore.model import FullPlaylistTrack -from featureflags.data import FeatureFlag from taskrunner.tasks import get_event_updates from web.data import TrackData from web.spotify import get_client_token @@ -34,7 +34,7 @@ def add_arguments(self, parser: Any) -> None: parser.add_argument( "--concurrency-limit", type=int, - default=FeatureFlag.event_fetching_concurrency_limit(), + default=settings.EVENTS_FETCH_CONCURRENCY_LIMIT, help="Limit the number of concurrent executions for event fetching (how many artists to check at once)", ) parser.add_argument( @@ -47,9 +47,7 @@ def add_arguments(self, parser: Any) -> None: def handle(self, *_: Any, **options: str) -> None: playlist_id: str | None = options.get("playlist_id") force_refetch: bool = cast("bool", options.get("force_refetch", False)) - concurrency_limit: int = cast( - "int", options.get("concurrency_limit", FeatureFlag.event_fetching_concurrency_limit()) - ) + concurrency_limit: int = cast("int", options.get("concurrency_limit", settings.EVENTS_FETCH_CONCURRENCY_LIMIT)) compile_notifications: bool = cast("bool", options.get("compile_notifications", False)) if playlist_id: diff --git a/web/management/commands/track_artists_events.py b/web/management/commands/track_artists_events.py index d2323b6..acda65f 100644 --- a/web/management/commands/track_artists_events.py +++ b/web/management/commands/track_artists_events.py @@ -3,10 +3,10 @@ from typing import Any, cast from asgiref.sync import async_to_sync +from django.conf import settings from django.core.management.base import BaseCommand, CommandError from tekore.model import FullPlaylistTrack -from featureflags.data import FeatureFlag from taskrunner.tasks import task_track_artists_events from web.data import TrackData from web.models import SpotifyUser @@ -32,7 +32,7 @@ def add_arguments(self, parser: Any) -> None: parser.add_argument( "--concurrency-limit", type=int, - default=FeatureFlag.event_sources_fetching_concurrency_limit(), + default=settings.EVENT_SOURCES_FETCH_CONCURRENCY_LIMIT, help="Limit the number of concurrent executions for event fetching (how many artists to check at once)", ) @@ -41,9 +41,7 @@ def handle(self, *_: Any, **options: str) -> None: artist_id: str | None = options.get("artist_id") # pyright: ignore[reportAssignmentType] playlist_id: str | None = options.get("playlist_id") # pyright: ignore[reportAssignmentType] force_reevaluate: bool = cast("bool", options.get("force_reevaluate", False)) # pyright: ignore[reportAssignmentType] - concurrency_limit: int = cast( - "int", options.get("concurrency_limit", FeatureFlag.event_fetching_concurrency_limit()) - ) + concurrency_limit: int = cast("int", options.get("concurrency_limit", settings.EVENTS_FETCH_CONCURRENCY_LIMIT)) if not user_id: raise CommandError("You must provide a user ID") diff --git a/web/tasks.py b/web/tasks.py index 0ad48da..43bfd86 100644 --- a/web/tasks.py +++ b/web/tasks.py @@ -15,7 +15,6 @@ from django.db.models import Q from sentry_sdk import capture_exception -from featureflags.data import FeatureFlag from web.metrics import TASK_RUNTIME_SECONDS from .events.data import EventSourceArtist, MusicBrainzArtist @@ -231,7 +230,7 @@ async def acheck_artists_for_event_updates( if concurrent_execution: calls = [artist.update_events(force_refetch=force_refetch) async for artist in artists] - concurrency_limit = concurrency_limit or await FeatureFlag.aevent_fetching_concurrency_limit() + concurrency_limit = concurrency_limit or settings.EVENTS_FETCH_CONCURRENCY_LIMIT if concurrency_limit: results = await gather_with_concurrency(concurrency_limit, *calls, return_exceptions=True) @@ -527,7 +526,7 @@ async def atrack_artists_events( if artist_name ] - concurrency_limit = concurrency_limit or await FeatureFlag.aevent_sources_fetching_concurrency_limit() + concurrency_limit = concurrency_limit or settings.EVENT_SOURCES_FETCH_CONCURRENCY_LIMIT if concurrency_limit: results = await gather_with_concurrency(concurrency_limit, *calls, return_exceptions=True) diff --git a/web/views.py b/web/views.py index d53bb2f..e392f65 100644 --- a/web/views.py +++ b/web/views.py @@ -22,7 +22,6 @@ from django_htmx.http import push_url, trigger_client_event from tekore.model import AlbumType, FullPlaylistTrack -from featureflags.data import FeatureFlag from taskrunner.tasks import task_track_artists_events, task_upload_cover_image from web.templatetags.tekore_model_extras import get_smallest_image @@ -180,9 +179,7 @@ async def callback(request: MottleHttpRequest) -> HttpResponse: request.session["spotify_user_display_name"] = spotify_user.display_name request.session["spotify_user_email"] = spotify_user.email request.session["spotify_user_image_url"] = get_smallest_image(user.images) - request.session["feature_flags"] = { - "events_enabled": await FeatureFlag.aevents_enabled_for_user(spotify_user.spotify_id), - } + request.session["feature_flags"] = {"events_enabled": settings.EVENTS_ENABLED} await spotify_auth_request.adelete() @@ -334,9 +331,7 @@ async def albums(request: MottleHttpRequest, artist_id: str) -> HttpResponse: dump_to_disk=True, ) - if await FeatureFlag.aevents_enabled_for_user(request.session["spotify_user_spotify_id"]) and bool( - request.POST.get("track-events", False) - ): + if settings.EVENTS_ENABLED and bool(request.POST.get("track-events", False)): await sync_to_async(task_track_artists_events)( artists_data={artist_id: artist.name}, spotify_user_id=request.session["spotify_user_id"] ) @@ -504,9 +499,7 @@ async def playlist_items(request: MottleHttpRequest, playlist_id: str) -> HttpRe for track in playlist_tracks if isinstance(track.track, FullPlaylistTrack) ] - if await FeatureFlag.aevents_enabled_for_user(request.session["spotify_user_spotify_id"]) and request.GET.get( - "track-artists", False - ): + if settings.EVENTS_ENABLED and request.GET.get("track-artists", False): artists = {} for track in tracks: for artist in track.artists: @@ -1030,7 +1023,6 @@ async def artist_events(request: MottleHttpRequest, artist_id: str) -> HttpRespo @require_http_methods(["GET", "POST"]) async def user_settings(request: MottleHttpRequest) -> HttpResponse: spotify_user = await SpotifyUser.objects.aget(spotify_id=request.session["spotify_user_spotify_id"]) - # events_enabled = await FeatureFlag.aevents_enabled_for_user(request.session["spotify_user_spotify_id"]) user = await sync_to_async(lambda: spotify_user.user)() # pyright: ignore[reportAttributeAccessIssue] if request.method == "GET": @@ -1102,7 +1094,7 @@ async def user_settings(request: MottleHttpRequest) -> HttpResponse: @require_GET async def user_events(request: MottleHttpRequest) -> HttpResponse: spotify_user = await SpotifyUser.objects.aget(spotify_id=request.session["spotify_user_spotify_id"]) - events_enabled = FeatureFlag.aevents_enabled_for_user(request.session["spotify_user_spotify_id"]) + events_enabled = settings.EVENTS_ENABLED if not events_enabled: return trigger_client_event(