diff --git a/config/settings.py b/config/settings.py index 7a6710a852..0e6869a7d7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -52,9 +52,7 @@ "health_check.contrib.redis", "heroicons", "widget_tweaks", - "simplecasts.episodes", - "simplecasts.podcasts", - "simplecasts.users", + "simplecasts", ] @@ -77,8 +75,8 @@ "simplecasts.middleware.HtmxCacheMiddleware", "simplecasts.middleware.HtmxMessagesMiddleware", "simplecasts.middleware.HtmxRedirectMiddleware", + "simplecasts.middleware.PlayerMiddleware", "simplecasts.middleware.SearchMiddleware", - "simplecasts.episodes.middleware.PlayerMiddleware", ] # Databases @@ -216,7 +214,7 @@ # authentication settings # https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends -AUTH_USER_MODEL = "users.User" +AUTH_USER_MODEL = "simplecasts.User" AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", diff --git a/config/urls.py b/config/urls.py index 776a28b45b..8caeff171b 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,25 +2,8 @@ from django.contrib import admin from django.urls import include, path -from simplecasts import views - urlpatterns = [ - path("", views.index, name="index"), - path("about/", views.about, name="about"), - path("privacy/", views.privacy, name="privacy"), - path("accept-cookies/", views.accept_cookies, name="accept_cookies"), - path( - "covers//.webp", - views.cover_image, - name="cover_image", - ), - path("robots.txt", views.robots, name="robots"), - path("manifest.json", views.manifest, name="manifest"), - path(".well-known/assetlinks.json", views.assetlinks, name="assetlinks"), - path(".well-known/security.txt", views.security, name="security"), - path("", include("simplecasts.episodes.urls")), - path("", include("simplecasts.podcasts.urls")), - path("", include("simplecasts.users.urls")), + path("", include("simplecasts.urls")), path("account/", include("allauth.urls")), path("ht/", include("health_check.urls")), path(settings.ADMIN_URL, admin.site.urls), diff --git a/conftest.py b/conftest.py index acc0bc923b..bf6a44486e 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,3 @@ pytest_plugins = [ "simplecasts.tests.fixtures", - "simplecasts.episodes.tests.fixtures", - "simplecasts.podcasts.tests.fixtures", - "simplecasts.users.tests.fixtures", ] diff --git a/justfile b/justfile index 31e3a6cabb..22cad93368 100644 --- a/justfile +++ b/justfile @@ -16,11 +16,11 @@ export USE_X_FORWARDED_HOST := "false" # Install all dependencies [group('development')] -install: pyinstall precommitinstall nltkdownload +install: pyinstall pcinstall nltkdownload # Update all dependencies [group('development')] -update: pyupdate pyinstall precommitupdate +update: pyupdate pyinstall pcupdate # Install all Python dependencies [group('development')] @@ -59,7 +59,7 @@ typecheck *args: # Run all checks [group('development')] -check: precommitall typecheck test +check: pcrunall typecheck test # Download NLTK data [group('development')] @@ -88,24 +88,24 @@ psql *args: # Run pre-commit manually [group('development')] -precommit *args: +pc *args: uv run --with pre-commit-uv pre-commit {{ args }} # Install pre-commit hooks [group('development')] -precommitinstall: +pcinstall: @just precommit install @just precommit install --hook-type commit-msg # Update pre-commit hooks [group('development')] -precommitupdate: +pcupdate: @just precommit autoupdate # Re-run pre-commit on all files [group('development')] -precommitall: - @just precommit run --all-files +pcrunall: + @just pc run --all-files # Run Ansible playbook [group('deployment')] diff --git a/pyproject.toml b/pyproject.toml index 9047504dbc..63fc15782f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,7 +195,7 @@ django_settings_module = "simplecasts.settings" include=["simplecasts"] exclude=[ "**/migrations/*.py", - "**/tests/*.py", + "**/tests/**", ] typeCheckingMode = "basic" diff --git a/simplecasts/podcasts/admin.py b/simplecasts/admin.py similarity index 77% rename from simplecasts/podcasts/admin.py rename to simplecasts/admin.py index 66afdf404e..61d71ab7e6 100644 --- a/simplecasts/podcasts/admin.py +++ b/simplecasts/admin.py @@ -1,19 +1,24 @@ from typing import TYPE_CHECKING, ClassVar from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.db.models import Count, Exists, OuterRef, QuerySet from django.http import HttpRequest +from django.template.defaultfilters import truncatechars from django.utils import timezone from django.utils.timesince import timesince, timeuntil -from simplecasts.podcasts.models import ( +from simplecasts.models import ( + AudioLog, Category, + Episode, Podcast, - PodcastQuerySet, Recommendation, Subscription, + User, ) -from simplecasts.search import search_queryset +from simplecasts.models.episodes import EpisodeQuerySet +from simplecasts.models.podcasts import PodcastQuerySet if TYPE_CHECKING: from django_stubs_ext import StrOrPromise # pragma: no cover @@ -27,6 +32,21 @@ class CategoryWithNumPodcasts(Category): CategoryWithNumPodcasts = Category +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """User model admin.""" + + fieldsets = ( + *tuple(BaseUserAdmin.fieldsets or ()), + ( + "User preferences", + { + "fields": ("send_email_notifications",), + }, + ), + ) + + @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): """Admin for podcast categories.""" @@ -228,7 +248,7 @@ class PodcastAdmin(admin.ModelAdmin): "exception", ) - search_fields = ("search_vector",) + search_fields = ("search_document",) fieldsets = ( ( @@ -294,11 +314,9 @@ def get_search_results( """Search episodes.""" return ( ( - search_queryset( - queryset, - search_term, - *self.search_fields, - ).order_by("-rank", "-pub_date"), + queryset.search(search_term, *self.search_fields).order_by( + "-rank", "-pub_date" + ), False, ) if search_term @@ -352,3 +370,76 @@ class SubscriptionAdmin(admin.ModelAdmin): def get_queryset(self, request: HttpRequest) -> QuerySet[Subscription]: """Returns queryset with related fields.""" return super().get_queryset(request).select_related("podcast", "subscriber") + + +@admin.register(Episode) +class EpisodeAdmin(admin.ModelAdmin): + """Django admin for Episode model.""" + + list_display = ("episode_title", "podcast_title", "pub_date") + list_select_related = ("podcast",) + raw_id_fields = ("podcast",) + search_fields = ("search_document",) + + @admin.display(description="Title") + def episode_title(self, obj: Episode) -> str: + """Render truncated episode title.""" + return truncatechars(obj.title, 30) + + @admin.display(description="Podcast") + def podcast_title(self, obj: Episode) -> str: + """Render truncated podcast title.""" + return truncatechars(obj.podcast.title, 30) + + def get_search_results( + self, + request: HttpRequest, + queryset: EpisodeQuerySet, + search_term: str, + ) -> tuple[QuerySet[Episode], bool]: + """Search episodes.""" + return ( + ( + queryset.search(search_term, *self.search_fields).order_by( + "-rank", "-pub_date" + ), + False, + ) + if search_term + else super().get_search_results(request, queryset, search_term) + ) + + def get_ordering(self, request: HttpRequest) -> list[str]: + """Returns optimized search ordering. + + If unfiltered, just search by id. + """ + return ( + [] + if request.GET.get("q") + else [ + "-id", + ] + ) + + +@admin.register(AudioLog) +class AudioLogAdmin(admin.ModelAdmin): + """Django admin for AudioLog model.""" + + list_display = ( + "episode", + "user", + ) + readonly_fields = ( + "episode", + "user", + "current_time", + "duration", + "listened", + ) + ordering = ("-listened",) + + def get_queryset(self, request: HttpRequest) -> QuerySet[AudioLog]: + """Optimize queryset for admin.""" + return super().get_queryset(request).select_related("episode", "user") diff --git a/simplecasts/podcasts/apps.py b/simplecasts/apps.py similarity index 64% rename from simplecasts/podcasts/apps.py rename to simplecasts/apps.py index 1ee64a94d5..ba4d74436a 100644 --- a/simplecasts/podcasts/apps.py +++ b/simplecasts/apps.py @@ -1,8 +1,8 @@ from django.apps import AppConfig -class PodcastsConfig(AppConfig): +class ProjectConfig(AppConfig): """App configuration.""" - name = "simplecasts.podcasts" + name = "simplecasts" default_auto_field = "django.db.models.BigAutoField" diff --git a/simplecasts/episodes/admin.py b/simplecasts/episodes/admin.py deleted file mode 100644 index db35165ca9..0000000000 --- a/simplecasts/episodes/admin.py +++ /dev/null @@ -1,82 +0,0 @@ -from django.contrib import admin -from django.db.models import QuerySet -from django.http import HttpRequest -from django.template.defaultfilters import truncatechars - -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.search import search_queryset - - -@admin.register(Episode) -class EpisodeAdmin(admin.ModelAdmin): - """Django admin for Episode model.""" - - list_display = ("episode_title", "podcast_title", "pub_date") - list_select_related = ("podcast",) - raw_id_fields = ("podcast",) - search_fields = ("search_vector",) - - @admin.display(description="Title") - def episode_title(self, obj: Episode) -> str: - """Render truncated episode title.""" - return truncatechars(obj.title, 30) - - @admin.display(description="Podcast") - def podcast_title(self, obj: Episode) -> str: - """Render truncated podcast title.""" - return truncatechars(obj.podcast.title, 30) - - def get_search_results( - self, - request: HttpRequest, - queryset: QuerySet[Episode], - search_term: str, - ) -> tuple[QuerySet[Episode], bool]: - """Search episodes.""" - return ( - ( - search_queryset( - queryset, - search_term, - *self.search_fields, - ).order_by("-rank", "-pub_date"), - False, - ) - if search_term - else super().get_search_results(request, queryset, search_term) - ) - - def get_ordering(self, request: HttpRequest) -> list[str]: - """Returns optimized search ordering. - - If unfiltered, just search by id. - """ - return ( - [] - if request.GET.get("q") - else [ - "-id", - ] - ) - - -@admin.register(AudioLog) -class AudioLogAdmin(admin.ModelAdmin): - """Django admin for AudioLog model.""" - - list_display = ( - "episode", - "user", - ) - readonly_fields = ( - "episode", - "user", - "current_time", - "duration", - "listened", - ) - ordering = ("-listened",) - - def get_queryset(self, request: HttpRequest) -> QuerySet[AudioLog]: - """Optimize queryset for admin.""" - return super().get_queryset(request).select_related("episode", "user") diff --git a/simplecasts/episodes/apps.py b/simplecasts/episodes/apps.py deleted file mode 100644 index 0003df71e2..0000000000 --- a/simplecasts/episodes/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class EpisodesConfig(AppConfig): - name = "simplecasts.episodes" - default_auto_field = "django.db.models.BigAutoField" diff --git a/simplecasts/episodes/middleware.py b/simplecasts/episodes/middleware.py deleted file mode 100644 index 5dc3a82ffa..0000000000 --- a/simplecasts/episodes/middleware.py +++ /dev/null @@ -1,40 +0,0 @@ -import dataclasses - -from django.http import HttpResponse - -from simplecasts.middleware import BaseMiddleware -from simplecasts.request import HttpRequest - - -class PlayerMiddleware(BaseMiddleware): - """Adds `PlayerDetails` instance to request as `request.player`.""" - - def __call__(self, request: HttpRequest) -> HttpResponse: - """Middleware implementation.""" - request.player = PlayerDetails(request=request) - return self.get_response(request) - - -@dataclasses.dataclass(frozen=True, kw_only=True) -class PlayerDetails: - """Tracks current player episode in session.""" - - request: HttpRequest - session_id: str = "audio-player" - - def get(self) -> int | None: - """Returns primary key of episode in player, if any in session.""" - return self.request.session.get(self.session_id) - - def has(self, episode_id: int) -> bool: - """Checks if episode matching ID is in player.""" - return self.get() == episode_id - - def set(self, episode_id: int) -> None: - """Adds episode PK to player in session.""" - self.request.session[self.session_id] = episode_id - - def pop(self) -> int | None: - """Returns primary key of episode in player, if any in session, and removes - the episode ID from the session.""" - return self.request.session.pop(self.session_id, None) diff --git a/simplecasts/episodes/migrations/0001_initial.py b/simplecasts/episodes/migrations/0001_initial.py deleted file mode 100644 index 58af64c3dc..0000000000 --- a/simplecasts/episodes/migrations/0001_initial.py +++ /dev/null @@ -1,239 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:03 - -import django.contrib.postgres.indexes -import django.contrib.postgres.search -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("podcasts", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Episode", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("guid", models.TextField()), - ("pub_date", models.DateTimeField()), - ("title", models.TextField(blank=True)), - ("description", models.TextField(blank=True)), - ("keywords", models.TextField(blank=True)), - ("website", models.URLField(blank=True, max_length=2083, null=True)), - ("episode_type", models.CharField(default="full", max_length=30)), - ("episode", models.IntegerField(blank=True, null=True)), - ("season", models.IntegerField(blank=True, null=True)), - ("cover_url", models.URLField(blank=True, max_length=2083, null=True)), - ("media_url", models.URLField(max_length=2083)), - ("media_type", models.CharField(max_length=60)), - ("length", models.BigIntegerField(blank=True, null=True)), - ("duration", models.CharField(blank=True, max_length=30)), - ("explicit", models.BooleanField(default=False)), - ( - "search_vector", - django.contrib.postgres.search.SearchVectorField( - editable=False, null=True - ), - ), - ( - "podcast", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="episodes", - to="podcasts.podcast", - ), - ), - ], - ), - migrations.CreateModel( - name="Bookmark", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "episode", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="bookmarks", - to="episodes.episode", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="bookmarks", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="AudioLog", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ("listened", models.DateTimeField()), - ("current_time", models.IntegerField(default=0)), - ( - "episode", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="audio_logs", - to="episodes.episode", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="audio_logs", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["podcast", "pub_date"], name="episodes_ep_podcast_a7abe0_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["podcast", "-pub_date"], name="episodes_ep_podcast_b9a49e_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["podcast"], name="episodes_ep_podcast_3361d9_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index(fields=["guid"], name="episodes_ep_guid_b00554_idx"), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["pub_date"], name="episodes_ep_pub_dat_60d1c1_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["-pub_date"], name="episodes_ep_pub_dat_205e36_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["-pub_date", "-id"], name="episodes_ep_pub_dat_9b17cd_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=django.contrib.postgres.indexes.GinIndex( - fields=["search_vector"], name="episodes_ep_search__466ef4_gin" - ), - ), - migrations.AddConstraint( - model_name="episode", - constraint=models.UniqueConstraint( - fields=("podcast", "guid"), name="unique_episodes_episode_podcast_guid" - ), - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["-created"], name="episodes_bo_created_d69e08_idx" - ), - ), - migrations.AddConstraint( - model_name="bookmark", - constraint=models.UniqueConstraint( - fields=("user", "episode"), name="unique_episodes_bookmark_user_episode" - ), - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["-listened"], name="episodes_au_listene_7f0fdd_idx" - ), - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["listened"], name="episodes_au_listene_e5a9d5_idx" - ), - ), - migrations.AddConstraint( - model_name="audiolog", - constraint=models.UniqueConstraint( - fields=("user", "episode"), name="unique_episodes_audiolog_user_episode" - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0002_add_episode_search_trigger.py b/simplecasts/episodes/migrations/0002_add_episode_search_trigger.py deleted file mode 100644 index 90726b4c38..0000000000 --- a/simplecasts/episodes/migrations/0002_add_episode_search_trigger.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0001_initial"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title, keywords); -UPDATE episodes_episode SET search_vector = NULL;""", - reverse_sql="DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode;", - ), - ] diff --git a/simplecasts/episodes/migrations/0003_alter_episode_cover_url_alter_episode_website.py b/simplecasts/episodes/migrations/0003_alter_episode_cover_url_alter_episode_website.py deleted file mode 100644 index 2c22fa9185..0000000000 --- a/simplecasts/episodes/migrations/0003_alter_episode_cover_url_alter_episode_website.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-14 11:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0002_add_episode_search_trigger"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="cover_url", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - migrations.AlterField( - model_name="episode", - name="website", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - ] diff --git a/simplecasts/episodes/migrations/0004_remove_audiolog_created_remove_audiolog_modified_and_more.py b/simplecasts/episodes/migrations/0004_remove_audiolog_created_remove_audiolog_modified_and_more.py deleted file mode 100644 index 89b6d129c9..0000000000 --- a/simplecasts/episodes/migrations/0004_remove_audiolog_created_remove_audiolog_modified_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-15 08:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0003_alter_episode_cover_url_alter_episode_website"), - ] - - operations = [ - migrations.RemoveField( - model_name="audiolog", - name="created", - ), - migrations.RemoveField( - model_name="audiolog", - name="modified", - ), - migrations.RemoveField( - model_name="bookmark", - name="modified", - ), - migrations.AlterField( - model_name="bookmark", - name="created", - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/simplecasts/episodes/migrations/0005_alter_episode_episode_type.py b/simplecasts/episodes/migrations/0005_alter_episode_episode_type.py deleted file mode 100644 index 1f8bd52d67..0000000000 --- a/simplecasts/episodes/migrations/0005_alter_episode_episode_type.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-27 12:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0004_remove_audiolog_created_remove_audiolog_modified_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="episode_type", - field=models.CharField( - choices=[ - ("full", "Full episode"), - ("trailer", "Trailer"), - ("bonus", "Bonus"), - ], - default="full", - max_length=12, - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0006_episode_episodes_ep_podcast_965d74_idx.py b/simplecasts/episodes/migrations/0006_episode_episodes_ep_podcast_965d74_idx.py deleted file mode 100644 index 604fdc7744..0000000000 --- a/simplecasts/episodes/migrations/0006_episode_episodes_ep_podcast_965d74_idx.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-28 13:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0005_alter_episode_episode_type"), - ("podcasts", "0013_podcast_podcast_type"), - ] - - operations = [ - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["podcast", "season"], name="episodes_ep_podcast_965d74_idx" - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0007_rename_length_episode_file_size.py b/simplecasts/episodes/migrations/0007_rename_length_episode_file_size.py deleted file mode 100644 index 9ffd422bcc..0000000000 --- a/simplecasts/episodes/migrations/0007_rename_length_episode_file_size.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-25 07:46 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0006_episode_episodes_ep_podcast_965d74_idx"), - ] - - operations = [ - migrations.RenameField( - model_name="episode", - old_name="length", - new_name="file_size", - ), - ] diff --git a/simplecasts/episodes/migrations/0008_alter_episode_file_size.py b/simplecasts/episodes/migrations/0008_alter_episode_file_size.py deleted file mode 100644 index b6577e6510..0000000000 --- a/simplecasts/episodes/migrations/0008_alter_episode_file_size.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-25 07:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0007_rename_length_episode_file_size"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="file_size", - field=models.BigIntegerField( - blank=True, null=True, verbose_name="File size in bytes" - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more.py b/simplecasts/episodes/migrations/0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more.py deleted file mode 100644 index 870bfe391f..0000000000 --- a/simplecasts/episodes/migrations/0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-26 18:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0008_alter_episode_file_size"), - ("podcasts", "0013_podcast_podcast_type"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_pub_dat_9b17cd_idx", - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["episode", "pub_date"], name="episodes_ep_episode_c8cf94_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["-episode", "-pub_date"], name="episodes_ep_episode_ddf08c_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["season", "episode", "pub_date"], - name="episodes_ep_season_ec7620_idx", - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["-season", "-episode", "-pub_date"], - name="episodes_ep_season_2b409f_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more.py b/simplecasts/episodes/migrations/0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more.py deleted file mode 100644 index 2faa587e9d..0000000000 --- a/simplecasts/episodes/migrations/0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-26 19:15 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_episode_c8cf94_idx", - ), - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_episode_ddf08c_idx", - ), - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_season_ec7620_idx", - ), - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_season_2b409f_idx", - ), - ] diff --git a/simplecasts/episodes/migrations/0011_remove_episode_episodes_ep_podcast_3361d9_idx.py b/simplecasts/episodes/migrations/0011_remove_episode_episodes_ep_podcast_3361d9_idx.py deleted file mode 100644 index 0093bd8adb..0000000000 --- a/simplecasts/episodes/migrations/0011_remove_episode_episodes_ep_podcast_3361d9_idx.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-23 12:55 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_podcast_3361d9_idx", - ), - ] diff --git a/simplecasts/episodes/migrations/0012_alter_episode_cover_url_alter_episode_media_url_and_more.py b/simplecasts/episodes/migrations/0012_alter_episode_cover_url_alter_episode_media_url_and_more.py deleted file mode 100644 index 0b942c9d7b..0000000000 --- a/simplecasts/episodes/migrations/0012_alter_episode_cover_url_alter_episode_media_url_and_more.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.2 on 2025-04-03 17:09 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0011_remove_episode_episodes_ep_podcast_3361d9_idx"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="cover_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="episode", - name="media_url", - field=models.URLField( - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="episode", - name="website", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0013_alter_episode_cover_url_alter_episode_media_url_and_more.py b/simplecasts/episodes/migrations/0013_alter_episode_cover_url_alter_episode_media_url_and_more.py deleted file mode 100644 index 0429292b6d..0000000000 --- a/simplecasts/episodes/migrations/0013_alter_episode_cover_url_alter_episode_media_url_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.2 on 2025-04-03 17:33 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0012_alter_episode_cover_url_alter_episode_media_url_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="cover_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="episode", - name="media_url", - field=models.URLField( - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="episode", - name="website", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more.py b/simplecasts/episodes/migrations/0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more.py deleted file mode 100644 index 46582b7cf2..0000000000 --- a/simplecasts/episodes/migrations/0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-12 22:12 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - dependencies = [ - ("episodes", "0013_alter_episode_cover_url_alter_episode_media_url_and_more"), - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_a7abe0_idx", - ), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_b9a49e_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "pub_date", "id"], - name="episodes_ep_podcast_12cd3c_idx", - ), - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "-pub_date", "-id"], - name="episodes_ep_podcast_c43bb8_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more.py b/simplecasts/episodes/migrations/0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more.py deleted file mode 100644 index f7f84adf96..0000000000 --- a/simplecasts/episodes/migrations/0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-12 22:16 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - dependencies = [ - ("episodes", "0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more"), - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_pub_dat_60d1c1_idx", - ), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_pub_dat_205e36_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["pub_date", "id"], name="episodes_ep_pub_dat_866539_idx" - ), - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["-pub_date", "-id"], name="episodes_ep_pub_dat_9b17cd_idx" - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more.py b/simplecasts/episodes/migrations/0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more.py deleted file mode 100644 index 67908d616a..0000000000 --- a/simplecasts/episodes/migrations/0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-26 10:18 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more"), - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_12cd3c_idx", - ), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_c43bb8_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["pub_date", "podcast", "id"], - name="episodes_ep_pub_dat_34887e_idx", - ), - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["-pub_date", "podcast", "-id"], - name="episodes_ep_pub_dat_4abe4c_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0017_episode_episodes_ep_podcast_c43bb8_idx.py b/simplecasts/episodes/migrations/0017_episode_episodes_ep_podcast_c43bb8_idx.py deleted file mode 100644 index 46c99266e9..0000000000 --- a/simplecasts/episodes/migrations/0017_episode_episodes_ep_podcast_c43bb8_idx.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-04 11:17 - -from django.contrib.postgres.operations import AddIndexConcurrently -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more"), - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "-pub_date", "-id"], - name="episodes_ep_podcast_c43bb8_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0018_audiolog_duration.py b/simplecasts/episodes/migrations/0018_audiolog_duration.py deleted file mode 100644 index 157b33e24a..0000000000 --- a/simplecasts/episodes/migrations/0018_audiolog_duration.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-07 18:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0017_episode_episodes_ep_podcast_c43bb8_idx"), - ] - - operations = [ - migrations.AddField( - model_name="audiolog", - name="duration", - field=models.IntegerField(default=0), - ), - ] diff --git a/simplecasts/episodes/migrations/0019_set_default_audio_log_duration.py b/simplecasts/episodes/migrations/0019_set_default_audio_log_duration.py deleted file mode 100644 index 51bc1d351c..0000000000 --- a/simplecasts/episodes/migrations/0019_set_default_audio_log_duration.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-07 18:47 - -from django.db import migrations - - -def duration_in_seconds(duration_str) -> int: - """Returns total number of seconds given string in [h:][m:]s format.""" - if not duration_str: - return 0 - - try: - return sum( - (int(part) * multiplier) - for (part, multiplier) in zip( - reversed(duration_str.split(":")[:3]), - (1, 60, 3600), - strict=False, - ) - ) - except ValueError: - return 0 - - -def set_default_audio_log_duration(apps, schema_editor): - AudioLog = apps.get_model("episodes", "AudioLog") - for_update = [] - - for audio_log in AudioLog.objects.filter( - duration=0, - episode__duration__isnull=False, - ).select_related("episode"): - audio_log.duration = duration_in_seconds(audio_log.episode.duration) - for_update.append(audio_log) - - AudioLog.objects.bulk_update(for_update, ["duration"]) - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0018_audiolog_duration"), - ] - - operations = [ - migrations.RunPython( - set_default_audio_log_duration, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/simplecasts/episodes/migrations/0020_remove_episode_keywords.py b/simplecasts/episodes/migrations/0020_remove_episode_keywords.py deleted file mode 100644 index d3a0a659ea..0000000000 --- a/simplecasts/episodes/migrations/0020_remove_episode_keywords.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 15:40 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0019_set_default_audio_log_duration"), - ] - - operations = [ - migrations.RunSQL( - "SET statement_timeout = 0;", - reverse_sql="SET statement_timeout = 0;", - ), - migrations.RunSQL( - sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title); -""", - reverse_sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title, keywords); - """, - ), - migrations.RemoveField( - model_name="episode", - name="keywords", - ), - ] diff --git a/simplecasts/episodes/migrations/0021_episode_keywords.py b/simplecasts/episodes/migrations/0021_episode_keywords.py deleted file mode 100644 index 63c9076f5c..0000000000 --- a/simplecasts/episodes/migrations/0021_episode_keywords.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 18:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0020_remove_episode_keywords"), - ] - - operations = [ - migrations.AddField( - model_name="episode", - name="keywords", - field=models.TextField(blank=True), - ), - ] diff --git a/simplecasts/episodes/migrations/0022_update_search_trigger.py b/simplecasts/episodes/migrations/0022_update_search_trigger.py deleted file mode 100644 index 77d6879645..0000000000 --- a/simplecasts/episodes/migrations/0022_update_search_trigger.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 18:05 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0021_episode_keywords"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title, keywords); -""", - reverse_sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title); - """, - ), - ] diff --git a/simplecasts/episodes/migrations/0023_update_episode_search_trigger_with_simple.py b/simplecasts/episodes/migrations/0023_update_episode_search_trigger_with_simple.py deleted file mode 100644 index 71b423387d..0000000000 --- a/simplecasts/episodes/migrations/0023_update_episode_search_trigger_with_simple.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-22 22:13 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0022_update_search_trigger"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.simple', title, keywords); -""", - reverse_sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title, keywords); - """, - ) - ] diff --git a/simplecasts/episodes/migrations/0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx.py b/simplecasts/episodes/migrations/0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx.py deleted file mode 100644 index 1e3105892b..0000000000 --- a/simplecasts/episodes/migrations/0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 13:27 - -from django.contrib.postgres.operations import RemoveIndexConcurrently -from django.db import migrations - - -def ensure_episodes_loaded(apps, schema_editor): - """ - Forces Django to fully register the 'episodes' app in the historical registry. - This prevents LookupError in later migrations that reference Episode. - """ - apps.get_app_config("episodes") - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0023_update_episode_search_trigger_with_simple"), - ] - - operations = [ - migrations.RunPython( - ensure_episodes_loaded, - reverse_code=migrations.RunPython.noop, - ), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_pub_dat_4abe4c_idx", - ), - ] diff --git a/simplecasts/episodes/migrations/0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more.py b/simplecasts/episodes/migrations/0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more.py deleted file mode 100644 index 72aece2535..0000000000 --- a/simplecasts/episodes/migrations/0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 13:41 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx"), - ("podcasts", "0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_pub_dat_34887e_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "pub_date", "id"], - name="episodes_ep_podcast_12cd3c_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more.py b/simplecasts/episodes/migrations/0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more.py deleted file mode 100644 index ae5544ea35..0000000000 --- a/simplecasts/episodes/migrations/0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 14:20 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more"), - ("podcasts", "0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_965d74_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "season", "-pub_date", "-id"], - name="episodes_ep_podcast_d9b8d2_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more.py b/simplecasts/episodes/migrations/0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more.py deleted file mode 100644 index fd7996a978..0000000000 --- a/simplecasts/episodes/migrations/0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 20:20 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="audiolog", - name="episodes_au_listene_7f0fdd_idx", - ), - migrations.RemoveIndex( - model_name="audiolog", - name="episodes_au_listene_e5a9d5_idx", - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["user", "episode"], name="episodes_au_user_id_fef2ea_idx" - ), - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["user", "episode", "listened"], - name="episodes_au_user_id_fb8578_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more.py b/simplecasts/episodes/migrations/0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more.py deleted file mode 100644 index 3dad0d1eb0..0000000000 --- a/simplecasts/episodes/migrations/0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 20:26 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="bookmark", - name="episodes_bo_created_d69e08_idx", - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["user", "episode"], name="episodes_bo_user_id_09cdb6_idx" - ), - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["user", "episode", "created"], - name="episodes_bo_user_id_21f9c3_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more.py b/simplecasts/episodes/migrations/0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more.py deleted file mode 100644 index 34c76d4f3a..0000000000 --- a/simplecasts/episodes/migrations/0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-24 10:02 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="audiolog", - name="episodes_au_user_id_fb8578_idx", - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["user", "listened"], - include=("episode_id",), - name="episodes_audiolog_desc_idx", - ), - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["user", "-listened"], - include=("episode_id",), - name="episodes_audiolog_asc_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more.py b/simplecasts/episodes/migrations/0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more.py deleted file mode 100644 index 63bdc14cc3..0000000000 --- a/simplecasts/episodes/migrations/0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-24 10:05 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="bookmark", - name="episodes_bo_user_id_21f9c3_idx", - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["user", "created"], - include=("episode_id",), - name="episodes_bookmark_desc_idx", - ), - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["user", "-created"], - include=("episode_id",), - name="episodes_bookmark_asc_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0031_alter_audiolog_current_time_alter_audiolog_duration.py b/simplecasts/episodes/migrations/0031_alter_audiolog_current_time_alter_audiolog_duration.py deleted file mode 100644 index 01eaa20b7f..0000000000 --- a/simplecasts/episodes/migrations/0031_alter_audiolog_current_time_alter_audiolog_duration.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-25 10:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="audiolog", - name="current_time", - field=models.PositiveIntegerField(default=0), - ), - migrations.AlterField( - model_name="audiolog", - name="duration", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/simplecasts/episodes/migrations/max_migration.txt b/simplecasts/episodes/migrations/max_migration.txt deleted file mode 100644 index cbd7284027..0000000000 --- a/simplecasts/episodes/migrations/max_migration.txt +++ /dev/null @@ -1 +0,0 @@ -0031_alter_audiolog_current_time_alter_audiolog_duration diff --git a/simplecasts/episodes/templatetags/episodes.py b/simplecasts/episodes/templatetags/episodes.py deleted file mode 100644 index 61029aaa03..0000000000 --- a/simplecasts/episodes/templatetags/episodes.py +++ /dev/null @@ -1,79 +0,0 @@ -from datetime import timedelta - -from django import template -from django.utils import timezone -from django.utils.timesince import timesince - -from simplecasts import covers -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.episodes.views import PlayerAction -from simplecasts.request import HttpRequest, RequestContext, is_authenticated_request - -register = template.Library() - - -@register.inclusion_tag("audio_player.html", takes_context=True) -def audio_player( - context: RequestContext, - audio_log: AudioLog | None = None, - action: PlayerAction = "load", - *, - hx_oob: bool = False, -) -> dict: - """Returns audio player.""" - dct = context.flatten() | { - "action": action, - "hx_oob": hx_oob, - } - - match action: - case "close": - return dct - - case "play": - return dct | {"audio_log": audio_log} - - case _: - return dct | {"audio_log": _get_audio_log(context.request)} - - -@register.simple_tag(takes_context=True) -def get_media_metadata(context: RequestContext, episode: Episode) -> dict: - """Returns media session metadata for integration with client device. - - For more details: - - https://developers.google.com/web/updates/2017/02/media-session - - Use with `json_script` template tag to render the JSON in a script tag. - """ - - return { - "title": episode.cleaned_title, - "album": episode.podcast.cleaned_title, - "artist": episode.podcast.cleaned_title, - "artwork": covers.get_metadata_info(context.request, episode.get_cover_url()), - } - - -@register.filter -def format_duration(total_seconds: int, min_value: int = 60) -> str: - """Formats duration (in seconds) as human readable value e.g. 1 hour, 30 minutes.""" - return ( - timesince(timezone.now() - timedelta(seconds=total_seconds)) - if total_seconds >= min_value - else "" - ) - - -def _get_audio_log(request: HttpRequest) -> AudioLog | None: - if is_authenticated_request(request) and (episode_id := request.player.get()): - return ( - request.user.audio_logs.select_related( - "episode", - "episode__podcast", - ) - .filter(episode_id=episode_id) - .first() - ) - return None diff --git a/simplecasts/episodes/tests/factories.py b/simplecasts/episodes/tests/factories.py deleted file mode 100644 index 0e9118b648..0000000000 --- a/simplecasts/episodes/tests/factories.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid - -import factory -from django.utils import timezone - -from simplecasts.episodes.models import AudioLog, Bookmark, Episode -from simplecasts.podcasts.tests.factories import PodcastFactory -from simplecasts.users.tests.factories import UserFactory - - -class EpisodeFactory(factory.django.DjangoModelFactory): - guid = factory.LazyFunction(lambda: uuid.uuid4().hex) - podcast = factory.SubFactory(PodcastFactory) - title = factory.Faker("text") - description = factory.Faker("text") - pub_date = factory.LazyFunction(timezone.now) - media_url = factory.Faker("url") - media_type = "audio/mpg" - duration = "100" - - class Meta: - model = Episode - - -class BookmarkFactory(factory.django.DjangoModelFactory): - user = factory.SubFactory(UserFactory) - episode = factory.SubFactory(EpisodeFactory) - - class Meta: - model = Bookmark - - -class AudioLogFactory(factory.django.DjangoModelFactory): - user = factory.SubFactory(UserFactory) - episode = factory.SubFactory(EpisodeFactory) - listened = factory.LazyFunction(timezone.now) - current_time = 1000 - - class Meta: - model = AudioLog diff --git a/simplecasts/episodes/tests/fixtures.py b/simplecasts/episodes/tests/fixtures.py deleted file mode 100644 index f53adf6fce..0000000000 --- a/simplecasts/episodes/tests/fixtures.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest -from django.test import Client - -from simplecasts.episodes.middleware import PlayerDetails -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.episodes.tests.factories import AudioLogFactory, EpisodeFactory -from simplecasts.users.models import User - - -@pytest.fixture -def episode() -> Episode: - return EpisodeFactory() - - -@pytest.fixture -def audio_log(episode: Episode) -> AudioLog: - return AudioLogFactory(episode=episode) - - -@pytest.fixture -def player_episode(auth_user: User, client: Client, episode: Episode) -> Episode: - """Fixture that creates an AudioLog for the given user and episode""" - AudioLogFactory(user=auth_user, episode=episode) - - session = client.session - session[PlayerDetails.session_id] = episode.pk - session.save() - - return episode diff --git a/simplecasts/episodes/tests/test_admin.py b/simplecasts/episodes/tests/test_admin.py deleted file mode 100644 index 440ae5bdcc..0000000000 --- a/simplecasts/episodes/tests/test_admin.py +++ /dev/null @@ -1,61 +0,0 @@ -import pytest -from django.contrib.admin.sites import AdminSite - -from simplecasts.episodes.admin import AudioLogAdmin, EpisodeAdmin -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.episodes.tests.factories import AudioLogFactory, EpisodeFactory -from simplecasts.podcasts.tests.factories import PodcastFactory - - -class TestAudioLogAdmin: - @pytest.mark.django_db - def test_get_queryset(self, rf): - AudioLogFactory() - admin = AudioLogAdmin(AudioLog, AdminSite()) - request = rf.get("/") - qs = admin.get_queryset(request) - assert qs.count() == 1 - - -class TestEpisodeAdmin: - @pytest.fixture(scope="class") - def admin(self): - return EpisodeAdmin(Episode, AdminSite()) - - @pytest.mark.django_db - def test_episode_title(self, admin): - episode = EpisodeFactory(title="testing") - assert admin.episode_title(episode) == "testing" - - @pytest.mark.django_db - def test_podcast_title(self, admin): - episode = EpisodeFactory(podcast=PodcastFactory(title="testing")) - assert admin.podcast_title(episode) == "testing" - - @pytest.mark.django_db - def test_get_ordering_no_search_term(self, admin, rf): - ordering = admin.get_ordering(rf.get("/")) - assert ordering == ["-id"] - - @pytest.mark.django_db - def test_get_ordering_search_term(self, admin, rf): - ordering = admin.get_ordering(rf.get("/", {"q": "test"})) - assert ordering == [] - - @pytest.mark.django_db - def test_get_search_results_no_search_term(self, rf, admin): - EpisodeFactory.create_batch(3) - qs, _ = admin.get_search_results(rf.get("/"), Episode.objects.all(), "") - assert qs.count() == 3 - - @pytest.mark.django_db - def test_get_search_results(self, rf, admin): - EpisodeFactory.create_batch(3) - - episode = EpisodeFactory(title="testing python") - - qs, _ = admin.get_search_results( - rf.get("/"), Episode.objects.all(), "testing python" - ) - assert qs.count() == 1 - assert qs.first() == episode diff --git a/simplecasts/episodes/tests/test_commands.py b/simplecasts/episodes/tests/test_commands.py deleted file mode 100644 index 0778391458..0000000000 --- a/simplecasts/episodes/tests/test_commands.py +++ /dev/null @@ -1,80 +0,0 @@ -from datetime import timedelta - -import pytest -from django.core.management import call_command -from django.utils import timezone - -from simplecasts.episodes.tests.factories import ( - AudioLogFactory, - BookmarkFactory, - EpisodeFactory, -) -from simplecasts.podcasts.tests.factories import ( - SubscriptionFactory, -) -from simplecasts.users.tests.factories import EmailAddressFactory - - -class TestSendEpisodeNotifications: - @pytest.fixture - def recipient(self): - return EmailAddressFactory( - verified=True, - primary=True, - ) - - @pytest.mark.django_db(transaction=True) - def test_has_episodes(self, mailoutbox, recipient): - subscription = SubscriptionFactory( - subscriber=recipient.user, - ) - EpisodeFactory.create_batch( - 3, - podcast=subscription.podcast, - pub_date=timezone.now() - timedelta(days=1), - ) - call_command("send_episode_notifications") - assert len(mailoutbox) == 1 - assert mailoutbox[0].to == [recipient.email] - - @pytest.mark.django_db(transaction=True) - def test_is_bookmarked(self, mailoutbox, recipient): - subscription = SubscriptionFactory( - subscriber=recipient.user, - ) - episode = EpisodeFactory( - podcast=subscription.podcast, - pub_date=timezone.now() - timedelta(days=1), - ) - BookmarkFactory(episode=episode, user=recipient.user) - call_command("send_episode_notifications") - assert len(mailoutbox) == 0 - - @pytest.mark.django_db(transaction=True) - def test_no_new_episodes(self, mailoutbox, recipient): - subscription = SubscriptionFactory( - subscriber=recipient.user, - ) - EpisodeFactory.create_batch( - 3, - podcast=subscription.podcast, - pub_date=timezone.now() - timedelta(days=10), - ) - call_command("send_episode_notifications") - assert len(mailoutbox) == 0 - - @pytest.mark.django_db(transaction=True) - def test_listened(self, mailoutbox, recipient): - subscription = SubscriptionFactory( - subscriber=recipient.user, - ) - episode = EpisodeFactory( - podcast=subscription.podcast, - pub_date=timezone.now() - timedelta(days=1), - ) - AudioLogFactory( - episode=episode, - user=recipient.user, - ) - call_command("send_episode_notifications") - assert len(mailoutbox) == 0 diff --git a/simplecasts/episodes/tests/test_middleware.py b/simplecasts/episodes/tests/test_middleware.py deleted file mode 100644 index c4d7ddd1c3..0000000000 --- a/simplecasts/episodes/tests/test_middleware.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from simplecasts.episodes.middleware import PlayerDetails, PlayerMiddleware - - -class TestPlayerMiddleware: - def test_middleware(self, rf, get_response): - req = rf.get("/") - PlayerMiddleware(get_response)(req) - assert req.player - - -class TestPlayerDetails: - episode_id = 12345 - - @pytest.fixture - def req(self, rf): - req = rf.get("/") - req.session = {} - return req - - @pytest.fixture - def player(self, req): - return PlayerDetails(request=req) - - def test_get_if_none(self, player): - assert player.get() is None - - def test_get_if_not_none(self, player): - player.set(self.episode_id) - assert player.get() == self.episode_id - - def test_pop_if_none(self, player): - assert player.pop() is None - - def test_pop_if_not_none(self, player): - player.set(self.episode_id) - - assert player.pop() == self.episode_id - assert player.get() is None - - def test_has_false(self, player): - assert not player.has(self.episode_id) - - def test_has_true(self, player): - player.set(self.episode_id) - assert player.has(self.episode_id) diff --git a/simplecasts/episodes/tests/test_templatetags.py b/simplecasts/episodes/tests/test_templatetags.py deleted file mode 100644 index dfb13296bf..0000000000 --- a/simplecasts/episodes/tests/test_templatetags.py +++ /dev/null @@ -1,97 +0,0 @@ -import pytest - -from simplecasts.episodes.middleware import PlayerDetails -from simplecasts.episodes.templatetags.episodes import ( - audio_player, - format_duration, - get_media_metadata, -) -from simplecasts.request import RequestContext - - -class TestFormatDuration: - @pytest.mark.parametrize( - ("duration", "expected"), - [ - pytest.param(0, "", id="zero"), - pytest.param(30, "", id="30 seconds"), - pytest.param(60, "1\xa0minute", id="1 minute"), - pytest.param(61, "1\xa0minute", id="just over 1 minute"), - pytest.param(90, "1\xa0minute", id="1 minute 30 seconds"), - pytest.param(540, "9\xa0minutes", id="9 minutes"), - pytest.param(2400, "40\xa0minutes", id="40 minutes"), - pytest.param(3600, "1\xa0hour", id="1 hour"), - pytest.param(9000, "2\xa0hours, 30\xa0minutes", id="2 hours 30 minutes"), - ], - ) - def test_format_duration(self, duration, expected): - assert format_duration(duration) == expected - - -class TestGetMediaMetadata: - @pytest.mark.django_db - def test_get_media_metadata(self, rf, episode): - req = rf.get("/") - context = RequestContext(request=req) - assert get_media_metadata(context, episode) - - -class TestAudioPlayer: - @pytest.mark.django_db - def test_close(self, rf, audio_log): - req = rf.get("/") - req.user = audio_log.user - req.player = PlayerDetails(request=req) - req.session = {req.player.session_id: audio_log.episode_id} - - context = RequestContext(request=req) - - dct = audio_player(context, audio_log, action="close") - assert "audio_log" not in dct - - @pytest.mark.django_db - def test_play(self, rf, audio_log): - req = rf.get("/") - req.user = audio_log.user - - context = RequestContext(request=req) - - dct = audio_player(context, audio_log, action="play") - assert dct["audio_log"] == audio_log - - @pytest.mark.django_db - def test_load(self, rf, audio_log): - req = rf.get("/") - req.user = audio_log.user - req.player = PlayerDetails(request=req) - req.session = {req.player.session_id: audio_log.episode_id} - - context = RequestContext(request=req) - - dct = audio_player(context, None, action="load") - assert dct["audio_log"] == audio_log - - @pytest.mark.django_db - def test_load_empty(self, rf, audio_log): - req = rf.get("/") - req.user = audio_log.user - req.player = PlayerDetails(request=req) - req.session = {} - - context = RequestContext(request=req) - - dct = audio_player(context, None, action="load") - assert dct["audio_log"] is None - - @pytest.mark.django_db - def test_load_user_not_authenticated(self, rf, audio_log, anonymous_user): - req = rf.get("/") - req.user = anonymous_user - req.player = PlayerDetails(request=req) - req.session = {} - req.session = {req.player.session_id: audio_log.episode_id} - - context = RequestContext(request=req) - - dct = audio_player(context, None, action="load") - assert dct["audio_log"] is None diff --git a/simplecasts/episodes/tests/test_views.py b/simplecasts/episodes/tests/test_views.py deleted file mode 100644 index 15bd045d64..0000000000 --- a/simplecasts/episodes/tests/test_views.py +++ /dev/null @@ -1,578 +0,0 @@ -import json -from datetime import timedelta - -import pytest -from django.urls import reverse, reverse_lazy -from django.utils import timezone -from pytest_django.asserts import assertContains, assertNotContains, assertTemplateUsed - -from simplecasts.episodes.middleware import PlayerDetails -from simplecasts.episodes.models import AudioLog, Bookmark -from simplecasts.episodes.tests.factories import ( - AudioLogFactory, - BookmarkFactory, - EpisodeFactory, -) -from simplecasts.podcasts.tests.factories import PodcastFactory, SubscriptionFactory -from simplecasts.tests.asserts import ( - assert200, - assert204, - assert400, - assert401, - assert404, - assert409, -) - -_index_url = reverse_lazy("episodes:index") - - -class TestIndex: - @pytest.mark.django_db - def test_no_episodes(self, client, auth_user): - response = client.get(_index_url) - assert200(response) - assertTemplateUsed(response, "episodes/index.html") - assert len(response.context["episodes"]) == 0 - - @pytest.mark.django_db - def test_has_no_subscriptions(self, client, auth_user): - EpisodeFactory.create_batch(3) - response = client.get(_index_url) - - assert200(response) - assertTemplateUsed(response, "episodes/index.html") - assert len(response.context["episodes"]) == 0 - - @pytest.mark.django_db - def test_has_subscriptions(self, client, auth_user): - episode = EpisodeFactory() - SubscriptionFactory(subscriber=auth_user, podcast=episode.podcast) - - response = client.get(_index_url) - - assert200(response) - assertTemplateUsed(response, "episodes/index.html") - assert len(response.context["episodes"]) == 1 - - -class TestSearchEpisodes: - url = reverse_lazy("episodes:search_episodes") - - @pytest.mark.django_db - def test_search(self, auth_user, client, faker): - EpisodeFactory.create_batch(3, title="zzzz") - episode = EpisodeFactory(title=faker.unique.name()) - response = client.get(self.url, {"search": episode.title}) - assert200(response) - assertTemplateUsed(response, "episodes/search.html") - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == episode - - @pytest.mark.django_db - def test_search_no_results(self, auth_user, client): - response = client.get(self.url, {"search": "zzzz"}) - assert200(response) - assertTemplateUsed(response, "episodes/search.html") - assert len(response.context["page"].object_list) == 0 - - @pytest.mark.django_db - def test_search_value_empty(self, auth_user, client): - response = client.get(self.url, {"search": ""}) - assert response.url == _index_url - - -class TestEpisodeDetail: - @pytest.fixture - def episode(self, faker): - return EpisodeFactory( - podcast=PodcastFactory( - owner=faker.name(), - website=faker.url(), - funding_url=faker.url(), - funding_text=faker.text(), - explicit=True, - ), - episode_type="full", - file_size=9000, - duration="3:30:30", - ) - - @pytest.mark.django_db - def test_ok(self, client, auth_user, episode): - response = client.get(episode.get_absolute_url()) - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - - @pytest.mark.django_db - def test_listened(self, client, auth_user, episode): - AudioLogFactory( - episode=episode, - user=auth_user, - current_time=900, - listened=timezone.now(), - ) - - response = client.get(episode.get_absolute_url()) - - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - - assertContains(response, "Remove episode from your History") - assertContains(response, "Listened") - - @pytest.mark.django_db - def test_no_prev_next_episode(self, client, auth_user, episode): - response = client.get(episode.get_absolute_url()) - - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - assertNotContains(response, "No More Episodes") - - @pytest.mark.django_db - def test_no_next_episode(self, client, auth_user, episode): - EpisodeFactory( - podcast=episode.podcast, - pub_date=episode.pub_date - timedelta(days=30), - ) - response = client.get(episode.get_absolute_url()) - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - assertContains(response, "Last Episode") - - @pytest.mark.django_db - def test_no_previous_episode(self, client, auth_user, episode): - EpisodeFactory( - podcast=episode.podcast, - pub_date=episode.pub_date + timedelta(days=30), - ) - response = client.get(episode.get_absolute_url()) - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - assertContains(response, "First Episode") - - -class TestStartPlayer: - @pytest.mark.django_db - def test_play_from_start(self, client, auth_user, episode): - response = client.post( - self.url(episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - assert200(response) - assertContains(response, 'id="audio-player-button"') - - assert AudioLog.objects.filter(user=auth_user, episode=episode).exists() - assert client.session[PlayerDetails.session_id] == episode.pk - - @pytest.mark.django_db - def test_another_episode_in_player(self, client, auth_user, player_episode): - episode = EpisodeFactory() - response = client.post( - self.url(episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - - assert200(response) - assertContains(response, 'id="audio-player-button"') - - assert AudioLog.objects.filter(user=auth_user, episode=episode).exists() - - assert client.session[PlayerDetails.session_id] == episode.pk - - @pytest.mark.django_db - def test_resume(self, client, auth_user, player_episode): - response = client.post( - self.url(player_episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - - assert200(response) - assertContains(response, 'id="audio-player-button"') - - assert client.session[PlayerDetails.session_id] == player_episode.pk - - def url(self, episode): - return reverse("episodes:start_player", args=[episode.pk]) - - -class TestClosePlayer: - url = reverse_lazy("episodes:close_player") - - @pytest.mark.django_db - def test_player_empty(self, client, auth_user, episode): - response = client.post( - self.url, - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - - assert204(response) - - @pytest.mark.django_db - def test_close( - self, - client, - player_episode, - ): - response = client.post( - self.url, - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - - assert200(response) - assertContains(response, 'id="audio-player-button"') - - assert player_episode.pk not in client.session - - -class TestBookmarks: - url = reverse_lazy("episodes:bookmarks") - - @pytest.mark.django_db - def test_get(self, client, auth_user): - BookmarkFactory.create_batch(33, user=auth_user) - - response = client.get(self.url) - - assert200(response) - assertTemplateUsed(response, "episodes/bookmarks.html") - - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_ascending(self, client, auth_user): - BookmarkFactory.create_batch(33, user=auth_user) - - response = client.get(self.url, {"order": "asc"}) - - assert200(response) - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(self.url) - assert200(response) - - @pytest.mark.django_db - def test_search(self, client, auth_user): - podcast = PodcastFactory(title="zzzz") - - for _ in range(3): - BookmarkFactory( - user=auth_user, - episode=EpisodeFactory(title="zzzz", podcast=podcast), - ) - - BookmarkFactory(user=auth_user, episode=EpisodeFactory(title="testing")) - - response = client.get(self.url, {"search": "testing"}) - - assert200(response) - assertTemplateUsed(response, "episodes/bookmarks.html") - - assert len(response.context["page"].object_list) == 1 - - -class TestAddBookmark: - @pytest.mark.django_db - def test_post(self, client, auth_user, episode): - response = client.post(self.url(episode), headers={"HX-Request": "true"}) - - assert200(response) - assert Bookmark.objects.filter(user=auth_user, episode=episode).exists() - - @pytest.mark.django_db()(transaction=True) - def test_already_bookmarked(self, client, auth_user, episode): - BookmarkFactory(episode=episode, user=auth_user) - - response = client.post(self.url(episode), headers={"HX-Request": "true"}) - assert409(response) - - assert Bookmark.objects.filter(user=auth_user, episode=episode).exists() - - def url(self, episode): - return reverse("episodes:add_bookmark", args=[episode.pk]) - - -class TestRemoveBookmark: - @pytest.mark.django_db - def test_post(self, client, auth_user, episode): - BookmarkFactory(user=auth_user, episode=episode) - response = client.delete( - reverse("episodes:remove_bookmark", args=[episode.pk]), - headers={"HX-Request": "true"}, - ) - assert200(response) - - assert not Bookmark.objects.filter(user=auth_user, episode=episode).exists() - - -class TestHistory: - url = reverse_lazy("episodes:history") - - @pytest.mark.django_db - def test_get(self, client, auth_user): - AudioLogFactory.create_batch(33, user=auth_user) - response = client.get(self.url) - assert200(response) - assertTemplateUsed(response, "episodes/history.html") - - assert200(response) - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(self.url) - assert200(response) - - @pytest.mark.django_db - def test_ascending(self, client, auth_user): - AudioLogFactory.create_batch(33, user=auth_user) - - response = client.get(self.url, {"order": "asc"}) - assert200(response) - - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_search(self, client, auth_user): - podcast = PodcastFactory(title="zzzz") - - for _ in range(3): - AudioLogFactory( - user=auth_user, - episode=EpisodeFactory(title="zzzz", podcast=podcast), - ) - - AudioLogFactory(user=auth_user, episode=EpisodeFactory(title="testing")) - response = client.get(self.url, {"search": "testing"}) - - assert200(response) - assert len(response.context["page"].object_list) == 1 - - -class TestMarkAudioLogComplete: - @pytest.mark.django_db - def test_ok(self, client, auth_user, episode): - audio_log = AudioLogFactory(user=auth_user, episode=episode, current_time=300) - - response = client.post( - self.url(episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - - assert200(response) - - audio_log.refresh_from_db() - assert audio_log.current_time == 0 - - @pytest.mark.django_db - def test_is_playing(self, client, auth_user, player_episode): - """Do not mark complete if episode is currently playing""" - - response = client.post( - self.url(player_episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - - assert404(response) - - assert AudioLog.objects.filter(user=auth_user, episode=player_episode).exists() - - def url(self, episode): - return reverse("episodes:mark_audio_log_complete", args=[episode.pk]) - - -class TestRemoveAudioLog: - @pytest.mark.django_db - def test_ok(self, client, auth_user, episode): - AudioLogFactory(user=auth_user, episode=episode) - AudioLogFactory(user=auth_user) - - response = client.delete( - self.url(episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - - assert200(response) - - assert not AudioLog.objects.filter(user=auth_user, episode=episode).exists() - assert AudioLog.objects.filter(user=auth_user).count() == 1 - - @pytest.mark.django_db - def test_is_playing(self, client, auth_user, player_episode): - """Do not remove log if episode is currently playing""" - - response = client.delete( - self.url(player_episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - - assert404(response) - assert AudioLog.objects.filter(user=auth_user, episode=player_episode).exists() - - @pytest.mark.django_db - def test_none_remaining(self, client, auth_user, episode): - log = AudioLogFactory(user=auth_user, episode=episode) - - response = client.delete( - self.url(log.episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - assert200(response) - - assert not AudioLog.objects.filter(user=auth_user, episode=episode).exists() - assert AudioLog.objects.filter(user=auth_user).count() == 0 - - def url(self, episode): - return reverse("episodes:remove_audio_log", args=[episode.pk]) - - -class TestPlayerTimeUpdate: - url = reverse_lazy("episodes:player_time_update") - - @pytest.mark.django_db - def test_is_running(self, client, player_episode): - response = client.post( - self.url, - json.dumps( - { - "current_time": 1030, - "duration": 3600, - } - ), - content_type="application/json", - ) - - assert200(response) - - log = AudioLog.objects.first() - assert log is not None - - assert log.current_time == 1030 - - @pytest.mark.django_db - def test_player_log_missing(self, client, auth_user, episode): - session = client.session - session[PlayerDetails.session_id] = episode.pk - session.save() - - response = client.post( - self.url, - json.dumps( - { - "current_time": 1030, - "duration": 3600, - } - ), - content_type="application/json", - ) - - assert200(response) - - log = AudioLog.objects.get() - - assert log.current_time == 1030 - assert log.episode == episode - - @pytest.mark.django_db - def test_player_not_in_session(self, client, auth_user, episode): - response = client.post( - self.url, - json.dumps( - { - "current_time": 1030, - "duration": 3600, - } - ), - content_type="application/json", - ) - - assert400(response) - - assert not AudioLog.objects.exists() - - @pytest.mark.django_db - def test_missing_data(self, client, auth_user, player_episode): - response = client.post(self.url) - assert400(response) - - @pytest.mark.django_db - def test_invalid_data(self, client, auth_user, player_episode): - response = client.post( - self.url, - json.dumps( - { - "current_time": "xyz", - "duration": "abc", - } - ), - content_type="application/json", - ) - assert400(response) - - @pytest.mark.django_db()(transaction=True) - def test_episode_does_not_exist(self, client, auth_user): - session = client.session - session[PlayerDetails.session_id] = 12345 - session.save() - - response = client.post( - self.url, - json.dumps( - { - "current_time": 1000, - "duration": 3600, - } - ), - content_type="application/json", - ) - assert409(response) - - @pytest.mark.django_db - def test_user_not_authenticated(self, client): - response = client.post( - self.url, - json.dumps( - { - "current_time": 1000, - "duration": 3600, - } - ), - content_type="application/json", - ) - assert401(response) diff --git a/simplecasts/episodes/urls.py b/simplecasts/episodes/urls.py deleted file mode 100644 index 156d363368..0000000000 --- a/simplecasts/episodes/urls.py +++ /dev/null @@ -1,53 +0,0 @@ -from django.urls import path - -from simplecasts.episodes import views - -app_name = "episodes" - - -urlpatterns = [ - path("new/", views.index, name="index"), - path("search/episodes/", views.search_episodes, name="search_episodes"), - path( - "episodes/-/", - views.episode_detail, - name="episode_detail", - ), - path( - "player/start//", - views.start_player, - name="start_player", - ), - path( - "player/close/", - views.close_player, - name="close_player", - ), - path( - "player/time-update/", - views.player_time_update, - name="player_time_update", - ), - path("history/", views.history, name="history"), - path( - "history//complete/", - views.mark_audio_log_complete, - name="mark_audio_log_complete", - ), - path( - "history//remove/", - views.remove_audio_log, - name="remove_audio_log", - ), - path("bookmarks/", views.bookmarks, name="bookmarks"), - path( - "bookmarks//add/", - views.add_bookmark, - name="add_bookmark", - ), - path( - "bookmarks//remove/", - views.remove_bookmark, - name="remove_bookmark", - ), -] diff --git a/simplecasts/episodes/views.py b/simplecasts/episodes/views.py deleted file mode 100644 index 694e698c39..0000000000 --- a/simplecasts/episodes/views.py +++ /dev/null @@ -1,396 +0,0 @@ -import http -from typing import Literal, TypedDict - -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.db import IntegrityError -from django.db.models import OuterRef, Subquery -from django.http import Http404, JsonResponse -from django.shortcuts import get_object_or_404, redirect -from django.template.response import TemplateResponse -from django.utils import timezone -from django.views.decorators.http import require_POST, require_safe -from pydantic import BaseModel, ValidationError - -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.http import require_DELETE -from simplecasts.paginator import render_paginated_response -from simplecasts.podcasts.models import Podcast -from simplecasts.request import ( - AuthenticatedHttpRequest, - HttpRequest, - is_authenticated_request, -) -from simplecasts.response import ( - HttpResponseConflict, - HttpResponseNoContent, - RenderOrRedirectResponse, -) -from simplecasts.search import search_queryset - -PlayerAction = Literal["load", "play", "close"] - - -class PlayerUpdate(BaseModel): - """Data model for player time update.""" - - current_time: int - duration: int - - -class PlayerUpdateError(TypedDict): - """Data model for player error response.""" - - error: str - - -@require_safe -@login_required -def index(request: AuthenticatedHttpRequest) -> TemplateResponse: - """List latest episodes from subscriptions.""" - - latest_episodes = ( - Podcast.objects.subscribed(request.user) - .annotate( - latest_episode=Subquery( - Episode.objects.filter(podcast_id=OuterRef("pk")) - .order_by("-pub_date", "-pk") - .values("pk")[:1] - ) - ) - .filter(latest_episode__isnull=False) - .order_by("-pub_date") - .values_list("latest_episode", flat=True)[: settings.DEFAULT_PAGE_SIZE] - ) - - episodes = ( - Episode.objects.filter(pk__in=latest_episodes) - .select_related("podcast") - .order_by("-pub_date", "-pk") - ) - - return TemplateResponse( - request, - "episodes/index.html", - { - "episodes": episodes, - }, - ) - - -@require_safe -@login_required -def search_episodes(request: HttpRequest) -> RenderOrRedirectResponse: - """Search any episodes in the database.""" - - if request.search: - episodes = ( - search_queryset( - Episode.objects.filter(podcast__private=False), - request.search.value, - "search_vector", - ) - .select_related("podcast") - .order_by("-rank", "-pub_date") - ) - - return render_paginated_response(request, "episodes/search.html", episodes) - - return redirect("episodes:index") - - -@require_safe -@login_required -def episode_detail( - request: AuthenticatedHttpRequest, - episode_id: int, - slug: str | None = None, -) -> TemplateResponse: - """Renders episode detail.""" - episode = get_object_or_404( - Episode.objects.select_related("podcast"), - pk=episode_id, - ) - - audio_log = request.user.audio_logs.filter(episode=episode).first() - - is_bookmarked = request.user.bookmarks.filter(episode=episode).exists() - is_playing = request.player.has(episode.pk) - - return TemplateResponse( - request, - "episodes/detail.html", - { - "episode": episode, - "audio_log": audio_log, - "is_bookmarked": is_bookmarked, - "is_playing": is_playing, - }, - ) - - -@require_POST -@login_required -def start_player( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse: - """Starts player. Creates new audio log if required.""" - episode = get_object_or_404( - Episode.objects.select_related("podcast"), - pk=episode_id, - ) - - audio_log, _ = request.user.audio_logs.update_or_create( - episode=episode, - defaults={ - "listened": timezone.now(), - }, - ) - - request.player.set(episode.pk) - - return _render_player_action(request, audio_log, action="play") - - -@require_POST -@login_required -def close_player( - request: AuthenticatedHttpRequest, -) -> TemplateResponse | HttpResponseNoContent: - """Closes audio player.""" - if episode_id := request.player.pop(): - audio_log = get_object_or_404( - request.user.audio_logs.select_related("episode"), - episode__pk=episode_id, - ) - return _render_player_action(request, audio_log, action="close") - return HttpResponseNoContent() - - -@require_POST -def player_time_update(request: HttpRequest) -> JsonResponse: - """Handles player time update AJAX requests.""" - - if not is_authenticated_request(request): - return JsonResponse( - PlayerUpdateError(error="Authentication required"), - status=http.HTTPStatus.UNAUTHORIZED, - ) - - episode_id = request.player.get() - - if episode_id is None: - return JsonResponse( - PlayerUpdateError(error="No episode in player"), - status=http.HTTPStatus.BAD_REQUEST, - ) - - try: - update = PlayerUpdate.model_validate_json(request.body) - except ValidationError as exc: - return JsonResponse( - PlayerUpdateError(error=exc.json()), - status=http.HTTPStatus.BAD_REQUEST, - ) - - try: - request.user.audio_logs.update_or_create( - episode_id=episode_id, - defaults={ - "listened": timezone.now(), - "current_time": update.current_time, - "duration": update.duration, - }, - ) - - except IntegrityError: - return JsonResponse( - PlayerUpdateError(error="Update cannot be saved"), - status=http.HTTPStatus.CONFLICT, - ) - - return JsonResponse(update.model_dump()) - - -@require_safe -@login_required -def history(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Renders user's listening history. User can also search history.""" - audio_logs = request.user.audio_logs.select_related("episode", "episode__podcast") - - ordering = request.GET.get("order", "desc") - order_by = "listened" if ordering == "asc" else "-listened" - - if request.search: - audio_logs = search_queryset( - audio_logs, - request.search.value, - "episode__search_vector", - "episode__podcast__search_vector", - ).order_by("-rank", order_by) - else: - audio_logs = audio_logs.order_by(order_by) - - return render_paginated_response( - request, - "episodes/history.html", - audio_logs, - { - "ordering": ordering, - }, - ) - - -@require_POST -@login_required -def mark_audio_log_complete( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse: - """Marks audio log complete.""" - - if request.player.has(episode_id): - raise Http404 - - audio_log = get_object_or_404( - request.user.audio_logs.select_related("episode"), - episode__pk=episode_id, - ) - - audio_log.current_time = 0 - audio_log.save() - - messages.success(request, "Episode marked complete") - - return _render_audio_log_action(request, audio_log, show_audio_log=True) - - -@require_DELETE -@login_required -def remove_audio_log( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse: - """Removes audio log from user history and returns HTMX snippet.""" - # cannot remove episode if in player - if request.player.has(episode_id): - raise Http404 - - audio_log = get_object_or_404( - request.user.audio_logs.select_related("episode"), - episode__pk=episode_id, - ) - - audio_log.delete() - - messages.info(request, "Removed from History") - - return _render_audio_log_action(request, audio_log, show_audio_log=False) - - -@require_safe -@login_required -def bookmarks(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Renders user's bookmarks. User can also search their bookmarks.""" - bookmarks = request.user.bookmarks.select_related("episode", "episode__podcast") - - ordering = request.GET.get("order", "desc") - order_by = "created" if ordering == "asc" else "-created" - - if request.search: - bookmarks = search_queryset( - bookmarks, - request.search.value, - "episode__search_vector", - "episode__podcast__search_vector", - ).order_by("-rank", order_by) - else: - bookmarks = bookmarks.order_by(order_by) - - return render_paginated_response( - request, - "episodes/bookmarks.html", - bookmarks, - { - "ordering": ordering, - }, - ) - - -@require_POST -@login_required -def add_bookmark( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse | HttpResponseConflict: - """Add episode to bookmarks.""" - episode = get_object_or_404(Episode, pk=episode_id) - - try: - request.user.bookmarks.create(episode=episode) - except IntegrityError: - return HttpResponseConflict() - - messages.success(request, "Added to Bookmarks") - - return _render_bookmark_action(request, episode, is_bookmarked=True) - - -@require_DELETE -@login_required -def remove_bookmark( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse: - """Remove episode from bookmarks.""" - episode = get_object_or_404(Episode, pk=episode_id) - request.user.bookmarks.filter(episode=episode).delete() - - messages.info(request, "Removed from Bookmarks") - - return _render_bookmark_action(request, episode, is_bookmarked=False) - - -def _render_player_action( - request: HttpRequest, - audio_log: AudioLog, - *, - action: PlayerAction, -) -> TemplateResponse: - return TemplateResponse( - request, - "episodes/detail.html#audio_player_button", - { - "action": action, - "audio_log": audio_log, - "episode": audio_log.episode, - "is_playing": action == "play", - }, - ) - - -def _render_bookmark_action( - request: AuthenticatedHttpRequest, - episode: Episode, - *, - is_bookmarked: bool, -) -> TemplateResponse: - return TemplateResponse( - request, - "episodes/detail.html#bookmark_button", - { - "episode": episode, - "is_bookmarked": is_bookmarked, - }, - ) - - -def _render_audio_log_action( - request: AuthenticatedHttpRequest, - audio_log: AudioLog, - *, - show_audio_log: bool, -) -> TemplateResponse: - context = {"episode": audio_log.episode} - - if show_audio_log: - context["audio_log"] = audio_log - - return TemplateResponse(request, "episodes/detail.html#audio_log", context) diff --git a/simplecasts/users/forms.py b/simplecasts/forms.py similarity index 79% rename from simplecasts/users/forms.py rename to simplecasts/forms.py index ac56d04b4d..b5a6290f8e 100644 --- a/simplecasts/users/forms.py +++ b/simplecasts/forms.py @@ -2,7 +2,19 @@ from django import forms -from simplecasts.users.models import User +from simplecasts.models import Podcast, User + + +class PodcastForm(forms.ModelForm): + """Form to add a new podcast feed.""" + + class Meta: + model = Podcast + fields: ClassVar[list] = ["rss"] + labels: ClassVar[dict] = {"rss": "RSS Feed URL"} + error_messages: ClassVar[dict] = { + "rss": {"unique": "This podcast is not available"} + } class UserPreferencesForm(forms.ModelForm): diff --git a/simplecasts/episodes/__init__.py b/simplecasts/http/__init__.py similarity index 100% rename from simplecasts/episodes/__init__.py rename to simplecasts/http/__init__.py diff --git a/simplecasts/http.py b/simplecasts/http/decorators.py similarity index 100% rename from simplecasts/http.py rename to simplecasts/http/decorators.py diff --git a/simplecasts/request.py b/simplecasts/http/request.py similarity index 86% rename from simplecasts/request.py rename to simplecasts/http/request.py index c682f539e8..2139b75c96 100644 --- a/simplecasts/request.py +++ b/simplecasts/http/request.py @@ -7,9 +7,8 @@ from django.contrib.auth.models import AnonymousUser from django_htmx.middleware import HtmxDetails - from simplecasts.episodes.middleware import PlayerDetails - from simplecasts.middleware import SearchDetails - from simplecasts.users.models import User + from simplecasts.middleware import PlayerDetails, SearchDetails + from simplecasts.models import User class HttpRequest(DjangoHttpRequest): diff --git a/simplecasts/response.py b/simplecasts/http/response.py similarity index 100% rename from simplecasts/response.py rename to simplecasts/http/response.py diff --git a/simplecasts/episodes/management/__init__.py b/simplecasts/management/__init__.py similarity index 100% rename from simplecasts/episodes/management/__init__.py rename to simplecasts/management/__init__.py diff --git a/simplecasts/episodes/management/commands/__init__.py b/simplecasts/management/commands/__init__.py similarity index 100% rename from simplecasts/episodes/management/commands/__init__.py rename to simplecasts/management/commands/__init__.py diff --git a/simplecasts/podcasts/management/commands/create_podcast_recommendations.py b/simplecasts/management/commands/create_podcast_recommendations.py similarity index 83% rename from simplecasts/podcasts/management/commands/create_podcast_recommendations.py rename to simplecasts/management/commands/create_podcast_recommendations.py index 05899b604e..ec28463410 100644 --- a/simplecasts/podcasts/management/commands/create_podcast_recommendations.py +++ b/simplecasts/management/commands/create_podcast_recommendations.py @@ -1,9 +1,9 @@ from django.core.management.base import BaseCommand from django.db.models.functions import Lower -from simplecasts.podcasts import recommender, tokenizer -from simplecasts.podcasts.models import Podcast -from simplecasts.thread_pool import db_threadsafe, thread_pool_map +from simplecasts.models import Podcast +from simplecasts.services import recommender, tokenizer +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map class Command(BaseCommand): diff --git a/simplecasts/podcasts/management/commands/fetch_itunes_feeds.py b/simplecasts/management/commands/fetch_itunes_feeds.py similarity index 94% rename from simplecasts/podcasts/management/commands/fetch_itunes_feeds.py rename to simplecasts/management/commands/fetch_itunes_feeds.py index 456dc4c811..5e9b6124ff 100644 --- a/simplecasts/podcasts/management/commands/fetch_itunes_feeds.py +++ b/simplecasts/management/commands/fetch_itunes_feeds.py @@ -6,10 +6,10 @@ from django.core.management import CommandError, CommandParser from django.core.management.base import BaseCommand -from simplecasts.http_client import get_client -from simplecasts.podcasts import itunes -from simplecasts.podcasts.models import Category -from simplecasts.thread_pool import db_threadsafe, thread_pool_map +from simplecasts.models import Category +from simplecasts.services import itunes +from simplecasts.services.http_client import get_client +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map @dataclasses.dataclass(kw_only=True, frozen=True) diff --git a/simplecasts/podcasts/management/commands/parse_podcast_feeds.py b/simplecasts/management/commands/parse_podcast_feeds.py similarity index 88% rename from simplecasts/podcasts/management/commands/parse_podcast_feeds.py rename to simplecasts/management/commands/parse_podcast_feeds.py index 0e79da2d2f..89c6904779 100644 --- a/simplecasts/podcasts/management/commands/parse_podcast_feeds.py +++ b/simplecasts/management/commands/parse_podcast_feeds.py @@ -3,10 +3,10 @@ from django.core.management.base import BaseCommand, CommandParser from django.db.models import Case, Count, IntegerField, When -from simplecasts.http_client import get_client -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.feed_parser import parse_feed -from simplecasts.thread_pool import db_threadsafe, thread_pool_map +from simplecasts.models import Podcast +from simplecasts.services.feed_parser import parse_feed +from simplecasts.services.http_client import get_client +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map @dataclasses.dataclass(frozen=True, kw_only=True) diff --git a/simplecasts/episodes/management/commands/send_episode_notifications.py b/simplecasts/management/commands/send_episode_notifications.py similarity index 92% rename from simplecasts/episodes/management/commands/send_episode_notifications.py rename to simplecasts/management/commands/send_episode_notifications.py index 6c03c79113..56a24eaeb4 100644 --- a/simplecasts/episodes/management/commands/send_episode_notifications.py +++ b/simplecasts/management/commands/send_episode_notifications.py @@ -8,10 +8,9 @@ from django.db.models import Exists, OuterRef, QuerySet from django.utils import timezone -from simplecasts.episodes.models import Episode -from simplecasts.thread_pool import db_threadsafe, thread_pool_map -from simplecasts.users.models import User -from simplecasts.users.notifications import get_recipients, send_notification_email +from simplecasts.models import Episode, User +from simplecasts.services.notifications import get_recipients, send_notification_email +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map class Command(BaseCommand): @@ -53,7 +52,7 @@ def _worker(recipient: EmailAddress) -> tuple[EmailAddress, bool]: site, recipient, f"Hi, {recipient.user.name}, check out these new podcast episodes!", - "episodes/emails/notifications.html", + "emails/episode_notifications.html", { "episodes": episodes, }, diff --git a/simplecasts/podcasts/management/commands/send_podcast_recommendations.py b/simplecasts/management/commands/send_podcast_recommendations.py similarity index 87% rename from simplecasts/podcasts/management/commands/send_podcast_recommendations.py rename to simplecasts/management/commands/send_podcast_recommendations.py index 540a1e2e9d..2facea0edc 100644 --- a/simplecasts/podcasts/management/commands/send_podcast_recommendations.py +++ b/simplecasts/management/commands/send_podcast_recommendations.py @@ -3,9 +3,9 @@ from django.core.mail import get_connection from django.core.management.base import BaseCommand, CommandParser -from simplecasts.podcasts.models import Podcast -from simplecasts.thread_pool import db_threadsafe, thread_pool_map -from simplecasts.users.notifications import get_recipients, send_notification_email +from simplecasts.models import Podcast +from simplecasts.services.notifications import get_recipients, send_notification_email +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map class Command(BaseCommand): @@ -40,7 +40,7 @@ def _worker(recipient: EmailAddress) -> tuple[EmailAddress, bool]: site, recipient, f"Hi, {recipient.user.name}, here are some podcasts you might like!", - "podcasts/emails/recommendations.html", + "emails/podcast_recommendations.html", { "podcasts": podcasts, }, diff --git a/simplecasts/middleware.py b/simplecasts/middleware.py index 2e720697a2..5ba8365810 100644 --- a/simplecasts/middleware.py +++ b/simplecasts/middleware.py @@ -10,7 +10,7 @@ from django.utils.functional import cached_property from django_htmx.http import HttpResponseLocation -from simplecasts.request import HttpRequest +from simplecasts.http.request import HttpRequest @dataclasses.dataclass(frozen=True, kw_only=False) @@ -89,6 +89,40 @@ def __call__(self, request: HttpRequest) -> HttpResponse: return response +class PlayerMiddleware(BaseMiddleware): + """Adds `PlayerDetails` instance to request as `request.player`.""" + + def __call__(self, request: HttpRequest) -> HttpResponse: + """Middleware implementation.""" + request.player = PlayerDetails(request=request) + return self.get_response(request) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class PlayerDetails: + """Tracks current player episode in session.""" + + request: HttpRequest + session_id: str = "audio-player" + + def get(self) -> int | None: + """Returns primary key of episode in player, if any in session.""" + return self.request.session.get(self.session_id) + + def has(self, episode_id: int) -> bool: + """Checks if episode matching ID is in player.""" + return self.get() == episode_id + + def set(self, episode_id: int) -> None: + """Adds episode PK to player in session.""" + self.request.session[self.session_id] = episode_id + + def pop(self) -> int | None: + """Returns primary key of episode in player, if any in session, and removes + the episode ID from the session.""" + return self.request.session.pop(self.session_id, None) + + class SearchMiddleware(BaseMiddleware): """Adds `SearchDetails` instance as `request.search`.""" diff --git a/simplecasts/migrations/0001_initial.py b/simplecasts/migrations/0001_initial.py new file mode 100644 index 0000000000..c1192c8032 --- /dev/null +++ b/simplecasts/migrations/0001_initial.py @@ -0,0 +1,706 @@ +# Generated by Django 6.0.1 on 2026-01-06 21:34 + +import datetime + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.contrib.postgres.indexes +import django.contrib.postgres.search +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ("slug", models.SlugField(unique=True)), + ("itunes_genre_id", models.PositiveIntegerField(blank=True, null=True)), + ], + options={ + "verbose_name_plural": "categories", + "ordering": ("name",), + }, + ), + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ("send_email_notifications", models.BooleanField(default=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="Podcast", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rss", + models.URLField( + max_length=2083, + unique=True, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Inactive podcasts will no longer be updated from their RSS feeds.", + ), + ), + ( + "private", + models.BooleanField( + default=False, help_text="Only available to subscribers" + ), + ), + ("etag", models.TextField(blank=True)), + ("title", models.TextField(blank=True)), + ("pub_date", models.DateTimeField(blank=True, null=True)), + ("num_episodes", models.PositiveIntegerField(default=0)), + ("parsed", models.DateTimeField(blank=True, null=True)), + ( + "feed_status", + models.CharField( + blank=True, + choices=[ + ("success", "Success"), + ("not_modified", "Not Modified"), + ("database_error", "Database Error"), + ("discontinued", "Discontinued"), + ("duplicate", "Duplicate"), + ("invalid_rss", "Invalid RSS"), + ("unavailable", "Unavailable"), + ], + max_length=20, + ), + ), + ("frequency", models.DurationField(default=datetime.timedelta(days=1))), + ("modified", models.DateTimeField(blank=True, null=True)), + ("content_hash", models.CharField(blank=True, max_length=64)), + ("exception", models.TextField(blank=True)), + ("num_retries", models.PositiveIntegerField(default=0)), + ( + "cover_url", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ( + "funding_url", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ("funding_text", models.TextField(blank=True)), + ( + "language", + models.CharField( + default="en", + max_length=2, + validators=[django.core.validators.MinLengthValidator(2)], + ), + ), + ("description", models.TextField(blank=True)), + ( + "website", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ("keywords", models.TextField(blank=True)), + ("extracted_text", models.TextField(blank=True)), + ("owner", models.TextField(blank=True)), + ("promoted", models.BooleanField(default=False)), + ( + "podcast_type", + models.CharField( + choices=[("episodic", "Episodic"), ("serial", "Serial")], + default="episodic", + max_length=10, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("explicit", models.BooleanField(default=False)), + ( + "search_document", + django.contrib.postgres.search.SearchVectorField( + blank=True, editable=False, null=True + ), + ), + ( + "owner_search_document", + django.contrib.postgres.search.SearchVectorField( + blank=True, editable=False, null=True + ), + ), + ( + "canonical", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="duplicates", + to="simplecasts.podcast", + ), + ), + ( + "categories", + models.ManyToManyField( + blank=True, related_name="podcasts", to="simplecasts.category" + ), + ), + ( + "recipients", + models.ManyToManyField( + blank=True, + related_name="recommended_podcasts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Episode", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("guid", models.TextField()), + ("pub_date", models.DateTimeField()), + ("title", models.TextField(blank=True)), + ("description", models.TextField(blank=True)), + ("keywords", models.TextField(blank=True)), + ( + "cover_url", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ( + "website", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ( + "episode_type", + models.CharField( + choices=[ + ("full", "Full episode"), + ("trailer", "Trailer"), + ("bonus", "Bonus"), + ], + default="full", + max_length=12, + ), + ), + ("episode", models.IntegerField(blank=True, null=True)), + ("season", models.IntegerField(blank=True, null=True)), + ( + "media_url", + models.URLField( + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ("media_type", models.CharField(max_length=60)), + ( + "file_size", + models.BigIntegerField( + blank=True, null=True, verbose_name="File size in bytes" + ), + ), + ("duration", models.CharField(blank=True, max_length=30)), + ("explicit", models.BooleanField(default=False)), + ( + "search_document", + django.contrib.postgres.search.SearchVectorField( + blank=True, editable=False, null=True + ), + ), + ( + "podcast", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="episodes", + to="simplecasts.podcast", + ), + ), + ], + ), + migrations.CreateModel( + name="Recommendation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "score", + models.DecimalField( + blank=True, decimal_places=10, max_digits=100, null=True + ), + ), + ( + "podcast", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="recommendations", + to="simplecasts.podcast", + ), + ), + ( + "recommended", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="similar", + to="simplecasts.podcast", + ), + ), + ], + ), + migrations.CreateModel( + name="Subscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "podcast", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to="simplecasts.podcast", + ), + ), + ( + "subscriber", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Bookmark", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bookmarks", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "episode", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bookmarks", + to="simplecasts.episode", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["user", "episode"], + name="simplecasts_user_id_5840b2_idx", + ), + models.Index( + fields=["user", "created"], + include=("episode_id",), + name="simplecasts_bookmark_desc_idx", + ), + models.Index( + fields=["user", "-created"], + include=("episode_id",), + name="simplecasts_bookmark_asc_idx", + ), + ], + "constraints": [ + models.UniqueConstraint( + fields=("user", "episode"), + name="unique_simplecasts_bookmark_user_episode", + ) + ], + }, + ), + migrations.CreateModel( + name="AudioLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("listened", models.DateTimeField()), + ("current_time", models.PositiveIntegerField(default=0)), + ("duration", models.PositiveIntegerField(default=0)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="audio_logs", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "episode", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="audio_logs", + to="simplecasts.episode", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["user", "episode"], + name="simplecasts_user_id_8cf983_idx", + ), + models.Index( + fields=["user", "listened"], + include=("episode_id",), + name="simplecasts_audiolog_desc_idx", + ), + models.Index( + fields=["user", "-listened"], + include=("episode_id",), + name="simplecasts_audiolog_asc_idx", + ), + ], + "constraints": [ + models.UniqueConstraint( + fields=("user", "episode"), + name="unique_simplecasts_audiolog_user_episode", + ) + ], + }, + ), + migrations.AddIndex( + model_name="podcast", + index=models.Index( + fields=["-pub_date"], name="simplecasts_pub_dat_74abec_idx" + ), + ), + migrations.AddIndex( + model_name="podcast", + index=models.Index( + fields=["-promoted", "language", "-pub_date"], + name="simplecasts_promote_2ab115_idx", + ), + ), + migrations.AddIndex( + model_name="podcast", + index=models.Index( + fields=["active", "-promoted", "parsed", "updated"], + name="simplecasts_active_89dae2_idx", + ), + ), + migrations.AddIndex( + model_name="podcast", + index=models.Index( + condition=models.Q(("private", False), ("pub_date__isnull", False)), + fields=["-pub_date"], + name="simplecasts_podcast_public_idx", + ), + ), + migrations.AddIndex( + model_name="podcast", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_document"], name="simplecasts_search__5a4124_gin" + ), + ), + migrations.AddIndex( + model_name="podcast", + index=django.contrib.postgres.indexes.GinIndex( + fields=["owner_search_document"], name="simplecasts_owner_s_b40637_gin" + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["podcast", "pub_date", "id"], + name="simplecasts_podcast_1111db_idx", + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["podcast", "-pub_date", "-id"], + name="simplecasts_podcast_0ff5cb_idx", + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["podcast", "season", "-pub_date", "-id"], + name="simplecasts_podcast_c440f0_idx", + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["pub_date", "id"], name="simplecasts_pub_dat_e63d62_idx" + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["-pub_date", "-id"], name="simplecasts_pub_dat_9acc44_idx" + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index(fields=["guid"], name="simplecasts_guid_bbb042_idx"), + ), + migrations.AddIndex( + model_name="episode", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_document"], name="simplecasts_search__8ab6de_gin" + ), + ), + migrations.AddConstraint( + model_name="episode", + constraint=models.UniqueConstraint( + fields=("podcast", "guid"), + name="unique_simplecasts_episode_podcast_guid", + ), + ), + migrations.AddIndex( + model_name="recommendation", + index=models.Index(fields=["-score"], name="simplecasts_score_866794_idx"), + ), + migrations.AddConstraint( + model_name="recommendation", + constraint=models.UniqueConstraint( + fields=("podcast", "recommended"), + name="unique_simplecasts_recommendation", + ), + ), + migrations.AddIndex( + model_name="subscription", + index=models.Index( + fields=["-created"], name="simplecasts_created_dffa8b_idx" + ), + ), + migrations.AddConstraint( + model_name="subscription", + constraint=models.UniqueConstraint( + fields=("subscriber", "podcast"), + name="unique_simplecasts_subscription_user_podcast", + ), + ), + ] diff --git a/simplecasts/migrations/0002_create_search_triggers.py b/simplecasts/migrations/0002_create_search_triggers.py new file mode 100644 index 0000000000..f9b80cd1ca --- /dev/null +++ b/simplecasts/migrations/0002_create_search_triggers.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.1 on 2026-01-06 21:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("simplecasts", "0001_initial"), + ] + + operations = [ + # Podcast: owner_search_document trigger on owner field + migrations.RunSQL( + sql=""" + CREATE TRIGGER podcast_update_owner_search_trigger + BEFORE INSERT OR UPDATE OF owner ON simplecasts_podcast + FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( + owner_search_document, 'pg_catalog.simple', owner + ); + """, + reverse_sql="DROP TRIGGER IF EXISTS podcast_update_owner_search_trigger ON simplecasts_podcast;", + ), + # Podcast: search_document trigger on title, owner, keywords fields + migrations.RunSQL( + sql=""" + CREATE TRIGGER podcast_update_search_trigger + BEFORE INSERT OR UPDATE OF title, owner, keywords ON simplecasts_podcast + FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( + search_document, 'pg_catalog.simple', title, owner, keywords + ); + """, + reverse_sql="DROP TRIGGER IF EXISTS podcast_update_search_trigger ON simplecasts_podcast;", + ), + # Episode: search_document trigger on title, keywords fields + migrations.RunSQL( + sql=""" + CREATE TRIGGER episode_update_search_trigger + BEFORE INSERT OR UPDATE OF title, keywords ON simplecasts_episode + FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( + search_document, 'pg_catalog.simple', title, keywords + ); + """, + reverse_sql="DROP TRIGGER IF EXISTS episode_update_search_trigger ON simplecasts_episode;", + ), + ] diff --git a/simplecasts/migrations/0003_add_categories.py b/simplecasts/migrations/0003_add_categories.py new file mode 100644 index 0000000000..d95f7f03ec --- /dev/null +++ b/simplecasts/migrations/0003_add_categories.py @@ -0,0 +1,148 @@ +# Generated by Django 6.0.1 on 2026-01-06 21:35 + +from django.db import migrations +from slugify import slugify + +""" +https://github.com/AquaChocomint/AppleStore-Genre-ID +""" + + +_CATEGORIES = [ + ("After Shows", 1562), + ("Alternative Health", 1513), + ("Animation & Manga", 1510), + ("Arts", 1301), + ("Astronomy", 1538), + ("Automotive", 1503), + ("Aviation", 1504), + ("Baseball", 1549), + ("Basketball", 1548), + ("Books", 1482), + ("Buddhism", 1438), + ("Business", 1321), + ("Business News", 1490), + ("Careers", 1410), + ("Chemistry", 1539), + ("Christianity", 1439), + ("Comedy", 1303), + ("Comedy Fiction", 1486), + ("Comedy Interviews", 1496), + ("Courses", 1501), + ("Crafts", 1506), + ("Cricket", 1554), + ("Daily News", 1526), + ("Design", 1402), + ("Documentary", 1543), + ("Drama", 1484), + ("Earth Sciences", 1540), + ("Education", 1304), + ("Education for Kids", 1519), + ("Entertainment News", 1531), + ("Entrepreneurship", 1493), + ("Fantasy Sports", 1560), + ("Fashion & Beauty", 1459), + ("Fiction", 1483), + ("Film History", 1564), + ("Film Interviews", 1565), + ("Film Reviews", 1563), + ("Fitness", 1514), + ("Food", 1306), + ("Football", 1547), + ("Games", 1507), + ("Golf", 1553), + ("Government", 1511), + ("Health & Fitness", 1512), + ("Hinduism", 1463), + ("History", 1487), + ("Hobbies", 1505), + ("Hockey", 1550), + ("Home & Garden", 1508), + ("How-To", 1499), + ("Improv", 1495), + ("Investing", 1412), + ("Islam", 1440), + ("Judaism", 1441), + ("Kids & Family", 1305), + ("Language Learning", 1498), + ("Leisure", 1502), + ("Life Sciences", 1541), + ("Management", 1491), + ("Marketing", 1492), + ("Mathematics", 1536), + ("Medicine", 1518), + ("Mental Health", 1517), + ("Music", 1310), + ("Music Commentary", 1523), + ("Music History", 1524), + ("Music Interviews", 1525), + ("Natural Sciences", 1534), + ("Nature", 1537), + ("News", 1489), + ("News Commentary", 1530), + ("Non-Profit", 1494), + ("Nutrition", 1515), + ("Parenting", 1521), + ("Performing Arts", 1405), + ("Personal Journals", 1302), + ("Pets & Animals", 1522), + ("Philosophy", 1443), + ("Physics", 1542), + ("Places & Travel", 1320), + ("Politics", 1527), + ("Relationships", 1544), + ("Religion", 1532), + ("Religion & Spirituality", 1314), + ("Rugby", 1552), + ("Running", 1551), + ("Science", 1533), + ("Science Fiction", 1485), + ("Self-Improvement", 1500), + ("Sexuality", 1516), + ("Soccer", 1546), + ("Social Sciences", 1535), + ("Society & Culture", 1324), + ("Spirituality", 1444), + ("Sports", 1545), + ("Sports News", 1529), + ("Stand-Up", 1497), + ("Stories for Kids", 1520), + ("Swimming", 1558), + ("Tech News", 1528), + ("Technology", 1318), + ("Tennis", 1556), + ("True Crime", 1488), + ("TV & Film", 1309), + ("TV Reviews", 1561), + ("Video Games", 1509), + ("Visual Arts", 1406), + ("Volleyball", 1557), + ("Wilderness", 1559), + ("Wrestling", 1555), +] + + +def _add_categories(apps, schema_editor): + Category = apps.get_model("simplecasts.Category") + categories = [ + Category( + name=name, + slug=slugify(name), + itunes_genre_id=itunes_genre_id, + ) + for name, itunes_genre_id in _CATEGORIES + ] + Category.objects.bulk_create(categories, ignore_conflicts=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("simplecasts", "0002_create_search_triggers"), + ] + + operations = [ + migrations.RunPython( + _add_categories, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/simplecasts/episodes/migrations/__init__.py b/simplecasts/migrations/__init__.py similarity index 100% rename from simplecasts/episodes/migrations/__init__.py rename to simplecasts/migrations/__init__.py diff --git a/simplecasts/migrations/max_migration.txt b/simplecasts/migrations/max_migration.txt new file mode 100644 index 0000000000..8fde70d7d7 --- /dev/null +++ b/simplecasts/migrations/max_migration.txt @@ -0,0 +1 @@ +0003_add_categories diff --git a/simplecasts/models/__init__.py b/simplecasts/models/__init__.py new file mode 100644 index 0000000000..8449b192c8 --- /dev/null +++ b/simplecasts/models/__init__.py @@ -0,0 +1,20 @@ +from simplecasts.models.audio_logs import AudioLog +from simplecasts.models.bookmarks import Bookmark +from simplecasts.models.categories import Category +from simplecasts.models.episodes import Episode +from simplecasts.models.podcasts import Podcast, Season +from simplecasts.models.recommendations import Recommendation +from simplecasts.models.subscriptions import Subscription +from simplecasts.models.users import User + +__all__ = [ + "AudioLog", + "Bookmark", + "Category", + "Episode", + "Podcast", + "Recommendation", + "Season", + "Subscription", + "User", +] diff --git a/simplecasts/models/audio_logs.py b/simplecasts/models/audio_logs.py new file mode 100644 index 0000000000..9ca8edadea --- /dev/null +++ b/simplecasts/models/audio_logs.py @@ -0,0 +1,66 @@ +from typing import ClassVar + +from django.conf import settings +from django.db import models +from django.utils.functional import cached_property + +from simplecasts.models.search import SearchQuerySetMixin + + +class AudioLogQuerySet(SearchQuerySetMixin, models.QuerySet): + """Custom queryset for Bookmark model.""" + + default_search_fields = ( + "episode__search_document", + "episode__podcast__search_document", + ) + + +class AudioLog(models.Model): + """Record of user listening history.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="audio_logs", + ) + episode = models.ForeignKey( + "simplecasts.Episode", + on_delete=models.CASCADE, + related_name="audio_logs", + ) + + listened = models.DateTimeField() + current_time = models.PositiveIntegerField(default=0) + duration = models.PositiveIntegerField(default=0) + + objects: AudioLogQuerySet = AudioLogQuerySet.as_manager() # type: ignore[assignment] + + class Meta: + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s_user_episode", + fields=["user", "episode"], + ), + ] + indexes: ClassVar[list] = [ + models.Index(fields=["user", "episode"]), + models.Index( + fields=["user", "listened"], + include=["episode_id"], + name="%(app_label)s_%(class)s_desc_idx", + ), + models.Index( + fields=["user", "-listened"], + include=["episode_id"], + name="%(app_label)s_%(class)s_asc_idx", + ), + ] + + @cached_property + def percent_complete(self) -> int: + """Returns percentage of episode listened to.""" + if 0 in (self.current_time, self.duration): + return 0 + + return min(100, round((self.current_time / self.duration) * 100)) diff --git a/simplecasts/models/bookmarks.py b/simplecasts/models/bookmarks.py new file mode 100644 index 0000000000..c3031fc845 --- /dev/null +++ b/simplecasts/models/bookmarks.py @@ -0,0 +1,56 @@ +from typing import ClassVar + +from django.conf import settings +from django.db import models + +from simplecasts.models.search import SearchQuerySetMixin + + +class BookmarkQuerySet(SearchQuerySetMixin, models.QuerySet): + """Custom queryset for Bookmark model.""" + + default_search_fields = ( + "episode__search_document", + "episode__podcast__search_document", + ) + + +class Bookmark(models.Model): + """Bookmarked episodes.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="bookmarks", + ) + + episode = models.ForeignKey( + "simplecasts.Episode", + on_delete=models.CASCADE, + related_name="bookmarks", + ) + + created = models.DateTimeField(auto_now_add=True) + + objects: BookmarkQuerySet = BookmarkQuerySet.as_manager() # type: ignore[assignment] + + class Meta: + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s_user_episode", + fields=["user", "episode"], + ) + ] + indexes: ClassVar[list] = [ + models.Index(fields=["user", "episode"]), + models.Index( + fields=["user", "created"], + include=["episode_id"], + name="%(app_label)s_%(class)s_desc_idx", + ), + models.Index( + fields=["user", "-created"], + include=["episode_id"], + name="%(app_label)s_%(class)s_asc_idx", + ), + ] diff --git a/simplecasts/models/categories.py b/simplecasts/models/categories.py new file mode 100644 index 0000000000..e6f075836b --- /dev/null +++ b/simplecasts/models/categories.py @@ -0,0 +1,36 @@ +from typing import TYPE_CHECKING + +from django.db import models +from django.urls import reverse +from slugify import slugify + +if TYPE_CHECKING: + from simplecasts.models.podcasts import PodcastQuerySet + + +class Category(models.Model): + """iTunes category.""" + + name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(unique=True) + itunes_genre_id = models.PositiveIntegerField(null=True, blank=True) + + if TYPE_CHECKING: + podcasts: "PodcastQuerySet" + + class Meta: + verbose_name_plural = "categories" + ordering = ("name",) + + def __str__(self) -> str: + """Returns category name.""" + return self.name + + def save(self, **kwargs) -> None: + """Overrides save to auto-generate slug.""" + self.slug = slugify(self.name, allow_unicode=False) + super().save(**kwargs) + + def get_absolute_url(self) -> str: + """Absolute URL to a category.""" + return reverse("podcasts:category_detail", kwargs={"slug": self.slug}) diff --git a/simplecasts/episodes/models.py b/simplecasts/models/episodes.py similarity index 65% rename from simplecasts/episodes/models.py rename to simplecasts/models/episodes.py index c0f13faf33..aeb776ebf1 100644 --- a/simplecasts/episodes/models.py +++ b/simplecasts/models/episodes.py @@ -1,6 +1,5 @@ from typing import ClassVar, Optional -from django.conf import settings from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVectorField from django.db import models @@ -12,9 +11,16 @@ from django.utils.functional import cached_property from slugify import slugify -from simplecasts.fields import URLField -from simplecasts.podcasts.models import Season -from simplecasts.sanitizer import strip_html +from simplecasts.models.fields import URLField +from simplecasts.models.podcasts import Season +from simplecasts.models.search import SearchQuerySetMixin +from simplecasts.services.sanitizer import strip_html + + +class EpisodeQuerySet(SearchQuerySetMixin, models.QuerySet): + """Custom queryset for Episode model.""" + + default_search_fields = ("search_document",) class Episode(models.Model): @@ -26,7 +32,7 @@ class EpisodeType(models.TextChoices): BONUS = "bonus", "Bonus" podcast = models.ForeignKey( - "podcasts.Podcast", + "simplecasts.Podcast", on_delete=models.CASCADE, related_name="episodes", ) @@ -63,7 +69,9 @@ class EpisodeType(models.TextChoices): explicit = models.BooleanField(default=False) - search_vector = SearchVectorField(null=True, editable=False) + search_document = SearchVectorField(null=True, blank=True, editable=False) + + objects: EpisodeQuerySet = EpisodeQuerySet.as_manager() # type: ignore[assignment] class Meta: constraints: ClassVar[list] = [ @@ -79,7 +87,7 @@ class Meta: models.Index(fields=["pub_date", "id"]), models.Index(fields=["-pub_date", "-id"]), models.Index(fields=["guid"]), - GinIndex(fields=["search_vector"]), + GinIndex(fields=["search_document"]), ] def __str__(self) -> str: @@ -89,7 +97,7 @@ def __str__(self) -> str: def get_absolute_url(self) -> str: """Canonical episode URL.""" return reverse( - "episodes:episode_detail", + "episodes:detail", kwargs={ "episode_id": self.pk, "slug": self.slug, @@ -181,90 +189,3 @@ def _get_other_episodes_in_podcast(self) -> models.QuerySet["Episode"]: return self._meta.default_manager.filter( # type: ignore[reportOptionalMemberAccess] podcast=self.podcast, ).exclude(pk=self.pk) - - -class Bookmark(models.Model): - """Bookmarked episodes.""" - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="bookmarks", - ) - - episode = models.ForeignKey( - "episodes.Episode", - on_delete=models.CASCADE, - related_name="bookmarks", - ) - - created = models.DateTimeField(auto_now_add=True) - - class Meta: - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s_user_episode", - fields=["user", "episode"], - ) - ] - indexes: ClassVar[list] = [ - models.Index(fields=["user", "episode"]), - models.Index( - fields=["user", "created"], - include=["episode_id"], - name="%(app_label)s_%(class)s_desc_idx", - ), - models.Index( - fields=["user", "-created"], - include=["episode_id"], - name="%(app_label)s_%(class)s_asc_idx", - ), - ] - - -class AudioLog(models.Model): - """Record of user listening history.""" - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="audio_logs", - ) - episode = models.ForeignKey( - "episodes.Episode", - on_delete=models.CASCADE, - related_name="audio_logs", - ) - - listened = models.DateTimeField() - current_time = models.PositiveIntegerField(default=0) - duration = models.PositiveIntegerField(default=0) - - class Meta: - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s_user_episode", - fields=["user", "episode"], - ), - ] - indexes: ClassVar[list] = [ - models.Index(fields=["user", "episode"]), - models.Index( - fields=["user", "listened"], - include=["episode_id"], - name="%(app_label)s_%(class)s_desc_idx", - ), - models.Index( - fields=["user", "-listened"], - include=["episode_id"], - name="%(app_label)s_%(class)s_asc_idx", - ), - ] - - @cached_property - def percent_complete(self) -> int: - """Returns percentage of episode listened to.""" - if 0 in (self.current_time, self.duration): - return 0 - - return min(100, round((self.current_time / self.duration) * 100)) diff --git a/simplecasts/fields.py b/simplecasts/models/fields.py similarity index 100% rename from simplecasts/fields.py rename to simplecasts/models/fields.py diff --git a/simplecasts/podcasts/models.py b/simplecasts/models/podcasts.py similarity index 77% rename from simplecasts/podcasts/models.py rename to simplecasts/models/podcasts.py index c91fc155a5..6b1cbbaf22 100644 --- a/simplecasts/podcasts/models.py +++ b/simplecasts/models/podcasts.py @@ -13,12 +13,15 @@ from django.utils.functional import cached_property from slugify import slugify -from simplecasts.fields import URLField -from simplecasts.sanitizer import strip_html -from simplecasts.users.models import User +from simplecasts.models.fields import URLField +from simplecasts.models.recommendations import Recommendation +from simplecasts.models.search import SearchQuerySetMixin +from simplecasts.models.users import User +from simplecasts.services.sanitizer import strip_html if TYPE_CHECKING: - from simplecasts.episodes.models import Episode + from simplecasts.models.episodes import Episode + from simplecasts.models.subscriptions import Subscription @dataclasses.dataclass(kw_only=True, frozen=True) @@ -50,37 +53,11 @@ def url(self) -> str: ) -class Category(models.Model): - """iTunes category.""" - - name = models.CharField(max_length=100, unique=True) - slug = models.SlugField(unique=True) - itunes_genre_id = models.PositiveIntegerField(null=True, blank=True) - - if TYPE_CHECKING: - podcasts: "PodcastQuerySet" - - class Meta: - verbose_name_plural = "categories" - ordering = ("name",) - - def __str__(self) -> str: - """Returns category name.""" - return self.name - - def save(self, **kwargs) -> None: - """Overrides save to auto-generate slug.""" - self.slug = slugify(self.name, allow_unicode=False) - super().save(**kwargs) - - def get_absolute_url(self) -> str: - """Absolute URL to a category.""" - return reverse("podcasts:category_detail", kwargs={"slug": self.slug}) - - -class PodcastQuerySet(models.QuerySet): +class PodcastQuerySet(SearchQuerySetMixin, models.QuerySet): """Custom QuerySet of Podcast model.""" + default_search_fields = ("search_document",) + def subscribed(self, user: User) -> Self: """Returns podcasts subscribed by user.""" return self.filter(pk__in=user.subscriptions.values("podcast")) @@ -252,7 +229,7 @@ class FeedStatus(models.TextChoices): explicit = models.BooleanField(default=False) categories = models.ManyToManyField( - "podcasts.Category", + "simplecasts.Category", blank=True, related_name="podcasts", ) @@ -263,8 +240,8 @@ class FeedStatus(models.TextChoices): related_name="recommended_podcasts", ) - search_vector = SearchVectorField(null=True, editable=False) - owner_search_vector = SearchVectorField(null=True, editable=False) + search_document = SearchVectorField(null=True, blank=True, editable=False) + owner_search_document = SearchVectorField(null=True, blank=True, editable=False) objects: PodcastQuerySet = PodcastQuerySet.as_manager() # type: ignore[assignment] @@ -299,8 +276,8 @@ class Meta: name="%(app_label)s_%(class)s_public_idx", ), # Search indexes - GinIndex(fields=["search_vector"]), - GinIndex(fields=["owner_search_vector"]), + GinIndex(fields=["search_document"]), + GinIndex(fields=["owner_search_document"]), ] def __str__(self) -> str: @@ -314,7 +291,7 @@ def get_absolute_url(self) -> str: def get_detail_url(self) -> str: """Podcast detail URL""" return reverse( - "podcasts:podcast_detail", + "podcasts:detail", kwargs={ "podcast_id": self.pk, "slug": self.slug, @@ -412,78 +389,3 @@ def is_episodic(self) -> bool: def is_serial(self) -> bool: """Returns true if podcast is serial.""" return self.podcast_type == self.PodcastType.SERIAL - - -class Subscription(models.Model): - """Subscribed podcast belonging to a user's collection.""" - - subscriber = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="subscriptions", - ) - - podcast = models.ForeignKey( - "podcasts.Podcast", - on_delete=models.CASCADE, - related_name="subscriptions", - ) - - created = models.DateTimeField(auto_now_add=True) - - class Meta: - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s_user_podcast", - fields=["subscriber", "podcast"], - ) - ] - indexes: ClassVar[list] = [models.Index(fields=["-created"])] - - -class RecommendationQuerySet(models.QuerySet): - """Custom QuerySet for Recommendation model.""" - - def bulk_delete(self) -> int: - """More efficient quick delete. - - Returns: - number of rows deleted - """ - return self._raw_delete(self.db) - - -class Recommendation(models.Model): - """Recommendation based on similarity between two podcasts.""" - - podcast = models.ForeignKey( - "podcasts.Podcast", - on_delete=models.CASCADE, - related_name="recommendations", - ) - - recommended = models.ForeignKey( - "podcasts.Podcast", - on_delete=models.CASCADE, - related_name="similar", - ) - - score = models.DecimalField( - decimal_places=10, - max_digits=100, - null=True, - blank=True, - ) - - objects: RecommendationQuerySet = RecommendationQuerySet.as_manager() # type: ignore[assignment] - - class Meta: - indexes: ClassVar[list] = [ - models.Index(fields=["-score"]), - ] - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s", - fields=["podcast", "recommended"], - ), - ] diff --git a/simplecasts/models/recommendations.py b/simplecasts/models/recommendations.py new file mode 100644 index 0000000000..1fbda6e913 --- /dev/null +++ b/simplecasts/models/recommendations.py @@ -0,0 +1,51 @@ +from typing import ClassVar + +from django.db import models + + +class RecommendationQuerySet(models.QuerySet): + """Custom QuerySet for Recommendation model.""" + + def bulk_delete(self) -> int: + """More efficient quick delete. + + Returns: + number of rows deleted + """ + return self._raw_delete(self.db) + + +class Recommendation(models.Model): + """Recommendation based on similarity between two podcasts.""" + + podcast = models.ForeignKey( + "simplecasts.Podcast", + on_delete=models.CASCADE, + related_name="recommendations", + ) + + recommended = models.ForeignKey( + "simplecasts.Podcast", + on_delete=models.CASCADE, + related_name="similar", + ) + + score = models.DecimalField( + decimal_places=10, + max_digits=100, + null=True, + blank=True, + ) + + objects: RecommendationQuerySet = RecommendationQuerySet.as_manager() # type: ignore[assignment] + + class Meta: + indexes: ClassVar[list] = [ + models.Index(fields=["-score"]), + ] + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s", + fields=["podcast", "recommended"], + ), + ] diff --git a/simplecasts/models/search.py b/simplecasts/models/search.py new file mode 100644 index 0000000000..69ca9abd2e --- /dev/null +++ b/simplecasts/models/search.py @@ -0,0 +1,60 @@ +import functools +import operator +from typing import TYPE_CHECKING, Self + +from django.contrib.postgres.search import SearchQuery, SearchRank +from django.db.models import F, Q, QuerySet + +if TYPE_CHECKING: + BaseQuerySet = QuerySet +else: + BaseQuerySet = object + + +class SearchQuerySetMixin(BaseQuerySet): + """A queryset mixin that adds full-text search capabilities.""" + + default_search_fields: tuple[str, ...] = () + + def search( + self, + value: str, + *search_fields: str, + annotation: str = "rank", + config: str = "simple", + search_type: str = "websearch", + ) -> Self: + """Search queryset using full-text search.""" + if not value: + return self.none() + + search_fields = search_fields or self.default_search_fields + + query = SearchQuery( + value, + search_type=search_type, + config=config, + ) + + rank = functools.reduce( + operator.add, + ( + SearchRank( + F(field), + query=query, + ) + for field in search_fields + ), + ) + + q = functools.reduce( + operator.or_, + ( + Q( + **{field: query}, + ) + for field in search_fields + ), + ) + + return self.annotate(**{annotation: rank}).filter(q) diff --git a/simplecasts/models/subscriptions.py b/simplecasts/models/subscriptions.py new file mode 100644 index 0000000000..8d8caa4512 --- /dev/null +++ b/simplecasts/models/subscriptions.py @@ -0,0 +1,31 @@ +from typing import ClassVar + +from django.conf import settings +from django.db import models + + +class Subscription(models.Model): + """Subscribed podcast belonging to a user's collection.""" + + subscriber = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="subscriptions", + ) + + podcast = models.ForeignKey( + "simplecasts.Podcast", + on_delete=models.CASCADE, + related_name="subscriptions", + ) + + created = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s_user_podcast", + fields=["subscriber", "podcast"], + ) + ] + indexes: ClassVar[list] = [models.Index(fields=["-created"])] diff --git a/simplecasts/users/models.py b/simplecasts/models/users.py similarity index 61% rename from simplecasts/users/models.py rename to simplecasts/models/users.py index 6a0499de82..df091bb025 100644 --- a/simplecasts/users/models.py +++ b/simplecasts/models/users.py @@ -4,8 +4,10 @@ from django.db import models if TYPE_CHECKING: - from simplecasts.episodes.models import AudioLog, Bookmark - from simplecasts.podcasts.models import PodcastQuerySet, Subscription + from simplecasts.models.audio_logs import AudioLogQuerySet + from simplecasts.models.bookmarks import BookmarkQuerySet + from simplecasts.models.podcasts import PodcastQuerySet + from simplecasts.models.subscriptions import Subscription class User(AbstractUser): @@ -14,10 +16,9 @@ class User(AbstractUser): send_email_notifications = models.BooleanField(default=True) if TYPE_CHECKING: + audio_logs: AudioLogQuerySet + bookmarks: BookmarkQuerySet recommended_podcasts: PodcastQuerySet - - audio_logs: models.Manager[AudioLog] - bookmarks: models.Manager[Bookmark] subscriptions: models.Manager[Subscription] @property diff --git a/simplecasts/podcasts/forms.py b/simplecasts/podcasts/forms.py deleted file mode 100644 index 6387600fb0..0000000000 --- a/simplecasts/podcasts/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import ClassVar - -from django import forms - -from simplecasts.podcasts.models import Podcast - - -class PodcastForm(forms.ModelForm): - """Form to add a new podcast feed.""" - - class Meta: - model = Podcast - fields: ClassVar[list] = ["rss"] - labels: ClassVar[dict] = {"rss": "RSS Feed URL"} - error_messages: ClassVar[dict] = { - "rss": {"unique": "This podcast is not available"} - } diff --git a/simplecasts/podcasts/migrations/0001_initial.py b/simplecasts/podcasts/migrations/0001_initial.py deleted file mode 100644 index 5235b41090..0000000000 --- a/simplecasts/podcasts/migrations/0001_initial.py +++ /dev/null @@ -1,314 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:03 - -import datetime - -import django.contrib.postgres.indexes -import django.contrib.postgres.search -import django.core.validators -import django.db.models.deletion -import django.db.models.functions.text -import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Category", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, unique=True)), - ( - "parent", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="children", - to="podcasts.category", - ), - ), - ], - options={ - "verbose_name_plural": "categories", - "ordering": ("name",), - }, - ), - migrations.CreateModel( - name="Podcast", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("rss", models.URLField(max_length=500, unique=True)), - ( - "active", - models.BooleanField( - default=True, - help_text="Inactive podcasts will no longer be updated from their RSS feeds.", - ), - ), - ( - "private", - models.BooleanField( - default=False, help_text="Only available to subscribers" - ), - ), - ("etag", models.TextField(blank=True)), - ("title", models.TextField(blank=True)), - ("pub_date", models.DateTimeField(blank=True, null=True)), - ("parsed", models.DateTimeField(blank=True, null=True)), - ( - "parser_error", - models.CharField( - blank=True, - choices=[ - ("duplicate", "Duplicate"), - ("inaccessible", "Inaccessible"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ], - max_length=30, - null=True, - ), - ), - ("frequency", models.DurationField(default=datetime.timedelta(days=1))), - ("modified", models.DateTimeField(blank=True, null=True)), - ( - "content_hash", - models.CharField(blank=True, max_length=64, null=True), - ), - ("num_retries", models.PositiveSmallIntegerField(default=0)), - ("cover_url", models.URLField(blank=True, max_length=2083, null=True)), - ( - "funding_url", - models.URLField(blank=True, max_length=2083, null=True), - ), - ("funding_text", models.TextField(blank=True)), - ( - "language", - models.CharField( - default="en", - max_length=2, - validators=[django.core.validators.MinLengthValidator(2)], - ), - ), - ("description", models.TextField(blank=True)), - ("website", models.URLField(blank=True, max_length=2083, null=True)), - ("keywords", models.TextField(blank=True)), - ("extracted_text", models.TextField(blank=True)), - ("owner", models.TextField(blank=True)), - ("created", models.DateTimeField(auto_now_add=True)), - ( - "updated", - models.DateTimeField( - auto_now=True, verbose_name="Podcast Updated in Database" - ), - ), - ("explicit", models.BooleanField(default=False)), - ("promoted", models.BooleanField(default=False)), - ( - "search_vector", - django.contrib.postgres.search.SearchVectorField( - editable=False, null=True - ), - ), - ( - "categories", - models.ManyToManyField( - blank=True, related_name="podcasts", to="podcasts.category" - ), - ), - ( - "recipients", - models.ManyToManyField( - blank=True, - related_name="recommended_podcasts", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="Recommendation", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("frequency", models.PositiveIntegerField(default=0)), - ( - "similarity", - models.DecimalField( - blank=True, decimal_places=10, max_digits=100, null=True - ), - ), - ( - "podcast", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="recommendations", - to="podcasts.podcast", - ), - ), - ( - "recommended", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="similar", - to="podcasts.podcast", - ), - ), - ], - ), - migrations.CreateModel( - name="Subscription", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "podcast", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="subscriptions", - to="podcasts.podcast", - ), - ), - ( - "subscriber", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="subscriptions", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "indexes": [ - models.Index( - fields=["-created"], name="podcasts_su_created_55323d_idx" - ) - ], - }, - ), - migrations.AddConstraint( - model_name="subscription", - constraint=models.UniqueConstraint( - fields=("subscriber", "podcast"), - name="unique_podcasts_subscription_user_podcast", - ), - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index( - fields=["podcast"], name="podcasts_re_podcast_10c46d_idx" - ), - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index( - fields=["recommended"], name="podcasts_re_recomme_244ce9_idx" - ), - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index( - fields=["-similarity", "-frequency"], - name="podcasts_re_similar_3e4170_idx", - ), - ), - migrations.AddConstraint( - model_name="recommendation", - constraint=models.UniqueConstraint( - fields=("podcast", "recommended"), name="unique_podcasts_recommendation" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-pub_date"], name="podcasts_po_pub_dat_850a22_idx" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["pub_date"], name="podcasts_po_pub_dat_2e433a_idx" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted"], name="podcasts_po_promote_fdc955_idx" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["content_hash"], name="podcasts_po_content_736948_idx" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - django.db.models.functions.text.Lower("title"), - name="podcasts_podcast_lwr_title_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=django.contrib.postgres.indexes.GinIndex( - fields=["search_vector"], name="podcasts_po_search__4c951f_gin" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0002_add_podcast_search_trigger.py b/simplecasts/podcasts/migrations/0002_add_podcast_search_trigger.py deleted file mode 100644 index 21b1519772..0000000000 --- a/simplecasts/podcasts/migrations/0002_add_podcast_search_trigger.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:22 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0001_initial"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector ON podcasts_podcast -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', title, owner, keywords); -UPDATE podcasts_podcast SET search_vector = NULL;""", - reverse_sql="DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast;", - ), - ] diff --git a/simplecasts/podcasts/migrations/0003_categories.py b/simplecasts/podcasts/migrations/0003_categories.py deleted file mode 100644 index 4eefe3aa37..0000000000 --- a/simplecasts/podcasts/migrations/0003_categories.py +++ /dev/null @@ -1,132 +0,0 @@ -# Generated by Django 5.0.4 on 2024-04-26 11:54 - -from django.db import migrations - -_CATEGORIES = [ - "After Shows", - "Alternative Health", - "Animation & Manga", - "Arts", - "Astronomy", - "Automotive", - "Aviation", - "Baseball", - "Basketball", - "Books", - "Buddhism", - "Business", - "Business News", - "Careers", - "Chemistry", - "Christianity", - "Comedy", - "Comedy Fiction", - "Comedy Interviews", - "Courses", - "Crafts", - "Cricket", - "Daily News", - "Design", - "Documentary", - "Drama", - "Earth Sciences", - "Education", - "Education for Kids", - "Entertainment News", - "Entrepreneurship", - "Fantasy Sports", - "Fashion & Beauty", - "Fiction", - "Film History", - "Film Interviews", - "Film Reviews", - "Fitness", - "Food", - "Football", - "Games", - "Golf", - "Government", - "Health & Fitness", - "Hinduism", - "History", - "Hobbies", - "Hockey", - "Home & Garden", - "How-To", - "Improv", - "Investing", - "Islam", - "Judaism", - "Kids & Family", - "Language Learning", - "Leisure", - "Life Sciences", - "Management", - "Marketing", - "Mathematics", - "Medicine", - "Mental Health", - "Music", - "Music Commentary", - "Music History", - "Music Interviews", - "Natural Sciences", - "Nature", - "News", - "News Commentary", - "Non-Profit", - "Nutrition", - "Parenting", - "Performing Arts", - "Personal Journals", - "Pets & Animals", - "Philosophy", - "Physics", - "Places & Travel", - "Politics", - "Relationships", - "Religion", - "Religion & Spirituality", - "Rugby", - "Running", - "Science", - "Science Fiction", - "Self-Improvement", - "Sexuality", - "Soccer", - "Social Sciences", - "Society & Culture", - "Spirituality", - "Sports", - "Sports News", - "Stand Up", - "Stories for Kids", - "Swimming", - "Tech News", - "Technology", - "Tennis", - "True Crime", - "TV & Film", - "TV Reviews", - "Video Games", - "Visual Arts", - "Volleyball", - "Wilderness", - "Wrestling", -] - - -def _add_categories(apps, schema_editor): - Category = apps.get_model("podcasts.Category") - categories = [Category(name=name) for name in _CATEGORIES] - Category.objects.bulk_create(categories, ignore_conflicts=True) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0002_add_podcast_search_trigger"), - ] - - operations = [ - migrations.RunPython(_add_categories, reverse_code=migrations.RunPython.noop) - ] diff --git a/simplecasts/podcasts/migrations/0004_alter_podcast_frequency.py b/simplecasts/podcasts/migrations/0004_alter_podcast_frequency.py deleted file mode 100644 index 0f4cbc634c..0000000000 --- a/simplecasts/podcasts/migrations/0004_alter_podcast_frequency.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.6 on 2024-05-14 08:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0003_categories"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="frequency", - field=models.DurationField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more.py b/simplecasts/podcasts/migrations/0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more.py deleted file mode 100644 index 17c81d1ac5..0000000000 --- a/simplecasts/podcasts/migrations/0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-14 11:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0004_alter_podcast_frequency"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="content_hash", - field=models.CharField(blank=True, default="", max_length=64), - preserve_default=False, - ), - migrations.AlterField( - model_name="podcast", - name="cover_url", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - migrations.AlterField( - model_name="podcast", - name="funding_url", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - migrations.AlterField( - model_name="podcast", - name="parser_error", - field=models.CharField( - blank=True, - choices=[ - ("duplicate", "Duplicate"), - ("inaccessible", "Inaccessible"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ], - default="", - max_length=30, - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="podcast", - name="website", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - ] diff --git a/simplecasts/podcasts/migrations/0006_remove_subscription_modified_and_more.py b/simplecasts/podcasts/migrations/0006_remove_subscription_modified_and_more.py deleted file mode 100644 index f1e25a43b3..0000000000 --- a/simplecasts/podcasts/migrations/0006_remove_subscription_modified_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-15 08:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "podcasts", - "0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more", - ), - ] - - operations = [ - migrations.RemoveField( - model_name="subscription", - name="modified", - ), - migrations.AlterField( - model_name="subscription", - name="created", - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0007_alter_podcast_frequency.py b/simplecasts/podcasts/migrations/0007_alter_podcast_frequency.py deleted file mode 100644 index 12f7f539ae..0000000000 --- a/simplecasts/podcasts/migrations/0007_alter_podcast_frequency.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-02 22:00 - -import datetime - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0006_remove_subscription_modified_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="frequency", - field=models.DurationField(default=datetime.timedelta(days=1)), - ), - ] diff --git a/simplecasts/podcasts/migrations/0008_recommendation_score.py b/simplecasts/podcasts/migrations/0008_recommendation_score.py deleted file mode 100644 index 8c23f09104..0000000000 --- a/simplecasts/podcasts/migrations/0008_recommendation_score.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.4 on 2025-01-02 09:51 - -import django.db.models.expressions -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0007_alter_podcast_frequency"), - ] - - operations = [ - migrations.AddField( - model_name="recommendation", - name="score", - field=models.GeneratedField( - db_persist=True, - expression=django.db.models.expressions.CombinedExpression( - models.F("frequency"), "*", models.F("similarity") - ), - output_field=models.DecimalField(decimal_places=10, max_digits=100), - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more.py b/simplecasts/podcasts/migrations/0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more.py deleted file mode 100644 index 1a8c39a115..0000000000 --- a/simplecasts/podcasts/migrations/0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.4 on 2025-01-02 10:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0008_recommendation_score"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="recommendation", - name="podcasts_re_similar_3e4170_idx", - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index(fields=["-score"], name="podcasts_re_score_c89df8_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0010_podcast_itunes_ranking.py b/simplecasts/podcasts/migrations/0010_podcast_itunes_ranking.py deleted file mode 100644 index 6c66994a7d..0000000000 --- a/simplecasts/podcasts/migrations/0010_podcast_itunes_ranking.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-15 13:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "podcasts", - "0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more", - ), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="itunes_ranking", - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0011_podcast_podcasts_po_itunes__8b4558_idx.py b/simplecasts/podcasts/migrations/0011_podcast_podcasts_po_itunes__8b4558_idx.py deleted file mode 100644 index f37f1c29b3..0000000000 --- a/simplecasts/podcasts/migrations/0011_podcast_podcasts_po_itunes__8b4558_idx.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-15 13:25 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0010_podcast_itunes_ranking"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["itunes_ranking"], name="podcasts_po_itunes__8b4558_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more.py b/simplecasts/podcasts/migrations/0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more.py deleted file mode 100644 index ec90532c3a..0000000000 --- a/simplecasts/podcasts/migrations/0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-15 23:21 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0011_podcast_podcasts_po_itunes__8b4558_idx"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_itunes__8b4558_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="itunes_ranking", - ), - ] diff --git a/simplecasts/podcasts/migrations/0013_podcast_podcast_type.py b/simplecasts/podcasts/migrations/0013_podcast_podcast_type.py deleted file mode 100644 index 1162d448db..0000000000 --- a/simplecasts/podcasts/migrations/0013_podcast_podcast_type.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-26 12:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="podcast_type", - field=models.CharField( - choices=[("episodic", "Episodic"), ("serial", "Serial")], - default="episodic", - max_length=10, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0014_podcast_rating.py b/simplecasts/podcasts/migrations/0014_podcast_rating.py deleted file mode 100644 index 89d4a5bb89..0000000000 --- a/simplecasts/podcasts/migrations/0014_podcast_rating.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 11:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0013_podcast_podcast_type"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="rating", - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0015_podcast_podcasts_po_rating_f96c31_idx.py b/simplecasts/podcasts/migrations/0015_podcast_podcasts_po_rating_f96c31_idx.py deleted file mode 100644 index 539978ea48..0000000000 --- a/simplecasts/podcasts/migrations/0015_podcast_podcasts_po_rating_f96c31_idx.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 11:48 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0014_podcast_rating"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index(fields=["rating"], name="podcasts_po_rating_f96c31_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py b/simplecasts/podcasts/migrations/0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py deleted file mode 100644 index 3e3e9d5f04..0000000000 --- a/simplecasts/podcasts/migrations/0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 12:05 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0015_podcast_podcasts_po_rating_f96c31_idx"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_fdc955_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="promoted", - ), - ] diff --git a/simplecasts/podcasts/migrations/0017_podcast_promoted_and_more.py b/simplecasts/podcasts/migrations/0017_podcast_promoted_and_more.py deleted file mode 100644 index 4cf4f094d4..0000000000 --- a/simplecasts/podcasts/migrations/0017_podcast_promoted_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 13:09 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="promoted", - field=models.BooleanField(default=False), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted"], name="podcasts_po_promote_fdc955_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0018_set_default_promoted.py b/simplecasts/podcasts/migrations/0018_set_default_promoted.py deleted file mode 100644 index c272671040..0000000000 --- a/simplecasts/podcasts/migrations/0018_set_default_promoted.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 13:10 - -from django.db import migrations - - -def _set_default_promoted(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(rating__isnull=False).update(promoted=True) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0017_podcast_promoted_and_more"), - ] - - operations = [ - migrations.RunPython( - _set_default_promoted, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/simplecasts/podcasts/migrations/0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more.py b/simplecasts/podcasts/migrations/0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more.py deleted file mode 100644 index d72d7aba10..0000000000 --- a/simplecasts/podcasts/migrations/0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-10 13:15 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0018_set_default_promoted"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_rating_f96c31_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="rating", - ), - ] diff --git a/simplecasts/podcasts/migrations/0020_podcast_canonical.py b/simplecasts/podcasts/migrations/0020_podcast_canonical.py deleted file mode 100644 index 9aebc911e7..0000000000 --- a/simplecasts/podcasts/migrations/0020_podcast_canonical.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-10 14:09 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="canonical", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="duplicates", - to="podcasts.podcast", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0021_clear_duplicates.py b/simplecasts/podcasts/migrations/0021_clear_duplicates.py deleted file mode 100644 index 59e677de64..0000000000 --- a/simplecasts/podcasts/migrations/0021_clear_duplicates.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-10 14:29 - -from django.db import migrations - - -def _clear_duplicates(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(parser_error="duplicate").update( - active=True, - parser_error="", - num_retries=0, - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0020_podcast_canonical"), - ] - - operations = [ - migrations.RunPython( - _clear_duplicates, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0022_alter_podcast_parser_error.py b/simplecasts/podcasts/migrations/0022_alter_podcast_parser_error.py deleted file mode 100644 index 1a9d66a279..0000000000 --- a/simplecasts/podcasts/migrations/0022_alter_podcast_parser_error.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 11:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0021_clear_duplicates"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="parser_error", - field=models.CharField( - blank=True, - choices=[ - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ("inaccessible", "Inaccessible"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0023_move_inaccessible_podcasts_to_unavailable.py b/simplecasts/podcasts/migrations/0023_move_inaccessible_podcasts_to_unavailable.py deleted file mode 100644 index b61149e799..0000000000 --- a/simplecasts/podcasts/migrations/0023_move_inaccessible_podcasts_to_unavailable.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 11:40 - -from django.db import migrations - - -def _move_inaccessible_podcasts_to_unavailable(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - Podcast.objects.filter(parser_error="inaccessible").update( - parser_error="unavailable" - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0022_alter_podcast_parser_error"), - ] - - operations = [ - migrations.RunPython( - _move_inaccessible_podcasts_to_unavailable, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0024_alter_podcast_parser_error.py b/simplecasts/podcasts/migrations/0024_alter_podcast_parser_error.py deleted file mode 100644 index 15c08eed03..0000000000 --- a/simplecasts/podcasts/migrations/0024_alter_podcast_parser_error.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 11:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0023_move_inaccessible_podcasts_to_unavailable"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="parser_error", - field=models.CharField( - blank=True, - choices=[ - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0025_podcast_complete.py b/simplecasts/podcasts/migrations/0025_podcast_complete.py deleted file mode 100644 index b57e2ca58b..0000000000 --- a/simplecasts/podcasts/migrations/0025_podcast_complete.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 12:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0024_alter_podcast_parser_error"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="complete", - field=models.BooleanField( - default=False, - help_text="Podcast marked complete, no new episodes will be added.", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0026_mark_podcasts_complete.py b/simplecasts/podcasts/migrations/0026_mark_podcasts_complete.py deleted file mode 100644 index 831836d403..0000000000 --- a/simplecasts/podcasts/migrations/0026_mark_podcasts_complete.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 12:02 - -from django.db import migrations - - -def _mark_podcasts_complete(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - # all podcasts that are not active and have no parser error - # can be assumed to be complete - Podcast.objects.filter( - complete=False, - active=False, - parser_error__isnull=True, - ).update(complete=True) - - -def _unmark_podcasts_complete(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(complete=True).update(complete=False) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0025_podcast_complete"), - ] - - operations = [ - migrations.RunPython( - _mark_podcasts_complete, - reverse_code=_unmark_podcasts_complete, - ) - ] diff --git a/simplecasts/podcasts/migrations/0027_podcast_podcasts_po_active_a4c988_idx.py b/simplecasts/podcasts/migrations/0027_podcast_podcasts_po_active_a4c988_idx.py deleted file mode 100644 index 0637dc6a3a..0000000000 --- a/simplecasts/podcasts/migrations/0027_podcast_podcasts_po_active_a4c988_idx.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 12:35 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0026_mark_podcasts_complete"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index(fields=["active"], name="podcasts_po_active_a4c988_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0028_remove_podcast_complete.py b/simplecasts/podcasts/migrations/0028_remove_podcast_complete.py deleted file mode 100644 index 678f3a4dde..0000000000 --- a/simplecasts/podcasts/migrations/0028_remove_podcast_complete.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 23:37 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0027_podcast_podcasts_po_active_a4c988_idx"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="complete", - ), - ] diff --git a/simplecasts/podcasts/migrations/0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more.py b/simplecasts/podcasts/migrations/0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more.py deleted file mode 100644 index 2e445e1357..0000000000 --- a/simplecasts/podcasts/migrations/0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-21 16:03 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0028_remove_podcast_complete"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="recommendation", - name="podcasts_re_podcast_10c46d_idx", - ), - migrations.RemoveIndex( - model_name="recommendation", - name="podcasts_re_recomme_244ce9_idx", - ), - ] diff --git a/simplecasts/podcasts/migrations/0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py b/simplecasts/podcasts/migrations/0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py deleted file mode 100644 index 0b9a6e75fb..0000000000 --- a/simplecasts/podcasts/migrations/0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 5.2 on 2025-04-03 17:09 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "podcasts", - "0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more", - ), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="cover_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="podcast", - name="funding_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="podcast", - name="rss", - field=models.URLField( - max_length=500, - unique=True, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="podcast", - name="website", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py b/simplecasts/podcasts/migrations/0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py deleted file mode 100644 index 51d28d887e..0000000000 --- a/simplecasts/podcasts/migrations/0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 5.2 on 2025-04-03 17:33 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="cover_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="podcast", - name="funding_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="podcast", - name="rss", - field=models.URLField( - max_length=2083, - unique=True, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="podcast", - name="website", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0032_podcast_queued.py b/simplecasts/podcasts/migrations/0032_podcast_queued.py deleted file mode 100644 index bb0e19016a..0000000000 --- a/simplecasts/podcasts/migrations/0032_podcast_queued.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2 on 2025-04-11 20:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="queued", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0033_remove_podcast_queued.py b/simplecasts/podcasts/migrations/0033_remove_podcast_queued.py deleted file mode 100644 index aaec82f6d5..0000000000 --- a/simplecasts/podcasts/migrations/0033_remove_podcast_queued.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2 on 2025-04-11 22:49 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0032_podcast_queued"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="queued", - ), - ] diff --git a/simplecasts/podcasts/migrations/0034_alter_podcast_updated.py b/simplecasts/podcasts/migrations/0034_alter_podcast_updated.py deleted file mode 100644 index f17a2cfcd0..0000000000 --- a/simplecasts/podcasts/migrations/0034_alter_podcast_updated.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2 on 2025-04-27 19:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0033_remove_podcast_queued"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="updated", - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more.py b/simplecasts/podcasts/migrations/0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more.py deleted file mode 100644 index 5f1b80c61d..0000000000 --- a/simplecasts/podcasts/migrations/0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.1 on 2025-05-27 14:56 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0034_alter_podcast_updated"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="recommendation", - name="podcasts_re_score_c89df8_idx", - ), - migrations.RemoveField( - model_name="recommendation", - name="score", - ), - ] diff --git a/simplecasts/podcasts/migrations/0036_remove_recommendation_frequency_and_more.py b/simplecasts/podcasts/migrations/0036_remove_recommendation_frequency_and_more.py deleted file mode 100644 index b9f95c17f3..0000000000 --- a/simplecasts/podcasts/migrations/0036_remove_recommendation_frequency_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.2.1 on 2025-05-27 14:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "podcasts", - "0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more", - ), - ] - - operations = [ - migrations.RemoveField( - model_name="recommendation", - name="frequency", - ), - migrations.RemoveField( - model_name="recommendation", - name="similarity", - ), - migrations.AddField( - model_name="recommendation", - name="score", - field=models.DecimalField( - blank=True, decimal_places=10, max_digits=100, null=True - ), - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index(fields=["-score"], name="podcasts_re_score_c89df8_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0037_podcast_itunes_ranking.py b/simplecasts/podcasts/migrations/0037_podcast_itunes_ranking.py deleted file mode 100644 index f035687ec3..0000000000 --- a/simplecasts/podcasts/migrations/0037_podcast_itunes_ranking.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-07 13:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0036_remove_recommendation_frequency_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="itunes_ranking", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/simplecasts/podcasts/migrations/0038_alter_podcast_itunes_ranking.py b/simplecasts/podcasts/migrations/0038_alter_podcast_itunes_ranking.py deleted file mode 100644 index 50affdfdcd..0000000000 --- a/simplecasts/podcasts/migrations/0038_alter_podcast_itunes_ranking.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-07 15:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0037_podcast_itunes_ranking"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="itunes_ranking", - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0039_podcast_podcasts_po_itunes__d69e24_idx.py b/simplecasts/podcasts/migrations/0039_podcast_podcasts_po_itunes__d69e24_idx.py deleted file mode 100644 index ea1a416243..0000000000 --- a/simplecasts/podcasts/migrations/0039_podcast_podcasts_po_itunes__d69e24_idx.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-07 15:29 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0038_alter_podcast_itunes_ranking"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["itunes_ranking", "-pub_date"], - name="podcasts_po_itunes__d69e24_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py b/simplecasts/podcasts/migrations/0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py deleted file mode 100644 index a68980b376..0000000000 --- a/simplecasts/podcasts/migrations/0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-07 15:50 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0039_podcast_podcasts_po_itunes__d69e24_idx"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_fdc955_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="promoted", - ), - ] diff --git a/simplecasts/podcasts/migrations/0041_remove_podcast_parser_error_podcast_parser_result.py b/simplecasts/podcasts/migrations/0041_remove_podcast_parser_error_podcast_parser_result.py deleted file mode 100644 index aad45a2e07..0000000000 --- a/simplecasts/podcasts/migrations/0041_remove_podcast_parser_error_podcast_parser_result.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 09:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="parser_error", - ), - migrations.AddField( - model_name="podcast", - name="parser_result", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0042_podcast_podcasts_po_parser__9f31ab_idx.py b/simplecasts/podcasts/migrations/0042_podcast_podcasts_po_parser__9f31ab_idx.py deleted file mode 100644 index 33a9ac30e7..0000000000 --- a/simplecasts/podcasts/migrations/0042_podcast_podcasts_po_parser__9f31ab_idx.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 09:44 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0041_remove_podcast_parser_error_podcast_parser_result"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["parser_result"], name="podcasts_po_parser__9f31ab_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0043_podcast_promoted.py b/simplecasts/podcasts/migrations/0043_podcast_promoted.py deleted file mode 100644 index 25bc05bd8c..0000000000 --- a/simplecasts/podcasts/migrations/0043_podcast_promoted.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 13:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0042_podcast_podcasts_po_parser__9f31ab_idx"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="promoted", - field=models.BooleanField(default=False), - ), - ] diff --git a/simplecasts/podcasts/migrations/0044_promote_itunes_feeds.py b/simplecasts/podcasts/migrations/0044_promote_itunes_feeds.py deleted file mode 100644 index 7659b77fbc..0000000000 --- a/simplecasts/podcasts/migrations/0044_promote_itunes_feeds.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 13:12 - -from django.db import migrations - - -def promote_feeds(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(itunes_ranking__isnull=False).update(promoted=True) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0043_podcast_promoted"), - ] - - operations = [migrations.RunPython(promote_feeds, migrations.RunPython.noop)] diff --git a/simplecasts/podcasts/migrations/0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more.py b/simplecasts/podcasts/migrations/0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more.py deleted file mode 100644 index ee6fc365d3..0000000000 --- a/simplecasts/podcasts/migrations/0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 13:14 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0044_promote_itunes_feeds"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_itunes__d69e24_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="itunes_ranking", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted"], name="podcasts_po_promote_fdc955_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0046_category_podcasts_ca_name_604e91_idx.py b/simplecasts/podcasts/migrations/0046_category_podcasts_ca_name_604e91_idx.py deleted file mode 100644 index a87fb0979f..0000000000 --- a/simplecasts/podcasts/migrations/0046_category_podcasts_ca_name_604e91_idx.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-06 21:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.AddIndex( - model_name="category", - index=models.Index(fields=["name"], name="podcasts_ca_name_604e91_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0047_remove_category_parent.py b/simplecasts/podcasts/migrations/0047_remove_category_parent.py deleted file mode 100644 index 2f8962ba65..0000000000 --- a/simplecasts/podcasts/migrations/0047_remove_category_parent.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-28 13:49 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0046_category_podcasts_ca_name_604e91_idx"), - ] - - operations = [ - migrations.RemoveField( - model_name="category", - name="parent", - ), - ] diff --git a/simplecasts/podcasts/migrations/0048_podcast_num_episodes.py b/simplecasts/podcasts/migrations/0048_podcast_num_episodes.py deleted file mode 100644 index c630ce6424..0000000000 --- a/simplecasts/podcasts/migrations/0048_podcast_num_episodes.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-12 20:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0047_remove_category_parent"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="num_episodes", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/simplecasts/podcasts/migrations/0049_set_podcast_num_episodes.py b/simplecasts/podcasts/migrations/0049_set_podcast_num_episodes.py deleted file mode 100644 index e243187a6c..0000000000 --- a/simplecasts/podcasts/migrations/0049_set_podcast_num_episodes.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-12 21:16 - -import itertools - -from django.db import migrations - - -def set_podcast_num_episodes(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - podcast_ids = Podcast.objects.values_list("id", flat=True) - - for batch in itertools.batched(podcast_ids, 1000, strict=False): - podcasts = Podcast.objects.filter(id__in=batch) - to_update = [] - for podcast in podcasts: - podcast.num_episodes = podcast.episodes.count() - to_update.append(podcast) - Podcast.objects.bulk_update(to_update, ["num_episodes"]) - - -def reverse_set_podcast_num_episodes(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(num_episodes__gt=0).update(num_episodes=0) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0048_podcast_num_episodes"), - ] - - operations = [ - migrations.RunPython( - set_podcast_num_episodes, - reverse_set_podcast_num_episodes, - ), - ] diff --git a/simplecasts/podcasts/migrations/0050_podcast_has_similar_podcasts.py b/simplecasts/podcasts/migrations/0050_podcast_has_similar_podcasts.py deleted file mode 100644 index 45870f4123..0000000000 --- a/simplecasts/podcasts/migrations/0050_podcast_has_similar_podcasts.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-12 21:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0049_set_podcast_num_episodes"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="has_similar_podcasts", - field=models.BooleanField(default=False), - ), - ] diff --git a/simplecasts/podcasts/migrations/0051_podcast_score.py b/simplecasts/podcasts/migrations/0051_podcast_score.py deleted file mode 100644 index 3e2fa8b7fd..0000000000 --- a/simplecasts/podcasts/migrations/0051_podcast_score.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 10:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0050_podcast_has_similar_podcasts"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="score", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/simplecasts/podcasts/migrations/0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py b/simplecasts/podcasts/migrations/0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py deleted file mode 100644 index 51f85995db..0000000000 --- a/simplecasts/podcasts/migrations/0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 10:54 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0051_podcast_score"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_fdc955_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-score", "-pub_date"], name="podcasts_po_score_aeb891_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more.py b/simplecasts/podcasts/migrations/0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more.py deleted file mode 100644 index 4eda81e6cf..0000000000 --- a/simplecasts/podcasts/migrations/0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 17:45 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_score_aeb891_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="score", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted"], name="podcasts_po_promote_fdc955_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0054_category_itunes_genre_id.py b/simplecasts/podcasts/migrations/0054_category_itunes_genre_id.py deleted file mode 100644 index 761a2c34af..0000000000 --- a/simplecasts/podcasts/migrations/0054_category_itunes_genre_id.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 22:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="category", - name="itunes_genre_id", - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0055_itunes_genre_ids.py b/simplecasts/podcasts/migrations/0055_itunes_genre_ids.py deleted file mode 100644 index 7008832bba..0000000000 --- a/simplecasts/podcasts/migrations/0055_itunes_genre_ids.py +++ /dev/null @@ -1,144 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 22:28 - -from django.db import migrations - -_ITUNES_GENRE_IDS = ( - (1301, "Arts"), - (1302, "Personal Journals"), - (1303, "Comedy"), - (1304, "Education"), - (1305, "Kids & Family"), - (1306, "Food"), - (1309, "TV & Film"), - (1310, "Music"), - (1314, "Religion & Spirituality"), - (1318, "Technology"), - (1320, "Places & Travel"), - (1321, "Business"), - (1324, "Society & Culture"), - (1402, "Design"), - (1405, "Performing Arts"), - (1406, "Visual Arts"), - (1410, "Careers"), - (1412, "Investing"), - (1438, "Buddhism"), - (1439, "Christianity"), - (1440, "Islam"), - (1441, "Judaism"), - (1443, "Philosophy"), - (1444, "Spirituality"), - (1459, "Fashion & Beauty"), - (1463, "Hinduism"), - (1482, "Books"), - (1483, "Fiction"), - (1484, "Drama"), - (1485, "Science Fiction"), - (1486, "Comedy Fiction"), - (1487, "History"), - (1488, "True Crime"), - (1489, "News"), - (1490, "Business News"), - (1491, "Management"), - (1492, "Marketing"), - (1493, "Entrepreneurship"), - (1494, "Non-Profit"), - (1495, "Improv"), - (1496, "Comedy Interviews"), - (1497, "Stand Up"), - (1498, "Language Learning"), - (1499, "How-To"), - (1500, "Self-Improvement"), - (1501, "Courses"), - (1502, "Leisure"), - (1503, "Automotive"), - (1504, "Aviation"), - (1505, "Hobbies"), - (1506, "Crafts"), - (1507, "Games"), - (1508, "Home & Garden"), - (1509, "Video Games"), - (1510, "Animation & Manga"), - (1511, "Government"), - (1512, "Health & Fitness"), - (1513, "Alternative Health"), - (1514, "Fitness"), - (1515, "Nutrition"), - (1516, "Sexuality"), - (1517, "Mental Health"), - (1518, "Medicine"), - (1519, "Education for Kids"), - (1520, "Stories for Kids"), - (1521, "Parenting"), - (1522, "Pets & Animals"), - (1523, "Music Commentary"), - (1524, "Music History"), - (1525, "Music Interviews"), - (1526, "Daily News"), - (1527, "Politics"), - (1528, "Tech News"), - (1529, "Sports News"), - (1530, "News Commentary"), - (1531, "Entertainment News"), - (1532, "Religion"), - (1533, "Science"), - (1534, "Natural Sciences"), - (1535, "Social Sciences"), - (1536, "Mathematics"), - (1537, "Nature"), - (1538, "Astronomy"), - (1539, "Chemistry"), - (1540, "Earth Sciences"), - (1541, "Life Sciences"), - (1542, "Physics"), - (1543, "Documentary"), - (1544, "Relationships"), - (1545, "Sports"), - (1546, "Soccer"), - (1547, "Football"), - (1548, "Basketball"), - (1549, "Baseball"), - (1550, "Hockey"), - (1551, "Running"), - (1552, "Rugby"), - (1553, "Golf"), - (1554, "Cricket"), - (1555, "Wrestling"), - (1556, "Tennis"), - (1557, "Volleyball"), - (1558, "Swimming"), - (1559, "Wilderness"), - (1560, "Fantasy Sports"), - (1561, "TV Reviews"), - (1562, "After Shows"), - (1563, "Film Reviews"), - (1564, "Film History"), - (1565, "Film Interviews"), -) - - -def set_itunes_genre_ids(apps, schema_editor): - Category = apps.get_model("podcasts", "Category") - - categories_dct = Category.objects.select_for_update().in_bulk(field_name="name") - - for_update = [] - - for genre_id, genre_name in _ITUNES_GENRE_IDS: - if category := categories_dct.get(genre_name): - category.itunes_genre_id = genre_id - for_update.append(category) - - Category.objects.bulk_update(for_update, ["itunes_genre_id"]) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0054_category_itunes_genre_id"), - ] - - operations = [ - migrations.RunPython( - set_itunes_genre_ids, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0056_category_slug.py b/simplecasts/podcasts/migrations/0056_category_slug.py deleted file mode 100644 index 3e5d24134e..0000000000 --- a/simplecasts/podcasts/migrations/0056_category_slug.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-17 16:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0055_itunes_genre_ids"), - ] - - operations = [ - migrations.AddField( - model_name="category", - name="slug", - field=models.SlugField(blank=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0057_set_category_slugs.py b/simplecasts/podcasts/migrations/0057_set_category_slugs.py deleted file mode 100644 index 48ecd58c64..0000000000 --- a/simplecasts/podcasts/migrations/0057_set_category_slugs.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-17 16:15 - -from django.db import migrations -from django.utils.text import slugify - - -def set_podcast_slugs(apps, schema_editor): - Category = apps.get_model("podcasts", "Category") - to_update = [] - for category in Category.objects.all(): - if not category.slug: - category.slug = slugify(category.name) - to_update.append(category) - - Category.objects.bulk_update(to_update, ["slug"]) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0056_category_slug"), - ] - - operations = [ - migrations.RunPython( - set_podcast_slugs, - migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0058_alter_category_slug.py b/simplecasts/podcasts/migrations/0058_alter_category_slug.py deleted file mode 100644 index c04010e63b..0000000000 --- a/simplecasts/podcasts/migrations/0058_alter_category_slug.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-17 16:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0057_set_category_slugs"), - ] - - operations = [ - migrations.AlterField( - model_name="category", - name="slug", - field=models.SlugField(unique=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0059_remove_podcast_keywords.py b/simplecasts/podcasts/migrations/0059_remove_podcast_keywords.py deleted file mode 100644 index dc8b63c453..0000000000 --- a/simplecasts/podcasts/migrations/0059_remove_podcast_keywords.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 14:16 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0058_alter_category_slug"), - ] - - operations = [ - migrations.RunSQL( - """ -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner -);""", - reverse_sql=""" -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner, keywords -); -""", - ), - migrations.RemoveField( - model_name="podcast", - name="keywords", - ), - ] diff --git a/simplecasts/podcasts/migrations/0060_podcast_keywords.py b/simplecasts/podcasts/migrations/0060_podcast_keywords.py deleted file mode 100644 index 7d792f784c..0000000000 --- a/simplecasts/podcasts/migrations/0060_podcast_keywords.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 17:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0059_remove_podcast_keywords"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="keywords", - field=models.TextField(blank=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0061_update_podcast_search_trigger.py b/simplecasts/podcasts/migrations/0061_update_podcast_search_trigger.py deleted file mode 100644 index c164411cdd..0000000000 --- a/simplecasts/podcasts/migrations/0061_update_podcast_search_trigger.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 17:29 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0060_podcast_keywords"), - ] - - operations = [ - migrations.RunSQL( - """ -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner, keywords -);""", - reverse_sql=""" -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner -); -""", - ) - ] diff --git a/simplecasts/podcasts/migrations/0062_remove_podcast_podcasts_podcast_lwr_title_idx.py b/simplecasts/podcasts/migrations/0062_remove_podcast_podcasts_podcast_lwr_title_idx.py deleted file mode 100644 index 8e194c3936..0000000000 --- a/simplecasts/podcasts/migrations/0062_remove_podcast_podcasts_podcast_lwr_title_idx.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-22 21:53 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0061_update_podcast_search_trigger"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_podcast_lwr_title_idx", - ), - ] diff --git a/simplecasts/podcasts/migrations/0063_update_podcast_search_trigger_with_simple.py b/simplecasts/podcasts/migrations/0063_update_podcast_search_trigger_with_simple.py deleted file mode 100644 index 66315d1c01..0000000000 --- a/simplecasts/podcasts/migrations/0063_update_podcast_search_trigger_with_simple.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-22 22:08 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0062_remove_podcast_podcasts_podcast_lwr_title_idx"), - ] - - operations = [ - migrations.RunSQL( - """ -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.simple', - title, owner, keywords -);""", - reverse_sql=""" -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner, keywords -); -""", - ) - ] diff --git a/simplecasts/podcasts/migrations/0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py b/simplecasts/podcasts/migrations/0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py deleted file mode 100644 index 0df9cfc0fb..0000000000 --- a/simplecasts/podcasts/migrations/0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 10:11 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0063_update_podcast_search_trigger_with_simple"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_fdc955_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted", "language", "-pub_date"], - name="podcasts_po_promote_54fdb6_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted", "parsed", "updated"], - name="podcasts_po_promote_a89157_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more.py b/simplecasts/podcasts/migrations/0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more.py deleted file mode 100644 index 7854e3fd25..0000000000 --- a/simplecasts/podcasts/migrations/0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 10:23 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_pub_dat_2e433a_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_a4c988_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_parser__9f31ab_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_a89157_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted", "parsed", "updated"], - name="podcasts_po_active_b74445_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - condition=models.Q(("private", False), ("pub_date__isnull", False)), - fields=["-pub_date"], - name="podcasts_podcast_public_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0066_podcast_podcasts_podcast_owner_idx.py b/simplecasts/podcasts/migrations/0066_podcast_podcasts_podcast_owner_idx.py deleted file mode 100644 index 8033640bfc..0000000000 --- a/simplecasts/podcasts/migrations/0066_podcast_podcasts_podcast_owner_idx.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 10:21 - -import django.db.models.functions.text -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index( - django.db.models.functions.text.Lower("owner"), - name="podcasts_podcast_owner_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0067_remove_podcast_podcasts_podcast_owner_idx_and_more.py b/simplecasts/podcasts/migrations/0067_remove_podcast_podcasts_podcast_owner_idx_and_more.py deleted file mode 100644 index 5fecb70b7c..0000000000 --- a/simplecasts/podcasts/migrations/0067_remove_podcast_podcasts_podcast_owner_idx_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 10:51 - -import django.contrib.postgres.indexes -import django.contrib.postgres.search -from django.conf import settings -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0066_podcast_podcasts_podcast_owner_idx"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_podcast_owner_idx", - ), - migrations.AddField( - model_name="podcast", - name="owner_search_vector", - field=django.contrib.postgres.search.SearchVectorField( - editable=False, null=True - ), - ), - migrations.AddIndex( - model_name="podcast", - index=django.contrib.postgres.indexes.GinIndex( - fields=["owner_search_vector"], name="podcasts_po_owner_s_734402_gin" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0068_create_owner_search_trigger.py b/simplecasts/podcasts/migrations/0068_create_owner_search_trigger.py deleted file mode 100644 index d1af3f6b68..0000000000 --- a/simplecasts/podcasts/migrations/0068_create_owner_search_trigger.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 10:51 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0067_remove_podcast_podcasts_podcast_owner_idx_and_more"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -CREATE TRIGGER podcast_update_owner_search_trigger -BEFORE INSERT OR UPDATE OF owner ON podcasts_podcast -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( - owner_search_vector, 'pg_catalog.simple', owner -); -UPDATE podcasts_podcast SET owner = owner;""", - reverse_sql="DROP TRIGGER IF EXISTS podcast_update_owner_search_trigger ON podcasts_podcast;", - ), - ] diff --git a/simplecasts/podcasts/migrations/0069_category_search_vector_and_more.py b/simplecasts/podcasts/migrations/0069_category_search_vector_and_more.py deleted file mode 100644 index 290c3d917f..0000000000 --- a/simplecasts/podcasts/migrations/0069_category_search_vector_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 12:01 - -import django.contrib.postgres.indexes -import django.contrib.postgres.search -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0068_create_owner_search_trigger"), - ] - - operations = [ - migrations.AddField( - model_name="category", - name="search_vector", - field=django.contrib.postgres.search.SearchVectorField( - editable=False, null=True - ), - ), - migrations.AddIndex( - model_name="category", - index=django.contrib.postgres.indexes.GinIndex( - fields=["search_vector"], name="podcasts_ca_search__6e34c5_gin" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0070_create_category_name_search_trigger.py b/simplecasts/podcasts/migrations/0070_create_category_name_search_trigger.py deleted file mode 100644 index 9bca678241..0000000000 --- a/simplecasts/podcasts/migrations/0070_create_category_name_search_trigger.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 12:02 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0069_category_search_vector_and_more"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -CREATE TRIGGER category_update_search_trigger -BEFORE INSERT OR UPDATE OF name ON podcasts_category -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.simple', name -); -UPDATE podcasts_category SET name = name;""", - reverse_sql="DROP TRIGGER IF EXISTS category_update_search_trigger ON podcasts_category;", - ), - ] diff --git a/simplecasts/podcasts/migrations/0071_podcast_queued.py b/simplecasts/podcasts/migrations/0071_podcast_queued.py deleted file mode 100644 index ea83ee904c..0000000000 --- a/simplecasts/podcasts/migrations/0071_podcast_queued.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-08 22:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0070_create_category_name_search_trigger"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="queued", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more.py b/simplecasts/podcasts/migrations/0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more.py deleted file mode 100644 index 796c523c54..0000000000 --- a/simplecasts/podcasts/migrations/0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 11:10 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0071_podcast_queued"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_54fdb6_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_b74445_idx", - ), - migrations.AddField( - model_name="podcast", - name="promoted_at", - field=models.DateField(blank=True, null=True), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted_at", "language", "-pub_date"], - name="podcasts_po_promote_a84cd9_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted_at", "parsed", "updated"], - name="podcasts_po_active_aed488_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0073_set_default_promoted_at.py b/simplecasts/podcasts/migrations/0073_set_default_promoted_at.py deleted file mode 100644 index 79a062da17..0000000000 --- a/simplecasts/podcasts/migrations/0073_set_default_promoted_at.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 11:10 - -from django.db import migrations -from django.utils import timezone - - -def set_default_promoted_at(apps, schema_editor) -> None: - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(promoted=True, promoted_at__isnull=True).update( - promoted_at=timezone.now().today() - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more"), - ] - - operations = [ - migrations.RunPython( - set_default_promoted_at, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more.py b/simplecasts/podcasts/migrations/0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more.py deleted file mode 100644 index 0a0b007423..0000000000 --- a/simplecasts/podcasts/migrations/0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 11:13 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0073_set_default_promoted_at"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_a84cd9_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted_at", "language", "-pub_date"], - name="podcasts_po_promote_ab9069_idx", - ), - ), - migrations.RemoveField( - model_name="podcast", - name="promoted", - ), - ] diff --git a/simplecasts/podcasts/migrations/0075_remove_podcast_podcasts_po_active_aed488_idx_and_more.py b/simplecasts/podcasts/migrations/0075_remove_podcast_podcasts_po_active_aed488_idx_and_more.py deleted file mode 100644 index d235d8eea3..0000000000 --- a/simplecasts/podcasts/migrations/0075_remove_podcast_podcasts_po_active_aed488_idx_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 11:14 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_aed488_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_ab9069_idx", - ), - migrations.RenameField( - model_name="podcast", - old_name="promoted_at", - new_name="promoted", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted", "language", "-pub_date"], - name="podcasts_po_promote_047f56_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted", "parsed", "updated"], - name="podcasts_po_active_b74445_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0076_remove_podcast_queued.py b/simplecasts/podcasts/migrations/0076_remove_podcast_queued.py deleted file mode 100644 index 51cc526f99..0000000000 --- a/simplecasts/podcasts/migrations/0076_remove_podcast_queued.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 16:31 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0075_remove_podcast_podcasts_po_active_aed488_idx_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="queued", - ), - ] diff --git a/simplecasts/podcasts/migrations/0077_podcast_promoted_at.py b/simplecasts/podcasts/migrations/0077_podcast_promoted_at.py deleted file mode 100644 index 73ca964824..0000000000 --- a/simplecasts/podcasts/migrations/0077_podcast_promoted_at.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 16:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0076_remove_podcast_queued"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="promoted_at", - field=models.BooleanField(default=False), - ), - ] diff --git a/simplecasts/podcasts/migrations/0078_set_default_is_promoted.py b/simplecasts/podcasts/migrations/0078_set_default_is_promoted.py deleted file mode 100644 index b26c71b5a1..0000000000 --- a/simplecasts/podcasts/migrations/0078_set_default_is_promoted.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 16:59 - -from django.db import migrations - - -def set_is_promoted(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(promoted__isnull=False).update(promoted_at=True) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0077_podcast_promoted_at"), - ] - - operations = [ - migrations.RunPython(set_is_promoted, reverse_code=migrations.RunPython.noop) - ] diff --git a/simplecasts/podcasts/migrations/0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more.py b/simplecasts/podcasts/migrations/0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more.py deleted file mode 100644 index dbcf836d29..0000000000 --- a/simplecasts/podcasts/migrations/0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 17:05 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0078_set_default_is_promoted"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_047f56_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_b74445_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted_at", "language", "-pub_date"], - name="podcasts_po_promote_ab9069_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted_at", "parsed", "updated"], - name="podcasts_po_active_aed488_idx", - ), - ), - migrations.RemoveField( - model_name="podcast", - name="promoted", - ), - ] diff --git a/simplecasts/podcasts/migrations/0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more.py b/simplecasts/podcasts/migrations/0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more.py deleted file mode 100644 index a4f2a6c247..0000000000 --- a/simplecasts/podcasts/migrations/0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 17:06 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_ab9069_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_aed488_idx", - ), - migrations.RenameField( - model_name="podcast", - old_name="promoted_at", - new_name="promoted", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted", "language", "-pub_date"], - name="podcasts_po_promote_047f56_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted", "parsed", "updated"], - name="podcasts_po_active_b74445_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0081_remove_category_podcasts_ca_name_604e91_idx_and_more.py b/simplecasts/podcasts/migrations/0081_remove_category_podcasts_ca_name_604e91_idx_and_more.py deleted file mode 100644 index bd2ef5f276..0000000000 --- a/simplecasts/podcasts/migrations/0081_remove_category_podcasts_ca_name_604e91_idx_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 19:14 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="category", - name="podcasts_ca_name_604e91_idx", - ), - migrations.RemoveIndex( - model_name="category", - name="podcasts_ca_search__6e34c5_gin", - ), - migrations.RemoveField( - model_name="category", - name="search_vector", - ), - ] diff --git a/simplecasts/podcasts/migrations/0082_remove_category_search_trigger.py b/simplecasts/podcasts/migrations/0082_remove_category_search_trigger.py deleted file mode 100644 index 7c886bc4e3..0000000000 --- a/simplecasts/podcasts/migrations/0082_remove_category_search_trigger.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 19:21 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0081_remove_category_podcasts_ca_name_604e91_idx_and_more"), - ] - - operations = [ - migrations.RunSQL( - "DROP TRIGGER IF EXISTS category_update_search_trigger ON podcasts_category;", - reverse_sql=""" -CREATE TRIGGER category_update_search_trigger -BEFORE INSERT OR UPDATE OF name ON podcasts_category -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.simple', name -); -UPDATE podcasts_category SET name = name;""", - ) - ] diff --git a/simplecasts/podcasts/migrations/0083_remove_podcast_has_similar_podcasts.py b/simplecasts/podcasts/migrations/0083_remove_podcast_has_similar_podcasts.py deleted file mode 100644 index d0bc0367e5..0000000000 --- a/simplecasts/podcasts/migrations/0083_remove_podcast_has_similar_podcasts.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-10 23:01 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0082_remove_category_search_trigger"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="has_similar_podcasts", - ), - ] diff --git a/simplecasts/podcasts/migrations/0084_remove_podcast_num_retries.py b/simplecasts/podcasts/migrations/0084_remove_podcast_num_retries.py deleted file mode 100644 index d9b8ac0782..0000000000 --- a/simplecasts/podcasts/migrations/0084_remove_podcast_num_retries.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-17 20:41 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0083_remove_podcast_has_similar_podcasts"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="num_retries", - ), - ] diff --git a/simplecasts/podcasts/migrations/0085_alter_podcast_parser_result.py b/simplecasts/podcasts/migrations/0085_alter_podcast_parser_result.py deleted file mode 100644 index e3671c885e..0000000000 --- a/simplecasts/podcasts/migrations/0085_alter_podcast_parser_result.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 6.0 on 2025-12-18 10:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0084_remove_podcast_num_retries"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="parser_result", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("invalid_rss", "Invalid RSS"), - ("permanent_network_error", "Permanent Network Error"), - ("temporary_network_error", "Temporary Network Error"), - ("database_error", "Database Error"), - ("invalid_data", "Invalid Data"), - ("unavailable", "Unavailable"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0086_update_parser_results.py b/simplecasts/podcasts/migrations/0086_update_parser_results.py deleted file mode 100644 index 1dc4690201..0000000000 --- a/simplecasts/podcasts/migrations/0086_update_parser_results.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 6.0 on 2025-12-18 10:30 - -from django.db import migrations - - -def update_parser_results(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - Podcast.objects.filter(parser_result="unavailable").update( - parser_result="temporary_network_error" - ) - - Podcast.objects.filter(parser_result="invalid_data").update( - parser_result="database_error" - ) - - -def reverse_parser_results(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - Podcast.objects.filter( - parser_result__in=("temporary_network_error", "permanent_network_error") - ).update(parser_result="unavailable") - - Podcast.objects.filter(parser_result="database_error").update( - parser_result="invalid_data" - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0085_alter_podcast_parser_result"), - ] - - operations = [ - migrations.RunPython(update_parser_results, reverse_parser_results), - ] diff --git a/simplecasts/podcasts/migrations/0087_alter_podcast_parser_result.py b/simplecasts/podcasts/migrations/0087_alter_podcast_parser_result.py deleted file mode 100644 index 8e0edc5911..0000000000 --- a/simplecasts/podcasts/migrations/0087_alter_podcast_parser_result.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 6.0 on 2025-12-18 11:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0086_update_parser_results"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="parser_result", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("invalid_rss", "Invalid RSS"), - ("permanent_network_error", "Permanent Network Error"), - ("temporary_network_error", "Temporary Network Error"), - ("database_error", "Database Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0088_remove_podcast_podcasts_po_content_736948_idx.py b/simplecasts/podcasts/migrations/0088_remove_podcast_podcasts_po_content_736948_idx.py deleted file mode 100644 index 31be8a8ca8..0000000000 --- a/simplecasts/podcasts/migrations/0088_remove_podcast_podcasts_po_content_736948_idx.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-18 16:23 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0087_alter_podcast_parser_result"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_content_736948_idx", - ), - ] diff --git a/simplecasts/podcasts/migrations/0089_remove_podcast_parser_result.py b/simplecasts/podcasts/migrations/0089_remove_podcast_parser_result.py deleted file mode 100644 index 4fa417210c..0000000000 --- a/simplecasts/podcasts/migrations/0089_remove_podcast_parser_result.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-19 11:13 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0088_remove_podcast_podcasts_po_content_736948_idx"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="parser_result", - ), - ] diff --git a/simplecasts/podcasts/migrations/0090_podcast_http_status.py b/simplecasts/podcasts/migrations/0090_podcast_http_status.py deleted file mode 100644 index de4c762ea9..0000000000 --- a/simplecasts/podcasts/migrations/0090_podcast_http_status.py +++ /dev/null @@ -1,84 +0,0 @@ -# Generated by Django 6.0 on 2025-12-19 11:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0089_remove_podcast_parser_result"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="http_status", - field=models.PositiveSmallIntegerField( - blank=True, - choices=[ - (100, "Continue"), - (101, "Switching Protocols"), - (102, "Processing"), - (103, "Early Hints"), - (200, "OK"), - (201, "Created"), - (202, "Accepted"), - (203, "Non-Authoritative Information"), - (204, "No Content"), - (205, "Reset Content"), - (206, "Partial Content"), - (207, "Multi-Status"), - (208, "Already Reported"), - (226, "IM Used"), - (300, "Multiple Choices"), - (301, "Moved Permanently"), - (302, "Found"), - (303, "See Other"), - (304, "Not Modified"), - (305, "Use Proxy"), - (307, "Temporary Redirect"), - (308, "Permanent Redirect"), - (400, "Bad Request"), - (401, "Unauthorized"), - (402, "Payment Required"), - (403, "Forbidden"), - (404, "Not Found"), - (405, "Method Not Allowed"), - (406, "Not Acceptable"), - (407, "Proxy Authentication Required"), - (408, "Request Timeout"), - (409, "Conflict"), - (410, "Gone"), - (411, "Length Required"), - (412, "Precondition Failed"), - (413, "Content Too Large"), - (414, "URI Too Long"), - (415, "Unsupported Media Type"), - (416, "Range Not Satisfiable"), - (417, "Expectation Failed"), - (418, "I'm a Teapot"), - (421, "Misdirected Request"), - (422, "Unprocessable Content"), - (423, "Locked"), - (424, "Failed Dependency"), - (425, "Too Early"), - (426, "Upgrade Required"), - (428, "Precondition Required"), - (429, "Too Many Requests"), - (431, "Request Header Fields Too Large"), - (451, "Unavailable For Legal Reasons"), - (500, "Internal Server Error"), - (501, "Not Implemented"), - (502, "Bad Gateway"), - (503, "Service Unavailable"), - (504, "Gateway Timeout"), - (505, "HTTP Version Not Supported"), - (506, "Variant Also Negotiates"), - (507, "Insufficient Storage"), - (508, "Loop Detected"), - (510, "Not Extended"), - (511, "Network Authentication Required"), - ], - null=True, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0091_podcast_feed_status.py b/simplecasts/podcasts/migrations/0091_podcast_feed_status.py deleted file mode 100644 index 123bb5a5c2..0000000000 --- a/simplecasts/podcasts/migrations/0091_podcast_feed_status.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 6.0 on 2025-12-19 16:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0090_podcast_http_status"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("ok", "OK"), - ("not_modified", "Not Modified"), - ("invalid_rss", "Invalid RSS"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("database_error", "Database Error"), - ("temporary_http_error", "Temporary HTTP Error"), - ("permanent_http_error", "Permanent HTTP Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0092_default_feed_status.py b/simplecasts/podcasts/migrations/0092_default_feed_status.py deleted file mode 100644 index af248cc0cd..0000000000 --- a/simplecasts/podcasts/migrations/0092_default_feed_status.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 6.0 on 2025-12-19 16:13 - -import http - -from django.db import migrations - - -def set_default_feed_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - podcasts = Podcast.objects.filter(feed_status="") - - # Any podcast with a canonical ID is "duplicate" - - podcasts.filter(canonical_id__isnull=False).update(feed_status="duplicate") - - # Any podcast with HTTP 304 status is "not_modified" - - podcasts.filter(http_status=http.HTTPStatus.NOT_MODIFIED).update( - feed_status="not_modified" - ) - - # Any podcast with specific permanent HTTP error statuses is "permanent_http_error" - podcasts.filter( - http_status__in=( - http.HTTPStatus.BAD_REQUEST, - http.HTTPStatus.FORBIDDEN, - http.HTTPStatus.METHOD_NOT_ALLOWED, - http.HTTPStatus.NOT_ACCEPTABLE, - http.HTTPStatus.NOT_FOUND, - http.HTTPStatus.UNAUTHORIZED, - http.HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS, - ) - ).update(feed_status="permanent_http_error") - - # Any podcast with HTTP 410 status is "discontinued" - - podcasts.filter(http_status=http.HTTPStatus.GONE).update(feed_status="discontinued") - - # Any podcast with not OK status (excluding the above) is "temporary_http_error" - # - podcasts.filter(http_status__isnull=False).exclude( - http_status=http.HTTPStatus.OK - ).update(feed_status="temporary_http_error") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0091_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - set_default_feed_status, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0093_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0093_alter_podcast_feed_status.py deleted file mode 100644 index 48d1ae7b8d..0000000000 --- a/simplecasts/podcasts/migrations/0093_alter_podcast_feed_status.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 10:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0092_default_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("ok", "OK"), - ("not_modified", "Not Modified"), - ("invalid_rss", "Invalid RSS"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("database_error", "Database Error"), - ("permanent_http_error", "Permanent HTTP Error"), - ("temporary_http_error", "Temporary HTTP Error"), - ("network_error", "Network Error"), - ("transient_http_error", "Transient HTTP Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0094_update_feed_status.py b/simplecasts/podcasts/migrations/0094_update_feed_status.py deleted file mode 100644 index 07d1830185..0000000000 --- a/simplecasts/podcasts/migrations/0094_update_feed_status.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 10:37 - -from django.db import migrations - - -def update_feed_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - # Any podcast with feed_status 'temporary_http_error' and 'http_status' 'OK' or 'Not Modified' or NULL: - # should have their feed_status updated to 'network_error'. - # - Podcast.objects.filter( - feed_status="temporary_http_error", - ).filter(http_status__in=(200, 304)).update(feed_status="network_error") - - Podcast.objects.filter( - feed_status="temporary_http_error", - ).exclude(http_status__isnull=True).update(feed_status="network_error") - - # Any other podcast with feed_status 'temporary_http_error' should have their feed_status updated to 'transient_feed_error'. - Podcast.objects.filter( - feed_status="temporary_http_error", - ).update(feed_status="transient_feed_error") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0093_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - update_feed_status, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0095_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0095_alter_podcast_feed_status.py deleted file mode 100644 index 0b5ecec0bc..0000000000 --- a/simplecasts/podcasts/migrations/0095_alter_podcast_feed_status.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 10:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0094_update_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("ok", "OK"), - ("not_modified", "Not Modified"), - ("invalid_rss", "Invalid RSS"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("database_error", "Database Error"), - ("transient_http_error", "Transient HTTP Error"), - ("permanent_http_error", "Permanent HTTP Error"), - ("network_error", "Network Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0096_update_feed_status.py b/simplecasts/podcasts/migrations/0096_update_feed_status.py deleted file mode 100644 index 88fca9b06d..0000000000 --- a/simplecasts/podcasts/migrations/0096_update_feed_status.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 14:14 - -from django.db import migrations - - -def update_feed_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter( - feed_status__in=( - "transient_http_error", - "permanent_http_error", - ) - ).update(feed_status="network_error") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0095_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - update_feed_status, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0097_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0097_alter_podcast_feed_status.py deleted file mode 100644 index 9d9f92f3d6..0000000000 --- a/simplecasts/podcasts/migrations/0097_alter_podcast_feed_status.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 14:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0096_update_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("ok", "OK"), - ("not_modified", "Not Modified"), - ("invalid_rss", "Invalid RSS"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("database_error", "Database Error"), - ("network_error", "Network Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0098_remove_podcast_feed_status_and_more.py b/simplecasts/podcasts/migrations/0098_remove_podcast_feed_status_and_more.py deleted file mode 100644 index a88ad8537f..0000000000 --- a/simplecasts/podcasts/migrations/0098_remove_podcast_feed_status_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 14:35 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0097_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="feed_status", - ), - migrations.RemoveField( - model_name="podcast", - name="http_status", - ), - ] diff --git a/simplecasts/podcasts/migrations/0099_podcast_feed_last_updated.py b/simplecasts/podcasts/migrations/0099_podcast_feed_last_updated.py deleted file mode 100644 index f2a4809f7b..0000000000 --- a/simplecasts/podcasts/migrations/0099_podcast_feed_last_updated.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 14:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0098_remove_podcast_feed_status_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="feed_last_updated", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0100_podcast_exception.py b/simplecasts/podcasts/migrations/0100_podcast_exception.py deleted file mode 100644 index 89a77406c7..0000000000 --- a/simplecasts/podcasts/migrations/0100_podcast_exception.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 15:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0099_podcast_feed_last_updated"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="exception", - field=models.TextField(blank=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0101_podcast_traceback_alter_podcast_exception.py b/simplecasts/podcasts/migrations/0101_podcast_traceback_alter_podcast_exception.py deleted file mode 100644 index 539d3a228c..0000000000 --- a/simplecasts/podcasts/migrations/0101_podcast_traceback_alter_podcast_exception.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 17:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0100_podcast_exception"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="traceback", - field=models.TextField(blank=True), - ), - migrations.AlterField( - model_name="podcast", - name="exception", - field=models.CharField(blank=True, max_length=200), - ), - ] diff --git a/simplecasts/podcasts/migrations/0102_remove_podcast_traceback.py b/simplecasts/podcasts/migrations/0102_remove_podcast_traceback.py deleted file mode 100644 index 3eac69fc0a..0000000000 --- a/simplecasts/podcasts/migrations/0102_remove_podcast_traceback.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 20:58 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0101_podcast_traceback_alter_podcast_exception"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="traceback", - ), - ] diff --git a/simplecasts/podcasts/migrations/0103_alter_podcast_exception.py b/simplecasts/podcasts/migrations/0103_alter_podcast_exception.py deleted file mode 100644 index 785e43b2d9..0000000000 --- a/simplecasts/podcasts/migrations/0103_alter_podcast_exception.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 21:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0102_remove_podcast_traceback"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="exception", - field=models.TextField(blank=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0104_podcast_feed_status.py b/simplecasts/podcasts/migrations/0104_podcast_feed_status.py deleted file mode 100644 index 48a67c24c8..0000000000 --- a/simplecasts/podcasts/migrations/0104_podcast_feed_status.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 6.0 on 2025-12-22 11:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0103_alter_podcast_exception"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("error", "Error"), - ], - max_length=20, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0105_update_feed_status.py b/simplecasts/podcasts/migrations/0105_update_feed_status.py deleted file mode 100644 index 08c5c7344c..0000000000 --- a/simplecasts/podcasts/migrations/0105_update_feed_status.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 6.0 on 2025-12-22 11:50 - -from django.db import migrations -from django.db.models import F - - -def update_feed_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - # If exception set to "error" - Podcast.objects.exclude(exception="").update(feed_status="error") - - # If inactive + canonical_id not NULL, set to "duplicate" - Podcast.objects.filter(active=False, canonical__isnull=False).update( - feed_status="duplicate" - ) - - # If inactive + canonical_id is NULL, set to "discontinued" - - Podcast.objects.filter(active=False, canonical__isnull=True).update( - feed_status="discontinued" - ) - - # Any with active=True and exception ="" and parsed == feed_last_updated, set to "success" - Podcast.objects.filter( - active=True, - exception="", - parsed=F("feed_last_updated"), - ).update(feed_status="success") - - # Any with active=True and exception ="" and parsed != feed_last_updated, set to "not_modified" - # - Podcast.objects.filter( - active=True, - exception="", - ).exclude( - parsed=F("feed_last_updated"), - ).update(feed_status="not_modified") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0104_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - update_feed_status, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/simplecasts/podcasts/migrations/0106_podcast_num_retries_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0106_podcast_num_retries_alter_podcast_feed_status.py deleted file mode 100644 index 5849c3fc94..0000000000 --- a/simplecasts/podcasts/migrations/0106_podcast_num_retries_alter_podcast_feed_status.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 6.0 on 2025-12-23 12:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0105_update_feed_status"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="num_retries", - field=models.PositiveIntegerField(default=0), - ), - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_rss", "Invalid RSS"), - ("unavailable", "Unavailable"), - ("error", "Error"), - ], - max_length=20, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0107_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0107_alter_podcast_feed_status.py deleted file mode 100644 index 9423646cc4..0000000000 --- a/simplecasts/podcasts/migrations/0107_alter_podcast_feed_status.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0 on 2025-12-23 21:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0106_podcast_num_retries_alter_podcast_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("error", "Database Error"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_rss", "Invalid RSS"), - ("unavailable", "Unavailable"), - ], - max_length=20, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0108_update_feed_status.py b/simplecasts/podcasts/migrations/0108_update_feed_status.py deleted file mode 100644 index c92806deb7..0000000000 --- a/simplecasts/podcasts/migrations/0108_update_feed_status.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 6.0 on 2025-12-23 21:57 - -from django.db import migrations - - -def update_database_error_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(feed_status="error").update(feed_status="database_error") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0107_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - update_database_error_status, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/simplecasts/podcasts/migrations/0109_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0109_alter_podcast_feed_status.py deleted file mode 100644 index d198fccba6..0000000000 --- a/simplecasts/podcasts/migrations/0109_alter_podcast_feed_status.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0 on 2025-12-23 21:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0108_update_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("database_error", "Database Error"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_rss", "Invalid RSS"), - ("unavailable", "Unavailable"), - ], - max_length=20, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0110_remove_podcast_feed_last_updated.py b/simplecasts/podcasts/migrations/0110_remove_podcast_feed_last_updated.py deleted file mode 100644 index 453f84fd38..0000000000 --- a/simplecasts/podcasts/migrations/0110_remove_podcast_feed_last_updated.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-24 17:23 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0109_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="feed_last_updated", - ), - ] diff --git a/simplecasts/podcasts/migrations/__init__.py b/simplecasts/podcasts/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/podcasts/migrations/max_migration.txt b/simplecasts/podcasts/migrations/max_migration.txt deleted file mode 100644 index b365319b13..0000000000 --- a/simplecasts/podcasts/migrations/max_migration.txt +++ /dev/null @@ -1 +0,0 @@ -0110_remove_podcast_feed_last_updated diff --git a/simplecasts/podcasts/parsers/__init__.py b/simplecasts/podcasts/parsers/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/podcasts/parsers/tests/__init__.py b/simplecasts/podcasts/parsers/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/podcasts/tests/__init__.py b/simplecasts/podcasts/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/podcasts/tests/factories.py b/simplecasts/podcasts/tests/factories.py deleted file mode 100644 index 1ab6b1f793..0000000000 --- a/simplecasts/podcasts/tests/factories.py +++ /dev/null @@ -1,50 +0,0 @@ -import factory -from django.utils import timezone - -from simplecasts.podcasts.models import ( - Category, - Podcast, - Recommendation, - Subscription, -) -from simplecasts.users.tests.factories import UserFactory - - -class CategoryFactory(factory.django.DjangoModelFactory): - name = factory.Sequence(lambda n: f"Category {n}") - - class Meta: - model = Category - - -class PodcastFactory(factory.django.DjangoModelFactory): - title = factory.Faker("text") - rss = factory.Sequence(lambda n: f"https://{n}.example.com") - pub_date = factory.LazyFunction(timezone.now) - cover_url = "https://example.com/cover.jpg" - - class Meta: - model = Podcast - - @factory.post_generation - def categories(self, create, extracted, **kwargs): - if create and extracted: - self.categories.set(extracted) - - -class RecommendationFactory(factory.django.DjangoModelFactory): - score = 0.5 - - podcast = factory.SubFactory(PodcastFactory) - recommended = factory.SubFactory(PodcastFactory) - - class Meta: - model = Recommendation - - -class SubscriptionFactory(factory.django.DjangoModelFactory): - subscriber = factory.SubFactory(UserFactory) - podcast = factory.SubFactory(PodcastFactory) - - class Meta: - model = Subscription diff --git a/simplecasts/podcasts/tests/fixtures.py b/simplecasts/podcasts/tests/fixtures.py deleted file mode 100644 index e7f1e2c98f..0000000000 --- a/simplecasts/podcasts/tests/fixtures.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest - -from simplecasts.podcasts.models import Category, Podcast -from simplecasts.podcasts.tests.factories import ( - CategoryFactory, - PodcastFactory, -) - - -@pytest.fixture -def podcast() -> Podcast: - return PodcastFactory() - - -@pytest.fixture -def category() -> Category: - return CategoryFactory() diff --git a/simplecasts/podcasts/tests/test_commands.py b/simplecasts/podcasts/tests/test_commands.py deleted file mode 100644 index 6a41452e1f..0000000000 --- a/simplecasts/podcasts/tests/test_commands.py +++ /dev/null @@ -1,117 +0,0 @@ -import pytest -from django.core.management import CommandError, call_command - -from simplecasts.podcasts.itunes import Feed, ItunesError -from simplecasts.podcasts.tests.factories import ( - CategoryFactory, - PodcastFactory, - RecommendationFactory, - SubscriptionFactory, -) -from simplecasts.users.tests.factories import EmailAddressFactory - - -class TestParsePodcastFeeds: - parse_feed = ( - "simplecasts.podcasts.management.commands.parse_podcast_feeds.parse_feed" - ) - - @pytest.fixture - def mock_parse(self, mocker): - return mocker.patch(self.parse_feed) - - @pytest.mark.django_db - def test_ok(self, mocker): - mock_parse = mocker.patch(self.parse_feed) - PodcastFactory(pub_date=None) - call_command("parse_podcast_feeds") - mock_parse.assert_called() - - @pytest.mark.django_db - def test_not_scheduled(self, mocker): - mock_parse = mocker.patch(self.parse_feed) - PodcastFactory(active=False) - call_command("parse_podcast_feeds") - mock_parse.assert_not_called() - - -class TestFetchItunesFeeds: - mock_fetch = "simplecasts.podcasts.management.commands.fetch_itunes_feeds.itunes.fetch_top_feeds" - mock_save = "simplecasts.podcasts.management.commands.fetch_itunes_feeds.itunes.save_feeds_to_db" - - @pytest.fixture - def category(self): - return CategoryFactory(itunes_genre_id=1301) - - @pytest.fixture - def feed(self): - return Feed( - artworkUrl100="https://example.com/test.jpg", - collectionName="example", - collectionViewUrl="https://example.com/", - feedUrl="https://example.com/rss/", - ) - - @pytest.mark.django_db - def test_ok(self, category, mocker, feed): - mock_fetch = mocker.patch(self.mock_fetch, return_value=[feed]) - mock_save_feeds = mocker.patch(self.mock_save) - call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) - mock_fetch.assert_called() - mock_save_feeds.assert_any_call([feed], promoted=True) - mock_save_feeds.assert_any_call([feed]) - - @pytest.mark.django_db - def test_invalid_country_codes(self): - with pytest.raises(CommandError): - call_command( - "fetch_itunes_feeds", min_jitter=0, max_jitter=0, countries=["us", "tx"] - ) - - @pytest.mark.django_db - def test_no_chart_feeds(self, category, mocker, feed): - mock_fetch = mocker.patch(self.mock_fetch, return_value=[]) - mock_save_feeds = mocker.patch(self.mock_save) - call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) - mock_fetch.assert_called() - mock_save_feeds.assert_not_called() - - @pytest.mark.django_db - def test_itunes_error(self, mocker): - mock_fetch = mocker.patch( - self.mock_fetch, side_effect=ItunesError("Error fetching iTunes") - ) - mock_save_feeds = mocker.patch(self.mock_save) - call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) - mock_fetch.assert_called() - mock_save_feeds.assert_not_called() - - -class TestCreatePodcastRecommendations: - @pytest.mark.django_db - def test_create_recommendations(self, mocker): - patched = mocker.patch( - "simplecasts.podcasts.recommender.recommend", - return_value=RecommendationFactory.create_batch(3), - ) - call_command("create_podcast_recommendations") - patched.assert_called() - - -class TestSendPodcastRecommendations: - @pytest.fixture - def recipient(self): - return EmailAddressFactory(verified=True, primary=True) - - @pytest.mark.django_db(transaction=True) - def test_ok(self, recipient, mailoutbox): - podcast = SubscriptionFactory(subscriber=recipient.user).podcast - RecommendationFactory(podcast=podcast) - call_command("send_podcast_recommendations") - assert len(mailoutbox) == 1 - - @pytest.mark.django_db(transaction=True) - def test_no_recommendations(self, recipient, mailoutbox): - PodcastFactory() - call_command("send_podcast_recommendations") - assert len(mailoutbox) == 0 diff --git a/simplecasts/podcasts/urls.py b/simplecasts/podcasts/urls.py deleted file mode 100644 index 3d7c39c591..0000000000 --- a/simplecasts/podcasts/urls.py +++ /dev/null @@ -1,86 +0,0 @@ -from django.urls import path, register_converter - -from simplecasts.podcasts import views - -app_name = "podcasts" - - -class _SignedIntConverter: - regex = r"-?\d+" # allow optional leading '-' - - def to_python(self, value: str) -> int: - return int(value) - - def to_url(self, value: int) -> str: - return str(value) - - -register_converter(_SignedIntConverter, "sint") - -urlpatterns = [ - path("subscriptions/", views.subscriptions, name="subscriptions"), - path("discover/", views.discover, name="discover"), - path("search/podcasts/", views.search_podcasts, name="search_podcasts"), - path("search/people/", views.search_people, name="search_people"), - path("search/itunes/", views.search_itunes, name="search_itunes"), - path( - "podcasts/-/", - views.podcast_detail, - name="podcast_detail", - ), - path( - "podcasts/-/episodes/", - views.episodes, - name="episodes", - ), - path( - "podcasts/-/season//", - views.season, - name="season", - ), - path( - "podcasts/-/similar/", - views.similar, - name="similar", - ), - path( - "podcasts//latest-episode/", - views.latest_episode, - name="latest_episode", - ), - path( - "subscribe//", - views.subscribe, - name="subscribe", - ), - path( - "unsubscribe//", - views.unsubscribe, - name="unsubscribe", - ), - path( - "categories/", - views.category_list, - name="category_list", - ), - path( - "categories//", - views.category_detail, - name="category_detail", - ), - path( - "private-feeds/", - views.private_feeds, - name="private_feeds", - ), - path( - "private-feeds/new/", - views.add_private_feed, - name="add_private_feed", - ), - path( - "private-feeds/remove//", - views.remove_private_feed, - name="remove_private_feed", - ), -] diff --git a/simplecasts/podcasts/views.py b/simplecasts/podcasts/views.py deleted file mode 100644 index 53bb028bb8..0000000000 --- a/simplecasts/podcasts/views.py +++ /dev/null @@ -1,422 +0,0 @@ -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.db import IntegrityError -from django.db.models import Exists, OuterRef -from django.http import Http404, HttpResponseRedirect -from django.shortcuts import get_object_or_404, redirect -from django.template.response import TemplateResponse -from django.views.decorators.http import require_POST, require_safe - -from simplecasts.episodes.models import Episode -from simplecasts.http import require_DELETE, require_form_methods -from simplecasts.http_client import get_client -from simplecasts.paginator import render_paginated_response -from simplecasts.partials import render_partial_response -from simplecasts.podcasts import itunes -from simplecasts.podcasts.forms import PodcastForm -from simplecasts.podcasts.models import Category, Podcast, PodcastQuerySet -from simplecasts.request import AuthenticatedHttpRequest, HttpRequest -from simplecasts.response import HttpResponseConflict, RenderOrRedirectResponse -from simplecasts.search import search_queryset - - -@require_safe -@login_required -def subscriptions(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Render podcast index page.""" - podcasts = _get_podcasts().subscribed(request.user).distinct() - - if request.search: - podcasts = search_queryset( - podcasts, - request.search.value, - "search_vector", - ).order_by("-rank", "-pub_date") - else: - podcasts = podcasts.order_by("-pub_date") - - return render_paginated_response(request, "podcasts/subscriptions.html", podcasts) - - -@require_safe -@login_required -def discover(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Shows all promoted podcasts.""" - podcasts = ( - _get_public_podcasts() - .filter( - promoted=True, - language=settings.DISCOVER_FEED_LANGUAGE, - ) - .order_by("-pub_date")[: settings.DEFAULT_PAGE_SIZE] - ) - - return TemplateResponse(request, "podcasts/discover.html", {"podcasts": podcasts}) - - -@require_safe -@login_required -def search_podcasts(request: HttpRequest) -> RenderOrRedirectResponse: - """Search all public podcasts in database. Redirects to discover page if search is empty.""" - - if request.search: - podcasts = search_queryset( - _get_public_podcasts(), - request.search.value, - "search_vector", - ).order_by("-rank", "-pub_date") - - return render_paginated_response( - request, "podcasts/search_podcasts.html", podcasts - ) - - return redirect("podcasts:discover") - - -@require_safe -@login_required -def search_itunes(request: HttpRequest) -> RenderOrRedirectResponse: - """Render iTunes search page. Redirects to discover page if search is empty.""" - - if request.search: - try: - with get_client() as client: - feeds, is_new = itunes.search_cached( - client, - request.search.value, - limit=settings.DEFAULT_PAGE_SIZE, - ) - if is_new: - itunes.save_feeds_to_db(feeds) - return TemplateResponse( - request, - "podcasts/search_itunes.html", - { - "feeds": feeds, - }, - ) - except itunes.ItunesError as exc: - messages.error(request, f"Failed to search iTunes: {exc}") - - return redirect("podcasts:discover") - - -@require_safe -@login_required -def search_people(request: HttpRequest) -> RenderOrRedirectResponse: - """Search all podcasts by owner(s). Redirects to discover page if no owner is given.""" - - if request.search: - podcasts = search_queryset( - _get_public_podcasts(), - request.search.value, - "owner_search_vector", - ).order_by("-rank", "-pub_date") - return render_paginated_response( - request, - "podcasts/search_people.html", - podcasts, - ) - - return redirect("podcasts:discover") - - -@require_safe -@login_required -def podcast_detail( - request: AuthenticatedHttpRequest, - podcast_id: int, - slug: str, -) -> RenderOrRedirectResponse: - """Details for a single podcast.""" - - podcast = get_object_or_404( - _get_podcasts().select_related("canonical"), - pk=podcast_id, - ) - - is_subscribed = request.user.subscriptions.filter(podcast=podcast).exists() - - return TemplateResponse( - request, - "podcasts/detail.html", - { - "podcast": podcast, - "is_subscribed": is_subscribed, - }, - ) - - -@require_safe -@login_required -def latest_episode(_, podcast_id: int) -> HttpResponseRedirect: - """Redirects to latest episode.""" - if ( - episode := Episode.objects.filter(podcast__pk=podcast_id) - .order_by("-pub_date", "-id") - .first() - ): - return redirect(episode) - raise Http404 - - -@require_safe -@login_required -def episodes( - request: HttpRequest, - podcast_id: int, - slug: str | None = None, -) -> TemplateResponse: - """Render episodes for a single podcast.""" - podcast = get_object_or_404(_get_podcasts(), pk=podcast_id) - episodes = podcast.episodes.select_related("podcast") - - default_ordering = "asc" if podcast.is_serial() else "desc" - ordering = request.GET.get("order", default_ordering) - order_by = ("pub_date", "id") if ordering == "asc" else ("-pub_date", "-id") - - if request.search: - episodes = search_queryset( - episodes, - request.search.value, - "search_vector", - ).order_by("-rank", *order_by) - else: - episodes = episodes.order_by(*order_by) - - return render_paginated_response( - request, - "podcasts/episodes.html", - episodes, - { - "podcast": podcast, - "ordering": ordering, - }, - ) - - -@require_safe -@login_required -def season( - request: HttpRequest, - podcast_id: int, - season: int, - slug: str | None = None, -) -> TemplateResponse: - """Render episodes for a podcast season.""" - podcast = get_object_or_404(_get_podcasts(), pk=podcast_id) - - episodes = podcast.episodes.filter(season=season).select_related("podcast") - - order_by = ("pub_date", "id") if podcast.is_serial() else ("-pub_date", "-id") - episodes = episodes.order_by(*order_by) - - return render_paginated_response( - request, - "podcasts/season.html", - episodes, - { - "podcast": podcast, - "season": podcast.get_season(season), - }, - ) - - -@require_safe -@login_required -def similar( - request: HttpRequest, - podcast_id: int, - slug: str | None = None, -) -> TemplateResponse: - """List similar podcasts based on recommendations.""" - - podcast = get_object_or_404(_get_podcasts(), pk=podcast_id) - - recommendations = podcast.recommendations.select_related("recommended").order_by( - "-score" - )[: settings.DEFAULT_PAGE_SIZE] - - return TemplateResponse( - request, - "podcasts/similar.html", - { - "podcast": podcast, - "recommendations": recommendations, - }, - ) - - -@require_safe -@login_required -def category_list(request: HttpRequest) -> TemplateResponse: - """List all categories containing podcasts.""" - categories = ( - Category.objects.alias( - has_podcasts=Exists( - _get_public_podcasts().filter(categories=OuterRef("pk")) - ) - ) - .filter(has_podcasts=True) - .order_by("name") - ) - - return TemplateResponse( - request, - "podcasts/categories.html", - { - "categories": categories, - }, - ) - - -@require_safe -@login_required -def category_detail(request: HttpRequest, slug: str) -> TemplateResponse: - """Render individual podcast category along with its podcasts. - - Podcasts can also be searched. - """ - category = get_object_or_404(Category, slug=slug) - - podcasts = category.podcasts.published().filter(private=False).distinct() - - if request.search: - podcasts = search_queryset( - podcasts, - request.search.value, - "search_vector", - ).order_by("-rank", "-pub_date") - else: - podcasts = podcasts.order_by("-pub_date") - - return render_paginated_response( - request, - "podcasts/category_detail.html", - podcasts, - { - "category": category, - }, - ) - - -@require_POST -@login_required -def subscribe( - request: AuthenticatedHttpRequest, podcast_id: int -) -> TemplateResponse | HttpResponseConflict: - """Subscribe a user to a podcast. Podcast must be active and public.""" - podcast = get_object_or_404(_get_public_podcasts(), pk=podcast_id) - - try: - request.user.subscriptions.create(podcast=podcast) - except IntegrityError: - return HttpResponseConflict() - - messages.success(request, "Subscribed to Podcast") - - return _render_subscribe_action(request, podcast, is_subscribed=True) - - -@require_DELETE -@login_required -def unsubscribe(request: AuthenticatedHttpRequest, podcast_id: int) -> TemplateResponse: - """Unsubscribe user from a podcast.""" - podcast = get_object_or_404(_get_public_podcasts(), pk=podcast_id) - request.user.subscriptions.filter(podcast=podcast).delete() - messages.info(request, "Unsubscribed from Podcast") - return _render_subscribe_action(request, podcast, is_subscribed=False) - - -@require_safe -@login_required -def private_feeds(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Lists user's private feeds.""" - podcasts = _get_private_podcasts().subscribed(request.user) - - if request.search: - podcasts = search_queryset( - podcasts, - request.search.value, - "search_vector", - ).order_by("-rank", "-pub_date") - else: - podcasts = podcasts.order_by("-pub_date") - - return render_paginated_response(request, "podcasts/private_feeds.html", podcasts) - - -@require_form_methods -@login_required -def add_private_feed(request: AuthenticatedHttpRequest) -> RenderOrRedirectResponse: - """Add new private feed to collection.""" - if request.method == "POST": - form = PodcastForm(request.POST) - if form.is_valid(): - podcast = form.save(commit=False) - podcast.private = True - podcast.save() - - request.user.subscriptions.create(podcast=podcast) - - messages.success( - request, - "Podcast added to your Private Feeds and will appear here soon", - ) - return redirect("podcasts:private_feeds") - else: - form = PodcastForm() - - return render_partial_response( - request, - "podcasts/private_feed_form.html", - {"form": form}, - target="private-feed-form", - partial="form", - ) - - -@require_DELETE -@login_required -def remove_private_feed( - request: AuthenticatedHttpRequest, - podcast_id: int, -) -> HttpResponseRedirect: - """Delete private feed.""" - - get_object_or_404( - _get_private_podcasts().subscribed(request.user), - pk=podcast_id, - ).delete() - - messages.info(request, "Removed from Private Feeds") - return redirect("podcasts:private_feeds") - - -def _get_podcasts() -> PodcastQuerySet: - return Podcast.objects.published() - - -def _get_public_podcasts() -> PodcastQuerySet: - return _get_podcasts().filter(private=False) - - -def _get_private_podcasts() -> PodcastQuerySet: - return _get_podcasts().filter(private=True) - - -def _render_subscribe_action( - request: HttpRequest, - podcast: Podcast, - *, - is_subscribed: bool, -) -> TemplateResponse: - return TemplateResponse( - request, - "podcasts/detail.html#subscribe_button", - { - "podcast": podcast, - "is_subscribed": is_subscribed, - }, - ) diff --git a/simplecasts/search.py b/simplecasts/search.py deleted file mode 100644 index de053edf5a..0000000000 --- a/simplecasts/search.py +++ /dev/null @@ -1,51 +0,0 @@ -import functools -import operator -from typing import TypeAlias, TypeVar - -from django.contrib.postgres.search import SearchQuery, SearchRank -from django.db.models import F, Model, Q, QuerySet - -T_Model = TypeVar("T_Model", bound=Model) -T_QuerySet: TypeAlias = QuerySet[T_Model] - - -def search_queryset( - queryset: T_QuerySet, - value: str, - *search_fields: str, - annotation: str = "rank", - config: str = "simple", - search_type: str = "websearch", -) -> T_QuerySet: - """Search queryset using full-text search.""" - if not value: - return queryset.none() - - query = SearchQuery( - value, - search_type=search_type, - config=config, - ) - - rank = functools.reduce( - operator.add, - ( - SearchRank( - F(field), - query=query, - ) - for field in search_fields - ), - ) - - q = functools.reduce( - operator.or_, - ( - Q( - **{field: query}, - ) - for field in search_fields - ), - ) - - return queryset.annotate(**{annotation: rank}).filter(q) diff --git a/simplecasts/episodes/templatetags/__init__.py b/simplecasts/services/__init__.py similarity index 100% rename from simplecasts/episodes/templatetags/__init__.py rename to simplecasts/services/__init__.py diff --git a/simplecasts/covers.py b/simplecasts/services/covers.py similarity index 98% rename from simplecasts/covers.py rename to simplecasts/services/covers.py index a014c769e1..dd5e6b8c99 100644 --- a/simplecasts/covers.py +++ b/simplecasts/services/covers.py @@ -15,8 +15,8 @@ from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from PIL import Image -from simplecasts.http_client import Client -from simplecasts.pwa import ImageInfo +from simplecasts.services.http_client import Client +from simplecasts.services.pwa import ImageInfo CoverVariant = Literal["card", "detail", "tile"] diff --git a/simplecasts/podcasts/parsers/feed_parser.py b/simplecasts/services/feed_parser/__init__.py similarity index 96% rename from simplecasts/podcasts/parsers/feed_parser.py rename to simplecasts/services/feed_parser/__init__.py index 2a8633f795..7db672f8bf 100644 --- a/simplecasts/podcasts/parsers/feed_parser.py +++ b/simplecasts/services/feed_parser/__init__.py @@ -7,18 +7,17 @@ from django.db.utils import DatabaseError from django.utils import timezone -from simplecasts.episodes.models import Episode -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Category, Podcast -from simplecasts.podcasts.parsers import rss_fetcher, rss_parser, scheduler -from simplecasts.podcasts.parsers.exceptions import ( +from simplecasts.models import Category, Episode, Podcast +from simplecasts.services.feed_parser import rss_fetcher, rss_parser, scheduler +from simplecasts.services.feed_parser.exceptions import ( DiscontinuedError, DuplicateError, InvalidRSSError, NotModifiedError, UnavailableError, ) -from simplecasts.podcasts.parsers.models import Feed, Item +from simplecasts.services.feed_parser.schemas import Feed, Item +from simplecasts.services.http_client import Client def parse_feed(podcast: Podcast, client: Client) -> Podcast.FeedStatus: diff --git a/simplecasts/podcasts/parsers/date_parser.py b/simplecasts/services/feed_parser/date_parser.py similarity index 100% rename from simplecasts/podcasts/parsers/date_parser.py rename to simplecasts/services/feed_parser/date_parser.py diff --git a/simplecasts/podcasts/parsers/exceptions.py b/simplecasts/services/feed_parser/exceptions.py similarity index 100% rename from simplecasts/podcasts/parsers/exceptions.py rename to simplecasts/services/feed_parser/exceptions.py diff --git a/simplecasts/podcasts/parsers/rss_fetcher.py b/simplecasts/services/feed_parser/rss_fetcher.py similarity index 94% rename from simplecasts/podcasts/parsers/rss_fetcher.py rename to simplecasts/services/feed_parser/rss_fetcher.py index 731a991fda..1084ad9b1b 100644 --- a/simplecasts/podcasts/parsers/rss_fetcher.py +++ b/simplecasts/services/feed_parser/rss_fetcher.py @@ -7,14 +7,14 @@ from django.utils.functional import cached_property from django.utils.http import http_date, quote_etag -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.date_parser import parse_date -from simplecasts.podcasts.parsers.exceptions import ( +from simplecasts.models import Podcast +from simplecasts.services.feed_parser.date_parser import parse_date +from simplecasts.services.feed_parser.exceptions import ( DiscontinuedError, NotModifiedError, UnavailableError, ) +from simplecasts.services.http_client import Client @dataclasses.dataclass(kw_only=True, frozen=True) diff --git a/simplecasts/podcasts/parsers/rss_parser.py b/simplecasts/services/feed_parser/rss_parser.py similarity index 96% rename from simplecasts/podcasts/parsers/rss_parser.py rename to simplecasts/services/feed_parser/rss_parser.py index 602463145b..c5ddd4f913 100644 --- a/simplecasts/podcasts/parsers/rss_parser.py +++ b/simplecasts/services/feed_parser/rss_parser.py @@ -5,9 +5,9 @@ from pydantic import ValidationError -from simplecasts.podcasts.parsers.exceptions import InvalidRSSError -from simplecasts.podcasts.parsers.models import Feed, Item -from simplecasts.podcasts.parsers.xpath_parser import OptionalXmlElement, XPathParser +from simplecasts.services.feed_parser.exceptions import InvalidRSSError +from simplecasts.services.feed_parser.schemas import Feed, Item +from simplecasts.services.xpath_parser import OptionalXmlElement, XPathParser def parse_rss(content: bytes) -> Feed: diff --git a/simplecasts/podcasts/parsers/scheduler.py b/simplecasts/services/feed_parser/scheduler.py similarity index 92% rename from simplecasts/podcasts/parsers/scheduler.py rename to simplecasts/services/feed_parser/scheduler.py index 0c25cf3dcb..038e9d2cbe 100644 --- a/simplecasts/podcasts/parsers/scheduler.py +++ b/simplecasts/services/feed_parser/scheduler.py @@ -3,8 +3,8 @@ from django.utils import timezone -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.models import Feed +from simplecasts.models import Podcast +from simplecasts.services.feed_parser.schemas import Feed def schedule(feed: Feed) -> timedelta: diff --git a/simplecasts/podcasts/parsers/models.py b/simplecasts/services/feed_parser/schemas/__init__.py similarity index 94% rename from simplecasts/podcasts/parsers/models.py rename to simplecasts/services/feed_parser/schemas/__init__.py index 1ce2a62f4d..4891f6778d 100644 --- a/simplecasts/podcasts/parsers/models.py +++ b/simplecasts/services/feed_parser/schemas/__init__.py @@ -10,11 +10,10 @@ model_validator, ) -from simplecasts.episodes.models import Episode -from simplecasts.podcasts import tokenizer -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.date_parser import parse_date -from simplecasts.podcasts.parsers.fields import ( +from simplecasts.models import Episode, Podcast +from simplecasts.services import tokenizer +from simplecasts.services.feed_parser.date_parser import parse_date +from simplecasts.services.feed_parser.schemas.fields import ( AudioMimetype, EmptyIfNone, EpisodeType, @@ -23,7 +22,7 @@ PgInteger, PodcastType, ) -from simplecasts.podcasts.parsers.validators import is_one_of, normalize_url +from simplecasts.services.feed_parser.schemas.validators import is_one_of, normalize_url class Item(BaseModel): diff --git a/simplecasts/podcasts/parsers/fields.py b/simplecasts/services/feed_parser/schemas/fields.py similarity index 92% rename from simplecasts/podcasts/parsers/fields.py rename to simplecasts/services/feed_parser/schemas/fields.py index ee0e49d22a..d9ba25ae26 100644 --- a/simplecasts/podcasts/parsers/fields.py +++ b/simplecasts/services/feed_parser/schemas/fields.py @@ -3,9 +3,8 @@ from pydantic import AfterValidator, BeforeValidator -from simplecasts.episodes.models import Episode -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.validators import ( +from simplecasts.models import Episode, Podcast +from simplecasts.services.feed_parser.schemas.validators import ( default_if_none, is_one_of, normalize_url, diff --git a/simplecasts/podcasts/parsers/validators.py b/simplecasts/services/feed_parser/schemas/validators.py similarity index 100% rename from simplecasts/podcasts/parsers/validators.py rename to simplecasts/services/feed_parser/schemas/validators.py diff --git a/simplecasts/http_client.py b/simplecasts/services/http_client.py similarity index 100% rename from simplecasts/http_client.py rename to simplecasts/services/http_client.py diff --git a/simplecasts/podcasts/itunes.py b/simplecasts/services/itunes.py similarity index 98% rename from simplecasts/podcasts/itunes.py rename to simplecasts/services/itunes.py index cfd0ef96a5..d459f71d9d 100644 --- a/simplecasts/podcasts/itunes.py +++ b/simplecasts/services/itunes.py @@ -16,8 +16,8 @@ from lxml import html from pydantic import BaseModel, Field, ValidationError -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Podcast +from simplecasts.models import Podcast +from simplecasts.services.http_client import Client COUNTRIES: Final = ( "br", diff --git a/simplecasts/users/notifications.py b/simplecasts/services/notifications.py similarity index 97% rename from simplecasts/users/notifications.py rename to simplecasts/services/notifications.py index 9e855e6ca9..bdba1ea2a9 100644 --- a/simplecasts/users/notifications.py +++ b/simplecasts/services/notifications.py @@ -9,7 +9,7 @@ from django.template import loader from django.urls import reverse -from simplecasts.sanitizer import strip_html +from simplecasts.services.sanitizer import strip_html from simplecasts.templatetags import absolute_uri diff --git a/simplecasts/podcasts/parsers/opml_parser.py b/simplecasts/services/opml_parser.py similarity index 90% rename from simplecasts/podcasts/parsers/opml_parser.py rename to simplecasts/services/opml_parser.py index 8dd09cb7b7..b4be1d8455 100644 --- a/simplecasts/podcasts/parsers/opml_parser.py +++ b/simplecasts/services/opml_parser.py @@ -1,7 +1,7 @@ import functools from collections.abc import Iterator -from simplecasts.podcasts.parsers.xpath_parser import XPathParser +from simplecasts.services.xpath_parser import XPathParser def parse_opml(content: bytes) -> Iterator[str]: diff --git a/simplecasts/pwa.py b/simplecasts/services/pwa.py similarity index 100% rename from simplecasts/pwa.py rename to simplecasts/services/pwa.py diff --git a/simplecasts/podcasts/recommender.py b/simplecasts/services/recommender.py similarity index 98% rename from simplecasts/podcasts/recommender.py rename to simplecasts/services/recommender.py index 15383d52c0..9d7c3bc09a 100644 --- a/simplecasts/podcasts/recommender.py +++ b/simplecasts/services/recommender.py @@ -17,7 +17,7 @@ ) from sklearn.neighbors import NearestNeighbors -from simplecasts.podcasts.models import Podcast, Recommendation +from simplecasts.models import Podcast, Recommendation _default_timeframe: Final = timedelta(days=90) diff --git a/simplecasts/sanitizer.py b/simplecasts/services/sanitizer.py similarity index 100% rename from simplecasts/sanitizer.py rename to simplecasts/services/sanitizer.py diff --git a/simplecasts/thread_pool.py b/simplecasts/services/thread_pool.py similarity index 100% rename from simplecasts/thread_pool.py rename to simplecasts/services/thread_pool.py diff --git a/simplecasts/podcasts/tokenizer.py b/simplecasts/services/tokenizer.py similarity index 98% rename from simplecasts/podcasts/tokenizer.py rename to simplecasts/services/tokenizer.py index 21e065bd15..af83cdbb5a 100644 --- a/simplecasts/podcasts/tokenizer.py +++ b/simplecasts/services/tokenizer.py @@ -14,7 +14,7 @@ from nltk.stem.wordnet import WordNetLemmatizer from nltk.tokenize import RegexpTokenizer -from simplecasts.sanitizer import strip_html +from simplecasts.services.sanitizer import strip_html _STOPWORDS_LANGUAGES: Final = { "ar": "arabic", diff --git a/simplecasts/podcasts/parsers/xpath_parser.py b/simplecasts/services/xpath_parser.py similarity index 100% rename from simplecasts/podcasts/parsers/xpath_parser.py rename to simplecasts/services/xpath_parser.py diff --git a/simplecasts/templatetags.py b/simplecasts/templatetags.py index 183f9ce87a..f68f4e0825 100644 --- a/simplecasts/templatetags.py +++ b/simplecasts/templatetags.py @@ -1,5 +1,6 @@ import functools import json +from datetime import timedelta from django import template from django.conf import settings @@ -10,14 +11,81 @@ from django.utils import timezone from django.utils.html import format_html, format_html_join from django.utils.safestring import SafeString - -from simplecasts import covers, sanitizer -from simplecasts.pwa import get_theme_color -from simplecasts.request import RequestContext +from django.utils.timesince import timesince + +from simplecasts.http.request import ( + HttpRequest, + RequestContext, + is_authenticated_request, +) +from simplecasts.models import AudioLog, Episode +from simplecasts.services import covers, sanitizer +from simplecasts.services.pwa import get_theme_color +from simplecasts.views.episodes import PlayerAction register = template.Library() +@register.simple_block_tag(takes_context=True) +def fragment( + context: Context, + content: str, + template_name: str, + *, + only: bool = False, + **extra_context, +) -> SafeString: + """Renders include in block. + + Example: + + Calling template: + + {% fragment "header.html" %} + title goes here + {% endfragment %} + + header.html: + +

{{ content }}

+ + Renders: + +

title goes here

+ + If `only` is passed it will not include outer context. + """ + + context = context.new() if only else context + + if context.template is None: + raise template.TemplateSyntaxError( + "Can only be used inside a template context." + ) + + tmpl = context.template.engine.get_template(template_name) + + with context.push(content=content, **extra_context): + return tmpl.render(context) + + +@register.inclusion_tag("cookie_banner.html", takes_context=True) +def cookie_banner(context: RequestContext) -> dict: + """Renders GDPR cookie banner""" + cookies_accepted = settings.GDPR_COOKIE_NAME in context.request.COOKIES + return context.flatten() | {"cookies_accepted": cookies_accepted} + + +@register.filter +def format_duration(total_seconds: int, min_value: int = 60) -> str: + """Formats duration (in seconds) as human readable value e.g. 1 hour, 30 minutes.""" + return ( + timesince(timezone.now() - timedelta(seconds=total_seconds)) + if total_seconds >= min_value + else "" + ) + + @register.simple_tag(takes_context=True) def title_tag(context: RequestContext, *elements: str, divider: str = " | ") -> str: """Renders content including the site name. @@ -72,29 +140,6 @@ def meta_tags() -> str: ) -@register.simple_tag -@functools.cache -def get_cover_image_attrs( - variant: covers.CoverVariant, - cover_url: str, - title: str, -) -> dict: - """Returns cover image attributes.""" - return covers.get_cover_image_attrs(variant, cover_url, title) - - -@register.simple_tag -@functools.cache -def cover_image( - variant: covers.CoverVariant, - cover_url: str, - title: str, -) -> str: - """Renders a cover image.""" - attrs = get_cover_image_attrs(variant, cover_url, title) - return format_html("<img {}>", flatatt(_clean_attrs(attrs))) - - @register.simple_tag def absolute_uri(site: Site, path: str, *args, **kwargs) -> str: """Returns absolute URI for the given path.""" @@ -109,55 +154,82 @@ def markdown(text: str) -> dict: return {"markdown": sanitizer.markdown(text)} -@register.inclusion_tag("cookie_banner.html", takes_context=True) -def cookie_banner(context: RequestContext) -> dict: - """Renders GDPR cookie banner""" - cookies_accepted = settings.GDPR_COOKIE_NAME in context.request.COOKIES - return context.flatten() | {"cookies_accepted": cookies_accepted} - - -@register.simple_block_tag(takes_context=True) -def fragment( - context: Context, - content: str, - template_name: str, +@register.inclusion_tag("audio_player.html", takes_context=True) +def audio_player( + context: RequestContext, + audio_log: AudioLog | None = None, + action: PlayerAction = "load", *, - only: bool = False, - **extra_context, -) -> SafeString: - """Renders include in block. + hx_oob: bool = False, +) -> dict: + """Returns audio player.""" + dct = context.flatten() | { + "action": action, + "hx_oob": hx_oob, + } - Example: + match action: + case "close": + return dct - Calling template: + case "play": + return dct | {"audio_log": audio_log} - {% fragment "header.html" %} - title goes here - {% endfragment %} + case _: + return dct | {"audio_log": _get_audio_log(context.request)} - header.html: - <h1>{{ content }}</h1> +@register.simple_tag(takes_context=True) +def get_media_metadata(context: RequestContext, episode: Episode) -> dict: + """Returns media session metadata for integration with client device. - Renders: + For more details: - <h1>title goes here</h1> + https://developers.google.com/web/updates/2017/02/media-session - If `only` is passed it will not include outer context. + Use with `json_script` template tag to render the JSON in a script tag. """ - context = context.new() if only else context + return { + "title": episode.cleaned_title, + "album": episode.podcast.cleaned_title, + "artist": episode.podcast.cleaned_title, + "artwork": covers.get_metadata_info(context.request, episode.get_cover_url()), + } - if context.template is None: - raise template.TemplateSyntaxError( - "Can only be used inside a template context." - ) - tmpl = context.template.engine.get_template(template_name) +@register.simple_tag +@functools.cache +def get_cover_image_attrs( + variant: covers.CoverVariant, + cover_url: str | None, + title: str, +) -> dict: + """Returns cover image attributes.""" + return covers.get_cover_image_attrs(variant, cover_url, title) - with context.push(content=content, **extra_context): - return tmpl.render(context) + +@register.simple_tag +@functools.cache +def cover_image( + variant: covers.CoverVariant, + cover_url: str | None, + title: str, +) -> str: + """Renders a cover image.""" + attrs = get_cover_image_attrs(variant, cover_url, title) + attrs = {k.replace("_", "-"): v for k, v in attrs.items() if v not in (None, False)} + return format_html("<img {}>", flatatt(attrs)) -def _clean_attrs(attrs: dict) -> dict: - return {k.replace("_", "-"): v for k, v in attrs.items() if v not in (None, False)} +def _get_audio_log(request: HttpRequest) -> AudioLog | None: + if is_authenticated_request(request) and (episode_id := request.player.get()): + return ( + request.user.audio_logs.select_related( + "episode", + "episode__podcast", + ) + .filter(episode_id=episode_id) + .first() + ) + return None diff --git a/simplecasts/tests/factories.py b/simplecasts/tests/factories.py new file mode 100644 index 0000000000..8ae88aa62c --- /dev/null +++ b/simplecasts/tests/factories.py @@ -0,0 +1,106 @@ +import uuid + +import factory +from allauth.account.models import EmailAddress +from django.utils import timezone + +from simplecasts.models import ( + AudioLog, + Bookmark, + Category, + Episode, + Podcast, + Recommendation, + Subscription, + User, +) + + +class UserFactory(factory.django.DjangoModelFactory): + username = factory.Sequence(lambda n: f"user-{n}") + email = factory.Sequence(lambda n: f"user-{n}@example.com") + password = factory.django.Password("testpass") + + class Meta: + model = User + + +class EmailAddressFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + email = factory.LazyAttribute(lambda a: a.user.email) + verified = True + + class Meta: + model = EmailAddress + + +class CategoryFactory(factory.django.DjangoModelFactory): + name = factory.Sequence(lambda n: f"Category {n}") + + class Meta: + model = Category + + +class PodcastFactory(factory.django.DjangoModelFactory): + title = factory.Faker("text") + rss = factory.Sequence(lambda n: f"https://{n}.example.com") + pub_date = factory.LazyFunction(timezone.now) + cover_url = "https://example.com/cover.jpg" + + class Meta: + model = Podcast + + @factory.post_generation + def categories(self, create, extracted, **kwargs): + if create and extracted: + self.categories.set(extracted) + + +class RecommendationFactory(factory.django.DjangoModelFactory): + score = 0.5 + + podcast = factory.SubFactory(PodcastFactory) + recommended = factory.SubFactory(PodcastFactory) + + class Meta: + model = Recommendation + + +class SubscriptionFactory(factory.django.DjangoModelFactory): + subscriber = factory.SubFactory(UserFactory) + podcast = factory.SubFactory(PodcastFactory) + + class Meta: + model = Subscription + + +class EpisodeFactory(factory.django.DjangoModelFactory): + guid = factory.LazyFunction(lambda: uuid.uuid4().hex) + podcast = factory.SubFactory(PodcastFactory) + title = factory.Faker("text") + description = factory.Faker("text") + pub_date = factory.LazyFunction(timezone.now) + media_url = factory.Faker("url") + media_type = "audio/mpg" + duration = "100" + + class Meta: + model = Episode + + +class BookmarkFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + episode = factory.SubFactory(EpisodeFactory) + + class Meta: + model = Bookmark + + +class AudioLogFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + episode = factory.SubFactory(EpisodeFactory) + listened = factory.LazyFunction(timezone.now) + current_time = 1000 + + class Meta: + model = AudioLog diff --git a/simplecasts/tests/fixtures.py b/simplecasts/tests/fixtures.py index 1d3ced2108..8e428edfc3 100644 --- a/simplecasts/tests/fixtures.py +++ b/simplecasts/tests/fixtures.py @@ -1,11 +1,22 @@ from collections.abc import Callable, Generator import pytest -from django.conf import Settings +from django.contrib.auth.models import AnonymousUser from django.contrib.auth.signals import user_logged_in from django.contrib.sites.models import Site from django.core.cache import cache from django.http import HttpRequest, HttpResponse +from django.test import Client + +from simplecasts.middleware import PlayerDetails +from simplecasts.models import AudioLog, Category, Episode, Podcast, User +from simplecasts.tests.factories import ( + AudioLogFactory, + CategoryFactory, + EpisodeFactory, + PodcastFactory, + UserFactory, +) @pytest.fixture @@ -14,7 +25,7 @@ def site(): @pytest.fixture(autouse=True) -def _settings_overrides(settings: Settings) -> None: +def _settings_overrides(settings) -> None: """Default settings for all tests.""" settings.CACHES = { "default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"} @@ -25,7 +36,7 @@ def _settings_overrides(settings: Settings) -> None: @pytest.fixture -def _locmem_cache(settings: Settings) -> Generator: +def _locmem_cache(settings) -> Generator: settings.CACHES = { "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"} } @@ -46,3 +57,58 @@ def _disable_update_last_login() -> None: @pytest.fixture(scope="session") def get_response() -> Callable[[HttpRequest], HttpResponse]: return lambda req: HttpResponse() + + +@pytest.fixture +def podcast() -> Podcast: + return PodcastFactory() + + +@pytest.fixture +def category() -> Category: + return CategoryFactory() + + +@pytest.fixture +def user() -> User: + return UserFactory() + + +@pytest.fixture +def anonymous_user() -> AnonymousUser: + return AnonymousUser() + + +@pytest.fixture +def auth_user(client: Client, user: User) -> User: + client.force_login(user) + return user + + +@pytest.fixture +def staff_user(client: Client) -> User: + user = UserFactory(is_staff=True) + client.force_login(user) + return user + + +@pytest.fixture +def episode() -> Episode: + return EpisodeFactory() + + +@pytest.fixture +def audio_log(episode: Episode) -> AudioLog: + return AudioLogFactory(episode=episode) + + +@pytest.fixture +def player_episode(auth_user: User, client: Client, episode: Episode) -> Episode: + """Fixture that creates an AudioLog for the given user and episode""" + AudioLogFactory(user=auth_user, episode=episode) + + session = client.session + session[PlayerDetails.session_id] = episode.pk + session.save() + + return episode diff --git a/simplecasts/podcasts/parsers/tests/mocks/feeds.opml b/simplecasts/tests/mocks/feeds.opml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/feeds.opml rename to simplecasts/tests/mocks/feeds.opml diff --git a/simplecasts/users/tests/mocks/feeds_with_invalid.opml b/simplecasts/tests/mocks/feeds_with_invalid.opml similarity index 100% rename from simplecasts/users/tests/mocks/feeds_with_invalid.opml rename to simplecasts/tests/mocks/feeds_with_invalid.opml diff --git a/simplecasts/podcasts/tests/mocks/itunes_chart.html b/simplecasts/tests/mocks/itunes_chart.html similarity index 100% rename from simplecasts/podcasts/tests/mocks/itunes_chart.html rename to simplecasts/tests/mocks/itunes_chart.html diff --git a/simplecasts/episodes/tests/__init__.py b/simplecasts/tests/models/__init__.py similarity index 100% rename from simplecasts/episodes/tests/__init__.py rename to simplecasts/tests/models/__init__.py diff --git a/simplecasts/tests/models/test_audio_logs.py b/simplecasts/tests/models/test_audio_logs.py new file mode 100644 index 0000000000..940baca1e6 --- /dev/null +++ b/simplecasts/tests/models/test_audio_logs.py @@ -0,0 +1,41 @@ +import pytest + +from simplecasts.models import AudioLog +from simplecasts.tests.factories import AudioLogFactory + + +class TestAudioLogManager: + @pytest.mark.django_db + def test_search(self): + audio_log1 = AudioLogFactory( + episode__title="Learn Python Programming", + episode__podcast__title="Tech Talks", + ) + audio_log2 = AudioLogFactory( + episode__title="Advanced Django Techniques", + episode__podcast__title="Web Dev Weekly", + ) + + results = AudioLog.objects.search("Python") + assert audio_log1 in results + assert audio_log2 not in results + + +class TestAudioLogModel: + @pytest.mark.parametrize( + ("current_time", "duration", "expected"), + [ + pytest.param(0, 0, 0, id="both zero"), + pytest.param(0, 0, 0, id="current time zero"), + pytest.param(60 * 60, 0, 0, id="duration zero"), + pytest.param(60 * 60, 60 * 60, 100, id="both one hour"), + pytest.param(60 * 30, 60 * 60, 50, id="current time half"), + pytest.param(60 * 60, 30 * 60, 100, id="more than 100 percent"), + ], + ) + def test_percent_complete(self, current_time, duration, expected): + audio_log = AudioLog( + current_time=current_time, + duration=duration, + ) + assert audio_log.percent_complete == expected diff --git a/simplecasts/tests/models/test_bookmarks.py b/simplecasts/tests/models/test_bookmarks.py new file mode 100644 index 0000000000..6480070606 --- /dev/null +++ b/simplecasts/tests/models/test_bookmarks.py @@ -0,0 +1,21 @@ +import pytest + +from simplecasts.models.bookmarks import Bookmark +from simplecasts.tests.factories import BookmarkFactory + + +class TestBookmarkManager: + @pytest.mark.django_db + def test_search(self): + bookmark1 = BookmarkFactory( + episode__title="Learn Python Programming", + episode__podcast__title="Tech Talks", + ) + bookmark2 = BookmarkFactory( + episode__title="Advanced Django Techniques", + episode__podcast__title="Web Dev Weekly", + ) + + results = Bookmark.objects.search("Python") + assert bookmark1 in results + assert bookmark2 not in results diff --git a/simplecasts/tests/models/test_categories.py b/simplecasts/tests/models/test_categories.py new file mode 100644 index 0000000000..b92d2b95b7 --- /dev/null +++ b/simplecasts/tests/models/test_categories.py @@ -0,0 +1,19 @@ +import pytest + +from simplecasts.models import ( + Category, +) +from simplecasts.tests.factories import ( + CategoryFactory, +) + + +class TestCategoryModel: + def test_str(self): + category = Category(name="Testing") + assert str(category) == "Testing" + + @pytest.mark.django_db + def test_slug(self): + category = CategoryFactory(name="Testing") + assert category.slug == "testing" diff --git a/simplecasts/episodes/tests/test_models.py b/simplecasts/tests/models/test_episodes.py similarity index 78% rename from simplecasts/episodes/tests/test_models.py rename to simplecasts/tests/models/test_episodes.py index aed43084e0..793135c1a3 100644 --- a/simplecasts/episodes/tests/test_models.py +++ b/simplecasts/tests/models/test_episodes.py @@ -2,12 +2,28 @@ import pytest -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.episodes.tests.factories import ( - EpisodeFactory, -) -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.tests.factories import PodcastFactory +from simplecasts.models import Episode, Podcast +from simplecasts.tests.factories import EpisodeFactory, PodcastFactory + + +class TestEpisodeManager: + @pytest.mark.django_db + def test_search(self): + episode = EpisodeFactory(title="UniqueTitle123") + results = Episode.objects.search("UniqueTitle123") + assert episode in results + + @pytest.mark.django_db + def test_search_no_results(self): + EpisodeFactory(title="Some Other Title") + results = Episode.objects.search("NonExistentTitle456") + assert results.count() == 0 + + @pytest.mark.django_db + def test_search_empty_query(self): + EpisodeFactory(title="Any Title") + results = Episode.objects.search("") + assert results.count() == 0 class TestEpisodeModel: @@ -121,23 +137,3 @@ def test_get_cover_url_if_none(self): ) def test_duration_in_seconds(self, duration, expected): assert Episode(duration=duration).duration_in_seconds == expected - - -class TestAudioLogModel: - @pytest.mark.parametrize( - ("current_time", "duration", "expected"), - [ - pytest.param(0, 0, 0, id="both zero"), - pytest.param(0, 0, 0, id="current time zero"), - pytest.param(60 * 60, 0, 0, id="duration zero"), - pytest.param(60 * 60, 60 * 60, 100, id="both one hour"), - pytest.param(60 * 30, 60 * 60, 50, id="current time half"), - pytest.param(60 * 60, 30 * 60, 100, id="more than 100 percent"), - ], - ) - def test_percent_complete(self, current_time, duration, expected): - audio_log = AudioLog( - current_time=current_time, - duration=duration, - ) - assert audio_log.percent_complete == expected diff --git a/simplecasts/podcasts/tests/test_models.py b/simplecasts/tests/models/test_podcasts.py similarity index 90% rename from simplecasts/podcasts/tests/test_models.py rename to simplecasts/tests/models/test_podcasts.py index d316d7f3a9..ac99c72aeb 100644 --- a/simplecasts/podcasts/tests/test_models.py +++ b/simplecasts/tests/models/test_podcasts.py @@ -3,36 +3,48 @@ import pytest from django.utils import timezone -from simplecasts.episodes.tests.factories import EpisodeFactory -from simplecasts.podcasts.models import Category, Podcast, Recommendation -from simplecasts.podcasts.tests.factories import ( - CategoryFactory, +from simplecasts.models import ( + Podcast, +) +from simplecasts.tests.factories import ( + EpisodeFactory, PodcastFactory, RecommendationFactory, SubscriptionFactory, ) -class TestRecommendationManager: +class TestPodcastManager: @pytest.mark.django_db - def test_bulk_delete(self): - RecommendationFactory.create_batch(3) - Recommendation.objects.bulk_delete() - assert Recommendation.objects.count() == 0 + def test_search(self): + podcast1 = PodcastFactory(title="Learn Python") + podcast2 = PodcastFactory(title="Learn Django") + PodcastFactory(title="Cooking Tips") + results = Podcast.objects.search("Learn") + assert podcast1 in results + assert podcast2 in results + assert results.count() == 2 -class TestCategoryModel: - def test_str(self): - category = Category(name="Testing") - assert str(category) == "Testing" + @pytest.mark.django_db + def test_search_owner(self): + PodcastFactory(owner="Python Guru") + podcast2 = PodcastFactory(owner="Django Expert") + PodcastFactory(owner="Chef Extraordinaire") + + results = Podcast.objects.search("Django", "owner_search_document") + assert podcast2 in results + assert results.count() == 1 @pytest.mark.django_db - def test_slug(self): - category = CategoryFactory(name="Testing") - assert category.slug == "testing" + def test_search_empty(self): + PodcastFactory(title="Learn Python") + PodcastFactory(title="Learn Django") + PodcastFactory(title="Cooking Tips") + results = Podcast.objects.search("") + assert results.count() == 0 -class TestPodcastManager: @pytest.mark.django_db def test_subscribed_true(self, user): SubscriptionFactory(subscriber=user) @@ -83,7 +95,7 @@ def test_published_false(self): "frequency": datetime.timedelta(hours=3), }, False, - id="pub date is not None, just parsed", + id="pub date is None, just parsed", ), pytest.param( { diff --git a/simplecasts/tests/models/test_recommendations.py b/simplecasts/tests/models/test_recommendations.py new file mode 100644 index 0000000000..1ee01ca209 --- /dev/null +++ b/simplecasts/tests/models/test_recommendations.py @@ -0,0 +1,16 @@ +import pytest + +from simplecasts.models import ( + Recommendation, +) +from simplecasts.tests.factories import ( + RecommendationFactory, +) + + +class TestRecommendationManager: + @pytest.mark.django_db + def test_bulk_delete(self): + RecommendationFactory.create_batch(3) + Recommendation.objects.bulk_delete() + assert Recommendation.objects.count() == 0 diff --git a/simplecasts/users/tests/test_models.py b/simplecasts/tests/models/test_users.py similarity index 90% rename from simplecasts/users/tests/test_models.py rename to simplecasts/tests/models/test_users.py index 06084c3c51..1b609d5c11 100644 --- a/simplecasts/users/tests/test_models.py +++ b/simplecasts/tests/models/test_users.py @@ -1,4 +1,4 @@ -from simplecasts.users.models import User +from simplecasts.models import User class TestUserModel: diff --git a/simplecasts/podcasts/__init__.py b/simplecasts/tests/services/__init__.py similarity index 100% rename from simplecasts/podcasts/__init__.py rename to simplecasts/tests/services/__init__.py diff --git a/simplecasts/podcasts/management/__init__.py b/simplecasts/tests/services/feed_parser/__init__.py similarity index 100% rename from simplecasts/podcasts/management/__init__.py rename to simplecasts/tests/services/feed_parser/__init__.py diff --git a/simplecasts/podcasts/parsers/tests/factories.py b/simplecasts/tests/services/feed_parser/factories.py similarity index 100% rename from simplecasts/podcasts/parsers/tests/factories.py rename to simplecasts/tests/services/feed_parser/factories.py diff --git a/simplecasts/users/tests/mocks/feeds.opml b/simplecasts/tests/services/feed_parser/mocks/feeds.opml similarity index 100% rename from simplecasts/users/tests/mocks/feeds.opml rename to simplecasts/tests/services/feed_parser/mocks/feeds.opml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_bad_cover_urls.xml b/simplecasts/tests/services/feed_parser/mocks/rss_bad_cover_urls.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_bad_cover_urls.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_bad_cover_urls.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_bad_pub_date.xml b/simplecasts/tests/services/feed_parser/mocks/rss_bad_pub_date.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_bad_pub_date.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_bad_pub_date.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_bad_sig.xml b/simplecasts/tests/services/feed_parser/mocks/rss_bad_sig.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_bad_sig.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_bad_sig.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_bad_urls.xml b/simplecasts/tests/services/feed_parser/mocks/rss_bad_urls.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_bad_urls.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_bad_urls.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_empty_mock.xml b/simplecasts/tests/services/feed_parser/mocks/rss_empty_mock.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_empty_mock.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_empty_mock.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_high_num_episodes.xml b/simplecasts/tests/services/feed_parser/mocks/rss_high_num_episodes.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_high_num_episodes.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_high_num_episodes.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_invalid_data.xml b/simplecasts/tests/services/feed_parser/mocks/rss_invalid_data.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_invalid_data.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_invalid_data.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_invalid_duration.xml b/simplecasts/tests/services/feed_parser/mocks/rss_invalid_duration.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_invalid_duration.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_invalid_duration.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_missing_enc_length.xml b/simplecasts/tests/services/feed_parser/mocks/rss_missing_enc_length.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_missing_enc_length.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_missing_enc_length.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_complete.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_complete.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_complete.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_complete.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_iso_8859-1.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_iso_8859-1.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_iso_8859-1.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_iso_8859-1.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_large.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_large.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_large.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_large.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_modified.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_modified.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_modified.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_modified.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_no_build_date.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_no_build_date.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_no_build_date.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_no_build_date.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_small.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_small.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_small.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_small.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_new_feed_url.xml b/simplecasts/tests/services/feed_parser/mocks/rss_new_feed_url.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_new_feed_url.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_new_feed_url.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_no_podcasts_mock.xml b/simplecasts/tests/services/feed_parser/mocks/rss_no_podcasts_mock.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_no_podcasts_mock.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_no_podcasts_mock.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_serial.xml b/simplecasts/tests/services/feed_parser/mocks/rss_serial.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_serial.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_serial.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_superfeedr.xml b/simplecasts/tests/services/feed_parser/mocks/rss_superfeedr.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_superfeedr.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_superfeedr.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_use_link_ids.xml b/simplecasts/tests/services/feed_parser/mocks/rss_use_link_ids.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_use_link_ids.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_use_link_ids.xml diff --git a/simplecasts/podcasts/parsers/tests/test_date_parser.py b/simplecasts/tests/services/feed_parser/test_date_parser.py similarity index 94% rename from simplecasts/podcasts/parsers/tests/test_date_parser.py rename to simplecasts/tests/services/feed_parser/test_date_parser.py index b4c6f9a4b8..59e962c39e 100644 --- a/simplecasts/podcasts/parsers/tests/test_date_parser.py +++ b/simplecasts/tests/services/feed_parser/test_date_parser.py @@ -1,7 +1,7 @@ import datetime from zoneinfo import ZoneInfo -from simplecasts.podcasts.parsers.date_parser import parse_date +from simplecasts.services.feed_parser.date_parser import parse_date UTC = ZoneInfo(key="UTC") diff --git a/simplecasts/podcasts/parsers/tests/test_feed_parser.py b/simplecasts/tests/services/feed_parser/test_feed_parser.py similarity index 97% rename from simplecasts/podcasts/parsers/tests/test_feed_parser.py rename to simplecasts/tests/services/feed_parser/test_feed_parser.py index fcecfcf367..ad900fed41 100644 --- a/simplecasts/podcasts/parsers/tests/test_feed_parser.py +++ b/simplecasts/tests/services/feed_parser/test_feed_parser.py @@ -7,14 +7,12 @@ from django.db.utils import DatabaseError from django.utils.text import slugify -from simplecasts.episodes.models import Episode -from simplecasts.episodes.tests.factories import EpisodeFactory -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Category, Podcast -from simplecasts.podcasts.parsers.date_parser import parse_date -from simplecasts.podcasts.parsers.feed_parser import get_categories_dict, parse_feed -from simplecasts.podcasts.parsers.rss_fetcher import make_content_hash -from simplecasts.podcasts.tests.factories import PodcastFactory +from simplecasts.models import Category, Episode, Podcast +from simplecasts.services.feed_parser import get_categories_dict, parse_feed +from simplecasts.services.feed_parser.date_parser import parse_date +from simplecasts.services.feed_parser.rss_fetcher import make_content_hash +from simplecasts.services.http_client import Client +from simplecasts.tests.factories import EpisodeFactory, PodcastFactory @pytest.fixture @@ -406,7 +404,7 @@ def test_parse_same_content(self, mocker): podcast = PodcastFactory(content_hash=make_content_hash(content)) mock_parse_rss = mocker.patch( - "simplecasts.podcasts.parsers.rss_parser.parse_rss" + "simplecasts.services.feed_parser.rss_parser.parse_rss" ) client = _mock_client( diff --git a/simplecasts/podcasts/parsers/tests/test_opml_parser.py b/simplecasts/tests/services/feed_parser/test_opml_parser.py similarity index 83% rename from simplecasts/podcasts/parsers/tests/test_opml_parser.py rename to simplecasts/tests/services/feed_parser/test_opml_parser.py index c42d554295..0b1091ec08 100644 --- a/simplecasts/podcasts/parsers/tests/test_opml_parser.py +++ b/simplecasts/tests/services/feed_parser/test_opml_parser.py @@ -1,6 +1,6 @@ import pathlib -from simplecasts.podcasts.parsers.opml_parser import parse_opml +from simplecasts.services.opml_parser import parse_opml class TestParseOpml: diff --git a/simplecasts/podcasts/parsers/tests/test_rss_fetcher.py b/simplecasts/tests/services/feed_parser/test_rss_fetcher.py similarity index 94% rename from simplecasts/podcasts/parsers/tests/test_rss_fetcher.py rename to simplecasts/tests/services/feed_parser/test_rss_fetcher.py index 143e93b3a2..87b6f62fca 100644 --- a/simplecasts/podcasts/parsers/tests/test_rss_fetcher.py +++ b/simplecasts/tests/services/feed_parser/test_rss_fetcher.py @@ -4,16 +4,16 @@ import httpx import pytest -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.date_parser import parse_date -from simplecasts.podcasts.parsers.rss_fetcher import ( +from simplecasts.models import Podcast +from simplecasts.services.feed_parser.date_parser import parse_date +from simplecasts.services.feed_parser.rss_fetcher import ( DiscontinuedError, NotModifiedError, UnavailableError, fetch_rss, make_content_hash, ) +from simplecasts.services.http_client import Client class TestMakeContentHash: diff --git a/simplecasts/podcasts/parsers/tests/test_rss_parser.py b/simplecasts/tests/services/feed_parser/test_rss_parser.py similarity index 95% rename from simplecasts/podcasts/parsers/tests/test_rss_parser.py rename to simplecasts/tests/services/feed_parser/test_rss_parser.py index e426d4b9dc..6c23704906 100644 --- a/simplecasts/podcasts/parsers/tests/test_rss_parser.py +++ b/simplecasts/tests/services/feed_parser/test_rss_parser.py @@ -2,8 +2,8 @@ import pytest -from simplecasts.podcasts.parsers.exceptions import InvalidRSSError -from simplecasts.podcasts.parsers.rss_parser import parse_rss +from simplecasts.services.feed_parser.exceptions import InvalidRSSError +from simplecasts.services.feed_parser.rss_parser import parse_rss class TestParseRss: diff --git a/simplecasts/podcasts/parsers/tests/test_scheduler.py b/simplecasts/tests/services/feed_parser/test_scheduler.py similarity index 95% rename from simplecasts/podcasts/parsers/tests/test_scheduler.py rename to simplecasts/tests/services/feed_parser/test_scheduler.py index c7be0fbe38..c9b94acff9 100644 --- a/simplecasts/podcasts/parsers/tests/test_scheduler.py +++ b/simplecasts/tests/services/feed_parser/test_scheduler.py @@ -3,9 +3,9 @@ import pytest from django.utils import timezone -from simplecasts.podcasts.parsers import scheduler -from simplecasts.podcasts.parsers.models import Feed, Item -from simplecasts.podcasts.parsers.tests.factories import FeedFactory, ItemFactory +from simplecasts.services.feed_parser import scheduler +from simplecasts.services.feed_parser.schemas import Feed, Item +from simplecasts.tests.services.feed_parser.factories import FeedFactory, ItemFactory class TestReschedule: diff --git a/simplecasts/podcasts/parsers/tests/test_models.py b/simplecasts/tests/services/feed_parser/test_schemas.py similarity index 96% rename from simplecasts/podcasts/parsers/tests/test_models.py rename to simplecasts/tests/services/feed_parser/test_schemas.py index bb495684b6..a681c14c12 100644 --- a/simplecasts/podcasts/parsers/tests/test_models.py +++ b/simplecasts/tests/services/feed_parser/test_schemas.py @@ -4,9 +4,9 @@ from django.utils import timezone from pydantic import ValidationError -from simplecasts.episodes.models import Episode -from simplecasts.podcasts.parsers.models import Feed, Item -from simplecasts.podcasts.parsers.tests.factories import FeedFactory, ItemFactory +from simplecasts.models import Episode +from simplecasts.services.feed_parser.schemas import Feed, Item +from simplecasts.tests.services.feed_parser.factories import FeedFactory, ItemFactory class TestItem: diff --git a/simplecasts/podcasts/parsers/tests/test_validators.py b/simplecasts/tests/services/feed_parser/test_validators.py similarity index 97% rename from simplecasts/podcasts/parsers/tests/test_validators.py rename to simplecasts/tests/services/feed_parser/test_validators.py index f56bd8fd0a..7ed855a644 100644 --- a/simplecasts/podcasts/parsers/tests/test_validators.py +++ b/simplecasts/tests/services/feed_parser/test_validators.py @@ -3,7 +3,7 @@ import pytest from django.db.models import TextChoices -from simplecasts.podcasts.parsers.validators import ( +from simplecasts.services.feed_parser.schemas.validators import ( default_if_none, is_one_of, normalize_url, diff --git a/simplecasts/tests/test_covers.py b/simplecasts/tests/services/test_covers.py similarity index 98% rename from simplecasts/tests/test_covers.py rename to simplecasts/tests/services/test_covers.py index 7a47a96c86..308a4fc8ce 100644 --- a/simplecasts/tests/test_covers.py +++ b/simplecasts/tests/services/test_covers.py @@ -5,7 +5,7 @@ import pytest from PIL import Image -from simplecasts.covers import ( +from simplecasts.services.covers import ( CoverFetchError, CoverProcessError, CoverSaveError, @@ -21,7 +21,7 @@ process_cover_image, save_cover_image, ) -from simplecasts.http_client import Client +from simplecasts.services.http_client import Client class TestEncodeDecodeCoverUrl: @@ -74,7 +74,9 @@ def mock_stream(*args, **kwargs): mock_bytesio = mocker.Mock() mock_bytesio.tell.return_value = 100_000_000_000 - mocker.patch("simplecasts.covers.io.BytesIO", return_value=mock_bytesio) + mocker.patch( + "simplecasts.services.covers.io.BytesIO", return_value=mock_bytesio + ) with pytest.raises(CoverFetchError): fetch_cover_image(client, self.cover_url) diff --git a/simplecasts/podcasts/tests/test_itunes.py b/simplecasts/tests/services/test_itunes.py similarity index 95% rename from simplecasts/podcasts/tests/test_itunes.py rename to simplecasts/tests/services/test_itunes.py index d0c7e4e1ea..f906f63b2a 100644 --- a/simplecasts/podcasts/tests/test_itunes.py +++ b/simplecasts/tests/services/test_itunes.py @@ -4,9 +4,9 @@ import httpx import pytest -from simplecasts.http_client import Client -from simplecasts.podcasts import itunes -from simplecasts.podcasts.models import Podcast +from simplecasts.models import Podcast +from simplecasts.services import itunes +from simplecasts.services.http_client import Client MOCK_SEARCH_RESULT = { "results": [ @@ -42,7 +42,7 @@ def good_client(self): def _get_result(request): if "podcasts.apple.com" in str(request.url): with ( - pathlib.Path(__file__).parent / "mocks" / "itunes_chart.html" + pathlib.Path(__file__).parent.parent / "mocks" / "itunes_chart.html" ).open("rb") as f: chart_content = f.read() @@ -188,7 +188,7 @@ class TestSearchCached: def test_cached(self, mocker, _locmem_cache): client = Client() mock_search = mocker.patch( - "simplecasts.podcasts.itunes.search", + "simplecasts.services.itunes.search", return_value=MOCK_SEARCH_RESULT, ) feeds, is_new = itunes.search_cached(client, "test", limit=30) diff --git a/simplecasts/users/tests/test_notifications.py b/simplecasts/tests/services/test_notifications.py similarity index 90% rename from simplecasts/users/tests/test_notifications.py rename to simplecasts/tests/services/test_notifications.py index bc65601143..ec2e840a87 100644 --- a/simplecasts/users/tests/test_notifications.py +++ b/simplecasts/tests/services/test_notifications.py @@ -1,7 +1,7 @@ import pytest -from simplecasts.users.notifications import get_recipients -from simplecasts.users.tests.factories import EmailAddressFactory +from simplecasts.services.notifications import get_recipients +from simplecasts.tests.factories import EmailAddressFactory class TestGetRecipients: diff --git a/simplecasts/podcasts/tests/test_recommender.py b/simplecasts/tests/services/test_recommender.py similarity index 94% rename from simplecasts/podcasts/tests/test_recommender.py rename to simplecasts/tests/services/test_recommender.py index 258a022138..879312c7fe 100644 --- a/simplecasts/podcasts/tests/test_recommender.py +++ b/simplecasts/tests/services/test_recommender.py @@ -1,8 +1,8 @@ import pytest -from simplecasts.podcasts.models import Category, Recommendation -from simplecasts.podcasts.recommender import recommend -from simplecasts.podcasts.tests.factories import ( +from simplecasts.models import Category, Recommendation +from simplecasts.services.recommender import recommend +from simplecasts.tests.factories import ( CategoryFactory, PodcastFactory, RecommendationFactory, @@ -33,7 +33,7 @@ def test_podcast_never_recommends_itself(self): for podcast in podcasts: recs = Recommendation.objects.filter(podcast=podcast) - assert recs.count() > 0 + assert recs.exists() # The podcast itself should never appear in its recommendations assert all(r.recommended != podcast for r in recs) @@ -46,7 +46,7 @@ def test_handle_empty_data_frame(self): ) recommend("en") - assert Recommendation.objects.count() == 0 + assert Recommendation.objects.exists() is False @pytest.mark.django_db def test_no_categories(self): diff --git a/simplecasts/tests/test_sanitizier.py b/simplecasts/tests/services/test_sanitizer.py similarity index 94% rename from simplecasts/tests/test_sanitizier.py rename to simplecasts/tests/services/test_sanitizer.py index ca9b8a99ce..aa357fb015 100644 --- a/simplecasts/tests/test_sanitizier.py +++ b/simplecasts/tests/services/test_sanitizer.py @@ -1,6 +1,6 @@ import pytest -from simplecasts.sanitizer import markdown, strip_extra_spaces, strip_html +from simplecasts.services.sanitizer import markdown, strip_extra_spaces, strip_html class TestMarkdown: diff --git a/simplecasts/podcasts/tests/test_tokenizer.py b/simplecasts/tests/services/test_tokenizer.py similarity index 88% rename from simplecasts/podcasts/tests/test_tokenizer.py rename to simplecasts/tests/services/test_tokenizer.py index 717c0bae20..659593c8c6 100644 --- a/simplecasts/podcasts/tests/test_tokenizer.py +++ b/simplecasts/tests/services/test_tokenizer.py @@ -1,4 +1,4 @@ -from simplecasts.podcasts.tokenizer import clean_text, get_stopwords, tokenize +from simplecasts.services.tokenizer import clean_text, get_stopwords, tokenize class TestStopwords: @@ -22,7 +22,7 @@ def test_extract(self): def test_extract_attribute_error(self, mocker): mocker.patch( - "simplecasts.podcasts.tokenizer._lemmatizer.lemmatize", + "simplecasts.services.tokenizer._lemmatizer.lemmatize", side_effect=AttributeError, ) assert tokenize("en", "the cat sits on the mat") == [] diff --git a/simplecasts/podcasts/tests/test_admin.py b/simplecasts/tests/test_admin.py similarity index 71% rename from simplecasts/podcasts/tests/test_admin.py rename to simplecasts/tests/test_admin.py index a42e338547..98e99ec539 100644 --- a/simplecasts/podcasts/tests/test_admin.py +++ b/simplecasts/tests/test_admin.py @@ -5,9 +5,11 @@ from django.contrib.admin.sites import AdminSite from django.utils import timezone -from simplecasts.podcasts.admin import ( +from simplecasts.admin import ( ActiveFilter, + AudioLogAdmin, CategoryAdmin, + EpisodeAdmin, FeedStatusFilter, PodcastAdmin, PrivateFilter, @@ -17,19 +19,51 @@ SubscribedFilter, SubscriptionAdmin, ) -from simplecasts.podcasts.models import Category, Podcast, Recommendation, Subscription -from simplecasts.podcasts.tests.factories import ( +from simplecasts.models import ( + AudioLog, + Category, + Episode, + Podcast, + Recommendation, + Subscription, +) +from simplecasts.tests.factories import ( + AudioLogFactory, + EpisodeFactory, PodcastFactory, RecommendationFactory, SubscriptionFactory, ) +# ============================================================================= +# Category Admin +# ============================================================================= + @pytest.fixture(scope="module") def category_admin(): return CategoryAdmin(Category, AdminSite()) +class TestCategoryAdmin: + @pytest.mark.django_db + def test_get_queryset(self, rf, category_admin, category): + podcasts = PodcastFactory.create_batch(3, active=True, parsed=timezone.now()) + category.podcasts.set(podcasts) + category = Category.objects.first() + for podcast in podcasts: + podcast.categories.add(category) + req = rf.get("/") + req._messages = mock.Mock() + qs = category_admin.get_queryset(req) + assert category_admin.num_podcasts(qs.first()) == 3 + + +# ============================================================================= +# Podcast Admin +# ============================================================================= + + @pytest.fixture(scope="module") def podcast_admin(): return PodcastAdmin(Podcast, AdminSite()) @@ -47,17 +81,6 @@ def req(rf): return req -class TestCategoryAdmin: - @pytest.mark.django_db - def test_get_queryset(self, req, category_admin, podcasts, category): - category.podcasts.set(podcasts) - category = Category.objects.first() - for podcast in podcasts: - podcast.categories.add(category) - qs = category_admin.get_queryset(req) - assert category_admin.num_podcasts(qs.first()) == 3 - - class TestPodcastAdmin: @pytest.mark.django_db def test_get_queryset(self, podcasts, podcast_admin, req): @@ -92,7 +115,7 @@ def test_get_ordering_search_term(self, podcast_admin, req): @pytest.mark.django_db def test_next_scheduled_update(self, mocker, podcast, podcast_admin): mocker.patch( - "simplecasts.podcasts.admin.Podcast.get_next_scheduled_update", + "simplecasts.admin.Podcast.get_next_scheduled_update", return_value=timezone.now() + datetime.timedelta(hours=3), ) assert ( @@ -102,7 +125,7 @@ def test_next_scheduled_update(self, mocker, podcast, podcast_admin): @pytest.mark.django_db def test_next_scheduled_update_in_past(self, mocker, podcast, podcast_admin): mocker.patch( - "simplecasts.podcasts.admin.Podcast.get_next_scheduled_update", + "simplecasts.admin.Podcast.get_next_scheduled_update", return_value=timezone.now() + datetime.timedelta(hours=-3), ) assert podcast_admin.next_scheduled_update(podcast) == "3\xa0hours ago" @@ -250,6 +273,11 @@ def test_true(self, podcasts, podcast_admin, req, subscribed): assert qs.first() == subscribed +# ============================================================================= +# Recommendation Admin +# ============================================================================= + + class TestRecommendationAdmin: @pytest.mark.django_db def test_get_queryset(self, rf): @@ -259,6 +287,11 @@ def test_get_queryset(self, rf): assert qs.count() == 1 +# ============================================================================= +# Subscription Admin +# ============================================================================= + + class TestSubscriptionAdmin: @pytest.mark.django_db def test_get_queryset(self, rf): @@ -266,3 +299,67 @@ def test_get_queryset(self, rf): admin = SubscriptionAdmin(Subscription, AdminSite()) qs = admin.get_queryset(rf.get("/")) assert qs.count() == 1 + + +# ============================================================================= +# Episode Admin +# ============================================================================= + + +class TestEpisodeAdmin: + @pytest.fixture(scope="class") + def admin(self): + return EpisodeAdmin(Episode, AdminSite()) + + @pytest.mark.django_db + def test_episode_title(self, admin): + episode = EpisodeFactory(title="testing") + assert admin.episode_title(episode) == "testing" + + @pytest.mark.django_db + def test_podcast_title(self, admin): + episode = EpisodeFactory(podcast=PodcastFactory(title="testing")) + assert admin.podcast_title(episode) == "testing" + + @pytest.mark.django_db + def test_get_ordering_no_search_term(self, admin, rf): + ordering = admin.get_ordering(rf.get("/")) + assert ordering == ["-id"] + + @pytest.mark.django_db + def test_get_ordering_search_term(self, admin, rf): + ordering = admin.get_ordering(rf.get("/", {"q": "test"})) + assert ordering == [] + + @pytest.mark.django_db + def test_get_search_results_no_search_term(self, rf, admin): + EpisodeFactory.create_batch(3) + qs, _ = admin.get_search_results(rf.get("/"), Episode.objects.all(), "") + assert qs.count() == 3 + + @pytest.mark.django_db + def test_get_search_results(self, rf, admin): + EpisodeFactory.create_batch(3) + + episode = EpisodeFactory(title="testing python") + + qs, _ = admin.get_search_results( + rf.get("/"), Episode.objects.all(), "testing python" + ) + assert qs.count() == 1 + assert qs.first() == episode + + +# ============================================================================= +# AudioLog Admin +# ============================================================================= + + +class TestAudioLogAdmin: + @pytest.mark.django_db + def test_get_queryset(self, rf): + AudioLogFactory() + admin = AudioLogAdmin(AudioLog, AdminSite()) + request = rf.get("/") + qs = admin.get_queryset(request) + assert qs.count() == 1 diff --git a/simplecasts/tests/test_commands.py b/simplecasts/tests/test_commands.py new file mode 100644 index 0000000000..15ab8d7727 --- /dev/null +++ b/simplecasts/tests/test_commands.py @@ -0,0 +1,214 @@ +from datetime import timedelta + +import pytest +from django.core.management import CommandError, call_command +from django.utils import timezone + +from simplecasts.services.itunes import Feed, ItunesError +from simplecasts.tests.factories import ( + AudioLogFactory, + BookmarkFactory, + CategoryFactory, + EmailAddressFactory, + EpisodeFactory, + PodcastFactory, + RecommendationFactory, + SubscriptionFactory, +) + +# ============================================================================= +# Parse Podcast Feeds +# ============================================================================= + + +class TestParsePodcastFeeds: + parse_feed = "simplecasts.management.commands.parse_podcast_feeds.parse_feed" + + @pytest.fixture + def mock_parse(self, mocker): + return mocker.patch(self.parse_feed) + + @pytest.mark.django_db + def test_ok(self, mocker): + mock_parse = mocker.patch(self.parse_feed) + PodcastFactory(pub_date=None) + call_command("parse_podcast_feeds") + mock_parse.assert_called() + + @pytest.mark.django_db + def test_not_scheduled(self, mocker): + mock_parse = mocker.patch(self.parse_feed) + PodcastFactory(active=False) + call_command("parse_podcast_feeds") + mock_parse.assert_not_called() + + +# ============================================================================= +# Fetch iTunes Feeds +# ============================================================================= + + +class TestFetchItunesFeeds: + mock_fetch = ( + "simplecasts.management.commands.fetch_itunes_feeds.itunes.fetch_top_feeds" + ) + mock_save = ( + "simplecasts.management.commands.fetch_itunes_feeds.itunes.save_feeds_to_db" + ) + + @pytest.fixture + def category(self): + return CategoryFactory(itunes_genre_id=1301) + + @pytest.fixture + def feed(self): + return Feed( + artworkUrl100="https://example.com/test.jpg", + collectionName="example", + collectionViewUrl="https://example.com/", + feedUrl="https://example.com/rss/", + ) + + @pytest.mark.django_db + def test_ok(self, category, mocker, feed): + mock_fetch = mocker.patch(self.mock_fetch, return_value=[feed]) + mock_save_feeds = mocker.patch(self.mock_save) + call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) + mock_fetch.assert_called() + mock_save_feeds.assert_any_call([feed], promoted=True) + mock_save_feeds.assert_any_call([feed]) + + @pytest.mark.django_db + def test_invalid_country_codes(self): + with pytest.raises(CommandError): + call_command( + "fetch_itunes_feeds", min_jitter=0, max_jitter=0, countries=["us", "tx"] + ) + + @pytest.mark.django_db + def test_no_chart_feeds(self, category, mocker, feed): + mock_fetch = mocker.patch(self.mock_fetch, return_value=[]) + mock_save_feeds = mocker.patch(self.mock_save) + call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) + mock_fetch.assert_called() + mock_save_feeds.assert_not_called() + + @pytest.mark.django_db + def test_itunes_error(self, mocker): + mock_fetch = mocker.patch( + self.mock_fetch, side_effect=ItunesError("Error fetching iTunes") + ) + mock_save_feeds = mocker.patch(self.mock_save) + call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) + mock_fetch.assert_called() + mock_save_feeds.assert_not_called() + + +# ============================================================================= +# Create Podcast Recommendations +# ============================================================================= + + +class TestCreatePodcastRecommendations: + @pytest.mark.django_db + def test_create_recommendations(self, mocker): + patched = mocker.patch( + "simplecasts.services.recommender.recommend", + return_value=RecommendationFactory.create_batch(3), + ) + call_command("create_podcast_recommendations") + patched.assert_called() + + +# ============================================================================= +# Send Podcast Recommendations +# ============================================================================= + + +class TestSendPodcastRecommendations: + @pytest.fixture + def recipient(self): + return EmailAddressFactory(verified=True, primary=True) + + @pytest.mark.django_db(transaction=True) + def test_ok(self, recipient, mailoutbox): + podcast = SubscriptionFactory(subscriber=recipient.user).podcast + RecommendationFactory(podcast=podcast) + call_command("send_podcast_recommendations") + assert len(mailoutbox) == 1 + + @pytest.mark.django_db(transaction=True) + def test_no_recommendations(self, recipient, mailoutbox): + PodcastFactory() + call_command("send_podcast_recommendations") + assert len(mailoutbox) == 0 + + +# ============================================================================= +# Send Episode Notifications +# ============================================================================= + + +class TestSendEpisodeNotifications: + @pytest.fixture + def recipient(self): + return EmailAddressFactory( + verified=True, + primary=True, + ) + + @pytest.mark.django_db(transaction=True) + def test_has_episodes(self, mailoutbox, recipient): + subscription = SubscriptionFactory( + subscriber=recipient.user, + ) + EpisodeFactory.create_batch( + 3, + podcast=subscription.podcast, + pub_date=timezone.now() - timedelta(days=1), + ) + call_command("send_episode_notifications") + assert len(mailoutbox) == 1 + assert mailoutbox[0].to == [recipient.email] + + @pytest.mark.django_db(transaction=True) + def test_is_bookmarked(self, mailoutbox, recipient): + subscription = SubscriptionFactory( + subscriber=recipient.user, + ) + episode = EpisodeFactory( + podcast=subscription.podcast, + pub_date=timezone.now() - timedelta(days=1), + ) + BookmarkFactory(episode=episode, user=recipient.user) + call_command("send_episode_notifications") + assert len(mailoutbox) == 0 + + @pytest.mark.django_db(transaction=True) + def test_no_new_episodes(self, mailoutbox, recipient): + subscription = SubscriptionFactory( + subscriber=recipient.user, + ) + EpisodeFactory.create_batch( + 3, + podcast=subscription.podcast, + pub_date=timezone.now() - timedelta(days=10), + ) + call_command("send_episode_notifications") + assert len(mailoutbox) == 0 + + @pytest.mark.django_db(transaction=True) + def test_listened(self, mailoutbox, recipient): + subscription = SubscriptionFactory( + subscriber=recipient.user, + ) + episode = EpisodeFactory( + podcast=subscription.podcast, + pub_date=timezone.now() - timedelta(days=1), + ) + AudioLogFactory( + episode=episode, + user=recipient.user, + ) + call_command("send_episode_notifications") + assert len(mailoutbox) == 0 diff --git a/simplecasts/tests/test_middleware.py b/simplecasts/tests/test_middleware.py index c376aaa0ac..ea23df30bb 100644 --- a/simplecasts/tests/test_middleware.py +++ b/simplecasts/tests/test_middleware.py @@ -8,6 +8,8 @@ HtmxCacheMiddleware, HtmxMessagesMiddleware, HtmxRedirectMiddleware, + PlayerDetails, + PlayerMiddleware, SearchDetails, SearchMiddleware, ) @@ -161,3 +163,47 @@ def _get_response(_): req._messages = messages resp = mw(req) assert b"OK" not in resp.content + + +class TestPlayerMiddleware: + def test_middleware(self, rf, get_response): + req = rf.get("/") + PlayerMiddleware(get_response)(req) + assert req.player + + +class TestPlayerDetails: + episode_id = 12345 + + @pytest.fixture + def player_req(self, rf): + req = rf.get("/") + req.session = {} + return req + + @pytest.fixture + def player(self, player_req): + return PlayerDetails(request=player_req) + + def test_get_if_none(self, player): + assert player.get() is None + + def test_get_if_not_none(self, player): + player.set(self.episode_id) + assert player.get() == self.episode_id + + def test_pop_if_none(self, player): + assert player.pop() is None + + def test_pop_if_not_none(self, player): + player.set(self.episode_id) + + assert player.pop() == self.episode_id + assert player.get() is None + + def test_has_false(self, player): + assert not player.has(self.episode_id) + + def test_has_true(self, player): + player.set(self.episode_id) + assert player.has(self.episode_id) diff --git a/simplecasts/tests/test_search.py b/simplecasts/tests/test_search.py deleted file mode 100644 index 01473f4299..0000000000 --- a/simplecasts/tests/test_search.py +++ /dev/null @@ -1,62 +0,0 @@ -import pytest - -from simplecasts.episodes.models import AudioLog -from simplecasts.episodes.tests.factories import AudioLogFactory, EpisodeFactory -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.tests.factories import PodcastFactory -from simplecasts.search import search_queryset - - -class TestSearchQueryset: - @pytest.mark.django_db - def test_empty_value(self): - result = search_queryset(Podcast.objects.all(), "", "search_vector") - assert result.count() == 0 - - @pytest.mark.django_db - def test_search_found(self): - podcast_1 = PodcastFactory(title="Learn Python Programming") - podcast_2 = PodcastFactory(title="Advanced Python Techniques") - - PodcastFactory(title="JavaScript Basics") - result = search_queryset( - Podcast.objects.all(), - "Python", - "search_vector", - ) - - assert result.count() == 2 - - assert podcast_1 in result - assert podcast_2 in result - - @pytest.mark.django_db - def test_multiple_joined_fields(self): - audio_log_1 = AudioLogFactory( - episode=EpisodeFactory( - title="This is a test transcript about Django.", - podcast__title="Django Podcast", - ) - ) - audio_log_2 = AudioLogFactory( - episode=EpisodeFactory( - title="Django for beginners.", - podcast__title="Web Dev Podcast", - ) - ) - AudioLogFactory( - episode=EpisodeFactory( - title="Learning Flask framework.", - podcast__title="Flask Podcast", - ) - ) - - result = search_queryset( - AudioLog.objects.all(), - "Django", - "episode__search_vector", - "episode__podcast__search_vector", - ) - assert result.count() == 2 - assert audio_log_1 in result - assert audio_log_2 in result diff --git a/simplecasts/tests/test_templatetags.py b/simplecasts/tests/test_templatetags.py index 152aeb367e..5627b6ec0a 100644 --- a/simplecasts/tests/test_templatetags.py +++ b/simplecasts/tests/test_templatetags.py @@ -2,8 +2,15 @@ from django.contrib.sites.models import Site from django.template import TemplateSyntaxError -from simplecasts.request import RequestContext -from simplecasts.templatetags import cookie_banner, fragment +from simplecasts.http.request import RequestContext +from simplecasts.middleware import PlayerDetails +from simplecasts.templatetags import ( + audio_player, + cookie_banner, + format_duration, + fragment, + get_media_metadata, +) @pytest.fixture @@ -41,3 +48,96 @@ def test_accepted(self, rf): req.COOKIES = {"accept-cookies": True} context = RequestContext(request=req) assert cookie_banner(context)["cookies_accepted"] is True + + +# ============================================================================= +# Episode Template Tags +# ============================================================================= + + +class TestFormatDuration: + @pytest.mark.parametrize( + ("duration", "expected"), + [ + pytest.param(0, "", id="zero"), + pytest.param(30, "", id="30 seconds"), + pytest.param(60, "1\xa0minute", id="1 minute"), + pytest.param(61, "1\xa0minute", id="just over 1 minute"), + pytest.param(90, "1\xa0minute", id="1 minute 30 seconds"), + pytest.param(540, "9\xa0minutes", id="9 minutes"), + pytest.param(2400, "40\xa0minutes", id="40 minutes"), + pytest.param(3600, "1\xa0hour", id="1 hour"), + pytest.param(9000, "2\xa0hours, 30\xa0minutes", id="2 hours 30 minutes"), + ], + ) + def test_format_duration(self, duration, expected): + assert format_duration(duration) == expected + + +class TestGetMediaMetadata: + @pytest.mark.django_db + def test_get_media_metadata(self, rf, episode): + req = rf.get("/") + context = RequestContext(request=req) + assert get_media_metadata(context, episode) + + +class TestAudioPlayer: + @pytest.mark.django_db + def test_close(self, rf, audio_log): + req = rf.get("/") + req.user = audio_log.user + req.player = PlayerDetails(request=req) + req.session = {req.player.session_id: audio_log.episode_id} + + context = RequestContext(request=req) + + dct = audio_player(context, audio_log, action="close") + assert "audio_log" not in dct + + @pytest.mark.django_db + def test_play(self, rf, audio_log): + req = rf.get("/") + req.user = audio_log.user + + context = RequestContext(request=req) + + dct = audio_player(context, audio_log, action="play") + assert dct["audio_log"] == audio_log + + @pytest.mark.django_db + def test_load(self, rf, audio_log): + req = rf.get("/") + req.user = audio_log.user + req.player = PlayerDetails(request=req) + req.session = {req.player.session_id: audio_log.episode_id} + + context = RequestContext(request=req) + + dct = audio_player(context, None, action="load") + assert dct["audio_log"] == audio_log + + @pytest.mark.django_db + def test_load_empty(self, rf, audio_log): + req = rf.get("/") + req.user = audio_log.user + req.player = PlayerDetails(request=req) + req.session = {} + + context = RequestContext(request=req) + + dct = audio_player(context, None, action="load") + assert dct["audio_log"] is None + + @pytest.mark.django_db + def test_load_user_not_authenticated(self, rf, audio_log, anonymous_user): + req = rf.get("/") + req.user = anonymous_user + req.player = PlayerDetails(request=req) + req.session = {} + req.session = {req.player.session_id: audio_log.episode_id} + + context = RequestContext(request=req) + + dct = audio_player(context, None, action="load") + assert dct["audio_log"] is None diff --git a/simplecasts/podcasts/management/commands/__init__.py b/simplecasts/tests/views/__init__.py similarity index 100% rename from simplecasts/podcasts/management/commands/__init__.py rename to simplecasts/tests/views/__init__.py diff --git a/simplecasts/tests/views/test_bookmarks.py b/simplecasts/tests/views/test_bookmarks.py new file mode 100644 index 0000000000..d400a3017d --- /dev/null +++ b/simplecasts/tests/views/test_bookmarks.py @@ -0,0 +1,89 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertTemplateUsed + +from simplecasts.models import Bookmark +from simplecasts.tests.asserts import assert200, assert409 +from simplecasts.tests.factories import BookmarkFactory, EpisodeFactory, PodcastFactory + + +class TestBookmarks: + url = reverse_lazy("bookmarks:bookmarks") + + @pytest.mark.django_db + def test_get(self, client, auth_user): + BookmarkFactory.create_batch(33, user=auth_user) + + response = client.get(self.url) + + assert200(response) + assertTemplateUsed(response, "episodes/bookmarks.html") + + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_ascending(self, client, auth_user): + BookmarkFactory.create_batch(33, user=auth_user) + + response = client.get(self.url, {"order": "asc"}) + + assert200(response) + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(self.url) + assert200(response) + + @pytest.mark.django_db + def test_search(self, client, auth_user): + podcast = PodcastFactory(title="zzzz") + + for _ in range(3): + BookmarkFactory( + user=auth_user, + episode=EpisodeFactory(title="zzzz", podcast=podcast), + ) + + BookmarkFactory(user=auth_user, episode=EpisodeFactory(title="testing")) + + response = client.get(self.url, {"search": "testing"}) + + assert200(response) + assertTemplateUsed(response, "episodes/bookmarks.html") + + assert len(response.context["page"].object_list) == 1 + + +class TestAddBookmark: + @pytest.mark.django_db + def test_post(self, client, auth_user, episode): + response = client.post(self.url(episode), headers={"HX-Request": "true"}) + + assert200(response) + assert Bookmark.objects.filter(user=auth_user, episode=episode).exists() + + @pytest.mark.django_db(transaction=True) + def test_already_bookmarked(self, client, auth_user, episode): + BookmarkFactory(episode=episode, user=auth_user) + + response = client.post(self.url(episode), headers={"HX-Request": "true"}) + assert409(response) + + assert Bookmark.objects.filter(user=auth_user, episode=episode).exists() + + def url(self, episode): + return reverse("bookmarks:add_bookmark", args=[episode.pk]) + + +class TestRemoveBookmark: + @pytest.mark.django_db + def test_post(self, client, auth_user, episode): + BookmarkFactory(user=auth_user, episode=episode) + response = client.delete( + reverse("bookmarks:remove_bookmark", args=[episode.pk]), + headers={"HX-Request": "true"}, + ) + assert200(response) + + assert not Bookmark.objects.filter(user=auth_user, episode=episode).exists() diff --git a/simplecasts/tests/views/test_episodes.py b/simplecasts/tests/views/test_episodes.py new file mode 100644 index 0000000000..f658c6e317 --- /dev/null +++ b/simplecasts/tests/views/test_episodes.py @@ -0,0 +1,138 @@ +import pytest +from django.urls import reverse_lazy +from django.utils import timezone +from pytest_django.asserts import assertContains, assertTemplateUsed + +from simplecasts.tests.asserts import assert200 +from simplecasts.tests.factories import ( + AudioLogFactory, + EpisodeFactory, + PodcastFactory, + SubscriptionFactory, +) + +_index_url = reverse_lazy("episodes:index") + + +class TestNewReleases: + @pytest.mark.django_db + def test_no_episodes(self, client, auth_user): + response = client.get(_index_url) + assert200(response) + assertTemplateUsed(response, "episodes/index.html") + assert len(response.context["episodes"]) == 0 + + @pytest.mark.django_db + def test_has_no_subscriptions(self, client, auth_user): + EpisodeFactory.create_batch(3) + response = client.get(_index_url) + + assert200(response) + assertTemplateUsed(response, "episodes/index.html") + assert len(response.context["episodes"]) == 0 + + @pytest.mark.django_db + def test_has_subscriptions(self, client, auth_user): + episode = EpisodeFactory() + SubscriptionFactory(subscriber=auth_user, podcast=episode.podcast) + + response = client.get(_index_url) + + assert200(response) + assertTemplateUsed(response, "episodes/index.html") + assert len(response.context["episodes"]) == 1 + + +class TestEpisodeDetail: + @pytest.fixture + def episode(self, faker): + return EpisodeFactory( + podcast=PodcastFactory( + owner=faker.name(), + website=faker.url(), + funding_url=faker.url(), + funding_text=faker.text(), + explicit=True, + ), + episode_type="full", + file_size=9000, + duration="3:30:30", + ) + + @pytest.mark.django_db + def test_ok(self, client, auth_user, episode): + response = client.get(episode.get_absolute_url()) + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == episode + + @pytest.mark.django_db + def test_listened(self, client, auth_user, episode): + AudioLogFactory( + episode=episode, + user=auth_user, + current_time=900, + listened=timezone.now(), + ) + + response = client.get(episode.get_absolute_url()) + + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == episode + + assertContains(response, "Remove episode from your History") + assertContains(response, "Listened") + + @pytest.mark.django_db + def test_bookmarked(self, client, auth_user, episode): + from simplecasts.tests.factories import BookmarkFactory + + BookmarkFactory(user=auth_user, episode=episode) + + response = client.get(episode.get_absolute_url()) + assert200(response) + + assertContains(response, "Remove from Bookmarks") + + @pytest.mark.django_db + def test_is_not_playing(self, client, auth_user, episode): + response = client.get(episode.get_absolute_url()) + + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == episode + + assertContains(response, "Play") + + @pytest.mark.django_db + def test_is_playing(self, client, auth_user, player_episode): + response = client.get(player_episode.get_absolute_url()) + + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == player_episode + + assertContains(response, "Close Player") + + +class TestSearchEpisodes: + url = reverse_lazy("episodes:search_episodes") + + @pytest.mark.django_db + def test_search(self, client, auth_user): + podcast = PodcastFactory() + EpisodeFactory.create_batch(3, podcast=podcast, title="testing") + EpisodeFactory.create_batch(3, podcast=podcast, title="xyz") + response = client.get(self.url, {"search": "testing"}) + + assert200(response) + assertTemplateUsed(response, "episodes/search_episodes.html") + + assert len(response.context["page"].object_list) == 3 + + @pytest.mark.django_db + def test_redirect_no_search(self, client, auth_user): + response = client.get(self.url) + assert response.status_code == 302 + assert response.url == _index_url diff --git a/simplecasts/tests/views/test_history.py b/simplecasts/tests/views/test_history.py new file mode 100644 index 0000000000..8a052cc44e --- /dev/null +++ b/simplecasts/tests/views/test_history.py @@ -0,0 +1,142 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertTemplateUsed + +from simplecasts.models import AudioLog +from simplecasts.tests.asserts import assert200, assert404 +from simplecasts.tests.factories import AudioLogFactory, EpisodeFactory, PodcastFactory + + +class TestHistory: + url = reverse_lazy("history:history") + + @pytest.mark.django_db + def test_get(self, client, auth_user): + AudioLogFactory.create_batch(33, user=auth_user) + response = client.get(self.url) + assert200(response) + assertTemplateUsed(response, "episodes/history.html") + + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(self.url) + assert200(response) + + @pytest.mark.django_db + def test_ascending(self, client, auth_user): + AudioLogFactory.create_batch(33, user=auth_user) + + response = client.get(self.url, {"order": "asc"}) + assert200(response) + + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_search(self, client, auth_user): + podcast = PodcastFactory(title="zzzz") + + for _ in range(3): + AudioLogFactory( + user=auth_user, + episode=EpisodeFactory(title="zzzz", podcast=podcast), + ) + + AudioLogFactory(user=auth_user, episode=EpisodeFactory(title="testing")) + response = client.get(self.url, {"search": "testing"}) + + assert200(response) + assert len(response.context["page"].object_list) == 1 + + +class TestMarkAudioLogComplete: + @pytest.mark.django_db + def test_ok(self, client, auth_user, episode): + audio_log = AudioLogFactory(user=auth_user, episode=episode, current_time=300) + + response = client.post( + self.url(episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + + assert200(response) + + audio_log.refresh_from_db() + assert audio_log.current_time == 0 + + @pytest.mark.django_db + def test_is_playing(self, client, auth_user, player_episode): + """Do not mark complete if episode is currently playing""" + + response = client.post( + self.url(player_episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + + assert404(response) + + assert AudioLog.objects.filter(user=auth_user, episode=player_episode).exists() + + def url(self, episode): + return reverse("history:mark_complete", args=[episode.pk]) + + +class TestRemoveAudioLog: + @pytest.mark.django_db + def test_ok(self, client, auth_user, episode): + AudioLogFactory(user=auth_user, episode=episode) + AudioLogFactory(user=auth_user) + + response = client.delete( + self.url(episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + + assert200(response) + + assert not AudioLog.objects.filter(user=auth_user, episode=episode).exists() + assert AudioLog.objects.filter(user=auth_user).count() == 1 + + @pytest.mark.django_db + def test_is_playing(self, client, auth_user, player_episode): + """Do not remove log if episode is currently playing""" + + response = client.delete( + self.url(player_episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + + assert404(response) + assert AudioLog.objects.filter(user=auth_user, episode=player_episode).exists() + + @pytest.mark.django_db + def test_none_remaining(self, client, auth_user, episode): + log = AudioLogFactory(user=auth_user, episode=episode) + + response = client.delete( + self.url(log.episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + assert200(response) + + assert not AudioLog.objects.filter(user=auth_user, episode=episode).exists() + assert not AudioLog.objects.filter(user=auth_user).exists() + + def url(self, episode): + return reverse("history:remove_audio_log", args=[episode.pk]) diff --git a/simplecasts/tests/test_paginator.py b/simplecasts/tests/views/test_paginator.py similarity index 97% rename from simplecasts/tests/test_paginator.py rename to simplecasts/tests/views/test_paginator.py index a0cec1072a..aa224ee2b3 100644 --- a/simplecasts/tests/test_paginator.py +++ b/simplecasts/tests/views/test_paginator.py @@ -1,7 +1,7 @@ import pytest from django.core.paginator import EmptyPage, PageNotAnInteger -from simplecasts.paginator import Paginator, validate_page_number +from simplecasts.views.paginator import Paginator, validate_page_number class TestPage: diff --git a/simplecasts/tests/test_partials.py b/simplecasts/tests/views/test_partials.py similarity index 95% rename from simplecasts/tests/test_partials.py rename to simplecasts/tests/views/test_partials.py index ea3fbdab06..bd64e13434 100644 --- a/simplecasts/tests/test_partials.py +++ b/simplecasts/tests/views/test_partials.py @@ -1,6 +1,6 @@ from django_htmx.middleware import HtmxDetails -from simplecasts.partials import render_partial_response +from simplecasts.views.partials import render_partial_response class TestRenderPartialResponse: diff --git a/simplecasts/tests/views/test_player.py b/simplecasts/tests/views/test_player.py new file mode 100644 index 0000000000..f8aab8423f --- /dev/null +++ b/simplecasts/tests/views/test_player.py @@ -0,0 +1,220 @@ +import json + +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertContains + +from simplecasts.middleware import PlayerDetails +from simplecasts.models import AudioLog +from simplecasts.tests.asserts import ( + assert200, + assert204, + assert400, + assert401, + assert409, +) +from simplecasts.tests.factories import EpisodeFactory + + +class TestStartPlayer: + @pytest.mark.django_db + def test_play_from_start(self, client, auth_user, episode): + response = client.post( + self.url(episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + assert200(response) + assertContains(response, 'id="audio-player-button"') + + assert AudioLog.objects.filter(user=auth_user, episode=episode).exists() + assert client.session[PlayerDetails.session_id] == episode.pk + + @pytest.mark.django_db + def test_another_episode_in_player(self, client, auth_user, player_episode): + episode = EpisodeFactory() + response = client.post( + self.url(episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + + assert200(response) + assertContains(response, 'id="audio-player-button"') + + assert AudioLog.objects.filter(user=auth_user, episode=episode).exists() + + assert client.session[PlayerDetails.session_id] == episode.pk + + @pytest.mark.django_db + def test_resume(self, client, auth_user, player_episode): + response = client.post( + self.url(player_episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + + assert200(response) + assertContains(response, 'id="audio-player-button"') + + assert client.session[PlayerDetails.session_id] == player_episode.pk + + def url(self, episode): + return reverse("player:start_player", args=[episode.pk]) + + +class TestClosePlayer: + url = reverse_lazy("player:close_player") + + @pytest.mark.django_db + def test_player_empty(self, client, auth_user, episode): + response = client.post( + self.url, + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + + assert204(response) + + @pytest.mark.django_db + def test_close( + self, + client, + player_episode, + ): + response = client.post( + self.url, + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + + assert200(response) + assertContains(response, 'id="audio-player-button"') + + assert player_episode.pk not in client.session + + +class TestPlayerTimeUpdate: + url = reverse_lazy("player:player_time_update") + + @pytest.mark.django_db + def test_is_running(self, client, player_episode): + response = client.post( + self.url, + json.dumps( + { + "current_time": 1030, + "duration": 3600, + } + ), + content_type="application/json", + ) + + assert200(response) + + log = AudioLog.objects.first() + assert log is not None + + assert log.current_time == 1030 + + @pytest.mark.django_db + def test_player_log_missing(self, client, auth_user, episode): + session = client.session + session[PlayerDetails.session_id] = episode.pk + session.save() + + response = client.post( + self.url, + json.dumps( + { + "current_time": 1030, + "duration": 3600, + } + ), + content_type="application/json", + ) + + assert200(response) + + log = AudioLog.objects.get() + + assert log.current_time == 1030 + assert log.episode == episode + + @pytest.mark.django_db + def test_player_not_in_session(self, client, auth_user, episode): + response = client.post( + self.url, + json.dumps( + { + "current_time": 1030, + "duration": 3600, + } + ), + content_type="application/json", + ) + + assert400(response) + + assert not AudioLog.objects.exists() + + @pytest.mark.django_db + def test_missing_data(self, client, auth_user, player_episode): + response = client.post(self.url) + assert400(response) + + @pytest.mark.django_db + def test_invalid_data(self, client, auth_user, player_episode): + response = client.post( + self.url, + json.dumps( + { + "current_time": "xyz", + "duration": "abc", + } + ), + content_type="application/json", + ) + assert400(response) + + @pytest.mark.django_db(transaction=True) + def test_episode_does_not_exist(self, client, auth_user): + session = client.session + session[PlayerDetails.session_id] = 12345 + session.save() + + response = client.post( + self.url, + json.dumps( + { + "current_time": 1000, + "duration": 3600, + } + ), + content_type="application/json", + ) + assert409(response) + + @pytest.mark.django_db + def test_user_not_authenticated(self, client): + response = client.post( + self.url, + json.dumps( + { + "current_time": 1000, + "duration": 3600, + } + ), + content_type="application/json", + ) + assert401(response) diff --git a/simplecasts/podcasts/tests/test_views.py b/simplecasts/tests/views/test_podcasts.py similarity index 56% rename from simplecasts/podcasts/tests/test_views.py rename to simplecasts/tests/views/test_podcasts.py index 71e0835be9..b15e7f5a17 100644 --- a/simplecasts/podcasts/tests/test_views.py +++ b/simplecasts/tests/views/test_podcasts.py @@ -2,196 +2,43 @@ from django.urls import reverse, reverse_lazy from pytest_django.asserts import assertContains, assertTemplateUsed -from simplecasts.episodes.tests.factories import EpisodeFactory -from simplecasts.podcasts import itunes -from simplecasts.podcasts.models import Podcast, Subscription -from simplecasts.podcasts.tests.factories import ( +from simplecasts.models import Podcast +from simplecasts.services import itunes +from simplecasts.tests.asserts import ( + assert200, + assert404, +) +from simplecasts.tests.factories import ( CategoryFactory, + EpisodeFactory, PodcastFactory, RecommendationFactory, SubscriptionFactory, ) -from simplecasts.tests.asserts import assert200, assert404, assert409 -_subscriptions_url = reverse_lazy("podcasts:subscriptions") _discover_url = reverse_lazy("podcasts:discover") -class TestSubscriptions: - @pytest.mark.django_db - def test_authenticated_no_subscriptions(self, client, auth_user): - response = client.get(_subscriptions_url) - assert200(response) - - assertTemplateUsed(response, "podcasts/subscriptions.html") - - @pytest.mark.django_db - def test_user_is_subscribed(self, client, auth_user): - """If user subscribed any podcasts, show only own feed with these podcasts""" - - sub = SubscriptionFactory(subscriber=auth_user) - response = client.get(_subscriptions_url) - - assert200(response) - - assertTemplateUsed(response, "podcasts/subscriptions.html") - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == sub.podcast - - @pytest.mark.django_db - def test_htmx_request(self, client, auth_user): - sub = SubscriptionFactory(subscriber=auth_user) - response = client.get( - _subscriptions_url, - headers={ - "HX-Request": "true", - "HX-Target": "pagination", - }, - ) - - assert200(response) - - assertContains(response, 'id="pagination"') - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == sub.podcast - - @pytest.mark.django_db - def test_user_is_subscribed_search(self, client, auth_user): - """If user subscribed any podcasts, show only own feed with these podcasts""" - - sub = SubscriptionFactory(subscriber=auth_user) - response = client.get(_subscriptions_url, {"search": sub.podcast.title}) - - assert200(response) - - assertTemplateUsed(response, "podcasts/subscriptions.html") - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == sub.podcast - - class TestDiscover: + url = reverse_lazy("podcasts:discover") + @pytest.mark.django_db - def test_get(self, client, auth_user): - response = client.get(_discover_url) + def test_get(self, client, auth_user, settings): + settings.DISCOVER_FEED_LANGUAGE = "en" + response = client.get(self.url) + PodcastFactory.create_batch(3, promoted=True, language="en") assert200(response) assertTemplateUsed(response, "podcasts/discover.html") @pytest.mark.django_db def test_empty(self, client, auth_user): - response = client.get(_discover_url) + response = client.get(self.url) assert200(response) assertTemplateUsed(response, "podcasts/discover.html") assert len(response.context["podcasts"]) == 0 -class TestSearchPeople: - @pytest.mark.django_db - def test_get(self, client, auth_user, faker): - podcast = PodcastFactory(owner=faker.name()) - response = client.get( - reverse("podcasts:search_people"), - { - "search": podcast.cleaned_owner, - }, - ) - assert200(response) - assertTemplateUsed(response, "podcasts/search_people.html") - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(reverse("podcasts:search_people"), {"search": ""}) - assert response.url == _discover_url - - -class TestSearchPodcasts: - url = reverse_lazy("podcasts:search_podcasts") - - @pytest.mark.django_db - def test_search(self, client, auth_user, faker): - podcast = PodcastFactory(title=faker.unique.text()) - PodcastFactory.create_batch(3, title="zzz") - response = client.get(self.url, {"search": podcast.title}) - - assert200(response) - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == podcast - - @pytest.mark.django_db - def test_search_value_empty(self, client, auth_user, faker): - response = client.get(self.url, {"search": ""}) - assert response.url == _discover_url - - @pytest.mark.django_db - def test_search_filter_private(self, client, auth_user, faker): - podcast = PodcastFactory(title=faker.unique.text(), private=True) - PodcastFactory.create_batch(3, title="zzz") - response = client.get(self.url, {"search": podcast.title}) - - assert200(response) - - assert len(response.context["page"].object_list) == 0 - - @pytest.mark.django_db - def test_search_no_results(self, client, auth_user, faker): - response = client.get(self.url, {"search": "zzzz"}) - assert200(response) - assert len(response.context["page"].object_list) == 0 - - -class TestSearchItunes: - url = reverse_lazy("podcasts:search_itunes") - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(self.url, {"search": ""}) - assert response.url == _discover_url - - @pytest.mark.django_db - def test_search(self, client, auth_user, podcast, mocker): - feeds = [ - itunes.Feed( - artworkUrl100="https://assets.fireside.fm/file/fireside-images/podcasts/images/b/bc7f1faf-8aad-4135-bb12-83a8af679756/cover.jpg?v=3", - collectionName="Test & Code : Python Testing", - collectionViewUrl="https://example.com/id123456", - feedUrl="https://feeds.fireside.fm/testandcode/rss", - ), - itunes.Feed( - artworkUrl100="https://assets.fireside.fm/file/fireside-images/podcasts/images/b/bc7f1faf-8aad-4135-bb12-83a8af679756/cover.jpg?v=3", - collectionName=podcast.title, - collectionViewUrl=podcast.website, - feedUrl=podcast.rss, - ), - ] - mock_search = mocker.patch( - "simplecasts.podcasts.itunes.search_cached", - return_value=(feeds, True), - ) - - response = client.get(self.url, {"search": "test"}) - assert200(response) - - assertTemplateUsed(response, "podcasts/search_itunes.html") - - assertContains(response, "Test & Code : Python Testing") - assertContains(response, podcast.title) - - mock_search.assert_called() - - @pytest.mark.django_db - def test_search_error(self, client, auth_user, mocker): - mocker.patch( - "simplecasts.podcasts.itunes.search_cached", - side_effect=itunes.ItunesError("Error"), - ) - response = client.get(self.url, {"search": "test"}) - assert response.url == _discover_url - - class TestPodcastSimilar: @pytest.mark.django_db def test_get(self, client, auth_user, podcast): @@ -396,8 +243,93 @@ def test_search(self, client, auth_user, podcast, faker): assert len(response.context["page"].object_list) == 1 +class TestSearchPodcasts: + url = reverse_lazy("podcasts:search_podcasts") + + @pytest.mark.django_db + def test_search(self, client, auth_user, faker): + podcast = PodcastFactory(title=faker.unique.text()) + PodcastFactory.create_batch(3, title="zzz") + response = client.get(self.url, {"search": podcast.title}) + + assert200(response) + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == podcast + + @pytest.mark.django_db + def test_search_value_empty(self, client, auth_user, faker): + response = client.get(self.url, {"search": ""}) + assert response.url == _discover_url + + @pytest.mark.django_db + def test_search_filter_private(self, client, auth_user, faker): + podcast = PodcastFactory(title=faker.unique.text(), private=True) + PodcastFactory.create_batch(3, title="zzz") + response = client.get(self.url, {"search": podcast.title}) + + assert200(response) + + assert len(response.context["page"].object_list) == 0 + + @pytest.mark.django_db + def test_search_no_results(self, client, auth_user, faker): + response = client.get(self.url, {"search": "zzzz"}) + assert200(response) + assert len(response.context["page"].object_list) == 0 + + +class TestSearchItunes: + url = reverse_lazy("podcasts:search_itunes") + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(self.url, {"search": ""}) + assert response.url == _discover_url + + @pytest.mark.django_db + def test_search(self, client, auth_user, podcast, mocker): + feeds = [ + itunes.Feed( + artworkUrl100="https://assets.fireside.fm/file/fireside-images/podcasts/images/b/bc7f1faf-8aad-4135-bb12-83a8af679756/cover.jpg?v=3", + collectionName="Test & Code : Python Testing", + collectionViewUrl="https://example.com/id123456", + feedUrl="https://feeds.fireside.fm/testandcode/rss", + ), + itunes.Feed( + artworkUrl100="https://assets.fireside.fm/file/fireside-images/podcasts/images/b/bc7f1faf-8aad-4135-bb12-83a8af679756/cover.jpg?v=3", + collectionName=podcast.title, + collectionViewUrl=podcast.website, + feedUrl=podcast.rss, + ), + ] + mock_search = mocker.patch( + "simplecasts.services.itunes.search_cached", + return_value=(feeds, True), + ) + + response = client.get(self.url, {"search": "test"}) + assert200(response) + + assertTemplateUsed(response, "podcasts/search_itunes.html") + + assertContains(response, "Test & Code : Python Testing") + assertContains(response, podcast.title) + + mock_search.assert_called() + + @pytest.mark.django_db + def test_search_error(self, client, auth_user, mocker): + mocker.patch( + "simplecasts.services.itunes.search_cached", + side_effect=itunes.ItunesError("Error"), + ) + response = client.get(self.url, {"search": "test"}) + assert response.url == _discover_url + + class TestCategoryList: - url = reverse_lazy("podcasts:category_list") + url = reverse_lazy("podcasts:categories") @pytest.mark.django_db def test_matching_podcasts(self, client, auth_user): @@ -471,236 +403,3 @@ def test_no_podcasts(self, client, auth_user, category): assert200(response) assert len(response.context["page"].object_list) == 0 - - -class TestSubscribe: - @pytest.mark.django_db - def test_subscribe(self, client, podcast, auth_user): - response = client.post( - self.url(podcast), - headers={ - "HX-Request": "true", - }, - ) - - assert200(response) - assertContains(response, 'id="subscribe-button"') - - assert Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - @pytest.mark.django_db()(transaction=True) - def test_already_subscribed( - self, - client, - podcast, - auth_user, - ): - SubscriptionFactory(subscriber=auth_user, podcast=podcast) - response = client.post( - self.url(podcast), - headers={ - "HX-Request": "true", - "HX-Target": "subscribe-button", - }, - ) - - assert409(response) - - assert Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - @pytest.mark.django_db - def test_subscribe_private(self, client, auth_user): - podcast = PodcastFactory(private=True) - - response = client.post( - self.url(podcast), - headers={ - "HX-Request": "true", - "HX-Target": "subscribe-button", - }, - ) - - assert404(response) - - assert not Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - def url(self, podcast): - return reverse("podcasts:subscribe", args=[podcast.pk]) - - -class TestUnsubscribe: - @pytest.mark.django_db - def test_unsubscribe(self, client, auth_user, podcast): - SubscriptionFactory(subscriber=auth_user, podcast=podcast) - response = client.delete( - self.url(podcast), - headers={ - "HX-Request": "true", - "HX-Target": "subscribe-button", - }, - ) - - assert200(response) - assertContains(response, 'id="subscribe-button"') - - assert not Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - @pytest.mark.django_db - def test_unsubscribe_private(self, client, auth_user): - podcast = SubscriptionFactory( - subscriber=auth_user, podcast=PodcastFactory(private=True) - ).podcast - - response = client.delete( - self.url(podcast), - headers={ - "HX-Request": "true", - "HX-Target": "subscribe-button", - }, - ) - - assert404(response) - - assert Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - def url(self, podcast): - return reverse("podcasts:unsubscribe", args=[podcast.pk]) - - -class TestPrivateFeeds: - url = reverse_lazy("podcasts:private_feeds") - - @pytest.mark.django_db - def test_ok(self, client, auth_user): - for podcast in PodcastFactory.create_batch(33, private=True): - SubscriptionFactory(subscriber=auth_user, podcast=podcast) - response = client.get(self.url) - assert200(response) - assert len(response.context["page"]) == 30 - assert response.context["page"].has_other_pages is True - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - PodcastFactory(private=True) - response = client.get(self.url) - assert200(response) - assert len(response.context["page"]) == 0 - assert response.context["page"].has_other_pages is False - - @pytest.mark.django_db - def test_search(self, client, auth_user, faker): - podcast = SubscriptionFactory( - subscriber=auth_user, - podcast=PodcastFactory(title=faker.unique.text(), private=True), - ).podcast - - SubscriptionFactory( - subscriber=auth_user, - podcast=PodcastFactory(title="zzz", private=True), - ) - - response = client.get(self.url, {"search": podcast.title}) - assert200(response) - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == podcast - - -class TestRemovePrivateFeed: - def url(self, podcast): - return reverse("podcasts:remove_private_feed", args=[podcast.pk]) - - @pytest.mark.django_db - def test_ok(self, client, auth_user): - podcast = PodcastFactory(private=True) - SubscriptionFactory(podcast=podcast, subscriber=auth_user) - - response = client.delete( - self.url(podcast), - {"rss": podcast.rss}, - ) - assert response.url == reverse("podcasts:private_feeds") - - assert not Podcast.objects.filter(pk=podcast.pk).exists() - - @pytest.mark.django_db - def test_not_owned_by_user(self, client, auth_user): - podcast = PodcastFactory(private=True) - - response = client.delete( - self.url(podcast), - {"rss": podcast.rss}, - ) - assert404(response) - - assert Podcast.objects.filter(pk=podcast.pk).exists() - - @pytest.mark.django_db - def test_not_private_feed(self, client, auth_user): - podcast = PodcastFactory(private=False) - SubscriptionFactory(podcast=podcast, subscriber=auth_user) - response = client.delete(self.url(podcast), {"rss": podcast.rss}) - assert404(response) - - assert Podcast.objects.filter(pk=podcast.pk).exists() - - assert Subscription.objects.filter( - subscriber=auth_user, podcast=podcast - ).exists() - - -class TestAddPrivateFeed: - url = reverse_lazy("podcasts:add_private_feed") - - @pytest.fixture - def rss(self, faker): - return faker.url() - - @pytest.mark.django_db - def test_get(self, client, auth_user): - response = client.get(self.url) - assert200(response) - assertTemplateUsed(response, "podcasts/private_feed_form.html") - - @pytest.mark.django_db - def test_post_not_existing(self, client, auth_user, rss): - response = client.post(self.url, {"rss": rss}) - assert response.url == reverse("podcasts:private_feeds") - - podcast = Subscription.objects.get( - subscriber=auth_user, podcast__rss=rss - ).podcast - - assert podcast.private - - @pytest.mark.django_db - def test_existing_private(self, client, auth_user): - podcast = PodcastFactory(private=True) - - response = client.post(self.url, {"rss": podcast.rss}) - assert200(response) - - assert not Subscription.objects.filter( - subscriber=auth_user, podcast=podcast - ).exists() - - @pytest.mark.django_db - def test_existing_public(self, client, auth_user): - podcast = PodcastFactory(private=False) - - response = client.post(self.url, {"rss": podcast.rss}) - assert200(response) - - assert not Subscription.objects.filter( - subscriber=auth_user, podcast=podcast - ).exists() diff --git a/simplecasts/tests/views/test_private_feeds.py b/simplecasts/tests/views/test_private_feeds.py new file mode 100644 index 0000000000..74b6528392 --- /dev/null +++ b/simplecasts/tests/views/test_private_feeds.py @@ -0,0 +1,136 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertTemplateUsed + +from simplecasts.models import Podcast, Subscription +from simplecasts.tests.asserts import assert200, assert404 +from simplecasts.tests.factories import PodcastFactory, SubscriptionFactory + + +class TestPrivateFeeds: + url = reverse_lazy("private_feeds:private_feeds") + + @pytest.mark.django_db + def test_ok(self, client, auth_user): + for podcast in PodcastFactory.create_batch(33, private=True): + SubscriptionFactory(subscriber=auth_user, podcast=podcast) + response = client.get(self.url) + assert200(response) + assert len(response.context["page"]) == 30 + assert response.context["page"].has_other_pages is True + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + PodcastFactory(private=True) + response = client.get(self.url) + assert200(response) + assert len(response.context["page"]) == 0 + assert response.context["page"].has_other_pages is False + + @pytest.mark.django_db + def test_search(self, client, auth_user, faker): + podcast = SubscriptionFactory( + subscriber=auth_user, + podcast=PodcastFactory(title=faker.unique.text(), private=True), + ).podcast + + SubscriptionFactory( + subscriber=auth_user, + podcast=PodcastFactory(title="zzz", private=True), + ) + + response = client.get(self.url, {"search": podcast.title}) + assert200(response) + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == podcast + + +class TestRemovePrivateFeed: + def url(self, podcast): + return reverse("private_feeds:remove_private_feed", args=[podcast.pk]) + + @pytest.mark.django_db + def test_ok(self, client, auth_user): + podcast = PodcastFactory(private=True) + SubscriptionFactory(podcast=podcast, subscriber=auth_user) + + response = client.delete( + self.url(podcast), + {"rss": podcast.rss}, + ) + assert response.url == reverse("private_feeds:private_feeds") + + assert not Podcast.objects.filter(pk=podcast.pk).exists() + + @pytest.mark.django_db + def test_not_owned_by_user(self, client, auth_user): + podcast = PodcastFactory(private=True) + + response = client.delete( + self.url(podcast), + {"rss": podcast.rss}, + ) + assert404(response) + + assert Podcast.objects.filter(pk=podcast.pk).exists() + + @pytest.mark.django_db + def test_not_private_feed(self, client, auth_user): + podcast = PodcastFactory(private=False) + SubscriptionFactory(podcast=podcast, subscriber=auth_user) + response = client.delete(self.url(podcast), {"rss": podcast.rss}) + assert404(response) + + assert Podcast.objects.filter(pk=podcast.pk).exists() + + assert Subscription.objects.filter( + subscriber=auth_user, podcast=podcast + ).exists() + + +class TestAddPrivateFeed: + url = reverse_lazy("private_feeds:add_private_feed") + + @pytest.fixture + def rss(self, faker): + return faker.url() + + @pytest.mark.django_db + def test_get(self, client, auth_user): + response = client.get(self.url) + assert200(response) + assertTemplateUsed(response, "podcasts/private_feed_form.html") + + @pytest.mark.django_db + def test_post_not_existing(self, client, auth_user, rss): + response = client.post(self.url, {"rss": rss}) + assert response.url == reverse("private_feeds:private_feeds") + + podcast = Subscription.objects.get( + subscriber=auth_user, podcast__rss=rss + ).podcast + + assert podcast.private + + @pytest.mark.django_db + def test_existing_private(self, client, auth_user): + podcast = PodcastFactory(private=True) + + response = client.post(self.url, {"rss": podcast.rss}) + assert200(response) + + assert not Subscription.objects.filter( + subscriber=auth_user, podcast=podcast + ).exists() + + @pytest.mark.django_db + def test_existing_public(self, client, auth_user): + podcast = PodcastFactory(private=False) + + response = client.post(self.url, {"rss": podcast.rss}) + assert200(response) + + assert not Subscription.objects.filter( + subscriber=auth_user, podcast=podcast + ).exists() diff --git a/simplecasts/tests/views/test_search.py b/simplecasts/tests/views/test_search.py new file mode 100644 index 0000000000..cb5e3c9dd1 --- /dev/null +++ b/simplecasts/tests/views/test_search.py @@ -0,0 +1,31 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertTemplateUsed + +from simplecasts.tests.asserts import ( + assert200, +) +from simplecasts.tests.factories import ( + PodcastFactory, +) + +_discover_url = reverse_lazy("podcasts:discover") + + +class TestSearchPeople: + @pytest.mark.django_db + def test_get(self, client, auth_user, faker): + podcast = PodcastFactory(owner=faker.name()) + response = client.get( + reverse("podcasts:search_people"), + { + "search": podcast.cleaned_owner, + }, + ) + assert200(response) + assertTemplateUsed(response, "podcasts/search_people.html") + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(reverse("podcasts:search_people"), {"search": ""}) + assert response.url == _discover_url diff --git a/simplecasts/tests/views/test_subscriptions.py b/simplecasts/tests/views/test_subscriptions.py new file mode 100644 index 0000000000..73fd8dab8e --- /dev/null +++ b/simplecasts/tests/views/test_subscriptions.py @@ -0,0 +1,168 @@ +import pytest +from django.urls import reverse +from pytest_django.asserts import assertContains, assertTemplateUsed + +from simplecasts.models import Subscription +from simplecasts.tests.asserts import assert200, assert404, assert409 +from simplecasts.tests.factories import PodcastFactory, SubscriptionFactory + + +class TestSubscriptions: + @pytest.mark.django_db + def test_authenticated_no_subscriptions(self, client, auth_user): + response = client.get(reverse("subscriptions:subscriptions")) + assert200(response) + + assertTemplateUsed(response, "podcasts/subscriptions.html") + + @pytest.mark.django_db + def test_user_is_subscribed(self, client, auth_user): + """If user subscribed any podcasts, show only own feed with these podcasts""" + + sub = SubscriptionFactory(subscriber=auth_user) + response = client.get(reverse("subscriptions:subscriptions")) + + assert200(response) + + assertTemplateUsed(response, "podcasts/subscriptions.html") + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == sub.podcast + + @pytest.mark.django_db + def test_htmx_request(self, client, auth_user): + sub = SubscriptionFactory(subscriber=auth_user) + response = client.get( + reverse("subscriptions:subscriptions"), + headers={ + "HX-Request": "true", + "HX-Target": "pagination", + }, + ) + + assert200(response) + + assertContains(response, 'id="pagination"') + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == sub.podcast + + @pytest.mark.django_db + def test_user_is_subscribed_search(self, client, auth_user): + """If user subscribed any podcasts, show only own feed with these podcasts""" + + sub = SubscriptionFactory(subscriber=auth_user) + response = client.get( + reverse("subscriptions:subscriptions"), {"search": sub.podcast.title} + ) + + assert200(response) + + assertTemplateUsed(response, "podcasts/subscriptions.html") + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == sub.podcast + + +class TestSubscribe: + @pytest.mark.django_db + def test_subscribe(self, client, podcast, auth_user): + response = client.post( + self.url(podcast), + headers={ + "HX-Request": "true", + }, + ) + + assert200(response) + assertContains(response, 'id="subscribe-button"') + + assert Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + @pytest.mark.django_db(transaction=True) + def test_already_subscribed( + self, + client, + podcast, + auth_user, + ): + SubscriptionFactory(subscriber=auth_user, podcast=podcast) + response = client.post( + self.url(podcast), + headers={ + "HX-Request": "true", + "HX-Target": "subscribe-button", + }, + ) + + assert409(response) + + assert Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + @pytest.mark.django_db + def test_subscribe_private(self, client, auth_user): + podcast = PodcastFactory(private=True) + + response = client.post( + self.url(podcast), + headers={ + "HX-Request": "true", + "HX-Target": "subscribe-button", + }, + ) + + assert404(response) + + assert not Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + def url(self, podcast): + return reverse("subscriptions:subscribe", args=[podcast.pk]) + + +class TestUnsubscribe: + @pytest.mark.django_db + def test_unsubscribe(self, client, auth_user, podcast): + SubscriptionFactory(subscriber=auth_user, podcast=podcast) + response = client.delete( + self.url(podcast), + headers={ + "HX-Request": "true", + "HX-Target": "subscribe-button", + }, + ) + + assert200(response) + assertContains(response, 'id="subscribe-button"') + + assert not Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + @pytest.mark.django_db + def test_unsubscribe_private(self, client, auth_user): + podcast = SubscriptionFactory( + subscriber=auth_user, podcast=PodcastFactory(private=True) + ).podcast + + response = client.delete( + self.url(podcast), + headers={ + "HX-Request": "true", + "HX-Target": "subscribe-button", + }, + ) + + assert404(response) + + assert Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + def url(self, podcast): + return reverse("subscriptions:unsubscribe", args=[podcast.pk]) diff --git a/simplecasts/users/tests/test_views.py b/simplecasts/tests/views/test_users.py similarity index 94% rename from simplecasts/users/tests/test_views.py rename to simplecasts/tests/views/test_users.py index a8ca6d6b68..04d18d20c7 100644 --- a/simplecasts/users/tests/test_views.py +++ b/simplecasts/tests/views/test_users.py @@ -6,14 +6,19 @@ from django.urls import reverse, reverse_lazy from pytest_django.asserts import assertTemplateUsed -from simplecasts.episodes.middleware import PlayerDetails -from simplecasts.episodes.tests.factories import AudioLogFactory, BookmarkFactory -from simplecasts.podcasts.models import Subscription -from simplecasts.podcasts.tests.factories import PodcastFactory, SubscriptionFactory -from simplecasts.tests.asserts import assert200 -from simplecasts.users.models import User -from simplecasts.users.notifications import get_unsubscribe_signer -from simplecasts.users.tests.factories import EmailAddressFactory +from simplecasts.middleware import PlayerDetails +from simplecasts.models import Subscription, User +from simplecasts.services.notifications import get_unsubscribe_signer +from simplecasts.tests.asserts import ( + assert200, +) +from simplecasts.tests.factories import ( + AudioLogFactory, + BookmarkFactory, + EmailAddressFactory, + PodcastFactory, + SubscriptionFactory, +) class MockGoogleAdapter: @@ -129,7 +134,9 @@ class TestImportPodcastFeeds: def upload_file(self): return SimpleUploadedFile( "feeds.opml", - (pathlib.Path(__file__).parent / "mocks" / "feeds.opml").read_bytes(), + ( + pathlib.Path(__file__).parent.parent / "mocks" / "feeds.opml" + ).read_bytes(), content_type="text/xml", ) @@ -262,7 +269,7 @@ def test_post_confirmed(self, client, auth_user): assert not User.objects.exists() -class TestUnsubscribe: +class TestUnsubscribeNotifications: @pytest.fixture def email_address(self): return EmailAddressFactory() diff --git a/simplecasts/tests/test_views.py b/simplecasts/tests/views/test_views.py similarity index 84% rename from simplecasts/tests/test_views.py rename to simplecasts/tests/views/test_views.py index 8e5e9eac6d..23088d17c2 100644 --- a/simplecasts/tests/test_views.py +++ b/simplecasts/tests/views/test_views.py @@ -4,8 +4,11 @@ from django.urls import reverse, reverse_lazy from pytest_django.asserts import assertTemplateUsed -from simplecasts import covers -from simplecasts.tests.asserts import assert200, assert404 +from simplecasts.services import covers +from simplecasts.tests.asserts import ( + assert200, + assert404, +) class TestErrorPages: @@ -97,18 +100,21 @@ class TestCoverImage: @pytest.mark.django_db def test_ok(self, client, mocker): - mocker.patch("simplecasts.covers.fetch_cover_image", return_value=b"ok") mocker.patch( - "simplecasts.covers.process_cover_image", return_value=mocker.MagicMock() + "simplecasts.services.covers.fetch_cover_image", return_value=b"ok" ) - mocker.patch("simplecasts.covers.save_cover_image") + mocker.patch( + "simplecasts.services.covers.process_cover_image", + return_value=mocker.MagicMock(), + ) + mocker.patch("simplecasts.services.covers.save_cover_image") response = client.get(covers.get_cover_url(self.cover_url, 96)) assert200(response) @pytest.mark.django_db def test_invalid_fetch(self, client, mocker): mocker.patch( - "simplecasts.covers.fetch_cover_image", + "simplecasts.services.covers.fetch_cover_image", return_value=b"ok", side_effect=covers.CoverError(), ) @@ -117,9 +123,12 @@ def test_invalid_fetch(self, client, mocker): @pytest.mark.django_db def test_invalid_image(self, client, mocker): - mocker.patch("simplecasts.covers.fetch_cover_image", return_value=b"ok") mocker.patch( - "simplecasts.covers.save_cover_image", side_effect=covers.CoverSaveError() + "simplecasts.services.covers.fetch_cover_image", return_value=b"ok" + ) + mocker.patch( + "simplecasts.services.covers.save_cover_image", + side_effect=covers.CoverSaveError(), ) response = client.get(covers.get_cover_url(self.cover_url, 96)) assert200(response) diff --git a/simplecasts/urls/__init__.py b/simplecasts/urls/__init__.py new file mode 100644 index 0000000000..16852cc8bf --- /dev/null +++ b/simplecasts/urls/__init__.py @@ -0,0 +1,27 @@ +from django.urls import include, path + +from simplecasts import views + +urlpatterns = [ + path("", views.index, name="index"), + path("about/", views.about, name="about"), + path("privacy/", views.privacy, name="privacy"), + path("accept-cookies/", views.accept_cookies, name="accept_cookies"), + path( + "covers/<int:size>/<str:encoded_url>.webp", + views.cover_image, + name="cover_image", + ), + path("robots.txt", views.robots, name="robots"), + path("manifest.json", views.manifest, name="manifest"), + path(".well-known/assetlinks.json", views.assetlinks, name="assetlinks"), + path(".well-known/security.txt", views.security, name="security"), + path("", include("simplecasts.urls.bookmarks")), + path("", include("simplecasts.urls.episodes")), + path("", include("simplecasts.urls.history")), + path("", include("simplecasts.urls.player")), + path("", include("simplecasts.urls.podcasts")), + path("", include("simplecasts.urls.private_feeds")), + path("", include("simplecasts.urls.subscriptions")), + path("", include("simplecasts.urls.users")), +] diff --git a/simplecasts/urls/bookmarks.py b/simplecasts/urls/bookmarks.py new file mode 100644 index 0000000000..594e123c9e --- /dev/null +++ b/simplecasts/urls/bookmarks.py @@ -0,0 +1,20 @@ +from django.urls import path + +from simplecasts.views import bookmarks + +app_name = "bookmarks" + + +urlpatterns = [ + path("bookmarks/", bookmarks.bookmarks, name="bookmarks"), + path( + "bookmarks/<int:episode_id>/add/", + bookmarks.add_bookmark, + name="add_bookmark", + ), + path( + "bookmarks/<int:episode_id>/remove/", + bookmarks.remove_bookmark, + name="remove_bookmark", + ), +] diff --git a/simplecasts/urls/episodes.py b/simplecasts/urls/episodes.py new file mode 100644 index 0000000000..8f5c947249 --- /dev/null +++ b/simplecasts/urls/episodes.py @@ -0,0 +1,16 @@ +from django.urls import path + +from simplecasts.views import episodes + +app_name = "episodes" + + +urlpatterns = [ + path("new/", episodes.index, name="index"), + path("search/episodes/", episodes.search_episodes, name="search_episodes"), + path( + "episodes/<slug:slug>-<int:episode_id>/", + episodes.episode_detail, + name="detail", + ), +] diff --git a/simplecasts/urls/history.py b/simplecasts/urls/history.py new file mode 100644 index 0000000000..e2f64fe283 --- /dev/null +++ b/simplecasts/urls/history.py @@ -0,0 +1,20 @@ +from django.urls import path + +from simplecasts.views import history + +app_name = "history" + + +urlpatterns = [ + path("history/", history.history, name="history"), + path( + "history/<int:episode_id>/complete/", + history.mark_complete, + name="mark_complete", + ), + path( + "history/<int:episode_id>/remove/", + history.remove_audio_log, + name="remove_audio_log", + ), +] diff --git a/simplecasts/urls/player.py b/simplecasts/urls/player.py new file mode 100644 index 0000000000..194bf5aca2 --- /dev/null +++ b/simplecasts/urls/player.py @@ -0,0 +1,12 @@ +from django.urls import path + +from simplecasts.views import player + +app_name = "player" + + +urlpatterns = [ + path("player/start/<int:episode_id>/", player.start_player, name="start_player"), + path("player/close/", player.close_player, name="close_player"), + path("player/time-update/", player.player_time_update, name="player_time_update"), +] diff --git a/simplecasts/urls/podcasts.py b/simplecasts/urls/podcasts.py new file mode 100644 index 0000000000..c9cfe4acf7 --- /dev/null +++ b/simplecasts/urls/podcasts.py @@ -0,0 +1,52 @@ +from django.urls import path, register_converter + +from simplecasts.views import podcasts + +app_name = "podcasts" + + +class _SignedIntConverter: + regex = r"-?\d+" # allow optional leading '-' + + def to_python(self, value: str) -> int: + return int(value) + + def to_url(self, value: int) -> str: + return str(value) + + +register_converter(_SignedIntConverter, "sint") + +urlpatterns = [ + path("discover/", podcasts.discover, name="discover"), + path("categories/", podcasts.categories, name="categories"), + path("categories/<slug:slug>/", podcasts.category_detail, name="category_detail"), + path( + "podcasts/<slug:slug>-<int:podcast_id>/", + podcasts.podcast_detail, + name="detail", + ), + path( + "podcasts/<slug:slug>-<int:podcast_id>/episodes/", + podcasts.episodes, + name="episodes", + ), + path( + "podcasts/<slug:slug>-<int:podcast_id>/season/<sint:season>/", + podcasts.season, + name="season", + ), + path( + "podcasts/<slug:slug>-<int:podcast_id>/similar/", + podcasts.similar, + name="similar", + ), + path( + "podcasts/<int:podcast_id>/latest-episode/", + podcasts.latest_episode, + name="latest_episode", + ), + path("search/podcasts/", podcasts.search_podcasts, name="search_podcasts"), + path("search/itunes/", podcasts.search_itunes, name="search_itunes"), + path("search/people/", podcasts.search_people, name="search_people"), +] diff --git a/simplecasts/urls/private_feeds.py b/simplecasts/urls/private_feeds.py new file mode 100644 index 0000000000..e5af92748d --- /dev/null +++ b/simplecasts/urls/private_feeds.py @@ -0,0 +1,20 @@ +from django.urls import path + +from simplecasts.views import private_feeds + +app_name = "private_feeds" + + +urlpatterns = [ + path("private-feeds/", private_feeds.private_feeds, name="private_feeds"), + path( + "private-feeds/new/", + private_feeds.add_private_feed, + name="add_private_feed", + ), + path( + "private-feeds/<int:podcast_id>/remove/", + private_feeds.remove_private_feed, + name="remove_private_feed", + ), +] diff --git a/simplecasts/urls/subscriptions.py b/simplecasts/urls/subscriptions.py new file mode 100644 index 0000000000..7d99d32a06 --- /dev/null +++ b/simplecasts/urls/subscriptions.py @@ -0,0 +1,20 @@ +from django.urls import path + +from simplecasts.views import subscriptions + +app_name = "subscriptions" + + +urlpatterns = [ + path("subscriptions/", subscriptions.subscriptions, name="subscriptions"), + path( + "podcasts/<int:podcast_id>/subscribe/", + subscriptions.subscribe, + name="subscribe", + ), + path( + "podcasts/<int:podcast_id>/unsubscribe/", + subscriptions.unsubscribe, + name="unsubscribe", + ), +] diff --git a/simplecasts/urls/users.py b/simplecasts/urls/users.py new file mode 100644 index 0000000000..8c22bffa26 --- /dev/null +++ b/simplecasts/urls/users.py @@ -0,0 +1,22 @@ +from django.urls import path + +from simplecasts.views import users + +app_name = "users" + +urlpatterns = [ + path("account/preferences/", users.user_preferences, name="preferences"), + path("account/stats/", users.user_stats, name="stats"), + path( + "account/feeds/", + users.import_podcast_feeds, + name="import_podcast_feeds", + ), + path( + "account/feeds/export/", + users.export_podcast_feeds, + name="export_podcast_feeds", + ), + path("account/delete/", users.delete_account, name="delete_account"), + path("unsubscribe/", users.unsubscribe, name="unsubscribe"), +] diff --git a/simplecasts/users/__init__.py b/simplecasts/users/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/users/admin.py b/simplecasts/users/admin.py deleted file mode 100644 index 62beaa943b..0000000000 --- a/simplecasts/users/admin.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin - -from simplecasts.users.models import User - - -@admin.register(User) -class UserAdmin(BaseUserAdmin): - """User model admin.""" - - fieldsets = ( - *tuple(BaseUserAdmin.fieldsets or ()), - ( - "User preferences", - { - "fields": ("send_email_notifications",), - }, - ), - ) diff --git a/simplecasts/users/apps.py b/simplecasts/users/apps.py deleted file mode 100644 index 94372c07ec..0000000000 --- a/simplecasts/users/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class UsersConfig(AppConfig): - name = "simplecasts.users" - default_auto_field = "django.db.models.BigAutoField" diff --git a/simplecasts/users/migrations/0001_initial.py b/simplecasts/users/migrations/0001_initial.py deleted file mode 100644 index f1f8ab04ec..0000000000 --- a/simplecasts/users/migrations/0001_initial.py +++ /dev/null @@ -1,128 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:02 - -import django.contrib.auth.validators -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - ] - - operations = [ - migrations.CreateModel( - name="User", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("password", models.CharField(max_length=128, verbose_name="password")), - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "is_superuser", - models.BooleanField( - default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", - verbose_name="superuser status", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "first_name", - models.CharField( - blank=True, max_length=150, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - blank=True, max_length=150, verbose_name="last name" - ), - ), - ( - "email", - models.EmailField( - blank=True, max_length=254, verbose_name="email address" - ), - ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", - verbose_name="active", - ), - ), - ( - "date_joined", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" - ), - ), - ("send_email_notifications", models.BooleanField(default=True)), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.group", - verbose_name="groups", - ), - ), - ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.permission", - verbose_name="user permissions", - ), - ), - ], - options={ - "verbose_name": "user", - "verbose_name_plural": "users", - "abstract": False, - }, - ), - ] diff --git a/simplecasts/users/migrations/0002_alter_user_managers.py b/simplecasts/users/migrations/0002_alter_user_managers.py deleted file mode 100644 index 430d1a042c..0000000000 --- a/simplecasts/users/migrations/0002_alter_user_managers.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-21 08:18 - -import django.contrib.auth.models -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("users", "0001_initial"), - ] - - operations = [ - migrations.AlterModelManagers( - name="user", - managers=[ - ("objects", django.contrib.auth.models.UserManager()), - ], - ), - ] diff --git a/simplecasts/users/migrations/__init__.py b/simplecasts/users/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/users/migrations/max_migration.txt b/simplecasts/users/migrations/max_migration.txt deleted file mode 100644 index 480f655ce0..0000000000 --- a/simplecasts/users/migrations/max_migration.txt +++ /dev/null @@ -1 +0,0 @@ -0002_alter_user_managers diff --git a/simplecasts/users/tests/__init__.py b/simplecasts/users/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/users/tests/factories.py b/simplecasts/users/tests/factories.py deleted file mode 100644 index d020df646b..0000000000 --- a/simplecasts/users/tests/factories.py +++ /dev/null @@ -1,22 +0,0 @@ -import factory -from allauth.account.models import EmailAddress - -from simplecasts.users.models import User - - -class UserFactory(factory.django.DjangoModelFactory): - username = factory.Sequence(lambda n: f"user-{n}") - email = factory.Sequence(lambda n: f"user-{n}@example.com") - password = factory.django.Password("testpass") - - class Meta: - model = User - - -class EmailAddressFactory(factory.django.DjangoModelFactory): - user = factory.SubFactory(UserFactory) - email = factory.LazyAttribute(lambda a: a.user.email) - verified = True - - class Meta: - model = EmailAddress diff --git a/simplecasts/users/tests/fixtures.py b/simplecasts/users/tests/fixtures.py deleted file mode 100644 index 6ad7b0af1d..0000000000 --- a/simplecasts/users/tests/fixtures.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest -from django.contrib.auth.models import AnonymousUser -from django.test import Client - -from simplecasts.users.models import User -from simplecasts.users.tests.factories import UserFactory - - -@pytest.fixture -def user() -> User: - return UserFactory() - - -@pytest.fixture -def anonymous_user() -> AnonymousUser: - return AnonymousUser() - - -@pytest.fixture -def auth_user(client: Client, user: User) -> User: - client.force_login(user) - return user - - -@pytest.fixture -def staff_user(client: Client) -> User: - user = UserFactory(is_staff=True) - client.force_login(user) - return user diff --git a/simplecasts/users/urls.py b/simplecasts/users/urls.py deleted file mode 100644 index 2edbcfdde5..0000000000 --- a/simplecasts/users/urls.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.urls import path - -from simplecasts.users import views - -app_name = "users" - -urlpatterns = [ - path("account/preferences/", views.user_preferences, name="preferences"), - path("account/stats/", views.user_stats, name="stats"), - path( - "account/feeds/", - views.import_podcast_feeds, - name="import_podcast_feeds", - ), - path( - "account/feeds/export/", - views.export_podcast_feeds, - name="export_podcast_feeds", - ), - path("account/delete/", views.delete_account, name="delete_account"), - path("unsubscribe/", views.unsubscribe, name="unsubscribe"), -] diff --git a/simplecasts/views.py b/simplecasts/views/__init__.py similarity index 94% rename from simplecasts/views.py rename to simplecasts/views/__init__.py index 399adcfddc..b3adac6b4d 100644 --- a/simplecasts/views.py +++ b/simplecasts/views/__init__.py @@ -15,10 +15,10 @@ from django.views.decorators.cache import cache_control, cache_page from django.views.decorators.http import require_POST, require_safe -from simplecasts import covers, pwa -from simplecasts.http_client import get_client -from simplecasts.request import HttpRequest -from simplecasts.response import RenderOrRedirectResponse, TextResponse +from simplecasts.http.request import HttpRequest +from simplecasts.http.response import RenderOrRedirectResponse, TextResponse +from simplecasts.services import covers, pwa +from simplecasts.services.http_client import get_client _CACHE_TIMEOUT: Final = 60 * 60 * 24 * 365 diff --git a/simplecasts/views/bookmarks.py b/simplecasts/views/bookmarks.py new file mode 100644 index 0000000000..8bb9cce8c9 --- /dev/null +++ b/simplecasts/views/bookmarks.py @@ -0,0 +1,84 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db import IntegrityError +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.views.decorators.http import require_POST, require_safe + +from simplecasts.http.decorators import require_DELETE +from simplecasts.http.request import AuthenticatedHttpRequest +from simplecasts.http.response import HttpResponseConflict +from simplecasts.models import Episode +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def bookmarks(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Renders user's bookmarks. User can also search their bookmarks.""" + bookmarks = request.user.bookmarks.select_related("episode", "episode__podcast") + + ordering = request.GET.get("order", "desc") + order_by = "created" if ordering == "asc" else "-created" + + if request.search: + bookmarks = bookmarks.search(request.search.value).order_by("-rank", order_by) + else: + bookmarks = bookmarks.order_by(order_by) + + return render_paginated_response( + request, + "episodes/bookmarks.html", + bookmarks, + { + "ordering": ordering, + }, + ) + + +@require_POST +@login_required +def add_bookmark( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse | HttpResponseConflict: + """Add episode to bookmarks.""" + episode = get_object_or_404(Episode, pk=episode_id) + + try: + request.user.bookmarks.create(episode=episode) + except IntegrityError: + return HttpResponseConflict() + + messages.success(request, "Added to Bookmarks") + + return _render_bookmark_action(request, episode, is_bookmarked=True) + + +@require_DELETE +@login_required +def remove_bookmark( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse: + """Remove episode from bookmarks.""" + episode = get_object_or_404(Episode, pk=episode_id) + request.user.bookmarks.filter(episode=episode).delete() + + messages.info(request, "Removed from Bookmarks") + + return _render_bookmark_action(request, episode, is_bookmarked=False) + + +def _render_bookmark_action( + request: AuthenticatedHttpRequest, + episode: Episode, + *, + is_bookmarked: bool, +) -> TemplateResponse: + return TemplateResponse( + request, + "episodes/detail.html#bookmark_button", + { + "episode": episode, + "is_bookmarked": is_bookmarked, + }, + ) diff --git a/simplecasts/views/episodes.py b/simplecasts/views/episodes.py new file mode 100644 index 0000000000..6c13c35001 --- /dev/null +++ b/simplecasts/views/episodes.py @@ -0,0 +1,98 @@ +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.db.models import OuterRef, Subquery +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.views.decorators.http import require_safe + +from simplecasts.http.request import AuthenticatedHttpRequest, HttpRequest +from simplecasts.http.response import RenderOrRedirectResponse +from simplecasts.models import Episode, Podcast +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def index(request: AuthenticatedHttpRequest) -> TemplateResponse: + """List latest episodes from subscriptions.""" + + latest_episodes = ( + Podcast.objects.subscribed(request.user) + .annotate( + latest_episode=Subquery( + Episode.objects.filter(podcast_id=OuterRef("pk")) + .order_by("-pub_date", "-pk") + .values("pk")[:1] + ) + ) + .filter(latest_episode__isnull=False) + .order_by("-pub_date") + .values_list("latest_episode", flat=True)[: settings.DEFAULT_PAGE_SIZE] + ) + + episodes = ( + Episode.objects.filter(pk__in=latest_episodes) + .select_related("podcast") + .order_by("-pub_date", "-pk") + ) + + return TemplateResponse( + request, + "episodes/index.html", + { + "episodes": episodes, + }, + ) + + +@require_safe +@login_required +def episode_detail( + request: AuthenticatedHttpRequest, + episode_id: int, + slug: str | None = None, +) -> TemplateResponse: + """Renders episode detail.""" + episode = get_object_or_404( + Episode.objects.select_related("podcast"), + pk=episode_id, + ) + + audio_log = request.user.audio_logs.filter(episode=episode).first() + + is_bookmarked = request.user.bookmarks.filter(episode=episode).exists() + is_playing = request.player.has(episode.pk) + + return TemplateResponse( + request, + "episodes/detail.html", + { + "episode": episode, + "audio_log": audio_log, + "is_bookmarked": is_bookmarked, + "is_playing": is_playing, + }, + ) + + +@require_safe +@login_required +def search_episodes(request: HttpRequest) -> RenderOrRedirectResponse: + """Search any episodes in the database.""" + + if request.search: + results = ( + ( + Episode.objects.filter(podcast__private=False).search( + request.search.value + ) + ) + .select_related("podcast") + .order_by("-rank", "-pub_date") + ) + + return render_paginated_response( + request, "episodes/search_episodes.html", results + ) + + return redirect("episodes:index") diff --git a/simplecasts/views/history.py b/simplecasts/views/history.py new file mode 100644 index 0000000000..d89ec3f484 --- /dev/null +++ b/simplecasts/views/history.py @@ -0,0 +1,94 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.views.decorators.http import require_POST, require_safe + +from simplecasts.http.decorators import require_DELETE +from simplecasts.http.request import AuthenticatedHttpRequest +from simplecasts.models import AudioLog +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def history(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Renders user's listening history. User can also search history.""" + audio_logs = request.user.audio_logs.select_related("episode", "episode__podcast") + + ordering = request.GET.get("order", "desc") + order_by = "listened" if ordering == "asc" else "-listened" + + if request.search: + audio_logs = audio_logs.search(request.search.value).order_by("-rank", order_by) + else: + audio_logs = audio_logs.order_by(order_by) + + return render_paginated_response( + request, + "episodes/history.html", + audio_logs, + { + "ordering": ordering, + }, + ) + + +@require_POST +@login_required +def mark_complete( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse: + """Marks audio log complete.""" + + if request.player.has(episode_id): + raise Http404 + + audio_log = get_object_or_404( + request.user.audio_logs.select_related("episode"), + episode__pk=episode_id, + ) + + audio_log.current_time = 0 + audio_log.save() + + messages.success(request, "Episode marked complete") + + return _render_audio_log_action(request, audio_log, show_audio_log=True) + + +@require_DELETE +@login_required +def remove_audio_log( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse: + """Removes audio log from user history and returns HTMX snippet.""" + # cannot remove episode if in player + if request.player.has(episode_id): + raise Http404 + + audio_log = get_object_or_404( + request.user.audio_logs.select_related("episode"), + episode__pk=episode_id, + ) + + audio_log.delete() + + messages.info(request, "Removed from History") + + return _render_audio_log_action(request, audio_log, show_audio_log=False) + + +def _render_audio_log_action( + request: AuthenticatedHttpRequest, + audio_log: AudioLog, + *, + show_audio_log: bool, +) -> TemplateResponse: + context = {"episode": audio_log.episode} + + if show_audio_log: + context["audio_log"] = audio_log + + return TemplateResponse(request, "episodes/detail.html#audio_log", context) diff --git a/simplecasts/paginator.py b/simplecasts/views/paginator.py similarity index 97% rename from simplecasts/paginator.py rename to simplecasts/views/paginator.py index 5c2ddfa982..cc2cedd9e6 100644 --- a/simplecasts/paginator.py +++ b/simplecasts/views/paginator.py @@ -7,8 +7,8 @@ from django.template.response import TemplateResponse from django.utils.functional import cached_property -from simplecasts.partials import render_partial_response -from simplecasts.request import HttpRequest +from simplecasts.http.request import HttpRequest +from simplecasts.views.partials import render_partial_response T = TypeVar("T") T_Model = TypeVar("T_Model", bound=Model) diff --git a/simplecasts/partials.py b/simplecasts/views/partials.py similarity index 92% rename from simplecasts/partials.py rename to simplecasts/views/partials.py index b2466f3216..de333a8763 100644 --- a/simplecasts/partials.py +++ b/simplecasts/views/partials.py @@ -1,6 +1,6 @@ from django.template.response import TemplateResponse -from simplecasts.request import HttpRequest +from simplecasts.http.request import HttpRequest def render_partial_response( diff --git a/simplecasts/views/player.py b/simplecasts/views/player.py new file mode 100644 index 0000000000..47dafd40af --- /dev/null +++ b/simplecasts/views/player.py @@ -0,0 +1,135 @@ +import http +from typing import Literal, TypedDict + +from django.contrib.auth.decorators import login_required +from django.db import IntegrityError +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.utils import timezone +from django.views.decorators.http import require_POST +from pydantic import BaseModel, ValidationError + +from simplecasts.http.request import ( + AuthenticatedHttpRequest, + HttpRequest, + is_authenticated_request, +) +from simplecasts.http.response import HttpResponseNoContent +from simplecasts.models import AudioLog, Episode + +PlayerAction = Literal["load", "play", "close"] + + +class PlayerUpdate(BaseModel): + """Data model for player time update.""" + + current_time: int + duration: int + + +class PlayerUpdateError(TypedDict): + """Data model for player error response.""" + + error: str + + +@require_POST +@login_required +def start_player( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse: + """Starts player. Creates new audio log if required.""" + episode = get_object_or_404( + Episode.objects.select_related("podcast"), + pk=episode_id, + ) + + audio_log, _ = request.user.audio_logs.update_or_create( + episode=episode, + defaults={ + "listened": timezone.now(), + }, + ) + + request.player.set(episode.pk) + + return _render_player_action(request, audio_log, action="play") + + +@require_POST +@login_required +def close_player( + request: AuthenticatedHttpRequest, +) -> TemplateResponse | HttpResponseNoContent: + """Closes audio player.""" + if episode_id := request.player.pop(): + audio_log = get_object_or_404( + request.user.audio_logs.select_related("episode"), + episode__pk=episode_id, + ) + return _render_player_action(request, audio_log, action="close") + return HttpResponseNoContent() + + +@require_POST +def player_time_update(request: HttpRequest) -> JsonResponse: + """Handles player time update AJAX requests.""" + + if not is_authenticated_request(request): + return JsonResponse( + PlayerUpdateError(error="Authentication required"), + status=http.HTTPStatus.UNAUTHORIZED, + ) + + episode_id = request.player.get() + + if episode_id is None: + return JsonResponse( + PlayerUpdateError(error="No episode in player"), + status=http.HTTPStatus.BAD_REQUEST, + ) + + try: + update = PlayerUpdate.model_validate_json(request.body) + except ValidationError as exc: + return JsonResponse( + PlayerUpdateError(error=exc.json()), + status=http.HTTPStatus.BAD_REQUEST, + ) + + try: + request.user.audio_logs.update_or_create( + episode_id=episode_id, + defaults={ + "listened": timezone.now(), + "current_time": update.current_time, + "duration": update.duration, + }, + ) + + except IntegrityError: + return JsonResponse( + PlayerUpdateError(error="Update cannot be saved"), + status=http.HTTPStatus.CONFLICT, + ) + + return JsonResponse(update.model_dump()) + + +def _render_player_action( + request: HttpRequest, + audio_log: AudioLog, + *, + action: PlayerAction, +) -> TemplateResponse: + return TemplateResponse( + request, + "episodes/detail.html#audio_player_button", + { + "action": action, + "audio_log": audio_log, + "episode": audio_log.episode, + "is_playing": action == "play", + }, + ) diff --git a/simplecasts/views/podcasts.py b/simplecasts/views/podcasts.py new file mode 100644 index 0000000000..8be5421c5e --- /dev/null +++ b/simplecasts/views/podcasts.py @@ -0,0 +1,277 @@ +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db.models import Exists, OuterRef +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.views.decorators.http import require_safe + +from simplecasts.http.request import AuthenticatedHttpRequest, HttpRequest +from simplecasts.http.response import RenderOrRedirectResponse +from simplecasts.models import Category, Episode, Podcast +from simplecasts.services import itunes +from simplecasts.services.http_client import get_client +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def discover(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Shows all promoted podcasts.""" + podcasts = ( + Podcast.objects.published() + .filter( + promoted=True, + language=settings.DISCOVER_FEED_LANGUAGE, + private=False, + ) + .order_by("-pub_date")[: settings.DEFAULT_PAGE_SIZE] + ) + + return TemplateResponse(request, "podcasts/discover.html", {"podcasts": podcasts}) + + +@require_safe +@login_required +def podcast_detail( + request: AuthenticatedHttpRequest, + podcast_id: int, + slug: str, +) -> RenderOrRedirectResponse: + """Details for a single podcast.""" + + podcast = get_object_or_404( + Podcast.objects.published().select_related("canonical"), + pk=podcast_id, + ) + + is_subscribed = request.user.subscriptions.filter(podcast=podcast).exists() + + return TemplateResponse( + request, + "podcasts/detail.html", + { + "podcast": podcast, + "is_subscribed": is_subscribed, + }, + ) + + +@require_safe +@login_required +def latest_episode(_, podcast_id: int) -> HttpResponseRedirect: + """Redirects to latest episode.""" + if ( + episode := Episode.objects.filter(podcast__pk=podcast_id) + .order_by("-pub_date", "-id") + .first() + ): + return redirect(episode) + raise Http404 + + +@require_safe +@login_required +def episodes( + request: HttpRequest, + podcast_id: int, + slug: str | None = None, +) -> TemplateResponse: + """Render episodes for a single podcast.""" + podcast = get_object_or_404(Podcast.objects.published(), pk=podcast_id) + podcast_episodes = podcast.episodes.select_related("podcast") + + default_ordering = "asc" if podcast.is_serial() else "desc" + ordering = request.GET.get("order", default_ordering) + order_by = ("pub_date", "id") if ordering == "asc" else ("-pub_date", "-id") + + if request.search: + podcast_episodes = podcast_episodes.search(request.search.value).order_by( + "-rank", *order_by + ) + else: + podcast_episodes = podcast_episodes.order_by(*order_by) + + return render_paginated_response( + request, + "podcasts/episodes.html", + podcast_episodes, + { + "podcast": podcast, + "ordering": ordering, + }, + ) + + +@require_safe +@login_required +def season( + request: HttpRequest, + podcast_id: int, + season: int, + slug: str | None = None, +) -> TemplateResponse: + """Render episodes for a podcast season.""" + podcast = get_object_or_404(Podcast.objects.published(), pk=podcast_id) + + podcast_episodes = podcast.episodes.filter(season=season).select_related("podcast") + + order_by = ("pub_date", "id") if podcast.is_serial() else ("-pub_date", "-id") + podcast_episodes = podcast_episodes.order_by(*order_by) + + return render_paginated_response( + request, + "podcasts/season.html", + podcast_episodes, + { + "podcast": podcast, + "season": podcast.get_season(season), + }, + ) + + +@require_safe +@login_required +def similar( + request: HttpRequest, + podcast_id: int, + slug: str | None = None, +) -> TemplateResponse: + """List similar podcasts based on recommendations.""" + + podcast = get_object_or_404(Podcast.objects.published(), pk=podcast_id) + + recommendations = podcast.recommendations.select_related("recommended").order_by( + "-score" + )[: settings.DEFAULT_PAGE_SIZE] + + return TemplateResponse( + request, + "podcasts/similar.html", + { + "podcast": podcast, + "recommendations": recommendations, + }, + ) + + +@require_safe +@login_required +def search_podcasts(request: HttpRequest) -> RenderOrRedirectResponse: + """Search all public podcasts in database. Redirects to discover page if search is empty.""" + + if request.search: + results = ( + Podcast.objects.published() + .filter(private=False) + .search(request.search.value) + ).order_by("-rank", "-pub_date") + + return render_paginated_response( + request, "podcasts/search_podcasts.html", results + ) + + return redirect("podcasts:discover") + + +@require_safe +@login_required +def search_itunes(request: HttpRequest) -> RenderOrRedirectResponse: + """Render iTunes search page. Redirects to discover page if search is empty.""" + + if request.search: + try: + with get_client() as client: + feeds, is_new = itunes.search_cached( + client, + request.search.value, + limit=settings.DEFAULT_PAGE_SIZE, + ) + if is_new: + itunes.save_feeds_to_db(feeds) + return TemplateResponse( + request, + "podcasts/search_itunes.html", + { + "feeds": feeds, + }, + ) + except itunes.ItunesError as exc: + messages.error(request, f"Failed to search iTunes: {exc}") + + return redirect("podcasts:discover") + + +@require_safe +@login_required +def search_people(request: HttpRequest) -> RenderOrRedirectResponse: + """Search all podcasts by owner(s). Redirects to discover page if no owner is given.""" + + if request.search: + results = ( + Podcast.objects.published() + .filter(private=False) + .search( + request.search.value, + "owner_search_document", + ) + ).order_by("-rank", "-pub_date") + return render_paginated_response( + request, + "podcasts/search_people.html", + results, + ) + + return redirect("podcasts:discover") + + +@require_safe +@login_required +def categories(request: HttpRequest) -> TemplateResponse: + """List all categories containing podcasts.""" + category_list = ( + Category.objects.alias( + has_podcasts=Exists( + Podcast.objects.published() + .filter(private=False) + .filter(categories=OuterRef("pk")) + ) + ) + .filter(has_podcasts=True) + .order_by("name") + ) + + return TemplateResponse( + request, + "podcasts/categories.html", + { + "categories": category_list, + }, + ) + + +@require_safe +@login_required +def category_detail(request: HttpRequest, slug: str) -> TemplateResponse: + """Render individual podcast category along with its podcasts. + + Podcasts can also be searched. + """ + category = get_object_or_404(Category, slug=slug) + + podcasts = category.podcasts.published().filter(private=False).distinct() + + if request.search: + podcasts = podcasts.search(request.search.value).order_by("-rank", "-pub_date") + else: + podcasts = podcasts.order_by("-pub_date") + + return render_paginated_response( + request, + "podcasts/category_detail.html", + podcasts, + { + "category": category, + }, + ) diff --git a/simplecasts/views/private_feeds.py b/simplecasts/views/private_feeds.py new file mode 100644 index 0000000000..e86c62999c --- /dev/null +++ b/simplecasts/views/private_feeds.py @@ -0,0 +1,75 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.views.decorators.http import require_safe + +from simplecasts.forms import PodcastForm +from simplecasts.http.decorators import require_DELETE, require_form_methods +from simplecasts.http.request import AuthenticatedHttpRequest +from simplecasts.http.response import RenderOrRedirectResponse +from simplecasts.models import Podcast +from simplecasts.views.paginator import render_paginated_response +from simplecasts.views.partials import render_partial_response + + +@require_safe +@login_required +def private_feeds(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Lists user's private feeds.""" + podcasts = Podcast.objects.published().filter(private=True).subscribed(request.user) + + if request.search: + podcasts = podcasts.search(request.search.value).order_by("-rank", "-pub_date") + else: + podcasts = podcasts.order_by("-pub_date") + + return render_paginated_response(request, "podcasts/private_feeds.html", podcasts) + + +@require_form_methods +@login_required +def add_private_feed(request: AuthenticatedHttpRequest) -> RenderOrRedirectResponse: + """Add new private feed to collection.""" + if request.method == "POST": + form = PodcastForm(request.POST) + if form.is_valid(): + podcast = form.save(commit=False) + podcast.private = True + podcast.save() + + request.user.subscriptions.create(podcast=podcast) + + messages.success( + request, + "Podcast added to your Private Feeds and will appear here soon", + ) + return redirect("podcasts:private_feeds") + else: + form = PodcastForm() + + return render_partial_response( + request, + "podcasts/private_feed_form.html", + {"form": form}, + target="private-feed-form", + partial="form", + ) + + +@require_DELETE +@login_required +def remove_private_feed( + request: AuthenticatedHttpRequest, + podcast_id: int, +) -> HttpResponseRedirect: + """Delete private feed.""" + + get_object_or_404( + Podcast.objects.published().filter(private=True).subscribed(request.user), + pk=podcast_id, + ).delete() + + messages.info(request, "Removed from Private Feeds") + return redirect("podcasts:private_feeds") diff --git a/simplecasts/views/subscriptions.py b/simplecasts/views/subscriptions.py new file mode 100644 index 0000000000..4c90ddb52b --- /dev/null +++ b/simplecasts/views/subscriptions.py @@ -0,0 +1,74 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db import IntegrityError +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.views.decorators.http import require_POST, require_safe + +from simplecasts.http.decorators import require_DELETE +from simplecasts.http.request import AuthenticatedHttpRequest, HttpRequest +from simplecasts.http.response import HttpResponseConflict +from simplecasts.models import Podcast +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def subscriptions(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Render podcast index page.""" + podcasts = Podcast.objects.published().subscribed(request.user).distinct() + + if request.search: + podcasts = podcasts.search(request.search.value).order_by("-rank", "-pub_date") + else: + podcasts = podcasts.order_by("-pub_date") + + return render_paginated_response(request, "podcasts/subscriptions.html", podcasts) + + +@require_POST +@login_required +def subscribe( + request: AuthenticatedHttpRequest, podcast_id: int +) -> TemplateResponse | HttpResponseConflict: + """Subscribe a user to a podcast. Podcast must be active and public.""" + podcast = get_object_or_404( + Podcast.objects.published().filter(private=False), pk=podcast_id + ) + + try: + request.user.subscriptions.create(podcast=podcast) + except IntegrityError: + return HttpResponseConflict() + + messages.success(request, "Subscribed to Podcast") + + return _render_subscribe_action(request, podcast, is_subscribed=True) + + +@require_DELETE +@login_required +def unsubscribe(request: AuthenticatedHttpRequest, podcast_id: int) -> TemplateResponse: + """Unsubscribe user from a podcast.""" + podcast = get_object_or_404( + Podcast.objects.published().filter(private=False), pk=podcast_id + ) + request.user.subscriptions.filter(podcast=podcast).delete() + messages.info(request, "Unsubscribed from Podcast") + return _render_subscribe_action(request, podcast, is_subscribed=False) + + +def _render_subscribe_action( + request: HttpRequest, + podcast: Podcast, + *, + is_subscribed: bool, +) -> TemplateResponse: + return TemplateResponse( + request, + "podcasts/detail.html#subscribe_button", + { + "podcast": podcast, + "is_subscribed": is_subscribed, + }, + ) diff --git a/simplecasts/users/views.py b/simplecasts/views/users.py similarity index 93% rename from simplecasts/users/views.py rename to simplecasts/views/users.py index c2fbd9908a..78ff29a85b 100644 --- a/simplecasts/users/views.py +++ b/simplecasts/views/users.py @@ -13,22 +13,22 @@ from django.utils import timezone from django.views.decorators.http import require_safe -from simplecasts.http import require_form_methods -from simplecasts.partials import render_partial_response -from simplecasts.podcasts.models import Podcast, Subscription -from simplecasts.podcasts.parsers.opml_parser import parse_opml -from simplecasts.request import ( - AuthenticatedHttpRequest, - HttpRequest, - is_authenticated_request, -) -from simplecasts.response import RenderOrRedirectResponse -from simplecasts.users.forms import ( +from simplecasts.forms import ( AccountDeletionConfirmationForm, OpmlUploadForm, UserPreferencesForm, ) -from simplecasts.users.notifications import get_unsubscribe_signer +from simplecasts.http.decorators import require_form_methods +from simplecasts.http.request import ( + AuthenticatedHttpRequest, + HttpRequest, + is_authenticated_request, +) +from simplecasts.http.response import RenderOrRedirectResponse +from simplecasts.models import Podcast, Subscription +from simplecasts.services.notifications import get_unsubscribe_signer +from simplecasts.services.opml_parser import parse_opml +from simplecasts.views.partials import render_partial_response class UserStat(TypedDict): diff --git a/templates/audio_player.html b/templates/audio_player.html index ae68937e54..1f02b1ef71 100644 --- a/templates/audio_player.html +++ b/templates/audio_player.html @@ -1,5 +1,4 @@ {% load heroicons static %} -{% load get_media_metadata from episodes %} {% if request.user.is_authenticated %} <div id="audio-player"{% if hx_oob %} hx-swap-oob="true" {% endif %}> {% if audio_log and action != "close" %} diff --git a/templates/card.html b/templates/card.html deleted file mode 100644 index 79445677cd..0000000000 --- a/templates/card.html +++ /dev/null @@ -1,20 +0,0 @@ -<a href="{{ url }}" - title="{{ title }}" - class="flex items-center space-x-3 cursor-pointer group" - {% if external %} - target="_blank" - rel="noopener noreferrer" - {% endif %} -> - {% cover_image "card" cover_url title %} - <div class="flex flex-col place-content-between h-16 leading-tight"> - <h2 - class="font-bold leading-tight break-words group-hover:text-blue-600 line-clamp-2 dark:group-hover:text-blue-300"> - {{ title }} - </h2> - <h3 - class="text-sm font-semibold leading-tight group-hover:text-blue-600 line-clamp-1 dark:group-hover:text-blue-300"> - {{ content }} - </h3> - </div> -</a> diff --git a/templates/cards.html b/templates/cards.html new file mode 100644 index 0000000000..6aa2522047 --- /dev/null +++ b/templates/cards.html @@ -0,0 +1,42 @@ +{% partialdef card %} + <a href="{{ url }}" title="{{ title }}" class="flex items-center space-x-3 cursor-pointer group" {% if external %} + target="_blank" rel="noopener noreferrer" {% endif %}> + {% cover_image "card" cover_url title %} + <div class="flex flex-col place-content-between h-16 leading-tight"> + <h2 + class="font-bold leading-tight break-words group-hover:text-blue-600 line-clamp-2 dark:group-hover:text-blue-300"> + {{ title }} + </h2> + <h3 + class="text-sm font-semibold leading-tight group-hover:text-blue-600 line-clamp-1 dark:group-hover:text-blue-300"> + {{ content }} + </h3> + </div> + </a> +{% endpartialdef %} + +{% partialdef podcast %} + {% fragment "cards.html#card" url=podcast.get_absolute_url title=podcast.cleaned_title cover_url=podcast.cover_url %} + {{ podcast.pub_date|date:"DATE_FORMAT" }} + {% endfragment %} +{% endpartialdef %} + +{% partialdef podcast_episode %} + {% fragment "cards.html#card" url=episode.get_absolute_url title=episode.cleaned_title cover_url=episode.get_cover_url %} + {{ episode.pub_date|date:"DATE_FORMAT" }} + {% endfragment %} +{% endpartialdef %} + +{% partialdef itunes_feed %} + {% fragment "cards.html#card" url=feed.url title=feed.title cover_url=feed.image external=True %} + View in iTunes + {% endfragment %} +{% endpartialdef %} + +{% partialdef episode %} + {% with podcast=episode.podcast %} + {% fragment "cards.html#card" url=episode.get_absolute_url title=episode.cleaned_title cover_url=podcast.cover_url %} + {{ podcast.cleaned_title }} + {% endfragment %} + {% endwith %} +{% endpartialdef %} diff --git a/templates/default_base.html b/templates/default_base.html index 0f4a01811c..54b759cb19 100644 --- a/templates/default_base.html +++ b/templates/default_base.html @@ -1,6 +1,5 @@ {% spaceless %} {% load django_htmx static tailwind_cli %} - {% load audio_player from episodes %} <!DOCTYPE html> <!-- djlint:off H016 --> <html lang="en"> diff --git a/templates/episodes/emails/notifications.html b/templates/emails/episode_notifications.html similarity index 100% rename from templates/episodes/emails/notifications.html rename to templates/emails/episode_notifications.html diff --git a/templates/podcasts/emails/recommendations.html b/templates/emails/podcast_recommendations.html similarity index 100% rename from templates/podcasts/emails/recommendations.html rename to templates/emails/podcast_recommendations.html diff --git a/templates/episodes/bookmarks.html b/templates/episodes/bookmarks.html index b258d06559..a4f53c53c3 100644 --- a/templates/episodes/bookmarks.html +++ b/templates/episodes/bookmarks.html @@ -34,7 +34,7 @@ {% fragment "paginate.html" %} {% for bookmark in page %} {% fragment "browse.html#item" %} - {% include "episodes/episode.html" with episode=bookmark.episode %} + {% include "cards.html#episode" with episode=bookmark.episode %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/episodes/detail.html b/templates/episodes/detail.html index 0f5542a240..a24c90e787 100644 --- a/templates/episodes/detail.html +++ b/templates/episodes/detail.html @@ -1,6 +1,5 @@ {% extends "base.html" %} {% load cache heroicons %} -{% load audio_player format_duration from episodes %} {% block title %} {% title_tag episode.podcast.cleaned_title episode.cleaned_title %} {% endblock title %} @@ -166,7 +165,7 @@ <h2 class="text-lg font-semibold"> {% endwith %} {% if user.is_staff %} <div> - <a href="{% url "admin:episodes_episode_change" episode.pk %}" + <a href="{% url "admin:simplecasts_episode_change" episode.pk %}" target="_blank" rel="noopener" class="link">Admin</a> @@ -216,7 +215,7 @@ <h2 class="text-lg font-semibold"> <button class="text-sm btn btn-default" title="Mark this episode complete" - hx-post="{% url "episodes:mark_audio_log_complete" episode.pk %}" + hx-post="{% url "episodes:mark_complete" episode.pk %}" hx-disabled-elt="this" hx-indicator="this" > diff --git a/templates/episodes/episode.html b/templates/episodes/episode.html deleted file mode 100644 index 0b6125c8c9..0000000000 --- a/templates/episodes/episode.html +++ /dev/null @@ -1,5 +0,0 @@ -{% with podcast=episode.podcast %} - {% fragment "card.html" url=episode.get_absolute_url title=episode.cleaned_title cover_url=podcast.cover_url %} - {{ podcast.cleaned_title }} - {% endfragment %} -{% endwith %} diff --git a/templates/episodes/history.html b/templates/episodes/history.html index 620e0d1f65..002e425a37 100644 --- a/templates/episodes/history.html +++ b/templates/episodes/history.html @@ -34,7 +34,7 @@ {% fragment "paginate.html" %} {% for audio_log in page %} {% fragment "browse.html#item" %} - {% include "episodes/episode.html" with episode=audio_log.episode %} + {% include "cards.html#episode" with episode=audio_log.episode %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/episodes/index.html b/templates/episodes/index.html index 2e66f89b06..f6ceb180d8 100644 --- a/templates/episodes/index.html +++ b/templates/episodes/index.html @@ -17,7 +17,7 @@ {% fragment "browse.html" %} {% for episode in episodes %} {% fragment "browse.html#item" %} - {% include "episodes/episode.html" %} + {% include "cards.html#episode" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/episodes/search.html b/templates/episodes/search_episodes.html similarity index 95% rename from templates/episodes/search.html rename to templates/episodes/search_episodes.html index abfdaa6473..0082893d9d 100644 --- a/templates/episodes/search.html +++ b/templates/episodes/search_episodes.html @@ -22,7 +22,7 @@ {% fragment "paginate.html" %} {% for episode in page %} {% fragment "browse.html#item" %} - {% include "episodes/episode.html" %} + {% include "cards.html#episode" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/podcasts/cards.html b/templates/podcasts/cards.html deleted file mode 100644 index 2e47313f41..0000000000 --- a/templates/podcasts/cards.html +++ /dev/null @@ -1,17 +0,0 @@ -{% partialdef podcast %} - {% fragment "card.html" url=podcast.get_absolute_url title=podcast.cleaned_title cover_url=podcast.cover_url %} - {{ podcast.pub_date|date:"DATE_FORMAT" }} - {% endfragment %} -{% endpartialdef %} - -{% partialdef episode %} - {% fragment "card.html" url=episode.get_absolute_url title=episode.cleaned_title cover_url=episode.get_cover_url %} - {{ episode.pub_date|date:"DATE_FORMAT" }} - {% endfragment %} -{% endpartialdef %} - -{% partialdef itunes_feed %} - {% fragment "card.html" url=feed.url title=feed.title cover_url=feed.image external=True %} - View in iTunes - {% endfragment %} -{% endpartialdef %} diff --git a/templates/podcasts/category_detail.html b/templates/podcasts/category_detail.html index c0612b0ca7..5ff40797bb 100644 --- a/templates/podcasts/category_detail.html +++ b/templates/podcasts/category_detail.html @@ -29,7 +29,7 @@ {% fragment "paginate.html" %} {% for podcast in page %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#podcast" %} + {% include "cards.html#podcast" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/podcasts/detail.html b/templates/podcasts/detail.html index 217e215fd9..10e5356426 100644 --- a/templates/podcasts/detail.html +++ b/templates/podcasts/detail.html @@ -88,7 +88,7 @@ <h2 class="text-lg font-semibold"> {% endif %} {% if user.is_staff %} <div> - <a href="{% url "admin:podcasts_podcast_change" podcast.pk %}" + <a href="{% url "admin:simplecasts_podcast_change" podcast.pk %}" target="_blank" rel="noopener" class="link">Admin</a> diff --git a/templates/podcasts/discover.html b/templates/podcasts/discover.html index fa1fc55e07..ddebcbbb9b 100644 --- a/templates/podcasts/discover.html +++ b/templates/podcasts/discover.html @@ -19,7 +19,7 @@ {% fragment "browse.html" %} {% for podcast in podcasts %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#podcast" %} + {% include "cards.html#podcast" %} {% endfragment %} {% endfor %} {% endfragment %} diff --git a/templates/podcasts/episodes.html b/templates/podcasts/episodes.html index 4b9f68ad5b..5194a77c06 100644 --- a/templates/podcasts/episodes.html +++ b/templates/podcasts/episodes.html @@ -39,7 +39,7 @@ {% fragment "paginate.html" %} {% for episode in page %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#episode" %} + {% include "cards.html#podcast_episode" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/podcasts/private_feeds.html b/templates/podcasts/private_feeds.html index 2c6436026e..fc693f545d 100644 --- a/templates/podcasts/private_feeds.html +++ b/templates/podcasts/private_feeds.html @@ -35,7 +35,7 @@ {% fragment "paginate.html" %} {% for podcast in page %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#podcast" %} + {% include "cards.html#podcast" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/podcasts/search_itunes.html b/templates/podcasts/search_itunes.html index b58e27aee9..34a18802d1 100644 --- a/templates/podcasts/search_itunes.html +++ b/templates/podcasts/search_itunes.html @@ -21,7 +21,7 @@ {% fragment "browse.html" %} {% for feed in feeds %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#itunes_feed" with feed=feed %} + {% include "cards.html#itunes_feed" with feed=feed %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/podcasts/search_people.html b/templates/podcasts/search_people.html index 0029f389f9..e39fa62a34 100644 --- a/templates/podcasts/search_people.html +++ b/templates/podcasts/search_people.html @@ -23,7 +23,7 @@ {% fragment "paginate.html" %} {% for podcast in page %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#podcast" %} + {% include "cards.html#podcast" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/podcasts/search_podcasts.html b/templates/podcasts/search_podcasts.html index ff1a5cafc2..6e567b7da8 100644 --- a/templates/podcasts/search_podcasts.html +++ b/templates/podcasts/search_podcasts.html @@ -22,7 +22,7 @@ {% fragment "paginate.html" %} {% for podcast in page %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#podcast" %} + {% include "cards.html#podcast" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/podcasts/season.html b/templates/podcasts/season.html index 9203cc3218..bff1013975 100644 --- a/templates/podcasts/season.html +++ b/templates/podcasts/season.html @@ -33,7 +33,7 @@ {% fragment "paginate.html" %} {% for episode in page %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#episode" %} + {% include "cards.html#podcast_episode" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/podcasts/similar.html b/templates/podcasts/similar.html index 60d50ed5c6..565caac351 100644 --- a/templates/podcasts/similar.html +++ b/templates/podcasts/similar.html @@ -10,7 +10,7 @@ {% fragment "browse.html" %} {% for recommendation in recommendations %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#podcast" with podcast=recommendation.recommended %} + {% include "cards.html#podcast" with podcast=recommendation.recommended %} {% endfragment %} {% endfor %} {% endfragment %} diff --git a/templates/podcasts/subscriptions.html b/templates/podcasts/subscriptions.html index 3d238ccaf2..e456a84330 100644 --- a/templates/podcasts/subscriptions.html +++ b/templates/podcasts/subscriptions.html @@ -24,7 +24,7 @@ {% fragment "paginate.html" %} {% for podcast in page %} {% fragment "browse.html#item" %} - {% include "podcasts/cards.html#podcast" %} + {% include "cards.html#podcast" %} {% endfragment %} {% empty %} {% fragment "browse.html#empty" %} diff --git a/templates/sidebar.html b/templates/sidebar.html index 8843dd71cf..2b08be9750 100644 --- a/templates/sidebar.html +++ b/templates/sidebar.html @@ -16,7 +16,7 @@ {% endwith %} {% with icon="tag" label="Categories" %} - {% url "podcasts:category_list" as url %} + {% url "podcasts:categories" as url %} {% partial item %} {% endwith %} diff --git a/terraform/cloudflare.tf b/terraform/cloudflare.tf new file mode 100644 index 0000000000..f63ec24c81 --- /dev/null +++ b/terraform/cloudflare.tf @@ -0,0 +1,48 @@ +# Cloudflare Zone (use existing or create new) +data "cloudflare_zone" "existing" { + count = var.cloudflare_zone_id != "" ? 1 : 0 + zone_id = var.cloudflare_zone_id +} + +resource "cloudflare_zone" "new" { + count = var.cloudflare_zone_id == "" ? 1 : 0 + account_id = var.cloudflare_account_id + zone = var.domain +} + +locals { + zone_id = var.cloudflare_zone_id != "" ? var.cloudflare_zone_id : cloudflare_zone.new[0].id +} + +# SSL/TLS settings +resource "cloudflare_zone_settings_override" "ssl" { + zone_id = local.zone_id + + settings { + ssl = var.cloudflare_ssl_mode + always_use_https = var.cloudflare_always_use_https ? "on" : "off" + min_tls_version = var.cloudflare_min_tls_version + automatic_https_rewrites = "on" + tls_1_3 = "on" + } +} + +# A record for root domain -> server IP +resource "cloudflare_record" "root" { + zone_id = local.zone_id + name = "@" + content = hcloud_server.server.ipv4_address + type = "A" + proxied = var.cloudflare_proxied + ttl = var.cloudflare_proxied ? 1 : 300 +} + +# CNAME record for www -> root domain +resource "cloudflare_record" "www" { + zone_id = local.zone_id + name = "www" + content = var.domain + type = "CNAME" + proxied = var.cloudflare_proxied + ttl = var.cloudflare_proxied ? 1 : 300 +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..cd39f36b78 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,188 @@ +# Private Network +resource "hcloud_network" "cluster" { + count = var.create_network ? 1 : 0 + name = var.network_name + ip_range = var.network_ip_range + labels = var.labels +} + +resource "hcloud_network_subnet" "cluster" { + count = var.create_network ? 1 : 0 + network_id = hcloud_network.cluster[0].id + type = "cloud" + network_zone = "eu-central" + ip_range = var.subnet_ip_range +} + +# Database Server +resource "hcloud_server" "database" { + name = var.database_name + server_type = var.database_server_type + image = var.image + location = var.location + ssh_keys = var.ssh_keys + labels = merge(var.labels, { role = "database" }) + + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + + dynamic "network" { + for_each = var.create_network ? [1] : [] + content { + network_id = hcloud_network.cluster[0].id + } + } + + depends_on = [hcloud_network_subnet.cluster] +} + +# Web Servers +resource "hcloud_server" "web" { + count = var.web_count + name = "${var.web_name_prefix}-${count.index + 1}" + server_type = var.web_server_type + image = var.image + location = var.location + ssh_keys = var.ssh_keys + labels = merge(var.labels, { role = "web" }) + + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + + dynamic "network" { + for_each = var.create_network ? [1] : [] + content { + network_id = hcloud_network.cluster[0].id + } + } + + depends_on = [hcloud_network_subnet.cluster] +} + +# Load Balancer Server +resource "hcloud_server" "server" { + name = var.server_name + server_type = var.server_server_type + image = var.image + location = var.location + ssh_keys = var.ssh_keys + labels = merge(var.labels, { role = "server" }) + + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + + dynamic "network" { + for_each = var.create_network ? [1] : [] + content { + network_id = hcloud_network.cluster[0].id + } + } + + depends_on = [hcloud_network_subnet.cluster] +} + +# Job Runner Server +resource "hcloud_server" "jobrunner" { + name = var.jobrunner_name + server_type = var.jobrunner_server_type + image = var.image + location = var.location + ssh_keys = var.ssh_keys + labels = merge(var.labels, { role = "jobrunner" }) + + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + + dynamic "network" { + for_each = var.create_network ? [1] : [] + content { + network_id = hcloud_network.cluster[0].id + } + } + + depends_on = [hcloud_network_subnet.cluster] +} + +# Data Volume (attached to database server) +resource "hcloud_volume" "data" { + name = var.volume_name + size = var.volume_size + location = var.location + format = var.volume_format + automount = var.volume_automount + labels = var.labels +} + +resource "hcloud_volume_attachment" "data" { + volume_id = hcloud_volume.data.id + server_id = hcloud_server.database.id + automount = var.volume_automount +} + +# Firewall for SSH access (all servers) +resource "hcloud_firewall" "ssh" { + name = "ssh" + labels = var.labels + + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = ["0.0.0.0/0", "::/0"] + } +} + +# Firewall for HTTP/HTTPS (server only) +resource "hcloud_firewall" "web" { + name = "web" + labels = var.labels + + rule { + direction = "in" + protocol = "tcp" + port = "80" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "in" + protocol = "tcp" + port = "443" + source_ips = ["0.0.0.0/0", "::/0"] + } +} + +# Attach SSH firewall to all servers +resource "hcloud_firewall_attachment" "ssh_database" { + firewall_id = hcloud_firewall.ssh.id + server_ids = [hcloud_server.database.id] +} + +resource "hcloud_firewall_attachment" "ssh_web" { + firewall_id = hcloud_firewall.ssh.id + server_ids = hcloud_server.web[*].id +} + +resource "hcloud_firewall_attachment" "ssh_server" { + firewall_id = hcloud_firewall.ssh.id + server_ids = [hcloud_server.server.id] +} + +resource "hcloud_firewall_attachment" "ssh_jobrunner" { + firewall_id = hcloud_firewall.ssh.id + server_ids = [hcloud_server.jobrunner.id] +} + +# Attach web firewall to server only +resource "hcloud_firewall_attachment" "web_server" { + firewall_id = hcloud_firewall.web.id + server_ids = [hcloud_server.server.id] +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..b1dbbdd86f --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,124 @@ +# Database Server +output "database_ipv4" { + description = "Public IPv4 address of the database server" + value = hcloud_server.database.ipv4_address +} + +output "database_ipv6" { + description = "Public IPv6 address of the database server" + value = hcloud_server.database.ipv6_address +} + +output "database_private_ip" { + description = "Private IP address of the database server" + value = var.create_network ? hcloud_server.database.network[*].ip : null +} + +# Web Servers +output "web_ipv4" { + description = "Public IPv4 addresses of web servers" + value = hcloud_server.web[*].ipv4_address +} + +output "web_ipv6" { + description = "Public IPv6 addresses of web servers" + value = hcloud_server.web[*].ipv6_address +} + +output "web_private_ips" { + description = "Private IP addresses of web servers" + value = var.create_network ? [for s in hcloud_server.web : s.network[*].ip] : null +} + +# Server (load balancer/reverse proxy) +output "server_ipv4" { + description = "Public IPv4 address of the server" + value = hcloud_server.server.ipv4_address +} + +output "server_ipv6" { + description = "Public IPv6 address of the server" + value = hcloud_server.server.ipv6_address +} + +output "server_private_ip" { + description = "Private IP address of the server" + value = var.create_network ? hcloud_server.server.network[*].ip : null +} + +# Job Runner Server +output "jobrunner_ipv4" { + description = "Public IPv4 address of the job runner server" + value = hcloud_server.jobrunner.ipv4_address +} + +output "jobrunner_ipv6" { + description = "Public IPv6 address of the job runner server" + value = hcloud_server.jobrunner.ipv6_address +} + +output "jobrunner_private_ip" { + description = "Private IP address of the job runner server" + value = var.create_network ? hcloud_server.jobrunner.network[*].ip : null +} + +# Volume +output "volume_id" { + description = "ID of the data volume" + value = hcloud_volume.data.id +} + +output "volume_linux_device" { + description = "Linux device path of the volume" + value = hcloud_volume.data.linux_device +} + +# Network +output "network_id" { + description = "ID of the private network" + value = var.create_network ? hcloud_network.cluster[0].id : null +} + +# All server IPs (useful for Ansible inventory) +output "all_servers" { + description = "Map of all servers with their IPs" + value = { + database = { + ipv4 = hcloud_server.database.ipv4_address + ipv6 = hcloud_server.database.ipv6_address + private_ip = var.create_network ? hcloud_server.database.network[*].ip : null + } + server = { + ipv4 = hcloud_server.server.ipv4_address + ipv6 = hcloud_server.server.ipv6_address + private_ip = var.create_network ? hcloud_server.server.network[*].ip : null + } + jobrunner = { + ipv4 = hcloud_server.jobrunner.ipv4_address + ipv6 = hcloud_server.jobrunner.ipv6_address + private_ip = var.create_network ? hcloud_server.jobrunner.network[*].ip : null + } + web = [for i, s in hcloud_server.web : { + name = s.name + ipv4 = s.ipv4_address + ipv6 = s.ipv6_address + private_ip = var.create_network ? s.network[*].ip : null + }] + } +} + +# Cloudflare +output "cloudflare_zone_id" { + description = "Cloudflare Zone ID" + value = local.zone_id +} + +output "cloudflare_nameservers" { + description = "Cloudflare nameservers (update your domain registrar with these)" + value = var.cloudflare_zone_id == "" ? cloudflare_zone.new[0].name_servers : data.cloudflare_zone.existing[0].name_servers +} + +output "domain_url" { + description = "Application URL" + value = "https://${var.domain}" +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000000..656a9f55fe --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.45" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.0" + } + } +} + +provider "hcloud" { + token = var.hcloud_token +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000000..536422613b --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,78 @@ +# Hetzner Cloud API Token (required) +# Get this from https://console.hetzner.cloud/projects/*/security/tokens +hcloud_token = "your-hcloud-api-token" + +# General settings +location = "fsn1" # fsn1 (Falkenstein), nbg1 (Nuremberg), hel1 (Helsinki), ash (Ashburn), hil (Hillsboro) +image = "ubuntu-24.04" + +# SSH keys (names or IDs as configured in Hetzner Cloud) +ssh_keys = ["my-ssh-key"] + +# Labels for all resources +labels = { + project = "simplecasts" + environment = "production" +} + +# Database VM +database_name = "database" +database_server_type = "cx22" # 2 vCPU, 4GB RAM + +# Web VMs +web_count = 2 +web_name_prefix = "web" +web_server_type = "cx22" + +# Server VM (load balancer/reverse proxy) +server_name = "server" +server_server_type = "cx22" + +# Job Runner VM +jobrunner_name = "jobrunner" +jobrunner_server_type = "cx22" + +# Volume (attached to database) +volume_name = "data" +volume_size = 50 # GB +volume_format = "ext4" +volume_automount = true + +# Private Network +create_network = true +network_name = "cluster-network" +network_ip_range = "10.0.0.0/16" +subnet_ip_range = "10.0.1.0/24" + +# Cloudflare +# Get API token from https://dash.cloudflare.com/profile/api-tokens +# Required permissions: Zone:Zone:Read, Zone:DNS:Edit, Zone:Zone Settings:Edit +cloudflare_api_token = "your-cloudflare-api-token" +cloudflare_account_id = "your-cloudflare-account-id" + +# Leave empty to create a new zone, or provide existing zone ID +cloudflare_zone_id = "" + +# Domain name +domain = "example.com" + +# SSL/TLS settings +cloudflare_ssl_mode = "full" # off, flexible, full, strict +cloudflare_always_use_https = true +cloudflare_min_tls_version = "1.2" # 1.0, 1.1, 1.2, 1.3 +cloudflare_proxied = true # Enable Cloudflare proxy (recommended) + +# Available server types (for reference): +# Shared vCPU: +# cx22 - 2 vCPU, 4GB RAM, 40GB disk +# cx32 - 4 vCPU, 8GB RAM, 80GB disk +# cx42 - 8 vCPU, 16GB RAM, 160GB disk +# cx52 - 16 vCPU, 32GB RAM, 320GB disk +# +# Dedicated vCPU: +# ccx13 - 2 vCPU, 8GB RAM, 80GB disk +# ccx23 - 4 vCPU, 16GB RAM, 160GB disk +# ccx33 - 8 vCPU, 32GB RAM, 240GB disk +# ccx43 - 16 vCPU, 64GB RAM, 360GB disk +# ccx53 - 32 vCPU, 128GB RAM, 600GB disk +# ccx63 - 48 vCPU, 192GB RAM, 960GB disk diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..308f9bda66 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,186 @@ +# Hetzner Cloud API Token +variable "hcloud_token" { + description = "Hetzner Cloud API token" + type = string + sensitive = true +} + +# General settings +variable "location" { + description = "Hetzner datacenter location (fsn1, nbg1, hel1, ash, hil)" + type = string + default = "fsn1" +} + +variable "ssh_keys" { + description = "List of SSH key names or IDs to add to servers" + type = list(string) + default = [] +} + +variable "labels" { + description = "Labels to apply to all resources" + type = map(string) + default = {} +} + +variable "image" { + description = "OS image for all servers" + type = string + default = "ubuntu-24.04" +} + +# Database VM +variable "database_server_type" { + description = "Hetzner server type for database VM (e.g., cx22, cx32, cx42)" + type = string + default = "cx22" +} + +variable "database_name" { + description = "Name for the database server" + type = string + default = "database" +} + +# Web VMs +variable "web_count" { + description = "Number of web servers to create" + type = number + default = 2 +} + +variable "web_server_type" { + description = "Hetzner server type for web VMs" + type = string + default = "cx22" +} + +variable "web_name_prefix" { + description = "Name prefix for web servers" + type = string + default = "web" +} + +# Server VM (load balancer/reverse proxy) +variable "server_server_type" { + description = "Hetzner server type for server VM" + type = string + default = "cx22" +} + +variable "server_name" { + description = "Name for the server" + type = string + default = "server" +} + +# Job Runner VM +variable "jobrunner_server_type" { + description = "Hetzner server type for job runner VM" + type = string + default = "cx22" +} + +variable "jobrunner_name" { + description = "Name for the job runner server" + type = string + default = "jobrunner" +} + +# Volume +variable "volume_size" { + description = "Size of the volume in GB" + type = number + default = 50 +} + +variable "volume_name" { + description = "Name for the volume" + type = string + default = "data" +} + +variable "volume_format" { + description = "Filesystem format for the volume (ext4, xfs)" + type = string + default = "ext4" +} + +variable "volume_automount" { + description = "Whether to automount the volume" + type = bool + default = true +} + +# Network settings +variable "create_network" { + description = "Whether to create a private network" + type = bool + default = true +} + +variable "network_ip_range" { + description = "IP range for the private network" + type = string + default = "10.0.0.0/16" +} + +variable "subnet_ip_range" { + description = "IP range for the subnet" + type = string + default = "10.0.1.0/24" +} + +variable "network_name" { + description = "Name for the private network" + type = string + default = "cluster-network" +} + +# Cloudflare settings +variable "cloudflare_api_token" { + description = "Cloudflare API token" + type = string + sensitive = true +} + +variable "cloudflare_account_id" { + description = "Cloudflare Account ID" + type = string +} + +variable "cloudflare_zone_id" { + description = "Cloudflare Zone ID (leave empty to create new zone)" + type = string + default = "" +} + +variable "domain" { + description = "Domain name for the application" + type = string +} + +variable "cloudflare_proxied" { + description = "Whether to proxy traffic through Cloudflare" + type = bool + default = true +} + +variable "cloudflare_ssl_mode" { + description = "SSL/TLS encryption mode (off, flexible, full, strict)" + type = string + default = "full" +} + +variable "cloudflare_always_use_https" { + description = "Always redirect HTTP to HTTPS" + type = bool + default = true +} + +variable "cloudflare_min_tls_version" { + description = "Minimum TLS version (1.0, 1.1, 1.2, 1.3)" + type = string + default = "1.2" +} diff --git a/uv.lock b/uv.lock index 01b7f91e2e..89a5756979 100644 --- a/uv.lock +++ b/uv.lock @@ -13,14 +13,14 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.0" +version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] [[package]] @@ -292,16 +292,16 @@ wheels = [ [[package]] name = "django" -version = "6.0" +version = "6.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/75/19762bfc4ea556c303d9af8e36f0cd910ab17dff6c8774644314427a2120/django-6.0.tar.gz", hash = "sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26", size = 10932418, upload-time = "2025-12-03T16:26:21.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/016f7e55e855ee738a352b05139d4f8b278d0b451bd01ebef07456ef3b0e/django-6.0.1.tar.gz", hash = "sha256:ed76a7af4da21551573b3d9dfc1f53e20dd2e6c7d70a3adc93eedb6338130a5f", size = 11069565, upload-time = "2026-01-06T18:55:53.069Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/814ed98bd21235c116fd3436a7ed44d47560329a6d694ec8aac2982dbb93/django-6.0.1-py3-none-any.whl", hash = "sha256:a92a4ff14f664a896f9849009cb8afaca7abe0d6fc53325f3d1895a15253433d", size = 8338791, upload-time = "2026-01-06T18:55:46.175Z" }, ] [[package]] @@ -778,11 +778,11 @@ wheels = [ [[package]] name = "marshmallow" -version = "4.1.2" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/e1/5edfd1edf05d3cc98415b0810ca45fa19d7dee6def0d0ec639eb4eb14e20/marshmallow-4.1.2.tar.gz", hash = "sha256:083f250643d2e75fd363f256aeb6b1af369a7513ad37647ce4a601f6966e3ba5", size = 220974, upload-time = "2025-12-22T06:16:35.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/34/55d47aab1ef03fb5aab96257a31acfc58791d274cf86c044e6e75e6d3bfe/marshmallow-4.2.0.tar.gz", hash = "sha256:908acabd5aa14741419d3678d3296bda6abe28a167b7dcd05969ceb8256943ac", size = 221225, upload-time = "2026-01-04T16:07:36.921Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b6/66d1748fb45453e337c8a334dafed7b818e72ac9cf9d105a56e0cf21865f/marshmallow-4.1.2-py3-none-any.whl", hash = "sha256:a8cfa18bd8d0e5f7339e734edf84815fe8db1bdb57358c7ccc05472b746eeadc", size = 48360, upload-time = "2025-12-22T06:16:33.994Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b6/0a907f92c2158c9841da0227c7074ce1490f578f34d67cbba82ba8f9146e/marshmallow-4.2.0-py3-none-any.whl", hash = "sha256:1dc369bd13a8708a9566d6f73d1db07d50142a7580f04fd81e1c29a4d2e10af4", size = 48448, upload-time = "2026-01-04T16:07:34.269Z" }, ] [[package]] @@ -1668,7 +1668,7 @@ wheels = [ [[package]] name = "typer" -version = "0.21.0" +version = "0.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1676,9 +1676,9 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/30/ff9ede605e3bd086b4dd842499814e128500621f7951ca1e5ce84bbf61b1/typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53", size = 106781, upload-time = "2025-12-25T09:54:53.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e4/5ebc1899d31d2b1601b32d21cfb4bba022ae6fce323d365f0448031b1660/typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6", size = 47109, upload-time = "2025-12-25T09:54:51.918Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, ] [[package]]