From 25fd1b02694cacc7b55a7f39d0151596c6066bb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:04:43 +0000 Subject: [PATCH 1/6] Initial plan From 45f8c2669d841b451605a7875fc16f1d4b1e449e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:14:58 +0000 Subject: [PATCH 2/6] Consolidate models and tests into single files Co-authored-by: danjac <249779+danjac@users.noreply.github.com> --- simplecasts/admin.py | 4 +- simplecasts/models/__init__.py | 893 ++++++++++++++++++++++++++++++++- simplecasts/tests/models.py | 563 +++++++++++++++++++++ 3 files changed, 1450 insertions(+), 10 deletions(-) create mode 100644 simplecasts/tests/models.py diff --git a/simplecasts/admin.py b/simplecasts/admin.py index 61d71ab7e..1debe5373 100644 --- a/simplecasts/admin.py +++ b/simplecasts/admin.py @@ -12,13 +12,13 @@ AudioLog, Category, Episode, + EpisodeQuerySet, Podcast, + PodcastQuerySet, Recommendation, Subscription, User, ) -from simplecasts.models.episodes import EpisodeQuerySet -from simplecasts.models.podcasts import PodcastQuerySet if TYPE_CHECKING: from django_stubs_ext import StrOrPromise # pragma: no cover diff --git a/simplecasts/models/__init__.py b/simplecasts/models/__init__.py index 8449b192c..d3356d3e0 100644 --- a/simplecasts/models/__init__.py +++ b/simplecasts/models/__init__.py @@ -1,20 +1,897 @@ -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 +import dataclasses +import functools +import operator +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, ClassVar, Final, Optional, Self + +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVectorField +from django.core.validators import MinLengthValidator +from django.db import models +from django.db.models import F, Q, QuerySet +from django.db.models.fields.tuple_lookups import ( # type: ignore[reportMissingTypeStubs] + TupleGreaterThan, + TupleLessThan, +) +from django.db.models.functions import Coalesce +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property +from slugify import slugify + +from simplecasts.services.sanitizer import strip_html +from simplecasts.validators import url_validator + +if TYPE_CHECKING: + BaseQuerySet = QuerySet +else: + BaseQuerySet = object + + +# Fields + +# URLField with sensible defaults +URLField = functools.partial( + models.URLField, + max_length=2083, + validators=[url_validator], +) + + +# Search + +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) + + +# User + +class User(AbstractUser): + """Custom User model.""" + + send_email_notifications = models.BooleanField(default=True) + + if TYPE_CHECKING: + audio_logs: "AudioLogQuerySet" + bookmarks: "BookmarkQuerySet" + recommended_podcasts: "PodcastQuerySet" + subscriptions: models.Manager["Subscription"] + + @property + def name(self): + """Return the user's first name or username.""" + return self.first_name or self.username + + +# Category + +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}) + + +# Recommendation + +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"], + ), + ] + + +# Podcast + +@dataclasses.dataclass(kw_only=True, frozen=True) +class Season: + """Encapsulates podcast season""" + + podcast: "Podcast" + season: int + + def __str__(self) -> str: + """Return season label.""" + return self.label + + @cached_property + def label(self) -> str: + """Returns label for season.""" + return f"Season {self.season}" + + @cached_property + def url(self) -> str: + """Returns URL of season.""" + return reverse( + "podcasts:season", + kwargs={ + "podcast_id": self.podcast.pk, + "slug": self.podcast.slug, + "season": self.season, + }, + ) + + +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")) + + def published(self) -> Self: + """Returns only published podcasts (pub_date NOT NULL).""" + return self.filter(pub_date__isnull=False) + + def scheduled(self) -> Self: + """Returns all podcasts scheduled for feed parser update. + + 1. If parsed is NULL, should be ASAP. + 2. If pub date is NULL, if NOW - frequency > parsed + 3. If pub date is not NULL, if NOW - frequency > pub date + 4. If parsed more than 3 days ago + + Last parsed time must be at least one hour. + """ + now = timezone.now() + since = now - models.F("frequency") # type: ignore[operator] + + return self.filter( + models.Q(parsed__isnull=True) + | models.Q( + models.Q(pub_date__isnull=True, parsed__lt=since) + | models.Q(pub_date__isnull=False, pub_date__lt=since) + | models.Q(parsed__lt=now - self.model.MAX_PARSER_FREQUENCY), + parsed__lt=now - self.model.MIN_PARSER_FREQUENCY, + ) + ) + + def recommended(self, user: User) -> Self: + """Returns recommended podcasts for user based on subscriptions. Includes `relevance` annotation.""" + + # pick highest matches + # we want the sum of the relevance of the recommendations, grouped by recommended + + subscribed = set(user.subscriptions.values_list("podcast", flat=True)) + recommended = set(user.recommended_podcasts.values_list("pk", flat=True)) + + exclude = subscribed | recommended + + scores = ( + Recommendation.objects.filter( + podcast__in=subscribed, + recommended=models.OuterRef("pk"), + ) + .exclude(recommended__in=exclude) + .values("score") + .order_by("-score") + ) + + return ( + self.alias( + relevance=Coalesce( + models.Subquery( + scores.values("score")[:1], + ), + 0, + output_field=models.DecimalField(), + ), + ) + .filter(models.Q(relevance__gt=0)) + .exclude(pk__in=exclude) + ) + + +class Podcast(models.Model): + """Podcast channel or feed.""" + + DEFAULT_PARSER_FREQUENCY: Final = timedelta(hours=24) + MIN_PARSER_FREQUENCY: Final = timedelta(hours=1) + MAX_PARSER_FREQUENCY: Final = timedelta(days=3) + + MAX_RETRIES: Final = 12 + + class PodcastType(models.TextChoices): + EPISODIC = "episodic", "Episodic" + SERIAL = "serial", "Serial" + + class FeedStatus(models.TextChoices): + """Result of the last feed parse.""" + + SUCCESS = "success", "Success" + NOT_MODIFIED = "not_modified", "Not Modified" + DATABASE_ERROR = "database_error", "Database Error" + DISCONTINUED = "discontinued", "Discontinued" + DUPLICATE = "duplicate", "Duplicate" + INVALID_RSS = "invalid_rss", "Invalid RSS" + UNAVAILABLE = "unavailable", "Unavailable" + + rss = URLField(unique=True) + + active = models.BooleanField( + default=True, + help_text="Inactive podcasts will no longer be updated from their RSS feeds.", + ) + + canonical = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="duplicates", + ) + + 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(null=True, blank=True) + + num_episodes = models.PositiveIntegerField(default=0) + + parsed = models.DateTimeField(null=True, blank=True) + + feed_status = models.CharField( + max_length=20, + choices=FeedStatus.choices, + blank=True, + ) + + frequency = models.DurationField(default=DEFAULT_PARSER_FREQUENCY) + + modified = models.DateTimeField( + null=True, + blank=True, + ) + + content_hash = models.CharField(max_length=64, blank=True) + + exception = models.TextField(blank=True) + + num_retries = models.PositiveIntegerField(default=0) + + cover_url = URLField(blank=True) + + funding_url = URLField(blank=True) + funding_text = models.TextField(blank=True) + + language = models.CharField( + max_length=2, + default="en", + validators=[MinLengthValidator(2)], + ) + + description = models.TextField(blank=True) + website = URLField(blank=True) + 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( + max_length=10, + choices=PodcastType.choices, + default=PodcastType.EPISODIC, + ) + + created = models.DateTimeField(auto_now_add=True) + + updated = models.DateTimeField(auto_now=True) + + explicit = models.BooleanField(default=False) + + categories = models.ManyToManyField( + "simplecasts.Category", + blank=True, + related_name="podcasts", + ) + + recipients = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="recommended_podcasts", + ) + + 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] + + if TYPE_CHECKING: + episodes: models.Manager["Episode"] + subscriptions: models.Manager["Subscription"] + recommendations: models.Manager[Recommendation] + similar: models.Manager[Recommendation] + + class Meta: + indexes: ClassVar[list] = [ + # Recent podcasts index + models.Index(fields=["-pub_date"]), + # Discover feed index + models.Index(fields=["-promoted", "language", "-pub_date"]), + # Feed parser scheduling index + models.Index( + fields=[ + "active", + "-promoted", + "parsed", + "updated", + ] + ), + # Common lookup index for public feeds + models.Index( + fields=["-pub_date"], + condition=models.Q( + private=False, + pub_date__isnull=False, + ), + name="%(app_label)s_%(class)s_public_idx", + ), + # Search indexes + GinIndex(fields=["search_document"]), + GinIndex(fields=["owner_search_document"]), + ] + + def __str__(self) -> str: + """Returns podcast title or RSS if missing.""" + return self.title or self.rss + + def get_absolute_url(self) -> str: + """Default absolute URL of podcast.""" + return self.get_detail_url() + + def get_detail_url(self) -> str: + """Podcast detail URL""" + return reverse( + "podcasts:detail", + kwargs={ + "podcast_id": self.pk, + "slug": self.slug, + }, + ) + + def get_episodes_url(self) -> str: + """Podcast episodes URL""" + return reverse( + "podcasts:episodes", + kwargs={ + "podcast_id": self.pk, + "slug": self.slug, + }, + ) + + def get_similar_url(self) -> str: + """Podcast recommendations URL""" + return reverse( + "podcasts:similar", + kwargs={ + "podcast_id": self.pk, + "slug": self.slug, + }, + ) + + @cached_property + def cleaned_title(self) -> str: + """Strips HTML from title field.""" + return strip_html(self.title) + + @cached_property + def cleaned_description(self) -> str: + """Strips HTML from description field.""" + return strip_html(self.description) + + @cached_property + def cleaned_owner(self) -> str: + """Strips HTML from owner field.""" + return strip_html(self.owner) + + @cached_property + def slug(self) -> str: + """Returns slugified title.""" + return slugify(self.title) or "podcast" + + @cached_property + def has_similar_podcasts(self) -> bool: + """Returns True if any similar podcasts.""" + return self.private is False and self.recommendations.exists() + + @cached_property + def seasons(self) -> list[Season]: + """Returns list of seasons.""" + return [ + self.get_season(season) + for season in self.episodes.filter(season__isnull=False) + .values_list("season", flat=True) + .order_by("season") + .distinct() + ] + + def get_season(self, season: int) -> Season: + """Returns Season instance.""" + return Season(podcast=self, season=season) + + def get_next_scheduled_update(self) -> datetime: + """Returns estimated next update: + + 1. If parsed is NULL, should be ASAP. + 2. If pub date is NULL, add frequency to last parsed + 3. If pub date is not NULL, add frequency to pub date + 4. Scheduled time should always be in range of 1-24 hours. + + Note that this is a rough estimate: the precise update time depends + on the frequency of the parse feeds cron and the number of other + scheduled podcasts in the queue. + """ + + if self.parsed is None or self.frequency is None: + return timezone.now() + + return min( + self.parsed + self.MAX_PARSER_FREQUENCY, + max( + (self.pub_date or self.parsed) + self.frequency, + self.parsed + self.MIN_PARSER_FREQUENCY, + ), + ) + + def is_episodic(self) -> bool: + """Returns true if podcast is episodic.""" + return self.podcast_type == self.PodcastType.EPISODIC + + def is_serial(self) -> bool: + """Returns true if podcast is serial.""" + return self.podcast_type == self.PodcastType.SERIAL + + +# Episode + +class EpisodeQuerySet(SearchQuerySetMixin, models.QuerySet): + """Custom queryset for Episode model.""" + + default_search_fields = ("search_document",) + + +class Episode(models.Model): + """Individual podcast episode.""" + + class EpisodeType(models.TextChoices): + FULL = "full", "Full episode" + TRAILER = "trailer", "Trailer" + BONUS = "bonus", "Bonus" + + podcast = models.ForeignKey( + "simplecasts.Podcast", + on_delete=models.CASCADE, + related_name="episodes", + ) + + guid = models.TextField() + + pub_date = models.DateTimeField() + + title = models.TextField(blank=True) + description = models.TextField(blank=True) + keywords = models.TextField(blank=True) + + cover_url = URLField(blank=True) + + website = URLField(blank=True) + + episode_type = models.CharField( + max_length=12, + choices=EpisodeType.choices, + default=EpisodeType.FULL, + ) + + episode = models.IntegerField(null=True, blank=True) + season = models.IntegerField(null=True, blank=True) + + media_url = URLField() + + media_type = models.CharField(max_length=60) + + file_size = models.BigIntegerField( + null=True, blank=True, verbose_name="File size in bytes" + ) + duration = models.CharField(max_length=30, blank=True) + + explicit = models.BooleanField(default=False) + + search_document = SearchVectorField(null=True, blank=True, editable=False) + + objects: EpisodeQuerySet = EpisodeQuerySet.as_manager() # type: ignore[assignment] + + class Meta: + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s_podcast_guid", + fields=["podcast", "guid"], + ) + ] + indexes: ClassVar[list] = [ + models.Index(fields=["podcast", "pub_date", "id"]), + models.Index(fields=["podcast", "-pub_date", "-id"]), + models.Index(fields=["podcast", "season", "-pub_date", "-id"]), + models.Index(fields=["pub_date", "id"]), + models.Index(fields=["-pub_date", "-id"]), + models.Index(fields=["guid"]), + GinIndex(fields=["search_document"]), + ] + + def __str__(self) -> str: + """Returns title or guid.""" + return self.title or self.guid + + def get_absolute_url(self) -> str: + """Canonical episode URL.""" + return reverse( + "episodes:detail", + kwargs={ + "episode_id": self.pk, + "slug": self.slug, + }, + ) + + def get_cover_url(self) -> str: + """Returns cover image URL or podcast cover image if former not provided.""" + return self.cover_url or self.podcast.cover_url + + def is_explicit(self) -> bool: + """Check if either this specific episode or the podcast is explicit.""" + return self.explicit or self.podcast.explicit + + def get_season(self) -> Season | None: + """Returns season object if episode has a season.""" + return self.podcast.get_season(season=self.season) if self.season else None + + @cached_property + def next_episode(self) -> Optional["Episode"]: + """Returns the next episode in this podcast.""" + return ( + self._get_other_episodes_in_podcast() + .filter( + TupleGreaterThan( + (models.F("pub_date"), models.F("id")), + (models.Value(self.pub_date), models.Value(self.pk)), + ), + ) + .order_by( + "pub_date", + "id", + ) + .first() + ) + + @cached_property + def previous_episode(self) -> Optional["Episode"]: + """Returns the previous episode in this podcast.""" + return ( + self._get_other_episodes_in_podcast() + .filter( + TupleLessThan( + (models.F("pub_date"), models.F("id")), + (models.Value(self.pub_date), models.Value(self.pk)), + ), + ) + .order_by( + "-pub_date", + "-id", + ) + .first() + ) + + @cached_property + def slug(self) -> str: + """Returns slugified title, if any.""" + return slugify(self.title) or "episode" + + @cached_property + def cleaned_title(self) -> str: + """Strips HTML from title field.""" + return strip_html(self.title) + + @cached_property + def cleaned_description(self) -> str: + """Strips HTML from description field.""" + return strip_html(self.description) + + @cached_property + def duration_in_seconds(self) -> int: + """Returns total number of seconds given string in [h:][m:]s format.""" + if not self.duration: + return 0 + + try: + return sum( + (int(part) * multiplier) + for (part, multiplier) in zip( + reversed(self.duration.split(":")[:3]), + (1, 60, 3600), + strict=False, + ) + ) + except ValueError: + return 0 + + 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) + + +# Subscription + +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"])] + + +# Bookmark + +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", + ), + ] + + +# AudioLog + +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)) + __all__ = [ "AudioLog", + "AudioLogQuerySet", "Bookmark", + "BookmarkQuerySet", "Category", "Episode", + "EpisodeQuerySet", "Podcast", + "PodcastQuerySet", "Recommendation", + "RecommendationQuerySet", "Season", + "SearchQuerySetMixin", "Subscription", + "URLField", "User", ] diff --git a/simplecasts/tests/models.py b/simplecasts/tests/models.py new file mode 100644 index 000000000..7ae9f8c24 --- /dev/null +++ b/simplecasts/tests/models.py @@ -0,0 +1,563 @@ +import datetime +from datetime import timedelta + +import pytest +from django.utils import timezone + +from simplecasts.models import ( + AudioLog, + Bookmark, + Category, + Episode, + Podcast, + Recommendation, + User, +) +from simplecasts.tests.factories import ( + AudioLogFactory, + BookmarkFactory, + CategoryFactory, + EpisodeFactory, + PodcastFactory, + RecommendationFactory, + SubscriptionFactory, +) + + +# User tests + + +class TestUserModel: + def test_name_has_first_and_last_name(self): + """Test name property with first and last name.""" + user = User(first_name="John", username="johndoe") + assert user.name == "John" + + def test_name_has_only_username(self): + """Test name property with only username.""" + user = User(username="johndoe") + assert user.name == "johndoe" + + +# Category tests + + +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" + + +# Recommendation tests + + +class TestRecommendationManager: + @pytest.mark.django_db + def test_bulk_delete(self): + RecommendationFactory.create_batch(3) + Recommendation.objects.bulk_delete() + assert Recommendation.objects.count() == 0 + + +# Podcast tests + + +class TestPodcastManager: + @pytest.mark.django_db + 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 + + @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_search_empty(self): + PodcastFactory(title="Learn Python") + PodcastFactory(title="Learn Django") + PodcastFactory(title="Cooking Tips") + + results = Podcast.objects.search("") + assert results.count() == 0 + + @pytest.mark.django_db + def test_subscribed_true(self, user): + SubscriptionFactory(subscriber=user) + assert Podcast.objects.subscribed(user).exists() is True + + @pytest.mark.django_db + def test_subscribed_false(self, user, podcast): + assert Podcast.objects.subscribed(user).exists() is False + + @pytest.mark.django_db + def test_published_true(self): + PodcastFactory(pub_date=timezone.now()) + assert Podcast.objects.published().exists() is True + + @pytest.mark.django_db + def test_published_false(self): + PodcastFactory(pub_date=None) + assert Podcast.objects.published().exists() is False + + @pytest.mark.parametrize( + ("kwargs", "exists"), + [ + pytest.param( + {}, + True, + id="parsed is None", + ), + pytest.param( + { + "parsed": datetime.timedelta(hours=3), + "frequency": datetime.timedelta(hours=1), + }, + True, + id="pub date is None, parsed more than now-frequency", + ), + pytest.param( + { + "parsed": datetime.timedelta(minutes=30), + "frequency": datetime.timedelta(hours=1), + }, + False, + id="pub date is None, parsed less than now-frequency", + ), + pytest.param( + { + "parsed": datetime.timedelta(seconds=1200), + "pub_date": datetime.timedelta(days=3), + "frequency": datetime.timedelta(hours=3), + }, + False, + id="pub date is None, just parsed", + ), + pytest.param( + { + "parsed": datetime.timedelta(hours=3), + "pub_date": datetime.timedelta(days=1), + "frequency": datetime.timedelta(hours=3), + }, + True, + id="parsed before pub date+frequency", + ), + pytest.param( + { + "parsed": datetime.timedelta(days=8), + "pub_date": datetime.timedelta(days=8, minutes=1), + "frequency": datetime.timedelta(days=12), + }, + True, + id="parsed just before max frequency", + ), + pytest.param( + { + "parsed": datetime.timedelta(days=30), + "pub_date": datetime.timedelta(days=90), + "frequency": datetime.timedelta(days=30), + }, + True, + id="parsed before max frequency", + ), + ], + ) + @pytest.mark.django_db + def test_scheduled(self, kwargs, exists): + now = timezone.now() + + parsed = kwargs.get("parsed", None) + pub_date = kwargs.get("pub_date", None) + + frequency = kwargs.get("frequency", Podcast.DEFAULT_PARSER_FREQUENCY) + + PodcastFactory( + frequency=frequency, + parsed=now - parsed if parsed else None, + pub_date=now - pub_date if pub_date else None, + ) + + assert Podcast.objects.scheduled().exists() is exists + + @pytest.mark.django_db + def test_recommended(self, user): + podcast = SubscriptionFactory(subscriber=user).podcast + RecommendationFactory.create_batch(3, podcast=podcast) + outlier = PodcastFactory() # not recommended + podcasts = Podcast.objects.recommended(user) + assert podcasts.count() == 3 + assert outlier not in podcasts + + @pytest.mark.django_db + def test_recommended_is_subscribed(self, user): + podcast = SubscriptionFactory(subscriber=user).podcast + RecommendationFactory(recommended=podcast) + assert Podcast.objects.recommended(user).count() == 0 + + @pytest.mark.django_db + def test_already_recommended(self, user): + podcast = SubscriptionFactory(subscriber=user).podcast + recommended = RecommendationFactory(podcast=podcast).recommended + user.recommended_podcasts.add(recommended) + assert Podcast.objects.recommended(user).count() == 0 + + @pytest.mark.django_db + def test_recommended_is_subscribed_or_recommended(self, user): + podcast = SubscriptionFactory(subscriber=user).podcast + RecommendationFactory(recommended=podcast) + recommended = RecommendationFactory(podcast=podcast).recommended + user.recommended_podcasts.add(recommended) + assert Podcast.objects.recommended(user).count() == 0 + + +class TestPodcastModel: + def test_str(self): + assert str(Podcast(title="title")) == "title" + + def test_str_title_empty(self): + rss = "https://example.com/rss.xml" + assert str(Podcast(title="", rss=rss)) == rss + + def test_slug(self): + assert Podcast(title="Testing").slug == "testing" + + def test_slug_if_title_empty(self): + assert Podcast().slug == "podcast" + + def test_cleaned_title(self): + podcast = Podcast(title="Test & Code") + assert podcast.cleaned_title == "Test & Code" + + def test_cleaned_description(self): + podcast = Podcast(description="Test & Code") + assert podcast.cleaned_description == "Test & Code" + + def test_cleaned_owner(self): + podcast = Podcast(owner="Test & Code") + assert podcast.cleaned_owner == "Test & Code" + + @pytest.mark.django_db + def test_has_similar_podcasts_private(self): + podcast = RecommendationFactory(podcast__private=True).podcast + assert podcast.has_similar_podcasts is False + + @pytest.mark.django_db + def test_has_similar_podcasts_true(self): + podcast = RecommendationFactory().podcast + assert podcast.has_similar_podcasts is True + + @pytest.mark.django_db + def test_has_similar_podcasts_false(self): + podcast = PodcastFactory() + assert podcast.has_similar_podcasts is False + + @pytest.mark.django_db + def test_seasons(self, podcast): + EpisodeFactory.create_batch(3, podcast=podcast, season=-1) + EpisodeFactory.create_batch(3, podcast=podcast, season=2) + EpisodeFactory.create_batch(3, podcast=podcast, season=1) + EpisodeFactory.create_batch(1, podcast=podcast, season=None) + assert len(podcast.seasons) == 3 + assert podcast.seasons[0].season == -1 + assert podcast.seasons[1].season == 1 + assert podcast.seasons[2].season == 2 + assert podcast.seasons[0].url + assert podcast.seasons[1].url + assert podcast.seasons[2].url + + def test_get_next_scheduled_update_pub_date_none(self): + now = timezone.now() + podcast = Podcast( + parsed=now - datetime.timedelta(hours=1), + pub_date=None, + frequency=datetime.timedelta(hours=3), + ) + self.assert_hours_diff(podcast.get_next_scheduled_update() - now, 2) + + def test_get_next_scheduled_update_frequency_none(self): + now = timezone.now() + podcast = Podcast( + parsed=now - datetime.timedelta(hours=1), pub_date=None, frequency=None + ) + assert (podcast.get_next_scheduled_update() - now).total_seconds() < 10 + + def test_get_next_scheduled_update_parsed_none(self): + now = timezone.now() + podcast = Podcast( + pub_date=now - datetime.timedelta(hours=3), + parsed=None, + frequency=datetime.timedelta(hours=3), + ) + assert (podcast.get_next_scheduled_update() - now).total_seconds() < 10 + + def test_get_next_scheduled_update_parsed_gt_max(self): + now = timezone.now() + podcast = Podcast( + pub_date=now, + parsed=now, + frequency=datetime.timedelta(days=30), + ) + self.assert_hours_diff(podcast.get_next_scheduled_update() - now, 72) + + def test_get_next_scheduled_update_parsed_lt_now(self): + now = timezone.now() + podcast = Podcast( + pub_date=now - datetime.timedelta(days=5), + parsed=now - datetime.timedelta(days=16), + frequency=datetime.timedelta(days=30), + ) + assert (podcast.get_next_scheduled_update() - now).total_seconds() < 10 + + def test_get_next_scheduled_update_pub_date_lt_now(self): + now = timezone.now() + podcast = Podcast( + pub_date=now - datetime.timedelta(days=33), + parsed=now - datetime.timedelta(days=3), + frequency=datetime.timedelta(days=30), + ) + assert (podcast.get_next_scheduled_update() - now).total_seconds() < 10 + + def test_get_next_scheduled_update_pub_date_in_future(self): + now = timezone.now() + podcast = Podcast( + pub_date=now - datetime.timedelta(days=1), + parsed=now - datetime.timedelta(hours=1), + frequency=datetime.timedelta(days=7), + ) + self.assert_hours_diff(podcast.get_next_scheduled_update() - now, 71) + + def test_get_next_scheduled_update_pub_date_lt_min(self): + now = timezone.now() + podcast = Podcast( + pub_date=now - datetime.timedelta(hours=3), + parsed=now - datetime.timedelta(minutes=30), + frequency=datetime.timedelta(hours=3), + ) + + self.assert_hours_diff(podcast.get_next_scheduled_update() - now, 0.5) + + def test_is_episodic(self): + podcast = Podcast(podcast_type=Podcast.PodcastType.EPISODIC) + assert podcast.is_episodic() is True + assert podcast.is_serial() is False + + def test_is_serial(self): + podcast = Podcast(podcast_type=Podcast.PodcastType.SERIAL) + assert podcast.is_episodic() is False + assert podcast.is_serial() is True + + def assert_hours_diff(self, delta, hours): + assert delta.total_seconds() / 3600 == pytest.approx(hours) + + +# Episode tests + + +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: + link = "https://example.com" + + @pytest.mark.django_db + def test_next_episode_if_none(self, episode): + assert episode.next_episode is None + + @pytest.mark.django_db + def test_previous_episode_if_none(self, episode): + assert episode.previous_episode is None + + @pytest.mark.django_db + def test_next_episode_not_same_podcast(self, episode): + EpisodeFactory( + pub_date=episode.pub_date + timedelta(days=2), + ) + + assert episode.next_episode is None + + @pytest.mark.django_db + def test_previous_episode_not_same_podcast(self, episode): + EpisodeFactory( + pub_date=episode.pub_date - timedelta(days=2), + ) + + assert episode.previous_episode is None + + @pytest.mark.django_db + def test_next_episode(self, episode): + next_episode = EpisodeFactory( + podcast=episode.podcast, + pub_date=episode.pub_date + timedelta(days=2), + ) + + assert episode.next_episode == next_episode + + @pytest.mark.django_db + def test_previous_episode(self, episode): + previous_episode = EpisodeFactory( + podcast=episode.podcast, + pub_date=episode.pub_date - timedelta(days=2), + ) + + assert episode.previous_episode == previous_episode + + def test_episode_explicit(self): + assert Episode(explicit=True).is_explicit() is True + + def test_podcast_explicit(self): + assert ( + Episode(explicit=False, podcast=Podcast(explicit=True)).is_explicit() + is True + ) + + def test_not_explicit(self): + assert ( + Episode(explicit=False, podcast=Podcast(explicit=False)).is_explicit() + is False + ) + + def test_slug(self): + episode = Episode(title="Testing") + assert episode.slug == "testing" + + def test_slug_if_title_empty(self): + assert Episode().slug == "episode" + + def test_str(self): + assert str(Episode(title="testing")) == "testing" + + def test_str_no_title(self): + episode = Episode(title="", guid="abc123") + assert str(episode) == episode.guid + + def test_cleaned_title(self): + episode = Episode(title="Test & Code") + assert episode.cleaned_title == "Test & Code" + + def test_cleaned_description(self): + episode = Episode(description="Test & Code") + assert episode.cleaned_description == "Test & Code" + + @pytest.mark.django_db + def test_get_cover_url_if_episode_cover(self, podcast): + episode = EpisodeFactory( + podcast=podcast, cover_url="https://example.com/episode-cover.jpg" + ) + assert episode.get_cover_url() == "https://example.com/episode-cover.jpg" + + @pytest.mark.django_db + def test_get_cover_url_if_podcast_cover(self, episode): + assert episode.get_cover_url() == "https://example.com/cover.jpg" + + @pytest.mark.django_db + def test_get_cover_url_if_none(self): + episode = EpisodeFactory(podcast=PodcastFactory(cover_url="")) + assert episode.get_cover_url() == "" + + @pytest.mark.parametrize( + ("duration", "expected"), + [ + pytest.param("2:30:40", 9040, id="hours"), + pytest.param("2:30:40:2903903", 9040, id="extra digit"), + pytest.param("30:40", 1840, id="minutes and seconds"), + pytest.param("40", 40, id="seconds"), + pytest.param("NaN", 0, id="non-numeric"), + pytest.param("", 0, id="empty"), + ], + ) + def test_duration_in_seconds(self, duration, expected): + assert Episode(duration=duration).duration_in_seconds == expected + + +# Bookmark tests + + +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 + + +# AudioLog tests + + +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 From 142ce98b4bfdc13234d872d206a949772d6e0ac4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:17:03 +0000 Subject: [PATCH 3/6] Delete old individual model and test files Co-authored-by: danjac <249779+danjac@users.noreply.github.com> --- simplecasts/models/audio_logs.py | 66 --- simplecasts/models/bookmarks.py | 56 --- simplecasts/models/categories.py | 36 -- simplecasts/models/episodes.py | 191 --------- simplecasts/models/fields.py | 12 - simplecasts/models/podcasts.py | 391 ------------------ simplecasts/models/recommendations.py | 51 --- simplecasts/models/search.py | 60 --- simplecasts/models/subscriptions.py | 31 -- simplecasts/models/users.py | 27 -- simplecasts/tests/models/__init__.py | 0 simplecasts/tests/models/test_audio_logs.py | 41 -- simplecasts/tests/models/test_bookmarks.py | 21 - simplecasts/tests/models/test_categories.py | 19 - simplecasts/tests/models/test_episodes.py | 139 ------- simplecasts/tests/models/test_podcasts.py | 314 -------------- .../tests/models/test_recommendations.py | 16 - simplecasts/tests/models/test_users.py | 13 - 18 files changed, 1484 deletions(-) delete mode 100644 simplecasts/models/audio_logs.py delete mode 100644 simplecasts/models/bookmarks.py delete mode 100644 simplecasts/models/categories.py delete mode 100644 simplecasts/models/episodes.py delete mode 100644 simplecasts/models/fields.py delete mode 100644 simplecasts/models/podcasts.py delete mode 100644 simplecasts/models/recommendations.py delete mode 100644 simplecasts/models/search.py delete mode 100644 simplecasts/models/subscriptions.py delete mode 100644 simplecasts/models/users.py delete mode 100644 simplecasts/tests/models/__init__.py delete mode 100644 simplecasts/tests/models/test_audio_logs.py delete mode 100644 simplecasts/tests/models/test_bookmarks.py delete mode 100644 simplecasts/tests/models/test_categories.py delete mode 100644 simplecasts/tests/models/test_episodes.py delete mode 100644 simplecasts/tests/models/test_podcasts.py delete mode 100644 simplecasts/tests/models/test_recommendations.py delete mode 100644 simplecasts/tests/models/test_users.py diff --git a/simplecasts/models/audio_logs.py b/simplecasts/models/audio_logs.py deleted file mode 100644 index 9ca8edade..000000000 --- a/simplecasts/models/audio_logs.py +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index c3031fc84..000000000 --- a/simplecasts/models/bookmarks.py +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index e6f075836..000000000 --- a/simplecasts/models/categories.py +++ /dev/null @@ -1,36 +0,0 @@ -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/models/episodes.py b/simplecasts/models/episodes.py deleted file mode 100644 index aeb776ebf..000000000 --- a/simplecasts/models/episodes.py +++ /dev/null @@ -1,191 +0,0 @@ -from typing import ClassVar, Optional - -from django.contrib.postgres.indexes import GinIndex -from django.contrib.postgres.search import SearchVectorField -from django.db import models -from django.db.models.fields.tuple_lookups import ( # type: ignore[reportMissingTypeStubs] - TupleGreaterThan, - TupleLessThan, -) -from django.urls import reverse -from django.utils.functional import cached_property -from slugify import slugify - -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): - """Individual podcast episode.""" - - class EpisodeType(models.TextChoices): - FULL = "full", "Full episode" - TRAILER = "trailer", "Trailer" - BONUS = "bonus", "Bonus" - - podcast = models.ForeignKey( - "simplecasts.Podcast", - on_delete=models.CASCADE, - related_name="episodes", - ) - - guid = models.TextField() - - pub_date = models.DateTimeField() - - title = models.TextField(blank=True) - description = models.TextField(blank=True) - keywords = models.TextField(blank=True) - - cover_url = URLField(blank=True) - - website = URLField(blank=True) - - episode_type = models.CharField( - max_length=12, - choices=EpisodeType.choices, - default=EpisodeType.FULL, - ) - - episode = models.IntegerField(null=True, blank=True) - season = models.IntegerField(null=True, blank=True) - - media_url = URLField() - - media_type = models.CharField(max_length=60) - - file_size = models.BigIntegerField( - null=True, blank=True, verbose_name="File size in bytes" - ) - duration = models.CharField(max_length=30, blank=True) - - explicit = models.BooleanField(default=False) - - search_document = SearchVectorField(null=True, blank=True, editable=False) - - objects: EpisodeQuerySet = EpisodeQuerySet.as_manager() # type: ignore[assignment] - - class Meta: - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s_podcast_guid", - fields=["podcast", "guid"], - ) - ] - indexes: ClassVar[list] = [ - models.Index(fields=["podcast", "pub_date", "id"]), - models.Index(fields=["podcast", "-pub_date", "-id"]), - models.Index(fields=["podcast", "season", "-pub_date", "-id"]), - models.Index(fields=["pub_date", "id"]), - models.Index(fields=["-pub_date", "-id"]), - models.Index(fields=["guid"]), - GinIndex(fields=["search_document"]), - ] - - def __str__(self) -> str: - """Returns title or guid.""" - return self.title or self.guid - - def get_absolute_url(self) -> str: - """Canonical episode URL.""" - return reverse( - "episodes:detail", - kwargs={ - "episode_id": self.pk, - "slug": self.slug, - }, - ) - - def get_cover_url(self) -> str: - """Returns cover image URL or podcast cover image if former not provided.""" - return self.cover_url or self.podcast.cover_url - - def is_explicit(self) -> bool: - """Check if either this specific episode or the podcast is explicit.""" - return self.explicit or self.podcast.explicit - - def get_season(self) -> Season | None: - """Returns season object if episode has a season.""" - return self.podcast.get_season(season=self.season) if self.season else None - - @cached_property - def next_episode(self) -> Optional["Episode"]: - """Returns the next episode in this podcast.""" - return ( - self._get_other_episodes_in_podcast() - .filter( - TupleGreaterThan( - (models.F("pub_date"), models.F("id")), - (models.Value(self.pub_date), models.Value(self.pk)), - ), - ) - .order_by( - "pub_date", - "id", - ) - .first() - ) - - @cached_property - def previous_episode(self) -> Optional["Episode"]: - """Returns the previous episode in this podcast.""" - return ( - self._get_other_episodes_in_podcast() - .filter( - TupleLessThan( - (models.F("pub_date"), models.F("id")), - (models.Value(self.pub_date), models.Value(self.pk)), - ) - ) - .order_by( - "-pub_date", - "-id", - ) - .first() - ) - - @cached_property - def slug(self) -> str: - """Returns slugified title, if any.""" - return slugify(self.title) or "episode" - - @cached_property - def cleaned_title(self) -> str: - """Strips HTML from title field.""" - return strip_html(self.title) - - @cached_property - def cleaned_description(self) -> str: - """Strips HTML from description field.""" - return strip_html(self.description) - - @cached_property - def duration_in_seconds(self) -> int: - """Returns total number of seconds given string in [h:][m:]s format.""" - if not self.duration: - return 0 - - try: - return sum( - (int(part) * multiplier) - for (part, multiplier) in zip( - reversed(self.duration.split(":")[:3]), - (1, 60, 3600), - strict=False, - ) - ) - except ValueError: - return 0 - - 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) diff --git a/simplecasts/models/fields.py b/simplecasts/models/fields.py deleted file mode 100644 index 44d43ec28..000000000 --- a/simplecasts/models/fields.py +++ /dev/null @@ -1,12 +0,0 @@ -import functools - -from django.db import models - -from simplecasts.validators import url_validator - -# URLField with sensible defaults -URLField = functools.partial( - models.URLField, - max_length=2083, - validators=[url_validator], -) diff --git a/simplecasts/models/podcasts.py b/simplecasts/models/podcasts.py deleted file mode 100644 index 6b1cbbaf2..000000000 --- a/simplecasts/models/podcasts.py +++ /dev/null @@ -1,391 +0,0 @@ -import dataclasses -from datetime import datetime, timedelta -from typing import TYPE_CHECKING, ClassVar, Final, Self - -from django.conf import settings -from django.contrib.postgres.indexes import GinIndex -from django.contrib.postgres.search import SearchVectorField -from django.core.validators import MinLengthValidator -from django.db import models -from django.db.models.functions import Coalesce -from django.urls import reverse -from django.utils import timezone -from django.utils.functional import cached_property -from slugify import slugify - -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.models.episodes import Episode - from simplecasts.models.subscriptions import Subscription - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class Season: - """Encapsulates podcast season""" - - podcast: "Podcast" - season: int - - def __str__(self) -> str: - """Return season label.""" - return self.label - - @cached_property - def label(self) -> str: - """Returns label for season.""" - return f"Season {self.season}" - - @cached_property - def url(self) -> str: - """Returns URL of season.""" - return reverse( - "podcasts:season", - kwargs={ - "podcast_id": self.podcast.pk, - "slug": self.podcast.slug, - "season": self.season, - }, - ) - - -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")) - - def published(self) -> Self: - """Returns only published podcasts (pub_date NOT NULL).""" - return self.filter(pub_date__isnull=False) - - def scheduled(self) -> Self: - """Returns all podcasts scheduled for feed parser update. - - 1. If parsed is NULL, should be ASAP. - 2. If pub date is NULL, if NOW - frequency > parsed - 3. If pub date is not NULL, if NOW - frequency > pub date - 4. If parsed more than 3 days ago - - Last parsed time must be at least one hour. - """ - now = timezone.now() - since = now - models.F("frequency") # type: ignore[operator] - - return self.filter( - models.Q(parsed__isnull=True) - | models.Q( - models.Q(pub_date__isnull=True, parsed__lt=since) - | models.Q(pub_date__isnull=False, pub_date__lt=since) - | models.Q(parsed__lt=now - self.model.MAX_PARSER_FREQUENCY), - parsed__lt=now - self.model.MIN_PARSER_FREQUENCY, - ) - ) - - def recommended(self, user: User) -> Self: - """Returns recommended podcasts for user based on subscriptions. Includes `relevance` annotation.""" - - # pick highest matches - # we want the sum of the relevance of the recommendations, grouped by recommended - - subscribed = set(user.subscriptions.values_list("podcast", flat=True)) - recommended = set(user.recommended_podcasts.values_list("pk", flat=True)) - - exclude = subscribed | recommended - - scores = ( - Recommendation.objects.filter( - podcast__in=subscribed, - recommended=models.OuterRef("pk"), - ) - .exclude(recommended__in=exclude) - .values("score") - .order_by("-score") - ) - - return ( - self.alias( - relevance=Coalesce( - models.Subquery( - scores.values("score")[:1], - ), - 0, - output_field=models.DecimalField(), - ), - ) - .filter(models.Q(relevance__gt=0)) - .exclude(pk__in=exclude) - ) - - -class Podcast(models.Model): - """Podcast channel or feed.""" - - DEFAULT_PARSER_FREQUENCY: Final = timedelta(hours=24) - MIN_PARSER_FREQUENCY: Final = timedelta(hours=1) - MAX_PARSER_FREQUENCY: Final = timedelta(days=3) - - MAX_RETRIES: Final = 12 - - class PodcastType(models.TextChoices): - EPISODIC = "episodic", "Episodic" - SERIAL = "serial", "Serial" - - class FeedStatus(models.TextChoices): - """Result of the last feed parse.""" - - SUCCESS = "success", "Success" - NOT_MODIFIED = "not_modified", "Not Modified" - DATABASE_ERROR = "database_error", "Database Error" - DISCONTINUED = "discontinued", "Discontinued" - DUPLICATE = "duplicate", "Duplicate" - INVALID_RSS = "invalid_rss", "Invalid RSS" - UNAVAILABLE = "unavailable", "Unavailable" - - rss = URLField(unique=True) - - active = models.BooleanField( - default=True, - help_text="Inactive podcasts will no longer be updated from their RSS feeds.", - ) - - canonical = models.ForeignKey( - "self", - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name="duplicates", - ) - - 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(null=True, blank=True) - - num_episodes = models.PositiveIntegerField(default=0) - - parsed = models.DateTimeField(null=True, blank=True) - - feed_status = models.CharField( - max_length=20, - choices=FeedStatus.choices, - blank=True, - ) - - frequency = models.DurationField(default=DEFAULT_PARSER_FREQUENCY) - - modified = models.DateTimeField( - null=True, - blank=True, - ) - - content_hash = models.CharField(max_length=64, blank=True) - - exception = models.TextField(blank=True) - - num_retries = models.PositiveIntegerField(default=0) - - cover_url = URLField(blank=True) - - funding_url = URLField(blank=True) - funding_text = models.TextField(blank=True) - - language = models.CharField( - max_length=2, - default="en", - validators=[MinLengthValidator(2)], - ) - - description = models.TextField(blank=True) - website = URLField(blank=True) - 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( - max_length=10, - choices=PodcastType.choices, - default=PodcastType.EPISODIC, - ) - - created = models.DateTimeField(auto_now_add=True) - - updated = models.DateTimeField(auto_now=True) - - explicit = models.BooleanField(default=False) - - categories = models.ManyToManyField( - "simplecasts.Category", - blank=True, - related_name="podcasts", - ) - - recipients = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - related_name="recommended_podcasts", - ) - - 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] - - if TYPE_CHECKING: - episodes: models.Manager["Episode"] - subscriptions: models.Manager["Subscription"] - recommendations: models.Manager["Recommendation"] - similar: models.Manager["Recommendation"] - - class Meta: - indexes: ClassVar[list] = [ - # Recent podcasts index - models.Index(fields=["-pub_date"]), - # Discover feed index - models.Index(fields=["-promoted", "language", "-pub_date"]), - # Feed parser scheduling index - models.Index( - fields=[ - "active", - "-promoted", - "parsed", - "updated", - ] - ), - # Common lookup index for public feeds - models.Index( - fields=["-pub_date"], - condition=models.Q( - private=False, - pub_date__isnull=False, - ), - name="%(app_label)s_%(class)s_public_idx", - ), - # Search indexes - GinIndex(fields=["search_document"]), - GinIndex(fields=["owner_search_document"]), - ] - - def __str__(self) -> str: - """Returns podcast title or RSS if missing.""" - return self.title or self.rss - - def get_absolute_url(self) -> str: - """Default absolute URL of podcast.""" - return self.get_detail_url() - - def get_detail_url(self) -> str: - """Podcast detail URL""" - return reverse( - "podcasts:detail", - kwargs={ - "podcast_id": self.pk, - "slug": self.slug, - }, - ) - - def get_episodes_url(self) -> str: - """Podcast episodes URL""" - return reverse( - "podcasts:episodes", - kwargs={ - "podcast_id": self.pk, - "slug": self.slug, - }, - ) - - def get_similar_url(self) -> str: - """Podcast recommendations URL""" - return reverse( - "podcasts:similar", - kwargs={ - "podcast_id": self.pk, - "slug": self.slug, - }, - ) - - @cached_property - def cleaned_title(self) -> str: - """Strips HTML from title field.""" - return strip_html(self.title) - - @cached_property - def cleaned_description(self) -> str: - """Strips HTML from description field.""" - return strip_html(self.description) - - @cached_property - def cleaned_owner(self) -> str: - """Strips HTML from owner field.""" - return strip_html(self.owner) - - @cached_property - def slug(self) -> str: - """Returns slugified title.""" - return slugify(self.title) or "podcast" - - @cached_property - def has_similar_podcasts(self) -> bool: - """Returns True if any similar podcasts.""" - return self.private is False and self.recommendations.exists() - - @cached_property - def seasons(self) -> list[Season]: - """Returns list of seasons.""" - return [ - self.get_season(season) - for season in self.episodes.filter(season__isnull=False) - .values_list("season", flat=True) - .order_by("season") - .distinct() - ] - - def get_season(self, season: int) -> Season: - """Returns Season instance.""" - return Season(podcast=self, season=season) - - def get_next_scheduled_update(self) -> datetime: - """Returns estimated next update: - - 1. If parsed is NULL, should be ASAP. - 2. If pub date is NULL, add frequency to last parsed - 3. If pub date is not NULL, add frequency to pub date - 4. Scheduled time should always be in range of 1-24 hours. - - Note that this is a rough estimate: the precise update time depends - on the frequency of the parse feeds cron and the number of other - scheduled podcasts in the queue. - """ - - if self.parsed is None or self.frequency is None: - return timezone.now() - - return min( - self.parsed + self.MAX_PARSER_FREQUENCY, - max( - (self.pub_date or self.parsed) + self.frequency, - self.parsed + self.MIN_PARSER_FREQUENCY, - ), - ) - - def is_episodic(self) -> bool: - """Returns true if podcast is episodic.""" - return self.podcast_type == self.PodcastType.EPISODIC - - def is_serial(self) -> bool: - """Returns true if podcast is serial.""" - return self.podcast_type == self.PodcastType.SERIAL diff --git a/simplecasts/models/recommendations.py b/simplecasts/models/recommendations.py deleted file mode 100644 index 1fbda6e91..000000000 --- a/simplecasts/models/recommendations.py +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 69ca9abd2..000000000 --- a/simplecasts/models/search.py +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 8d8caa451..000000000 --- a/simplecasts/models/subscriptions.py +++ /dev/null @@ -1,31 +0,0 @@ -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/models/users.py b/simplecasts/models/users.py deleted file mode 100644 index df091bb02..000000000 --- a/simplecasts/models/users.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import TYPE_CHECKING - -from django.contrib.auth.models import AbstractUser -from django.db import models - -if TYPE_CHECKING: - 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): - """Custom User model.""" - - send_email_notifications = models.BooleanField(default=True) - - if TYPE_CHECKING: - audio_logs: AudioLogQuerySet - bookmarks: BookmarkQuerySet - recommended_podcasts: PodcastQuerySet - subscriptions: models.Manager[Subscription] - - @property - def name(self): - """Return the user's first name or username.""" - return self.first_name or self.username diff --git a/simplecasts/tests/models/__init__.py b/simplecasts/tests/models/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/simplecasts/tests/models/test_audio_logs.py b/simplecasts/tests/models/test_audio_logs.py deleted file mode 100644 index 940baca1e..000000000 --- a/simplecasts/tests/models/test_audio_logs.py +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 648007060..000000000 --- a/simplecasts/tests/models/test_bookmarks.py +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index b92d2b95b..000000000 --- a/simplecasts/tests/models/test_categories.py +++ /dev/null @@ -1,19 +0,0 @@ -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/tests/models/test_episodes.py b/simplecasts/tests/models/test_episodes.py deleted file mode 100644 index 793135c1a..000000000 --- a/simplecasts/tests/models/test_episodes.py +++ /dev/null @@ -1,139 +0,0 @@ -from datetime import timedelta - -import pytest - -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: - link = "https://example.com" - - @pytest.mark.django_db - def test_next_episode_if_none(self, episode): - assert episode.next_episode is None - - @pytest.mark.django_db - def test_previous_episode_if_none(self, episode): - assert episode.previous_episode is None - - @pytest.mark.django_db - def test_next_episode_not_same_podcast(self, episode): - EpisodeFactory( - pub_date=episode.pub_date + timedelta(days=2), - ) - - assert episode.next_episode is None - - @pytest.mark.django_db - def test_previous_episode_not_same_podcast(self, episode): - EpisodeFactory( - pub_date=episode.pub_date - timedelta(days=2), - ) - - assert episode.previous_episode is None - - @pytest.mark.django_db - def test_next_episode(self, episode): - next_episode = EpisodeFactory( - podcast=episode.podcast, - pub_date=episode.pub_date + timedelta(days=2), - ) - - assert episode.next_episode == next_episode - - @pytest.mark.django_db - def test_previous_episode(self, episode): - previous_episode = EpisodeFactory( - podcast=episode.podcast, - pub_date=episode.pub_date - timedelta(days=2), - ) - - assert episode.previous_episode == previous_episode - - def test_episode_explicit(self): - assert Episode(explicit=True).is_explicit() is True - - def test_podcast_explicit(self): - assert ( - Episode(explicit=False, podcast=Podcast(explicit=True)).is_explicit() - is True - ) - - def test_not_explicit(self): - assert ( - Episode(explicit=False, podcast=Podcast(explicit=False)).is_explicit() - is False - ) - - def test_slug(self): - episode = Episode(title="Testing") - assert episode.slug == "testing" - - def test_slug_if_title_empty(self): - assert Episode().slug == "episode" - - def test_str(self): - assert str(Episode(title="testing")) == "testing" - - def test_str_no_title(self): - episode = Episode(title="", guid="abc123") - assert str(episode) == episode.guid - - def test_cleaned_title(self): - episode = Episode(title="Test & Code") - assert episode.cleaned_title == "Test & Code" - - def test_cleaned_description(self): - episode = Episode(description="Test & Code") - assert episode.cleaned_description == "Test & Code" - - @pytest.mark.django_db - def test_get_cover_url_if_episode_cover(self, podcast): - episode = EpisodeFactory( - podcast=podcast, cover_url="https://example.com/episode-cover.jpg" - ) - assert episode.get_cover_url() == "https://example.com/episode-cover.jpg" - - @pytest.mark.django_db - def test_get_cover_url_if_podcast_cover(self, episode): - assert episode.get_cover_url() == "https://example.com/cover.jpg" - - @pytest.mark.django_db - def test_get_cover_url_if_none(self): - episode = EpisodeFactory(podcast=PodcastFactory(cover_url="")) - assert episode.get_cover_url() == "" - - @pytest.mark.parametrize( - ("duration", "expected"), - [ - pytest.param("2:30:40", 9040, id="hours"), - pytest.param("2:30:40:2903903", 9040, id="extra digit"), - pytest.param("30:40", 1840, id="minutes and seconds"), - pytest.param("40", 40, id="seconds"), - pytest.param("NaN", 0, id="non-numeric"), - pytest.param("", 0, id="empty"), - ], - ) - def test_duration_in_seconds(self, duration, expected): - assert Episode(duration=duration).duration_in_seconds == expected diff --git a/simplecasts/tests/models/test_podcasts.py b/simplecasts/tests/models/test_podcasts.py deleted file mode 100644 index ac99c72ae..000000000 --- a/simplecasts/tests/models/test_podcasts.py +++ /dev/null @@ -1,314 +0,0 @@ -import datetime - -import pytest -from django.utils import timezone - -from simplecasts.models import ( - Podcast, -) -from simplecasts.tests.factories import ( - EpisodeFactory, - PodcastFactory, - RecommendationFactory, - SubscriptionFactory, -) - - -class TestPodcastManager: - @pytest.mark.django_db - 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 - - @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_search_empty(self): - PodcastFactory(title="Learn Python") - PodcastFactory(title="Learn Django") - PodcastFactory(title="Cooking Tips") - - results = Podcast.objects.search("") - assert results.count() == 0 - - @pytest.mark.django_db - def test_subscribed_true(self, user): - SubscriptionFactory(subscriber=user) - assert Podcast.objects.subscribed(user).exists() is True - - @pytest.mark.django_db - def test_subscribed_false(self, user, podcast): - assert Podcast.objects.subscribed(user).exists() is False - - @pytest.mark.django_db - def test_published_true(self): - PodcastFactory(pub_date=timezone.now()) - assert Podcast.objects.published().exists() is True - - @pytest.mark.django_db - def test_published_false(self): - PodcastFactory(pub_date=None) - assert Podcast.objects.published().exists() is False - - @pytest.mark.parametrize( - ("kwargs", "exists"), - [ - pytest.param( - {}, - True, - id="parsed is None", - ), - pytest.param( - { - "parsed": datetime.timedelta(hours=3), - "frequency": datetime.timedelta(hours=1), - }, - True, - id="pub date is None, parsed more than now-frequency", - ), - pytest.param( - { - "parsed": datetime.timedelta(minutes=30), - "frequency": datetime.timedelta(hours=1), - }, - False, - id="pub date is None, parsed less than now-frequency", - ), - pytest.param( - { - "parsed": datetime.timedelta(seconds=1200), - "pub_date": datetime.timedelta(days=3), - "frequency": datetime.timedelta(hours=3), - }, - False, - id="pub date is None, just parsed", - ), - pytest.param( - { - "parsed": datetime.timedelta(hours=3), - "pub_date": datetime.timedelta(days=1), - "frequency": datetime.timedelta(hours=3), - }, - True, - id="parsed before pub date+frequency", - ), - pytest.param( - { - "parsed": datetime.timedelta(days=8), - "pub_date": datetime.timedelta(days=8, minutes=1), - "frequency": datetime.timedelta(days=12), - }, - True, - id="parsed just before max frequency", - ), - pytest.param( - { - "parsed": datetime.timedelta(days=30), - "pub_date": datetime.timedelta(days=90), - "frequency": datetime.timedelta(days=30), - }, - True, - id="parsed before max frequency", - ), - ], - ) - @pytest.mark.django_db - def test_scheduled(self, kwargs, exists): - now = timezone.now() - - parsed = kwargs.get("parsed", None) - pub_date = kwargs.get("pub_date", None) - - frequency = kwargs.get("frequency", Podcast.DEFAULT_PARSER_FREQUENCY) - - PodcastFactory( - frequency=frequency, - parsed=now - parsed if parsed else None, - pub_date=now - pub_date if pub_date else None, - ) - - assert Podcast.objects.scheduled().exists() is exists - - @pytest.mark.django_db - def test_recommended(self, user): - podcast = SubscriptionFactory(subscriber=user).podcast - RecommendationFactory.create_batch(3, podcast=podcast) - outlier = PodcastFactory() # not recommended - podcasts = Podcast.objects.recommended(user) - assert podcasts.count() == 3 - assert outlier not in podcasts - - @pytest.mark.django_db - def test_recommended_is_subscribed(self, user): - podcast = SubscriptionFactory(subscriber=user).podcast - RecommendationFactory(recommended=podcast) - assert Podcast.objects.recommended(user).count() == 0 - - @pytest.mark.django_db - def test_already_recommended(self, user): - podcast = SubscriptionFactory(subscriber=user).podcast - recommended = RecommendationFactory(podcast=podcast).recommended - user.recommended_podcasts.add(recommended) - assert Podcast.objects.recommended(user).count() == 0 - - @pytest.mark.django_db - def test_recommended_is_subscribed_or_recommended(self, user): - podcast = SubscriptionFactory(subscriber=user).podcast - RecommendationFactory(recommended=podcast) - recommended = RecommendationFactory(podcast=podcast).recommended - user.recommended_podcasts.add(recommended) - assert Podcast.objects.recommended(user).count() == 0 - - -class TestPodcastModel: - def test_str(self): - assert str(Podcast(title="title")) == "title" - - def test_str_title_empty(self): - rss = "https://example.com/rss.xml" - assert str(Podcast(title="", rss=rss)) == rss - - def test_slug(self): - assert Podcast(title="Testing").slug == "testing" - - def test_slug_if_title_empty(self): - assert Podcast().slug == "podcast" - - def test_cleaned_title(self): - podcast = Podcast(title="Test & Code") - assert podcast.cleaned_title == "Test & Code" - - def test_cleaned_description(self): - podcast = Podcast(description="Test & Code") - assert podcast.cleaned_description == "Test & Code" - - def test_cleaned_owner(self): - podcast = Podcast(owner="Test & Code") - assert podcast.cleaned_owner == "Test & Code" - - @pytest.mark.django_db - def test_has_similar_podcasts_private(self): - podcast = RecommendationFactory(podcast__private=True).podcast - assert podcast.has_similar_podcasts is False - - @pytest.mark.django_db - def test_has_similar_podcasts_true(self): - podcast = RecommendationFactory().podcast - assert podcast.has_similar_podcasts is True - - @pytest.mark.django_db - def test_has_similar_podcasts_false(self): - podcast = PodcastFactory() - assert podcast.has_similar_podcasts is False - - @pytest.mark.django_db - def test_seasons(self, podcast): - EpisodeFactory.create_batch(3, podcast=podcast, season=-1) - EpisodeFactory.create_batch(3, podcast=podcast, season=2) - EpisodeFactory.create_batch(3, podcast=podcast, season=1) - EpisodeFactory.create_batch(1, podcast=podcast, season=None) - assert len(podcast.seasons) == 3 - assert podcast.seasons[0].season == -1 - assert podcast.seasons[1].season == 1 - assert podcast.seasons[2].season == 2 - assert podcast.seasons[0].url - assert podcast.seasons[1].url - assert podcast.seasons[2].url - - def test_get_next_scheduled_update_pub_date_none(self): - now = timezone.now() - podcast = Podcast( - parsed=now - datetime.timedelta(hours=1), - pub_date=None, - frequency=datetime.timedelta(hours=3), - ) - self.assert_hours_diff(podcast.get_next_scheduled_update() - now, 2) - - def test_get_next_scheduled_update_frequency_none(self): - now = timezone.now() - podcast = Podcast( - parsed=now - datetime.timedelta(hours=1), pub_date=None, frequency=None - ) - assert (podcast.get_next_scheduled_update() - now).total_seconds() < 10 - - def test_get_next_scheduled_update_parsed_none(self): - now = timezone.now() - podcast = Podcast( - pub_date=now - datetime.timedelta(hours=3), - parsed=None, - frequency=datetime.timedelta(hours=3), - ) - assert (podcast.get_next_scheduled_update() - now).total_seconds() < 10 - - def test_get_next_scheduled_update_parsed_gt_max(self): - now = timezone.now() - podcast = Podcast( - pub_date=now, - parsed=now, - frequency=datetime.timedelta(days=30), - ) - self.assert_hours_diff(podcast.get_next_scheduled_update() - now, 72) - - def test_get_next_scheduled_update_parsed_lt_now(self): - now = timezone.now() - podcast = Podcast( - pub_date=now - datetime.timedelta(days=5), - parsed=now - datetime.timedelta(days=16), - frequency=datetime.timedelta(days=30), - ) - assert (podcast.get_next_scheduled_update() - now).total_seconds() < 10 - - def test_get_next_scheduled_update_pub_date_lt_now(self): - now = timezone.now() - podcast = Podcast( - pub_date=now - datetime.timedelta(days=33), - parsed=now - datetime.timedelta(days=3), - frequency=datetime.timedelta(days=30), - ) - assert (podcast.get_next_scheduled_update() - now).total_seconds() < 10 - - def test_get_next_scheduled_update_pub_date_in_future(self): - now = timezone.now() - podcast = Podcast( - pub_date=now - datetime.timedelta(days=1), - parsed=now - datetime.timedelta(hours=1), - frequency=datetime.timedelta(days=7), - ) - self.assert_hours_diff(podcast.get_next_scheduled_update() - now, 71) - - def test_get_next_scheduled_update_pub_date_lt_min(self): - now = timezone.now() - podcast = Podcast( - pub_date=now - datetime.timedelta(hours=3), - parsed=now - datetime.timedelta(minutes=30), - frequency=datetime.timedelta(hours=3), - ) - - self.assert_hours_diff(podcast.get_next_scheduled_update() - now, 0.5) - - def test_is_episodic(self): - podcast = Podcast(podcast_type=Podcast.PodcastType.EPISODIC) - assert podcast.is_episodic() is True - assert podcast.is_serial() is False - - def test_is_serial(self): - podcast = Podcast(podcast_type=Podcast.PodcastType.SERIAL) - assert podcast.is_episodic() is False - assert podcast.is_serial() is True - - def assert_hours_diff(self, delta, hours): - assert delta.total_seconds() / 3600 == pytest.approx(hours) diff --git a/simplecasts/tests/models/test_recommendations.py b/simplecasts/tests/models/test_recommendations.py deleted file mode 100644 index 1ee01ca20..000000000 --- a/simplecasts/tests/models/test_recommendations.py +++ /dev/null @@ -1,16 +0,0 @@ -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/tests/models/test_users.py b/simplecasts/tests/models/test_users.py deleted file mode 100644 index 1b609d5c1..000000000 --- a/simplecasts/tests/models/test_users.py +++ /dev/null @@ -1,13 +0,0 @@ -from simplecasts.models import User - - -class TestUserModel: - def test_name_has_first_and_last_name(self): - """Test name property with first and last name.""" - user = User(first_name="John", username="johndoe") - assert user.name == "John" - - def test_name_has_only_username(self): - """Test name property with only username.""" - user = User(username="johndoe") - assert user.name == "johndoe" From c8be4f0077b12841b43b9d66177f4bd5efc165b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:31:12 +0000 Subject: [PATCH 4/6] Move fields and search to db package, remove __all__ from models Co-authored-by: danjac <249779+danjac@users.noreply.github.com> --- simplecasts/db/__init__.py | 7 +++ simplecasts/db/fields.py | 12 +++++ simplecasts/db/search.py | 60 +++++++++++++++++++++ simplecasts/models/__init__.py | 95 ++-------------------------------- 4 files changed, 82 insertions(+), 92 deletions(-) create mode 100644 simplecasts/db/__init__.py create mode 100644 simplecasts/db/fields.py create mode 100644 simplecasts/db/search.py diff --git a/simplecasts/db/__init__.py b/simplecasts/db/__init__.py new file mode 100644 index 000000000..1c8bfcc32 --- /dev/null +++ b/simplecasts/db/__init__.py @@ -0,0 +1,7 @@ +from simplecasts.db.fields import URLField +from simplecasts.db.search import SearchQuerySetMixin + +__all__ = [ + "SearchQuerySetMixin", + "URLField", +] diff --git a/simplecasts/db/fields.py b/simplecasts/db/fields.py new file mode 100644 index 000000000..44d43ec28 --- /dev/null +++ b/simplecasts/db/fields.py @@ -0,0 +1,12 @@ +import functools + +from django.db import models + +from simplecasts.validators import url_validator + +# URLField with sensible defaults +URLField = functools.partial( + models.URLField, + max_length=2083, + validators=[url_validator], +) diff --git a/simplecasts/db/search.py b/simplecasts/db/search.py new file mode 100644 index 000000000..69ca9abd2 --- /dev/null +++ b/simplecasts/db/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/__init__.py b/simplecasts/models/__init__.py index d3356d3e0..87d1f6a24 100644 --- a/simplecasts/models/__init__.py +++ b/simplecasts/models/__init__.py @@ -1,16 +1,13 @@ import dataclasses -import functools -import operator from datetime import datetime, timedelta -from typing import TYPE_CHECKING, ClassVar, Final, Optional, Self +from typing import TYPE_CHECKING, ClassVar, Final, Optional from django.conf import settings from django.contrib.auth.models import AbstractUser from django.contrib.postgres.indexes import GinIndex -from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVectorField +from django.contrib.postgres.search import SearchVectorField from django.core.validators import MinLengthValidator from django.db import models -from django.db.models import F, Q, QuerySet from django.db.models.fields.tuple_lookups import ( # type: ignore[reportMissingTypeStubs] TupleGreaterThan, TupleLessThan, @@ -21,74 +18,8 @@ from django.utils.functional import cached_property from slugify import slugify +from simplecasts.db import SearchQuerySetMixin, URLField from simplecasts.services.sanitizer import strip_html -from simplecasts.validators import url_validator - -if TYPE_CHECKING: - BaseQuerySet = QuerySet -else: - BaseQuerySet = object - - -# Fields - -# URLField with sensible defaults -URLField = functools.partial( - models.URLField, - max_length=2083, - validators=[url_validator], -) - - -# Search - -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) # User @@ -875,23 +806,3 @@ def percent_complete(self) -> int: return 0 return min(100, round((self.current_time / self.duration) * 100)) - - -__all__ = [ - "AudioLog", - "AudioLogQuerySet", - "Bookmark", - "BookmarkQuerySet", - "Category", - "Episode", - "EpisodeQuerySet", - "Podcast", - "PodcastQuerySet", - "Recommendation", - "RecommendationQuerySet", - "Season", - "SearchQuerySetMixin", - "Subscription", - "URLField", - "User", -] From 393b89d5b165ba5f5c3a7ad2de432bc40a51ef73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:36:14 +0000 Subject: [PATCH 5/6] Refactor: rename test file, move models to single file, move validators to db Co-authored-by: danjac <249779+danjac@users.noreply.github.com> --- simplecasts/db/__init__.py | 2 ++ simplecasts/db/fields.py | 2 +- simplecasts/{ => db}/validators.py | 0 simplecasts/{models/__init__.py => models.py} | 0 simplecasts/services/feed_parser/schemas/validators.py | 2 +- simplecasts/tests/{models.py => test_models.py} | 0 6 files changed, 4 insertions(+), 2 deletions(-) rename simplecasts/{ => db}/validators.py (100%) rename simplecasts/{models/__init__.py => models.py} (100%) rename simplecasts/tests/{models.py => test_models.py} (100%) diff --git a/simplecasts/db/__init__.py b/simplecasts/db/__init__.py index 1c8bfcc32..95727ec34 100644 --- a/simplecasts/db/__init__.py +++ b/simplecasts/db/__init__.py @@ -1,7 +1,9 @@ from simplecasts.db.fields import URLField from simplecasts.db.search import SearchQuerySetMixin +from simplecasts.db.validators import url_validator __all__ = [ "SearchQuerySetMixin", "URLField", + "url_validator", ] diff --git a/simplecasts/db/fields.py b/simplecasts/db/fields.py index 44d43ec28..3fe406ad8 100644 --- a/simplecasts/db/fields.py +++ b/simplecasts/db/fields.py @@ -2,7 +2,7 @@ from django.db import models -from simplecasts.validators import url_validator +from simplecasts.db.validators import url_validator # URLField with sensible defaults URLField = functools.partial( diff --git a/simplecasts/validators.py b/simplecasts/db/validators.py similarity index 100% rename from simplecasts/validators.py rename to simplecasts/db/validators.py diff --git a/simplecasts/models/__init__.py b/simplecasts/models.py similarity index 100% rename from simplecasts/models/__init__.py rename to simplecasts/models.py diff --git a/simplecasts/services/feed_parser/schemas/validators.py b/simplecasts/services/feed_parser/schemas/validators.py index 29412a122..c014bbeeb 100644 --- a/simplecasts/services/feed_parser/schemas/validators.py +++ b/simplecasts/services/feed_parser/schemas/validators.py @@ -5,7 +5,7 @@ from django.core.exceptions import ValidationError from django.db.models import TextChoices -from simplecasts.validators import url_validator +from simplecasts.db.validators import url_validator T = TypeVar("T") diff --git a/simplecasts/tests/models.py b/simplecasts/tests/test_models.py similarity index 100% rename from simplecasts/tests/models.py rename to simplecasts/tests/test_models.py From 998ab4bb3ba826b364b6cdd37b866f47483dfb72 Mon Sep 17 00:00:00 2001 From: Dan Jacob Date: Wed, 7 Jan 2026 15:42:45 +0200 Subject: [PATCH 6/6] fix: test fixes --- simplecasts/db/__init__.py | 9 --------- simplecasts/models.py | 14 +++++++++++--- simplecasts/tests/test_models.py | 1 - 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/simplecasts/db/__init__.py b/simplecasts/db/__init__.py index 95727ec34..e69de29bb 100644 --- a/simplecasts/db/__init__.py +++ b/simplecasts/db/__init__.py @@ -1,9 +0,0 @@ -from simplecasts.db.fields import URLField -from simplecasts.db.search import SearchQuerySetMixin -from simplecasts.db.validators import url_validator - -__all__ = [ - "SearchQuerySetMixin", - "URLField", - "url_validator", -] diff --git a/simplecasts/models.py b/simplecasts/models.py index 87d1f6a24..f45e4a2f4 100644 --- a/simplecasts/models.py +++ b/simplecasts/models.py @@ -1,6 +1,6 @@ import dataclasses from datetime import datetime, timedelta -from typing import TYPE_CHECKING, ClassVar, Final, Optional +from typing import TYPE_CHECKING, ClassVar, Final, Optional, Self from django.conf import settings from django.contrib.auth.models import AbstractUser @@ -18,12 +18,13 @@ from django.utils.functional import cached_property from slugify import slugify -from simplecasts.db import SearchQuerySetMixin, URLField +from simplecasts.db.fields import URLField +from simplecasts.db.search import SearchQuerySetMixin from simplecasts.services.sanitizer import strip_html - # User + class User(AbstractUser): """Custom User model.""" @@ -43,6 +44,7 @@ def name(self): # Category + class Category(models.Model): """iTunes category.""" @@ -73,6 +75,7 @@ def get_absolute_url(self) -> str: # Recommendation + class RecommendationQuerySet(models.QuerySet): """Custom QuerySet for Recommendation model.""" @@ -123,6 +126,7 @@ class Meta: # Podcast + @dataclasses.dataclass(kw_only=True, frozen=True) class Season: """Encapsulates podcast season""" @@ -492,6 +496,7 @@ def is_serial(self) -> bool: # Episode + class EpisodeQuerySet(SearchQuerySetMixin, models.QuerySet): """Custom queryset for Episode model.""" @@ -668,6 +673,7 @@ def _get_other_episodes_in_podcast(self) -> models.QuerySet["Episode"]: # Subscription + class Subscription(models.Model): """Subscribed podcast belonging to a user's collection.""" @@ -697,6 +703,7 @@ class Meta: # Bookmark + class BookmarkQuerySet(SearchQuerySetMixin, models.QuerySet): """Custom queryset for Bookmark model.""" @@ -749,6 +756,7 @@ class Meta: # AudioLog + class AudioLogQuerySet(SearchQuerySetMixin, models.QuerySet): """Custom queryset for Bookmark model.""" diff --git a/simplecasts/tests/test_models.py b/simplecasts/tests/test_models.py index 7ae9f8c24..39b7b5009 100644 --- a/simplecasts/tests/test_models.py +++ b/simplecasts/tests/test_models.py @@ -23,7 +23,6 @@ SubscriptionFactory, ) - # User tests