From 39498fb138763da070c41605b2d55902207a12d5 Mon Sep 17 00:00:00 2001 From: Dan Jacob Date: Sun, 4 Jan 2026 15:37:32 +0200 Subject: [PATCH 01/43] refactor: merge into single app --- config/settings.py | 10 +- config/urls.py | 19 +- conftest.py | 3 - pyproject.toml | 2 +- simplecasts/{podcasts => }/admin.py | 101 ++- simplecasts/{podcasts => }/apps.py | 4 +- simplecasts/episodes/admin.py | 82 -- simplecasts/episodes/apps.py | 6 - simplecasts/episodes/middleware.py | 40 - .../episodes/migrations/0001_initial.py | 239 ------ .../0002_add_episode_search_trigger.py | 21 - ...episode_cover_url_alter_episode_website.py | 24 - ...eated_remove_audiolog_modified_and_more.py | 29 - .../0005_alter_episode_episode_type.py | 25 - ..._episode_episodes_ep_podcast_965d74_idx.py | 19 - .../0007_rename_length_episode_file_size.py | 17 - .../0008_alter_episode_file_size.py | 19 - ...episodes_ep_pub_dat_9b17cd_idx_and_more.py | 43 -- ...episodes_ep_episode_c8cf94_idx_and_more.py | 28 - ..._episode_episodes_ep_podcast_3361d9_idx.py | 16 - ...er_url_alter_episode_media_url_and_more.py | 39 - ...er_url_alter_episode_media_url_and_more.py | 45 -- ...episodes_ep_podcast_a7abe0_idx_and_more.py | 43 -- ...episodes_ep_pub_dat_60d1c1_idx_and_more.py | 41 - ...episodes_ep_podcast_12cd3c_idx_and_more.py | 44 -- ..._episode_episodes_ep_podcast_c43bb8_idx.py | 26 - .../migrations/0018_audiolog_duration.py | 17 - .../0019_set_default_audio_log_duration.py | 48 -- .../0020_remove_episode_keywords.py | 38 - .../migrations/0021_episode_keywords.py | 17 - .../migrations/0022_update_search_trigger.py | 30 - ...date_episode_search_trigger_with_simple.py | 30 - ..._episode_episodes_ep_pub_dat_4abe4c_idx.py | 31 - ...episodes_ep_pub_dat_34887e_idx_and_more.py | 33 - ...episodes_ep_podcast_965d74_idx_and_more.py | 33 - ...episodes_au_listene_7f0fdd_idx_and_more.py | 35 - ...episodes_bo_created_d69e08_idx_and_more.py | 31 - ...episodes_au_user_id_fb8578_idx_and_more.py | 34 - ...episodes_bo_user_id_21f9c3_idx_and_more.py | 34 - ...og_current_time_alter_audiolog_duration.py | 22 - .../episodes/migrations/max_migration.txt | 1 - simplecasts/episodes/tests/factories.py | 40 - simplecasts/episodes/tests/fixtures.py | 29 - simplecasts/episodes/tests/test_admin.py | 61 -- simplecasts/episodes/tests/test_commands.py | 80 -- simplecasts/episodes/tests/test_middleware.py | 47 -- .../episodes/tests/test_templatetags.py | 97 --- simplecasts/episodes/tests/test_views.py | 578 -------------- simplecasts/episodes/urls.py | 53 -- simplecasts/episodes/views.py | 396 ---------- simplecasts/{users => }/forms.py | 14 +- simplecasts/{episodes => http}/__init__.py | 0 simplecasts/{http.py => http/decorators.py} | 0 simplecasts/{ => http}/request.py | 5 +- simplecasts/{ => http}/response.py | 0 .../{episodes => }/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../create_podcast_recommendations.py | 6 +- .../management/commands/fetch_itunes_feeds.py | 8 +- .../commands/parse_podcast_feeds.py | 8 +- .../commands/send_episode_notifications.py | 9 +- .../commands/send_podcast_recommendations.py | 8 +- simplecasts/middleware.py | 36 +- simplecasts/migrations/0001_initial.py | 706 ++++++++++++++++++ .../migrations/0002_create_search_triggers.py | 45 ++ .../0003_alter_episode_search_vector.py | 20 + ...er_podcast_owner_search_vector_and_more.py | 27 + .../{episodes => }/migrations/__init__.py | 0 simplecasts/migrations/max_migration.txt | 1 + simplecasts/models/__init__.py | 20 + simplecasts/models/audio_logs.py | 53 ++ simplecasts/models/bookmarks.py | 43 ++ simplecasts/models/categories.py | 36 + .../models.py => models/episodes.py} | 100 +-- simplecasts/{ => models}/fields.py | 0 .../models.py => models/podcasts.py} | 121 +-- simplecasts/models/recommendations.py | 51 ++ simplecasts/models/subscriptions.py | 31 + .../{users/models.py => models/users.py} | 6 +- simplecasts/podcasts/forms.py | 17 - .../podcasts/migrations/0001_initial.py | 314 -------- .../0002_add_podcast_search_trigger.py | 21 - .../podcasts/migrations/0003_categories.py | 132 ---- .../0004_alter_podcast_frequency.py | 17 - ...t_hash_alter_podcast_cover_url_and_more.py | 54 -- ...6_remove_subscription_modified_and_more.py | 24 - .../0007_alter_podcast_frequency.py | 19 - .../migrations/0008_recommendation_score.py | 24 - ...podcasts_re_similar_3e4170_idx_and_more.py | 20 - .../migrations/0010_podcast_itunes_ranking.py | 20 - ..._podcast_podcasts_po_itunes__8b4558_idx.py | 20 - ...podcasts_po_itunes__8b4558_idx_and_more.py | 20 - .../migrations/0013_podcast_podcast_type.py | 21 - .../migrations/0014_podcast_rating.py | 17 - ...5_podcast_podcasts_po_rating_f96c31_idx.py | 18 - ...podcasts_po_promote_fdc955_idx_and_more.py | 20 - .../0017_podcast_promoted_and_more.py | 25 - .../migrations/0018_set_default_promoted.py | 21 - ..._podcasts_po_rating_f96c31_idx_and_more.py | 20 - .../migrations/0020_podcast_canonical.py | 24 - .../migrations/0021_clear_duplicates.py | 25 - .../0022_alter_podcast_parser_error.py | 29 - ...ve_inaccessible_podcasts_to_unavailable.py | 24 - .../0024_alter_podcast_parser_error.py | 28 - .../migrations/0025_podcast_complete.py | 20 - .../migrations/0026_mark_podcasts_complete.py | 32 - ...7_podcast_podcasts_po_active_a4c988_idx.py | 18 - .../0028_remove_podcast_complete.py | 16 - ...podcasts_re_podcast_10c46d_idx_and_more.py | 20 - ..._url_alter_podcast_funding_url_and_more.py | 52 -- ..._url_alter_podcast_funding_url_and_more.py | 57 -- .../migrations/0032_podcast_queued.py | 17 - .../migrations/0033_remove_podcast_queued.py | 16 - .../migrations/0034_alter_podcast_updated.py | 17 - ...n_podcasts_re_score_c89df8_idx_and_more.py | 20 - ...emove_recommendation_frequency_and_more.py | 34 - .../migrations/0037_podcast_itunes_ranking.py | 17 - .../0038_alter_podcast_itunes_ranking.py | 17 - ..._podcast_podcasts_po_itunes__d69e24_idx.py | 21 - ...podcasts_po_promote_fdc955_idx_and_more.py | 20 - ...cast_parser_error_podcast_parser_result.py | 33 - ..._podcast_podcasts_po_parser__9f31ab_idx.py | 20 - .../migrations/0043_podcast_promoted.py | 17 - .../migrations/0044_promote_itunes_feeds.py | 16 - ...podcasts_po_itunes__d69e24_idx_and_more.py | 28 - ...46_category_podcasts_ca_name_604e91_idx.py | 16 - .../migrations/0047_remove_category_parent.py | 16 - .../migrations/0048_podcast_num_episodes.py | 17 - .../0049_set_podcast_num_episodes.py | 37 - .../0050_podcast_has_similar_podcasts.py | 17 - .../podcasts/migrations/0051_podcast_score.py | 17 - ...podcasts_po_promote_fdc955_idx_and_more.py | 24 - ...t_podcasts_po_score_aeb891_idx_and_more.py | 28 - .../0054_category_itunes_genre_id.py | 17 - .../migrations/0055_itunes_genre_ids.py | 144 ---- .../podcasts/migrations/0056_category_slug.py | 17 - .../migrations/0057_set_category_slugs.py | 28 - .../migrations/0058_alter_category_slug.py | 17 - .../0059_remove_podcast_keywords.py | 39 - .../migrations/0060_podcast_keywords.py | 17 - .../0061_update_podcast_search_trigger.py | 35 - ..._podcast_podcasts_podcast_lwr_title_idx.py | 16 - ...date_podcast_search_trigger_with_simple.py | 35 - ...podcasts_po_promote_fdc955_idx_and_more.py | 32 - ...podcasts_po_pub_dat_2e433a_idx_and_more.py | 45 -- ...0066_podcast_podcasts_podcast_owner_idx.py | 22 - ...ast_podcasts_podcast_owner_idx_and_more.py | 33 - .../0068_create_owner_search_trigger.py | 22 - .../0069_category_search_vector_and_more.py | 27 - ...070_create_category_name_search_trigger.py | 22 - .../migrations/0071_podcast_queued.py | 17 - ...podcasts_po_promote_54fdb6_idx_and_more.py | 41 - .../0073_set_default_promoted_at.py | 24 - ...podcasts_po_promote_a84cd9_idx_and_more.py | 29 - ..._podcasts_po_active_aed488_idx_and_more.py | 41 - .../migrations/0076_remove_podcast_queued.py | 16 - .../migrations/0077_podcast_promoted_at.py | 17 - .../0078_set_default_is_promoted.py | 18 - ...podcasts_po_promote_047f56_idx_and_more.py | 40 - ...podcasts_po_promote_ab9069_idx_and_more.py | 41 - ...ry_podcasts_ca_name_604e91_idx_and_more.py | 24 - .../0082_remove_category_search_trigger.py | 22 - ...083_remove_podcast_has_similar_podcasts.py | 16 - .../0084_remove_podcast_num_retries.py | 16 - .../0085_alter_podcast_parser_result.py | 32 - .../migrations/0086_update_parser_results.py | 37 - .../0087_alter_podcast_parser_result.py | 30 - ..._podcast_podcasts_po_content_736948_idx.py | 16 - .../0089_remove_podcast_parser_result.py | 16 - .../migrations/0090_podcast_http_status.py | 84 --- .../migrations/0091_podcast_feed_status.py | 30 - .../migrations/0092_default_feed_status.py | 57 -- .../0093_alter_podcast_feed_status.py | 32 - .../migrations/0094_update_feed_status.py | 35 - .../0095_alter_podcast_feed_status.py | 31 - .../migrations/0096_update_feed_status.py | 26 - .../0097_alter_podcast_feed_status.py | 29 - ...098_remove_podcast_feed_status_and_more.py | 20 - .../0099_podcast_feed_last_updated.py | 17 - .../migrations/0100_podcast_exception.py | 17 - ...dcast_traceback_alter_podcast_exception.py | 22 - .../0102_remove_podcast_traceback.py | 16 - .../0103_alter_podcast_exception.py | 17 - .../migrations/0104_podcast_feed_status.py | 27 - .../migrations/0105_update_feed_status.py | 51 -- ...t_num_retries_alter_podcast_feed_status.py | 34 - .../0107_alter_podcast_feed_status.py | 29 - .../migrations/0108_update_feed_status.py | 21 - .../0109_alter_podcast_feed_status.py | 29 - .../0110_remove_podcast_feed_last_updated.py | 16 - simplecasts/podcasts/migrations/__init__.py | 0 .../podcasts/migrations/max_migration.txt | 1 - simplecasts/podcasts/parsers/__init__.py | 0 .../podcasts/parsers/tests/__init__.py | 0 simplecasts/podcasts/tests/__init__.py | 0 simplecasts/podcasts/tests/factories.py | 50 -- simplecasts/podcasts/tests/fixtures.py | 17 - simplecasts/podcasts/tests/test_commands.py | 117 --- simplecasts/podcasts/tests/test_views.py | 706 ------------------ simplecasts/podcasts/urls.py | 86 --- simplecasts/podcasts/views.py | 422 ----------- .../templatetags => services}/__init__.py | 0 simplecasts/{ => services}/covers.py | 4 +- .../feed_parser/__init__.py} | 11 +- .../feed_parser}/date_parser.py | 0 .../feed_parser}/exceptions.py | 0 .../feed_parser}/rss_fetcher.py | 8 +- .../feed_parser}/rss_parser.py | 6 +- .../feed_parser}/scheduler.py | 4 +- .../feed_parser/schemas/__init__.py} | 11 +- .../feed_parser/schemas}/fields.py | 5 +- .../feed_parser/schemas}/validators.py | 0 simplecasts/{ => services}/http_client.py | 0 simplecasts/{podcasts => services}/itunes.py | 4 +- .../{users => services}/notifications.py | 2 +- .../parsers => services}/opml_parser.py | 2 +- simplecasts/{ => services}/pwa.py | 0 .../{podcasts => services}/recommender.py | 2 +- simplecasts/{ => services}/sanitizer.py | 0 simplecasts/{ => services}/search.py | 0 simplecasts/{ => services}/thread_pool.py | 0 .../{podcasts => services}/tokenizer.py | 2 +- .../parsers => services}/xpath_parser.py | 0 .../__init__.py} | 18 +- .../audio_player.py} | 26 +- simplecasts/tests/factories.py | 106 +++ simplecasts/tests/fixtures.py | 72 +- .../parsers => }/tests/mocks/feeds.opml | 0 .../tests/mocks/feeds_with_invalid.opml | 0 .../tests/mocks/itunes_chart.html | 0 .../tests => tests/models}/__init__.py | 0 simplecasts/tests/models/test_audio_logs.py | 25 + simplecasts/tests/models/test_categories.py | 19 + .../models/test_episodes.py} | 30 +- .../models/test_podcasts.py} | 30 +- .../tests/models/test_recommendations.py | 16 + .../models/test_users.py} | 2 +- .../{podcasts => tests/services}/__init__.py | 0 .../services/feed_parser}/__init__.py | 0 .../services/feed_parser}/factories.py | 0 .../services/feed_parser}/mocks/feeds.opml | 0 .../feed_parser}/mocks/rss_bad_cover_urls.xml | 0 .../feed_parser}/mocks/rss_bad_pub_date.xml | 0 .../feed_parser}/mocks/rss_bad_sig.xml | 0 .../feed_parser}/mocks/rss_bad_urls.xml | 0 .../feed_parser}/mocks/rss_empty_mock.xml | 0 .../mocks/rss_high_num_episodes.xml | 0 .../feed_parser}/mocks/rss_invalid_data.xml | 0 .../mocks/rss_invalid_duration.xml | 0 .../mocks/rss_missing_enc_length.xml | 0 .../services/feed_parser}/mocks/rss_mock.xml | 0 .../feed_parser}/mocks/rss_mock_complete.xml | 0 .../mocks/rss_mock_iso_8859-1.xml | 0 .../feed_parser}/mocks/rss_mock_large.xml | 0 .../feed_parser}/mocks/rss_mock_modified.xml | 0 .../mocks/rss_mock_no_build_date.xml | 0 .../feed_parser}/mocks/rss_mock_small.xml | 0 .../feed_parser}/mocks/rss_new_feed_url.xml | 0 .../mocks/rss_no_podcasts_mock.xml | 0 .../feed_parser}/mocks/rss_serial.xml | 0 .../feed_parser}/mocks/rss_superfeedr.xml | 0 .../feed_parser}/mocks/rss_use_link_ids.xml | 0 .../services/feed_parser}/test_date_parser.py | 2 +- .../services/feed_parser}/test_feed_parser.py | 16 +- .../services/feed_parser}/test_opml_parser.py | 2 +- .../services/feed_parser}/test_rss_fetcher.py | 8 +- .../services/feed_parser}/test_rss_parser.py | 4 +- .../services/feed_parser}/test_scheduler.py | 6 +- .../services/feed_parser/test_schemas.py} | 6 +- .../services/feed_parser}/test_validators.py | 2 +- .../tests/{ => services}/test_covers.py | 8 +- .../tests => tests/services}/test_itunes.py | 10 +- .../services}/test_notifications.py | 4 +- .../services}/test_recommender.py | 10 +- .../tests/{ => services}/test_sanitizier.py | 2 +- .../tests/{ => services}/test_search.py | 8 +- .../services}/test_tokenizer.py | 4 +- .../{podcasts => }/tests/test_admin.py | 129 +++- simplecasts/tests/test_commands.py | 214 ++++++ simplecasts/tests/test_middleware.py | 46 ++ simplecasts/tests/test_templatetags.py | 102 ++- .../commands => tests/views}/__init__.py | 0 simplecasts/tests/views/test_bookmarks.py | 96 +++ simplecasts/tests/views/test_categories.py | 87 +++ simplecasts/tests/views/test_episodes.py | 122 +++ simplecasts/tests/views/test_history.py | 150 ++++ .../tests/{ => views}/test_paginator.py | 2 +- .../tests/{ => views}/test_partials.py | 2 +- simplecasts/tests/views/test_player.py | 222 ++++++ simplecasts/tests/views/test_podcasts.py | 240 ++++++ simplecasts/tests/views/test_private_feeds.py | 139 ++++ simplecasts/tests/views/test_search.py | 145 ++++ simplecasts/tests/views/test_subscriptions.py | 175 +++++ .../views/test_users.py} | 27 +- simplecasts/tests/{ => views}/test_views.py | 25 +- simplecasts/urls/__init__.py | 29 + simplecasts/urls/bookmarks.py | 19 + simplecasts/urls/categories.py | 10 + simplecasts/urls/episodes.py | 15 + simplecasts/urls/history.py | 19 + simplecasts/urls/player.py | 24 + simplecasts/urls/podcasts.py | 47 ++ simplecasts/urls/private_feeds.py | 23 + simplecasts/urls/search.py | 12 + simplecasts/urls/subscriptions.py | 19 + simplecasts/urls/users.py | 22 + simplecasts/users/__init__.py | 0 simplecasts/users/admin.py | 19 - simplecasts/users/apps.py | 6 - simplecasts/users/migrations/0001_initial.py | 128 ---- .../migrations/0002_alter_user_managers.py | 19 - simplecasts/users/migrations/__init__.py | 0 .../users/migrations/max_migration.txt | 1 - simplecasts/users/tests/__init__.py | 0 simplecasts/users/tests/factories.py | 22 - simplecasts/users/tests/fixtures.py | 29 - simplecasts/users/urls.py | 22 - simplecasts/{views.py => views/__init__.py} | 8 +- simplecasts/views/bookmarks.py | 90 +++ simplecasts/views/categories.py | 65 ++ simplecasts/views/episodes.py | 73 ++ simplecasts/views/history.py | 100 +++ simplecasts/{ => views}/paginator.py | 4 +- simplecasts/{ => views}/partials.py | 2 +- simplecasts/views/player.py | 135 ++++ simplecasts/views/podcasts.py | 155 ++++ simplecasts/views/private_feeds.py | 80 ++ simplecasts/views/search.py | 104 +++ simplecasts/views/subscriptions.py | 79 ++ .../{users/views.py => views/users.py} | 32 +- templates/account/podcast_feeds.html | 2 +- templates/audio_player.html | 4 +- .../bookmarks.html => bookmarks/index.html} | 4 +- templates/card.html | 20 - templates/cards.html | 42 ++ .../category_detail.html | 4 +- .../categories.html => categories/index.html} | 0 templates/default_base.html | 3 +- .../episode_notifications.html} | 0 .../podcast_recommendations.html} | 0 templates/episodes/detail.html | 15 +- templates/episodes/episode.html | 5 - templates/episodes/index.html | 4 +- .../history.html => history/index.html} | 4 +- templates/navbar.html | 2 +- templates/podcasts/cards.html | 17 - templates/podcasts/detail.html | 10 +- templates/podcasts/discover.html | 4 +- templates/podcasts/episodes.html | 4 +- templates/podcasts/season.html | 2 +- templates/podcasts/similar.html | 2 +- .../index.html} | 6 +- .../private_feed_form.html | 2 +- .../search_episodes.html} | 4 +- .../{podcasts => search}/search_itunes.html | 4 +- .../{podcasts => search}/search_people.html | 4 +- .../{podcasts => search}/search_podcasts.html | 4 +- templates/sidebar.html | 10 +- .../index.html} | 4 +- 359 files changed, 4693 insertions(+), 8233 deletions(-) rename simplecasts/{podcasts => }/admin.py (78%) rename simplecasts/{podcasts => }/apps.py (64%) delete mode 100644 simplecasts/episodes/admin.py delete mode 100644 simplecasts/episodes/apps.py delete mode 100644 simplecasts/episodes/middleware.py delete mode 100644 simplecasts/episodes/migrations/0001_initial.py delete mode 100644 simplecasts/episodes/migrations/0002_add_episode_search_trigger.py delete mode 100644 simplecasts/episodes/migrations/0003_alter_episode_cover_url_alter_episode_website.py delete mode 100644 simplecasts/episodes/migrations/0004_remove_audiolog_created_remove_audiolog_modified_and_more.py delete mode 100644 simplecasts/episodes/migrations/0005_alter_episode_episode_type.py delete mode 100644 simplecasts/episodes/migrations/0006_episode_episodes_ep_podcast_965d74_idx.py delete mode 100644 simplecasts/episodes/migrations/0007_rename_length_episode_file_size.py delete mode 100644 simplecasts/episodes/migrations/0008_alter_episode_file_size.py delete mode 100644 simplecasts/episodes/migrations/0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0011_remove_episode_episodes_ep_podcast_3361d9_idx.py delete mode 100644 simplecasts/episodes/migrations/0012_alter_episode_cover_url_alter_episode_media_url_and_more.py delete mode 100644 simplecasts/episodes/migrations/0013_alter_episode_cover_url_alter_episode_media_url_and_more.py delete mode 100644 simplecasts/episodes/migrations/0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0017_episode_episodes_ep_podcast_c43bb8_idx.py delete mode 100644 simplecasts/episodes/migrations/0018_audiolog_duration.py delete mode 100644 simplecasts/episodes/migrations/0019_set_default_audio_log_duration.py delete mode 100644 simplecasts/episodes/migrations/0020_remove_episode_keywords.py delete mode 100644 simplecasts/episodes/migrations/0021_episode_keywords.py delete mode 100644 simplecasts/episodes/migrations/0022_update_search_trigger.py delete mode 100644 simplecasts/episodes/migrations/0023_update_episode_search_trigger_with_simple.py delete mode 100644 simplecasts/episodes/migrations/0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx.py delete mode 100644 simplecasts/episodes/migrations/0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more.py delete mode 100644 simplecasts/episodes/migrations/0031_alter_audiolog_current_time_alter_audiolog_duration.py delete mode 100644 simplecasts/episodes/migrations/max_migration.txt delete mode 100644 simplecasts/episodes/tests/factories.py delete mode 100644 simplecasts/episodes/tests/fixtures.py delete mode 100644 simplecasts/episodes/tests/test_admin.py delete mode 100644 simplecasts/episodes/tests/test_commands.py delete mode 100644 simplecasts/episodes/tests/test_middleware.py delete mode 100644 simplecasts/episodes/tests/test_templatetags.py delete mode 100644 simplecasts/episodes/tests/test_views.py delete mode 100644 simplecasts/episodes/urls.py delete mode 100644 simplecasts/episodes/views.py rename simplecasts/{users => }/forms.py (79%) rename simplecasts/{episodes => http}/__init__.py (100%) rename simplecasts/{http.py => http/decorators.py} (100%) rename simplecasts/{ => http}/request.py (86%) rename simplecasts/{ => http}/response.py (100%) rename simplecasts/{episodes => }/management/__init__.py (100%) rename simplecasts/{episodes => }/management/commands/__init__.py (100%) rename simplecasts/{podcasts => }/management/commands/create_podcast_recommendations.py (83%) rename simplecasts/{podcasts => }/management/commands/fetch_itunes_feeds.py (94%) rename simplecasts/{podcasts => }/management/commands/parse_podcast_feeds.py (88%) rename simplecasts/{episodes => }/management/commands/send_episode_notifications.py (92%) rename simplecasts/{podcasts => }/management/commands/send_podcast_recommendations.py (87%) create mode 100644 simplecasts/migrations/0001_initial.py create mode 100644 simplecasts/migrations/0002_create_search_triggers.py create mode 100644 simplecasts/migrations/0003_alter_episode_search_vector.py create mode 100644 simplecasts/migrations/0004_alter_podcast_owner_search_vector_and_more.py rename simplecasts/{episodes => }/migrations/__init__.py (100%) create mode 100644 simplecasts/migrations/max_migration.txt create mode 100644 simplecasts/models/__init__.py create mode 100644 simplecasts/models/audio_logs.py create mode 100644 simplecasts/models/bookmarks.py create mode 100644 simplecasts/models/categories.py rename simplecasts/{episodes/models.py => models/episodes.py} (65%) rename simplecasts/{ => models}/fields.py (100%) rename simplecasts/{podcasts/models.py => models/podcasts.py} (78%) create mode 100644 simplecasts/models/recommendations.py create mode 100644 simplecasts/models/subscriptions.py rename simplecasts/{users/models.py => models/users.py} (72%) delete mode 100644 simplecasts/podcasts/forms.py delete mode 100644 simplecasts/podcasts/migrations/0001_initial.py delete mode 100644 simplecasts/podcasts/migrations/0002_add_podcast_search_trigger.py delete mode 100644 simplecasts/podcasts/migrations/0003_categories.py delete mode 100644 simplecasts/podcasts/migrations/0004_alter_podcast_frequency.py delete mode 100644 simplecasts/podcasts/migrations/0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0006_remove_subscription_modified_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0007_alter_podcast_frequency.py delete mode 100644 simplecasts/podcasts/migrations/0008_recommendation_score.py delete mode 100644 simplecasts/podcasts/migrations/0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0010_podcast_itunes_ranking.py delete mode 100644 simplecasts/podcasts/migrations/0011_podcast_podcasts_po_itunes__8b4558_idx.py delete mode 100644 simplecasts/podcasts/migrations/0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0013_podcast_podcast_type.py delete mode 100644 simplecasts/podcasts/migrations/0014_podcast_rating.py delete mode 100644 simplecasts/podcasts/migrations/0015_podcast_podcasts_po_rating_f96c31_idx.py delete mode 100644 simplecasts/podcasts/migrations/0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0017_podcast_promoted_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0018_set_default_promoted.py delete mode 100644 simplecasts/podcasts/migrations/0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0020_podcast_canonical.py delete mode 100644 simplecasts/podcasts/migrations/0021_clear_duplicates.py delete mode 100644 simplecasts/podcasts/migrations/0022_alter_podcast_parser_error.py delete mode 100644 simplecasts/podcasts/migrations/0023_move_inaccessible_podcasts_to_unavailable.py delete mode 100644 simplecasts/podcasts/migrations/0024_alter_podcast_parser_error.py delete mode 100644 simplecasts/podcasts/migrations/0025_podcast_complete.py delete mode 100644 simplecasts/podcasts/migrations/0026_mark_podcasts_complete.py delete mode 100644 simplecasts/podcasts/migrations/0027_podcast_podcasts_po_active_a4c988_idx.py delete mode 100644 simplecasts/podcasts/migrations/0028_remove_podcast_complete.py delete mode 100644 simplecasts/podcasts/migrations/0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0032_podcast_queued.py delete mode 100644 simplecasts/podcasts/migrations/0033_remove_podcast_queued.py delete mode 100644 simplecasts/podcasts/migrations/0034_alter_podcast_updated.py delete mode 100644 simplecasts/podcasts/migrations/0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0036_remove_recommendation_frequency_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0037_podcast_itunes_ranking.py delete mode 100644 simplecasts/podcasts/migrations/0038_alter_podcast_itunes_ranking.py delete mode 100644 simplecasts/podcasts/migrations/0039_podcast_podcasts_po_itunes__d69e24_idx.py delete mode 100644 simplecasts/podcasts/migrations/0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0041_remove_podcast_parser_error_podcast_parser_result.py delete mode 100644 simplecasts/podcasts/migrations/0042_podcast_podcasts_po_parser__9f31ab_idx.py delete mode 100644 simplecasts/podcasts/migrations/0043_podcast_promoted.py delete mode 100644 simplecasts/podcasts/migrations/0044_promote_itunes_feeds.py delete mode 100644 simplecasts/podcasts/migrations/0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0046_category_podcasts_ca_name_604e91_idx.py delete mode 100644 simplecasts/podcasts/migrations/0047_remove_category_parent.py delete mode 100644 simplecasts/podcasts/migrations/0048_podcast_num_episodes.py delete mode 100644 simplecasts/podcasts/migrations/0049_set_podcast_num_episodes.py delete mode 100644 simplecasts/podcasts/migrations/0050_podcast_has_similar_podcasts.py delete mode 100644 simplecasts/podcasts/migrations/0051_podcast_score.py delete mode 100644 simplecasts/podcasts/migrations/0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0054_category_itunes_genre_id.py delete mode 100644 simplecasts/podcasts/migrations/0055_itunes_genre_ids.py delete mode 100644 simplecasts/podcasts/migrations/0056_category_slug.py delete mode 100644 simplecasts/podcasts/migrations/0057_set_category_slugs.py delete mode 100644 simplecasts/podcasts/migrations/0058_alter_category_slug.py delete mode 100644 simplecasts/podcasts/migrations/0059_remove_podcast_keywords.py delete mode 100644 simplecasts/podcasts/migrations/0060_podcast_keywords.py delete mode 100644 simplecasts/podcasts/migrations/0061_update_podcast_search_trigger.py delete mode 100644 simplecasts/podcasts/migrations/0062_remove_podcast_podcasts_podcast_lwr_title_idx.py delete mode 100644 simplecasts/podcasts/migrations/0063_update_podcast_search_trigger_with_simple.py delete mode 100644 simplecasts/podcasts/migrations/0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0066_podcast_podcasts_podcast_owner_idx.py delete mode 100644 simplecasts/podcasts/migrations/0067_remove_podcast_podcasts_podcast_owner_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0068_create_owner_search_trigger.py delete mode 100644 simplecasts/podcasts/migrations/0069_category_search_vector_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0070_create_category_name_search_trigger.py delete mode 100644 simplecasts/podcasts/migrations/0071_podcast_queued.py delete mode 100644 simplecasts/podcasts/migrations/0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0073_set_default_promoted_at.py delete mode 100644 simplecasts/podcasts/migrations/0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0075_remove_podcast_podcasts_po_active_aed488_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0076_remove_podcast_queued.py delete mode 100644 simplecasts/podcasts/migrations/0077_podcast_promoted_at.py delete mode 100644 simplecasts/podcasts/migrations/0078_set_default_is_promoted.py delete mode 100644 simplecasts/podcasts/migrations/0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0081_remove_category_podcasts_ca_name_604e91_idx_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0082_remove_category_search_trigger.py delete mode 100644 simplecasts/podcasts/migrations/0083_remove_podcast_has_similar_podcasts.py delete mode 100644 simplecasts/podcasts/migrations/0084_remove_podcast_num_retries.py delete mode 100644 simplecasts/podcasts/migrations/0085_alter_podcast_parser_result.py delete mode 100644 simplecasts/podcasts/migrations/0086_update_parser_results.py delete mode 100644 simplecasts/podcasts/migrations/0087_alter_podcast_parser_result.py delete mode 100644 simplecasts/podcasts/migrations/0088_remove_podcast_podcasts_po_content_736948_idx.py delete mode 100644 simplecasts/podcasts/migrations/0089_remove_podcast_parser_result.py delete mode 100644 simplecasts/podcasts/migrations/0090_podcast_http_status.py delete mode 100644 simplecasts/podcasts/migrations/0091_podcast_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0092_default_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0093_alter_podcast_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0094_update_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0095_alter_podcast_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0096_update_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0097_alter_podcast_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0098_remove_podcast_feed_status_and_more.py delete mode 100644 simplecasts/podcasts/migrations/0099_podcast_feed_last_updated.py delete mode 100644 simplecasts/podcasts/migrations/0100_podcast_exception.py delete mode 100644 simplecasts/podcasts/migrations/0101_podcast_traceback_alter_podcast_exception.py delete mode 100644 simplecasts/podcasts/migrations/0102_remove_podcast_traceback.py delete mode 100644 simplecasts/podcasts/migrations/0103_alter_podcast_exception.py delete mode 100644 simplecasts/podcasts/migrations/0104_podcast_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0105_update_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0106_podcast_num_retries_alter_podcast_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0107_alter_podcast_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0108_update_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0109_alter_podcast_feed_status.py delete mode 100644 simplecasts/podcasts/migrations/0110_remove_podcast_feed_last_updated.py delete mode 100644 simplecasts/podcasts/migrations/__init__.py delete mode 100644 simplecasts/podcasts/migrations/max_migration.txt delete mode 100644 simplecasts/podcasts/parsers/__init__.py delete mode 100644 simplecasts/podcasts/parsers/tests/__init__.py delete mode 100644 simplecasts/podcasts/tests/__init__.py delete mode 100644 simplecasts/podcasts/tests/factories.py delete mode 100644 simplecasts/podcasts/tests/fixtures.py delete mode 100644 simplecasts/podcasts/tests/test_commands.py delete mode 100644 simplecasts/podcasts/tests/test_views.py delete mode 100644 simplecasts/podcasts/urls.py delete mode 100644 simplecasts/podcasts/views.py rename simplecasts/{episodes/templatetags => services}/__init__.py (100%) rename simplecasts/{ => services}/covers.py (98%) rename simplecasts/{podcasts/parsers/feed_parser.py => services/feed_parser/__init__.py} (96%) rename simplecasts/{podcasts/parsers => services/feed_parser}/date_parser.py (100%) rename simplecasts/{podcasts/parsers => services/feed_parser}/exceptions.py (100%) rename simplecasts/{podcasts/parsers => services/feed_parser}/rss_fetcher.py (94%) rename simplecasts/{podcasts/parsers => services/feed_parser}/rss_parser.py (96%) rename simplecasts/{podcasts/parsers => services/feed_parser}/scheduler.py (92%) rename simplecasts/{podcasts/parsers/models.py => services/feed_parser/schemas/__init__.py} (94%) rename simplecasts/{podcasts/parsers => services/feed_parser/schemas}/fields.py (92%) rename simplecasts/{podcasts/parsers => services/feed_parser/schemas}/validators.py (100%) rename simplecasts/{ => services}/http_client.py (100%) rename simplecasts/{podcasts => services}/itunes.py (98%) rename simplecasts/{users => services}/notifications.py (97%) rename simplecasts/{podcasts/parsers => services}/opml_parser.py (90%) rename simplecasts/{ => services}/pwa.py (100%) rename simplecasts/{podcasts => services}/recommender.py (98%) rename simplecasts/{ => services}/sanitizer.py (100%) rename simplecasts/{ => services}/search.py (100%) rename simplecasts/{ => services}/thread_pool.py (100%) rename simplecasts/{podcasts => services}/tokenizer.py (98%) rename simplecasts/{podcasts/parsers => services}/xpath_parser.py (100%) rename simplecasts/{templatetags.py => templatetags/__init__.py} (88%) rename simplecasts/{episodes/templatetags/episodes.py => templatetags/audio_player.py} (71%) create mode 100644 simplecasts/tests/factories.py rename simplecasts/{podcasts/parsers => }/tests/mocks/feeds.opml (100%) rename simplecasts/{users => }/tests/mocks/feeds_with_invalid.opml (100%) rename simplecasts/{podcasts => }/tests/mocks/itunes_chart.html (100%) rename simplecasts/{episodes/tests => tests/models}/__init__.py (100%) create mode 100644 simplecasts/tests/models/test_audio_logs.py create mode 100644 simplecasts/tests/models/test_categories.py rename simplecasts/{episodes/tests/test_models.py => tests/models/test_episodes.py} (79%) rename simplecasts/{podcasts/tests/test_models.py => tests/models/test_podcasts.py} (92%) create mode 100644 simplecasts/tests/models/test_recommendations.py rename simplecasts/{users/tests/test_models.py => tests/models/test_users.py} (90%) rename simplecasts/{podcasts => tests/services}/__init__.py (100%) rename simplecasts/{podcasts/management => tests/services/feed_parser}/__init__.py (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/factories.py (100%) rename simplecasts/{users/tests => tests/services/feed_parser}/mocks/feeds.opml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_bad_cover_urls.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_bad_pub_date.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_bad_sig.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_bad_urls.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_empty_mock.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_high_num_episodes.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_invalid_data.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_invalid_duration.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_missing_enc_length.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_mock.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_mock_complete.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_mock_iso_8859-1.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_mock_large.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_mock_modified.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_mock_no_build_date.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_mock_small.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_new_feed_url.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_no_podcasts_mock.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_serial.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_superfeedr.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/mocks/rss_use_link_ids.xml (100%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/test_date_parser.py (94%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/test_feed_parser.py (97%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/test_opml_parser.py (83%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/test_rss_fetcher.py (94%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/test_rss_parser.py (95%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/test_scheduler.py (95%) rename simplecasts/{podcasts/parsers/tests/test_models.py => tests/services/feed_parser/test_schemas.py} (96%) rename simplecasts/{podcasts/parsers/tests => tests/services/feed_parser}/test_validators.py (97%) rename simplecasts/tests/{ => services}/test_covers.py (98%) rename simplecasts/{podcasts/tests => tests/services}/test_itunes.py (95%) rename simplecasts/{users/tests => tests/services}/test_notifications.py (90%) rename simplecasts/{podcasts/tests => tests/services}/test_recommender.py (94%) rename simplecasts/tests/{ => services}/test_sanitizier.py (94%) rename simplecasts/tests/{ => services}/test_search.py (85%) rename simplecasts/{podcasts/tests => tests/services}/test_tokenizer.py (88%) rename simplecasts/{podcasts => }/tests/test_admin.py (71%) create mode 100644 simplecasts/tests/test_commands.py rename simplecasts/{podcasts/management/commands => tests/views}/__init__.py (100%) create mode 100644 simplecasts/tests/views/test_bookmarks.py create mode 100644 simplecasts/tests/views/test_categories.py create mode 100644 simplecasts/tests/views/test_episodes.py create mode 100644 simplecasts/tests/views/test_history.py rename simplecasts/tests/{ => views}/test_paginator.py (97%) rename simplecasts/tests/{ => views}/test_partials.py (95%) create mode 100644 simplecasts/tests/views/test_player.py create mode 100644 simplecasts/tests/views/test_podcasts.py create mode 100644 simplecasts/tests/views/test_private_feeds.py create mode 100644 simplecasts/tests/views/test_search.py create mode 100644 simplecasts/tests/views/test_subscriptions.py rename simplecasts/{users/tests/test_views.py => tests/views/test_users.py} (94%) rename simplecasts/tests/{ => views}/test_views.py (84%) create mode 100644 simplecasts/urls/__init__.py create mode 100644 simplecasts/urls/bookmarks.py create mode 100644 simplecasts/urls/categories.py create mode 100644 simplecasts/urls/episodes.py create mode 100644 simplecasts/urls/history.py create mode 100644 simplecasts/urls/player.py create mode 100644 simplecasts/urls/podcasts.py create mode 100644 simplecasts/urls/private_feeds.py create mode 100644 simplecasts/urls/search.py create mode 100644 simplecasts/urls/subscriptions.py create mode 100644 simplecasts/urls/users.py delete mode 100644 simplecasts/users/__init__.py delete mode 100644 simplecasts/users/admin.py delete mode 100644 simplecasts/users/apps.py delete mode 100644 simplecasts/users/migrations/0001_initial.py delete mode 100644 simplecasts/users/migrations/0002_alter_user_managers.py delete mode 100644 simplecasts/users/migrations/__init__.py delete mode 100644 simplecasts/users/migrations/max_migration.txt delete mode 100644 simplecasts/users/tests/__init__.py delete mode 100644 simplecasts/users/tests/factories.py delete mode 100644 simplecasts/users/tests/fixtures.py delete mode 100644 simplecasts/users/urls.py rename simplecasts/{views.py => views/__init__.py} (94%) create mode 100644 simplecasts/views/bookmarks.py create mode 100644 simplecasts/views/categories.py create mode 100644 simplecasts/views/episodes.py create mode 100644 simplecasts/views/history.py rename simplecasts/{ => views}/paginator.py (97%) rename simplecasts/{ => views}/partials.py (92%) create mode 100644 simplecasts/views/player.py create mode 100644 simplecasts/views/podcasts.py create mode 100644 simplecasts/views/private_feeds.py create mode 100644 simplecasts/views/search.py create mode 100644 simplecasts/views/subscriptions.py rename simplecasts/{users/views.py => views/users.py} (90%) rename templates/{episodes/bookmarks.html => bookmarks/index.html} (93%) delete mode 100644 templates/card.html create mode 100644 templates/cards.html rename templates/{podcasts => categories}/category_detail.html (92%) rename templates/{podcasts/categories.html => categories/index.html} (100%) rename templates/{episodes/emails/notifications.html => emails/episode_notifications.html} (100%) rename templates/{podcasts/emails/recommendations.html => emails/podcast_recommendations.html} (100%) delete mode 100644 templates/episodes/episode.html rename templates/{episodes/history.html => history/index.html} (93%) delete mode 100644 templates/podcasts/cards.html rename templates/{podcasts/private_feeds.html => private_feeds/index.html} (90%) rename templates/{podcasts => private_feeds}/private_feed_form.html (96%) rename templates/{episodes/search.html => search/search_episodes.html} (90%) rename templates/{podcasts => search}/search_itunes.html (86%) rename templates/{podcasts => search}/search_people.html (90%) rename templates/{podcasts => search}/search_podcasts.html (91%) rename templates/{podcasts/subscriptions.html => subscriptions/index.html} (93%) diff --git a/config/settings.py b/config/settings.py index 7a6710a852..8b8fae7557 100644 --- a/config/settings.py +++ b/config/settings.py @@ -52,9 +52,7 @@ "health_check.contrib.redis", "heroicons", "widget_tweaks", - "simplecasts.episodes", - "simplecasts.podcasts", - "simplecasts.users", + "simplecasts", ] @@ -78,7 +76,7 @@ "simplecasts.middleware.HtmxMessagesMiddleware", "simplecasts.middleware.HtmxRedirectMiddleware", "simplecasts.middleware.SearchMiddleware", - "simplecasts.episodes.middleware.PlayerMiddleware", + "simplecasts.middleware.PlayerMiddleware", ] # Databases @@ -216,7 +214,7 @@ # authentication settings # https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends -AUTH_USER_MODEL = "users.User" +AUTH_USER_MODEL = "simplecasts.User" AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", @@ -232,7 +230,7 @@ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] -LOGIN_REDIRECT_URL = reverse_lazy("podcasts:subscriptions") +LOGIN_REDIRECT_URL = reverse_lazy("subscriptions:index") LOGIN_URL = reverse_lazy("account_login") # https://django-allauth.readthedocs.io/en/latest/configuration.html diff --git a/config/urls.py b/config/urls.py index 776a28b45b..8caeff171b 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,25 +2,8 @@ from django.contrib import admin from django.urls import include, path -from simplecasts import views - urlpatterns = [ - path("", views.index, name="index"), - path("about/", views.about, name="about"), - path("privacy/", views.privacy, name="privacy"), - path("accept-cookies/", views.accept_cookies, name="accept_cookies"), - path( - "covers//.webp", - views.cover_image, - name="cover_image", - ), - path("robots.txt", views.robots, name="robots"), - path("manifest.json", views.manifest, name="manifest"), - path(".well-known/assetlinks.json", views.assetlinks, name="assetlinks"), - path(".well-known/security.txt", views.security, name="security"), - path("", include("simplecasts.episodes.urls")), - path("", include("simplecasts.podcasts.urls")), - path("", include("simplecasts.users.urls")), + path("", include("simplecasts.urls")), path("account/", include("allauth.urls")), path("ht/", include("health_check.urls")), path(settings.ADMIN_URL, admin.site.urls), diff --git a/conftest.py b/conftest.py index acc0bc923b..bf6a44486e 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,3 @@ pytest_plugins = [ "simplecasts.tests.fixtures", - "simplecasts.episodes.tests.fixtures", - "simplecasts.podcasts.tests.fixtures", - "simplecasts.users.tests.fixtures", ] diff --git a/pyproject.toml b/pyproject.toml index 9047504dbc..63fc15782f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,7 +195,7 @@ django_settings_module = "simplecasts.settings" include=["simplecasts"] exclude=[ "**/migrations/*.py", - "**/tests/*.py", + "**/tests/**", ] typeCheckingMode = "basic" diff --git a/simplecasts/podcasts/admin.py b/simplecasts/admin.py similarity index 78% rename from simplecasts/podcasts/admin.py rename to simplecasts/admin.py index 66afdf404e..44a25dde69 100644 --- a/simplecasts/podcasts/admin.py +++ b/simplecasts/admin.py @@ -1,19 +1,24 @@ from typing import TYPE_CHECKING, ClassVar from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.db.models import Count, Exists, OuterRef, QuerySet from django.http import HttpRequest +from django.template.defaultfilters import truncatechars from django.utils import timezone from django.utils.timesince import timesince, timeuntil -from simplecasts.podcasts.models import ( +from simplecasts.models import ( + AudioLog, Category, + Episode, Podcast, - PodcastQuerySet, Recommendation, Subscription, + User, ) -from simplecasts.search import search_queryset +from simplecasts.models.podcasts import PodcastQuerySet +from simplecasts.services.search import search_queryset if TYPE_CHECKING: from django_stubs_ext import StrOrPromise # pragma: no cover @@ -27,6 +32,21 @@ class CategoryWithNumPodcasts(Category): CategoryWithNumPodcasts = Category +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """User model admin.""" + + fieldsets = ( + *tuple(BaseUserAdmin.fieldsets or ()), + ( + "User preferences", + { + "fields": ("send_email_notifications",), + }, + ), + ) + + @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): """Admin for podcast categories.""" @@ -352,3 +372,78 @@ class SubscriptionAdmin(admin.ModelAdmin): def get_queryset(self, request: HttpRequest) -> QuerySet[Subscription]: """Returns queryset with related fields.""" return super().get_queryset(request).select_related("podcast", "subscriber") + + +@admin.register(Episode) +class EpisodeAdmin(admin.ModelAdmin): + """Django admin for Episode model.""" + + list_display = ("episode_title", "podcast_title", "pub_date") + list_select_related = ("podcast",) + raw_id_fields = ("podcast",) + search_fields = ("search_vector",) + + @admin.display(description="Title") + def episode_title(self, obj: Episode) -> str: + """Render truncated episode title.""" + return truncatechars(obj.title, 30) + + @admin.display(description="Podcast") + def podcast_title(self, obj: Episode) -> str: + """Render truncated podcast title.""" + return truncatechars(obj.podcast.title, 30) + + def get_search_results( + self, + request: HttpRequest, + queryset: QuerySet[Episode], + search_term: str, + ) -> tuple[QuerySet[Episode], bool]: + """Search episodes.""" + return ( + ( + search_queryset( + queryset, + search_term, + *self.search_fields, + ).order_by("-rank", "-pub_date"), + False, + ) + if search_term + else super().get_search_results(request, queryset, search_term) + ) + + def get_ordering(self, request: HttpRequest) -> list[str]: + """Returns optimized search ordering. + + If unfiltered, just search by id. + """ + return ( + [] + if request.GET.get("q") + else [ + "-id", + ] + ) + + +@admin.register(AudioLog) +class AudioLogAdmin(admin.ModelAdmin): + """Django admin for AudioLog model.""" + + list_display = ( + "episode", + "user", + ) + readonly_fields = ( + "episode", + "user", + "current_time", + "duration", + "listened", + ) + ordering = ("-listened",) + + def get_queryset(self, request: HttpRequest) -> QuerySet[AudioLog]: + """Optimize queryset for admin.""" + return super().get_queryset(request).select_related("episode", "user") diff --git a/simplecasts/podcasts/apps.py b/simplecasts/apps.py similarity index 64% rename from simplecasts/podcasts/apps.py rename to simplecasts/apps.py index 1ee64a94d5..ba4d74436a 100644 --- a/simplecasts/podcasts/apps.py +++ b/simplecasts/apps.py @@ -1,8 +1,8 @@ from django.apps import AppConfig -class PodcastsConfig(AppConfig): +class ProjectConfig(AppConfig): """App configuration.""" - name = "simplecasts.podcasts" + name = "simplecasts" default_auto_field = "django.db.models.BigAutoField" diff --git a/simplecasts/episodes/admin.py b/simplecasts/episodes/admin.py deleted file mode 100644 index db35165ca9..0000000000 --- a/simplecasts/episodes/admin.py +++ /dev/null @@ -1,82 +0,0 @@ -from django.contrib import admin -from django.db.models import QuerySet -from django.http import HttpRequest -from django.template.defaultfilters import truncatechars - -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.search import search_queryset - - -@admin.register(Episode) -class EpisodeAdmin(admin.ModelAdmin): - """Django admin for Episode model.""" - - list_display = ("episode_title", "podcast_title", "pub_date") - list_select_related = ("podcast",) - raw_id_fields = ("podcast",) - search_fields = ("search_vector",) - - @admin.display(description="Title") - def episode_title(self, obj: Episode) -> str: - """Render truncated episode title.""" - return truncatechars(obj.title, 30) - - @admin.display(description="Podcast") - def podcast_title(self, obj: Episode) -> str: - """Render truncated podcast title.""" - return truncatechars(obj.podcast.title, 30) - - def get_search_results( - self, - request: HttpRequest, - queryset: QuerySet[Episode], - search_term: str, - ) -> tuple[QuerySet[Episode], bool]: - """Search episodes.""" - return ( - ( - search_queryset( - queryset, - search_term, - *self.search_fields, - ).order_by("-rank", "-pub_date"), - False, - ) - if search_term - else super().get_search_results(request, queryset, search_term) - ) - - def get_ordering(self, request: HttpRequest) -> list[str]: - """Returns optimized search ordering. - - If unfiltered, just search by id. - """ - return ( - [] - if request.GET.get("q") - else [ - "-id", - ] - ) - - -@admin.register(AudioLog) -class AudioLogAdmin(admin.ModelAdmin): - """Django admin for AudioLog model.""" - - list_display = ( - "episode", - "user", - ) - readonly_fields = ( - "episode", - "user", - "current_time", - "duration", - "listened", - ) - ordering = ("-listened",) - - def get_queryset(self, request: HttpRequest) -> QuerySet[AudioLog]: - """Optimize queryset for admin.""" - return super().get_queryset(request).select_related("episode", "user") diff --git a/simplecasts/episodes/apps.py b/simplecasts/episodes/apps.py deleted file mode 100644 index 0003df71e2..0000000000 --- a/simplecasts/episodes/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class EpisodesConfig(AppConfig): - name = "simplecasts.episodes" - default_auto_field = "django.db.models.BigAutoField" diff --git a/simplecasts/episodes/middleware.py b/simplecasts/episodes/middleware.py deleted file mode 100644 index 5dc3a82ffa..0000000000 --- a/simplecasts/episodes/middleware.py +++ /dev/null @@ -1,40 +0,0 @@ -import dataclasses - -from django.http import HttpResponse - -from simplecasts.middleware import BaseMiddleware -from simplecasts.request import HttpRequest - - -class PlayerMiddleware(BaseMiddleware): - """Adds `PlayerDetails` instance to request as `request.player`.""" - - def __call__(self, request: HttpRequest) -> HttpResponse: - """Middleware implementation.""" - request.player = PlayerDetails(request=request) - return self.get_response(request) - - -@dataclasses.dataclass(frozen=True, kw_only=True) -class PlayerDetails: - """Tracks current player episode in session.""" - - request: HttpRequest - session_id: str = "audio-player" - - def get(self) -> int | None: - """Returns primary key of episode in player, if any in session.""" - return self.request.session.get(self.session_id) - - def has(self, episode_id: int) -> bool: - """Checks if episode matching ID is in player.""" - return self.get() == episode_id - - def set(self, episode_id: int) -> None: - """Adds episode PK to player in session.""" - self.request.session[self.session_id] = episode_id - - def pop(self) -> int | None: - """Returns primary key of episode in player, if any in session, and removes - the episode ID from the session.""" - return self.request.session.pop(self.session_id, None) diff --git a/simplecasts/episodes/migrations/0001_initial.py b/simplecasts/episodes/migrations/0001_initial.py deleted file mode 100644 index 58af64c3dc..0000000000 --- a/simplecasts/episodes/migrations/0001_initial.py +++ /dev/null @@ -1,239 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:03 - -import django.contrib.postgres.indexes -import django.contrib.postgres.search -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("podcasts", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Episode", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("guid", models.TextField()), - ("pub_date", models.DateTimeField()), - ("title", models.TextField(blank=True)), - ("description", models.TextField(blank=True)), - ("keywords", models.TextField(blank=True)), - ("website", models.URLField(blank=True, max_length=2083, null=True)), - ("episode_type", models.CharField(default="full", max_length=30)), - ("episode", models.IntegerField(blank=True, null=True)), - ("season", models.IntegerField(blank=True, null=True)), - ("cover_url", models.URLField(blank=True, max_length=2083, null=True)), - ("media_url", models.URLField(max_length=2083)), - ("media_type", models.CharField(max_length=60)), - ("length", models.BigIntegerField(blank=True, null=True)), - ("duration", models.CharField(blank=True, max_length=30)), - ("explicit", models.BooleanField(default=False)), - ( - "search_vector", - django.contrib.postgres.search.SearchVectorField( - editable=False, null=True - ), - ), - ( - "podcast", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="episodes", - to="podcasts.podcast", - ), - ), - ], - ), - migrations.CreateModel( - name="Bookmark", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "episode", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="bookmarks", - to="episodes.episode", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="bookmarks", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="AudioLog", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ("listened", models.DateTimeField()), - ("current_time", models.IntegerField(default=0)), - ( - "episode", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="audio_logs", - to="episodes.episode", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="audio_logs", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["podcast", "pub_date"], name="episodes_ep_podcast_a7abe0_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["podcast", "-pub_date"], name="episodes_ep_podcast_b9a49e_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["podcast"], name="episodes_ep_podcast_3361d9_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index(fields=["guid"], name="episodes_ep_guid_b00554_idx"), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["pub_date"], name="episodes_ep_pub_dat_60d1c1_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["-pub_date"], name="episodes_ep_pub_dat_205e36_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["-pub_date", "-id"], name="episodes_ep_pub_dat_9b17cd_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=django.contrib.postgres.indexes.GinIndex( - fields=["search_vector"], name="episodes_ep_search__466ef4_gin" - ), - ), - migrations.AddConstraint( - model_name="episode", - constraint=models.UniqueConstraint( - fields=("podcast", "guid"), name="unique_episodes_episode_podcast_guid" - ), - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["-created"], name="episodes_bo_created_d69e08_idx" - ), - ), - migrations.AddConstraint( - model_name="bookmark", - constraint=models.UniqueConstraint( - fields=("user", "episode"), name="unique_episodes_bookmark_user_episode" - ), - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["-listened"], name="episodes_au_listene_7f0fdd_idx" - ), - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["listened"], name="episodes_au_listene_e5a9d5_idx" - ), - ), - migrations.AddConstraint( - model_name="audiolog", - constraint=models.UniqueConstraint( - fields=("user", "episode"), name="unique_episodes_audiolog_user_episode" - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0002_add_episode_search_trigger.py b/simplecasts/episodes/migrations/0002_add_episode_search_trigger.py deleted file mode 100644 index 90726b4c38..0000000000 --- a/simplecasts/episodes/migrations/0002_add_episode_search_trigger.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0001_initial"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title, keywords); -UPDATE episodes_episode SET search_vector = NULL;""", - reverse_sql="DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode;", - ), - ] diff --git a/simplecasts/episodes/migrations/0003_alter_episode_cover_url_alter_episode_website.py b/simplecasts/episodes/migrations/0003_alter_episode_cover_url_alter_episode_website.py deleted file mode 100644 index 2c22fa9185..0000000000 --- a/simplecasts/episodes/migrations/0003_alter_episode_cover_url_alter_episode_website.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-14 11:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0002_add_episode_search_trigger"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="cover_url", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - migrations.AlterField( - model_name="episode", - name="website", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - ] diff --git a/simplecasts/episodes/migrations/0004_remove_audiolog_created_remove_audiolog_modified_and_more.py b/simplecasts/episodes/migrations/0004_remove_audiolog_created_remove_audiolog_modified_and_more.py deleted file mode 100644 index 89b6d129c9..0000000000 --- a/simplecasts/episodes/migrations/0004_remove_audiolog_created_remove_audiolog_modified_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-15 08:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0003_alter_episode_cover_url_alter_episode_website"), - ] - - operations = [ - migrations.RemoveField( - model_name="audiolog", - name="created", - ), - migrations.RemoveField( - model_name="audiolog", - name="modified", - ), - migrations.RemoveField( - model_name="bookmark", - name="modified", - ), - migrations.AlterField( - model_name="bookmark", - name="created", - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/simplecasts/episodes/migrations/0005_alter_episode_episode_type.py b/simplecasts/episodes/migrations/0005_alter_episode_episode_type.py deleted file mode 100644 index 1f8bd52d67..0000000000 --- a/simplecasts/episodes/migrations/0005_alter_episode_episode_type.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-27 12:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0004_remove_audiolog_created_remove_audiolog_modified_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="episode_type", - field=models.CharField( - choices=[ - ("full", "Full episode"), - ("trailer", "Trailer"), - ("bonus", "Bonus"), - ], - default="full", - max_length=12, - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0006_episode_episodes_ep_podcast_965d74_idx.py b/simplecasts/episodes/migrations/0006_episode_episodes_ep_podcast_965d74_idx.py deleted file mode 100644 index 604fdc7744..0000000000 --- a/simplecasts/episodes/migrations/0006_episode_episodes_ep_podcast_965d74_idx.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-28 13:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0005_alter_episode_episode_type"), - ("podcasts", "0013_podcast_podcast_type"), - ] - - operations = [ - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["podcast", "season"], name="episodes_ep_podcast_965d74_idx" - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0007_rename_length_episode_file_size.py b/simplecasts/episodes/migrations/0007_rename_length_episode_file_size.py deleted file mode 100644 index 9ffd422bcc..0000000000 --- a/simplecasts/episodes/migrations/0007_rename_length_episode_file_size.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-25 07:46 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0006_episode_episodes_ep_podcast_965d74_idx"), - ] - - operations = [ - migrations.RenameField( - model_name="episode", - old_name="length", - new_name="file_size", - ), - ] diff --git a/simplecasts/episodes/migrations/0008_alter_episode_file_size.py b/simplecasts/episodes/migrations/0008_alter_episode_file_size.py deleted file mode 100644 index b6577e6510..0000000000 --- a/simplecasts/episodes/migrations/0008_alter_episode_file_size.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-25 07:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0007_rename_length_episode_file_size"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="file_size", - field=models.BigIntegerField( - blank=True, null=True, verbose_name="File size in bytes" - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more.py b/simplecasts/episodes/migrations/0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more.py deleted file mode 100644 index 870bfe391f..0000000000 --- a/simplecasts/episodes/migrations/0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-26 18:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0008_alter_episode_file_size"), - ("podcasts", "0013_podcast_podcast_type"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_pub_dat_9b17cd_idx", - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["episode", "pub_date"], name="episodes_ep_episode_c8cf94_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["-episode", "-pub_date"], name="episodes_ep_episode_ddf08c_idx" - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["season", "episode", "pub_date"], - name="episodes_ep_season_ec7620_idx", - ), - ), - migrations.AddIndex( - model_name="episode", - index=models.Index( - fields=["-season", "-episode", "-pub_date"], - name="episodes_ep_season_2b409f_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more.py b/simplecasts/episodes/migrations/0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more.py deleted file mode 100644 index 2faa587e9d..0000000000 --- a/simplecasts/episodes/migrations/0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-26 19:15 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0009_remove_episode_episodes_ep_pub_dat_9b17cd_idx_and_more"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_episode_c8cf94_idx", - ), - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_episode_ddf08c_idx", - ), - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_season_ec7620_idx", - ), - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_season_2b409f_idx", - ), - ] diff --git a/simplecasts/episodes/migrations/0011_remove_episode_episodes_ep_podcast_3361d9_idx.py b/simplecasts/episodes/migrations/0011_remove_episode_episodes_ep_podcast_3361d9_idx.py deleted file mode 100644 index 0093bd8adb..0000000000 --- a/simplecasts/episodes/migrations/0011_remove_episode_episodes_ep_podcast_3361d9_idx.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-23 12:55 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0010_remove_episode_episodes_ep_episode_c8cf94_idx_and_more"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="episode", - name="episodes_ep_podcast_3361d9_idx", - ), - ] diff --git a/simplecasts/episodes/migrations/0012_alter_episode_cover_url_alter_episode_media_url_and_more.py b/simplecasts/episodes/migrations/0012_alter_episode_cover_url_alter_episode_media_url_and_more.py deleted file mode 100644 index 0b942c9d7b..0000000000 --- a/simplecasts/episodes/migrations/0012_alter_episode_cover_url_alter_episode_media_url_and_more.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.2 on 2025-04-03 17:09 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0011_remove_episode_episodes_ep_podcast_3361d9_idx"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="cover_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="episode", - name="media_url", - field=models.URLField( - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="episode", - name="website", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0013_alter_episode_cover_url_alter_episode_media_url_and_more.py b/simplecasts/episodes/migrations/0013_alter_episode_cover_url_alter_episode_media_url_and_more.py deleted file mode 100644 index 0429292b6d..0000000000 --- a/simplecasts/episodes/migrations/0013_alter_episode_cover_url_alter_episode_media_url_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.2 on 2025-04-03 17:33 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0012_alter_episode_cover_url_alter_episode_media_url_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="episode", - name="cover_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="episode", - name="media_url", - field=models.URLField( - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="episode", - name="website", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more.py b/simplecasts/episodes/migrations/0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more.py deleted file mode 100644 index 46582b7cf2..0000000000 --- a/simplecasts/episodes/migrations/0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-12 22:12 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - dependencies = [ - ("episodes", "0013_alter_episode_cover_url_alter_episode_media_url_and_more"), - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_a7abe0_idx", - ), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_b9a49e_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "pub_date", "id"], - name="episodes_ep_podcast_12cd3c_idx", - ), - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "-pub_date", "-id"], - name="episodes_ep_podcast_c43bb8_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more.py b/simplecasts/episodes/migrations/0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more.py deleted file mode 100644 index f7f84adf96..0000000000 --- a/simplecasts/episodes/migrations/0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-12 22:16 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - dependencies = [ - ("episodes", "0014_remove_episode_episodes_ep_podcast_a7abe0_idx_and_more"), - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_pub_dat_60d1c1_idx", - ), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_pub_dat_205e36_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["pub_date", "id"], name="episodes_ep_pub_dat_866539_idx" - ), - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["-pub_date", "-id"], name="episodes_ep_pub_dat_9b17cd_idx" - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more.py b/simplecasts/episodes/migrations/0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more.py deleted file mode 100644 index 67908d616a..0000000000 --- a/simplecasts/episodes/migrations/0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-26 10:18 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0015_remove_episode_episodes_ep_pub_dat_60d1c1_idx_and_more"), - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_12cd3c_idx", - ), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_c43bb8_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["pub_date", "podcast", "id"], - name="episodes_ep_pub_dat_34887e_idx", - ), - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["-pub_date", "podcast", "-id"], - name="episodes_ep_pub_dat_4abe4c_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0017_episode_episodes_ep_podcast_c43bb8_idx.py b/simplecasts/episodes/migrations/0017_episode_episodes_ep_podcast_c43bb8_idx.py deleted file mode 100644 index 46c99266e9..0000000000 --- a/simplecasts/episodes/migrations/0017_episode_episodes_ep_podcast_c43bb8_idx.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-04 11:17 - -from django.contrib.postgres.operations import AddIndexConcurrently -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0016_remove_episode_episodes_ep_podcast_12cd3c_idx_and_more"), - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "-pub_date", "-id"], - name="episodes_ep_podcast_c43bb8_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0018_audiolog_duration.py b/simplecasts/episodes/migrations/0018_audiolog_duration.py deleted file mode 100644 index 157b33e24a..0000000000 --- a/simplecasts/episodes/migrations/0018_audiolog_duration.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-07 18:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0017_episode_episodes_ep_podcast_c43bb8_idx"), - ] - - operations = [ - migrations.AddField( - model_name="audiolog", - name="duration", - field=models.IntegerField(default=0), - ), - ] diff --git a/simplecasts/episodes/migrations/0019_set_default_audio_log_duration.py b/simplecasts/episodes/migrations/0019_set_default_audio_log_duration.py deleted file mode 100644 index 51bc1d351c..0000000000 --- a/simplecasts/episodes/migrations/0019_set_default_audio_log_duration.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-07 18:47 - -from django.db import migrations - - -def duration_in_seconds(duration_str) -> int: - """Returns total number of seconds given string in [h:][m:]s format.""" - if not duration_str: - return 0 - - try: - return sum( - (int(part) * multiplier) - for (part, multiplier) in zip( - reversed(duration_str.split(":")[:3]), - (1, 60, 3600), - strict=False, - ) - ) - except ValueError: - return 0 - - -def set_default_audio_log_duration(apps, schema_editor): - AudioLog = apps.get_model("episodes", "AudioLog") - for_update = [] - - for audio_log in AudioLog.objects.filter( - duration=0, - episode__duration__isnull=False, - ).select_related("episode"): - audio_log.duration = duration_in_seconds(audio_log.episode.duration) - for_update.append(audio_log) - - AudioLog.objects.bulk_update(for_update, ["duration"]) - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0018_audiolog_duration"), - ] - - operations = [ - migrations.RunPython( - set_default_audio_log_duration, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/simplecasts/episodes/migrations/0020_remove_episode_keywords.py b/simplecasts/episodes/migrations/0020_remove_episode_keywords.py deleted file mode 100644 index d3a0a659ea..0000000000 --- a/simplecasts/episodes/migrations/0020_remove_episode_keywords.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 15:40 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0019_set_default_audio_log_duration"), - ] - - operations = [ - migrations.RunSQL( - "SET statement_timeout = 0;", - reverse_sql="SET statement_timeout = 0;", - ), - migrations.RunSQL( - sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title); -""", - reverse_sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title, keywords); - """, - ), - migrations.RemoveField( - model_name="episode", - name="keywords", - ), - ] diff --git a/simplecasts/episodes/migrations/0021_episode_keywords.py b/simplecasts/episodes/migrations/0021_episode_keywords.py deleted file mode 100644 index 63c9076f5c..0000000000 --- a/simplecasts/episodes/migrations/0021_episode_keywords.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 18:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0020_remove_episode_keywords"), - ] - - operations = [ - migrations.AddField( - model_name="episode", - name="keywords", - field=models.TextField(blank=True), - ), - ] diff --git a/simplecasts/episodes/migrations/0022_update_search_trigger.py b/simplecasts/episodes/migrations/0022_update_search_trigger.py deleted file mode 100644 index 77d6879645..0000000000 --- a/simplecasts/episodes/migrations/0022_update_search_trigger.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 18:05 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0021_episode_keywords"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title, keywords); -""", - reverse_sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title); - """, - ), - ] diff --git a/simplecasts/episodes/migrations/0023_update_episode_search_trigger_with_simple.py b/simplecasts/episodes/migrations/0023_update_episode_search_trigger_with_simple.py deleted file mode 100644 index 71b423387d..0000000000 --- a/simplecasts/episodes/migrations/0023_update_episode_search_trigger_with_simple.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-22 22:13 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0022_update_search_trigger"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.simple', title, keywords); -""", - reverse_sql=""" -DROP TRIGGER IF EXISTS episode_update_search_trigger ON episodes_episode; -CREATE TRIGGER episode_update_search_trigger -BEFORE INSERT OR UPDATE OF title, keywords, search_vector -ON episodes_episode -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger(search_vector, 'pg_catalog.english', title, keywords); - """, - ) - ] diff --git a/simplecasts/episodes/migrations/0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx.py b/simplecasts/episodes/migrations/0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx.py deleted file mode 100644 index 1e3105892b..0000000000 --- a/simplecasts/episodes/migrations/0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 13:27 - -from django.contrib.postgres.operations import RemoveIndexConcurrently -from django.db import migrations - - -def ensure_episodes_loaded(apps, schema_editor): - """ - Forces Django to fully register the 'episodes' app in the historical registry. - This prevents LookupError in later migrations that reference Episode. - """ - apps.get_app_config("episodes") - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0023_update_episode_search_trigger_with_simple"), - ] - - operations = [ - migrations.RunPython( - ensure_episodes_loaded, - reverse_code=migrations.RunPython.noop, - ), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_pub_dat_4abe4c_idx", - ), - ] diff --git a/simplecasts/episodes/migrations/0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more.py b/simplecasts/episodes/migrations/0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more.py deleted file mode 100644 index 72aece2535..0000000000 --- a/simplecasts/episodes/migrations/0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 13:41 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0024_remove_episode_episodes_ep_pub_dat_4abe4c_idx"), - ("podcasts", "0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_pub_dat_34887e_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "pub_date", "id"], - name="episodes_ep_podcast_12cd3c_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more.py b/simplecasts/episodes/migrations/0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more.py deleted file mode 100644 index ae5544ea35..0000000000 --- a/simplecasts/episodes/migrations/0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 14:20 - -from django.contrib.postgres.operations import ( - AddIndexConcurrently, - RemoveIndexConcurrently, -) -from django.db import migrations, models - -timeout_statement = "SET statement_timeout = 0;" - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("episodes", "0025_remove_episode_episodes_ep_pub_dat_34887e_idx_and_more"), - ("podcasts", "0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more"), - ] - - operations = [ - migrations.RunSQL(timeout_statement, reverse_sql=timeout_statement), - RemoveIndexConcurrently( - model_name="episode", - name="episodes_ep_podcast_965d74_idx", - ), - AddIndexConcurrently( - model_name="episode", - index=models.Index( - fields=["podcast", "season", "-pub_date", "-id"], - name="episodes_ep_podcast_d9b8d2_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more.py b/simplecasts/episodes/migrations/0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more.py deleted file mode 100644 index fd7996a978..0000000000 --- a/simplecasts/episodes/migrations/0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 20:20 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0026_remove_episode_episodes_ep_podcast_965d74_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="audiolog", - name="episodes_au_listene_7f0fdd_idx", - ), - migrations.RemoveIndex( - model_name="audiolog", - name="episodes_au_listene_e5a9d5_idx", - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["user", "episode"], name="episodes_au_user_id_fef2ea_idx" - ), - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["user", "episode", "listened"], - name="episodes_au_user_id_fb8578_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more.py b/simplecasts/episodes/migrations/0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more.py deleted file mode 100644 index 3dad0d1eb0..0000000000 --- a/simplecasts/episodes/migrations/0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 20:26 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0027_remove_audiolog_episodes_au_listene_7f0fdd_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="bookmark", - name="episodes_bo_created_d69e08_idx", - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["user", "episode"], name="episodes_bo_user_id_09cdb6_idx" - ), - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["user", "episode", "created"], - name="episodes_bo_user_id_21f9c3_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more.py b/simplecasts/episodes/migrations/0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more.py deleted file mode 100644 index 34c76d4f3a..0000000000 --- a/simplecasts/episodes/migrations/0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-24 10:02 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0028_remove_bookmark_episodes_bo_created_d69e08_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="audiolog", - name="episodes_au_user_id_fb8578_idx", - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["user", "listened"], - include=("episode_id",), - name="episodes_audiolog_desc_idx", - ), - ), - migrations.AddIndex( - model_name="audiolog", - index=models.Index( - fields=["user", "-listened"], - include=("episode_id",), - name="episodes_audiolog_asc_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more.py b/simplecasts/episodes/migrations/0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more.py deleted file mode 100644 index 63bdc14cc3..0000000000 --- a/simplecasts/episodes/migrations/0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-24 10:05 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0029_remove_audiolog_episodes_au_user_id_fb8578_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="bookmark", - name="episodes_bo_user_id_21f9c3_idx", - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["user", "created"], - include=("episode_id",), - name="episodes_bookmark_desc_idx", - ), - ), - migrations.AddIndex( - model_name="bookmark", - index=models.Index( - fields=["user", "-created"], - include=("episode_id",), - name="episodes_bookmark_asc_idx", - ), - ), - ] diff --git a/simplecasts/episodes/migrations/0031_alter_audiolog_current_time_alter_audiolog_duration.py b/simplecasts/episodes/migrations/0031_alter_audiolog_current_time_alter_audiolog_duration.py deleted file mode 100644 index 01eaa20b7f..0000000000 --- a/simplecasts/episodes/migrations/0031_alter_audiolog_current_time_alter_audiolog_duration.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-25 10:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("episodes", "0030_remove_bookmark_episodes_bo_user_id_21f9c3_idx_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="audiolog", - name="current_time", - field=models.PositiveIntegerField(default=0), - ), - migrations.AlterField( - model_name="audiolog", - name="duration", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/simplecasts/episodes/migrations/max_migration.txt b/simplecasts/episodes/migrations/max_migration.txt deleted file mode 100644 index cbd7284027..0000000000 --- a/simplecasts/episodes/migrations/max_migration.txt +++ /dev/null @@ -1 +0,0 @@ -0031_alter_audiolog_current_time_alter_audiolog_duration diff --git a/simplecasts/episodes/tests/factories.py b/simplecasts/episodes/tests/factories.py deleted file mode 100644 index 0e9118b648..0000000000 --- a/simplecasts/episodes/tests/factories.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid - -import factory -from django.utils import timezone - -from simplecasts.episodes.models import AudioLog, Bookmark, Episode -from simplecasts.podcasts.tests.factories import PodcastFactory -from simplecasts.users.tests.factories import UserFactory - - -class EpisodeFactory(factory.django.DjangoModelFactory): - guid = factory.LazyFunction(lambda: uuid.uuid4().hex) - podcast = factory.SubFactory(PodcastFactory) - title = factory.Faker("text") - description = factory.Faker("text") - pub_date = factory.LazyFunction(timezone.now) - media_url = factory.Faker("url") - media_type = "audio/mpg" - duration = "100" - - class Meta: - model = Episode - - -class BookmarkFactory(factory.django.DjangoModelFactory): - user = factory.SubFactory(UserFactory) - episode = factory.SubFactory(EpisodeFactory) - - class Meta: - model = Bookmark - - -class AudioLogFactory(factory.django.DjangoModelFactory): - user = factory.SubFactory(UserFactory) - episode = factory.SubFactory(EpisodeFactory) - listened = factory.LazyFunction(timezone.now) - current_time = 1000 - - class Meta: - model = AudioLog diff --git a/simplecasts/episodes/tests/fixtures.py b/simplecasts/episodes/tests/fixtures.py deleted file mode 100644 index f53adf6fce..0000000000 --- a/simplecasts/episodes/tests/fixtures.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest -from django.test import Client - -from simplecasts.episodes.middleware import PlayerDetails -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.episodes.tests.factories import AudioLogFactory, EpisodeFactory -from simplecasts.users.models import User - - -@pytest.fixture -def episode() -> Episode: - return EpisodeFactory() - - -@pytest.fixture -def audio_log(episode: Episode) -> AudioLog: - return AudioLogFactory(episode=episode) - - -@pytest.fixture -def player_episode(auth_user: User, client: Client, episode: Episode) -> Episode: - """Fixture that creates an AudioLog for the given user and episode""" - AudioLogFactory(user=auth_user, episode=episode) - - session = client.session - session[PlayerDetails.session_id] = episode.pk - session.save() - - return episode diff --git a/simplecasts/episodes/tests/test_admin.py b/simplecasts/episodes/tests/test_admin.py deleted file mode 100644 index 440ae5bdcc..0000000000 --- a/simplecasts/episodes/tests/test_admin.py +++ /dev/null @@ -1,61 +0,0 @@ -import pytest -from django.contrib.admin.sites import AdminSite - -from simplecasts.episodes.admin import AudioLogAdmin, EpisodeAdmin -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.episodes.tests.factories import AudioLogFactory, EpisodeFactory -from simplecasts.podcasts.tests.factories import PodcastFactory - - -class TestAudioLogAdmin: - @pytest.mark.django_db - def test_get_queryset(self, rf): - AudioLogFactory() - admin = AudioLogAdmin(AudioLog, AdminSite()) - request = rf.get("/") - qs = admin.get_queryset(request) - assert qs.count() == 1 - - -class TestEpisodeAdmin: - @pytest.fixture(scope="class") - def admin(self): - return EpisodeAdmin(Episode, AdminSite()) - - @pytest.mark.django_db - def test_episode_title(self, admin): - episode = EpisodeFactory(title="testing") - assert admin.episode_title(episode) == "testing" - - @pytest.mark.django_db - def test_podcast_title(self, admin): - episode = EpisodeFactory(podcast=PodcastFactory(title="testing")) - assert admin.podcast_title(episode) == "testing" - - @pytest.mark.django_db - def test_get_ordering_no_search_term(self, admin, rf): - ordering = admin.get_ordering(rf.get("/")) - assert ordering == ["-id"] - - @pytest.mark.django_db - def test_get_ordering_search_term(self, admin, rf): - ordering = admin.get_ordering(rf.get("/", {"q": "test"})) - assert ordering == [] - - @pytest.mark.django_db - def test_get_search_results_no_search_term(self, rf, admin): - EpisodeFactory.create_batch(3) - qs, _ = admin.get_search_results(rf.get("/"), Episode.objects.all(), "") - assert qs.count() == 3 - - @pytest.mark.django_db - def test_get_search_results(self, rf, admin): - EpisodeFactory.create_batch(3) - - episode = EpisodeFactory(title="testing python") - - qs, _ = admin.get_search_results( - rf.get("/"), Episode.objects.all(), "testing python" - ) - assert qs.count() == 1 - assert qs.first() == episode diff --git a/simplecasts/episodes/tests/test_commands.py b/simplecasts/episodes/tests/test_commands.py deleted file mode 100644 index 0778391458..0000000000 --- a/simplecasts/episodes/tests/test_commands.py +++ /dev/null @@ -1,80 +0,0 @@ -from datetime import timedelta - -import pytest -from django.core.management import call_command -from django.utils import timezone - -from simplecasts.episodes.tests.factories import ( - AudioLogFactory, - BookmarkFactory, - EpisodeFactory, -) -from simplecasts.podcasts.tests.factories import ( - SubscriptionFactory, -) -from simplecasts.users.tests.factories import EmailAddressFactory - - -class TestSendEpisodeNotifications: - @pytest.fixture - def recipient(self): - return EmailAddressFactory( - verified=True, - primary=True, - ) - - @pytest.mark.django_db(transaction=True) - def test_has_episodes(self, mailoutbox, recipient): - subscription = SubscriptionFactory( - subscriber=recipient.user, - ) - EpisodeFactory.create_batch( - 3, - podcast=subscription.podcast, - pub_date=timezone.now() - timedelta(days=1), - ) - call_command("send_episode_notifications") - assert len(mailoutbox) == 1 - assert mailoutbox[0].to == [recipient.email] - - @pytest.mark.django_db(transaction=True) - def test_is_bookmarked(self, mailoutbox, recipient): - subscription = SubscriptionFactory( - subscriber=recipient.user, - ) - episode = EpisodeFactory( - podcast=subscription.podcast, - pub_date=timezone.now() - timedelta(days=1), - ) - BookmarkFactory(episode=episode, user=recipient.user) - call_command("send_episode_notifications") - assert len(mailoutbox) == 0 - - @pytest.mark.django_db(transaction=True) - def test_no_new_episodes(self, mailoutbox, recipient): - subscription = SubscriptionFactory( - subscriber=recipient.user, - ) - EpisodeFactory.create_batch( - 3, - podcast=subscription.podcast, - pub_date=timezone.now() - timedelta(days=10), - ) - call_command("send_episode_notifications") - assert len(mailoutbox) == 0 - - @pytest.mark.django_db(transaction=True) - def test_listened(self, mailoutbox, recipient): - subscription = SubscriptionFactory( - subscriber=recipient.user, - ) - episode = EpisodeFactory( - podcast=subscription.podcast, - pub_date=timezone.now() - timedelta(days=1), - ) - AudioLogFactory( - episode=episode, - user=recipient.user, - ) - call_command("send_episode_notifications") - assert len(mailoutbox) == 0 diff --git a/simplecasts/episodes/tests/test_middleware.py b/simplecasts/episodes/tests/test_middleware.py deleted file mode 100644 index c4d7ddd1c3..0000000000 --- a/simplecasts/episodes/tests/test_middleware.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from simplecasts.episodes.middleware import PlayerDetails, PlayerMiddleware - - -class TestPlayerMiddleware: - def test_middleware(self, rf, get_response): - req = rf.get("/") - PlayerMiddleware(get_response)(req) - assert req.player - - -class TestPlayerDetails: - episode_id = 12345 - - @pytest.fixture - def req(self, rf): - req = rf.get("/") - req.session = {} - return req - - @pytest.fixture - def player(self, req): - return PlayerDetails(request=req) - - def test_get_if_none(self, player): - assert player.get() is None - - def test_get_if_not_none(self, player): - player.set(self.episode_id) - assert player.get() == self.episode_id - - def test_pop_if_none(self, player): - assert player.pop() is None - - def test_pop_if_not_none(self, player): - player.set(self.episode_id) - - assert player.pop() == self.episode_id - assert player.get() is None - - def test_has_false(self, player): - assert not player.has(self.episode_id) - - def test_has_true(self, player): - player.set(self.episode_id) - assert player.has(self.episode_id) diff --git a/simplecasts/episodes/tests/test_templatetags.py b/simplecasts/episodes/tests/test_templatetags.py deleted file mode 100644 index dfb13296bf..0000000000 --- a/simplecasts/episodes/tests/test_templatetags.py +++ /dev/null @@ -1,97 +0,0 @@ -import pytest - -from simplecasts.episodes.middleware import PlayerDetails -from simplecasts.episodes.templatetags.episodes import ( - audio_player, - format_duration, - get_media_metadata, -) -from simplecasts.request import RequestContext - - -class TestFormatDuration: - @pytest.mark.parametrize( - ("duration", "expected"), - [ - pytest.param(0, "", id="zero"), - pytest.param(30, "", id="30 seconds"), - pytest.param(60, "1\xa0minute", id="1 minute"), - pytest.param(61, "1\xa0minute", id="just over 1 minute"), - pytest.param(90, "1\xa0minute", id="1 minute 30 seconds"), - pytest.param(540, "9\xa0minutes", id="9 minutes"), - pytest.param(2400, "40\xa0minutes", id="40 minutes"), - pytest.param(3600, "1\xa0hour", id="1 hour"), - pytest.param(9000, "2\xa0hours, 30\xa0minutes", id="2 hours 30 minutes"), - ], - ) - def test_format_duration(self, duration, expected): - assert format_duration(duration) == expected - - -class TestGetMediaMetadata: - @pytest.mark.django_db - def test_get_media_metadata(self, rf, episode): - req = rf.get("/") - context = RequestContext(request=req) - assert get_media_metadata(context, episode) - - -class TestAudioPlayer: - @pytest.mark.django_db - def test_close(self, rf, audio_log): - req = rf.get("/") - req.user = audio_log.user - req.player = PlayerDetails(request=req) - req.session = {req.player.session_id: audio_log.episode_id} - - context = RequestContext(request=req) - - dct = audio_player(context, audio_log, action="close") - assert "audio_log" not in dct - - @pytest.mark.django_db - def test_play(self, rf, audio_log): - req = rf.get("/") - req.user = audio_log.user - - context = RequestContext(request=req) - - dct = audio_player(context, audio_log, action="play") - assert dct["audio_log"] == audio_log - - @pytest.mark.django_db - def test_load(self, rf, audio_log): - req = rf.get("/") - req.user = audio_log.user - req.player = PlayerDetails(request=req) - req.session = {req.player.session_id: audio_log.episode_id} - - context = RequestContext(request=req) - - dct = audio_player(context, None, action="load") - assert dct["audio_log"] == audio_log - - @pytest.mark.django_db - def test_load_empty(self, rf, audio_log): - req = rf.get("/") - req.user = audio_log.user - req.player = PlayerDetails(request=req) - req.session = {} - - context = RequestContext(request=req) - - dct = audio_player(context, None, action="load") - assert dct["audio_log"] is None - - @pytest.mark.django_db - def test_load_user_not_authenticated(self, rf, audio_log, anonymous_user): - req = rf.get("/") - req.user = anonymous_user - req.player = PlayerDetails(request=req) - req.session = {} - req.session = {req.player.session_id: audio_log.episode_id} - - context = RequestContext(request=req) - - dct = audio_player(context, None, action="load") - assert dct["audio_log"] is None diff --git a/simplecasts/episodes/tests/test_views.py b/simplecasts/episodes/tests/test_views.py deleted file mode 100644 index 15bd045d64..0000000000 --- a/simplecasts/episodes/tests/test_views.py +++ /dev/null @@ -1,578 +0,0 @@ -import json -from datetime import timedelta - -import pytest -from django.urls import reverse, reverse_lazy -from django.utils import timezone -from pytest_django.asserts import assertContains, assertNotContains, assertTemplateUsed - -from simplecasts.episodes.middleware import PlayerDetails -from simplecasts.episodes.models import AudioLog, Bookmark -from simplecasts.episodes.tests.factories import ( - AudioLogFactory, - BookmarkFactory, - EpisodeFactory, -) -from simplecasts.podcasts.tests.factories import PodcastFactory, SubscriptionFactory -from simplecasts.tests.asserts import ( - assert200, - assert204, - assert400, - assert401, - assert404, - assert409, -) - -_index_url = reverse_lazy("episodes:index") - - -class TestIndex: - @pytest.mark.django_db - def test_no_episodes(self, client, auth_user): - response = client.get(_index_url) - assert200(response) - assertTemplateUsed(response, "episodes/index.html") - assert len(response.context["episodes"]) == 0 - - @pytest.mark.django_db - def test_has_no_subscriptions(self, client, auth_user): - EpisodeFactory.create_batch(3) - response = client.get(_index_url) - - assert200(response) - assertTemplateUsed(response, "episodes/index.html") - assert len(response.context["episodes"]) == 0 - - @pytest.mark.django_db - def test_has_subscriptions(self, client, auth_user): - episode = EpisodeFactory() - SubscriptionFactory(subscriber=auth_user, podcast=episode.podcast) - - response = client.get(_index_url) - - assert200(response) - assertTemplateUsed(response, "episodes/index.html") - assert len(response.context["episodes"]) == 1 - - -class TestSearchEpisodes: - url = reverse_lazy("episodes:search_episodes") - - @pytest.mark.django_db - def test_search(self, auth_user, client, faker): - EpisodeFactory.create_batch(3, title="zzzz") - episode = EpisodeFactory(title=faker.unique.name()) - response = client.get(self.url, {"search": episode.title}) - assert200(response) - assertTemplateUsed(response, "episodes/search.html") - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == episode - - @pytest.mark.django_db - def test_search_no_results(self, auth_user, client): - response = client.get(self.url, {"search": "zzzz"}) - assert200(response) - assertTemplateUsed(response, "episodes/search.html") - assert len(response.context["page"].object_list) == 0 - - @pytest.mark.django_db - def test_search_value_empty(self, auth_user, client): - response = client.get(self.url, {"search": ""}) - assert response.url == _index_url - - -class TestEpisodeDetail: - @pytest.fixture - def episode(self, faker): - return EpisodeFactory( - podcast=PodcastFactory( - owner=faker.name(), - website=faker.url(), - funding_url=faker.url(), - funding_text=faker.text(), - explicit=True, - ), - episode_type="full", - file_size=9000, - duration="3:30:30", - ) - - @pytest.mark.django_db - def test_ok(self, client, auth_user, episode): - response = client.get(episode.get_absolute_url()) - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - - @pytest.mark.django_db - def test_listened(self, client, auth_user, episode): - AudioLogFactory( - episode=episode, - user=auth_user, - current_time=900, - listened=timezone.now(), - ) - - response = client.get(episode.get_absolute_url()) - - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - - assertContains(response, "Remove episode from your History") - assertContains(response, "Listened") - - @pytest.mark.django_db - def test_no_prev_next_episode(self, client, auth_user, episode): - response = client.get(episode.get_absolute_url()) - - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - assertNotContains(response, "No More Episodes") - - @pytest.mark.django_db - def test_no_next_episode(self, client, auth_user, episode): - EpisodeFactory( - podcast=episode.podcast, - pub_date=episode.pub_date - timedelta(days=30), - ) - response = client.get(episode.get_absolute_url()) - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - assertContains(response, "Last Episode") - - @pytest.mark.django_db - def test_no_previous_episode(self, client, auth_user, episode): - EpisodeFactory( - podcast=episode.podcast, - pub_date=episode.pub_date + timedelta(days=30), - ) - response = client.get(episode.get_absolute_url()) - assert200(response) - assertTemplateUsed(response, "episodes/detail.html") - assert response.context["episode"] == episode - assertContains(response, "First Episode") - - -class TestStartPlayer: - @pytest.mark.django_db - def test_play_from_start(self, client, auth_user, episode): - response = client.post( - self.url(episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - assert200(response) - assertContains(response, 'id="audio-player-button"') - - assert AudioLog.objects.filter(user=auth_user, episode=episode).exists() - assert client.session[PlayerDetails.session_id] == episode.pk - - @pytest.mark.django_db - def test_another_episode_in_player(self, client, auth_user, player_episode): - episode = EpisodeFactory() - response = client.post( - self.url(episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - - assert200(response) - assertContains(response, 'id="audio-player-button"') - - assert AudioLog.objects.filter(user=auth_user, episode=episode).exists() - - assert client.session[PlayerDetails.session_id] == episode.pk - - @pytest.mark.django_db - def test_resume(self, client, auth_user, player_episode): - response = client.post( - self.url(player_episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - - assert200(response) - assertContains(response, 'id="audio-player-button"') - - assert client.session[PlayerDetails.session_id] == player_episode.pk - - def url(self, episode): - return reverse("episodes:start_player", args=[episode.pk]) - - -class TestClosePlayer: - url = reverse_lazy("episodes:close_player") - - @pytest.mark.django_db - def test_player_empty(self, client, auth_user, episode): - response = client.post( - self.url, - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - - assert204(response) - - @pytest.mark.django_db - def test_close( - self, - client, - player_episode, - ): - response = client.post( - self.url, - headers={ - "HX-Request": "true", - "HX-Target": "audio-player-button", - }, - ) - - assert200(response) - assertContains(response, 'id="audio-player-button"') - - assert player_episode.pk not in client.session - - -class TestBookmarks: - url = reverse_lazy("episodes:bookmarks") - - @pytest.mark.django_db - def test_get(self, client, auth_user): - BookmarkFactory.create_batch(33, user=auth_user) - - response = client.get(self.url) - - assert200(response) - assertTemplateUsed(response, "episodes/bookmarks.html") - - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_ascending(self, client, auth_user): - BookmarkFactory.create_batch(33, user=auth_user) - - response = client.get(self.url, {"order": "asc"}) - - assert200(response) - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(self.url) - assert200(response) - - @pytest.mark.django_db - def test_search(self, client, auth_user): - podcast = PodcastFactory(title="zzzz") - - for _ in range(3): - BookmarkFactory( - user=auth_user, - episode=EpisodeFactory(title="zzzz", podcast=podcast), - ) - - BookmarkFactory(user=auth_user, episode=EpisodeFactory(title="testing")) - - response = client.get(self.url, {"search": "testing"}) - - assert200(response) - assertTemplateUsed(response, "episodes/bookmarks.html") - - assert len(response.context["page"].object_list) == 1 - - -class TestAddBookmark: - @pytest.mark.django_db - def test_post(self, client, auth_user, episode): - response = client.post(self.url(episode), headers={"HX-Request": "true"}) - - assert200(response) - assert Bookmark.objects.filter(user=auth_user, episode=episode).exists() - - @pytest.mark.django_db()(transaction=True) - def test_already_bookmarked(self, client, auth_user, episode): - BookmarkFactory(episode=episode, user=auth_user) - - response = client.post(self.url(episode), headers={"HX-Request": "true"}) - assert409(response) - - assert Bookmark.objects.filter(user=auth_user, episode=episode).exists() - - def url(self, episode): - return reverse("episodes:add_bookmark", args=[episode.pk]) - - -class TestRemoveBookmark: - @pytest.mark.django_db - def test_post(self, client, auth_user, episode): - BookmarkFactory(user=auth_user, episode=episode) - response = client.delete( - reverse("episodes:remove_bookmark", args=[episode.pk]), - headers={"HX-Request": "true"}, - ) - assert200(response) - - assert not Bookmark.objects.filter(user=auth_user, episode=episode).exists() - - -class TestHistory: - url = reverse_lazy("episodes:history") - - @pytest.mark.django_db - def test_get(self, client, auth_user): - AudioLogFactory.create_batch(33, user=auth_user) - response = client.get(self.url) - assert200(response) - assertTemplateUsed(response, "episodes/history.html") - - assert200(response) - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(self.url) - assert200(response) - - @pytest.mark.django_db - def test_ascending(self, client, auth_user): - AudioLogFactory.create_batch(33, user=auth_user) - - response = client.get(self.url, {"order": "asc"}) - assert200(response) - - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_search(self, client, auth_user): - podcast = PodcastFactory(title="zzzz") - - for _ in range(3): - AudioLogFactory( - user=auth_user, - episode=EpisodeFactory(title="zzzz", podcast=podcast), - ) - - AudioLogFactory(user=auth_user, episode=EpisodeFactory(title="testing")) - response = client.get(self.url, {"search": "testing"}) - - assert200(response) - assert len(response.context["page"].object_list) == 1 - - -class TestMarkAudioLogComplete: - @pytest.mark.django_db - def test_ok(self, client, auth_user, episode): - audio_log = AudioLogFactory(user=auth_user, episode=episode, current_time=300) - - response = client.post( - self.url(episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - - assert200(response) - - audio_log.refresh_from_db() - assert audio_log.current_time == 0 - - @pytest.mark.django_db - def test_is_playing(self, client, auth_user, player_episode): - """Do not mark complete if episode is currently playing""" - - response = client.post( - self.url(player_episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - - assert404(response) - - assert AudioLog.objects.filter(user=auth_user, episode=player_episode).exists() - - def url(self, episode): - return reverse("episodes:mark_audio_log_complete", args=[episode.pk]) - - -class TestRemoveAudioLog: - @pytest.mark.django_db - def test_ok(self, client, auth_user, episode): - AudioLogFactory(user=auth_user, episode=episode) - AudioLogFactory(user=auth_user) - - response = client.delete( - self.url(episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - - assert200(response) - - assert not AudioLog.objects.filter(user=auth_user, episode=episode).exists() - assert AudioLog.objects.filter(user=auth_user).count() == 1 - - @pytest.mark.django_db - def test_is_playing(self, client, auth_user, player_episode): - """Do not remove log if episode is currently playing""" - - response = client.delete( - self.url(player_episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - - assert404(response) - assert AudioLog.objects.filter(user=auth_user, episode=player_episode).exists() - - @pytest.mark.django_db - def test_none_remaining(self, client, auth_user, episode): - log = AudioLogFactory(user=auth_user, episode=episode) - - response = client.delete( - self.url(log.episode), - headers={ - "HX-Request": "true", - "HX-Target": "audio-log", - }, - ) - assert200(response) - - assert not AudioLog.objects.filter(user=auth_user, episode=episode).exists() - assert AudioLog.objects.filter(user=auth_user).count() == 0 - - def url(self, episode): - return reverse("episodes:remove_audio_log", args=[episode.pk]) - - -class TestPlayerTimeUpdate: - url = reverse_lazy("episodes:player_time_update") - - @pytest.mark.django_db - def test_is_running(self, client, player_episode): - response = client.post( - self.url, - json.dumps( - { - "current_time": 1030, - "duration": 3600, - } - ), - content_type="application/json", - ) - - assert200(response) - - log = AudioLog.objects.first() - assert log is not None - - assert log.current_time == 1030 - - @pytest.mark.django_db - def test_player_log_missing(self, client, auth_user, episode): - session = client.session - session[PlayerDetails.session_id] = episode.pk - session.save() - - response = client.post( - self.url, - json.dumps( - { - "current_time": 1030, - "duration": 3600, - } - ), - content_type="application/json", - ) - - assert200(response) - - log = AudioLog.objects.get() - - assert log.current_time == 1030 - assert log.episode == episode - - @pytest.mark.django_db - def test_player_not_in_session(self, client, auth_user, episode): - response = client.post( - self.url, - json.dumps( - { - "current_time": 1030, - "duration": 3600, - } - ), - content_type="application/json", - ) - - assert400(response) - - assert not AudioLog.objects.exists() - - @pytest.mark.django_db - def test_missing_data(self, client, auth_user, player_episode): - response = client.post(self.url) - assert400(response) - - @pytest.mark.django_db - def test_invalid_data(self, client, auth_user, player_episode): - response = client.post( - self.url, - json.dumps( - { - "current_time": "xyz", - "duration": "abc", - } - ), - content_type="application/json", - ) - assert400(response) - - @pytest.mark.django_db()(transaction=True) - def test_episode_does_not_exist(self, client, auth_user): - session = client.session - session[PlayerDetails.session_id] = 12345 - session.save() - - response = client.post( - self.url, - json.dumps( - { - "current_time": 1000, - "duration": 3600, - } - ), - content_type="application/json", - ) - assert409(response) - - @pytest.mark.django_db - def test_user_not_authenticated(self, client): - response = client.post( - self.url, - json.dumps( - { - "current_time": 1000, - "duration": 3600, - } - ), - content_type="application/json", - ) - assert401(response) diff --git a/simplecasts/episodes/urls.py b/simplecasts/episodes/urls.py deleted file mode 100644 index 156d363368..0000000000 --- a/simplecasts/episodes/urls.py +++ /dev/null @@ -1,53 +0,0 @@ -from django.urls import path - -from simplecasts.episodes import views - -app_name = "episodes" - - -urlpatterns = [ - path("new/", views.index, name="index"), - path("search/episodes/", views.search_episodes, name="search_episodes"), - path( - "episodes/-/", - views.episode_detail, - name="episode_detail", - ), - path( - "player/start//", - views.start_player, - name="start_player", - ), - path( - "player/close/", - views.close_player, - name="close_player", - ), - path( - "player/time-update/", - views.player_time_update, - name="player_time_update", - ), - path("history/", views.history, name="history"), - path( - "history//complete/", - views.mark_audio_log_complete, - name="mark_audio_log_complete", - ), - path( - "history//remove/", - views.remove_audio_log, - name="remove_audio_log", - ), - path("bookmarks/", views.bookmarks, name="bookmarks"), - path( - "bookmarks//add/", - views.add_bookmark, - name="add_bookmark", - ), - path( - "bookmarks//remove/", - views.remove_bookmark, - name="remove_bookmark", - ), -] diff --git a/simplecasts/episodes/views.py b/simplecasts/episodes/views.py deleted file mode 100644 index 694e698c39..0000000000 --- a/simplecasts/episodes/views.py +++ /dev/null @@ -1,396 +0,0 @@ -import http -from typing import Literal, TypedDict - -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.db import IntegrityError -from django.db.models import OuterRef, Subquery -from django.http import Http404, JsonResponse -from django.shortcuts import get_object_or_404, redirect -from django.template.response import TemplateResponse -from django.utils import timezone -from django.views.decorators.http import require_POST, require_safe -from pydantic import BaseModel, ValidationError - -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.http import require_DELETE -from simplecasts.paginator import render_paginated_response -from simplecasts.podcasts.models import Podcast -from simplecasts.request import ( - AuthenticatedHttpRequest, - HttpRequest, - is_authenticated_request, -) -from simplecasts.response import ( - HttpResponseConflict, - HttpResponseNoContent, - RenderOrRedirectResponse, -) -from simplecasts.search import search_queryset - -PlayerAction = Literal["load", "play", "close"] - - -class PlayerUpdate(BaseModel): - """Data model for player time update.""" - - current_time: int - duration: int - - -class PlayerUpdateError(TypedDict): - """Data model for player error response.""" - - error: str - - -@require_safe -@login_required -def index(request: AuthenticatedHttpRequest) -> TemplateResponse: - """List latest episodes from subscriptions.""" - - latest_episodes = ( - Podcast.objects.subscribed(request.user) - .annotate( - latest_episode=Subquery( - Episode.objects.filter(podcast_id=OuterRef("pk")) - .order_by("-pub_date", "-pk") - .values("pk")[:1] - ) - ) - .filter(latest_episode__isnull=False) - .order_by("-pub_date") - .values_list("latest_episode", flat=True)[: settings.DEFAULT_PAGE_SIZE] - ) - - episodes = ( - Episode.objects.filter(pk__in=latest_episodes) - .select_related("podcast") - .order_by("-pub_date", "-pk") - ) - - return TemplateResponse( - request, - "episodes/index.html", - { - "episodes": episodes, - }, - ) - - -@require_safe -@login_required -def search_episodes(request: HttpRequest) -> RenderOrRedirectResponse: - """Search any episodes in the database.""" - - if request.search: - episodes = ( - search_queryset( - Episode.objects.filter(podcast__private=False), - request.search.value, - "search_vector", - ) - .select_related("podcast") - .order_by("-rank", "-pub_date") - ) - - return render_paginated_response(request, "episodes/search.html", episodes) - - return redirect("episodes:index") - - -@require_safe -@login_required -def episode_detail( - request: AuthenticatedHttpRequest, - episode_id: int, - slug: str | None = None, -) -> TemplateResponse: - """Renders episode detail.""" - episode = get_object_or_404( - Episode.objects.select_related("podcast"), - pk=episode_id, - ) - - audio_log = request.user.audio_logs.filter(episode=episode).first() - - is_bookmarked = request.user.bookmarks.filter(episode=episode).exists() - is_playing = request.player.has(episode.pk) - - return TemplateResponse( - request, - "episodes/detail.html", - { - "episode": episode, - "audio_log": audio_log, - "is_bookmarked": is_bookmarked, - "is_playing": is_playing, - }, - ) - - -@require_POST -@login_required -def start_player( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse: - """Starts player. Creates new audio log if required.""" - episode = get_object_or_404( - Episode.objects.select_related("podcast"), - pk=episode_id, - ) - - audio_log, _ = request.user.audio_logs.update_or_create( - episode=episode, - defaults={ - "listened": timezone.now(), - }, - ) - - request.player.set(episode.pk) - - return _render_player_action(request, audio_log, action="play") - - -@require_POST -@login_required -def close_player( - request: AuthenticatedHttpRequest, -) -> TemplateResponse | HttpResponseNoContent: - """Closes audio player.""" - if episode_id := request.player.pop(): - audio_log = get_object_or_404( - request.user.audio_logs.select_related("episode"), - episode__pk=episode_id, - ) - return _render_player_action(request, audio_log, action="close") - return HttpResponseNoContent() - - -@require_POST -def player_time_update(request: HttpRequest) -> JsonResponse: - """Handles player time update AJAX requests.""" - - if not is_authenticated_request(request): - return JsonResponse( - PlayerUpdateError(error="Authentication required"), - status=http.HTTPStatus.UNAUTHORIZED, - ) - - episode_id = request.player.get() - - if episode_id is None: - return JsonResponse( - PlayerUpdateError(error="No episode in player"), - status=http.HTTPStatus.BAD_REQUEST, - ) - - try: - update = PlayerUpdate.model_validate_json(request.body) - except ValidationError as exc: - return JsonResponse( - PlayerUpdateError(error=exc.json()), - status=http.HTTPStatus.BAD_REQUEST, - ) - - try: - request.user.audio_logs.update_or_create( - episode_id=episode_id, - defaults={ - "listened": timezone.now(), - "current_time": update.current_time, - "duration": update.duration, - }, - ) - - except IntegrityError: - return JsonResponse( - PlayerUpdateError(error="Update cannot be saved"), - status=http.HTTPStatus.CONFLICT, - ) - - return JsonResponse(update.model_dump()) - - -@require_safe -@login_required -def history(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Renders user's listening history. User can also search history.""" - audio_logs = request.user.audio_logs.select_related("episode", "episode__podcast") - - ordering = request.GET.get("order", "desc") - order_by = "listened" if ordering == "asc" else "-listened" - - if request.search: - audio_logs = search_queryset( - audio_logs, - request.search.value, - "episode__search_vector", - "episode__podcast__search_vector", - ).order_by("-rank", order_by) - else: - audio_logs = audio_logs.order_by(order_by) - - return render_paginated_response( - request, - "episodes/history.html", - audio_logs, - { - "ordering": ordering, - }, - ) - - -@require_POST -@login_required -def mark_audio_log_complete( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse: - """Marks audio log complete.""" - - if request.player.has(episode_id): - raise Http404 - - audio_log = get_object_or_404( - request.user.audio_logs.select_related("episode"), - episode__pk=episode_id, - ) - - audio_log.current_time = 0 - audio_log.save() - - messages.success(request, "Episode marked complete") - - return _render_audio_log_action(request, audio_log, show_audio_log=True) - - -@require_DELETE -@login_required -def remove_audio_log( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse: - """Removes audio log from user history and returns HTMX snippet.""" - # cannot remove episode if in player - if request.player.has(episode_id): - raise Http404 - - audio_log = get_object_or_404( - request.user.audio_logs.select_related("episode"), - episode__pk=episode_id, - ) - - audio_log.delete() - - messages.info(request, "Removed from History") - - return _render_audio_log_action(request, audio_log, show_audio_log=False) - - -@require_safe -@login_required -def bookmarks(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Renders user's bookmarks. User can also search their bookmarks.""" - bookmarks = request.user.bookmarks.select_related("episode", "episode__podcast") - - ordering = request.GET.get("order", "desc") - order_by = "created" if ordering == "asc" else "-created" - - if request.search: - bookmarks = search_queryset( - bookmarks, - request.search.value, - "episode__search_vector", - "episode__podcast__search_vector", - ).order_by("-rank", order_by) - else: - bookmarks = bookmarks.order_by(order_by) - - return render_paginated_response( - request, - "episodes/bookmarks.html", - bookmarks, - { - "ordering": ordering, - }, - ) - - -@require_POST -@login_required -def add_bookmark( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse | HttpResponseConflict: - """Add episode to bookmarks.""" - episode = get_object_or_404(Episode, pk=episode_id) - - try: - request.user.bookmarks.create(episode=episode) - except IntegrityError: - return HttpResponseConflict() - - messages.success(request, "Added to Bookmarks") - - return _render_bookmark_action(request, episode, is_bookmarked=True) - - -@require_DELETE -@login_required -def remove_bookmark( - request: AuthenticatedHttpRequest, episode_id: int -) -> TemplateResponse: - """Remove episode from bookmarks.""" - episode = get_object_or_404(Episode, pk=episode_id) - request.user.bookmarks.filter(episode=episode).delete() - - messages.info(request, "Removed from Bookmarks") - - return _render_bookmark_action(request, episode, is_bookmarked=False) - - -def _render_player_action( - request: HttpRequest, - audio_log: AudioLog, - *, - action: PlayerAction, -) -> TemplateResponse: - return TemplateResponse( - request, - "episodes/detail.html#audio_player_button", - { - "action": action, - "audio_log": audio_log, - "episode": audio_log.episode, - "is_playing": action == "play", - }, - ) - - -def _render_bookmark_action( - request: AuthenticatedHttpRequest, - episode: Episode, - *, - is_bookmarked: bool, -) -> TemplateResponse: - return TemplateResponse( - request, - "episodes/detail.html#bookmark_button", - { - "episode": episode, - "is_bookmarked": is_bookmarked, - }, - ) - - -def _render_audio_log_action( - request: AuthenticatedHttpRequest, - audio_log: AudioLog, - *, - show_audio_log: bool, -) -> TemplateResponse: - context = {"episode": audio_log.episode} - - if show_audio_log: - context["audio_log"] = audio_log - - return TemplateResponse(request, "episodes/detail.html#audio_log", context) diff --git a/simplecasts/users/forms.py b/simplecasts/forms.py similarity index 79% rename from simplecasts/users/forms.py rename to simplecasts/forms.py index ac56d04b4d..b5a6290f8e 100644 --- a/simplecasts/users/forms.py +++ b/simplecasts/forms.py @@ -2,7 +2,19 @@ from django import forms -from simplecasts.users.models import User +from simplecasts.models import Podcast, User + + +class PodcastForm(forms.ModelForm): + """Form to add a new podcast feed.""" + + class Meta: + model = Podcast + fields: ClassVar[list] = ["rss"] + labels: ClassVar[dict] = {"rss": "RSS Feed URL"} + error_messages: ClassVar[dict] = { + "rss": {"unique": "This podcast is not available"} + } class UserPreferencesForm(forms.ModelForm): diff --git a/simplecasts/episodes/__init__.py b/simplecasts/http/__init__.py similarity index 100% rename from simplecasts/episodes/__init__.py rename to simplecasts/http/__init__.py diff --git a/simplecasts/http.py b/simplecasts/http/decorators.py similarity index 100% rename from simplecasts/http.py rename to simplecasts/http/decorators.py diff --git a/simplecasts/request.py b/simplecasts/http/request.py similarity index 86% rename from simplecasts/request.py rename to simplecasts/http/request.py index c682f539e8..2139b75c96 100644 --- a/simplecasts/request.py +++ b/simplecasts/http/request.py @@ -7,9 +7,8 @@ from django.contrib.auth.models import AnonymousUser from django_htmx.middleware import HtmxDetails - from simplecasts.episodes.middleware import PlayerDetails - from simplecasts.middleware import SearchDetails - from simplecasts.users.models import User + from simplecasts.middleware import PlayerDetails, SearchDetails + from simplecasts.models import User class HttpRequest(DjangoHttpRequest): diff --git a/simplecasts/response.py b/simplecasts/http/response.py similarity index 100% rename from simplecasts/response.py rename to simplecasts/http/response.py diff --git a/simplecasts/episodes/management/__init__.py b/simplecasts/management/__init__.py similarity index 100% rename from simplecasts/episodes/management/__init__.py rename to simplecasts/management/__init__.py diff --git a/simplecasts/episodes/management/commands/__init__.py b/simplecasts/management/commands/__init__.py similarity index 100% rename from simplecasts/episodes/management/commands/__init__.py rename to simplecasts/management/commands/__init__.py diff --git a/simplecasts/podcasts/management/commands/create_podcast_recommendations.py b/simplecasts/management/commands/create_podcast_recommendations.py similarity index 83% rename from simplecasts/podcasts/management/commands/create_podcast_recommendations.py rename to simplecasts/management/commands/create_podcast_recommendations.py index 05899b604e..ec28463410 100644 --- a/simplecasts/podcasts/management/commands/create_podcast_recommendations.py +++ b/simplecasts/management/commands/create_podcast_recommendations.py @@ -1,9 +1,9 @@ from django.core.management.base import BaseCommand from django.db.models.functions import Lower -from simplecasts.podcasts import recommender, tokenizer -from simplecasts.podcasts.models import Podcast -from simplecasts.thread_pool import db_threadsafe, thread_pool_map +from simplecasts.models import Podcast +from simplecasts.services import recommender, tokenizer +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map class Command(BaseCommand): diff --git a/simplecasts/podcasts/management/commands/fetch_itunes_feeds.py b/simplecasts/management/commands/fetch_itunes_feeds.py similarity index 94% rename from simplecasts/podcasts/management/commands/fetch_itunes_feeds.py rename to simplecasts/management/commands/fetch_itunes_feeds.py index 456dc4c811..5e9b6124ff 100644 --- a/simplecasts/podcasts/management/commands/fetch_itunes_feeds.py +++ b/simplecasts/management/commands/fetch_itunes_feeds.py @@ -6,10 +6,10 @@ from django.core.management import CommandError, CommandParser from django.core.management.base import BaseCommand -from simplecasts.http_client import get_client -from simplecasts.podcasts import itunes -from simplecasts.podcasts.models import Category -from simplecasts.thread_pool import db_threadsafe, thread_pool_map +from simplecasts.models import Category +from simplecasts.services import itunes +from simplecasts.services.http_client import get_client +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map @dataclasses.dataclass(kw_only=True, frozen=True) diff --git a/simplecasts/podcasts/management/commands/parse_podcast_feeds.py b/simplecasts/management/commands/parse_podcast_feeds.py similarity index 88% rename from simplecasts/podcasts/management/commands/parse_podcast_feeds.py rename to simplecasts/management/commands/parse_podcast_feeds.py index 0e79da2d2f..89c6904779 100644 --- a/simplecasts/podcasts/management/commands/parse_podcast_feeds.py +++ b/simplecasts/management/commands/parse_podcast_feeds.py @@ -3,10 +3,10 @@ from django.core.management.base import BaseCommand, CommandParser from django.db.models import Case, Count, IntegerField, When -from simplecasts.http_client import get_client -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.feed_parser import parse_feed -from simplecasts.thread_pool import db_threadsafe, thread_pool_map +from simplecasts.models import Podcast +from simplecasts.services.feed_parser import parse_feed +from simplecasts.services.http_client import get_client +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map @dataclasses.dataclass(frozen=True, kw_only=True) diff --git a/simplecasts/episodes/management/commands/send_episode_notifications.py b/simplecasts/management/commands/send_episode_notifications.py similarity index 92% rename from simplecasts/episodes/management/commands/send_episode_notifications.py rename to simplecasts/management/commands/send_episode_notifications.py index 6c03c79113..56a24eaeb4 100644 --- a/simplecasts/episodes/management/commands/send_episode_notifications.py +++ b/simplecasts/management/commands/send_episode_notifications.py @@ -8,10 +8,9 @@ from django.db.models import Exists, OuterRef, QuerySet from django.utils import timezone -from simplecasts.episodes.models import Episode -from simplecasts.thread_pool import db_threadsafe, thread_pool_map -from simplecasts.users.models import User -from simplecasts.users.notifications import get_recipients, send_notification_email +from simplecasts.models import Episode, User +from simplecasts.services.notifications import get_recipients, send_notification_email +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map class Command(BaseCommand): @@ -53,7 +52,7 @@ def _worker(recipient: EmailAddress) -> tuple[EmailAddress, bool]: site, recipient, f"Hi, {recipient.user.name}, check out these new podcast episodes!", - "episodes/emails/notifications.html", + "emails/episode_notifications.html", { "episodes": episodes, }, diff --git a/simplecasts/podcasts/management/commands/send_podcast_recommendations.py b/simplecasts/management/commands/send_podcast_recommendations.py similarity index 87% rename from simplecasts/podcasts/management/commands/send_podcast_recommendations.py rename to simplecasts/management/commands/send_podcast_recommendations.py index 540a1e2e9d..2facea0edc 100644 --- a/simplecasts/podcasts/management/commands/send_podcast_recommendations.py +++ b/simplecasts/management/commands/send_podcast_recommendations.py @@ -3,9 +3,9 @@ from django.core.mail import get_connection from django.core.management.base import BaseCommand, CommandParser -from simplecasts.podcasts.models import Podcast -from simplecasts.thread_pool import db_threadsafe, thread_pool_map -from simplecasts.users.notifications import get_recipients, send_notification_email +from simplecasts.models import Podcast +from simplecasts.services.notifications import get_recipients, send_notification_email +from simplecasts.services.thread_pool import db_threadsafe, thread_pool_map class Command(BaseCommand): @@ -40,7 +40,7 @@ def _worker(recipient: EmailAddress) -> tuple[EmailAddress, bool]: site, recipient, f"Hi, {recipient.user.name}, here are some podcasts you might like!", - "podcasts/emails/recommendations.html", + "emails/podcast_recommendations.html", { "podcasts": podcasts, }, diff --git a/simplecasts/middleware.py b/simplecasts/middleware.py index 2e720697a2..9046fecb96 100644 --- a/simplecasts/middleware.py +++ b/simplecasts/middleware.py @@ -10,7 +10,7 @@ from django.utils.functional import cached_property from django_htmx.http import HttpResponseLocation -from simplecasts.request import HttpRequest +from simplecasts.http.request import HttpRequest @dataclasses.dataclass(frozen=True, kw_only=False) @@ -129,3 +129,37 @@ def qs(self) -> str: if self else "" ) + + +class PlayerMiddleware(BaseMiddleware): + """Adds `PlayerDetails` instance to request as `request.player`.""" + + def __call__(self, request: HttpRequest) -> HttpResponse: + """Middleware implementation.""" + request.player = PlayerDetails(request=request) + return self.get_response(request) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class PlayerDetails: + """Tracks current player episode in session.""" + + request: HttpRequest + session_id: str = "audio-player" + + def get(self) -> int | None: + """Returns primary key of episode in player, if any in session.""" + return self.request.session.get(self.session_id) + + def has(self, episode_id: int) -> bool: + """Checks if episode matching ID is in player.""" + return self.get() == episode_id + + def set(self, episode_id: int) -> None: + """Adds episode PK to player in session.""" + self.request.session[self.session_id] = episode_id + + def pop(self) -> int | None: + """Returns primary key of episode in player, if any in session, and removes + the episode ID from the session.""" + return self.request.session.pop(self.session_id, None) diff --git a/simplecasts/migrations/0001_initial.py b/simplecasts/migrations/0001_initial.py new file mode 100644 index 0000000000..f563232f55 --- /dev/null +++ b/simplecasts/migrations/0001_initial.py @@ -0,0 +1,706 @@ +# Generated by Django 6.0 on 2026-01-04 13:36 + +import datetime + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.contrib.postgres.indexes +import django.contrib.postgres.search +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ("slug", models.SlugField(unique=True)), + ("itunes_genre_id", models.PositiveIntegerField(blank=True, null=True)), + ], + options={ + "verbose_name_plural": "categories", + "ordering": ("name",), + }, + ), + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ("send_email_notifications", models.BooleanField(default=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="Podcast", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rss", + models.URLField( + max_length=2083, + unique=True, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Inactive podcasts will no longer be updated from their RSS feeds.", + ), + ), + ( + "private", + models.BooleanField( + default=False, help_text="Only available to subscribers" + ), + ), + ("etag", models.TextField(blank=True)), + ("title", models.TextField(blank=True)), + ("pub_date", models.DateTimeField(blank=True, null=True)), + ("num_episodes", models.PositiveIntegerField(default=0)), + ("parsed", models.DateTimeField(blank=True, null=True)), + ( + "feed_status", + models.CharField( + blank=True, + choices=[ + ("success", "Success"), + ("not_modified", "Not Modified"), + ("database_error", "Database Error"), + ("discontinued", "Discontinued"), + ("duplicate", "Duplicate"), + ("invalid_rss", "Invalid RSS"), + ("unavailable", "Unavailable"), + ], + max_length=20, + ), + ), + ("frequency", models.DurationField(default=datetime.timedelta(days=1))), + ("modified", models.DateTimeField(blank=True, null=True)), + ("content_hash", models.CharField(blank=True, max_length=64)), + ("exception", models.TextField(blank=True)), + ("num_retries", models.PositiveIntegerField(default=0)), + ( + "cover_url", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ( + "funding_url", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ("funding_text", models.TextField(blank=True)), + ( + "language", + models.CharField( + default="en", + max_length=2, + validators=[django.core.validators.MinLengthValidator(2)], + ), + ), + ("description", models.TextField(blank=True)), + ( + "website", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ("keywords", models.TextField(blank=True)), + ("extracted_text", models.TextField(blank=True)), + ("owner", models.TextField(blank=True)), + ("promoted", models.BooleanField(default=False)), + ( + "podcast_type", + models.CharField( + choices=[("episodic", "Episodic"), ("serial", "Serial")], + default="episodic", + max_length=10, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("explicit", models.BooleanField(default=False)), + ( + "search_vector", + django.contrib.postgres.search.SearchVectorField( + editable=False, null=True + ), + ), + ( + "owner_search_vector", + django.contrib.postgres.search.SearchVectorField( + editable=False, null=True + ), + ), + ( + "canonical", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="duplicates", + to="simplecasts.podcast", + ), + ), + ( + "categories", + models.ManyToManyField( + blank=True, related_name="podcasts", to="simplecasts.category" + ), + ), + ( + "recipients", + models.ManyToManyField( + blank=True, + related_name="recommended_podcasts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Episode", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("guid", models.TextField()), + ("pub_date", models.DateTimeField()), + ("title", models.TextField(blank=True)), + ("description", models.TextField(blank=True)), + ("keywords", models.TextField(blank=True)), + ( + "cover_url", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ( + "website", + models.URLField( + blank=True, + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ( + "episode_type", + models.CharField( + choices=[ + ("full", "Full episode"), + ("trailer", "Trailer"), + ("bonus", "Bonus"), + ], + default="full", + max_length=12, + ), + ), + ("episode", models.IntegerField(blank=True, null=True)), + ("season", models.IntegerField(blank=True, null=True)), + ( + "media_url", + models.URLField( + max_length=2083, + validators=[ + django.core.validators.URLValidator( + schemes=["http", "https"] + ) + ], + ), + ), + ("media_type", models.CharField(max_length=60)), + ( + "file_size", + models.BigIntegerField( + blank=True, null=True, verbose_name="File size in bytes" + ), + ), + ("duration", models.CharField(blank=True, max_length=30)), + ("explicit", models.BooleanField(default=False)), + ( + "search_vector", + django.contrib.postgres.search.SearchVectorField( + editable=False, null=True + ), + ), + ( + "podcast", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="episodes", + to="simplecasts.podcast", + ), + ), + ], + ), + migrations.CreateModel( + name="Recommendation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "score", + models.DecimalField( + blank=True, decimal_places=10, max_digits=100, null=True + ), + ), + ( + "podcast", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="recommendations", + to="simplecasts.podcast", + ), + ), + ( + "recommended", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="similar", + to="simplecasts.podcast", + ), + ), + ], + ), + migrations.CreateModel( + name="Subscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "podcast", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to="simplecasts.podcast", + ), + ), + ( + "subscriber", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Bookmark", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bookmarks", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "episode", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bookmarks", + to="simplecasts.episode", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["user", "episode"], + name="simplecasts_user_id_5840b2_idx", + ), + models.Index( + fields=["user", "created"], + include=("episode_id",), + name="simplecasts_bookmark_desc_idx", + ), + models.Index( + fields=["user", "-created"], + include=("episode_id",), + name="simplecasts_bookmark_asc_idx", + ), + ], + "constraints": [ + models.UniqueConstraint( + fields=("user", "episode"), + name="unique_simplecasts_bookmark_user_episode", + ) + ], + }, + ), + migrations.CreateModel( + name="AudioLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("listened", models.DateTimeField()), + ("current_time", models.PositiveIntegerField(default=0)), + ("duration", models.PositiveIntegerField(default=0)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="audio_logs", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "episode", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="audio_logs", + to="simplecasts.episode", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["user", "episode"], + name="simplecasts_user_id_8cf983_idx", + ), + models.Index( + fields=["user", "listened"], + include=("episode_id",), + name="simplecasts_audiolog_desc_idx", + ), + models.Index( + fields=["user", "-listened"], + include=("episode_id",), + name="simplecasts_audiolog_asc_idx", + ), + ], + "constraints": [ + models.UniqueConstraint( + fields=("user", "episode"), + name="unique_simplecasts_audiolog_user_episode", + ) + ], + }, + ), + migrations.AddIndex( + model_name="podcast", + index=models.Index( + fields=["-pub_date"], name="simplecasts_pub_dat_74abec_idx" + ), + ), + migrations.AddIndex( + model_name="podcast", + index=models.Index( + fields=["-promoted", "language", "-pub_date"], + name="simplecasts_promote_2ab115_idx", + ), + ), + migrations.AddIndex( + model_name="podcast", + index=models.Index( + fields=["active", "-promoted", "parsed", "updated"], + name="simplecasts_active_89dae2_idx", + ), + ), + migrations.AddIndex( + model_name="podcast", + index=models.Index( + condition=models.Q(("private", False), ("pub_date__isnull", False)), + fields=["-pub_date"], + name="simplecasts_podcast_public_idx", + ), + ), + migrations.AddIndex( + model_name="podcast", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_vector"], name="simplecasts_search__b934fa_gin" + ), + ), + migrations.AddIndex( + model_name="podcast", + index=django.contrib.postgres.indexes.GinIndex( + fields=["owner_search_vector"], name="simplecasts_owner_s_3e1b46_gin" + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["podcast", "pub_date", "id"], + name="simplecasts_podcast_1111db_idx", + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["podcast", "-pub_date", "-id"], + name="simplecasts_podcast_0ff5cb_idx", + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["podcast", "season", "-pub_date", "-id"], + name="simplecasts_podcast_c440f0_idx", + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["pub_date", "id"], name="simplecasts_pub_dat_e63d62_idx" + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index( + fields=["-pub_date", "-id"], name="simplecasts_pub_dat_9acc44_idx" + ), + ), + migrations.AddIndex( + model_name="episode", + index=models.Index(fields=["guid"], name="simplecasts_guid_bbb042_idx"), + ), + migrations.AddIndex( + model_name="episode", + index=django.contrib.postgres.indexes.GinIndex( + fields=["search_vector"], name="simplecasts_search__3a2555_gin" + ), + ), + migrations.AddConstraint( + model_name="episode", + constraint=models.UniqueConstraint( + fields=("podcast", "guid"), + name="unique_simplecasts_episode_podcast_guid", + ), + ), + migrations.AddIndex( + model_name="recommendation", + index=models.Index(fields=["-score"], name="simplecasts_score_866794_idx"), + ), + migrations.AddConstraint( + model_name="recommendation", + constraint=models.UniqueConstraint( + fields=("podcast", "recommended"), + name="unique_simplecasts_recommendation", + ), + ), + migrations.AddIndex( + model_name="subscription", + index=models.Index( + fields=["-created"], name="simplecasts_created_dffa8b_idx" + ), + ), + migrations.AddConstraint( + model_name="subscription", + constraint=models.UniqueConstraint( + fields=("subscriber", "podcast"), + name="unique_simplecasts_subscription_user_podcast", + ), + ), + ] diff --git a/simplecasts/migrations/0002_create_search_triggers.py b/simplecasts/migrations/0002_create_search_triggers.py new file mode 100644 index 0000000000..0413a60276 --- /dev/null +++ b/simplecasts/migrations/0002_create_search_triggers.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0 on 2026-01-05 17:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("simplecasts", "0001_initial"), + ] + + operations = [ + # Podcast: owner_search_vector trigger on owner field + migrations.RunSQL( + sql=""" + CREATE TRIGGER podcast_update_owner_search_trigger + BEFORE INSERT OR UPDATE OF owner ON simplecasts_podcast + FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( + owner_search_vector, 'pg_catalog.simple', owner + ); + """, + reverse_sql="DROP TRIGGER IF EXISTS podcast_update_owner_search_trigger ON simplecasts_podcast;", + ), + # Podcast: search_vector trigger on title, owner, keywords fields + migrations.RunSQL( + sql=""" + CREATE TRIGGER podcast_update_search_trigger + BEFORE INSERT OR UPDATE OF title, owner, keywords ON simplecasts_podcast + FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( + search_vector, 'pg_catalog.simple', title, owner, keywords + ); + """, + reverse_sql="DROP TRIGGER IF EXISTS podcast_update_search_trigger ON simplecasts_podcast;", + ), + # Episode: search_vector trigger on title, keywords fields + migrations.RunSQL( + sql=""" + CREATE TRIGGER episode_update_search_trigger + BEFORE INSERT OR UPDATE OF title, keywords ON simplecasts_episode + FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( + search_vector, 'pg_catalog.simple', title, keywords + ); + """, + reverse_sql="DROP TRIGGER IF EXISTS episode_update_search_trigger ON simplecasts_episode;", + ), + ] diff --git a/simplecasts/migrations/0003_alter_episode_search_vector.py b/simplecasts/migrations/0003_alter_episode_search_vector.py new file mode 100644 index 0000000000..b06cf84b4f --- /dev/null +++ b/simplecasts/migrations/0003_alter_episode_search_vector.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0 on 2026-01-06 12:44 + +import django.contrib.postgres.search +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("simplecasts", "0002_create_search_triggers"), + ] + + operations = [ + migrations.AlterField( + model_name="episode", + name="search_vector", + field=django.contrib.postgres.search.SearchVectorField( + blank=True, editable=False, null=True + ), + ), + ] diff --git a/simplecasts/migrations/0004_alter_podcast_owner_search_vector_and_more.py b/simplecasts/migrations/0004_alter_podcast_owner_search_vector_and_more.py new file mode 100644 index 0000000000..3b04c847e2 --- /dev/null +++ b/simplecasts/migrations/0004_alter_podcast_owner_search_vector_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0 on 2026-01-06 12:45 + +import django.contrib.postgres.search +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("simplecasts", "0003_alter_episode_search_vector"), + ] + + operations = [ + migrations.AlterField( + model_name="podcast", + name="owner_search_vector", + field=django.contrib.postgres.search.SearchVectorField( + blank=True, editable=False, null=True + ), + ), + migrations.AlterField( + model_name="podcast", + name="search_vector", + field=django.contrib.postgres.search.SearchVectorField( + blank=True, editable=False, null=True + ), + ), + ] diff --git a/simplecasts/episodes/migrations/__init__.py b/simplecasts/migrations/__init__.py similarity index 100% rename from simplecasts/episodes/migrations/__init__.py rename to simplecasts/migrations/__init__.py diff --git a/simplecasts/migrations/max_migration.txt b/simplecasts/migrations/max_migration.txt new file mode 100644 index 0000000000..f0a211a87f --- /dev/null +++ b/simplecasts/migrations/max_migration.txt @@ -0,0 +1 @@ +0004_alter_podcast_owner_search_vector_and_more diff --git a/simplecasts/models/__init__.py b/simplecasts/models/__init__.py new file mode 100644 index 0000000000..8449b192c8 --- /dev/null +++ b/simplecasts/models/__init__.py @@ -0,0 +1,20 @@ +from simplecasts.models.audio_logs import AudioLog +from simplecasts.models.bookmarks import Bookmark +from simplecasts.models.categories import Category +from simplecasts.models.episodes import Episode +from simplecasts.models.podcasts import Podcast, Season +from simplecasts.models.recommendations import Recommendation +from simplecasts.models.subscriptions import Subscription +from simplecasts.models.users import User + +__all__ = [ + "AudioLog", + "Bookmark", + "Category", + "Episode", + "Podcast", + "Recommendation", + "Season", + "Subscription", + "User", +] diff --git a/simplecasts/models/audio_logs.py b/simplecasts/models/audio_logs.py new file mode 100644 index 0000000000..da547cf0a1 --- /dev/null +++ b/simplecasts/models/audio_logs.py @@ -0,0 +1,53 @@ +from typing import ClassVar + +from django.conf import settings +from django.db import models +from django.utils.functional import cached_property + + +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) + + class Meta: + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s_user_episode", + fields=["user", "episode"], + ), + ] + indexes: ClassVar[list] = [ + models.Index(fields=["user", "episode"]), + models.Index( + fields=["user", "listened"], + include=["episode_id"], + name="%(app_label)s_%(class)s_desc_idx", + ), + models.Index( + fields=["user", "-listened"], + include=["episode_id"], + name="%(app_label)s_%(class)s_asc_idx", + ), + ] + + @cached_property + def percent_complete(self) -> int: + """Returns percentage of episode listened to.""" + if 0 in (self.current_time, self.duration): + return 0 + + return min(100, round((self.current_time / self.duration) * 100)) diff --git a/simplecasts/models/bookmarks.py b/simplecasts/models/bookmarks.py new file mode 100644 index 0000000000..70729f9bed --- /dev/null +++ b/simplecasts/models/bookmarks.py @@ -0,0 +1,43 @@ +from typing import ClassVar + +from django.conf import settings +from django.db import models + + +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) + + class Meta: + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s_user_episode", + fields=["user", "episode"], + ) + ] + indexes: ClassVar[list] = [ + models.Index(fields=["user", "episode"]), + models.Index( + fields=["user", "created"], + include=["episode_id"], + name="%(app_label)s_%(class)s_desc_idx", + ), + models.Index( + fields=["user", "-created"], + include=["episode_id"], + name="%(app_label)s_%(class)s_asc_idx", + ), + ] diff --git a/simplecasts/models/categories.py b/simplecasts/models/categories.py new file mode 100644 index 0000000000..6f34b79ff4 --- /dev/null +++ b/simplecasts/models/categories.py @@ -0,0 +1,36 @@ +from typing import TYPE_CHECKING + +from django.db import models +from django.urls import reverse +from slugify import slugify + +if TYPE_CHECKING: + from simplecasts.models.podcasts import PodcastQuerySet + + +class Category(models.Model): + """iTunes category.""" + + name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(unique=True) + itunes_genre_id = models.PositiveIntegerField(null=True, blank=True) + + if TYPE_CHECKING: + podcasts: "PodcastQuerySet" + + class Meta: + verbose_name_plural = "categories" + ordering = ("name",) + + def __str__(self) -> str: + """Returns category name.""" + return self.name + + def save(self, **kwargs) -> None: + """Overrides save to auto-generate slug.""" + self.slug = slugify(self.name, allow_unicode=False) + super().save(**kwargs) + + def get_absolute_url(self) -> str: + """Absolute URL to a category.""" + return reverse("categories:detail", kwargs={"slug": self.slug}) diff --git a/simplecasts/episodes/models.py b/simplecasts/models/episodes.py similarity index 65% rename from simplecasts/episodes/models.py rename to simplecasts/models/episodes.py index c0f13faf33..bd57ba94c8 100644 --- a/simplecasts/episodes/models.py +++ b/simplecasts/models/episodes.py @@ -1,6 +1,5 @@ from typing import ClassVar, Optional -from django.conf import settings from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVectorField from django.db import models @@ -12,9 +11,9 @@ from django.utils.functional import cached_property from slugify import slugify -from simplecasts.fields import URLField -from simplecasts.podcasts.models import Season -from simplecasts.sanitizer import strip_html +from simplecasts.models.fields import URLField +from simplecasts.models.podcasts import Season +from simplecasts.services.sanitizer import strip_html class Episode(models.Model): @@ -26,7 +25,7 @@ class EpisodeType(models.TextChoices): BONUS = "bonus", "Bonus" podcast = models.ForeignKey( - "podcasts.Podcast", + "simplecasts.Podcast", on_delete=models.CASCADE, related_name="episodes", ) @@ -63,7 +62,7 @@ class EpisodeType(models.TextChoices): explicit = models.BooleanField(default=False) - search_vector = SearchVectorField(null=True, editable=False) + search_vector = SearchVectorField(null=True, blank=True, editable=False) class Meta: constraints: ClassVar[list] = [ @@ -89,7 +88,7 @@ def __str__(self) -> str: def get_absolute_url(self) -> str: """Canonical episode URL.""" return reverse( - "episodes:episode_detail", + "episodes:detail", kwargs={ "episode_id": self.pk, "slug": self.slug, @@ -181,90 +180,3 @@ def _get_other_episodes_in_podcast(self) -> models.QuerySet["Episode"]: return self._meta.default_manager.filter( # type: ignore[reportOptionalMemberAccess] podcast=self.podcast, ).exclude(pk=self.pk) - - -class Bookmark(models.Model): - """Bookmarked episodes.""" - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="bookmarks", - ) - - episode = models.ForeignKey( - "episodes.Episode", - on_delete=models.CASCADE, - related_name="bookmarks", - ) - - created = models.DateTimeField(auto_now_add=True) - - class Meta: - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s_user_episode", - fields=["user", "episode"], - ) - ] - indexes: ClassVar[list] = [ - models.Index(fields=["user", "episode"]), - models.Index( - fields=["user", "created"], - include=["episode_id"], - name="%(app_label)s_%(class)s_desc_idx", - ), - models.Index( - fields=["user", "-created"], - include=["episode_id"], - name="%(app_label)s_%(class)s_asc_idx", - ), - ] - - -class AudioLog(models.Model): - """Record of user listening history.""" - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="audio_logs", - ) - episode = models.ForeignKey( - "episodes.Episode", - on_delete=models.CASCADE, - related_name="audio_logs", - ) - - listened = models.DateTimeField() - current_time = models.PositiveIntegerField(default=0) - duration = models.PositiveIntegerField(default=0) - - class Meta: - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s_user_episode", - fields=["user", "episode"], - ), - ] - indexes: ClassVar[list] = [ - models.Index(fields=["user", "episode"]), - models.Index( - fields=["user", "listened"], - include=["episode_id"], - name="%(app_label)s_%(class)s_desc_idx", - ), - models.Index( - fields=["user", "-listened"], - include=["episode_id"], - name="%(app_label)s_%(class)s_asc_idx", - ), - ] - - @cached_property - def percent_complete(self) -> int: - """Returns percentage of episode listened to.""" - if 0 in (self.current_time, self.duration): - return 0 - - return min(100, round((self.current_time / self.duration) * 100)) diff --git a/simplecasts/fields.py b/simplecasts/models/fields.py similarity index 100% rename from simplecasts/fields.py rename to simplecasts/models/fields.py diff --git a/simplecasts/podcasts/models.py b/simplecasts/models/podcasts.py similarity index 78% rename from simplecasts/podcasts/models.py rename to simplecasts/models/podcasts.py index c91fc155a5..ecd3c3b0c6 100644 --- a/simplecasts/podcasts/models.py +++ b/simplecasts/models/podcasts.py @@ -13,12 +13,14 @@ from django.utils.functional import cached_property from slugify import slugify -from simplecasts.fields import URLField -from simplecasts.sanitizer import strip_html -from simplecasts.users.models import User +from simplecasts.models.fields import URLField +from simplecasts.models.recommendations import Recommendation +from simplecasts.models.users import User +from simplecasts.services.sanitizer import strip_html if TYPE_CHECKING: - from simplecasts.episodes.models import Episode + from simplecasts.models.episodes import Episode + from simplecasts.models.subscriptions import Subscription @dataclasses.dataclass(kw_only=True, frozen=True) @@ -50,34 +52,6 @@ def url(self) -> str: ) -class Category(models.Model): - """iTunes category.""" - - name = models.CharField(max_length=100, unique=True) - slug = models.SlugField(unique=True) - itunes_genre_id = models.PositiveIntegerField(null=True, blank=True) - - if TYPE_CHECKING: - podcasts: "PodcastQuerySet" - - class Meta: - verbose_name_plural = "categories" - ordering = ("name",) - - def __str__(self) -> str: - """Returns category name.""" - return self.name - - def save(self, **kwargs) -> None: - """Overrides save to auto-generate slug.""" - self.slug = slugify(self.name, allow_unicode=False) - super().save(**kwargs) - - def get_absolute_url(self) -> str: - """Absolute URL to a category.""" - return reverse("podcasts:category_detail", kwargs={"slug": self.slug}) - - class PodcastQuerySet(models.QuerySet): """Custom QuerySet of Podcast model.""" @@ -252,7 +226,7 @@ class FeedStatus(models.TextChoices): explicit = models.BooleanField(default=False) categories = models.ManyToManyField( - "podcasts.Category", + "simplecasts.Category", blank=True, related_name="podcasts", ) @@ -263,8 +237,8 @@ class FeedStatus(models.TextChoices): related_name="recommended_podcasts", ) - search_vector = SearchVectorField(null=True, editable=False) - owner_search_vector = SearchVectorField(null=True, editable=False) + search_vector = SearchVectorField(null=True, blank=True, editable=False) + owner_search_vector = SearchVectorField(null=True, blank=True, editable=False) objects: PodcastQuerySet = PodcastQuerySet.as_manager() # type: ignore[assignment] @@ -314,7 +288,7 @@ def get_absolute_url(self) -> str: def get_detail_url(self) -> str: """Podcast detail URL""" return reverse( - "podcasts:podcast_detail", + "podcasts:detail", kwargs={ "podcast_id": self.pk, "slug": self.slug, @@ -412,78 +386,3 @@ def is_episodic(self) -> bool: def is_serial(self) -> bool: """Returns true if podcast is serial.""" return self.podcast_type == self.PodcastType.SERIAL - - -class Subscription(models.Model): - """Subscribed podcast belonging to a user's collection.""" - - subscriber = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="subscriptions", - ) - - podcast = models.ForeignKey( - "podcasts.Podcast", - on_delete=models.CASCADE, - related_name="subscriptions", - ) - - created = models.DateTimeField(auto_now_add=True) - - class Meta: - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s_user_podcast", - fields=["subscriber", "podcast"], - ) - ] - indexes: ClassVar[list] = [models.Index(fields=["-created"])] - - -class RecommendationQuerySet(models.QuerySet): - """Custom QuerySet for Recommendation model.""" - - def bulk_delete(self) -> int: - """More efficient quick delete. - - Returns: - number of rows deleted - """ - return self._raw_delete(self.db) - - -class Recommendation(models.Model): - """Recommendation based on similarity between two podcasts.""" - - podcast = models.ForeignKey( - "podcasts.Podcast", - on_delete=models.CASCADE, - related_name="recommendations", - ) - - recommended = models.ForeignKey( - "podcasts.Podcast", - on_delete=models.CASCADE, - related_name="similar", - ) - - score = models.DecimalField( - decimal_places=10, - max_digits=100, - null=True, - blank=True, - ) - - objects: RecommendationQuerySet = RecommendationQuerySet.as_manager() # type: ignore[assignment] - - class Meta: - indexes: ClassVar[list] = [ - models.Index(fields=["-score"]), - ] - constraints: ClassVar[list] = [ - models.UniqueConstraint( - name="unique_%(app_label)s_%(class)s", - fields=["podcast", "recommended"], - ), - ] diff --git a/simplecasts/models/recommendations.py b/simplecasts/models/recommendations.py new file mode 100644 index 0000000000..1fbda6e913 --- /dev/null +++ b/simplecasts/models/recommendations.py @@ -0,0 +1,51 @@ +from typing import ClassVar + +from django.db import models + + +class RecommendationQuerySet(models.QuerySet): + """Custom QuerySet for Recommendation model.""" + + def bulk_delete(self) -> int: + """More efficient quick delete. + + Returns: + number of rows deleted + """ + return self._raw_delete(self.db) + + +class Recommendation(models.Model): + """Recommendation based on similarity between two podcasts.""" + + podcast = models.ForeignKey( + "simplecasts.Podcast", + on_delete=models.CASCADE, + related_name="recommendations", + ) + + recommended = models.ForeignKey( + "simplecasts.Podcast", + on_delete=models.CASCADE, + related_name="similar", + ) + + score = models.DecimalField( + decimal_places=10, + max_digits=100, + null=True, + blank=True, + ) + + objects: RecommendationQuerySet = RecommendationQuerySet.as_manager() # type: ignore[assignment] + + class Meta: + indexes: ClassVar[list] = [ + models.Index(fields=["-score"]), + ] + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s", + fields=["podcast", "recommended"], + ), + ] diff --git a/simplecasts/models/subscriptions.py b/simplecasts/models/subscriptions.py new file mode 100644 index 0000000000..8d8caa4512 --- /dev/null +++ b/simplecasts/models/subscriptions.py @@ -0,0 +1,31 @@ +from typing import ClassVar + +from django.conf import settings +from django.db import models + + +class Subscription(models.Model): + """Subscribed podcast belonging to a user's collection.""" + + subscriber = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="subscriptions", + ) + + podcast = models.ForeignKey( + "simplecasts.Podcast", + on_delete=models.CASCADE, + related_name="subscriptions", + ) + + created = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints: ClassVar[list] = [ + models.UniqueConstraint( + name="unique_%(app_label)s_%(class)s_user_podcast", + fields=["subscriber", "podcast"], + ) + ] + indexes: ClassVar[list] = [models.Index(fields=["-created"])] diff --git a/simplecasts/users/models.py b/simplecasts/models/users.py similarity index 72% rename from simplecasts/users/models.py rename to simplecasts/models/users.py index 6a0499de82..adf00d7598 100644 --- a/simplecasts/users/models.py +++ b/simplecasts/models/users.py @@ -4,8 +4,10 @@ from django.db import models if TYPE_CHECKING: - from simplecasts.episodes.models import AudioLog, Bookmark - from simplecasts.podcasts.models import PodcastQuerySet, Subscription + from simplecasts.models.audio_logs import AudioLog + from simplecasts.models.bookmarks import Bookmark + from simplecasts.models.podcasts import PodcastQuerySet + from simplecasts.models.subscriptions import Subscription class User(AbstractUser): diff --git a/simplecasts/podcasts/forms.py b/simplecasts/podcasts/forms.py deleted file mode 100644 index 6387600fb0..0000000000 --- a/simplecasts/podcasts/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import ClassVar - -from django import forms - -from simplecasts.podcasts.models import Podcast - - -class PodcastForm(forms.ModelForm): - """Form to add a new podcast feed.""" - - class Meta: - model = Podcast - fields: ClassVar[list] = ["rss"] - labels: ClassVar[dict] = {"rss": "RSS Feed URL"} - error_messages: ClassVar[dict] = { - "rss": {"unique": "This podcast is not available"} - } diff --git a/simplecasts/podcasts/migrations/0001_initial.py b/simplecasts/podcasts/migrations/0001_initial.py deleted file mode 100644 index 5235b41090..0000000000 --- a/simplecasts/podcasts/migrations/0001_initial.py +++ /dev/null @@ -1,314 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:03 - -import datetime - -import django.contrib.postgres.indexes -import django.contrib.postgres.search -import django.core.validators -import django.db.models.deletion -import django.db.models.functions.text -import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Category", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, unique=True)), - ( - "parent", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="children", - to="podcasts.category", - ), - ), - ], - options={ - "verbose_name_plural": "categories", - "ordering": ("name",), - }, - ), - migrations.CreateModel( - name="Podcast", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("rss", models.URLField(max_length=500, unique=True)), - ( - "active", - models.BooleanField( - default=True, - help_text="Inactive podcasts will no longer be updated from their RSS feeds.", - ), - ), - ( - "private", - models.BooleanField( - default=False, help_text="Only available to subscribers" - ), - ), - ("etag", models.TextField(blank=True)), - ("title", models.TextField(blank=True)), - ("pub_date", models.DateTimeField(blank=True, null=True)), - ("parsed", models.DateTimeField(blank=True, null=True)), - ( - "parser_error", - models.CharField( - blank=True, - choices=[ - ("duplicate", "Duplicate"), - ("inaccessible", "Inaccessible"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ], - max_length=30, - null=True, - ), - ), - ("frequency", models.DurationField(default=datetime.timedelta(days=1))), - ("modified", models.DateTimeField(blank=True, null=True)), - ( - "content_hash", - models.CharField(blank=True, max_length=64, null=True), - ), - ("num_retries", models.PositiveSmallIntegerField(default=0)), - ("cover_url", models.URLField(blank=True, max_length=2083, null=True)), - ( - "funding_url", - models.URLField(blank=True, max_length=2083, null=True), - ), - ("funding_text", models.TextField(blank=True)), - ( - "language", - models.CharField( - default="en", - max_length=2, - validators=[django.core.validators.MinLengthValidator(2)], - ), - ), - ("description", models.TextField(blank=True)), - ("website", models.URLField(blank=True, max_length=2083, null=True)), - ("keywords", models.TextField(blank=True)), - ("extracted_text", models.TextField(blank=True)), - ("owner", models.TextField(blank=True)), - ("created", models.DateTimeField(auto_now_add=True)), - ( - "updated", - models.DateTimeField( - auto_now=True, verbose_name="Podcast Updated in Database" - ), - ), - ("explicit", models.BooleanField(default=False)), - ("promoted", models.BooleanField(default=False)), - ( - "search_vector", - django.contrib.postgres.search.SearchVectorField( - editable=False, null=True - ), - ), - ( - "categories", - models.ManyToManyField( - blank=True, related_name="podcasts", to="podcasts.category" - ), - ), - ( - "recipients", - models.ManyToManyField( - blank=True, - related_name="recommended_podcasts", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="Recommendation", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("frequency", models.PositiveIntegerField(default=0)), - ( - "similarity", - models.DecimalField( - blank=True, decimal_places=10, max_digits=100, null=True - ), - ), - ( - "podcast", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="recommendations", - to="podcasts.podcast", - ), - ), - ( - "recommended", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="similar", - to="podcasts.podcast", - ), - ), - ], - ), - migrations.CreateModel( - name="Subscription", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "podcast", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="subscriptions", - to="podcasts.podcast", - ), - ), - ( - "subscriber", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="subscriptions", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "indexes": [ - models.Index( - fields=["-created"], name="podcasts_su_created_55323d_idx" - ) - ], - }, - ), - migrations.AddConstraint( - model_name="subscription", - constraint=models.UniqueConstraint( - fields=("subscriber", "podcast"), - name="unique_podcasts_subscription_user_podcast", - ), - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index( - fields=["podcast"], name="podcasts_re_podcast_10c46d_idx" - ), - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index( - fields=["recommended"], name="podcasts_re_recomme_244ce9_idx" - ), - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index( - fields=["-similarity", "-frequency"], - name="podcasts_re_similar_3e4170_idx", - ), - ), - migrations.AddConstraint( - model_name="recommendation", - constraint=models.UniqueConstraint( - fields=("podcast", "recommended"), name="unique_podcasts_recommendation" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-pub_date"], name="podcasts_po_pub_dat_850a22_idx" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["pub_date"], name="podcasts_po_pub_dat_2e433a_idx" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted"], name="podcasts_po_promote_fdc955_idx" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["content_hash"], name="podcasts_po_content_736948_idx" - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - django.db.models.functions.text.Lower("title"), - name="podcasts_podcast_lwr_title_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=django.contrib.postgres.indexes.GinIndex( - fields=["search_vector"], name="podcasts_po_search__4c951f_gin" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0002_add_podcast_search_trigger.py b/simplecasts/podcasts/migrations/0002_add_podcast_search_trigger.py deleted file mode 100644 index 21b1519772..0000000000 --- a/simplecasts/podcasts/migrations/0002_add_podcast_search_trigger.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:22 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0001_initial"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector ON podcasts_podcast -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', title, owner, keywords); -UPDATE podcasts_podcast SET search_vector = NULL;""", - reverse_sql="DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast;", - ), - ] diff --git a/simplecasts/podcasts/migrations/0003_categories.py b/simplecasts/podcasts/migrations/0003_categories.py deleted file mode 100644 index 4eefe3aa37..0000000000 --- a/simplecasts/podcasts/migrations/0003_categories.py +++ /dev/null @@ -1,132 +0,0 @@ -# Generated by Django 5.0.4 on 2024-04-26 11:54 - -from django.db import migrations - -_CATEGORIES = [ - "After Shows", - "Alternative Health", - "Animation & Manga", - "Arts", - "Astronomy", - "Automotive", - "Aviation", - "Baseball", - "Basketball", - "Books", - "Buddhism", - "Business", - "Business News", - "Careers", - "Chemistry", - "Christianity", - "Comedy", - "Comedy Fiction", - "Comedy Interviews", - "Courses", - "Crafts", - "Cricket", - "Daily News", - "Design", - "Documentary", - "Drama", - "Earth Sciences", - "Education", - "Education for Kids", - "Entertainment News", - "Entrepreneurship", - "Fantasy Sports", - "Fashion & Beauty", - "Fiction", - "Film History", - "Film Interviews", - "Film Reviews", - "Fitness", - "Food", - "Football", - "Games", - "Golf", - "Government", - "Health & Fitness", - "Hinduism", - "History", - "Hobbies", - "Hockey", - "Home & Garden", - "How-To", - "Improv", - "Investing", - "Islam", - "Judaism", - "Kids & Family", - "Language Learning", - "Leisure", - "Life Sciences", - "Management", - "Marketing", - "Mathematics", - "Medicine", - "Mental Health", - "Music", - "Music Commentary", - "Music History", - "Music Interviews", - "Natural Sciences", - "Nature", - "News", - "News Commentary", - "Non-Profit", - "Nutrition", - "Parenting", - "Performing Arts", - "Personal Journals", - "Pets & Animals", - "Philosophy", - "Physics", - "Places & Travel", - "Politics", - "Relationships", - "Religion", - "Religion & Spirituality", - "Rugby", - "Running", - "Science", - "Science Fiction", - "Self-Improvement", - "Sexuality", - "Soccer", - "Social Sciences", - "Society & Culture", - "Spirituality", - "Sports", - "Sports News", - "Stand Up", - "Stories for Kids", - "Swimming", - "Tech News", - "Technology", - "Tennis", - "True Crime", - "TV & Film", - "TV Reviews", - "Video Games", - "Visual Arts", - "Volleyball", - "Wilderness", - "Wrestling", -] - - -def _add_categories(apps, schema_editor): - Category = apps.get_model("podcasts.Category") - categories = [Category(name=name) for name in _CATEGORIES] - Category.objects.bulk_create(categories, ignore_conflicts=True) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0002_add_podcast_search_trigger"), - ] - - operations = [ - migrations.RunPython(_add_categories, reverse_code=migrations.RunPython.noop) - ] diff --git a/simplecasts/podcasts/migrations/0004_alter_podcast_frequency.py b/simplecasts/podcasts/migrations/0004_alter_podcast_frequency.py deleted file mode 100644 index 0f4cbc634c..0000000000 --- a/simplecasts/podcasts/migrations/0004_alter_podcast_frequency.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.6 on 2024-05-14 08:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0003_categories"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="frequency", - field=models.DurationField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more.py b/simplecasts/podcasts/migrations/0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more.py deleted file mode 100644 index 17c81d1ac5..0000000000 --- a/simplecasts/podcasts/migrations/0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-14 11:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0004_alter_podcast_frequency"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="content_hash", - field=models.CharField(blank=True, default="", max_length=64), - preserve_default=False, - ), - migrations.AlterField( - model_name="podcast", - name="cover_url", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - migrations.AlterField( - model_name="podcast", - name="funding_url", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - migrations.AlterField( - model_name="podcast", - name="parser_error", - field=models.CharField( - blank=True, - choices=[ - ("duplicate", "Duplicate"), - ("inaccessible", "Inaccessible"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ], - default="", - max_length=30, - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="podcast", - name="website", - field=models.URLField(blank=True, default="", max_length=2083), - preserve_default=False, - ), - ] diff --git a/simplecasts/podcasts/migrations/0006_remove_subscription_modified_and_more.py b/simplecasts/podcasts/migrations/0006_remove_subscription_modified_and_more.py deleted file mode 100644 index f1e25a43b3..0000000000 --- a/simplecasts/podcasts/migrations/0006_remove_subscription_modified_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-15 08:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "podcasts", - "0005_alter_podcast_content_hash_alter_podcast_cover_url_and_more", - ), - ] - - operations = [ - migrations.RemoveField( - model_name="subscription", - name="modified", - ), - migrations.AlterField( - model_name="subscription", - name="created", - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0007_alter_podcast_frequency.py b/simplecasts/podcasts/migrations/0007_alter_podcast_frequency.py deleted file mode 100644 index 12f7f539ae..0000000000 --- a/simplecasts/podcasts/migrations/0007_alter_podcast_frequency.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-02 22:00 - -import datetime - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0006_remove_subscription_modified_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="frequency", - field=models.DurationField(default=datetime.timedelta(days=1)), - ), - ] diff --git a/simplecasts/podcasts/migrations/0008_recommendation_score.py b/simplecasts/podcasts/migrations/0008_recommendation_score.py deleted file mode 100644 index 8c23f09104..0000000000 --- a/simplecasts/podcasts/migrations/0008_recommendation_score.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.4 on 2025-01-02 09:51 - -import django.db.models.expressions -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0007_alter_podcast_frequency"), - ] - - operations = [ - migrations.AddField( - model_name="recommendation", - name="score", - field=models.GeneratedField( - db_persist=True, - expression=django.db.models.expressions.CombinedExpression( - models.F("frequency"), "*", models.F("similarity") - ), - output_field=models.DecimalField(decimal_places=10, max_digits=100), - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more.py b/simplecasts/podcasts/migrations/0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more.py deleted file mode 100644 index 1a8c39a115..0000000000 --- a/simplecasts/podcasts/migrations/0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.4 on 2025-01-02 10:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0008_recommendation_score"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="recommendation", - name="podcasts_re_similar_3e4170_idx", - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index(fields=["-score"], name="podcasts_re_score_c89df8_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0010_podcast_itunes_ranking.py b/simplecasts/podcasts/migrations/0010_podcast_itunes_ranking.py deleted file mode 100644 index 6c66994a7d..0000000000 --- a/simplecasts/podcasts/migrations/0010_podcast_itunes_ranking.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-15 13:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "podcasts", - "0009_remove_recommendation_podcasts_re_similar_3e4170_idx_and_more", - ), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="itunes_ranking", - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0011_podcast_podcasts_po_itunes__8b4558_idx.py b/simplecasts/podcasts/migrations/0011_podcast_podcasts_po_itunes__8b4558_idx.py deleted file mode 100644 index f37f1c29b3..0000000000 --- a/simplecasts/podcasts/migrations/0011_podcast_podcasts_po_itunes__8b4558_idx.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-15 13:25 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0010_podcast_itunes_ranking"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["itunes_ranking"], name="podcasts_po_itunes__8b4558_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more.py b/simplecasts/podcasts/migrations/0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more.py deleted file mode 100644 index ec90532c3a..0000000000 --- a/simplecasts/podcasts/migrations/0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-15 23:21 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0011_podcast_podcasts_po_itunes__8b4558_idx"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_itunes__8b4558_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="itunes_ranking", - ), - ] diff --git a/simplecasts/podcasts/migrations/0013_podcast_podcast_type.py b/simplecasts/podcasts/migrations/0013_podcast_podcast_type.py deleted file mode 100644 index 1162d448db..0000000000 --- a/simplecasts/podcasts/migrations/0013_podcast_podcast_type.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1.5 on 2025-01-26 12:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0012_remove_podcast_podcasts_po_itunes__8b4558_idx_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="podcast_type", - field=models.CharField( - choices=[("episodic", "Episodic"), ("serial", "Serial")], - default="episodic", - max_length=10, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0014_podcast_rating.py b/simplecasts/podcasts/migrations/0014_podcast_rating.py deleted file mode 100644 index 89d4a5bb89..0000000000 --- a/simplecasts/podcasts/migrations/0014_podcast_rating.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 11:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0013_podcast_podcast_type"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="rating", - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0015_podcast_podcasts_po_rating_f96c31_idx.py b/simplecasts/podcasts/migrations/0015_podcast_podcasts_po_rating_f96c31_idx.py deleted file mode 100644 index 539978ea48..0000000000 --- a/simplecasts/podcasts/migrations/0015_podcast_podcasts_po_rating_f96c31_idx.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 11:48 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0014_podcast_rating"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index(fields=["rating"], name="podcasts_po_rating_f96c31_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py b/simplecasts/podcasts/migrations/0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py deleted file mode 100644 index 3e3e9d5f04..0000000000 --- a/simplecasts/podcasts/migrations/0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 12:05 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0015_podcast_podcasts_po_rating_f96c31_idx"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_fdc955_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="promoted", - ), - ] diff --git a/simplecasts/podcasts/migrations/0017_podcast_promoted_and_more.py b/simplecasts/podcasts/migrations/0017_podcast_promoted_and_more.py deleted file mode 100644 index 4cf4f094d4..0000000000 --- a/simplecasts/podcasts/migrations/0017_podcast_promoted_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 13:09 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0016_remove_podcast_podcasts_po_promote_fdc955_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="promoted", - field=models.BooleanField(default=False), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted"], name="podcasts_po_promote_fdc955_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0018_set_default_promoted.py b/simplecasts/podcasts/migrations/0018_set_default_promoted.py deleted file mode 100644 index c272671040..0000000000 --- a/simplecasts/podcasts/migrations/0018_set_default_promoted.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-09 13:10 - -from django.db import migrations - - -def _set_default_promoted(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(rating__isnull=False).update(promoted=True) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0017_podcast_promoted_and_more"), - ] - - operations = [ - migrations.RunPython( - _set_default_promoted, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/simplecasts/podcasts/migrations/0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more.py b/simplecasts/podcasts/migrations/0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more.py deleted file mode 100644 index d72d7aba10..0000000000 --- a/simplecasts/podcasts/migrations/0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-10 13:15 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0018_set_default_promoted"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_rating_f96c31_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="rating", - ), - ] diff --git a/simplecasts/podcasts/migrations/0020_podcast_canonical.py b/simplecasts/podcasts/migrations/0020_podcast_canonical.py deleted file mode 100644 index 9aebc911e7..0000000000 --- a/simplecasts/podcasts/migrations/0020_podcast_canonical.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-10 14:09 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0019_remove_podcast_podcasts_po_rating_f96c31_idx_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="canonical", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="duplicates", - to="podcasts.podcast", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0021_clear_duplicates.py b/simplecasts/podcasts/migrations/0021_clear_duplicates.py deleted file mode 100644 index 59e677de64..0000000000 --- a/simplecasts/podcasts/migrations/0021_clear_duplicates.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-10 14:29 - -from django.db import migrations - - -def _clear_duplicates(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(parser_error="duplicate").update( - active=True, - parser_error="", - num_retries=0, - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0020_podcast_canonical"), - ] - - operations = [ - migrations.RunPython( - _clear_duplicates, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0022_alter_podcast_parser_error.py b/simplecasts/podcasts/migrations/0022_alter_podcast_parser_error.py deleted file mode 100644 index 1a9d66a279..0000000000 --- a/simplecasts/podcasts/migrations/0022_alter_podcast_parser_error.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 11:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0021_clear_duplicates"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="parser_error", - field=models.CharField( - blank=True, - choices=[ - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ("inaccessible", "Inaccessible"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0023_move_inaccessible_podcasts_to_unavailable.py b/simplecasts/podcasts/migrations/0023_move_inaccessible_podcasts_to_unavailable.py deleted file mode 100644 index b61149e799..0000000000 --- a/simplecasts/podcasts/migrations/0023_move_inaccessible_podcasts_to_unavailable.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 11:40 - -from django.db import migrations - - -def _move_inaccessible_podcasts_to_unavailable(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - Podcast.objects.filter(parser_error="inaccessible").update( - parser_error="unavailable" - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0022_alter_podcast_parser_error"), - ] - - operations = [ - migrations.RunPython( - _move_inaccessible_podcasts_to_unavailable, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0024_alter_podcast_parser_error.py b/simplecasts/podcasts/migrations/0024_alter_podcast_parser_error.py deleted file mode 100644 index 15c08eed03..0000000000 --- a/simplecasts/podcasts/migrations/0024_alter_podcast_parser_error.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 11:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0023_move_inaccessible_podcasts_to_unavailable"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="parser_error", - field=models.CharField( - blank=True, - choices=[ - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0025_podcast_complete.py b/simplecasts/podcasts/migrations/0025_podcast_complete.py deleted file mode 100644 index b57e2ca58b..0000000000 --- a/simplecasts/podcasts/migrations/0025_podcast_complete.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 12:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0024_alter_podcast_parser_error"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="complete", - field=models.BooleanField( - default=False, - help_text="Podcast marked complete, no new episodes will be added.", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0026_mark_podcasts_complete.py b/simplecasts/podcasts/migrations/0026_mark_podcasts_complete.py deleted file mode 100644 index 831836d403..0000000000 --- a/simplecasts/podcasts/migrations/0026_mark_podcasts_complete.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 12:02 - -from django.db import migrations - - -def _mark_podcasts_complete(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - # all podcasts that are not active and have no parser error - # can be assumed to be complete - Podcast.objects.filter( - complete=False, - active=False, - parser_error__isnull=True, - ).update(complete=True) - - -def _unmark_podcasts_complete(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(complete=True).update(complete=False) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0025_podcast_complete"), - ] - - operations = [ - migrations.RunPython( - _mark_podcasts_complete, - reverse_code=_unmark_podcasts_complete, - ) - ] diff --git a/simplecasts/podcasts/migrations/0027_podcast_podcasts_po_active_a4c988_idx.py b/simplecasts/podcasts/migrations/0027_podcast_podcasts_po_active_a4c988_idx.py deleted file mode 100644 index 0637dc6a3a..0000000000 --- a/simplecasts/podcasts/migrations/0027_podcast_podcasts_po_active_a4c988_idx.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 12:35 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0026_mark_podcasts_complete"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index(fields=["active"], name="podcasts_po_active_a4c988_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0028_remove_podcast_complete.py b/simplecasts/podcasts/migrations/0028_remove_podcast_complete.py deleted file mode 100644 index 678f3a4dde..0000000000 --- a/simplecasts/podcasts/migrations/0028_remove_podcast_complete.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-15 23:37 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0027_podcast_podcasts_po_active_a4c988_idx"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="complete", - ), - ] diff --git a/simplecasts/podcasts/migrations/0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more.py b/simplecasts/podcasts/migrations/0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more.py deleted file mode 100644 index 2e445e1357..0000000000 --- a/simplecasts/podcasts/migrations/0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-21 16:03 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0028_remove_podcast_complete"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="recommendation", - name="podcasts_re_podcast_10c46d_idx", - ), - migrations.RemoveIndex( - model_name="recommendation", - name="podcasts_re_recomme_244ce9_idx", - ), - ] diff --git a/simplecasts/podcasts/migrations/0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py b/simplecasts/podcasts/migrations/0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py deleted file mode 100644 index 0b9a6e75fb..0000000000 --- a/simplecasts/podcasts/migrations/0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 5.2 on 2025-04-03 17:09 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "podcasts", - "0029_remove_recommendation_podcasts_re_podcast_10c46d_idx_and_more", - ), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="cover_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="podcast", - name="funding_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="podcast", - name="rss", - field=models.URLField( - max_length=500, - unique=True, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - migrations.AlterField( - model_name="podcast", - name="website", - field=models.URLField( - blank=True, - max_length=2083, - validators=[django.core.validators.URLValidator(["http", "https"])], - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py b/simplecasts/podcasts/migrations/0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py deleted file mode 100644 index 51d28d887e..0000000000 --- a/simplecasts/podcasts/migrations/0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 5.2 on 2025-04-03 17:33 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0030_alter_podcast_cover_url_alter_podcast_funding_url_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="cover_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="podcast", - name="funding_url", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="podcast", - name="rss", - field=models.URLField( - max_length=2083, - unique=True, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - migrations.AlterField( - model_name="podcast", - name="website", - field=models.URLField( - blank=True, - max_length=2083, - validators=[ - django.core.validators.URLValidator(schemes=["http", "https"]) - ], - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0032_podcast_queued.py b/simplecasts/podcasts/migrations/0032_podcast_queued.py deleted file mode 100644 index bb0e19016a..0000000000 --- a/simplecasts/podcasts/migrations/0032_podcast_queued.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2 on 2025-04-11 20:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0031_alter_podcast_cover_url_alter_podcast_funding_url_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="queued", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0033_remove_podcast_queued.py b/simplecasts/podcasts/migrations/0033_remove_podcast_queued.py deleted file mode 100644 index aaec82f6d5..0000000000 --- a/simplecasts/podcasts/migrations/0033_remove_podcast_queued.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2 on 2025-04-11 22:49 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0032_podcast_queued"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="queued", - ), - ] diff --git a/simplecasts/podcasts/migrations/0034_alter_podcast_updated.py b/simplecasts/podcasts/migrations/0034_alter_podcast_updated.py deleted file mode 100644 index f17a2cfcd0..0000000000 --- a/simplecasts/podcasts/migrations/0034_alter_podcast_updated.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2 on 2025-04-27 19:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0033_remove_podcast_queued"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="updated", - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more.py b/simplecasts/podcasts/migrations/0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more.py deleted file mode 100644 index 5f1b80c61d..0000000000 --- a/simplecasts/podcasts/migrations/0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.1 on 2025-05-27 14:56 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0034_alter_podcast_updated"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="recommendation", - name="podcasts_re_score_c89df8_idx", - ), - migrations.RemoveField( - model_name="recommendation", - name="score", - ), - ] diff --git a/simplecasts/podcasts/migrations/0036_remove_recommendation_frequency_and_more.py b/simplecasts/podcasts/migrations/0036_remove_recommendation_frequency_and_more.py deleted file mode 100644 index b9f95c17f3..0000000000 --- a/simplecasts/podcasts/migrations/0036_remove_recommendation_frequency_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.2.1 on 2025-05-27 14:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "podcasts", - "0035_remove_recommendation_podcasts_re_score_c89df8_idx_and_more", - ), - ] - - operations = [ - migrations.RemoveField( - model_name="recommendation", - name="frequency", - ), - migrations.RemoveField( - model_name="recommendation", - name="similarity", - ), - migrations.AddField( - model_name="recommendation", - name="score", - field=models.DecimalField( - blank=True, decimal_places=10, max_digits=100, null=True - ), - ), - migrations.AddIndex( - model_name="recommendation", - index=models.Index(fields=["-score"], name="podcasts_re_score_c89df8_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0037_podcast_itunes_ranking.py b/simplecasts/podcasts/migrations/0037_podcast_itunes_ranking.py deleted file mode 100644 index f035687ec3..0000000000 --- a/simplecasts/podcasts/migrations/0037_podcast_itunes_ranking.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-07 13:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0036_remove_recommendation_frequency_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="itunes_ranking", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/simplecasts/podcasts/migrations/0038_alter_podcast_itunes_ranking.py b/simplecasts/podcasts/migrations/0038_alter_podcast_itunes_ranking.py deleted file mode 100644 index 50affdfdcd..0000000000 --- a/simplecasts/podcasts/migrations/0038_alter_podcast_itunes_ranking.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-07 15:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0037_podcast_itunes_ranking"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="itunes_ranking", - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0039_podcast_podcasts_po_itunes__d69e24_idx.py b/simplecasts/podcasts/migrations/0039_podcast_podcasts_po_itunes__d69e24_idx.py deleted file mode 100644 index ea1a416243..0000000000 --- a/simplecasts/podcasts/migrations/0039_podcast_podcasts_po_itunes__d69e24_idx.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-07 15:29 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0038_alter_podcast_itunes_ranking"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["itunes_ranking", "-pub_date"], - name="podcasts_po_itunes__d69e24_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py b/simplecasts/podcasts/migrations/0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py deleted file mode 100644 index a68980b376..0000000000 --- a/simplecasts/podcasts/migrations/0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-07 15:50 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0039_podcast_podcasts_po_itunes__d69e24_idx"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_fdc955_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="promoted", - ), - ] diff --git a/simplecasts/podcasts/migrations/0041_remove_podcast_parser_error_podcast_parser_result.py b/simplecasts/podcasts/migrations/0041_remove_podcast_parser_error_podcast_parser_result.py deleted file mode 100644 index aad45a2e07..0000000000 --- a/simplecasts/podcasts/migrations/0041_remove_podcast_parser_error_podcast_parser_result.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 09:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0040_remove_podcast_podcasts_po_promote_fdc955_idx_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="parser_error", - ), - migrations.AddField( - model_name="podcast", - name="parser_result", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_data", "Invalid Data"), - ("invalid_rss", "Invalid RSS"), - ("not_modified", "Not Modified"), - ("unavailable", "Unavailable"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0042_podcast_podcasts_po_parser__9f31ab_idx.py b/simplecasts/podcasts/migrations/0042_podcast_podcasts_po_parser__9f31ab_idx.py deleted file mode 100644 index 33a9ac30e7..0000000000 --- a/simplecasts/podcasts/migrations/0042_podcast_podcasts_po_parser__9f31ab_idx.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 09:44 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0041_remove_podcast_parser_error_podcast_parser_result"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["parser_result"], name="podcasts_po_parser__9f31ab_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0043_podcast_promoted.py b/simplecasts/podcasts/migrations/0043_podcast_promoted.py deleted file mode 100644 index 25bc05bd8c..0000000000 --- a/simplecasts/podcasts/migrations/0043_podcast_promoted.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 13:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0042_podcast_podcasts_po_parser__9f31ab_idx"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="promoted", - field=models.BooleanField(default=False), - ), - ] diff --git a/simplecasts/podcasts/migrations/0044_promote_itunes_feeds.py b/simplecasts/podcasts/migrations/0044_promote_itunes_feeds.py deleted file mode 100644 index 7659b77fbc..0000000000 --- a/simplecasts/podcasts/migrations/0044_promote_itunes_feeds.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 13:12 - -from django.db import migrations - - -def promote_feeds(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(itunes_ranking__isnull=False).update(promoted=True) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0043_podcast_promoted"), - ] - - operations = [migrations.RunPython(promote_feeds, migrations.RunPython.noop)] diff --git a/simplecasts/podcasts/migrations/0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more.py b/simplecasts/podcasts/migrations/0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more.py deleted file mode 100644 index ee6fc365d3..0000000000 --- a/simplecasts/podcasts/migrations/0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.3 on 2025-06-18 13:14 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0044_promote_itunes_feeds"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_itunes__d69e24_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="itunes_ranking", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted"], name="podcasts_po_promote_fdc955_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0046_category_podcasts_ca_name_604e91_idx.py b/simplecasts/podcasts/migrations/0046_category_podcasts_ca_name_604e91_idx.py deleted file mode 100644 index a87fb0979f..0000000000 --- a/simplecasts/podcasts/migrations/0046_category_podcasts_ca_name_604e91_idx.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-06 21:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0045_remove_podcast_podcasts_po_itunes__d69e24_idx_and_more"), - ] - - operations = [ - migrations.AddIndex( - model_name="category", - index=models.Index(fields=["name"], name="podcasts_ca_name_604e91_idx"), - ), - ] diff --git a/simplecasts/podcasts/migrations/0047_remove_category_parent.py b/simplecasts/podcasts/migrations/0047_remove_category_parent.py deleted file mode 100644 index 2f8962ba65..0000000000 --- a/simplecasts/podcasts/migrations/0047_remove_category_parent.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-28 13:49 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0046_category_podcasts_ca_name_604e91_idx"), - ] - - operations = [ - migrations.RemoveField( - model_name="category", - name="parent", - ), - ] diff --git a/simplecasts/podcasts/migrations/0048_podcast_num_episodes.py b/simplecasts/podcasts/migrations/0048_podcast_num_episodes.py deleted file mode 100644 index c630ce6424..0000000000 --- a/simplecasts/podcasts/migrations/0048_podcast_num_episodes.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-12 20:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0047_remove_category_parent"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="num_episodes", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/simplecasts/podcasts/migrations/0049_set_podcast_num_episodes.py b/simplecasts/podcasts/migrations/0049_set_podcast_num_episodes.py deleted file mode 100644 index e243187a6c..0000000000 --- a/simplecasts/podcasts/migrations/0049_set_podcast_num_episodes.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-12 21:16 - -import itertools - -from django.db import migrations - - -def set_podcast_num_episodes(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - podcast_ids = Podcast.objects.values_list("id", flat=True) - - for batch in itertools.batched(podcast_ids, 1000, strict=False): - podcasts = Podcast.objects.filter(id__in=batch) - to_update = [] - for podcast in podcasts: - podcast.num_episodes = podcast.episodes.count() - to_update.append(podcast) - Podcast.objects.bulk_update(to_update, ["num_episodes"]) - - -def reverse_set_podcast_num_episodes(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(num_episodes__gt=0).update(num_episodes=0) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0048_podcast_num_episodes"), - ] - - operations = [ - migrations.RunPython( - set_podcast_num_episodes, - reverse_set_podcast_num_episodes, - ), - ] diff --git a/simplecasts/podcasts/migrations/0050_podcast_has_similar_podcasts.py b/simplecasts/podcasts/migrations/0050_podcast_has_similar_podcasts.py deleted file mode 100644 index 45870f4123..0000000000 --- a/simplecasts/podcasts/migrations/0050_podcast_has_similar_podcasts.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-12 21:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0049_set_podcast_num_episodes"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="has_similar_podcasts", - field=models.BooleanField(default=False), - ), - ] diff --git a/simplecasts/podcasts/migrations/0051_podcast_score.py b/simplecasts/podcasts/migrations/0051_podcast_score.py deleted file mode 100644 index 3e2fa8b7fd..0000000000 --- a/simplecasts/podcasts/migrations/0051_podcast_score.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 10:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0050_podcast_has_similar_podcasts"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="score", - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/simplecasts/podcasts/migrations/0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py b/simplecasts/podcasts/migrations/0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py deleted file mode 100644 index 51f85995db..0000000000 --- a/simplecasts/podcasts/migrations/0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 10:54 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0051_podcast_score"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_fdc955_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-score", "-pub_date"], name="podcasts_po_score_aeb891_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more.py b/simplecasts/podcasts/migrations/0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more.py deleted file mode 100644 index 4eda81e6cf..0000000000 --- a/simplecasts/podcasts/migrations/0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 17:45 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0052_remove_podcast_podcasts_po_promote_fdc955_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_score_aeb891_idx", - ), - migrations.RemoveField( - model_name="podcast", - name="score", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted"], name="podcasts_po_promote_fdc955_idx" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0054_category_itunes_genre_id.py b/simplecasts/podcasts/migrations/0054_category_itunes_genre_id.py deleted file mode 100644 index 761a2c34af..0000000000 --- a/simplecasts/podcasts/migrations/0054_category_itunes_genre_id.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 22:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0053_remove_podcast_podcasts_po_score_aeb891_idx_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="category", - name="itunes_genre_id", - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0055_itunes_genre_ids.py b/simplecasts/podcasts/migrations/0055_itunes_genre_ids.py deleted file mode 100644 index 7008832bba..0000000000 --- a/simplecasts/podcasts/migrations/0055_itunes_genre_ids.py +++ /dev/null @@ -1,144 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-14 22:28 - -from django.db import migrations - -_ITUNES_GENRE_IDS = ( - (1301, "Arts"), - (1302, "Personal Journals"), - (1303, "Comedy"), - (1304, "Education"), - (1305, "Kids & Family"), - (1306, "Food"), - (1309, "TV & Film"), - (1310, "Music"), - (1314, "Religion & Spirituality"), - (1318, "Technology"), - (1320, "Places & Travel"), - (1321, "Business"), - (1324, "Society & Culture"), - (1402, "Design"), - (1405, "Performing Arts"), - (1406, "Visual Arts"), - (1410, "Careers"), - (1412, "Investing"), - (1438, "Buddhism"), - (1439, "Christianity"), - (1440, "Islam"), - (1441, "Judaism"), - (1443, "Philosophy"), - (1444, "Spirituality"), - (1459, "Fashion & Beauty"), - (1463, "Hinduism"), - (1482, "Books"), - (1483, "Fiction"), - (1484, "Drama"), - (1485, "Science Fiction"), - (1486, "Comedy Fiction"), - (1487, "History"), - (1488, "True Crime"), - (1489, "News"), - (1490, "Business News"), - (1491, "Management"), - (1492, "Marketing"), - (1493, "Entrepreneurship"), - (1494, "Non-Profit"), - (1495, "Improv"), - (1496, "Comedy Interviews"), - (1497, "Stand Up"), - (1498, "Language Learning"), - (1499, "How-To"), - (1500, "Self-Improvement"), - (1501, "Courses"), - (1502, "Leisure"), - (1503, "Automotive"), - (1504, "Aviation"), - (1505, "Hobbies"), - (1506, "Crafts"), - (1507, "Games"), - (1508, "Home & Garden"), - (1509, "Video Games"), - (1510, "Animation & Manga"), - (1511, "Government"), - (1512, "Health & Fitness"), - (1513, "Alternative Health"), - (1514, "Fitness"), - (1515, "Nutrition"), - (1516, "Sexuality"), - (1517, "Mental Health"), - (1518, "Medicine"), - (1519, "Education for Kids"), - (1520, "Stories for Kids"), - (1521, "Parenting"), - (1522, "Pets & Animals"), - (1523, "Music Commentary"), - (1524, "Music History"), - (1525, "Music Interviews"), - (1526, "Daily News"), - (1527, "Politics"), - (1528, "Tech News"), - (1529, "Sports News"), - (1530, "News Commentary"), - (1531, "Entertainment News"), - (1532, "Religion"), - (1533, "Science"), - (1534, "Natural Sciences"), - (1535, "Social Sciences"), - (1536, "Mathematics"), - (1537, "Nature"), - (1538, "Astronomy"), - (1539, "Chemistry"), - (1540, "Earth Sciences"), - (1541, "Life Sciences"), - (1542, "Physics"), - (1543, "Documentary"), - (1544, "Relationships"), - (1545, "Sports"), - (1546, "Soccer"), - (1547, "Football"), - (1548, "Basketball"), - (1549, "Baseball"), - (1550, "Hockey"), - (1551, "Running"), - (1552, "Rugby"), - (1553, "Golf"), - (1554, "Cricket"), - (1555, "Wrestling"), - (1556, "Tennis"), - (1557, "Volleyball"), - (1558, "Swimming"), - (1559, "Wilderness"), - (1560, "Fantasy Sports"), - (1561, "TV Reviews"), - (1562, "After Shows"), - (1563, "Film Reviews"), - (1564, "Film History"), - (1565, "Film Interviews"), -) - - -def set_itunes_genre_ids(apps, schema_editor): - Category = apps.get_model("podcasts", "Category") - - categories_dct = Category.objects.select_for_update().in_bulk(field_name="name") - - for_update = [] - - for genre_id, genre_name in _ITUNES_GENRE_IDS: - if category := categories_dct.get(genre_name): - category.itunes_genre_id = genre_id - for_update.append(category) - - Category.objects.bulk_update(for_update, ["itunes_genre_id"]) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0054_category_itunes_genre_id"), - ] - - operations = [ - migrations.RunPython( - set_itunes_genre_ids, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0056_category_slug.py b/simplecasts/podcasts/migrations/0056_category_slug.py deleted file mode 100644 index 3e5d24134e..0000000000 --- a/simplecasts/podcasts/migrations/0056_category_slug.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-17 16:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0055_itunes_genre_ids"), - ] - - operations = [ - migrations.AddField( - model_name="category", - name="slug", - field=models.SlugField(blank=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0057_set_category_slugs.py b/simplecasts/podcasts/migrations/0057_set_category_slugs.py deleted file mode 100644 index 48ecd58c64..0000000000 --- a/simplecasts/podcasts/migrations/0057_set_category_slugs.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-17 16:15 - -from django.db import migrations -from django.utils.text import slugify - - -def set_podcast_slugs(apps, schema_editor): - Category = apps.get_model("podcasts", "Category") - to_update = [] - for category in Category.objects.all(): - if not category.slug: - category.slug = slugify(category.name) - to_update.append(category) - - Category.objects.bulk_update(to_update, ["slug"]) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0056_category_slug"), - ] - - operations = [ - migrations.RunPython( - set_podcast_slugs, - migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0058_alter_category_slug.py b/simplecasts/podcasts/migrations/0058_alter_category_slug.py deleted file mode 100644 index c04010e63b..0000000000 --- a/simplecasts/podcasts/migrations/0058_alter_category_slug.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-17 16:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0057_set_category_slugs"), - ] - - operations = [ - migrations.AlterField( - model_name="category", - name="slug", - field=models.SlugField(unique=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0059_remove_podcast_keywords.py b/simplecasts/podcasts/migrations/0059_remove_podcast_keywords.py deleted file mode 100644 index dc8b63c453..0000000000 --- a/simplecasts/podcasts/migrations/0059_remove_podcast_keywords.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 14:16 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0058_alter_category_slug"), - ] - - operations = [ - migrations.RunSQL( - """ -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner -);""", - reverse_sql=""" -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner, keywords -); -""", - ), - migrations.RemoveField( - model_name="podcast", - name="keywords", - ), - ] diff --git a/simplecasts/podcasts/migrations/0060_podcast_keywords.py b/simplecasts/podcasts/migrations/0060_podcast_keywords.py deleted file mode 100644 index 7d792f784c..0000000000 --- a/simplecasts/podcasts/migrations/0060_podcast_keywords.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 17:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0059_remove_podcast_keywords"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="keywords", - field=models.TextField(blank=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0061_update_podcast_search_trigger.py b/simplecasts/podcasts/migrations/0061_update_podcast_search_trigger.py deleted file mode 100644 index c164411cdd..0000000000 --- a/simplecasts/podcasts/migrations/0061_update_podcast_search_trigger.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 17:29 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0060_podcast_keywords"), - ] - - operations = [ - migrations.RunSQL( - """ -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner, keywords -);""", - reverse_sql=""" -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner -); -""", - ) - ] diff --git a/simplecasts/podcasts/migrations/0062_remove_podcast_podcasts_podcast_lwr_title_idx.py b/simplecasts/podcasts/migrations/0062_remove_podcast_podcasts_podcast_lwr_title_idx.py deleted file mode 100644 index 8e194c3936..0000000000 --- a/simplecasts/podcasts/migrations/0062_remove_podcast_podcasts_podcast_lwr_title_idx.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-22 21:53 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0061_update_podcast_search_trigger"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_podcast_lwr_title_idx", - ), - ] diff --git a/simplecasts/podcasts/migrations/0063_update_podcast_search_trigger_with_simple.py b/simplecasts/podcasts/migrations/0063_update_podcast_search_trigger_with_simple.py deleted file mode 100644 index 66315d1c01..0000000000 --- a/simplecasts/podcasts/migrations/0063_update_podcast_search_trigger_with_simple.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-22 22:08 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0062_remove_podcast_podcasts_podcast_lwr_title_idx"), - ] - - operations = [ - migrations.RunSQL( - """ -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.simple', - title, owner, keywords -);""", - reverse_sql=""" -DROP TRIGGER IF EXISTS podcast_update_search_trigger ON podcasts_podcast; -CREATE TRIGGER podcast_update_search_trigger -BEFORE INSERT OR UPDATE OF title, owner, keywords, search_vector -ON podcasts_podcast -FOR EACH ROW -EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.english', - title, owner, keywords -); -""", - ) - ] diff --git a/simplecasts/podcasts/migrations/0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py b/simplecasts/podcasts/migrations/0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py deleted file mode 100644 index 0df9cfc0fb..0000000000 --- a/simplecasts/podcasts/migrations/0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 10:11 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0063_update_podcast_search_trigger_with_simple"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_fdc955_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted", "language", "-pub_date"], - name="podcasts_po_promote_54fdb6_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted", "parsed", "updated"], - name="podcasts_po_promote_a89157_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more.py b/simplecasts/podcasts/migrations/0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more.py deleted file mode 100644 index 7854e3fd25..0000000000 --- a/simplecasts/podcasts/migrations/0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-23 10:23 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0064_remove_podcast_podcasts_po_promote_fdc955_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_pub_dat_2e433a_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_a4c988_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_parser__9f31ab_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_a89157_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted", "parsed", "updated"], - name="podcasts_po_active_b74445_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - condition=models.Q(("private", False), ("pub_date__isnull", False)), - fields=["-pub_date"], - name="podcasts_podcast_public_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0066_podcast_podcasts_podcast_owner_idx.py b/simplecasts/podcasts/migrations/0066_podcast_podcasts_podcast_owner_idx.py deleted file mode 100644 index 8033640bfc..0000000000 --- a/simplecasts/podcasts/migrations/0066_podcast_podcasts_podcast_owner_idx.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 10:21 - -import django.db.models.functions.text -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0065_remove_podcast_podcasts_po_pub_dat_2e433a_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name="podcast", - index=models.Index( - django.db.models.functions.text.Lower("owner"), - name="podcasts_podcast_owner_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0067_remove_podcast_podcasts_podcast_owner_idx_and_more.py b/simplecasts/podcasts/migrations/0067_remove_podcast_podcasts_podcast_owner_idx_and_more.py deleted file mode 100644 index 5fecb70b7c..0000000000 --- a/simplecasts/podcasts/migrations/0067_remove_podcast_podcasts_podcast_owner_idx_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 10:51 - -import django.contrib.postgres.indexes -import django.contrib.postgres.search -from django.conf import settings -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0066_podcast_podcasts_podcast_owner_idx"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_podcast_owner_idx", - ), - migrations.AddField( - model_name="podcast", - name="owner_search_vector", - field=django.contrib.postgres.search.SearchVectorField( - editable=False, null=True - ), - ), - migrations.AddIndex( - model_name="podcast", - index=django.contrib.postgres.indexes.GinIndex( - fields=["owner_search_vector"], name="podcasts_po_owner_s_734402_gin" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0068_create_owner_search_trigger.py b/simplecasts/podcasts/migrations/0068_create_owner_search_trigger.py deleted file mode 100644 index d1af3f6b68..0000000000 --- a/simplecasts/podcasts/migrations/0068_create_owner_search_trigger.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 10:51 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0067_remove_podcast_podcasts_podcast_owner_idx_and_more"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -CREATE TRIGGER podcast_update_owner_search_trigger -BEFORE INSERT OR UPDATE OF owner ON podcasts_podcast -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( - owner_search_vector, 'pg_catalog.simple', owner -); -UPDATE podcasts_podcast SET owner = owner;""", - reverse_sql="DROP TRIGGER IF EXISTS podcast_update_owner_search_trigger ON podcasts_podcast;", - ), - ] diff --git a/simplecasts/podcasts/migrations/0069_category_search_vector_and_more.py b/simplecasts/podcasts/migrations/0069_category_search_vector_and_more.py deleted file mode 100644 index 290c3d917f..0000000000 --- a/simplecasts/podcasts/migrations/0069_category_search_vector_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 12:01 - -import django.contrib.postgres.indexes -import django.contrib.postgres.search -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0068_create_owner_search_trigger"), - ] - - operations = [ - migrations.AddField( - model_name="category", - name="search_vector", - field=django.contrib.postgres.search.SearchVectorField( - editable=False, null=True - ), - ), - migrations.AddIndex( - model_name="category", - index=django.contrib.postgres.indexes.GinIndex( - fields=["search_vector"], name="podcasts_ca_search__6e34c5_gin" - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0070_create_category_name_search_trigger.py b/simplecasts/podcasts/migrations/0070_create_category_name_search_trigger.py deleted file mode 100644 index 9bca678241..0000000000 --- a/simplecasts/podcasts/migrations/0070_create_category_name_search_trigger.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-07 12:02 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0069_category_search_vector_and_more"), - ] - - operations = [ - migrations.RunSQL( - sql=""" -CREATE TRIGGER category_update_search_trigger -BEFORE INSERT OR UPDATE OF name ON podcasts_category -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.simple', name -); -UPDATE podcasts_category SET name = name;""", - reverse_sql="DROP TRIGGER IF EXISTS category_update_search_trigger ON podcasts_category;", - ), - ] diff --git a/simplecasts/podcasts/migrations/0071_podcast_queued.py b/simplecasts/podcasts/migrations/0071_podcast_queued.py deleted file mode 100644 index ea83ee904c..0000000000 --- a/simplecasts/podcasts/migrations/0071_podcast_queued.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-08 22:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0070_create_category_name_search_trigger"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="queued", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more.py b/simplecasts/podcasts/migrations/0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more.py deleted file mode 100644 index 796c523c54..0000000000 --- a/simplecasts/podcasts/migrations/0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 11:10 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0071_podcast_queued"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_54fdb6_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_b74445_idx", - ), - migrations.AddField( - model_name="podcast", - name="promoted_at", - field=models.DateField(blank=True, null=True), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["promoted_at", "language", "-pub_date"], - name="podcasts_po_promote_a84cd9_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted_at", "parsed", "updated"], - name="podcasts_po_active_aed488_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0073_set_default_promoted_at.py b/simplecasts/podcasts/migrations/0073_set_default_promoted_at.py deleted file mode 100644 index 79a062da17..0000000000 --- a/simplecasts/podcasts/migrations/0073_set_default_promoted_at.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 11:10 - -from django.db import migrations -from django.utils import timezone - - -def set_default_promoted_at(apps, schema_editor) -> None: - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(promoted=True, promoted_at__isnull=True).update( - promoted_at=timezone.now().today() - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0072_remove_podcast_podcasts_po_promote_54fdb6_idx_and_more"), - ] - - operations = [ - migrations.RunPython( - set_default_promoted_at, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more.py b/simplecasts/podcasts/migrations/0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more.py deleted file mode 100644 index 0a0b007423..0000000000 --- a/simplecasts/podcasts/migrations/0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 11:13 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0073_set_default_promoted_at"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_a84cd9_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted_at", "language", "-pub_date"], - name="podcasts_po_promote_ab9069_idx", - ), - ), - migrations.RemoveField( - model_name="podcast", - name="promoted", - ), - ] diff --git a/simplecasts/podcasts/migrations/0075_remove_podcast_podcasts_po_active_aed488_idx_and_more.py b/simplecasts/podcasts/migrations/0075_remove_podcast_podcasts_po_active_aed488_idx_and_more.py deleted file mode 100644 index d235d8eea3..0000000000 --- a/simplecasts/podcasts/migrations/0075_remove_podcast_podcasts_po_active_aed488_idx_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 11:14 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0074_remove_podcast_podcasts_po_promote_a84cd9_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_aed488_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_ab9069_idx", - ), - migrations.RenameField( - model_name="podcast", - old_name="promoted_at", - new_name="promoted", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted", "language", "-pub_date"], - name="podcasts_po_promote_047f56_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted", "parsed", "updated"], - name="podcasts_po_active_b74445_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0076_remove_podcast_queued.py b/simplecasts/podcasts/migrations/0076_remove_podcast_queued.py deleted file mode 100644 index 51cc526f99..0000000000 --- a/simplecasts/podcasts/migrations/0076_remove_podcast_queued.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 16:31 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0075_remove_podcast_podcasts_po_active_aed488_idx_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="queued", - ), - ] diff --git a/simplecasts/podcasts/migrations/0077_podcast_promoted_at.py b/simplecasts/podcasts/migrations/0077_podcast_promoted_at.py deleted file mode 100644 index 73ca964824..0000000000 --- a/simplecasts/podcasts/migrations/0077_podcast_promoted_at.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 16:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0076_remove_podcast_queued"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="promoted_at", - field=models.BooleanField(default=False), - ), - ] diff --git a/simplecasts/podcasts/migrations/0078_set_default_is_promoted.py b/simplecasts/podcasts/migrations/0078_set_default_is_promoted.py deleted file mode 100644 index b26c71b5a1..0000000000 --- a/simplecasts/podcasts/migrations/0078_set_default_is_promoted.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 16:59 - -from django.db import migrations - - -def set_is_promoted(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(promoted__isnull=False).update(promoted_at=True) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0077_podcast_promoted_at"), - ] - - operations = [ - migrations.RunPython(set_is_promoted, reverse_code=migrations.RunPython.noop) - ] diff --git a/simplecasts/podcasts/migrations/0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more.py b/simplecasts/podcasts/migrations/0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more.py deleted file mode 100644 index dbcf836d29..0000000000 --- a/simplecasts/podcasts/migrations/0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 17:05 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0078_set_default_is_promoted"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_047f56_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_b74445_idx", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted_at", "language", "-pub_date"], - name="podcasts_po_promote_ab9069_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted_at", "parsed", "updated"], - name="podcasts_po_active_aed488_idx", - ), - ), - migrations.RemoveField( - model_name="podcast", - name="promoted", - ), - ] diff --git a/simplecasts/podcasts/migrations/0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more.py b/simplecasts/podcasts/migrations/0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more.py deleted file mode 100644 index a4f2a6c247..0000000000 --- a/simplecasts/podcasts/migrations/0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 17:06 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0079_remove_podcast_podcasts_po_promote_047f56_idx_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_promote_ab9069_idx", - ), - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_active_aed488_idx", - ), - migrations.RenameField( - model_name="podcast", - old_name="promoted_at", - new_name="promoted", - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["-promoted", "language", "-pub_date"], - name="podcasts_po_promote_047f56_idx", - ), - ), - migrations.AddIndex( - model_name="podcast", - index=models.Index( - fields=["active", "-promoted", "parsed", "updated"], - name="podcasts_po_active_b74445_idx", - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0081_remove_category_podcasts_ca_name_604e91_idx_and_more.py b/simplecasts/podcasts/migrations/0081_remove_category_podcasts_ca_name_604e91_idx_and_more.py deleted file mode 100644 index bd2ef5f276..0000000000 --- a/simplecasts/podcasts/migrations/0081_remove_category_podcasts_ca_name_604e91_idx_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 19:14 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0080_remove_podcast_podcasts_po_promote_ab9069_idx_and_more"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="category", - name="podcasts_ca_name_604e91_idx", - ), - migrations.RemoveIndex( - model_name="category", - name="podcasts_ca_search__6e34c5_gin", - ), - migrations.RemoveField( - model_name="category", - name="search_vector", - ), - ] diff --git a/simplecasts/podcasts/migrations/0082_remove_category_search_trigger.py b/simplecasts/podcasts/migrations/0082_remove_category_search_trigger.py deleted file mode 100644 index 7c886bc4e3..0000000000 --- a/simplecasts/podcasts/migrations/0082_remove_category_search_trigger.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-09 19:21 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0081_remove_category_podcasts_ca_name_604e91_idx_and_more"), - ] - - operations = [ - migrations.RunSQL( - "DROP TRIGGER IF EXISTS category_update_search_trigger ON podcasts_category;", - reverse_sql=""" -CREATE TRIGGER category_update_search_trigger -BEFORE INSERT OR UPDATE OF name ON podcasts_category -FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger( - search_vector, 'pg_catalog.simple', name -); -UPDATE podcasts_category SET name = name;""", - ) - ] diff --git a/simplecasts/podcasts/migrations/0083_remove_podcast_has_similar_podcasts.py b/simplecasts/podcasts/migrations/0083_remove_podcast_has_similar_podcasts.py deleted file mode 100644 index d0bc0367e5..0000000000 --- a/simplecasts/podcasts/migrations/0083_remove_podcast_has_similar_podcasts.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-10 23:01 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0082_remove_category_search_trigger"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="has_similar_podcasts", - ), - ] diff --git a/simplecasts/podcasts/migrations/0084_remove_podcast_num_retries.py b/simplecasts/podcasts/migrations/0084_remove_podcast_num_retries.py deleted file mode 100644 index d9b8ac0782..0000000000 --- a/simplecasts/podcasts/migrations/0084_remove_podcast_num_retries.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-17 20:41 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0083_remove_podcast_has_similar_podcasts"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="num_retries", - ), - ] diff --git a/simplecasts/podcasts/migrations/0085_alter_podcast_parser_result.py b/simplecasts/podcasts/migrations/0085_alter_podcast_parser_result.py deleted file mode 100644 index e3671c885e..0000000000 --- a/simplecasts/podcasts/migrations/0085_alter_podcast_parser_result.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 6.0 on 2025-12-18 10:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0084_remove_podcast_num_retries"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="parser_result", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("invalid_rss", "Invalid RSS"), - ("permanent_network_error", "Permanent Network Error"), - ("temporary_network_error", "Temporary Network Error"), - ("database_error", "Database Error"), - ("invalid_data", "Invalid Data"), - ("unavailable", "Unavailable"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0086_update_parser_results.py b/simplecasts/podcasts/migrations/0086_update_parser_results.py deleted file mode 100644 index 1dc4690201..0000000000 --- a/simplecasts/podcasts/migrations/0086_update_parser_results.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 6.0 on 2025-12-18 10:30 - -from django.db import migrations - - -def update_parser_results(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - Podcast.objects.filter(parser_result="unavailable").update( - parser_result="temporary_network_error" - ) - - Podcast.objects.filter(parser_result="invalid_data").update( - parser_result="database_error" - ) - - -def reverse_parser_results(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - Podcast.objects.filter( - parser_result__in=("temporary_network_error", "permanent_network_error") - ).update(parser_result="unavailable") - - Podcast.objects.filter(parser_result="database_error").update( - parser_result="invalid_data" - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0085_alter_podcast_parser_result"), - ] - - operations = [ - migrations.RunPython(update_parser_results, reverse_parser_results), - ] diff --git a/simplecasts/podcasts/migrations/0087_alter_podcast_parser_result.py b/simplecasts/podcasts/migrations/0087_alter_podcast_parser_result.py deleted file mode 100644 index 8e0edc5911..0000000000 --- a/simplecasts/podcasts/migrations/0087_alter_podcast_parser_result.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 6.0 on 2025-12-18 11:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0086_update_parser_results"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="parser_result", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("invalid_rss", "Invalid RSS"), - ("permanent_network_error", "Permanent Network Error"), - ("temporary_network_error", "Temporary Network Error"), - ("database_error", "Database Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0088_remove_podcast_podcasts_po_content_736948_idx.py b/simplecasts/podcasts/migrations/0088_remove_podcast_podcasts_po_content_736948_idx.py deleted file mode 100644 index 31be8a8ca8..0000000000 --- a/simplecasts/podcasts/migrations/0088_remove_podcast_podcasts_po_content_736948_idx.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-18 16:23 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0087_alter_podcast_parser_result"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="podcast", - name="podcasts_po_content_736948_idx", - ), - ] diff --git a/simplecasts/podcasts/migrations/0089_remove_podcast_parser_result.py b/simplecasts/podcasts/migrations/0089_remove_podcast_parser_result.py deleted file mode 100644 index 4fa417210c..0000000000 --- a/simplecasts/podcasts/migrations/0089_remove_podcast_parser_result.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-19 11:13 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0088_remove_podcast_podcasts_po_content_736948_idx"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="parser_result", - ), - ] diff --git a/simplecasts/podcasts/migrations/0090_podcast_http_status.py b/simplecasts/podcasts/migrations/0090_podcast_http_status.py deleted file mode 100644 index de4c762ea9..0000000000 --- a/simplecasts/podcasts/migrations/0090_podcast_http_status.py +++ /dev/null @@ -1,84 +0,0 @@ -# Generated by Django 6.0 on 2025-12-19 11:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0089_remove_podcast_parser_result"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="http_status", - field=models.PositiveSmallIntegerField( - blank=True, - choices=[ - (100, "Continue"), - (101, "Switching Protocols"), - (102, "Processing"), - (103, "Early Hints"), - (200, "OK"), - (201, "Created"), - (202, "Accepted"), - (203, "Non-Authoritative Information"), - (204, "No Content"), - (205, "Reset Content"), - (206, "Partial Content"), - (207, "Multi-Status"), - (208, "Already Reported"), - (226, "IM Used"), - (300, "Multiple Choices"), - (301, "Moved Permanently"), - (302, "Found"), - (303, "See Other"), - (304, "Not Modified"), - (305, "Use Proxy"), - (307, "Temporary Redirect"), - (308, "Permanent Redirect"), - (400, "Bad Request"), - (401, "Unauthorized"), - (402, "Payment Required"), - (403, "Forbidden"), - (404, "Not Found"), - (405, "Method Not Allowed"), - (406, "Not Acceptable"), - (407, "Proxy Authentication Required"), - (408, "Request Timeout"), - (409, "Conflict"), - (410, "Gone"), - (411, "Length Required"), - (412, "Precondition Failed"), - (413, "Content Too Large"), - (414, "URI Too Long"), - (415, "Unsupported Media Type"), - (416, "Range Not Satisfiable"), - (417, "Expectation Failed"), - (418, "I'm a Teapot"), - (421, "Misdirected Request"), - (422, "Unprocessable Content"), - (423, "Locked"), - (424, "Failed Dependency"), - (425, "Too Early"), - (426, "Upgrade Required"), - (428, "Precondition Required"), - (429, "Too Many Requests"), - (431, "Request Header Fields Too Large"), - (451, "Unavailable For Legal Reasons"), - (500, "Internal Server Error"), - (501, "Not Implemented"), - (502, "Bad Gateway"), - (503, "Service Unavailable"), - (504, "Gateway Timeout"), - (505, "HTTP Version Not Supported"), - (506, "Variant Also Negotiates"), - (507, "Insufficient Storage"), - (508, "Loop Detected"), - (510, "Not Extended"), - (511, "Network Authentication Required"), - ], - null=True, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0091_podcast_feed_status.py b/simplecasts/podcasts/migrations/0091_podcast_feed_status.py deleted file mode 100644 index 123bb5a5c2..0000000000 --- a/simplecasts/podcasts/migrations/0091_podcast_feed_status.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 6.0 on 2025-12-19 16:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0090_podcast_http_status"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("ok", "OK"), - ("not_modified", "Not Modified"), - ("invalid_rss", "Invalid RSS"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("database_error", "Database Error"), - ("temporary_http_error", "Temporary HTTP Error"), - ("permanent_http_error", "Permanent HTTP Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0092_default_feed_status.py b/simplecasts/podcasts/migrations/0092_default_feed_status.py deleted file mode 100644 index af248cc0cd..0000000000 --- a/simplecasts/podcasts/migrations/0092_default_feed_status.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 6.0 on 2025-12-19 16:13 - -import http - -from django.db import migrations - - -def set_default_feed_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - podcasts = Podcast.objects.filter(feed_status="") - - # Any podcast with a canonical ID is "duplicate" - - podcasts.filter(canonical_id__isnull=False).update(feed_status="duplicate") - - # Any podcast with HTTP 304 status is "not_modified" - - podcasts.filter(http_status=http.HTTPStatus.NOT_MODIFIED).update( - feed_status="not_modified" - ) - - # Any podcast with specific permanent HTTP error statuses is "permanent_http_error" - podcasts.filter( - http_status__in=( - http.HTTPStatus.BAD_REQUEST, - http.HTTPStatus.FORBIDDEN, - http.HTTPStatus.METHOD_NOT_ALLOWED, - http.HTTPStatus.NOT_ACCEPTABLE, - http.HTTPStatus.NOT_FOUND, - http.HTTPStatus.UNAUTHORIZED, - http.HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS, - ) - ).update(feed_status="permanent_http_error") - - # Any podcast with HTTP 410 status is "discontinued" - - podcasts.filter(http_status=http.HTTPStatus.GONE).update(feed_status="discontinued") - - # Any podcast with not OK status (excluding the above) is "temporary_http_error" - # - podcasts.filter(http_status__isnull=False).exclude( - http_status=http.HTTPStatus.OK - ).update(feed_status="temporary_http_error") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0091_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - set_default_feed_status, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0093_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0093_alter_podcast_feed_status.py deleted file mode 100644 index 48d1ae7b8d..0000000000 --- a/simplecasts/podcasts/migrations/0093_alter_podcast_feed_status.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 10:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0092_default_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("ok", "OK"), - ("not_modified", "Not Modified"), - ("invalid_rss", "Invalid RSS"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("database_error", "Database Error"), - ("permanent_http_error", "Permanent HTTP Error"), - ("temporary_http_error", "Temporary HTTP Error"), - ("network_error", "Network Error"), - ("transient_http_error", "Transient HTTP Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0094_update_feed_status.py b/simplecasts/podcasts/migrations/0094_update_feed_status.py deleted file mode 100644 index 07d1830185..0000000000 --- a/simplecasts/podcasts/migrations/0094_update_feed_status.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 10:37 - -from django.db import migrations - - -def update_feed_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - # Any podcast with feed_status 'temporary_http_error' and 'http_status' 'OK' or 'Not Modified' or NULL: - # should have their feed_status updated to 'network_error'. - # - Podcast.objects.filter( - feed_status="temporary_http_error", - ).filter(http_status__in=(200, 304)).update(feed_status="network_error") - - Podcast.objects.filter( - feed_status="temporary_http_error", - ).exclude(http_status__isnull=True).update(feed_status="network_error") - - # Any other podcast with feed_status 'temporary_http_error' should have their feed_status updated to 'transient_feed_error'. - Podcast.objects.filter( - feed_status="temporary_http_error", - ).update(feed_status="transient_feed_error") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0093_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - update_feed_status, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0095_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0095_alter_podcast_feed_status.py deleted file mode 100644 index 0b5ecec0bc..0000000000 --- a/simplecasts/podcasts/migrations/0095_alter_podcast_feed_status.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 10:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0094_update_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("ok", "OK"), - ("not_modified", "Not Modified"), - ("invalid_rss", "Invalid RSS"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("database_error", "Database Error"), - ("transient_http_error", "Transient HTTP Error"), - ("permanent_http_error", "Permanent HTTP Error"), - ("network_error", "Network Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0096_update_feed_status.py b/simplecasts/podcasts/migrations/0096_update_feed_status.py deleted file mode 100644 index 88fca9b06d..0000000000 --- a/simplecasts/podcasts/migrations/0096_update_feed_status.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 14:14 - -from django.db import migrations - - -def update_feed_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter( - feed_status__in=( - "transient_http_error", - "permanent_http_error", - ) - ).update(feed_status="network_error") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0095_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - update_feed_status, - reverse_code=migrations.RunPython.noop, - ) - ] diff --git a/simplecasts/podcasts/migrations/0097_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0097_alter_podcast_feed_status.py deleted file mode 100644 index 9d9f92f3d6..0000000000 --- a/simplecasts/podcasts/migrations/0097_alter_podcast_feed_status.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 14:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0096_update_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("ok", "OK"), - ("not_modified", "Not Modified"), - ("invalid_rss", "Invalid RSS"), - ("duplicate", "Duplicate"), - ("discontinued", "Discontinued"), - ("database_error", "Database Error"), - ("network_error", "Network Error"), - ], - max_length=30, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0098_remove_podcast_feed_status_and_more.py b/simplecasts/podcasts/migrations/0098_remove_podcast_feed_status_and_more.py deleted file mode 100644 index a88ad8537f..0000000000 --- a/simplecasts/podcasts/migrations/0098_remove_podcast_feed_status_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 14:35 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0097_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="feed_status", - ), - migrations.RemoveField( - model_name="podcast", - name="http_status", - ), - ] diff --git a/simplecasts/podcasts/migrations/0099_podcast_feed_last_updated.py b/simplecasts/podcasts/migrations/0099_podcast_feed_last_updated.py deleted file mode 100644 index f2a4809f7b..0000000000 --- a/simplecasts/podcasts/migrations/0099_podcast_feed_last_updated.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 14:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0098_remove_podcast_feed_status_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="feed_last_updated", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0100_podcast_exception.py b/simplecasts/podcasts/migrations/0100_podcast_exception.py deleted file mode 100644 index 89a77406c7..0000000000 --- a/simplecasts/podcasts/migrations/0100_podcast_exception.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 15:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0099_podcast_feed_last_updated"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="exception", - field=models.TextField(blank=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0101_podcast_traceback_alter_podcast_exception.py b/simplecasts/podcasts/migrations/0101_podcast_traceback_alter_podcast_exception.py deleted file mode 100644 index 539d3a228c..0000000000 --- a/simplecasts/podcasts/migrations/0101_podcast_traceback_alter_podcast_exception.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 17:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0100_podcast_exception"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="traceback", - field=models.TextField(blank=True), - ), - migrations.AlterField( - model_name="podcast", - name="exception", - field=models.CharField(blank=True, max_length=200), - ), - ] diff --git a/simplecasts/podcasts/migrations/0102_remove_podcast_traceback.py b/simplecasts/podcasts/migrations/0102_remove_podcast_traceback.py deleted file mode 100644 index 3eac69fc0a..0000000000 --- a/simplecasts/podcasts/migrations/0102_remove_podcast_traceback.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 20:58 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0101_podcast_traceback_alter_podcast_exception"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="traceback", - ), - ] diff --git a/simplecasts/podcasts/migrations/0103_alter_podcast_exception.py b/simplecasts/podcasts/migrations/0103_alter_podcast_exception.py deleted file mode 100644 index 785e43b2d9..0000000000 --- a/simplecasts/podcasts/migrations/0103_alter_podcast_exception.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0 on 2025-12-20 21:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0102_remove_podcast_traceback"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="exception", - field=models.TextField(blank=True), - ), - ] diff --git a/simplecasts/podcasts/migrations/0104_podcast_feed_status.py b/simplecasts/podcasts/migrations/0104_podcast_feed_status.py deleted file mode 100644 index 48a67c24c8..0000000000 --- a/simplecasts/podcasts/migrations/0104_podcast_feed_status.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 6.0 on 2025-12-22 11:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0103_alter_podcast_exception"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("error", "Error"), - ], - max_length=20, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0105_update_feed_status.py b/simplecasts/podcasts/migrations/0105_update_feed_status.py deleted file mode 100644 index 08c5c7344c..0000000000 --- a/simplecasts/podcasts/migrations/0105_update_feed_status.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 6.0 on 2025-12-22 11:50 - -from django.db import migrations -from django.db.models import F - - -def update_feed_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - - # If exception set to "error" - Podcast.objects.exclude(exception="").update(feed_status="error") - - # If inactive + canonical_id not NULL, set to "duplicate" - Podcast.objects.filter(active=False, canonical__isnull=False).update( - feed_status="duplicate" - ) - - # If inactive + canonical_id is NULL, set to "discontinued" - - Podcast.objects.filter(active=False, canonical__isnull=True).update( - feed_status="discontinued" - ) - - # Any with active=True and exception ="" and parsed == feed_last_updated, set to "success" - Podcast.objects.filter( - active=True, - exception="", - parsed=F("feed_last_updated"), - ).update(feed_status="success") - - # Any with active=True and exception ="" and parsed != feed_last_updated, set to "not_modified" - # - Podcast.objects.filter( - active=True, - exception="", - ).exclude( - parsed=F("feed_last_updated"), - ).update(feed_status="not_modified") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0104_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - update_feed_status, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/simplecasts/podcasts/migrations/0106_podcast_num_retries_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0106_podcast_num_retries_alter_podcast_feed_status.py deleted file mode 100644 index 5849c3fc94..0000000000 --- a/simplecasts/podcasts/migrations/0106_podcast_num_retries_alter_podcast_feed_status.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 6.0 on 2025-12-23 12:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0105_update_feed_status"), - ] - - operations = [ - migrations.AddField( - model_name="podcast", - name="num_retries", - field=models.PositiveIntegerField(default=0), - ), - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_rss", "Invalid RSS"), - ("unavailable", "Unavailable"), - ("error", "Error"), - ], - max_length=20, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0107_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0107_alter_podcast_feed_status.py deleted file mode 100644 index 9423646cc4..0000000000 --- a/simplecasts/podcasts/migrations/0107_alter_podcast_feed_status.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0 on 2025-12-23 21:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0106_podcast_num_retries_alter_podcast_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("error", "Database Error"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_rss", "Invalid RSS"), - ("unavailable", "Unavailable"), - ], - max_length=20, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0108_update_feed_status.py b/simplecasts/podcasts/migrations/0108_update_feed_status.py deleted file mode 100644 index c92806deb7..0000000000 --- a/simplecasts/podcasts/migrations/0108_update_feed_status.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 6.0 on 2025-12-23 21:57 - -from django.db import migrations - - -def update_database_error_status(apps, schema_editor): - Podcast = apps.get_model("podcasts", "Podcast") - Podcast.objects.filter(feed_status="error").update(feed_status="database_error") - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0107_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RunPython( - update_database_error_status, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/simplecasts/podcasts/migrations/0109_alter_podcast_feed_status.py b/simplecasts/podcasts/migrations/0109_alter_podcast_feed_status.py deleted file mode 100644 index d198fccba6..0000000000 --- a/simplecasts/podcasts/migrations/0109_alter_podcast_feed_status.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 6.0 on 2025-12-23 21:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0108_update_feed_status"), - ] - - operations = [ - migrations.AlterField( - model_name="podcast", - name="feed_status", - field=models.CharField( - blank=True, - choices=[ - ("success", "Success"), - ("not_modified", "Not Modified"), - ("database_error", "Database Error"), - ("discontinued", "Discontinued"), - ("duplicate", "Duplicate"), - ("invalid_rss", "Invalid RSS"), - ("unavailable", "Unavailable"), - ], - max_length=20, - ), - ), - ] diff --git a/simplecasts/podcasts/migrations/0110_remove_podcast_feed_last_updated.py b/simplecasts/podcasts/migrations/0110_remove_podcast_feed_last_updated.py deleted file mode 100644 index 453f84fd38..0000000000 --- a/simplecasts/podcasts/migrations/0110_remove_podcast_feed_last_updated.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0 on 2025-12-24 17:23 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("podcasts", "0109_alter_podcast_feed_status"), - ] - - operations = [ - migrations.RemoveField( - model_name="podcast", - name="feed_last_updated", - ), - ] diff --git a/simplecasts/podcasts/migrations/__init__.py b/simplecasts/podcasts/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/podcasts/migrations/max_migration.txt b/simplecasts/podcasts/migrations/max_migration.txt deleted file mode 100644 index b365319b13..0000000000 --- a/simplecasts/podcasts/migrations/max_migration.txt +++ /dev/null @@ -1 +0,0 @@ -0110_remove_podcast_feed_last_updated diff --git a/simplecasts/podcasts/parsers/__init__.py b/simplecasts/podcasts/parsers/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/podcasts/parsers/tests/__init__.py b/simplecasts/podcasts/parsers/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/podcasts/tests/__init__.py b/simplecasts/podcasts/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/podcasts/tests/factories.py b/simplecasts/podcasts/tests/factories.py deleted file mode 100644 index 1ab6b1f793..0000000000 --- a/simplecasts/podcasts/tests/factories.py +++ /dev/null @@ -1,50 +0,0 @@ -import factory -from django.utils import timezone - -from simplecasts.podcasts.models import ( - Category, - Podcast, - Recommendation, - Subscription, -) -from simplecasts.users.tests.factories import UserFactory - - -class CategoryFactory(factory.django.DjangoModelFactory): - name = factory.Sequence(lambda n: f"Category {n}") - - class Meta: - model = Category - - -class PodcastFactory(factory.django.DjangoModelFactory): - title = factory.Faker("text") - rss = factory.Sequence(lambda n: f"https://{n}.example.com") - pub_date = factory.LazyFunction(timezone.now) - cover_url = "https://example.com/cover.jpg" - - class Meta: - model = Podcast - - @factory.post_generation - def categories(self, create, extracted, **kwargs): - if create and extracted: - self.categories.set(extracted) - - -class RecommendationFactory(factory.django.DjangoModelFactory): - score = 0.5 - - podcast = factory.SubFactory(PodcastFactory) - recommended = factory.SubFactory(PodcastFactory) - - class Meta: - model = Recommendation - - -class SubscriptionFactory(factory.django.DjangoModelFactory): - subscriber = factory.SubFactory(UserFactory) - podcast = factory.SubFactory(PodcastFactory) - - class Meta: - model = Subscription diff --git a/simplecasts/podcasts/tests/fixtures.py b/simplecasts/podcasts/tests/fixtures.py deleted file mode 100644 index e7f1e2c98f..0000000000 --- a/simplecasts/podcasts/tests/fixtures.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest - -from simplecasts.podcasts.models import Category, Podcast -from simplecasts.podcasts.tests.factories import ( - CategoryFactory, - PodcastFactory, -) - - -@pytest.fixture -def podcast() -> Podcast: - return PodcastFactory() - - -@pytest.fixture -def category() -> Category: - return CategoryFactory() diff --git a/simplecasts/podcasts/tests/test_commands.py b/simplecasts/podcasts/tests/test_commands.py deleted file mode 100644 index 6a41452e1f..0000000000 --- a/simplecasts/podcasts/tests/test_commands.py +++ /dev/null @@ -1,117 +0,0 @@ -import pytest -from django.core.management import CommandError, call_command - -from simplecasts.podcasts.itunes import Feed, ItunesError -from simplecasts.podcasts.tests.factories import ( - CategoryFactory, - PodcastFactory, - RecommendationFactory, - SubscriptionFactory, -) -from simplecasts.users.tests.factories import EmailAddressFactory - - -class TestParsePodcastFeeds: - parse_feed = ( - "simplecasts.podcasts.management.commands.parse_podcast_feeds.parse_feed" - ) - - @pytest.fixture - def mock_parse(self, mocker): - return mocker.patch(self.parse_feed) - - @pytest.mark.django_db - def test_ok(self, mocker): - mock_parse = mocker.patch(self.parse_feed) - PodcastFactory(pub_date=None) - call_command("parse_podcast_feeds") - mock_parse.assert_called() - - @pytest.mark.django_db - def test_not_scheduled(self, mocker): - mock_parse = mocker.patch(self.parse_feed) - PodcastFactory(active=False) - call_command("parse_podcast_feeds") - mock_parse.assert_not_called() - - -class TestFetchItunesFeeds: - mock_fetch = "simplecasts.podcasts.management.commands.fetch_itunes_feeds.itunes.fetch_top_feeds" - mock_save = "simplecasts.podcasts.management.commands.fetch_itunes_feeds.itunes.save_feeds_to_db" - - @pytest.fixture - def category(self): - return CategoryFactory(itunes_genre_id=1301) - - @pytest.fixture - def feed(self): - return Feed( - artworkUrl100="https://example.com/test.jpg", - collectionName="example", - collectionViewUrl="https://example.com/", - feedUrl="https://example.com/rss/", - ) - - @pytest.mark.django_db - def test_ok(self, category, mocker, feed): - mock_fetch = mocker.patch(self.mock_fetch, return_value=[feed]) - mock_save_feeds = mocker.patch(self.mock_save) - call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) - mock_fetch.assert_called() - mock_save_feeds.assert_any_call([feed], promoted=True) - mock_save_feeds.assert_any_call([feed]) - - @pytest.mark.django_db - def test_invalid_country_codes(self): - with pytest.raises(CommandError): - call_command( - "fetch_itunes_feeds", min_jitter=0, max_jitter=0, countries=["us", "tx"] - ) - - @pytest.mark.django_db - def test_no_chart_feeds(self, category, mocker, feed): - mock_fetch = mocker.patch(self.mock_fetch, return_value=[]) - mock_save_feeds = mocker.patch(self.mock_save) - call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) - mock_fetch.assert_called() - mock_save_feeds.assert_not_called() - - @pytest.mark.django_db - def test_itunes_error(self, mocker): - mock_fetch = mocker.patch( - self.mock_fetch, side_effect=ItunesError("Error fetching iTunes") - ) - mock_save_feeds = mocker.patch(self.mock_save) - call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) - mock_fetch.assert_called() - mock_save_feeds.assert_not_called() - - -class TestCreatePodcastRecommendations: - @pytest.mark.django_db - def test_create_recommendations(self, mocker): - patched = mocker.patch( - "simplecasts.podcasts.recommender.recommend", - return_value=RecommendationFactory.create_batch(3), - ) - call_command("create_podcast_recommendations") - patched.assert_called() - - -class TestSendPodcastRecommendations: - @pytest.fixture - def recipient(self): - return EmailAddressFactory(verified=True, primary=True) - - @pytest.mark.django_db(transaction=True) - def test_ok(self, recipient, mailoutbox): - podcast = SubscriptionFactory(subscriber=recipient.user).podcast - RecommendationFactory(podcast=podcast) - call_command("send_podcast_recommendations") - assert len(mailoutbox) == 1 - - @pytest.mark.django_db(transaction=True) - def test_no_recommendations(self, recipient, mailoutbox): - PodcastFactory() - call_command("send_podcast_recommendations") - assert len(mailoutbox) == 0 diff --git a/simplecasts/podcasts/tests/test_views.py b/simplecasts/podcasts/tests/test_views.py deleted file mode 100644 index 71e0835be9..0000000000 --- a/simplecasts/podcasts/tests/test_views.py +++ /dev/null @@ -1,706 +0,0 @@ -import pytest -from django.urls import reverse, reverse_lazy -from pytest_django.asserts import assertContains, assertTemplateUsed - -from simplecasts.episodes.tests.factories import EpisodeFactory -from simplecasts.podcasts import itunes -from simplecasts.podcasts.models import Podcast, Subscription -from simplecasts.podcasts.tests.factories import ( - CategoryFactory, - PodcastFactory, - RecommendationFactory, - SubscriptionFactory, -) -from simplecasts.tests.asserts import assert200, assert404, assert409 - -_subscriptions_url = reverse_lazy("podcasts:subscriptions") -_discover_url = reverse_lazy("podcasts:discover") - - -class TestSubscriptions: - @pytest.mark.django_db - def test_authenticated_no_subscriptions(self, client, auth_user): - response = client.get(_subscriptions_url) - assert200(response) - - assertTemplateUsed(response, "podcasts/subscriptions.html") - - @pytest.mark.django_db - def test_user_is_subscribed(self, client, auth_user): - """If user subscribed any podcasts, show only own feed with these podcasts""" - - sub = SubscriptionFactory(subscriber=auth_user) - response = client.get(_subscriptions_url) - - assert200(response) - - assertTemplateUsed(response, "podcasts/subscriptions.html") - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == sub.podcast - - @pytest.mark.django_db - def test_htmx_request(self, client, auth_user): - sub = SubscriptionFactory(subscriber=auth_user) - response = client.get( - _subscriptions_url, - headers={ - "HX-Request": "true", - "HX-Target": "pagination", - }, - ) - - assert200(response) - - assertContains(response, 'id="pagination"') - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == sub.podcast - - @pytest.mark.django_db - def test_user_is_subscribed_search(self, client, auth_user): - """If user subscribed any podcasts, show only own feed with these podcasts""" - - sub = SubscriptionFactory(subscriber=auth_user) - response = client.get(_subscriptions_url, {"search": sub.podcast.title}) - - assert200(response) - - assertTemplateUsed(response, "podcasts/subscriptions.html") - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == sub.podcast - - -class TestDiscover: - @pytest.mark.django_db - def test_get(self, client, auth_user): - response = client.get(_discover_url) - assert200(response) - assertTemplateUsed(response, "podcasts/discover.html") - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(_discover_url) - assert200(response) - assertTemplateUsed(response, "podcasts/discover.html") - - assert len(response.context["podcasts"]) == 0 - - -class TestSearchPeople: - @pytest.mark.django_db - def test_get(self, client, auth_user, faker): - podcast = PodcastFactory(owner=faker.name()) - response = client.get( - reverse("podcasts:search_people"), - { - "search": podcast.cleaned_owner, - }, - ) - assert200(response) - assertTemplateUsed(response, "podcasts/search_people.html") - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(reverse("podcasts:search_people"), {"search": ""}) - assert response.url == _discover_url - - -class TestSearchPodcasts: - url = reverse_lazy("podcasts:search_podcasts") - - @pytest.mark.django_db - def test_search(self, client, auth_user, faker): - podcast = PodcastFactory(title=faker.unique.text()) - PodcastFactory.create_batch(3, title="zzz") - response = client.get(self.url, {"search": podcast.title}) - - assert200(response) - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == podcast - - @pytest.mark.django_db - def test_search_value_empty(self, client, auth_user, faker): - response = client.get(self.url, {"search": ""}) - assert response.url == _discover_url - - @pytest.mark.django_db - def test_search_filter_private(self, client, auth_user, faker): - podcast = PodcastFactory(title=faker.unique.text(), private=True) - PodcastFactory.create_batch(3, title="zzz") - response = client.get(self.url, {"search": podcast.title}) - - assert200(response) - - assert len(response.context["page"].object_list) == 0 - - @pytest.mark.django_db - def test_search_no_results(self, client, auth_user, faker): - response = client.get(self.url, {"search": "zzzz"}) - assert200(response) - assert len(response.context["page"].object_list) == 0 - - -class TestSearchItunes: - url = reverse_lazy("podcasts:search_itunes") - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - response = client.get(self.url, {"search": ""}) - assert response.url == _discover_url - - @pytest.mark.django_db - def test_search(self, client, auth_user, podcast, mocker): - feeds = [ - itunes.Feed( - artworkUrl100="https://assets.fireside.fm/file/fireside-images/podcasts/images/b/bc7f1faf-8aad-4135-bb12-83a8af679756/cover.jpg?v=3", - collectionName="Test & Code : Python Testing", - collectionViewUrl="https://example.com/id123456", - feedUrl="https://feeds.fireside.fm/testandcode/rss", - ), - itunes.Feed( - artworkUrl100="https://assets.fireside.fm/file/fireside-images/podcasts/images/b/bc7f1faf-8aad-4135-bb12-83a8af679756/cover.jpg?v=3", - collectionName=podcast.title, - collectionViewUrl=podcast.website, - feedUrl=podcast.rss, - ), - ] - mock_search = mocker.patch( - "simplecasts.podcasts.itunes.search_cached", - return_value=(feeds, True), - ) - - response = client.get(self.url, {"search": "test"}) - assert200(response) - - assertTemplateUsed(response, "podcasts/search_itunes.html") - - assertContains(response, "Test & Code : Python Testing") - assertContains(response, podcast.title) - - mock_search.assert_called() - - @pytest.mark.django_db - def test_search_error(self, client, auth_user, mocker): - mocker.patch( - "simplecasts.podcasts.itunes.search_cached", - side_effect=itunes.ItunesError("Error"), - ) - response = client.get(self.url, {"search": "test"}) - assert response.url == _discover_url - - -class TestPodcastSimilar: - @pytest.mark.django_db - def test_get(self, client, auth_user, podcast): - EpisodeFactory.create_batch(3, podcast=podcast) - RecommendationFactory.create_batch(3, podcast=podcast) - response = client.get(podcast.get_similar_url()) - - assert200(response) - - assert response.context["podcast"] == podcast - assert len(response.context["recommendations"]) == 3 - - -class TestPodcastDetail: - @pytest.fixture - def podcast(self, faker): - return PodcastFactory( - owner=faker.name(), - website=faker.url(), - funding_url=faker.url(), - funding_text=faker.text(), - categories=CategoryFactory.create_batch(3), - ) - - @pytest.mark.django_db - def test_get_podcast_no_website(self, client, auth_user, faker): - podcast = PodcastFactory(website="", owner=faker.name()) - response = client.get(podcast.get_absolute_url()) - - assert200(response) - - assert response.context["podcast"] == podcast - - @pytest.mark.django_db - def test_get_podcast_subscribed(self, client, auth_user, podcast): - podcast.categories.set(CategoryFactory.create_batch(3)) - SubscriptionFactory(subscriber=auth_user, podcast=podcast) - response = client.get(podcast.get_absolute_url()) - - assert200(response) - - assert response.context["podcast"] == podcast - assert response.context["is_subscribed"] is True - - @pytest.mark.django_db - def test_get_podcast_private_subscribed(self, client, auth_user): - podcast = PodcastFactory(private=True) - SubscriptionFactory(subscriber=auth_user, podcast=podcast) - response = client.get(podcast.get_absolute_url()) - - assert200(response) - - assert response.context["podcast"] == podcast - assert response.context["is_subscribed"] is True - - @pytest.mark.django_db - def test_get_podcast_private_not_subscribed(self, client, auth_user): - podcast = PodcastFactory(private=True) - response = client.get(podcast.get_absolute_url()) - - assert200(response) - - assert response.context["podcast"] == podcast - assert response.context["is_subscribed"] is False - - @pytest.mark.django_db - def test_get_podcast_not_subscribed(self, client, auth_user, podcast): - response = client.get(podcast.get_absolute_url()) - - assert200(response) - - assert response.context["podcast"] == podcast - assert response.context["is_subscribed"] is False - - @pytest.mark.django_db - def test_get_podcast_admin(self, client, staff_user, podcast): - response = client.get(podcast.get_absolute_url()) - - assert200(response) - - assert response.context["podcast"] == podcast - assertContains(response, "Admin") - - @pytest.mark.django_db - def test_redirect_to_canonical(self, client, auth_user, podcast): - duplicate = PodcastFactory(canonical=podcast) - response = client.get(duplicate.get_absolute_url()) - assert200(response) - assertContains(response, "moved") - - -class TestLatestEpisode: - @pytest.mark.django_db - def test_ok(self, client, auth_user, episode): - response = client.get(self.url(episode.podcast)) - assert response.url == episode.get_absolute_url() - - @pytest.mark.django_db - def test_no_episodes(self, client, auth_user, podcast): - response = client.get(self.url(podcast)) - assert404(response) - - def url(self, podcast): - return reverse("podcasts:latest_episode", args=[podcast.pk]) - - -class TestPodcastSeason: - @pytest.mark.django_db - def test_get_episodes_for_season(self, client, auth_user, podcast): - EpisodeFactory.create_batch(20, podcast=podcast, season=1) - EpisodeFactory.create_batch(10, podcast=podcast, season=2) - - response = client.get( - reverse( - "podcasts:season", - kwargs={ - "podcast_id": podcast.pk, - "slug": podcast.slug, - "season": 1, - }, - ) - ) - assert200(response) - - assert len(response.context["page"].object_list) == 20 - assert response.context["season"].season == 1 - - @pytest.mark.django_db - def test_get_serial(self, client, auth_user): - podcast = PodcastFactory(podcast_type=Podcast.PodcastType.SERIAL) - EpisodeFactory.create_batch(20, podcast=podcast, season=1) - EpisodeFactory.create_batch(10, podcast=podcast, season=2) - - response = client.get( - reverse( - "podcasts:season", - kwargs={ - "podcast_id": podcast.pk, - "slug": podcast.slug, - "season": 1, - }, - ) - ) - assert200(response) - - assert len(response.context["page"].object_list) == 20 - assert response.context["season"].season == 1 - - -class TestPodcastEpisodes: - @pytest.mark.django_db - def test_get_episodes(self, client, auth_user, podcast): - EpisodeFactory.create_batch(33, podcast=podcast) - - response = client.get(podcast.get_episodes_url()) - assert200(response) - - assert len(response.context["page"].object_list) == 30 - assert response.context["ordering"] == "desc" - - @pytest.mark.django_db - def test_serial(self, client, auth_user): - podcast = PodcastFactory(podcast_type=Podcast.PodcastType.SERIAL) - EpisodeFactory.create_batch(33, podcast=podcast) - - response = client.get(podcast.get_episodes_url()) - assert200(response) - - assert len(response.context["page"].object_list) == 30 - assert response.context["ordering"] == "asc" - - @pytest.mark.django_db - def test_no_episodes(self, client, auth_user, podcast): - response = client.get(podcast.get_episodes_url()) - - assert200(response) - assert len(response.context["page"].object_list) == 0 - - @pytest.mark.django_db - def test_ascending(self, client, auth_user, podcast): - EpisodeFactory.create_batch(33, podcast=podcast) - - response = client.get( - podcast.get_episodes_url(), - {"order": "asc"}, - ) - assert200(response) - - assert len(response.context["page"].object_list) == 30 - - @pytest.mark.django_db - def test_search(self, client, auth_user, podcast, faker): - EpisodeFactory.create_batch(3, podcast=podcast) - - episode = EpisodeFactory(title=faker.unique.name(), podcast=podcast) - - response = client.get( - podcast.get_episodes_url(), - {"search": episode.title}, - ) - assert200(response) - assert len(response.context["page"].object_list) == 1 - - -class TestCategoryList: - url = reverse_lazy("podcasts:category_list") - - @pytest.mark.django_db - def test_matching_podcasts(self, client, auth_user): - for _ in range(3): - category = CategoryFactory() - category.podcasts.add(PodcastFactory()) - - response = client.get(self.url) - - assert200(response) - assert len(response.context["categories"]) == 3 - - @pytest.mark.django_db - def test_no_matching_podcasts( - self, - client, - auth_user, - ): - CategoryFactory.create_batch(3) - response = client.get(self.url) - - assert200(response) - assert len(response.context["categories"]) == 0 - - @pytest.mark.django_db - def test_search(self, client, auth_user, category, faker): - CategoryFactory.create_batch(3) - - category = CategoryFactory(name="testing") - category.podcasts.add(PodcastFactory()) - - response = client.get(self.url, {"search": "testing"}) - - assert200(response) - assert len(response.context["categories"]) == 1 - - @pytest.mark.django_db - def test_search_no_matching_podcasts(self, client, auth_user, category, faker): - CategoryFactory.create_batch(3) - - CategoryFactory(name="testing") - - response = client.get(self.url, {"search": "testing"}) - - assert200(response) - assert len(response.context["categories"]) == 0 - - -class TestCategoryDetail: - @pytest.mark.django_db - def test_get(self, client, auth_user, category): - PodcastFactory.create_batch(12, categories=[category]) - response = client.get(category.get_absolute_url()) - assert200(response) - assert response.context["category"] == category - - @pytest.mark.django_db - def test_search(self, client, auth_user, category, faker): - PodcastFactory.create_batch(12, title="zzzz", categories=[category]) - podcast = PodcastFactory(title=faker.unique.text(), categories=[category]) - - response = client.get(category.get_absolute_url(), {"search": podcast.title}) - - assert200(response) - - assert len(response.context["page"].object_list) == 1 - - @pytest.mark.django_db - def test_no_podcasts(self, client, auth_user, category): - response = client.get(category.get_absolute_url()) - assert200(response) - - assert len(response.context["page"].object_list) == 0 - - -class TestSubscribe: - @pytest.mark.django_db - def test_subscribe(self, client, podcast, auth_user): - response = client.post( - self.url(podcast), - headers={ - "HX-Request": "true", - }, - ) - - assert200(response) - assertContains(response, 'id="subscribe-button"') - - assert Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - @pytest.mark.django_db()(transaction=True) - def test_already_subscribed( - self, - client, - podcast, - auth_user, - ): - SubscriptionFactory(subscriber=auth_user, podcast=podcast) - response = client.post( - self.url(podcast), - headers={ - "HX-Request": "true", - "HX-Target": "subscribe-button", - }, - ) - - assert409(response) - - assert Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - @pytest.mark.django_db - def test_subscribe_private(self, client, auth_user): - podcast = PodcastFactory(private=True) - - response = client.post( - self.url(podcast), - headers={ - "HX-Request": "true", - "HX-Target": "subscribe-button", - }, - ) - - assert404(response) - - assert not Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - def url(self, podcast): - return reverse("podcasts:subscribe", args=[podcast.pk]) - - -class TestUnsubscribe: - @pytest.mark.django_db - def test_unsubscribe(self, client, auth_user, podcast): - SubscriptionFactory(subscriber=auth_user, podcast=podcast) - response = client.delete( - self.url(podcast), - headers={ - "HX-Request": "true", - "HX-Target": "subscribe-button", - }, - ) - - assert200(response) - assertContains(response, 'id="subscribe-button"') - - assert not Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - @pytest.mark.django_db - def test_unsubscribe_private(self, client, auth_user): - podcast = SubscriptionFactory( - subscriber=auth_user, podcast=PodcastFactory(private=True) - ).podcast - - response = client.delete( - self.url(podcast), - headers={ - "HX-Request": "true", - "HX-Target": "subscribe-button", - }, - ) - - assert404(response) - - assert Subscription.objects.filter( - podcast=podcast, subscriber=auth_user - ).exists() - - def url(self, podcast): - return reverse("podcasts:unsubscribe", args=[podcast.pk]) - - -class TestPrivateFeeds: - url = reverse_lazy("podcasts:private_feeds") - - @pytest.mark.django_db - def test_ok(self, client, auth_user): - for podcast in PodcastFactory.create_batch(33, private=True): - SubscriptionFactory(subscriber=auth_user, podcast=podcast) - response = client.get(self.url) - assert200(response) - assert len(response.context["page"]) == 30 - assert response.context["page"].has_other_pages is True - - @pytest.mark.django_db - def test_empty(self, client, auth_user): - PodcastFactory(private=True) - response = client.get(self.url) - assert200(response) - assert len(response.context["page"]) == 0 - assert response.context["page"].has_other_pages is False - - @pytest.mark.django_db - def test_search(self, client, auth_user, faker): - podcast = SubscriptionFactory( - subscriber=auth_user, - podcast=PodcastFactory(title=faker.unique.text(), private=True), - ).podcast - - SubscriptionFactory( - subscriber=auth_user, - podcast=PodcastFactory(title="zzz", private=True), - ) - - response = client.get(self.url, {"search": podcast.title}) - assert200(response) - - assert len(response.context["page"].object_list) == 1 - assert response.context["page"].object_list[0] == podcast - - -class TestRemovePrivateFeed: - def url(self, podcast): - return reverse("podcasts:remove_private_feed", args=[podcast.pk]) - - @pytest.mark.django_db - def test_ok(self, client, auth_user): - podcast = PodcastFactory(private=True) - SubscriptionFactory(podcast=podcast, subscriber=auth_user) - - response = client.delete( - self.url(podcast), - {"rss": podcast.rss}, - ) - assert response.url == reverse("podcasts:private_feeds") - - assert not Podcast.objects.filter(pk=podcast.pk).exists() - - @pytest.mark.django_db - def test_not_owned_by_user(self, client, auth_user): - podcast = PodcastFactory(private=True) - - response = client.delete( - self.url(podcast), - {"rss": podcast.rss}, - ) - assert404(response) - - assert Podcast.objects.filter(pk=podcast.pk).exists() - - @pytest.mark.django_db - def test_not_private_feed(self, client, auth_user): - podcast = PodcastFactory(private=False) - SubscriptionFactory(podcast=podcast, subscriber=auth_user) - response = client.delete(self.url(podcast), {"rss": podcast.rss}) - assert404(response) - - assert Podcast.objects.filter(pk=podcast.pk).exists() - - assert Subscription.objects.filter( - subscriber=auth_user, podcast=podcast - ).exists() - - -class TestAddPrivateFeed: - url = reverse_lazy("podcasts:add_private_feed") - - @pytest.fixture - def rss(self, faker): - return faker.url() - - @pytest.mark.django_db - def test_get(self, client, auth_user): - response = client.get(self.url) - assert200(response) - assertTemplateUsed(response, "podcasts/private_feed_form.html") - - @pytest.mark.django_db - def test_post_not_existing(self, client, auth_user, rss): - response = client.post(self.url, {"rss": rss}) - assert response.url == reverse("podcasts:private_feeds") - - podcast = Subscription.objects.get( - subscriber=auth_user, podcast__rss=rss - ).podcast - - assert podcast.private - - @pytest.mark.django_db - def test_existing_private(self, client, auth_user): - podcast = PodcastFactory(private=True) - - response = client.post(self.url, {"rss": podcast.rss}) - assert200(response) - - assert not Subscription.objects.filter( - subscriber=auth_user, podcast=podcast - ).exists() - - @pytest.mark.django_db - def test_existing_public(self, client, auth_user): - podcast = PodcastFactory(private=False) - - response = client.post(self.url, {"rss": podcast.rss}) - assert200(response) - - assert not Subscription.objects.filter( - subscriber=auth_user, podcast=podcast - ).exists() diff --git a/simplecasts/podcasts/urls.py b/simplecasts/podcasts/urls.py deleted file mode 100644 index 3d7c39c591..0000000000 --- a/simplecasts/podcasts/urls.py +++ /dev/null @@ -1,86 +0,0 @@ -from django.urls import path, register_converter - -from simplecasts.podcasts import views - -app_name = "podcasts" - - -class _SignedIntConverter: - regex = r"-?\d+" # allow optional leading '-' - - def to_python(self, value: str) -> int: - return int(value) - - def to_url(self, value: int) -> str: - return str(value) - - -register_converter(_SignedIntConverter, "sint") - -urlpatterns = [ - path("subscriptions/", views.subscriptions, name="subscriptions"), - path("discover/", views.discover, name="discover"), - path("search/podcasts/", views.search_podcasts, name="search_podcasts"), - path("search/people/", views.search_people, name="search_people"), - path("search/itunes/", views.search_itunes, name="search_itunes"), - path( - "podcasts/-/", - views.podcast_detail, - name="podcast_detail", - ), - path( - "podcasts/-/episodes/", - views.episodes, - name="episodes", - ), - path( - "podcasts/-/season//", - views.season, - name="season", - ), - path( - "podcasts/-/similar/", - views.similar, - name="similar", - ), - path( - "podcasts//latest-episode/", - views.latest_episode, - name="latest_episode", - ), - path( - "subscribe//", - views.subscribe, - name="subscribe", - ), - path( - "unsubscribe//", - views.unsubscribe, - name="unsubscribe", - ), - path( - "categories/", - views.category_list, - name="category_list", - ), - path( - "categories//", - views.category_detail, - name="category_detail", - ), - path( - "private-feeds/", - views.private_feeds, - name="private_feeds", - ), - path( - "private-feeds/new/", - views.add_private_feed, - name="add_private_feed", - ), - path( - "private-feeds/remove//", - views.remove_private_feed, - name="remove_private_feed", - ), -] diff --git a/simplecasts/podcasts/views.py b/simplecasts/podcasts/views.py deleted file mode 100644 index 53bb028bb8..0000000000 --- a/simplecasts/podcasts/views.py +++ /dev/null @@ -1,422 +0,0 @@ -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.db import IntegrityError -from django.db.models import Exists, OuterRef -from django.http import Http404, HttpResponseRedirect -from django.shortcuts import get_object_or_404, redirect -from django.template.response import TemplateResponse -from django.views.decorators.http import require_POST, require_safe - -from simplecasts.episodes.models import Episode -from simplecasts.http import require_DELETE, require_form_methods -from simplecasts.http_client import get_client -from simplecasts.paginator import render_paginated_response -from simplecasts.partials import render_partial_response -from simplecasts.podcasts import itunes -from simplecasts.podcasts.forms import PodcastForm -from simplecasts.podcasts.models import Category, Podcast, PodcastQuerySet -from simplecasts.request import AuthenticatedHttpRequest, HttpRequest -from simplecasts.response import HttpResponseConflict, RenderOrRedirectResponse -from simplecasts.search import search_queryset - - -@require_safe -@login_required -def subscriptions(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Render podcast index page.""" - podcasts = _get_podcasts().subscribed(request.user).distinct() - - if request.search: - podcasts = search_queryset( - podcasts, - request.search.value, - "search_vector", - ).order_by("-rank", "-pub_date") - else: - podcasts = podcasts.order_by("-pub_date") - - return render_paginated_response(request, "podcasts/subscriptions.html", podcasts) - - -@require_safe -@login_required -def discover(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Shows all promoted podcasts.""" - podcasts = ( - _get_public_podcasts() - .filter( - promoted=True, - language=settings.DISCOVER_FEED_LANGUAGE, - ) - .order_by("-pub_date")[: settings.DEFAULT_PAGE_SIZE] - ) - - return TemplateResponse(request, "podcasts/discover.html", {"podcasts": podcasts}) - - -@require_safe -@login_required -def search_podcasts(request: HttpRequest) -> RenderOrRedirectResponse: - """Search all public podcasts in database. Redirects to discover page if search is empty.""" - - if request.search: - podcasts = search_queryset( - _get_public_podcasts(), - request.search.value, - "search_vector", - ).order_by("-rank", "-pub_date") - - return render_paginated_response( - request, "podcasts/search_podcasts.html", podcasts - ) - - return redirect("podcasts:discover") - - -@require_safe -@login_required -def search_itunes(request: HttpRequest) -> RenderOrRedirectResponse: - """Render iTunes search page. Redirects to discover page if search is empty.""" - - if request.search: - try: - with get_client() as client: - feeds, is_new = itunes.search_cached( - client, - request.search.value, - limit=settings.DEFAULT_PAGE_SIZE, - ) - if is_new: - itunes.save_feeds_to_db(feeds) - return TemplateResponse( - request, - "podcasts/search_itunes.html", - { - "feeds": feeds, - }, - ) - except itunes.ItunesError as exc: - messages.error(request, f"Failed to search iTunes: {exc}") - - return redirect("podcasts:discover") - - -@require_safe -@login_required -def search_people(request: HttpRequest) -> RenderOrRedirectResponse: - """Search all podcasts by owner(s). Redirects to discover page if no owner is given.""" - - if request.search: - podcasts = search_queryset( - _get_public_podcasts(), - request.search.value, - "owner_search_vector", - ).order_by("-rank", "-pub_date") - return render_paginated_response( - request, - "podcasts/search_people.html", - podcasts, - ) - - return redirect("podcasts:discover") - - -@require_safe -@login_required -def podcast_detail( - request: AuthenticatedHttpRequest, - podcast_id: int, - slug: str, -) -> RenderOrRedirectResponse: - """Details for a single podcast.""" - - podcast = get_object_or_404( - _get_podcasts().select_related("canonical"), - pk=podcast_id, - ) - - is_subscribed = request.user.subscriptions.filter(podcast=podcast).exists() - - return TemplateResponse( - request, - "podcasts/detail.html", - { - "podcast": podcast, - "is_subscribed": is_subscribed, - }, - ) - - -@require_safe -@login_required -def latest_episode(_, podcast_id: int) -> HttpResponseRedirect: - """Redirects to latest episode.""" - if ( - episode := Episode.objects.filter(podcast__pk=podcast_id) - .order_by("-pub_date", "-id") - .first() - ): - return redirect(episode) - raise Http404 - - -@require_safe -@login_required -def episodes( - request: HttpRequest, - podcast_id: int, - slug: str | None = None, -) -> TemplateResponse: - """Render episodes for a single podcast.""" - podcast = get_object_or_404(_get_podcasts(), pk=podcast_id) - episodes = podcast.episodes.select_related("podcast") - - default_ordering = "asc" if podcast.is_serial() else "desc" - ordering = request.GET.get("order", default_ordering) - order_by = ("pub_date", "id") if ordering == "asc" else ("-pub_date", "-id") - - if request.search: - episodes = search_queryset( - episodes, - request.search.value, - "search_vector", - ).order_by("-rank", *order_by) - else: - episodes = episodes.order_by(*order_by) - - return render_paginated_response( - request, - "podcasts/episodes.html", - episodes, - { - "podcast": podcast, - "ordering": ordering, - }, - ) - - -@require_safe -@login_required -def season( - request: HttpRequest, - podcast_id: int, - season: int, - slug: str | None = None, -) -> TemplateResponse: - """Render episodes for a podcast season.""" - podcast = get_object_or_404(_get_podcasts(), pk=podcast_id) - - episodes = podcast.episodes.filter(season=season).select_related("podcast") - - order_by = ("pub_date", "id") if podcast.is_serial() else ("-pub_date", "-id") - episodes = episodes.order_by(*order_by) - - return render_paginated_response( - request, - "podcasts/season.html", - episodes, - { - "podcast": podcast, - "season": podcast.get_season(season), - }, - ) - - -@require_safe -@login_required -def similar( - request: HttpRequest, - podcast_id: int, - slug: str | None = None, -) -> TemplateResponse: - """List similar podcasts based on recommendations.""" - - podcast = get_object_or_404(_get_podcasts(), pk=podcast_id) - - recommendations = podcast.recommendations.select_related("recommended").order_by( - "-score" - )[: settings.DEFAULT_PAGE_SIZE] - - return TemplateResponse( - request, - "podcasts/similar.html", - { - "podcast": podcast, - "recommendations": recommendations, - }, - ) - - -@require_safe -@login_required -def category_list(request: HttpRequest) -> TemplateResponse: - """List all categories containing podcasts.""" - categories = ( - Category.objects.alias( - has_podcasts=Exists( - _get_public_podcasts().filter(categories=OuterRef("pk")) - ) - ) - .filter(has_podcasts=True) - .order_by("name") - ) - - return TemplateResponse( - request, - "podcasts/categories.html", - { - "categories": categories, - }, - ) - - -@require_safe -@login_required -def category_detail(request: HttpRequest, slug: str) -> TemplateResponse: - """Render individual podcast category along with its podcasts. - - Podcasts can also be searched. - """ - category = get_object_or_404(Category, slug=slug) - - podcasts = category.podcasts.published().filter(private=False).distinct() - - if request.search: - podcasts = search_queryset( - podcasts, - request.search.value, - "search_vector", - ).order_by("-rank", "-pub_date") - else: - podcasts = podcasts.order_by("-pub_date") - - return render_paginated_response( - request, - "podcasts/category_detail.html", - podcasts, - { - "category": category, - }, - ) - - -@require_POST -@login_required -def subscribe( - request: AuthenticatedHttpRequest, podcast_id: int -) -> TemplateResponse | HttpResponseConflict: - """Subscribe a user to a podcast. Podcast must be active and public.""" - podcast = get_object_or_404(_get_public_podcasts(), pk=podcast_id) - - try: - request.user.subscriptions.create(podcast=podcast) - except IntegrityError: - return HttpResponseConflict() - - messages.success(request, "Subscribed to Podcast") - - return _render_subscribe_action(request, podcast, is_subscribed=True) - - -@require_DELETE -@login_required -def unsubscribe(request: AuthenticatedHttpRequest, podcast_id: int) -> TemplateResponse: - """Unsubscribe user from a podcast.""" - podcast = get_object_or_404(_get_public_podcasts(), pk=podcast_id) - request.user.subscriptions.filter(podcast=podcast).delete() - messages.info(request, "Unsubscribed from Podcast") - return _render_subscribe_action(request, podcast, is_subscribed=False) - - -@require_safe -@login_required -def private_feeds(request: AuthenticatedHttpRequest) -> TemplateResponse: - """Lists user's private feeds.""" - podcasts = _get_private_podcasts().subscribed(request.user) - - if request.search: - podcasts = search_queryset( - podcasts, - request.search.value, - "search_vector", - ).order_by("-rank", "-pub_date") - else: - podcasts = podcasts.order_by("-pub_date") - - return render_paginated_response(request, "podcasts/private_feeds.html", podcasts) - - -@require_form_methods -@login_required -def add_private_feed(request: AuthenticatedHttpRequest) -> RenderOrRedirectResponse: - """Add new private feed to collection.""" - if request.method == "POST": - form = PodcastForm(request.POST) - if form.is_valid(): - podcast = form.save(commit=False) - podcast.private = True - podcast.save() - - request.user.subscriptions.create(podcast=podcast) - - messages.success( - request, - "Podcast added to your Private Feeds and will appear here soon", - ) - return redirect("podcasts:private_feeds") - else: - form = PodcastForm() - - return render_partial_response( - request, - "podcasts/private_feed_form.html", - {"form": form}, - target="private-feed-form", - partial="form", - ) - - -@require_DELETE -@login_required -def remove_private_feed( - request: AuthenticatedHttpRequest, - podcast_id: int, -) -> HttpResponseRedirect: - """Delete private feed.""" - - get_object_or_404( - _get_private_podcasts().subscribed(request.user), - pk=podcast_id, - ).delete() - - messages.info(request, "Removed from Private Feeds") - return redirect("podcasts:private_feeds") - - -def _get_podcasts() -> PodcastQuerySet: - return Podcast.objects.published() - - -def _get_public_podcasts() -> PodcastQuerySet: - return _get_podcasts().filter(private=False) - - -def _get_private_podcasts() -> PodcastQuerySet: - return _get_podcasts().filter(private=True) - - -def _render_subscribe_action( - request: HttpRequest, - podcast: Podcast, - *, - is_subscribed: bool, -) -> TemplateResponse: - return TemplateResponse( - request, - "podcasts/detail.html#subscribe_button", - { - "podcast": podcast, - "is_subscribed": is_subscribed, - }, - ) diff --git a/simplecasts/episodes/templatetags/__init__.py b/simplecasts/services/__init__.py similarity index 100% rename from simplecasts/episodes/templatetags/__init__.py rename to simplecasts/services/__init__.py diff --git a/simplecasts/covers.py b/simplecasts/services/covers.py similarity index 98% rename from simplecasts/covers.py rename to simplecasts/services/covers.py index a014c769e1..dd5e6b8c99 100644 --- a/simplecasts/covers.py +++ b/simplecasts/services/covers.py @@ -15,8 +15,8 @@ from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from PIL import Image -from simplecasts.http_client import Client -from simplecasts.pwa import ImageInfo +from simplecasts.services.http_client import Client +from simplecasts.services.pwa import ImageInfo CoverVariant = Literal["card", "detail", "tile"] diff --git a/simplecasts/podcasts/parsers/feed_parser.py b/simplecasts/services/feed_parser/__init__.py similarity index 96% rename from simplecasts/podcasts/parsers/feed_parser.py rename to simplecasts/services/feed_parser/__init__.py index 2a8633f795..7db672f8bf 100644 --- a/simplecasts/podcasts/parsers/feed_parser.py +++ b/simplecasts/services/feed_parser/__init__.py @@ -7,18 +7,17 @@ from django.db.utils import DatabaseError from django.utils import timezone -from simplecasts.episodes.models import Episode -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Category, Podcast -from simplecasts.podcasts.parsers import rss_fetcher, rss_parser, scheduler -from simplecasts.podcasts.parsers.exceptions import ( +from simplecasts.models import Category, Episode, Podcast +from simplecasts.services.feed_parser import rss_fetcher, rss_parser, scheduler +from simplecasts.services.feed_parser.exceptions import ( DiscontinuedError, DuplicateError, InvalidRSSError, NotModifiedError, UnavailableError, ) -from simplecasts.podcasts.parsers.models import Feed, Item +from simplecasts.services.feed_parser.schemas import Feed, Item +from simplecasts.services.http_client import Client def parse_feed(podcast: Podcast, client: Client) -> Podcast.FeedStatus: diff --git a/simplecasts/podcasts/parsers/date_parser.py b/simplecasts/services/feed_parser/date_parser.py similarity index 100% rename from simplecasts/podcasts/parsers/date_parser.py rename to simplecasts/services/feed_parser/date_parser.py diff --git a/simplecasts/podcasts/parsers/exceptions.py b/simplecasts/services/feed_parser/exceptions.py similarity index 100% rename from simplecasts/podcasts/parsers/exceptions.py rename to simplecasts/services/feed_parser/exceptions.py diff --git a/simplecasts/podcasts/parsers/rss_fetcher.py b/simplecasts/services/feed_parser/rss_fetcher.py similarity index 94% rename from simplecasts/podcasts/parsers/rss_fetcher.py rename to simplecasts/services/feed_parser/rss_fetcher.py index 731a991fda..1084ad9b1b 100644 --- a/simplecasts/podcasts/parsers/rss_fetcher.py +++ b/simplecasts/services/feed_parser/rss_fetcher.py @@ -7,14 +7,14 @@ from django.utils.functional import cached_property from django.utils.http import http_date, quote_etag -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.date_parser import parse_date -from simplecasts.podcasts.parsers.exceptions import ( +from simplecasts.models import Podcast +from simplecasts.services.feed_parser.date_parser import parse_date +from simplecasts.services.feed_parser.exceptions import ( DiscontinuedError, NotModifiedError, UnavailableError, ) +from simplecasts.services.http_client import Client @dataclasses.dataclass(kw_only=True, frozen=True) diff --git a/simplecasts/podcasts/parsers/rss_parser.py b/simplecasts/services/feed_parser/rss_parser.py similarity index 96% rename from simplecasts/podcasts/parsers/rss_parser.py rename to simplecasts/services/feed_parser/rss_parser.py index 602463145b..c5ddd4f913 100644 --- a/simplecasts/podcasts/parsers/rss_parser.py +++ b/simplecasts/services/feed_parser/rss_parser.py @@ -5,9 +5,9 @@ from pydantic import ValidationError -from simplecasts.podcasts.parsers.exceptions import InvalidRSSError -from simplecasts.podcasts.parsers.models import Feed, Item -from simplecasts.podcasts.parsers.xpath_parser import OptionalXmlElement, XPathParser +from simplecasts.services.feed_parser.exceptions import InvalidRSSError +from simplecasts.services.feed_parser.schemas import Feed, Item +from simplecasts.services.xpath_parser import OptionalXmlElement, XPathParser def parse_rss(content: bytes) -> Feed: diff --git a/simplecasts/podcasts/parsers/scheduler.py b/simplecasts/services/feed_parser/scheduler.py similarity index 92% rename from simplecasts/podcasts/parsers/scheduler.py rename to simplecasts/services/feed_parser/scheduler.py index 0c25cf3dcb..038e9d2cbe 100644 --- a/simplecasts/podcasts/parsers/scheduler.py +++ b/simplecasts/services/feed_parser/scheduler.py @@ -3,8 +3,8 @@ from django.utils import timezone -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.models import Feed +from simplecasts.models import Podcast +from simplecasts.services.feed_parser.schemas import Feed def schedule(feed: Feed) -> timedelta: diff --git a/simplecasts/podcasts/parsers/models.py b/simplecasts/services/feed_parser/schemas/__init__.py similarity index 94% rename from simplecasts/podcasts/parsers/models.py rename to simplecasts/services/feed_parser/schemas/__init__.py index 1ce2a62f4d..4891f6778d 100644 --- a/simplecasts/podcasts/parsers/models.py +++ b/simplecasts/services/feed_parser/schemas/__init__.py @@ -10,11 +10,10 @@ model_validator, ) -from simplecasts.episodes.models import Episode -from simplecasts.podcasts import tokenizer -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.date_parser import parse_date -from simplecasts.podcasts.parsers.fields import ( +from simplecasts.models import Episode, Podcast +from simplecasts.services import tokenizer +from simplecasts.services.feed_parser.date_parser import parse_date +from simplecasts.services.feed_parser.schemas.fields import ( AudioMimetype, EmptyIfNone, EpisodeType, @@ -23,7 +22,7 @@ PgInteger, PodcastType, ) -from simplecasts.podcasts.parsers.validators import is_one_of, normalize_url +from simplecasts.services.feed_parser.schemas.validators import is_one_of, normalize_url class Item(BaseModel): diff --git a/simplecasts/podcasts/parsers/fields.py b/simplecasts/services/feed_parser/schemas/fields.py similarity index 92% rename from simplecasts/podcasts/parsers/fields.py rename to simplecasts/services/feed_parser/schemas/fields.py index ee0e49d22a..d9ba25ae26 100644 --- a/simplecasts/podcasts/parsers/fields.py +++ b/simplecasts/services/feed_parser/schemas/fields.py @@ -3,9 +3,8 @@ from pydantic import AfterValidator, BeforeValidator -from simplecasts.episodes.models import Episode -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.validators import ( +from simplecasts.models import Episode, Podcast +from simplecasts.services.feed_parser.schemas.validators import ( default_if_none, is_one_of, normalize_url, diff --git a/simplecasts/podcasts/parsers/validators.py b/simplecasts/services/feed_parser/schemas/validators.py similarity index 100% rename from simplecasts/podcasts/parsers/validators.py rename to simplecasts/services/feed_parser/schemas/validators.py diff --git a/simplecasts/http_client.py b/simplecasts/services/http_client.py similarity index 100% rename from simplecasts/http_client.py rename to simplecasts/services/http_client.py diff --git a/simplecasts/podcasts/itunes.py b/simplecasts/services/itunes.py similarity index 98% rename from simplecasts/podcasts/itunes.py rename to simplecasts/services/itunes.py index cfd0ef96a5..d459f71d9d 100644 --- a/simplecasts/podcasts/itunes.py +++ b/simplecasts/services/itunes.py @@ -16,8 +16,8 @@ from lxml import html from pydantic import BaseModel, Field, ValidationError -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Podcast +from simplecasts.models import Podcast +from simplecasts.services.http_client import Client COUNTRIES: Final = ( "br", diff --git a/simplecasts/users/notifications.py b/simplecasts/services/notifications.py similarity index 97% rename from simplecasts/users/notifications.py rename to simplecasts/services/notifications.py index 9e855e6ca9..bdba1ea2a9 100644 --- a/simplecasts/users/notifications.py +++ b/simplecasts/services/notifications.py @@ -9,7 +9,7 @@ from django.template import loader from django.urls import reverse -from simplecasts.sanitizer import strip_html +from simplecasts.services.sanitizer import strip_html from simplecasts.templatetags import absolute_uri diff --git a/simplecasts/podcasts/parsers/opml_parser.py b/simplecasts/services/opml_parser.py similarity index 90% rename from simplecasts/podcasts/parsers/opml_parser.py rename to simplecasts/services/opml_parser.py index 8dd09cb7b7..b4be1d8455 100644 --- a/simplecasts/podcasts/parsers/opml_parser.py +++ b/simplecasts/services/opml_parser.py @@ -1,7 +1,7 @@ import functools from collections.abc import Iterator -from simplecasts.podcasts.parsers.xpath_parser import XPathParser +from simplecasts.services.xpath_parser import XPathParser def parse_opml(content: bytes) -> Iterator[str]: diff --git a/simplecasts/pwa.py b/simplecasts/services/pwa.py similarity index 100% rename from simplecasts/pwa.py rename to simplecasts/services/pwa.py diff --git a/simplecasts/podcasts/recommender.py b/simplecasts/services/recommender.py similarity index 98% rename from simplecasts/podcasts/recommender.py rename to simplecasts/services/recommender.py index 15383d52c0..9d7c3bc09a 100644 --- a/simplecasts/podcasts/recommender.py +++ b/simplecasts/services/recommender.py @@ -17,7 +17,7 @@ ) from sklearn.neighbors import NearestNeighbors -from simplecasts.podcasts.models import Podcast, Recommendation +from simplecasts.models import Podcast, Recommendation _default_timeframe: Final = timedelta(days=90) diff --git a/simplecasts/sanitizer.py b/simplecasts/services/sanitizer.py similarity index 100% rename from simplecasts/sanitizer.py rename to simplecasts/services/sanitizer.py diff --git a/simplecasts/search.py b/simplecasts/services/search.py similarity index 100% rename from simplecasts/search.py rename to simplecasts/services/search.py diff --git a/simplecasts/thread_pool.py b/simplecasts/services/thread_pool.py similarity index 100% rename from simplecasts/thread_pool.py rename to simplecasts/services/thread_pool.py diff --git a/simplecasts/podcasts/tokenizer.py b/simplecasts/services/tokenizer.py similarity index 98% rename from simplecasts/podcasts/tokenizer.py rename to simplecasts/services/tokenizer.py index 21e065bd15..af83cdbb5a 100644 --- a/simplecasts/podcasts/tokenizer.py +++ b/simplecasts/services/tokenizer.py @@ -14,7 +14,7 @@ from nltk.stem.wordnet import WordNetLemmatizer from nltk.tokenize import RegexpTokenizer -from simplecasts.sanitizer import strip_html +from simplecasts.services.sanitizer import strip_html _STOPWORDS_LANGUAGES: Final = { "ar": "arabic", diff --git a/simplecasts/podcasts/parsers/xpath_parser.py b/simplecasts/services/xpath_parser.py similarity index 100% rename from simplecasts/podcasts/parsers/xpath_parser.py rename to simplecasts/services/xpath_parser.py diff --git a/simplecasts/templatetags.py b/simplecasts/templatetags/__init__.py similarity index 88% rename from simplecasts/templatetags.py rename to simplecasts/templatetags/__init__.py index 183f9ce87a..d04b14828d 100644 --- a/simplecasts/templatetags.py +++ b/simplecasts/templatetags/__init__.py @@ -1,5 +1,6 @@ import functools import json +from datetime import timedelta from django import template from django.conf import settings @@ -10,10 +11,11 @@ from django.utils import timezone from django.utils.html import format_html, format_html_join from django.utils.safestring import SafeString +from django.utils.timesince import timesince -from simplecasts import covers, sanitizer -from simplecasts.pwa import get_theme_color -from simplecasts.request import RequestContext +from simplecasts.http.request import RequestContext +from simplecasts.services import covers, sanitizer +from simplecasts.services.pwa import get_theme_color register = template.Library() @@ -116,6 +118,16 @@ def cookie_banner(context: RequestContext) -> dict: return context.flatten() | {"cookies_accepted": cookies_accepted} +@register.filter +def format_duration(total_seconds: int, min_value: int = 60) -> str: + """Formats duration (in seconds) as human readable value e.g. 1 hour, 30 minutes.""" + return ( + timesince(timezone.now() - timedelta(seconds=total_seconds)) + if total_seconds >= min_value + else "" + ) + + @register.simple_block_tag(takes_context=True) def fragment( context: Context, diff --git a/simplecasts/episodes/templatetags/episodes.py b/simplecasts/templatetags/audio_player.py similarity index 71% rename from simplecasts/episodes/templatetags/episodes.py rename to simplecasts/templatetags/audio_player.py index 61029aaa03..5bff30d809 100644 --- a/simplecasts/episodes/templatetags/episodes.py +++ b/simplecasts/templatetags/audio_player.py @@ -1,13 +1,13 @@ -from datetime import timedelta - from django import template -from django.utils import timezone -from django.utils.timesince import timesince -from simplecasts import covers -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.episodes.views import PlayerAction -from simplecasts.request import HttpRequest, RequestContext, is_authenticated_request +from simplecasts.http.request import ( + HttpRequest, + RequestContext, + is_authenticated_request, +) +from simplecasts.models import AudioLog, Episode +from simplecasts.services import covers +from simplecasts.views.player import PlayerAction register = template.Library() @@ -56,16 +56,6 @@ def get_media_metadata(context: RequestContext, episode: Episode) -> dict: } -@register.filter -def format_duration(total_seconds: int, min_value: int = 60) -> str: - """Formats duration (in seconds) as human readable value e.g. 1 hour, 30 minutes.""" - return ( - timesince(timezone.now() - timedelta(seconds=total_seconds)) - if total_seconds >= min_value - else "" - ) - - def _get_audio_log(request: HttpRequest) -> AudioLog | None: if is_authenticated_request(request) and (episode_id := request.player.get()): return ( diff --git a/simplecasts/tests/factories.py b/simplecasts/tests/factories.py new file mode 100644 index 0000000000..8ae88aa62c --- /dev/null +++ b/simplecasts/tests/factories.py @@ -0,0 +1,106 @@ +import uuid + +import factory +from allauth.account.models import EmailAddress +from django.utils import timezone + +from simplecasts.models import ( + AudioLog, + Bookmark, + Category, + Episode, + Podcast, + Recommendation, + Subscription, + User, +) + + +class UserFactory(factory.django.DjangoModelFactory): + username = factory.Sequence(lambda n: f"user-{n}") + email = factory.Sequence(lambda n: f"user-{n}@example.com") + password = factory.django.Password("testpass") + + class Meta: + model = User + + +class EmailAddressFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + email = factory.LazyAttribute(lambda a: a.user.email) + verified = True + + class Meta: + model = EmailAddress + + +class CategoryFactory(factory.django.DjangoModelFactory): + name = factory.Sequence(lambda n: f"Category {n}") + + class Meta: + model = Category + + +class PodcastFactory(factory.django.DjangoModelFactory): + title = factory.Faker("text") + rss = factory.Sequence(lambda n: f"https://{n}.example.com") + pub_date = factory.LazyFunction(timezone.now) + cover_url = "https://example.com/cover.jpg" + + class Meta: + model = Podcast + + @factory.post_generation + def categories(self, create, extracted, **kwargs): + if create and extracted: + self.categories.set(extracted) + + +class RecommendationFactory(factory.django.DjangoModelFactory): + score = 0.5 + + podcast = factory.SubFactory(PodcastFactory) + recommended = factory.SubFactory(PodcastFactory) + + class Meta: + model = Recommendation + + +class SubscriptionFactory(factory.django.DjangoModelFactory): + subscriber = factory.SubFactory(UserFactory) + podcast = factory.SubFactory(PodcastFactory) + + class Meta: + model = Subscription + + +class EpisodeFactory(factory.django.DjangoModelFactory): + guid = factory.LazyFunction(lambda: uuid.uuid4().hex) + podcast = factory.SubFactory(PodcastFactory) + title = factory.Faker("text") + description = factory.Faker("text") + pub_date = factory.LazyFunction(timezone.now) + media_url = factory.Faker("url") + media_type = "audio/mpg" + duration = "100" + + class Meta: + model = Episode + + +class BookmarkFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + episode = factory.SubFactory(EpisodeFactory) + + class Meta: + model = Bookmark + + +class AudioLogFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + episode = factory.SubFactory(EpisodeFactory) + listened = factory.LazyFunction(timezone.now) + current_time = 1000 + + class Meta: + model = AudioLog diff --git a/simplecasts/tests/fixtures.py b/simplecasts/tests/fixtures.py index 1d3ced2108..8e428edfc3 100644 --- a/simplecasts/tests/fixtures.py +++ b/simplecasts/tests/fixtures.py @@ -1,11 +1,22 @@ from collections.abc import Callable, Generator import pytest -from django.conf import Settings +from django.contrib.auth.models import AnonymousUser from django.contrib.auth.signals import user_logged_in from django.contrib.sites.models import Site from django.core.cache import cache from django.http import HttpRequest, HttpResponse +from django.test import Client + +from simplecasts.middleware import PlayerDetails +from simplecasts.models import AudioLog, Category, Episode, Podcast, User +from simplecasts.tests.factories import ( + AudioLogFactory, + CategoryFactory, + EpisodeFactory, + PodcastFactory, + UserFactory, +) @pytest.fixture @@ -14,7 +25,7 @@ def site(): @pytest.fixture(autouse=True) -def _settings_overrides(settings: Settings) -> None: +def _settings_overrides(settings) -> None: """Default settings for all tests.""" settings.CACHES = { "default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"} @@ -25,7 +36,7 @@ def _settings_overrides(settings: Settings) -> None: @pytest.fixture -def _locmem_cache(settings: Settings) -> Generator: +def _locmem_cache(settings) -> Generator: settings.CACHES = { "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"} } @@ -46,3 +57,58 @@ def _disable_update_last_login() -> None: @pytest.fixture(scope="session") def get_response() -> Callable[[HttpRequest], HttpResponse]: return lambda req: HttpResponse() + + +@pytest.fixture +def podcast() -> Podcast: + return PodcastFactory() + + +@pytest.fixture +def category() -> Category: + return CategoryFactory() + + +@pytest.fixture +def user() -> User: + return UserFactory() + + +@pytest.fixture +def anonymous_user() -> AnonymousUser: + return AnonymousUser() + + +@pytest.fixture +def auth_user(client: Client, user: User) -> User: + client.force_login(user) + return user + + +@pytest.fixture +def staff_user(client: Client) -> User: + user = UserFactory(is_staff=True) + client.force_login(user) + return user + + +@pytest.fixture +def episode() -> Episode: + return EpisodeFactory() + + +@pytest.fixture +def audio_log(episode: Episode) -> AudioLog: + return AudioLogFactory(episode=episode) + + +@pytest.fixture +def player_episode(auth_user: User, client: Client, episode: Episode) -> Episode: + """Fixture that creates an AudioLog for the given user and episode""" + AudioLogFactory(user=auth_user, episode=episode) + + session = client.session + session[PlayerDetails.session_id] = episode.pk + session.save() + + return episode diff --git a/simplecasts/podcasts/parsers/tests/mocks/feeds.opml b/simplecasts/tests/mocks/feeds.opml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/feeds.opml rename to simplecasts/tests/mocks/feeds.opml diff --git a/simplecasts/users/tests/mocks/feeds_with_invalid.opml b/simplecasts/tests/mocks/feeds_with_invalid.opml similarity index 100% rename from simplecasts/users/tests/mocks/feeds_with_invalid.opml rename to simplecasts/tests/mocks/feeds_with_invalid.opml diff --git a/simplecasts/podcasts/tests/mocks/itunes_chart.html b/simplecasts/tests/mocks/itunes_chart.html similarity index 100% rename from simplecasts/podcasts/tests/mocks/itunes_chart.html rename to simplecasts/tests/mocks/itunes_chart.html diff --git a/simplecasts/episodes/tests/__init__.py b/simplecasts/tests/models/__init__.py similarity index 100% rename from simplecasts/episodes/tests/__init__.py rename to simplecasts/tests/models/__init__.py diff --git a/simplecasts/tests/models/test_audio_logs.py b/simplecasts/tests/models/test_audio_logs.py new file mode 100644 index 0000000000..c31cf7556f --- /dev/null +++ b/simplecasts/tests/models/test_audio_logs.py @@ -0,0 +1,25 @@ +import pytest + +from simplecasts.models import ( + AudioLog, +) + + +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_categories.py b/simplecasts/tests/models/test_categories.py new file mode 100644 index 0000000000..b92d2b95b7 --- /dev/null +++ b/simplecasts/tests/models/test_categories.py @@ -0,0 +1,19 @@ +import pytest + +from simplecasts.models import ( + Category, +) +from simplecasts.tests.factories import ( + CategoryFactory, +) + + +class TestCategoryModel: + def test_str(self): + category = Category(name="Testing") + assert str(category) == "Testing" + + @pytest.mark.django_db + def test_slug(self): + category = CategoryFactory(name="Testing") + assert category.slug == "testing" diff --git a/simplecasts/episodes/tests/test_models.py b/simplecasts/tests/models/test_episodes.py similarity index 79% rename from simplecasts/episodes/tests/test_models.py rename to simplecasts/tests/models/test_episodes.py index aed43084e0..ad6a9409e3 100644 --- a/simplecasts/episodes/tests/test_models.py +++ b/simplecasts/tests/models/test_episodes.py @@ -2,12 +2,14 @@ import pytest -from simplecasts.episodes.models import AudioLog, Episode -from simplecasts.episodes.tests.factories import ( +from simplecasts.models import ( + Episode, + Podcast, +) +from simplecasts.tests.factories import ( EpisodeFactory, + PodcastFactory, ) -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.tests.factories import PodcastFactory class TestEpisodeModel: @@ -121,23 +123,3 @@ def test_get_cover_url_if_none(self): ) def test_duration_in_seconds(self, duration, expected): assert Episode(duration=duration).duration_in_seconds == expected - - -class TestAudioLogModel: - @pytest.mark.parametrize( - ("current_time", "duration", "expected"), - [ - pytest.param(0, 0, 0, id="both zero"), - pytest.param(0, 0, 0, id="current time zero"), - pytest.param(60 * 60, 0, 0, id="duration zero"), - pytest.param(60 * 60, 60 * 60, 100, id="both one hour"), - pytest.param(60 * 30, 60 * 60, 50, id="current time half"), - pytest.param(60 * 60, 30 * 60, 100, id="more than 100 percent"), - ], - ) - def test_percent_complete(self, current_time, duration, expected): - audio_log = AudioLog( - current_time=current_time, - duration=duration, - ) - assert audio_log.percent_complete == expected diff --git a/simplecasts/podcasts/tests/test_models.py b/simplecasts/tests/models/test_podcasts.py similarity index 92% rename from simplecasts/podcasts/tests/test_models.py rename to simplecasts/tests/models/test_podcasts.py index d316d7f3a9..042dce67dc 100644 --- a/simplecasts/podcasts/tests/test_models.py +++ b/simplecasts/tests/models/test_podcasts.py @@ -3,35 +3,17 @@ import pytest from django.utils import timezone -from simplecasts.episodes.tests.factories import EpisodeFactory -from simplecasts.podcasts.models import Category, Podcast, Recommendation -from simplecasts.podcasts.tests.factories import ( - CategoryFactory, +from simplecasts.models import ( + Podcast, +) +from simplecasts.tests.factories import ( + EpisodeFactory, PodcastFactory, RecommendationFactory, SubscriptionFactory, ) -class TestRecommendationManager: - @pytest.mark.django_db - def test_bulk_delete(self): - RecommendationFactory.create_batch(3) - Recommendation.objects.bulk_delete() - assert Recommendation.objects.count() == 0 - - -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" - - class TestPodcastManager: @pytest.mark.django_db def test_subscribed_true(self, user): @@ -83,7 +65,7 @@ def test_published_false(self): "frequency": datetime.timedelta(hours=3), }, False, - id="pub date is not None, just parsed", + id="pub date is None, just parsed", ), pytest.param( { diff --git a/simplecasts/tests/models/test_recommendations.py b/simplecasts/tests/models/test_recommendations.py new file mode 100644 index 0000000000..1ee01ca209 --- /dev/null +++ b/simplecasts/tests/models/test_recommendations.py @@ -0,0 +1,16 @@ +import pytest + +from simplecasts.models import ( + Recommendation, +) +from simplecasts.tests.factories import ( + RecommendationFactory, +) + + +class TestRecommendationManager: + @pytest.mark.django_db + def test_bulk_delete(self): + RecommendationFactory.create_batch(3) + Recommendation.objects.bulk_delete() + assert Recommendation.objects.count() == 0 diff --git a/simplecasts/users/tests/test_models.py b/simplecasts/tests/models/test_users.py similarity index 90% rename from simplecasts/users/tests/test_models.py rename to simplecasts/tests/models/test_users.py index 06084c3c51..1b609d5c11 100644 --- a/simplecasts/users/tests/test_models.py +++ b/simplecasts/tests/models/test_users.py @@ -1,4 +1,4 @@ -from simplecasts.users.models import User +from simplecasts.models import User class TestUserModel: diff --git a/simplecasts/podcasts/__init__.py b/simplecasts/tests/services/__init__.py similarity index 100% rename from simplecasts/podcasts/__init__.py rename to simplecasts/tests/services/__init__.py diff --git a/simplecasts/podcasts/management/__init__.py b/simplecasts/tests/services/feed_parser/__init__.py similarity index 100% rename from simplecasts/podcasts/management/__init__.py rename to simplecasts/tests/services/feed_parser/__init__.py diff --git a/simplecasts/podcasts/parsers/tests/factories.py b/simplecasts/tests/services/feed_parser/factories.py similarity index 100% rename from simplecasts/podcasts/parsers/tests/factories.py rename to simplecasts/tests/services/feed_parser/factories.py diff --git a/simplecasts/users/tests/mocks/feeds.opml b/simplecasts/tests/services/feed_parser/mocks/feeds.opml similarity index 100% rename from simplecasts/users/tests/mocks/feeds.opml rename to simplecasts/tests/services/feed_parser/mocks/feeds.opml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_bad_cover_urls.xml b/simplecasts/tests/services/feed_parser/mocks/rss_bad_cover_urls.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_bad_cover_urls.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_bad_cover_urls.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_bad_pub_date.xml b/simplecasts/tests/services/feed_parser/mocks/rss_bad_pub_date.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_bad_pub_date.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_bad_pub_date.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_bad_sig.xml b/simplecasts/tests/services/feed_parser/mocks/rss_bad_sig.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_bad_sig.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_bad_sig.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_bad_urls.xml b/simplecasts/tests/services/feed_parser/mocks/rss_bad_urls.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_bad_urls.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_bad_urls.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_empty_mock.xml b/simplecasts/tests/services/feed_parser/mocks/rss_empty_mock.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_empty_mock.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_empty_mock.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_high_num_episodes.xml b/simplecasts/tests/services/feed_parser/mocks/rss_high_num_episodes.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_high_num_episodes.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_high_num_episodes.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_invalid_data.xml b/simplecasts/tests/services/feed_parser/mocks/rss_invalid_data.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_invalid_data.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_invalid_data.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_invalid_duration.xml b/simplecasts/tests/services/feed_parser/mocks/rss_invalid_duration.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_invalid_duration.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_invalid_duration.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_missing_enc_length.xml b/simplecasts/tests/services/feed_parser/mocks/rss_missing_enc_length.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_missing_enc_length.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_missing_enc_length.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_complete.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_complete.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_complete.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_complete.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_iso_8859-1.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_iso_8859-1.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_iso_8859-1.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_iso_8859-1.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_large.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_large.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_large.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_large.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_modified.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_modified.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_modified.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_modified.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_no_build_date.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_no_build_date.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_no_build_date.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_no_build_date.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_mock_small.xml b/simplecasts/tests/services/feed_parser/mocks/rss_mock_small.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_mock_small.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_mock_small.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_new_feed_url.xml b/simplecasts/tests/services/feed_parser/mocks/rss_new_feed_url.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_new_feed_url.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_new_feed_url.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_no_podcasts_mock.xml b/simplecasts/tests/services/feed_parser/mocks/rss_no_podcasts_mock.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_no_podcasts_mock.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_no_podcasts_mock.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_serial.xml b/simplecasts/tests/services/feed_parser/mocks/rss_serial.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_serial.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_serial.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_superfeedr.xml b/simplecasts/tests/services/feed_parser/mocks/rss_superfeedr.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_superfeedr.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_superfeedr.xml diff --git a/simplecasts/podcasts/parsers/tests/mocks/rss_use_link_ids.xml b/simplecasts/tests/services/feed_parser/mocks/rss_use_link_ids.xml similarity index 100% rename from simplecasts/podcasts/parsers/tests/mocks/rss_use_link_ids.xml rename to simplecasts/tests/services/feed_parser/mocks/rss_use_link_ids.xml diff --git a/simplecasts/podcasts/parsers/tests/test_date_parser.py b/simplecasts/tests/services/feed_parser/test_date_parser.py similarity index 94% rename from simplecasts/podcasts/parsers/tests/test_date_parser.py rename to simplecasts/tests/services/feed_parser/test_date_parser.py index b4c6f9a4b8..59e962c39e 100644 --- a/simplecasts/podcasts/parsers/tests/test_date_parser.py +++ b/simplecasts/tests/services/feed_parser/test_date_parser.py @@ -1,7 +1,7 @@ import datetime from zoneinfo import ZoneInfo -from simplecasts.podcasts.parsers.date_parser import parse_date +from simplecasts.services.feed_parser.date_parser import parse_date UTC = ZoneInfo(key="UTC") diff --git a/simplecasts/podcasts/parsers/tests/test_feed_parser.py b/simplecasts/tests/services/feed_parser/test_feed_parser.py similarity index 97% rename from simplecasts/podcasts/parsers/tests/test_feed_parser.py rename to simplecasts/tests/services/feed_parser/test_feed_parser.py index fcecfcf367..ad900fed41 100644 --- a/simplecasts/podcasts/parsers/tests/test_feed_parser.py +++ b/simplecasts/tests/services/feed_parser/test_feed_parser.py @@ -7,14 +7,12 @@ from django.db.utils import DatabaseError from django.utils.text import slugify -from simplecasts.episodes.models import Episode -from simplecasts.episodes.tests.factories import EpisodeFactory -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Category, Podcast -from simplecasts.podcasts.parsers.date_parser import parse_date -from simplecasts.podcasts.parsers.feed_parser import get_categories_dict, parse_feed -from simplecasts.podcasts.parsers.rss_fetcher import make_content_hash -from simplecasts.podcasts.tests.factories import PodcastFactory +from simplecasts.models import Category, Episode, Podcast +from simplecasts.services.feed_parser import get_categories_dict, parse_feed +from simplecasts.services.feed_parser.date_parser import parse_date +from simplecasts.services.feed_parser.rss_fetcher import make_content_hash +from simplecasts.services.http_client import Client +from simplecasts.tests.factories import EpisodeFactory, PodcastFactory @pytest.fixture @@ -406,7 +404,7 @@ def test_parse_same_content(self, mocker): podcast = PodcastFactory(content_hash=make_content_hash(content)) mock_parse_rss = mocker.patch( - "simplecasts.podcasts.parsers.rss_parser.parse_rss" + "simplecasts.services.feed_parser.rss_parser.parse_rss" ) client = _mock_client( diff --git a/simplecasts/podcasts/parsers/tests/test_opml_parser.py b/simplecasts/tests/services/feed_parser/test_opml_parser.py similarity index 83% rename from simplecasts/podcasts/parsers/tests/test_opml_parser.py rename to simplecasts/tests/services/feed_parser/test_opml_parser.py index c42d554295..0b1091ec08 100644 --- a/simplecasts/podcasts/parsers/tests/test_opml_parser.py +++ b/simplecasts/tests/services/feed_parser/test_opml_parser.py @@ -1,6 +1,6 @@ import pathlib -from simplecasts.podcasts.parsers.opml_parser import parse_opml +from simplecasts.services.opml_parser import parse_opml class TestParseOpml: diff --git a/simplecasts/podcasts/parsers/tests/test_rss_fetcher.py b/simplecasts/tests/services/feed_parser/test_rss_fetcher.py similarity index 94% rename from simplecasts/podcasts/parsers/tests/test_rss_fetcher.py rename to simplecasts/tests/services/feed_parser/test_rss_fetcher.py index 143e93b3a2..87b6f62fca 100644 --- a/simplecasts/podcasts/parsers/tests/test_rss_fetcher.py +++ b/simplecasts/tests/services/feed_parser/test_rss_fetcher.py @@ -4,16 +4,16 @@ import httpx import pytest -from simplecasts.http_client import Client -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.parsers.date_parser import parse_date -from simplecasts.podcasts.parsers.rss_fetcher import ( +from simplecasts.models import Podcast +from simplecasts.services.feed_parser.date_parser import parse_date +from simplecasts.services.feed_parser.rss_fetcher import ( DiscontinuedError, NotModifiedError, UnavailableError, fetch_rss, make_content_hash, ) +from simplecasts.services.http_client import Client class TestMakeContentHash: diff --git a/simplecasts/podcasts/parsers/tests/test_rss_parser.py b/simplecasts/tests/services/feed_parser/test_rss_parser.py similarity index 95% rename from simplecasts/podcasts/parsers/tests/test_rss_parser.py rename to simplecasts/tests/services/feed_parser/test_rss_parser.py index e426d4b9dc..6c23704906 100644 --- a/simplecasts/podcasts/parsers/tests/test_rss_parser.py +++ b/simplecasts/tests/services/feed_parser/test_rss_parser.py @@ -2,8 +2,8 @@ import pytest -from simplecasts.podcasts.parsers.exceptions import InvalidRSSError -from simplecasts.podcasts.parsers.rss_parser import parse_rss +from simplecasts.services.feed_parser.exceptions import InvalidRSSError +from simplecasts.services.feed_parser.rss_parser import parse_rss class TestParseRss: diff --git a/simplecasts/podcasts/parsers/tests/test_scheduler.py b/simplecasts/tests/services/feed_parser/test_scheduler.py similarity index 95% rename from simplecasts/podcasts/parsers/tests/test_scheduler.py rename to simplecasts/tests/services/feed_parser/test_scheduler.py index c7be0fbe38..c9b94acff9 100644 --- a/simplecasts/podcasts/parsers/tests/test_scheduler.py +++ b/simplecasts/tests/services/feed_parser/test_scheduler.py @@ -3,9 +3,9 @@ import pytest from django.utils import timezone -from simplecasts.podcasts.parsers import scheduler -from simplecasts.podcasts.parsers.models import Feed, Item -from simplecasts.podcasts.parsers.tests.factories import FeedFactory, ItemFactory +from simplecasts.services.feed_parser import scheduler +from simplecasts.services.feed_parser.schemas import Feed, Item +from simplecasts.tests.services.feed_parser.factories import FeedFactory, ItemFactory class TestReschedule: diff --git a/simplecasts/podcasts/parsers/tests/test_models.py b/simplecasts/tests/services/feed_parser/test_schemas.py similarity index 96% rename from simplecasts/podcasts/parsers/tests/test_models.py rename to simplecasts/tests/services/feed_parser/test_schemas.py index bb495684b6..a681c14c12 100644 --- a/simplecasts/podcasts/parsers/tests/test_models.py +++ b/simplecasts/tests/services/feed_parser/test_schemas.py @@ -4,9 +4,9 @@ from django.utils import timezone from pydantic import ValidationError -from simplecasts.episodes.models import Episode -from simplecasts.podcasts.parsers.models import Feed, Item -from simplecasts.podcasts.parsers.tests.factories import FeedFactory, ItemFactory +from simplecasts.models import Episode +from simplecasts.services.feed_parser.schemas import Feed, Item +from simplecasts.tests.services.feed_parser.factories import FeedFactory, ItemFactory class TestItem: diff --git a/simplecasts/podcasts/parsers/tests/test_validators.py b/simplecasts/tests/services/feed_parser/test_validators.py similarity index 97% rename from simplecasts/podcasts/parsers/tests/test_validators.py rename to simplecasts/tests/services/feed_parser/test_validators.py index f56bd8fd0a..7ed855a644 100644 --- a/simplecasts/podcasts/parsers/tests/test_validators.py +++ b/simplecasts/tests/services/feed_parser/test_validators.py @@ -3,7 +3,7 @@ import pytest from django.db.models import TextChoices -from simplecasts.podcasts.parsers.validators import ( +from simplecasts.services.feed_parser.schemas.validators import ( default_if_none, is_one_of, normalize_url, diff --git a/simplecasts/tests/test_covers.py b/simplecasts/tests/services/test_covers.py similarity index 98% rename from simplecasts/tests/test_covers.py rename to simplecasts/tests/services/test_covers.py index 7a47a96c86..308a4fc8ce 100644 --- a/simplecasts/tests/test_covers.py +++ b/simplecasts/tests/services/test_covers.py @@ -5,7 +5,7 @@ import pytest from PIL import Image -from simplecasts.covers import ( +from simplecasts.services.covers import ( CoverFetchError, CoverProcessError, CoverSaveError, @@ -21,7 +21,7 @@ process_cover_image, save_cover_image, ) -from simplecasts.http_client import Client +from simplecasts.services.http_client import Client class TestEncodeDecodeCoverUrl: @@ -74,7 +74,9 @@ def mock_stream(*args, **kwargs): mock_bytesio = mocker.Mock() mock_bytesio.tell.return_value = 100_000_000_000 - mocker.patch("simplecasts.covers.io.BytesIO", return_value=mock_bytesio) + mocker.patch( + "simplecasts.services.covers.io.BytesIO", return_value=mock_bytesio + ) with pytest.raises(CoverFetchError): fetch_cover_image(client, self.cover_url) diff --git a/simplecasts/podcasts/tests/test_itunes.py b/simplecasts/tests/services/test_itunes.py similarity index 95% rename from simplecasts/podcasts/tests/test_itunes.py rename to simplecasts/tests/services/test_itunes.py index d0c7e4e1ea..f906f63b2a 100644 --- a/simplecasts/podcasts/tests/test_itunes.py +++ b/simplecasts/tests/services/test_itunes.py @@ -4,9 +4,9 @@ import httpx import pytest -from simplecasts.http_client import Client -from simplecasts.podcasts import itunes -from simplecasts.podcasts.models import Podcast +from simplecasts.models import Podcast +from simplecasts.services import itunes +from simplecasts.services.http_client import Client MOCK_SEARCH_RESULT = { "results": [ @@ -42,7 +42,7 @@ def good_client(self): def _get_result(request): if "podcasts.apple.com" in str(request.url): with ( - pathlib.Path(__file__).parent / "mocks" / "itunes_chart.html" + pathlib.Path(__file__).parent.parent / "mocks" / "itunes_chart.html" ).open("rb") as f: chart_content = f.read() @@ -188,7 +188,7 @@ class TestSearchCached: def test_cached(self, mocker, _locmem_cache): client = Client() mock_search = mocker.patch( - "simplecasts.podcasts.itunes.search", + "simplecasts.services.itunes.search", return_value=MOCK_SEARCH_RESULT, ) feeds, is_new = itunes.search_cached(client, "test", limit=30) diff --git a/simplecasts/users/tests/test_notifications.py b/simplecasts/tests/services/test_notifications.py similarity index 90% rename from simplecasts/users/tests/test_notifications.py rename to simplecasts/tests/services/test_notifications.py index bc65601143..ec2e840a87 100644 --- a/simplecasts/users/tests/test_notifications.py +++ b/simplecasts/tests/services/test_notifications.py @@ -1,7 +1,7 @@ import pytest -from simplecasts.users.notifications import get_recipients -from simplecasts.users.tests.factories import EmailAddressFactory +from simplecasts.services.notifications import get_recipients +from simplecasts.tests.factories import EmailAddressFactory class TestGetRecipients: diff --git a/simplecasts/podcasts/tests/test_recommender.py b/simplecasts/tests/services/test_recommender.py similarity index 94% rename from simplecasts/podcasts/tests/test_recommender.py rename to simplecasts/tests/services/test_recommender.py index 258a022138..879312c7fe 100644 --- a/simplecasts/podcasts/tests/test_recommender.py +++ b/simplecasts/tests/services/test_recommender.py @@ -1,8 +1,8 @@ import pytest -from simplecasts.podcasts.models import Category, Recommendation -from simplecasts.podcasts.recommender import recommend -from simplecasts.podcasts.tests.factories import ( +from simplecasts.models import Category, Recommendation +from simplecasts.services.recommender import recommend +from simplecasts.tests.factories import ( CategoryFactory, PodcastFactory, RecommendationFactory, @@ -33,7 +33,7 @@ def test_podcast_never_recommends_itself(self): for podcast in podcasts: recs = Recommendation.objects.filter(podcast=podcast) - assert recs.count() > 0 + assert recs.exists() # The podcast itself should never appear in its recommendations assert all(r.recommended != podcast for r in recs) @@ -46,7 +46,7 @@ def test_handle_empty_data_frame(self): ) recommend("en") - assert Recommendation.objects.count() == 0 + assert Recommendation.objects.exists() is False @pytest.mark.django_db def test_no_categories(self): diff --git a/simplecasts/tests/test_sanitizier.py b/simplecasts/tests/services/test_sanitizier.py similarity index 94% rename from simplecasts/tests/test_sanitizier.py rename to simplecasts/tests/services/test_sanitizier.py index ca9b8a99ce..aa357fb015 100644 --- a/simplecasts/tests/test_sanitizier.py +++ b/simplecasts/tests/services/test_sanitizier.py @@ -1,6 +1,6 @@ import pytest -from simplecasts.sanitizer import markdown, strip_extra_spaces, strip_html +from simplecasts.services.sanitizer import markdown, strip_extra_spaces, strip_html class TestMarkdown: diff --git a/simplecasts/tests/test_search.py b/simplecasts/tests/services/test_search.py similarity index 85% rename from simplecasts/tests/test_search.py rename to simplecasts/tests/services/test_search.py index 01473f4299..949fddd31f 100644 --- a/simplecasts/tests/test_search.py +++ b/simplecasts/tests/services/test_search.py @@ -1,10 +1,8 @@ import pytest -from simplecasts.episodes.models import AudioLog -from simplecasts.episodes.tests.factories import AudioLogFactory, EpisodeFactory -from simplecasts.podcasts.models import Podcast -from simplecasts.podcasts.tests.factories import PodcastFactory -from simplecasts.search import search_queryset +from simplecasts.models import AudioLog, Podcast +from simplecasts.services.search import search_queryset +from simplecasts.tests.factories import AudioLogFactory, EpisodeFactory, PodcastFactory class TestSearchQueryset: diff --git a/simplecasts/podcasts/tests/test_tokenizer.py b/simplecasts/tests/services/test_tokenizer.py similarity index 88% rename from simplecasts/podcasts/tests/test_tokenizer.py rename to simplecasts/tests/services/test_tokenizer.py index 717c0bae20..659593c8c6 100644 --- a/simplecasts/podcasts/tests/test_tokenizer.py +++ b/simplecasts/tests/services/test_tokenizer.py @@ -1,4 +1,4 @@ -from simplecasts.podcasts.tokenizer import clean_text, get_stopwords, tokenize +from simplecasts.services.tokenizer import clean_text, get_stopwords, tokenize class TestStopwords: @@ -22,7 +22,7 @@ def test_extract(self): def test_extract_attribute_error(self, mocker): mocker.patch( - "simplecasts.podcasts.tokenizer._lemmatizer.lemmatize", + "simplecasts.services.tokenizer._lemmatizer.lemmatize", side_effect=AttributeError, ) assert tokenize("en", "the cat sits on the mat") == [] diff --git a/simplecasts/podcasts/tests/test_admin.py b/simplecasts/tests/test_admin.py similarity index 71% rename from simplecasts/podcasts/tests/test_admin.py rename to simplecasts/tests/test_admin.py index a42e338547..98e99ec539 100644 --- a/simplecasts/podcasts/tests/test_admin.py +++ b/simplecasts/tests/test_admin.py @@ -5,9 +5,11 @@ from django.contrib.admin.sites import AdminSite from django.utils import timezone -from simplecasts.podcasts.admin import ( +from simplecasts.admin import ( ActiveFilter, + AudioLogAdmin, CategoryAdmin, + EpisodeAdmin, FeedStatusFilter, PodcastAdmin, PrivateFilter, @@ -17,19 +19,51 @@ SubscribedFilter, SubscriptionAdmin, ) -from simplecasts.podcasts.models import Category, Podcast, Recommendation, Subscription -from simplecasts.podcasts.tests.factories import ( +from simplecasts.models import ( + AudioLog, + Category, + Episode, + Podcast, + Recommendation, + Subscription, +) +from simplecasts.tests.factories import ( + AudioLogFactory, + EpisodeFactory, PodcastFactory, RecommendationFactory, SubscriptionFactory, ) +# ============================================================================= +# Category Admin +# ============================================================================= + @pytest.fixture(scope="module") def category_admin(): return CategoryAdmin(Category, AdminSite()) +class TestCategoryAdmin: + @pytest.mark.django_db + def test_get_queryset(self, rf, category_admin, category): + podcasts = PodcastFactory.create_batch(3, active=True, parsed=timezone.now()) + category.podcasts.set(podcasts) + category = Category.objects.first() + for podcast in podcasts: + podcast.categories.add(category) + req = rf.get("/") + req._messages = mock.Mock() + qs = category_admin.get_queryset(req) + assert category_admin.num_podcasts(qs.first()) == 3 + + +# ============================================================================= +# Podcast Admin +# ============================================================================= + + @pytest.fixture(scope="module") def podcast_admin(): return PodcastAdmin(Podcast, AdminSite()) @@ -47,17 +81,6 @@ def req(rf): return req -class TestCategoryAdmin: - @pytest.mark.django_db - def test_get_queryset(self, req, category_admin, podcasts, category): - category.podcasts.set(podcasts) - category = Category.objects.first() - for podcast in podcasts: - podcast.categories.add(category) - qs = category_admin.get_queryset(req) - assert category_admin.num_podcasts(qs.first()) == 3 - - class TestPodcastAdmin: @pytest.mark.django_db def test_get_queryset(self, podcasts, podcast_admin, req): @@ -92,7 +115,7 @@ def test_get_ordering_search_term(self, podcast_admin, req): @pytest.mark.django_db def test_next_scheduled_update(self, mocker, podcast, podcast_admin): mocker.patch( - "simplecasts.podcasts.admin.Podcast.get_next_scheduled_update", + "simplecasts.admin.Podcast.get_next_scheduled_update", return_value=timezone.now() + datetime.timedelta(hours=3), ) assert ( @@ -102,7 +125,7 @@ def test_next_scheduled_update(self, mocker, podcast, podcast_admin): @pytest.mark.django_db def test_next_scheduled_update_in_past(self, mocker, podcast, podcast_admin): mocker.patch( - "simplecasts.podcasts.admin.Podcast.get_next_scheduled_update", + "simplecasts.admin.Podcast.get_next_scheduled_update", return_value=timezone.now() + datetime.timedelta(hours=-3), ) assert podcast_admin.next_scheduled_update(podcast) == "3\xa0hours ago" @@ -250,6 +273,11 @@ def test_true(self, podcasts, podcast_admin, req, subscribed): assert qs.first() == subscribed +# ============================================================================= +# Recommendation Admin +# ============================================================================= + + class TestRecommendationAdmin: @pytest.mark.django_db def test_get_queryset(self, rf): @@ -259,6 +287,11 @@ def test_get_queryset(self, rf): assert qs.count() == 1 +# ============================================================================= +# Subscription Admin +# ============================================================================= + + class TestSubscriptionAdmin: @pytest.mark.django_db def test_get_queryset(self, rf): @@ -266,3 +299,67 @@ def test_get_queryset(self, rf): admin = SubscriptionAdmin(Subscription, AdminSite()) qs = admin.get_queryset(rf.get("/")) assert qs.count() == 1 + + +# ============================================================================= +# Episode Admin +# ============================================================================= + + +class TestEpisodeAdmin: + @pytest.fixture(scope="class") + def admin(self): + return EpisodeAdmin(Episode, AdminSite()) + + @pytest.mark.django_db + def test_episode_title(self, admin): + episode = EpisodeFactory(title="testing") + assert admin.episode_title(episode) == "testing" + + @pytest.mark.django_db + def test_podcast_title(self, admin): + episode = EpisodeFactory(podcast=PodcastFactory(title="testing")) + assert admin.podcast_title(episode) == "testing" + + @pytest.mark.django_db + def test_get_ordering_no_search_term(self, admin, rf): + ordering = admin.get_ordering(rf.get("/")) + assert ordering == ["-id"] + + @pytest.mark.django_db + def test_get_ordering_search_term(self, admin, rf): + ordering = admin.get_ordering(rf.get("/", {"q": "test"})) + assert ordering == [] + + @pytest.mark.django_db + def test_get_search_results_no_search_term(self, rf, admin): + EpisodeFactory.create_batch(3) + qs, _ = admin.get_search_results(rf.get("/"), Episode.objects.all(), "") + assert qs.count() == 3 + + @pytest.mark.django_db + def test_get_search_results(self, rf, admin): + EpisodeFactory.create_batch(3) + + episode = EpisodeFactory(title="testing python") + + qs, _ = admin.get_search_results( + rf.get("/"), Episode.objects.all(), "testing python" + ) + assert qs.count() == 1 + assert qs.first() == episode + + +# ============================================================================= +# AudioLog Admin +# ============================================================================= + + +class TestAudioLogAdmin: + @pytest.mark.django_db + def test_get_queryset(self, rf): + AudioLogFactory() + admin = AudioLogAdmin(AudioLog, AdminSite()) + request = rf.get("/") + qs = admin.get_queryset(request) + assert qs.count() == 1 diff --git a/simplecasts/tests/test_commands.py b/simplecasts/tests/test_commands.py new file mode 100644 index 0000000000..15ab8d7727 --- /dev/null +++ b/simplecasts/tests/test_commands.py @@ -0,0 +1,214 @@ +from datetime import timedelta + +import pytest +from django.core.management import CommandError, call_command +from django.utils import timezone + +from simplecasts.services.itunes import Feed, ItunesError +from simplecasts.tests.factories import ( + AudioLogFactory, + BookmarkFactory, + CategoryFactory, + EmailAddressFactory, + EpisodeFactory, + PodcastFactory, + RecommendationFactory, + SubscriptionFactory, +) + +# ============================================================================= +# Parse Podcast Feeds +# ============================================================================= + + +class TestParsePodcastFeeds: + parse_feed = "simplecasts.management.commands.parse_podcast_feeds.parse_feed" + + @pytest.fixture + def mock_parse(self, mocker): + return mocker.patch(self.parse_feed) + + @pytest.mark.django_db + def test_ok(self, mocker): + mock_parse = mocker.patch(self.parse_feed) + PodcastFactory(pub_date=None) + call_command("parse_podcast_feeds") + mock_parse.assert_called() + + @pytest.mark.django_db + def test_not_scheduled(self, mocker): + mock_parse = mocker.patch(self.parse_feed) + PodcastFactory(active=False) + call_command("parse_podcast_feeds") + mock_parse.assert_not_called() + + +# ============================================================================= +# Fetch iTunes Feeds +# ============================================================================= + + +class TestFetchItunesFeeds: + mock_fetch = ( + "simplecasts.management.commands.fetch_itunes_feeds.itunes.fetch_top_feeds" + ) + mock_save = ( + "simplecasts.management.commands.fetch_itunes_feeds.itunes.save_feeds_to_db" + ) + + @pytest.fixture + def category(self): + return CategoryFactory(itunes_genre_id=1301) + + @pytest.fixture + def feed(self): + return Feed( + artworkUrl100="https://example.com/test.jpg", + collectionName="example", + collectionViewUrl="https://example.com/", + feedUrl="https://example.com/rss/", + ) + + @pytest.mark.django_db + def test_ok(self, category, mocker, feed): + mock_fetch = mocker.patch(self.mock_fetch, return_value=[feed]) + mock_save_feeds = mocker.patch(self.mock_save) + call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) + mock_fetch.assert_called() + mock_save_feeds.assert_any_call([feed], promoted=True) + mock_save_feeds.assert_any_call([feed]) + + @pytest.mark.django_db + def test_invalid_country_codes(self): + with pytest.raises(CommandError): + call_command( + "fetch_itunes_feeds", min_jitter=0, max_jitter=0, countries=["us", "tx"] + ) + + @pytest.mark.django_db + def test_no_chart_feeds(self, category, mocker, feed): + mock_fetch = mocker.patch(self.mock_fetch, return_value=[]) + mock_save_feeds = mocker.patch(self.mock_save) + call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) + mock_fetch.assert_called() + mock_save_feeds.assert_not_called() + + @pytest.mark.django_db + def test_itunes_error(self, mocker): + mock_fetch = mocker.patch( + self.mock_fetch, side_effect=ItunesError("Error fetching iTunes") + ) + mock_save_feeds = mocker.patch(self.mock_save) + call_command("fetch_itunes_feeds", min_jitter=0, max_jitter=0) + mock_fetch.assert_called() + mock_save_feeds.assert_not_called() + + +# ============================================================================= +# Create Podcast Recommendations +# ============================================================================= + + +class TestCreatePodcastRecommendations: + @pytest.mark.django_db + def test_create_recommendations(self, mocker): + patched = mocker.patch( + "simplecasts.services.recommender.recommend", + return_value=RecommendationFactory.create_batch(3), + ) + call_command("create_podcast_recommendations") + patched.assert_called() + + +# ============================================================================= +# Send Podcast Recommendations +# ============================================================================= + + +class TestSendPodcastRecommendations: + @pytest.fixture + def recipient(self): + return EmailAddressFactory(verified=True, primary=True) + + @pytest.mark.django_db(transaction=True) + def test_ok(self, recipient, mailoutbox): + podcast = SubscriptionFactory(subscriber=recipient.user).podcast + RecommendationFactory(podcast=podcast) + call_command("send_podcast_recommendations") + assert len(mailoutbox) == 1 + + @pytest.mark.django_db(transaction=True) + def test_no_recommendations(self, recipient, mailoutbox): + PodcastFactory() + call_command("send_podcast_recommendations") + assert len(mailoutbox) == 0 + + +# ============================================================================= +# Send Episode Notifications +# ============================================================================= + + +class TestSendEpisodeNotifications: + @pytest.fixture + def recipient(self): + return EmailAddressFactory( + verified=True, + primary=True, + ) + + @pytest.mark.django_db(transaction=True) + def test_has_episodes(self, mailoutbox, recipient): + subscription = SubscriptionFactory( + subscriber=recipient.user, + ) + EpisodeFactory.create_batch( + 3, + podcast=subscription.podcast, + pub_date=timezone.now() - timedelta(days=1), + ) + call_command("send_episode_notifications") + assert len(mailoutbox) == 1 + assert mailoutbox[0].to == [recipient.email] + + @pytest.mark.django_db(transaction=True) + def test_is_bookmarked(self, mailoutbox, recipient): + subscription = SubscriptionFactory( + subscriber=recipient.user, + ) + episode = EpisodeFactory( + podcast=subscription.podcast, + pub_date=timezone.now() - timedelta(days=1), + ) + BookmarkFactory(episode=episode, user=recipient.user) + call_command("send_episode_notifications") + assert len(mailoutbox) == 0 + + @pytest.mark.django_db(transaction=True) + def test_no_new_episodes(self, mailoutbox, recipient): + subscription = SubscriptionFactory( + subscriber=recipient.user, + ) + EpisodeFactory.create_batch( + 3, + podcast=subscription.podcast, + pub_date=timezone.now() - timedelta(days=10), + ) + call_command("send_episode_notifications") + assert len(mailoutbox) == 0 + + @pytest.mark.django_db(transaction=True) + def test_listened(self, mailoutbox, recipient): + subscription = SubscriptionFactory( + subscriber=recipient.user, + ) + episode = EpisodeFactory( + podcast=subscription.podcast, + pub_date=timezone.now() - timedelta(days=1), + ) + AudioLogFactory( + episode=episode, + user=recipient.user, + ) + call_command("send_episode_notifications") + assert len(mailoutbox) == 0 diff --git a/simplecasts/tests/test_middleware.py b/simplecasts/tests/test_middleware.py index c376aaa0ac..ea23df30bb 100644 --- a/simplecasts/tests/test_middleware.py +++ b/simplecasts/tests/test_middleware.py @@ -8,6 +8,8 @@ HtmxCacheMiddleware, HtmxMessagesMiddleware, HtmxRedirectMiddleware, + PlayerDetails, + PlayerMiddleware, SearchDetails, SearchMiddleware, ) @@ -161,3 +163,47 @@ def _get_response(_): req._messages = messages resp = mw(req) assert b"OK" not in resp.content + + +class TestPlayerMiddleware: + def test_middleware(self, rf, get_response): + req = rf.get("/") + PlayerMiddleware(get_response)(req) + assert req.player + + +class TestPlayerDetails: + episode_id = 12345 + + @pytest.fixture + def player_req(self, rf): + req = rf.get("/") + req.session = {} + return req + + @pytest.fixture + def player(self, player_req): + return PlayerDetails(request=player_req) + + def test_get_if_none(self, player): + assert player.get() is None + + def test_get_if_not_none(self, player): + player.set(self.episode_id) + assert player.get() == self.episode_id + + def test_pop_if_none(self, player): + assert player.pop() is None + + def test_pop_if_not_none(self, player): + player.set(self.episode_id) + + assert player.pop() == self.episode_id + assert player.get() is None + + def test_has_false(self, player): + assert not player.has(self.episode_id) + + def test_has_true(self, player): + player.set(self.episode_id) + assert player.has(self.episode_id) diff --git a/simplecasts/tests/test_templatetags.py b/simplecasts/tests/test_templatetags.py index 152aeb367e..4c9a022ce3 100644 --- a/simplecasts/tests/test_templatetags.py +++ b/simplecasts/tests/test_templatetags.py @@ -2,8 +2,13 @@ from django.contrib.sites.models import Site from django.template import TemplateSyntaxError -from simplecasts.request import RequestContext -from simplecasts.templatetags import cookie_banner, fragment +from simplecasts.http.request import RequestContext +from simplecasts.middleware import PlayerDetails +from simplecasts.templatetags import cookie_banner, format_duration, fragment +from simplecasts.templatetags.audio_player import ( + audio_player, + get_media_metadata, +) @pytest.fixture @@ -41,3 +46,96 @@ def test_accepted(self, rf): req.COOKIES = {"accept-cookies": True} context = RequestContext(request=req) assert cookie_banner(context)["cookies_accepted"] is True + + +# ============================================================================= +# Episode Template Tags +# ============================================================================= + + +class TestFormatDuration: + @pytest.mark.parametrize( + ("duration", "expected"), + [ + pytest.param(0, "", id="zero"), + pytest.param(30, "", id="30 seconds"), + pytest.param(60, "1\xa0minute", id="1 minute"), + pytest.param(61, "1\xa0minute", id="just over 1 minute"), + pytest.param(90, "1\xa0minute", id="1 minute 30 seconds"), + pytest.param(540, "9\xa0minutes", id="9 minutes"), + pytest.param(2400, "40\xa0minutes", id="40 minutes"), + pytest.param(3600, "1\xa0hour", id="1 hour"), + pytest.param(9000, "2\xa0hours, 30\xa0minutes", id="2 hours 30 minutes"), + ], + ) + def test_format_duration(self, duration, expected): + assert format_duration(duration) == expected + + +class TestGetMediaMetadata: + @pytest.mark.django_db + def test_get_media_metadata(self, rf, episode): + req = rf.get("/") + context = RequestContext(request=req) + assert get_media_metadata(context, episode) + + +class TestAudioPlayer: + @pytest.mark.django_db + def test_close(self, rf, audio_log): + req = rf.get("/") + req.user = audio_log.user + req.player = PlayerDetails(request=req) + req.session = {req.player.session_id: audio_log.episode_id} + + context = RequestContext(request=req) + + dct = audio_player(context, audio_log, action="close") + assert "audio_log" not in dct + + @pytest.mark.django_db + def test_play(self, rf, audio_log): + req = rf.get("/") + req.user = audio_log.user + + context = RequestContext(request=req) + + dct = audio_player(context, audio_log, action="play") + assert dct["audio_log"] == audio_log + + @pytest.mark.django_db + def test_load(self, rf, audio_log): + req = rf.get("/") + req.user = audio_log.user + req.player = PlayerDetails(request=req) + req.session = {req.player.session_id: audio_log.episode_id} + + context = RequestContext(request=req) + + dct = audio_player(context, None, action="load") + assert dct["audio_log"] == audio_log + + @pytest.mark.django_db + def test_load_empty(self, rf, audio_log): + req = rf.get("/") + req.user = audio_log.user + req.player = PlayerDetails(request=req) + req.session = {} + + context = RequestContext(request=req) + + dct = audio_player(context, None, action="load") + assert dct["audio_log"] is None + + @pytest.mark.django_db + def test_load_user_not_authenticated(self, rf, audio_log, anonymous_user): + req = rf.get("/") + req.user = anonymous_user + req.player = PlayerDetails(request=req) + req.session = {} + req.session = {req.player.session_id: audio_log.episode_id} + + context = RequestContext(request=req) + + dct = audio_player(context, None, action="load") + assert dct["audio_log"] is None diff --git a/simplecasts/podcasts/management/commands/__init__.py b/simplecasts/tests/views/__init__.py similarity index 100% rename from simplecasts/podcasts/management/commands/__init__.py rename to simplecasts/tests/views/__init__.py diff --git a/simplecasts/tests/views/test_bookmarks.py b/simplecasts/tests/views/test_bookmarks.py new file mode 100644 index 0000000000..6a47b03968 --- /dev/null +++ b/simplecasts/tests/views/test_bookmarks.py @@ -0,0 +1,96 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertTemplateUsed + +from simplecasts.models import Bookmark +from simplecasts.tests.asserts import ( + assert200, + assert409, +) +from simplecasts.tests.factories import ( + BookmarkFactory, + EpisodeFactory, + PodcastFactory, +) + + +class TestBookmarks: + url = reverse_lazy("bookmarks:index") + + @pytest.mark.django_db + def test_get(self, client, auth_user): + BookmarkFactory.create_batch(33, user=auth_user) + + response = client.get(self.url) + + assert200(response) + assertTemplateUsed(response, "bookmarks/index.html") + + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_ascending(self, client, auth_user): + BookmarkFactory.create_batch(33, user=auth_user) + + response = client.get(self.url, {"order": "asc"}) + + assert200(response) + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(self.url) + assert200(response) + + @pytest.mark.django_db + def test_search(self, client, auth_user): + podcast = PodcastFactory(title="zzzz") + + for _ in range(3): + BookmarkFactory( + user=auth_user, + episode=EpisodeFactory(title="zzzz", podcast=podcast), + ) + + BookmarkFactory(user=auth_user, episode=EpisodeFactory(title="testing")) + + response = client.get(self.url, {"search": "testing"}) + + assert200(response) + assertTemplateUsed(response, "bookmarks/index.html") + + assert len(response.context["page"].object_list) == 1 + + +class TestAddBookmark: + @pytest.mark.django_db + def test_post(self, client, auth_user, episode): + response = client.post(self.url(episode), headers={"HX-Request": "true"}) + + assert200(response) + assert Bookmark.objects.filter(user=auth_user, episode=episode).exists() + + @pytest.mark.django_db()(transaction=True) + def test_already_bookmarked(self, client, auth_user, episode): + BookmarkFactory(episode=episode, user=auth_user) + + response = client.post(self.url(episode), headers={"HX-Request": "true"}) + assert409(response) + + assert Bookmark.objects.filter(user=auth_user, episode=episode).exists() + + def url(self, episode): + return reverse("bookmarks:add", args=[episode.pk]) + + +class TestRemoveBookmark: + @pytest.mark.django_db + def test_post(self, client, auth_user, episode): + BookmarkFactory(user=auth_user, episode=episode) + response = client.delete( + reverse("bookmarks:remove", args=[episode.pk]), + headers={"HX-Request": "true"}, + ) + assert200(response) + + assert not Bookmark.objects.filter(user=auth_user, episode=episode).exists() diff --git a/simplecasts/tests/views/test_categories.py b/simplecasts/tests/views/test_categories.py new file mode 100644 index 0000000000..cae32351a2 --- /dev/null +++ b/simplecasts/tests/views/test_categories.py @@ -0,0 +1,87 @@ +import pytest +from django.urls import reverse_lazy + +from simplecasts.tests.asserts import ( + assert200, +) +from simplecasts.tests.factories import ( + CategoryFactory, + PodcastFactory, +) + + +class TestCategoryList: + url = reverse_lazy("categories:index") + + @pytest.mark.django_db + def test_matching_podcasts(self, client, auth_user): + for _ in range(3): + category = CategoryFactory() + category.podcasts.add(PodcastFactory()) + + response = client.get(self.url) + + assert200(response) + assert len(response.context["categories"]) == 3 + + @pytest.mark.django_db + def test_no_matching_podcasts( + self, + client, + auth_user, + ): + CategoryFactory.create_batch(3) + response = client.get(self.url) + + assert200(response) + assert len(response.context["categories"]) == 0 + + @pytest.mark.django_db + def test_search(self, client, auth_user, category, faker): + CategoryFactory.create_batch(3) + + category = CategoryFactory(name="testing") + category.podcasts.add(PodcastFactory()) + + response = client.get(self.url, {"search": "testing"}) + + assert200(response) + assert len(response.context["categories"]) == 1 + + @pytest.mark.django_db + def test_search_no_matching_podcasts(self, client, auth_user, category, faker): + CategoryFactory.create_batch(3) + + CategoryFactory(name="testing") + + response = client.get(self.url, {"search": "testing"}) + + assert200(response) + assert len(response.context["categories"]) == 0 + + +class TestCategoryDetail: + @pytest.mark.django_db + def test_get(self, client, auth_user, category): + PodcastFactory.create_batch(12, categories=[category]) + response = client.get(category.get_absolute_url()) + assert200(response) + assert response.context["category"] == category + + @pytest.mark.django_db + def test_search(self, client, auth_user, category, faker): + PodcastFactory.create_batch(12, title="zzzz", categories=[category]) + podcast = PodcastFactory(title=faker.unique.text(), categories=[category]) + + response = client.get(category.get_absolute_url(), {"search": podcast.title}) + + assert200(response) + + assert len(response.context["page"].object_list) == 1 + + @pytest.mark.django_db + def test_no_podcasts(self, client, auth_user, category): + response = client.get(category.get_absolute_url()) + assert200(response) + + assert len(response.context["page"].object_list) == 0 diff --git a/simplecasts/tests/views/test_episodes.py b/simplecasts/tests/views/test_episodes.py new file mode 100644 index 0000000000..bb0ea2ac47 --- /dev/null +++ b/simplecasts/tests/views/test_episodes.py @@ -0,0 +1,122 @@ +from datetime import timedelta + +import pytest +from django.urls import reverse_lazy +from django.utils import timezone +from pytest_django.asserts import assertContains, assertNotContains, assertTemplateUsed + +from simplecasts.tests.asserts import ( + assert200, +) +from simplecasts.tests.factories import ( + AudioLogFactory, + EpisodeFactory, + PodcastFactory, + SubscriptionFactory, +) + +_index_url = reverse_lazy("episodes:index") + + +class TestNewReleases: + @pytest.mark.django_db + def test_no_episodes(self, client, auth_user): + response = client.get(_index_url) + assert200(response) + assertTemplateUsed(response, "episodes/index.html") + assert len(response.context["episodes"]) == 0 + + @pytest.mark.django_db + def test_has_no_subscriptions(self, client, auth_user): + EpisodeFactory.create_batch(3) + response = client.get(_index_url) + + assert200(response) + assertTemplateUsed(response, "episodes/index.html") + assert len(response.context["episodes"]) == 0 + + @pytest.mark.django_db + def test_has_subscriptions(self, client, auth_user): + episode = EpisodeFactory() + SubscriptionFactory(subscriber=auth_user, podcast=episode.podcast) + + response = client.get(_index_url) + + assert200(response) + assertTemplateUsed(response, "episodes/index.html") + assert len(response.context["episodes"]) == 1 + + +class TestEpisodeDetail: + @pytest.fixture + def episode(self, faker): + return EpisodeFactory( + podcast=PodcastFactory( + owner=faker.name(), + website=faker.url(), + funding_url=faker.url(), + funding_text=faker.text(), + explicit=True, + ), + episode_type="full", + file_size=9000, + duration="3:30:30", + ) + + @pytest.mark.django_db + def test_ok(self, client, auth_user, episode): + response = client.get(episode.get_absolute_url()) + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == episode + + @pytest.mark.django_db + def test_listened(self, client, auth_user, episode): + AudioLogFactory( + episode=episode, + user=auth_user, + current_time=900, + listened=timezone.now(), + ) + + response = client.get(episode.get_absolute_url()) + + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == episode + + assertContains(response, "Remove episode from your History") + assertContains(response, "Listened") + + @pytest.mark.django_db + def test_no_prev_next_episode(self, client, auth_user, episode): + response = client.get(episode.get_absolute_url()) + + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == episode + assertNotContains(response, "No More Episodes") + + @pytest.mark.django_db + def test_no_next_episode(self, client, auth_user, episode): + EpisodeFactory( + podcast=episode.podcast, + pub_date=episode.pub_date - timedelta(days=30), + ) + response = client.get(episode.get_absolute_url()) + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == episode + assertContains(response, "Last Episode") + + @pytest.mark.django_db + def test_no_previous_episode(self, client, auth_user, episode): + EpisodeFactory( + podcast=episode.podcast, + pub_date=episode.pub_date + timedelta(days=30), + ) + response = client.get(episode.get_absolute_url()) + assert200(response) + assertTemplateUsed(response, "episodes/detail.html") + assert response.context["episode"] == episode + assertContains(response, "First Episode") diff --git a/simplecasts/tests/views/test_history.py b/simplecasts/tests/views/test_history.py new file mode 100644 index 0000000000..1615fa246c --- /dev/null +++ b/simplecasts/tests/views/test_history.py @@ -0,0 +1,150 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertTemplateUsed + +from simplecasts.models import AudioLog +from simplecasts.tests.asserts import ( + assert200, + assert404, +) +from simplecasts.tests.factories import ( + AudioLogFactory, + EpisodeFactory, + PodcastFactory, +) + + +class TestHistory: + url = reverse_lazy("history:index") + + @pytest.mark.django_db + def test_get(self, client, auth_user): + AudioLogFactory.create_batch(33, user=auth_user) + response = client.get(self.url) + assert200(response) + assertTemplateUsed(response, "history/index.html") + + assert200(response) + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(self.url) + assert200(response) + + @pytest.mark.django_db + def test_ascending(self, client, auth_user): + AudioLogFactory.create_batch(33, user=auth_user) + + response = client.get(self.url, {"order": "asc"}) + assert200(response) + + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_search(self, client, auth_user): + podcast = PodcastFactory(title="zzzz") + + for _ in range(3): + AudioLogFactory( + user=auth_user, + episode=EpisodeFactory(title="zzzz", podcast=podcast), + ) + + AudioLogFactory(user=auth_user, episode=EpisodeFactory(title="testing")) + response = client.get(self.url, {"search": "testing"}) + + assert200(response) + assert len(response.context["page"].object_list) == 1 + + +class TestMarkAudioLogComplete: + @pytest.mark.django_db + def test_ok(self, client, auth_user, episode): + audio_log = AudioLogFactory(user=auth_user, episode=episode, current_time=300) + + response = client.post( + self.url(episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + + assert200(response) + + audio_log.refresh_from_db() + assert audio_log.current_time == 0 + + @pytest.mark.django_db + def test_is_playing(self, client, auth_user, player_episode): + """Do not mark complete if episode is currently playing""" + + response = client.post( + self.url(player_episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + + assert404(response) + + assert AudioLog.objects.filter(user=auth_user, episode=player_episode).exists() + + def url(self, episode): + return reverse("history:mark_complete", args=[episode.pk]) + + +class TestRemoveAudioLog: + @pytest.mark.django_db + def test_ok(self, client, auth_user, episode): + AudioLogFactory(user=auth_user, episode=episode) + AudioLogFactory(user=auth_user) + + response = client.delete( + self.url(episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + + assert200(response) + + assert not AudioLog.objects.filter(user=auth_user, episode=episode).exists() + assert AudioLog.objects.filter(user=auth_user).count() == 1 + + @pytest.mark.django_db + def test_is_playing(self, client, auth_user, player_episode): + """Do not remove log if episode is currently playing""" + + response = client.delete( + self.url(player_episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + + assert404(response) + assert AudioLog.objects.filter(user=auth_user, episode=player_episode).exists() + + @pytest.mark.django_db + def test_none_remaining(self, client, auth_user, episode): + log = AudioLogFactory(user=auth_user, episode=episode) + + response = client.delete( + self.url(log.episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-log", + }, + ) + assert200(response) + + assert not AudioLog.objects.filter(user=auth_user, episode=episode).exists() + assert AudioLog.objects.filter(user=auth_user).count() == 0 + + def url(self, episode): + return reverse("history:remove", args=[episode.pk]) diff --git a/simplecasts/tests/test_paginator.py b/simplecasts/tests/views/test_paginator.py similarity index 97% rename from simplecasts/tests/test_paginator.py rename to simplecasts/tests/views/test_paginator.py index a0cec1072a..aa224ee2b3 100644 --- a/simplecasts/tests/test_paginator.py +++ b/simplecasts/tests/views/test_paginator.py @@ -1,7 +1,7 @@ import pytest from django.core.paginator import EmptyPage, PageNotAnInteger -from simplecasts.paginator import Paginator, validate_page_number +from simplecasts.views.paginator import Paginator, validate_page_number class TestPage: diff --git a/simplecasts/tests/test_partials.py b/simplecasts/tests/views/test_partials.py similarity index 95% rename from simplecasts/tests/test_partials.py rename to simplecasts/tests/views/test_partials.py index ea3fbdab06..bd64e13434 100644 --- a/simplecasts/tests/test_partials.py +++ b/simplecasts/tests/views/test_partials.py @@ -1,6 +1,6 @@ from django_htmx.middleware import HtmxDetails -from simplecasts.partials import render_partial_response +from simplecasts.views.partials import render_partial_response class TestRenderPartialResponse: diff --git a/simplecasts/tests/views/test_player.py b/simplecasts/tests/views/test_player.py new file mode 100644 index 0000000000..1f9feecb69 --- /dev/null +++ b/simplecasts/tests/views/test_player.py @@ -0,0 +1,222 @@ +import json + +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertContains + +from simplecasts.middleware import PlayerDetails +from simplecasts.models import AudioLog +from simplecasts.tests.asserts import ( + assert200, + assert204, + assert400, + assert401, + assert409, +) +from simplecasts.tests.factories import ( + EpisodeFactory, +) + + +class TestStartPlayer: + @pytest.mark.django_db + def test_play_from_start(self, client, auth_user, episode): + response = client.post( + self.url(episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + assert200(response) + assertContains(response, 'id="audio-player-button"') + + assert AudioLog.objects.filter(user=auth_user, episode=episode).exists() + assert client.session[PlayerDetails.session_id] == episode.pk + + @pytest.mark.django_db + def test_another_episode_in_player(self, client, auth_user, player_episode): + episode = EpisodeFactory() + response = client.post( + self.url(episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + + assert200(response) + assertContains(response, 'id="audio-player-button"') + + assert AudioLog.objects.filter(user=auth_user, episode=episode).exists() + + assert client.session[PlayerDetails.session_id] == episode.pk + + @pytest.mark.django_db + def test_resume(self, client, auth_user, player_episode): + response = client.post( + self.url(player_episode), + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + + assert200(response) + assertContains(response, 'id="audio-player-button"') + + assert client.session[PlayerDetails.session_id] == player_episode.pk + + def url(self, episode): + return reverse("player:start", args=[episode.pk]) + + +class TestClosePlayer: + url = reverse_lazy("player:close") + + @pytest.mark.django_db + def test_player_empty(self, client, auth_user, episode): + response = client.post( + self.url, + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + + assert204(response) + + @pytest.mark.django_db + def test_close( + self, + client, + player_episode, + ): + response = client.post( + self.url, + headers={ + "HX-Request": "true", + "HX-Target": "audio-player-button", + }, + ) + + assert200(response) + assertContains(response, 'id="audio-player-button"') + + assert player_episode.pk not in client.session + + +class TestPlayerTimeUpdate: + url = reverse_lazy("player:time_update") + + @pytest.mark.django_db + def test_is_running(self, client, player_episode): + response = client.post( + self.url, + json.dumps( + { + "current_time": 1030, + "duration": 3600, + } + ), + content_type="application/json", + ) + + assert200(response) + + log = AudioLog.objects.first() + assert log is not None + + assert log.current_time == 1030 + + @pytest.mark.django_db + def test_player_log_missing(self, client, auth_user, episode): + session = client.session + session[PlayerDetails.session_id] = episode.pk + session.save() + + response = client.post( + self.url, + json.dumps( + { + "current_time": 1030, + "duration": 3600, + } + ), + content_type="application/json", + ) + + assert200(response) + + log = AudioLog.objects.get() + + assert log.current_time == 1030 + assert log.episode == episode + + @pytest.mark.django_db + def test_player_not_in_session(self, client, auth_user, episode): + response = client.post( + self.url, + json.dumps( + { + "current_time": 1030, + "duration": 3600, + } + ), + content_type="application/json", + ) + + assert400(response) + + assert not AudioLog.objects.exists() + + @pytest.mark.django_db + def test_missing_data(self, client, auth_user, player_episode): + response = client.post(self.url) + assert400(response) + + @pytest.mark.django_db + def test_invalid_data(self, client, auth_user, player_episode): + response = client.post( + self.url, + json.dumps( + { + "current_time": "xyz", + "duration": "abc", + } + ), + content_type="application/json", + ) + assert400(response) + + @pytest.mark.django_db()(transaction=True) + def test_episode_does_not_exist(self, client, auth_user): + session = client.session + session[PlayerDetails.session_id] = 12345 + session.save() + + response = client.post( + self.url, + json.dumps( + { + "current_time": 1000, + "duration": 3600, + } + ), + content_type="application/json", + ) + assert409(response) + + @pytest.mark.django_db + def test_user_not_authenticated(self, client): + response = client.post( + self.url, + json.dumps( + { + "current_time": 1000, + "duration": 3600, + } + ), + content_type="application/json", + ) + assert401(response) diff --git a/simplecasts/tests/views/test_podcasts.py b/simplecasts/tests/views/test_podcasts.py new file mode 100644 index 0000000000..16c5fcce98 --- /dev/null +++ b/simplecasts/tests/views/test_podcasts.py @@ -0,0 +1,240 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertContains, assertTemplateUsed + +from simplecasts.models import Podcast +from simplecasts.tests.asserts import ( + assert200, + assert404, +) +from simplecasts.tests.factories import ( + CategoryFactory, + EpisodeFactory, + PodcastFactory, + RecommendationFactory, + SubscriptionFactory, +) + + +class TestDiscover: + url = reverse_lazy("podcasts:discover") + + @pytest.mark.django_db + def test_get(self, client, auth_user, settings): + settings.DISCOVER_FEED_LANGUAGE = "en" + response = client.get(self.url) + PodcastFactory.create_batch(3, promoted=True, language="en") + assert200(response) + assertTemplateUsed(response, "podcasts/discover.html") + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(self.url) + assert200(response) + assertTemplateUsed(response, "podcasts/discover.html") + + assert len(response.context["podcasts"]) == 0 + + +class TestPodcastSimilar: + @pytest.mark.django_db + def test_get(self, client, auth_user, podcast): + EpisodeFactory.create_batch(3, podcast=podcast) + RecommendationFactory.create_batch(3, podcast=podcast) + response = client.get(podcast.get_similar_url()) + + assert200(response) + + assert response.context["podcast"] == podcast + assert len(response.context["recommendations"]) == 3 + + +class TestPodcastDetail: + @pytest.fixture + def podcast(self, faker): + return PodcastFactory( + owner=faker.name(), + website=faker.url(), + funding_url=faker.url(), + funding_text=faker.text(), + categories=CategoryFactory.create_batch(3), + ) + + @pytest.mark.django_db + def test_get_podcast_no_website(self, client, auth_user, faker): + podcast = PodcastFactory(website="", owner=faker.name()) + response = client.get(podcast.get_absolute_url()) + + assert200(response) + + assert response.context["podcast"] == podcast + + @pytest.mark.django_db + def test_get_podcast_subscribed(self, client, auth_user, podcast): + podcast.categories.set(CategoryFactory.create_batch(3)) + SubscriptionFactory(subscriber=auth_user, podcast=podcast) + response = client.get(podcast.get_absolute_url()) + + assert200(response) + + assert response.context["podcast"] == podcast + assert response.context["is_subscribed"] is True + + @pytest.mark.django_db + def test_get_podcast_private_subscribed(self, client, auth_user): + podcast = PodcastFactory(private=True) + SubscriptionFactory(subscriber=auth_user, podcast=podcast) + response = client.get(podcast.get_absolute_url()) + + assert200(response) + + assert response.context["podcast"] == podcast + assert response.context["is_subscribed"] is True + + @pytest.mark.django_db + def test_get_podcast_private_not_subscribed(self, client, auth_user): + podcast = PodcastFactory(private=True) + response = client.get(podcast.get_absolute_url()) + + assert200(response) + + assert response.context["podcast"] == podcast + assert response.context["is_subscribed"] is False + + @pytest.mark.django_db + def test_get_podcast_not_subscribed(self, client, auth_user, podcast): + response = client.get(podcast.get_absolute_url()) + + assert200(response) + + assert response.context["podcast"] == podcast + assert response.context["is_subscribed"] is False + + @pytest.mark.django_db + def test_get_podcast_admin(self, client, staff_user, podcast): + response = client.get(podcast.get_absolute_url()) + + assert200(response) + + assert response.context["podcast"] == podcast + assertContains(response, "Admin") + + @pytest.mark.django_db + def test_redirect_to_canonical(self, client, auth_user, podcast): + duplicate = PodcastFactory(canonical=podcast) + response = client.get(duplicate.get_absolute_url()) + assert200(response) + assertContains(response, "moved") + + +class TestLatestEpisode: + @pytest.mark.django_db + def test_ok(self, client, auth_user, episode): + response = client.get(self.url(episode.podcast)) + assert response.url == episode.get_absolute_url() + + @pytest.mark.django_db + def test_no_episodes(self, client, auth_user, podcast): + response = client.get(self.url(podcast)) + assert404(response) + + def url(self, podcast): + return reverse("podcasts:latest_episode", args=[podcast.pk]) + + +class TestPodcastSeason: + @pytest.mark.django_db + def test_get_episodes_for_season(self, client, auth_user, podcast): + EpisodeFactory.create_batch(20, podcast=podcast, season=1) + EpisodeFactory.create_batch(10, podcast=podcast, season=2) + + response = client.get( + reverse( + "podcasts:season", + kwargs={ + "podcast_id": podcast.pk, + "slug": podcast.slug, + "season": 1, + }, + ) + ) + assert200(response) + + assert len(response.context["page"].object_list) == 20 + assert response.context["season"].season == 1 + + @pytest.mark.django_db + def test_get_serial(self, client, auth_user): + podcast = PodcastFactory(podcast_type=Podcast.PodcastType.SERIAL) + EpisodeFactory.create_batch(20, podcast=podcast, season=1) + EpisodeFactory.create_batch(10, podcast=podcast, season=2) + + response = client.get( + reverse( + "podcasts:season", + kwargs={ + "podcast_id": podcast.pk, + "slug": podcast.slug, + "season": 1, + }, + ) + ) + assert200(response) + + assert len(response.context["page"].object_list) == 20 + assert response.context["season"].season == 1 + + +class TestPodcastEpisodes: + @pytest.mark.django_db + def test_get_episodes(self, client, auth_user, podcast): + EpisodeFactory.create_batch(33, podcast=podcast) + + response = client.get(podcast.get_episodes_url()) + assert200(response) + + assert len(response.context["page"].object_list) == 30 + assert response.context["ordering"] == "desc" + + @pytest.mark.django_db + def test_serial(self, client, auth_user): + podcast = PodcastFactory(podcast_type=Podcast.PodcastType.SERIAL) + EpisodeFactory.create_batch(33, podcast=podcast) + + response = client.get(podcast.get_episodes_url()) + assert200(response) + + assert len(response.context["page"].object_list) == 30 + assert response.context["ordering"] == "asc" + + @pytest.mark.django_db + def test_no_episodes(self, client, auth_user, podcast): + response = client.get(podcast.get_episodes_url()) + + assert200(response) + assert len(response.context["page"].object_list) == 0 + + @pytest.mark.django_db + def test_ascending(self, client, auth_user, podcast): + EpisodeFactory.create_batch(33, podcast=podcast) + + response = client.get( + podcast.get_episodes_url(), + {"order": "asc"}, + ) + assert200(response) + + assert len(response.context["page"].object_list) == 30 + + @pytest.mark.django_db + def test_search(self, client, auth_user, podcast, faker): + EpisodeFactory.create_batch(3, podcast=podcast) + + episode = EpisodeFactory(title=faker.unique.name(), podcast=podcast) + + response = client.get( + podcast.get_episodes_url(), + {"search": episode.title}, + ) + assert200(response) + assert len(response.context["page"].object_list) == 1 diff --git a/simplecasts/tests/views/test_private_feeds.py b/simplecasts/tests/views/test_private_feeds.py new file mode 100644 index 0000000000..c7ada2c4c7 --- /dev/null +++ b/simplecasts/tests/views/test_private_feeds.py @@ -0,0 +1,139 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertTemplateUsed + +from simplecasts.models import Podcast, Subscription +from simplecasts.tests.asserts import ( + assert200, + assert404, +) +from simplecasts.tests.factories import PodcastFactory, SubscriptionFactory + + +class TestPrivateFeeds: + url = reverse_lazy("private_feeds:index") + + @pytest.mark.django_db + def test_ok(self, client, auth_user): + for podcast in PodcastFactory.create_batch(33, private=True): + SubscriptionFactory(subscriber=auth_user, podcast=podcast) + response = client.get(self.url) + assert200(response) + assert len(response.context["page"]) == 30 + assert response.context["page"].has_other_pages is True + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + PodcastFactory(private=True) + response = client.get(self.url) + assert200(response) + assert len(response.context["page"]) == 0 + assert response.context["page"].has_other_pages is False + + @pytest.mark.django_db + def test_search(self, client, auth_user, faker): + podcast = SubscriptionFactory( + subscriber=auth_user, + podcast=PodcastFactory(title=faker.unique.text(), private=True), + ).podcast + + SubscriptionFactory( + subscriber=auth_user, + podcast=PodcastFactory(title="zzz", private=True), + ) + + response = client.get(self.url, {"search": podcast.title}) + assert200(response) + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == podcast + + +class TestRemovePrivateFeed: + def url(self, podcast): + return reverse("private_feeds:remove", args=[podcast.pk]) + + @pytest.mark.django_db + def test_ok(self, client, auth_user): + podcast = PodcastFactory(private=True) + SubscriptionFactory(podcast=podcast, subscriber=auth_user) + + response = client.delete( + self.url(podcast), + {"rss": podcast.rss}, + ) + assert response.url == reverse("private_feeds:index") + + assert not Podcast.objects.filter(pk=podcast.pk).exists() + + @pytest.mark.django_db + def test_not_owned_by_user(self, client, auth_user): + podcast = PodcastFactory(private=True) + + response = client.delete( + self.url(podcast), + {"rss": podcast.rss}, + ) + assert404(response) + + assert Podcast.objects.filter(pk=podcast.pk).exists() + + @pytest.mark.django_db + def test_not_private_feed(self, client, auth_user): + podcast = PodcastFactory(private=False) + SubscriptionFactory(podcast=podcast, subscriber=auth_user) + response = client.delete(self.url(podcast), {"rss": podcast.rss}) + assert404(response) + + assert Podcast.objects.filter(pk=podcast.pk).exists() + + assert Subscription.objects.filter( + subscriber=auth_user, podcast=podcast + ).exists() + + +class TestAddPrivateFeed: + url = reverse_lazy("private_feeds:add") + + @pytest.fixture + def rss(self, faker): + return faker.url() + + @pytest.mark.django_db + def test_get(self, client, auth_user): + response = client.get(self.url) + assert200(response) + assertTemplateUsed(response, "private_feeds/private_feed_form.html") + + @pytest.mark.django_db + def test_post_not_existing(self, client, auth_user, rss): + response = client.post(self.url, {"rss": rss}) + assert response.url == reverse("private_feeds:index") + + podcast = Subscription.objects.get( + subscriber=auth_user, podcast__rss=rss + ).podcast + + assert podcast.private + + @pytest.mark.django_db + def test_existing_private(self, client, auth_user): + podcast = PodcastFactory(private=True) + + response = client.post(self.url, {"rss": podcast.rss}) + assert200(response) + + assert not Subscription.objects.filter( + subscriber=auth_user, podcast=podcast + ).exists() + + @pytest.mark.django_db + def test_existing_public(self, client, auth_user): + podcast = PodcastFactory(private=False) + + response = client.post(self.url, {"rss": podcast.rss}) + assert200(response) + + assert not Subscription.objects.filter( + subscriber=auth_user, podcast=podcast + ).exists() diff --git a/simplecasts/tests/views/test_search.py b/simplecasts/tests/views/test_search.py new file mode 100644 index 0000000000..13ab3460df --- /dev/null +++ b/simplecasts/tests/views/test_search.py @@ -0,0 +1,145 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertContains, assertTemplateUsed + +from simplecasts.services import itunes +from simplecasts.tests.asserts import ( + assert200, +) +from simplecasts.tests.factories import ( + EpisodeFactory, + PodcastFactory, +) + +_index_url = reverse_lazy("episodes:index") +_discover_url = reverse_lazy("podcasts:discover") + + +class TestSearchPeople: + @pytest.mark.django_db + def test_get(self, client, auth_user, faker): + podcast = PodcastFactory(owner=faker.name()) + response = client.get( + reverse("search:people"), + { + "search": podcast.cleaned_owner, + }, + ) + assert200(response) + assertTemplateUsed(response, "search/search_people.html") + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(reverse("search:people"), {"search": ""}) + assert response.url == _discover_url + + +class TestSearchPodcasts: + url = reverse_lazy("search:podcasts") + + @pytest.mark.django_db + def test_search(self, client, auth_user, faker): + podcast = PodcastFactory(title=faker.unique.text()) + PodcastFactory.create_batch(3, title="zzz") + response = client.get(self.url, {"search": podcast.title}) + + assert200(response) + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == podcast + + @pytest.mark.django_db + def test_search_value_empty(self, client, auth_user, faker): + response = client.get(self.url, {"search": ""}) + assert response.url == _discover_url + + @pytest.mark.django_db + def test_search_filter_private(self, client, auth_user, faker): + podcast = PodcastFactory(title=faker.unique.text(), private=True) + PodcastFactory.create_batch(3, title="zzz") + response = client.get(self.url, {"search": podcast.title}) + + assert200(response) + + assert len(response.context["page"].object_list) == 0 + + @pytest.mark.django_db + def test_search_no_results(self, client, auth_user, faker): + response = client.get(self.url, {"search": "zzzz"}) + assert200(response) + assert len(response.context["page"].object_list) == 0 + + +class TestSearchItunes: + url = reverse_lazy("search:itunes") + + @pytest.mark.django_db + def test_empty(self, client, auth_user): + response = client.get(self.url, {"search": ""}) + assert response.url == _discover_url + + @pytest.mark.django_db + def test_search(self, client, auth_user, podcast, mocker): + feeds = [ + itunes.Feed( + artworkUrl100="https://assets.fireside.fm/file/fireside-images/podcasts/images/b/bc7f1faf-8aad-4135-bb12-83a8af679756/cover.jpg?v=3", + collectionName="Test & Code : Python Testing", + collectionViewUrl="https://example.com/id123456", + feedUrl="https://feeds.fireside.fm/testandcode/rss", + ), + itunes.Feed( + artworkUrl100="https://assets.fireside.fm/file/fireside-images/podcasts/images/b/bc7f1faf-8aad-4135-bb12-83a8af679756/cover.jpg?v=3", + collectionName=podcast.title, + collectionViewUrl=podcast.website, + feedUrl=podcast.rss, + ), + ] + mock_search = mocker.patch( + "simplecasts.services.itunes.search_cached", + return_value=(feeds, True), + ) + + response = client.get(self.url, {"search": "test"}) + assert200(response) + + assertTemplateUsed(response, "search/search_itunes.html") + + assertContains(response, "Test & Code : Python Testing") + assertContains(response, podcast.title) + + mock_search.assert_called() + + @pytest.mark.django_db + def test_search_error(self, client, auth_user, mocker): + mocker.patch( + "simplecasts.services.itunes.search_cached", + side_effect=itunes.ItunesError("Error"), + ) + response = client.get(self.url, {"search": "test"}) + assert response.url == _discover_url + + +class TestSearchEpisodes: + url = reverse_lazy("search:episodes") + + @pytest.mark.django_db + def test_search(self, auth_user, client, faker): + EpisodeFactory.create_batch(3, title="zzzz") + episode = EpisodeFactory(title=faker.unique.name()) + response = client.get(self.url, {"search": episode.title}) + assert200(response) + assertTemplateUsed(response, "search/search_episodes.html") + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == episode + + @pytest.mark.django_db + def test_search_no_results(self, auth_user, client): + response = client.get(self.url, {"search": "zzzz"}) + assert200(response) + assertTemplateUsed(response, "search/search_episodes.html") + assert len(response.context["page"].object_list) == 0 + + @pytest.mark.django_db + def test_search_value_empty(self, auth_user, client): + response = client.get(self.url, {"search": ""}) + assert response.url == _index_url diff --git a/simplecasts/tests/views/test_subscriptions.py b/simplecasts/tests/views/test_subscriptions.py new file mode 100644 index 0000000000..30e306242a --- /dev/null +++ b/simplecasts/tests/views/test_subscriptions.py @@ -0,0 +1,175 @@ +import pytest +from django.urls import reverse, reverse_lazy +from pytest_django.asserts import assertContains, assertTemplateUsed + +from simplecasts.models import Subscription +from simplecasts.tests.asserts import ( + assert200, + assert404, + assert409, +) +from simplecasts.tests.factories import ( + PodcastFactory, + SubscriptionFactory, +) + +_subscriptions_url = reverse_lazy("subscriptions:index") + + +class TestSubscriptions: + @pytest.mark.django_db + def test_authenticated_no_subscriptions(self, client, auth_user): + response = client.get(_subscriptions_url) + assert200(response) + + assertTemplateUsed(response, "subscriptions/index.html") + + @pytest.mark.django_db + def test_user_is_subscribed(self, client, auth_user): + """If user subscribed any podcasts, show only own feed with these podcasts""" + + sub = SubscriptionFactory(subscriber=auth_user) + response = client.get(_subscriptions_url) + + assert200(response) + + assertTemplateUsed(response, "subscriptions/index.html") + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == sub.podcast + + @pytest.mark.django_db + def test_htmx_request(self, client, auth_user): + sub = SubscriptionFactory(subscriber=auth_user) + response = client.get( + _subscriptions_url, + headers={ + "HX-Request": "true", + "HX-Target": "pagination", + }, + ) + + assert200(response) + + assertContains(response, 'id="pagination"') + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == sub.podcast + + @pytest.mark.django_db + def test_user_is_subscribed_search(self, client, auth_user): + """If user subscribed any podcasts, show only own feed with these podcasts""" + + sub = SubscriptionFactory(subscriber=auth_user) + response = client.get(_subscriptions_url, {"search": sub.podcast.title}) + + assert200(response) + + assertTemplateUsed(response, "subscriptions/index.html") + + assert len(response.context["page"].object_list) == 1 + assert response.context["page"].object_list[0] == sub.podcast + + +class TestSubscribe: + @pytest.mark.django_db + def test_subscribe(self, client, podcast, auth_user): + response = client.post( + self.url(podcast), + headers={ + "HX-Request": "true", + }, + ) + + assert200(response) + assertContains(response, 'id="subscribe-button"') + + assert Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + @pytest.mark.django_db()(transaction=True) + def test_already_subscribed( + self, + client, + podcast, + auth_user, + ): + SubscriptionFactory(subscriber=auth_user, podcast=podcast) + response = client.post( + self.url(podcast), + headers={ + "HX-Request": "true", + "HX-Target": "subscribe-button", + }, + ) + + assert409(response) + + assert Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + @pytest.mark.django_db + def test_subscribe_private(self, client, auth_user): + podcast = PodcastFactory(private=True) + + response = client.post( + self.url(podcast), + headers={ + "HX-Request": "true", + "HX-Target": "subscribe-button", + }, + ) + + assert404(response) + + assert not Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + def url(self, podcast): + return reverse("subscriptions:subscribe", args=[podcast.pk]) + + +class TestUnsubscribe: + @pytest.mark.django_db + def test_unsubscribe(self, client, auth_user, podcast): + SubscriptionFactory(subscriber=auth_user, podcast=podcast) + response = client.delete( + self.url(podcast), + headers={ + "HX-Request": "true", + "HX-Target": "subscribe-button", + }, + ) + + assert200(response) + assertContains(response, 'id="subscribe-button"') + + assert not Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + @pytest.mark.django_db + def test_unsubscribe_private(self, client, auth_user): + podcast = SubscriptionFactory( + subscriber=auth_user, podcast=PodcastFactory(private=True) + ).podcast + + response = client.delete( + self.url(podcast), + headers={ + "HX-Request": "true", + "HX-Target": "subscribe-button", + }, + ) + + assert404(response) + + assert Subscription.objects.filter( + podcast=podcast, subscriber=auth_user + ).exists() + + def url(self, podcast): + return reverse("subscriptions:unsubscribe", args=[podcast.pk]) diff --git a/simplecasts/users/tests/test_views.py b/simplecasts/tests/views/test_users.py similarity index 94% rename from simplecasts/users/tests/test_views.py rename to simplecasts/tests/views/test_users.py index a8ca6d6b68..04d18d20c7 100644 --- a/simplecasts/users/tests/test_views.py +++ b/simplecasts/tests/views/test_users.py @@ -6,14 +6,19 @@ from django.urls import reverse, reverse_lazy from pytest_django.asserts import assertTemplateUsed -from simplecasts.episodes.middleware import PlayerDetails -from simplecasts.episodes.tests.factories import AudioLogFactory, BookmarkFactory -from simplecasts.podcasts.models import Subscription -from simplecasts.podcasts.tests.factories import PodcastFactory, SubscriptionFactory -from simplecasts.tests.asserts import assert200 -from simplecasts.users.models import User -from simplecasts.users.notifications import get_unsubscribe_signer -from simplecasts.users.tests.factories import EmailAddressFactory +from simplecasts.middleware import PlayerDetails +from simplecasts.models import Subscription, User +from simplecasts.services.notifications import get_unsubscribe_signer +from simplecasts.tests.asserts import ( + assert200, +) +from simplecasts.tests.factories import ( + AudioLogFactory, + BookmarkFactory, + EmailAddressFactory, + PodcastFactory, + SubscriptionFactory, +) class MockGoogleAdapter: @@ -129,7 +134,9 @@ class TestImportPodcastFeeds: def upload_file(self): return SimpleUploadedFile( "feeds.opml", - (pathlib.Path(__file__).parent / "mocks" / "feeds.opml").read_bytes(), + ( + pathlib.Path(__file__).parent.parent / "mocks" / "feeds.opml" + ).read_bytes(), content_type="text/xml", ) @@ -262,7 +269,7 @@ def test_post_confirmed(self, client, auth_user): assert not User.objects.exists() -class TestUnsubscribe: +class TestUnsubscribeNotifications: @pytest.fixture def email_address(self): return EmailAddressFactory() diff --git a/simplecasts/tests/test_views.py b/simplecasts/tests/views/test_views.py similarity index 84% rename from simplecasts/tests/test_views.py rename to simplecasts/tests/views/test_views.py index 8e5e9eac6d..23088d17c2 100644 --- a/simplecasts/tests/test_views.py +++ b/simplecasts/tests/views/test_views.py @@ -4,8 +4,11 @@ from django.urls import reverse, reverse_lazy from pytest_django.asserts import assertTemplateUsed -from simplecasts import covers -from simplecasts.tests.asserts import assert200, assert404 +from simplecasts.services import covers +from simplecasts.tests.asserts import ( + assert200, + assert404, +) class TestErrorPages: @@ -97,18 +100,21 @@ class TestCoverImage: @pytest.mark.django_db def test_ok(self, client, mocker): - mocker.patch("simplecasts.covers.fetch_cover_image", return_value=b"ok") mocker.patch( - "simplecasts.covers.process_cover_image", return_value=mocker.MagicMock() + "simplecasts.services.covers.fetch_cover_image", return_value=b"ok" ) - mocker.patch("simplecasts.covers.save_cover_image") + mocker.patch( + "simplecasts.services.covers.process_cover_image", + return_value=mocker.MagicMock(), + ) + mocker.patch("simplecasts.services.covers.save_cover_image") response = client.get(covers.get_cover_url(self.cover_url, 96)) assert200(response) @pytest.mark.django_db def test_invalid_fetch(self, client, mocker): mocker.patch( - "simplecasts.covers.fetch_cover_image", + "simplecasts.services.covers.fetch_cover_image", return_value=b"ok", side_effect=covers.CoverError(), ) @@ -117,9 +123,12 @@ def test_invalid_fetch(self, client, mocker): @pytest.mark.django_db def test_invalid_image(self, client, mocker): - mocker.patch("simplecasts.covers.fetch_cover_image", return_value=b"ok") mocker.patch( - "simplecasts.covers.save_cover_image", side_effect=covers.CoverSaveError() + "simplecasts.services.covers.fetch_cover_image", return_value=b"ok" + ) + mocker.patch( + "simplecasts.services.covers.save_cover_image", + side_effect=covers.CoverSaveError(), ) response = client.get(covers.get_cover_url(self.cover_url, 96)) assert200(response) diff --git a/simplecasts/urls/__init__.py b/simplecasts/urls/__init__.py new file mode 100644 index 0000000000..4a95e9f30e --- /dev/null +++ b/simplecasts/urls/__init__.py @@ -0,0 +1,29 @@ +from django.urls import include, path + +from simplecasts import views + +urlpatterns = [ + path("", views.index, name="index"), + path("about/", views.about, name="about"), + path("privacy/", views.privacy, name="privacy"), + path("accept-cookies/", views.accept_cookies, name="accept_cookies"), + path( + "covers//.webp", + views.cover_image, + name="cover_image", + ), + path("robots.txt", views.robots, name="robots"), + path("manifest.json", views.manifest, name="manifest"), + path(".well-known/assetlinks.json", views.assetlinks, name="assetlinks"), + path(".well-known/security.txt", views.security, name="security"), + path("", include("simplecasts.urls.episodes")), + path("", include("simplecasts.urls.podcasts")), + path("account/", include("simplecasts.urls.users")), + path("bookmarks/", include("simplecasts.urls.bookmarks")), + path("categories/", include("simplecasts.urls.categories")), + path("history/", include("simplecasts.urls.history")), + path("player/", include("simplecasts.urls.player")), + path("private-feeds/", include("simplecasts.urls.private_feeds")), + path("search/", include("simplecasts.urls.search")), + path("subscriptions/", include("simplecasts.urls.subscriptions")), +] diff --git a/simplecasts/urls/bookmarks.py b/simplecasts/urls/bookmarks.py new file mode 100644 index 0000000000..94e7a2c625 --- /dev/null +++ b/simplecasts/urls/bookmarks.py @@ -0,0 +1,19 @@ +from django.urls import path + +from simplecasts.views import bookmarks + +app_name = "bookmarks" + +urlpatterns = [ + path("", bookmarks.index, name="index"), + path( + "/add/", + bookmarks.add_bookmark, + name="add", + ), + path( + "/remove/", + bookmarks.remove_bookmark, + name="remove", + ), +] diff --git a/simplecasts/urls/categories.py b/simplecasts/urls/categories.py new file mode 100644 index 0000000000..ab70e85872 --- /dev/null +++ b/simplecasts/urls/categories.py @@ -0,0 +1,10 @@ +from django.urls import path + +from simplecasts.views import categories + +app_name = "categories" + +urlpatterns = [ + path("", categories.index, name="index"), + path("/", categories.detail, name="detail"), +] diff --git a/simplecasts/urls/episodes.py b/simplecasts/urls/episodes.py new file mode 100644 index 0000000000..e103e540d1 --- /dev/null +++ b/simplecasts/urls/episodes.py @@ -0,0 +1,15 @@ +from django.urls import path + +from simplecasts.views import episodes + +app_name = "episodes" + + +urlpatterns = [ + path("new/", episodes.index, name="index"), + path( + "episodes/-/", + episodes.detail, + name="detail", + ), +] diff --git a/simplecasts/urls/history.py b/simplecasts/urls/history.py new file mode 100644 index 0000000000..d4654ced3a --- /dev/null +++ b/simplecasts/urls/history.py @@ -0,0 +1,19 @@ +from django.urls import path + +from simplecasts.views import history + +app_name = "history" + +urlpatterns = [ + path("", history.index, name="index"), + path( + "/complete/", + history.mark_complete, + name="mark_complete", + ), + path( + "/remove/", + history.remove_audio_log, + name="remove", + ), +] diff --git a/simplecasts/urls/player.py b/simplecasts/urls/player.py new file mode 100644 index 0000000000..fc90002cfa --- /dev/null +++ b/simplecasts/urls/player.py @@ -0,0 +1,24 @@ +from django.urls import path + +from simplecasts.views import player + +app_name = "player" + + +urlpatterns = [ + path( + "start//", + player.start_player, + name="start", + ), + path( + "close/", + player.close_player, + name="close", + ), + path( + "time-update/", + player.player_time_update, + name="time_update", + ), +] diff --git a/simplecasts/urls/podcasts.py b/simplecasts/urls/podcasts.py new file mode 100644 index 0000000000..495472ed38 --- /dev/null +++ b/simplecasts/urls/podcasts.py @@ -0,0 +1,47 @@ +from django.urls import path, register_converter + +from simplecasts.views import podcasts + +app_name = "podcasts" + + +class _SignedIntConverter: + regex = r"-?\d+" # allow optional leading '-' + + def to_python(self, value: str) -> int: + return int(value) + + def to_url(self, value: int) -> str: + return str(value) + + +register_converter(_SignedIntConverter, "sint") + +urlpatterns = [ + path("discover/", podcasts.discover, name="discover"), + path( + "podcasts/-/", + podcasts.podcast_detail, + name="detail", + ), + path( + "podcasts/-/episodes/", + podcasts.episodes, + name="episodes", + ), + path( + "podcasts/-/season//", + podcasts.season, + name="season", + ), + path( + "podcasts/-/similar/", + podcasts.similar, + name="similar", + ), + path( + "podcasts//latest-episode/", + podcasts.latest_episode, + name="latest_episode", + ), +] diff --git a/simplecasts/urls/private_feeds.py b/simplecasts/urls/private_feeds.py new file mode 100644 index 0000000000..ffb6722c64 --- /dev/null +++ b/simplecasts/urls/private_feeds.py @@ -0,0 +1,23 @@ +from django.urls import path + +from simplecasts.views import private_feeds + +app_name = "private_feeds" + +urlpatterns = [ + path( + "", + private_feeds.index, + name="index", + ), + path( + "new/", + private_feeds.add_private_feed, + name="add", + ), + path( + "/remove/", + private_feeds.remove_private_feed, + name="remove", + ), +] diff --git a/simplecasts/urls/search.py b/simplecasts/urls/search.py new file mode 100644 index 0000000000..c5a49a7e12 --- /dev/null +++ b/simplecasts/urls/search.py @@ -0,0 +1,12 @@ +from django.urls import path + +from simplecasts.views import search + +app_name = "search" + +urlpatterns = [ + path("episodes/", search.search_episodes, name="episodes"), + path("podcasts/", search.search_podcasts, name="podcasts"), + path("people/", search.search_people, name="people"), + path("itunes/", search.search_itunes, name="itunes"), +] diff --git a/simplecasts/urls/subscriptions.py b/simplecasts/urls/subscriptions.py new file mode 100644 index 0000000000..4d142fd787 --- /dev/null +++ b/simplecasts/urls/subscriptions.py @@ -0,0 +1,19 @@ +from django.urls import path + +from simplecasts.views import subscriptions + +app_name = "subscriptions" + +urlpatterns = [ + path("", subscriptions.index, name="index"), + path( + "/subscribe/", + subscriptions.subscribe, + name="subscribe", + ), + path( + "/unsubscribe/", + subscriptions.unsubscribe, + name="unsubscribe", + ), +] diff --git a/simplecasts/urls/users.py b/simplecasts/urls/users.py new file mode 100644 index 0000000000..f10187667b --- /dev/null +++ b/simplecasts/urls/users.py @@ -0,0 +1,22 @@ +from django.urls import path + +from simplecasts.views import users + +app_name = "users" + +urlpatterns = [ + path("preferences/", users.user_preferences, name="preferences"), + path("stats/", users.user_stats, name="stats"), + path( + "feeds/", + users.import_podcast_feeds, + name="import_podcast_feeds", + ), + path( + "feeds/export/", + users.export_podcast_feeds, + name="export_podcast_feeds", + ), + path("delete/", users.delete_account, name="delete_account"), + path("unsubscribe/", users.unsubscribe, name="unsubscribe"), +] diff --git a/simplecasts/users/__init__.py b/simplecasts/users/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/users/admin.py b/simplecasts/users/admin.py deleted file mode 100644 index 62beaa943b..0000000000 --- a/simplecasts/users/admin.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin - -from simplecasts.users.models import User - - -@admin.register(User) -class UserAdmin(BaseUserAdmin): - """User model admin.""" - - fieldsets = ( - *tuple(BaseUserAdmin.fieldsets or ()), - ( - "User preferences", - { - "fields": ("send_email_notifications",), - }, - ), - ) diff --git a/simplecasts/users/apps.py b/simplecasts/users/apps.py deleted file mode 100644 index 94372c07ec..0000000000 --- a/simplecasts/users/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class UsersConfig(AppConfig): - name = "simplecasts.users" - default_auto_field = "django.db.models.BigAutoField" diff --git a/simplecasts/users/migrations/0001_initial.py b/simplecasts/users/migrations/0001_initial.py deleted file mode 100644 index f1f8ab04ec..0000000000 --- a/simplecasts/users/migrations/0001_initial.py +++ /dev/null @@ -1,128 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-01 18:02 - -import django.contrib.auth.validators -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - ] - - operations = [ - migrations.CreateModel( - name="User", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("password", models.CharField(max_length=128, verbose_name="password")), - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "is_superuser", - models.BooleanField( - default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", - verbose_name="superuser status", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "first_name", - models.CharField( - blank=True, max_length=150, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - blank=True, max_length=150, verbose_name="last name" - ), - ), - ( - "email", - models.EmailField( - blank=True, max_length=254, verbose_name="email address" - ), - ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", - verbose_name="active", - ), - ), - ( - "date_joined", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" - ), - ), - ("send_email_notifications", models.BooleanField(default=True)), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.group", - verbose_name="groups", - ), - ), - ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.permission", - verbose_name="user permissions", - ), - ), - ], - options={ - "verbose_name": "user", - "verbose_name_plural": "users", - "abstract": False, - }, - ), - ] diff --git a/simplecasts/users/migrations/0002_alter_user_managers.py b/simplecasts/users/migrations/0002_alter_user_managers.py deleted file mode 100644 index 430d1a042c..0000000000 --- a/simplecasts/users/migrations/0002_alter_user_managers.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-21 08:18 - -import django.contrib.auth.models -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("users", "0001_initial"), - ] - - operations = [ - migrations.AlterModelManagers( - name="user", - managers=[ - ("objects", django.contrib.auth.models.UserManager()), - ], - ), - ] diff --git a/simplecasts/users/migrations/__init__.py b/simplecasts/users/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/users/migrations/max_migration.txt b/simplecasts/users/migrations/max_migration.txt deleted file mode 100644 index 480f655ce0..0000000000 --- a/simplecasts/users/migrations/max_migration.txt +++ /dev/null @@ -1 +0,0 @@ -0002_alter_user_managers diff --git a/simplecasts/users/tests/__init__.py b/simplecasts/users/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/simplecasts/users/tests/factories.py b/simplecasts/users/tests/factories.py deleted file mode 100644 index d020df646b..0000000000 --- a/simplecasts/users/tests/factories.py +++ /dev/null @@ -1,22 +0,0 @@ -import factory -from allauth.account.models import EmailAddress - -from simplecasts.users.models import User - - -class UserFactory(factory.django.DjangoModelFactory): - username = factory.Sequence(lambda n: f"user-{n}") - email = factory.Sequence(lambda n: f"user-{n}@example.com") - password = factory.django.Password("testpass") - - class Meta: - model = User - - -class EmailAddressFactory(factory.django.DjangoModelFactory): - user = factory.SubFactory(UserFactory) - email = factory.LazyAttribute(lambda a: a.user.email) - verified = True - - class Meta: - model = EmailAddress diff --git a/simplecasts/users/tests/fixtures.py b/simplecasts/users/tests/fixtures.py deleted file mode 100644 index 6ad7b0af1d..0000000000 --- a/simplecasts/users/tests/fixtures.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest -from django.contrib.auth.models import AnonymousUser -from django.test import Client - -from simplecasts.users.models import User -from simplecasts.users.tests.factories import UserFactory - - -@pytest.fixture -def user() -> User: - return UserFactory() - - -@pytest.fixture -def anonymous_user() -> AnonymousUser: - return AnonymousUser() - - -@pytest.fixture -def auth_user(client: Client, user: User) -> User: - client.force_login(user) - return user - - -@pytest.fixture -def staff_user(client: Client) -> User: - user = UserFactory(is_staff=True) - client.force_login(user) - return user diff --git a/simplecasts/users/urls.py b/simplecasts/users/urls.py deleted file mode 100644 index 2edbcfdde5..0000000000 --- a/simplecasts/users/urls.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.urls import path - -from simplecasts.users import views - -app_name = "users" - -urlpatterns = [ - path("account/preferences/", views.user_preferences, name="preferences"), - path("account/stats/", views.user_stats, name="stats"), - path( - "account/feeds/", - views.import_podcast_feeds, - name="import_podcast_feeds", - ), - path( - "account/feeds/export/", - views.export_podcast_feeds, - name="export_podcast_feeds", - ), - path("account/delete/", views.delete_account, name="delete_account"), - path("unsubscribe/", views.unsubscribe, name="unsubscribe"), -] diff --git a/simplecasts/views.py b/simplecasts/views/__init__.py similarity index 94% rename from simplecasts/views.py rename to simplecasts/views/__init__.py index 399adcfddc..b3adac6b4d 100644 --- a/simplecasts/views.py +++ b/simplecasts/views/__init__.py @@ -15,10 +15,10 @@ from django.views.decorators.cache import cache_control, cache_page from django.views.decorators.http import require_POST, require_safe -from simplecasts import covers, pwa -from simplecasts.http_client import get_client -from simplecasts.request import HttpRequest -from simplecasts.response import RenderOrRedirectResponse, TextResponse +from simplecasts.http.request import HttpRequest +from simplecasts.http.response import RenderOrRedirectResponse, TextResponse +from simplecasts.services import covers, pwa +from simplecasts.services.http_client import get_client _CACHE_TIMEOUT: Final = 60 * 60 * 24 * 365 diff --git a/simplecasts/views/bookmarks.py b/simplecasts/views/bookmarks.py new file mode 100644 index 0000000000..df11308a43 --- /dev/null +++ b/simplecasts/views/bookmarks.py @@ -0,0 +1,90 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db import IntegrityError +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.views.decorators.http import require_POST, require_safe + +from simplecasts.http.decorators import require_DELETE +from simplecasts.http.request import AuthenticatedHttpRequest +from simplecasts.http.response import HttpResponseConflict +from simplecasts.models import Episode +from simplecasts.services.search import search_queryset +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def index(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Renders user's bookmarks. User can also search their bookmarks.""" + bookmarks = request.user.bookmarks.select_related("episode", "episode__podcast") + + ordering = request.GET.get("order", "desc") + order_by = "created" if ordering == "asc" else "-created" + + if request.search: + bookmarks = search_queryset( + bookmarks, + request.search.value, + "episode__search_vector", + "episode__podcast__search_vector", + ).order_by("-rank", order_by) + else: + bookmarks = bookmarks.order_by(order_by) + + return render_paginated_response( + request, + "bookmarks/index.html", + bookmarks, + { + "ordering": ordering, + }, + ) + + +@require_POST +@login_required +def add_bookmark( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse | HttpResponseConflict: + """Add episode to bookmarks.""" + episode = get_object_or_404(Episode, pk=episode_id) + + try: + request.user.bookmarks.create(episode=episode) + except IntegrityError: + return HttpResponseConflict() + + messages.success(request, "Added to Bookmarks") + + return _render_bookmark_action(request, episode, is_bookmarked=True) + + +@require_DELETE +@login_required +def remove_bookmark( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse: + """Remove episode from bookmarks.""" + episode = get_object_or_404(Episode, pk=episode_id) + request.user.bookmarks.filter(episode=episode).delete() + + messages.info(request, "Removed from Bookmarks") + + return _render_bookmark_action(request, episode, is_bookmarked=False) + + +def _render_bookmark_action( + request: AuthenticatedHttpRequest, + episode: Episode, + *, + is_bookmarked: bool, +) -> TemplateResponse: + return TemplateResponse( + request, + "episodes/detail.html#bookmark_button", + { + "episode": episode, + "is_bookmarked": is_bookmarked, + }, + ) diff --git a/simplecasts/views/categories.py b/simplecasts/views/categories.py new file mode 100644 index 0000000000..029c4548c0 --- /dev/null +++ b/simplecasts/views/categories.py @@ -0,0 +1,65 @@ +from django.contrib.auth.decorators import login_required +from django.db.models import Exists, OuterRef +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.views.decorators.http import require_safe + +from simplecasts.http.request import HttpRequest +from simplecasts.models import Category, Podcast +from simplecasts.services.search import search_queryset +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def index(request: HttpRequest) -> TemplateResponse: + """List all categories containing podcasts.""" + categories = ( + Category.objects.alias( + has_podcasts=Exists( + Podcast.objects.published() + .filter(private=False) + .filter(categories=OuterRef("pk")) + ) + ) + .filter(has_podcasts=True) + .order_by("name") + ) + + return TemplateResponse( + request, + "categories/index.html", + { + "categories": categories, + }, + ) + + +@require_safe +@login_required +def detail(request: HttpRequest, slug: str) -> TemplateResponse: + """Render individual podcast category along with its podcasts. + + Podcasts can also be searched. + """ + category = get_object_or_404(Category, slug=slug) + + podcasts = category.podcasts.published().filter(private=False).distinct() + + if request.search: + podcasts = search_queryset( + podcasts, + request.search.value, + "search_vector", + ).order_by("-rank", "-pub_date") + else: + podcasts = podcasts.order_by("-pub_date") + + return render_paginated_response( + request, + "categories/category_detail.html", + podcasts, + { + "category": category, + }, + ) diff --git a/simplecasts/views/episodes.py b/simplecasts/views/episodes.py new file mode 100644 index 0000000000..797421ef3c --- /dev/null +++ b/simplecasts/views/episodes.py @@ -0,0 +1,73 @@ +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.db.models import OuterRef, Subquery +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.views.decorators.http import require_safe + +from simplecasts.http.request import AuthenticatedHttpRequest +from simplecasts.models import Episode, Podcast + + +@require_safe +@login_required +def index(request: AuthenticatedHttpRequest) -> TemplateResponse: + """List latest episodes from subscriptions.""" + + latest_episodes = ( + Podcast.objects.subscribed(request.user) + .annotate( + latest_episode=Subquery( + Episode.objects.filter(podcast_id=OuterRef("pk")) + .order_by("-pub_date", "-pk") + .values("pk")[:1] + ) + ) + .filter(latest_episode__isnull=False) + .order_by("-pub_date") + .values_list("latest_episode", flat=True)[: settings.DEFAULT_PAGE_SIZE] + ) + + episodes = ( + Episode.objects.filter(pk__in=latest_episodes) + .select_related("podcast") + .order_by("-pub_date", "-pk") + ) + + return TemplateResponse( + request, + "episodes/index.html", + { + "episodes": episodes, + }, + ) + + +@require_safe +@login_required +def detail( + request: AuthenticatedHttpRequest, + episode_id: int, + slug: str | None = None, +) -> TemplateResponse: + """Renders episode detail.""" + episode = get_object_or_404( + Episode.objects.select_related("podcast"), + pk=episode_id, + ) + + audio_log = request.user.audio_logs.filter(episode=episode).first() + + is_bookmarked = request.user.bookmarks.filter(episode=episode).exists() + is_playing = request.player.has(episode.pk) + + return TemplateResponse( + request, + "episodes/detail.html", + { + "episode": episode, + "audio_log": audio_log, + "is_bookmarked": is_bookmarked, + "is_playing": is_playing, + }, + ) diff --git a/simplecasts/views/history.py b/simplecasts/views/history.py new file mode 100644 index 0000000000..3fad738a03 --- /dev/null +++ b/simplecasts/views/history.py @@ -0,0 +1,100 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.views.decorators.http import require_POST, require_safe + +from simplecasts.http.decorators import require_DELETE +from simplecasts.http.request import AuthenticatedHttpRequest +from simplecasts.models import AudioLog +from simplecasts.services.search import search_queryset +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def index(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Renders user's listening history. User can also search history.""" + audio_logs = request.user.audio_logs.select_related("episode", "episode__podcast") + + ordering = request.GET.get("order", "desc") + order_by = "listened" if ordering == "asc" else "-listened" + + if request.search: + audio_logs = search_queryset( + audio_logs, + request.search.value, + "episode__search_vector", + "episode__podcast__search_vector", + ).order_by("-rank", order_by) + else: + audio_logs = audio_logs.order_by(order_by) + + return render_paginated_response( + request, + "history/index.html", + audio_logs, + { + "ordering": ordering, + }, + ) + + +@require_POST +@login_required +def mark_complete( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse: + """Marks audio log complete.""" + + if request.player.has(episode_id): + raise Http404 + + audio_log = get_object_or_404( + request.user.audio_logs.select_related("episode"), + episode__pk=episode_id, + ) + + audio_log.current_time = 0 + audio_log.save() + + messages.success(request, "Episode marked complete") + + return _render_audio_log_action(request, audio_log, show_audio_log=True) + + +@require_DELETE +@login_required +def remove_audio_log( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse: + """Removes audio log from user history and returns HTMX snippet.""" + # cannot remove episode if in player + if request.player.has(episode_id): + raise Http404 + + audio_log = get_object_or_404( + request.user.audio_logs.select_related("episode"), + episode__pk=episode_id, + ) + + audio_log.delete() + + messages.info(request, "Removed from History") + + return _render_audio_log_action(request, audio_log, show_audio_log=False) + + +def _render_audio_log_action( + request: AuthenticatedHttpRequest, + audio_log: AudioLog, + *, + show_audio_log: bool, +) -> TemplateResponse: + context = {"episode": audio_log.episode} + + if show_audio_log: + context["audio_log"] = audio_log + + return TemplateResponse(request, "episodes/detail.html#audio_log", context) diff --git a/simplecasts/paginator.py b/simplecasts/views/paginator.py similarity index 97% rename from simplecasts/paginator.py rename to simplecasts/views/paginator.py index 5c2ddfa982..cc2cedd9e6 100644 --- a/simplecasts/paginator.py +++ b/simplecasts/views/paginator.py @@ -7,8 +7,8 @@ from django.template.response import TemplateResponse from django.utils.functional import cached_property -from simplecasts.partials import render_partial_response -from simplecasts.request import HttpRequest +from simplecasts.http.request import HttpRequest +from simplecasts.views.partials import render_partial_response T = TypeVar("T") T_Model = TypeVar("T_Model", bound=Model) diff --git a/simplecasts/partials.py b/simplecasts/views/partials.py similarity index 92% rename from simplecasts/partials.py rename to simplecasts/views/partials.py index b2466f3216..de333a8763 100644 --- a/simplecasts/partials.py +++ b/simplecasts/views/partials.py @@ -1,6 +1,6 @@ from django.template.response import TemplateResponse -from simplecasts.request import HttpRequest +from simplecasts.http.request import HttpRequest def render_partial_response( diff --git a/simplecasts/views/player.py b/simplecasts/views/player.py new file mode 100644 index 0000000000..47dafd40af --- /dev/null +++ b/simplecasts/views/player.py @@ -0,0 +1,135 @@ +import http +from typing import Literal, TypedDict + +from django.contrib.auth.decorators import login_required +from django.db import IntegrityError +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.utils import timezone +from django.views.decorators.http import require_POST +from pydantic import BaseModel, ValidationError + +from simplecasts.http.request import ( + AuthenticatedHttpRequest, + HttpRequest, + is_authenticated_request, +) +from simplecasts.http.response import HttpResponseNoContent +from simplecasts.models import AudioLog, Episode + +PlayerAction = Literal["load", "play", "close"] + + +class PlayerUpdate(BaseModel): + """Data model for player time update.""" + + current_time: int + duration: int + + +class PlayerUpdateError(TypedDict): + """Data model for player error response.""" + + error: str + + +@require_POST +@login_required +def start_player( + request: AuthenticatedHttpRequest, episode_id: int +) -> TemplateResponse: + """Starts player. Creates new audio log if required.""" + episode = get_object_or_404( + Episode.objects.select_related("podcast"), + pk=episode_id, + ) + + audio_log, _ = request.user.audio_logs.update_or_create( + episode=episode, + defaults={ + "listened": timezone.now(), + }, + ) + + request.player.set(episode.pk) + + return _render_player_action(request, audio_log, action="play") + + +@require_POST +@login_required +def close_player( + request: AuthenticatedHttpRequest, +) -> TemplateResponse | HttpResponseNoContent: + """Closes audio player.""" + if episode_id := request.player.pop(): + audio_log = get_object_or_404( + request.user.audio_logs.select_related("episode"), + episode__pk=episode_id, + ) + return _render_player_action(request, audio_log, action="close") + return HttpResponseNoContent() + + +@require_POST +def player_time_update(request: HttpRequest) -> JsonResponse: + """Handles player time update AJAX requests.""" + + if not is_authenticated_request(request): + return JsonResponse( + PlayerUpdateError(error="Authentication required"), + status=http.HTTPStatus.UNAUTHORIZED, + ) + + episode_id = request.player.get() + + if episode_id is None: + return JsonResponse( + PlayerUpdateError(error="No episode in player"), + status=http.HTTPStatus.BAD_REQUEST, + ) + + try: + update = PlayerUpdate.model_validate_json(request.body) + except ValidationError as exc: + return JsonResponse( + PlayerUpdateError(error=exc.json()), + status=http.HTTPStatus.BAD_REQUEST, + ) + + try: + request.user.audio_logs.update_or_create( + episode_id=episode_id, + defaults={ + "listened": timezone.now(), + "current_time": update.current_time, + "duration": update.duration, + }, + ) + + except IntegrityError: + return JsonResponse( + PlayerUpdateError(error="Update cannot be saved"), + status=http.HTTPStatus.CONFLICT, + ) + + return JsonResponse(update.model_dump()) + + +def _render_player_action( + request: HttpRequest, + audio_log: AudioLog, + *, + action: PlayerAction, +) -> TemplateResponse: + return TemplateResponse( + request, + "episodes/detail.html#audio_player_button", + { + "action": action, + "audio_log": audio_log, + "episode": audio_log.episode, + "is_playing": action == "play", + }, + ) diff --git a/simplecasts/views/podcasts.py b/simplecasts/views/podcasts.py new file mode 100644 index 0000000000..909c0c7127 --- /dev/null +++ b/simplecasts/views/podcasts.py @@ -0,0 +1,155 @@ +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.views.decorators.http import require_safe + +from simplecasts.http.request import AuthenticatedHttpRequest, HttpRequest +from simplecasts.http.response import RenderOrRedirectResponse +from simplecasts.models import Episode, Podcast +from simplecasts.services.search import search_queryset +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def discover(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Shows all promoted podcasts.""" + podcasts = ( + Podcast.objects.published() + .filter( + promoted=True, + language=settings.DISCOVER_FEED_LANGUAGE, + private=False, + ) + .order_by("-pub_date")[: settings.DEFAULT_PAGE_SIZE] + ) + + return TemplateResponse(request, "podcasts/discover.html", {"podcasts": podcasts}) + + +@require_safe +@login_required +def podcast_detail( + request: AuthenticatedHttpRequest, + podcast_id: int, + slug: str, +) -> RenderOrRedirectResponse: + """Details for a single podcast.""" + + podcast = get_object_or_404( + Podcast.objects.published().select_related("canonical"), + pk=podcast_id, + ) + + is_subscribed = request.user.subscriptions.filter(podcast=podcast).exists() + + return TemplateResponse( + request, + "podcasts/detail.html", + { + "podcast": podcast, + "is_subscribed": is_subscribed, + }, + ) + + +@require_safe +@login_required +def latest_episode(_, podcast_id: int) -> HttpResponseRedirect: + """Redirects to latest episode.""" + if ( + episode := Episode.objects.filter(podcast__pk=podcast_id) + .order_by("-pub_date", "-id") + .first() + ): + return redirect(episode) + raise Http404 + + +@require_safe +@login_required +def episodes( + request: HttpRequest, + podcast_id: int, + slug: str | None = None, +) -> TemplateResponse: + """Render episodes for a single podcast.""" + podcast = get_object_or_404(Podcast.objects.published(), pk=podcast_id) + podcast_episodes = podcast.episodes.select_related("podcast") + + default_ordering = "asc" if podcast.is_serial() else "desc" + ordering = request.GET.get("order", default_ordering) + order_by = ("pub_date", "id") if ordering == "asc" else ("-pub_date", "-id") + + if request.search: + podcast_episodes = search_queryset( + podcast_episodes, + request.search.value, + "search_vector", + ).order_by("-rank", *order_by) + else: + podcast_episodes = podcast_episodes.order_by(*order_by) + + return render_paginated_response( + request, + "podcasts/episodes.html", + podcast_episodes, + { + "podcast": podcast, + "ordering": ordering, + }, + ) + + +@require_safe +@login_required +def season( + request: HttpRequest, + podcast_id: int, + season: int, + slug: str | None = None, +) -> TemplateResponse: + """Render episodes for a podcast season.""" + podcast = get_object_or_404(Podcast.objects.published(), pk=podcast_id) + + podcast_episodes = podcast.episodes.filter(season=season).select_related("podcast") + + order_by = ("pub_date", "id") if podcast.is_serial() else ("-pub_date", "-id") + podcast_episodes = podcast_episodes.order_by(*order_by) + + return render_paginated_response( + request, + "podcasts/season.html", + podcast_episodes, + { + "podcast": podcast, + "season": podcast.get_season(season), + }, + ) + + +@require_safe +@login_required +def similar( + request: HttpRequest, + podcast_id: int, + slug: str | None = None, +) -> TemplateResponse: + """List similar podcasts based on recommendations.""" + + podcast = get_object_or_404(Podcast.objects.published(), pk=podcast_id) + + recommendations = podcast.recommendations.select_related("recommended").order_by( + "-score" + )[: settings.DEFAULT_PAGE_SIZE] + + return TemplateResponse( + request, + "podcasts/similar.html", + { + "podcast": podcast, + "recommendations": recommendations, + }, + ) diff --git a/simplecasts/views/private_feeds.py b/simplecasts/views/private_feeds.py new file mode 100644 index 0000000000..80ea0e3e8d --- /dev/null +++ b/simplecasts/views/private_feeds.py @@ -0,0 +1,80 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.views.decorators.http import require_safe + +from simplecasts.forms import PodcastForm +from simplecasts.http.decorators import require_DELETE, require_form_methods +from simplecasts.http.request import AuthenticatedHttpRequest +from simplecasts.http.response import RenderOrRedirectResponse +from simplecasts.models import Podcast +from simplecasts.services.search import search_queryset +from simplecasts.views.paginator import render_paginated_response +from simplecasts.views.partials import render_partial_response + + +@require_safe +@login_required +def index(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Lists user's private feeds.""" + podcasts = Podcast.objects.published().filter(private=True).subscribed(request.user) + + if request.search: + podcasts = search_queryset( + podcasts, + request.search.value, + "search_vector", + ).order_by("-rank", "-pub_date") + else: + podcasts = podcasts.order_by("-pub_date") + + return render_paginated_response(request, "private_feeds/index.html", podcasts) + + +@require_form_methods +@login_required +def add_private_feed(request: AuthenticatedHttpRequest) -> RenderOrRedirectResponse: + """Add new private feed to collection.""" + if request.method == "POST": + form = PodcastForm(request.POST) + if form.is_valid(): + podcast = form.save(commit=False) + podcast.private = True + podcast.save() + + request.user.subscriptions.create(podcast=podcast) + + messages.success( + request, + "Podcast added to your Private Feeds and will appear here soon", + ) + return redirect("private_feeds:index") + else: + form = PodcastForm() + + return render_partial_response( + request, + "private_feeds/private_feed_form.html", + {"form": form}, + target="private-feed-form", + partial="form", + ) + + +@require_DELETE +@login_required +def remove_private_feed( + request: AuthenticatedHttpRequest, + podcast_id: int, +) -> HttpResponseRedirect: + """Delete private feed.""" + + get_object_or_404( + Podcast.objects.published().filter(private=True).subscribed(request.user), + pk=podcast_id, + ).delete() + + messages.info(request, "Removed from Private Feeds") + return redirect("private_feeds:index") diff --git a/simplecasts/views/search.py b/simplecasts/views/search.py new file mode 100644 index 0000000000..92f2dca6f2 --- /dev/null +++ b/simplecasts/views/search.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.views.decorators.http import require_safe + +from simplecasts.http.request import HttpRequest +from simplecasts.http.response import RenderOrRedirectResponse +from simplecasts.models import Episode, Podcast +from simplecasts.services import itunes +from simplecasts.services.http_client import get_client +from simplecasts.services.search import search_queryset +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def search_episodes(request: HttpRequest) -> RenderOrRedirectResponse: + """Search any episodes in the database.""" + + if request.search: + results = ( + search_queryset( + Episode.objects.filter(podcast__private=False), + request.search.value, + "search_vector", + ) + .select_related("podcast") + .order_by("-rank", "-pub_date") + ) + + return render_paginated_response( + request, "search/search_episodes.html", results + ) + + return redirect("episodes:index") + + +@require_safe +@login_required +def search_podcasts(request: HttpRequest) -> RenderOrRedirectResponse: + """Search all public podcasts in database. Redirects to discover page if search is empty.""" + + if request.search: + results = search_queryset( + Podcast.objects.published().filter(private=False), + request.search.value, + "search_vector", + ).order_by("-rank", "-pub_date") + + return render_paginated_response( + request, "search/search_podcasts.html", results + ) + + return redirect("podcasts:discover") + + +@require_safe +@login_required +def search_itunes(request: HttpRequest) -> RenderOrRedirectResponse: + """Render iTunes search page. Redirects to discover page if search is empty.""" + + if request.search: + try: + with get_client() as client: + feeds, is_new = itunes.search_cached( + client, + request.search.value, + limit=settings.DEFAULT_PAGE_SIZE, + ) + if is_new: + itunes.save_feeds_to_db(feeds) + return TemplateResponse( + request, + "search/search_itunes.html", + { + "feeds": feeds, + }, + ) + except itunes.ItunesError as exc: + messages.error(request, f"Failed to search iTunes: {exc}") + + return redirect("podcasts:discover") + + +@require_safe +@login_required +def search_people(request: HttpRequest) -> RenderOrRedirectResponse: + """Search all podcasts by owner(s). Redirects to discover page if no owner is given.""" + + if request.search: + results = search_queryset( + Podcast.objects.published().filter(private=False), + request.search.value, + "owner_search_vector", + ).order_by("-rank", "-pub_date") + return render_paginated_response( + request, + "search/search_people.html", + results, + ) + + return redirect("podcasts:discover") diff --git a/simplecasts/views/subscriptions.py b/simplecasts/views/subscriptions.py new file mode 100644 index 0000000000..c237c7908c --- /dev/null +++ b/simplecasts/views/subscriptions.py @@ -0,0 +1,79 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db import IntegrityError +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.views.decorators.http import require_POST, require_safe + +from simplecasts.http.decorators import require_DELETE +from simplecasts.http.request import AuthenticatedHttpRequest, HttpRequest +from simplecasts.http.response import HttpResponseConflict +from simplecasts.models import Podcast +from simplecasts.services.search import search_queryset +from simplecasts.views.paginator import render_paginated_response + + +@require_safe +@login_required +def index(request: AuthenticatedHttpRequest) -> TemplateResponse: + """Render podcast index page.""" + podcasts = Podcast.objects.published().subscribed(request.user).distinct() + + if request.search: + podcasts = search_queryset( + podcasts, + request.search.value, + "search_vector", + ).order_by("-rank", "-pub_date") + else: + podcasts = podcasts.order_by("-pub_date") + + return render_paginated_response(request, "subscriptions/index.html", podcasts) + + +@require_POST +@login_required +def subscribe( + request: AuthenticatedHttpRequest, podcast_id: int +) -> TemplateResponse | HttpResponseConflict: + """Subscribe a user to a podcast. Podcast must be active and public.""" + podcast = get_object_or_404( + Podcast.objects.published().filter(private=False), pk=podcast_id + ) + + try: + request.user.subscriptions.create(podcast=podcast) + except IntegrityError: + return HttpResponseConflict() + + messages.success(request, "Subscribed to Podcast") + + return _render_subscribe_action(request, podcast, is_subscribed=True) + + +@require_DELETE +@login_required +def unsubscribe(request: AuthenticatedHttpRequest, podcast_id: int) -> TemplateResponse: + """Unsubscribe user from a podcast.""" + podcast = get_object_or_404( + Podcast.objects.published().filter(private=False), pk=podcast_id + ) + request.user.subscriptions.filter(podcast=podcast).delete() + messages.info(request, "Unsubscribed from Podcast") + return _render_subscribe_action(request, podcast, is_subscribed=False) + + +def _render_subscribe_action( + request: HttpRequest, + podcast: Podcast, + *, + is_subscribed: bool, +) -> TemplateResponse: + return TemplateResponse( + request, + "podcasts/detail.html#subscribe_button", + { + "podcast": podcast, + "is_subscribed": is_subscribed, + }, + ) diff --git a/simplecasts/users/views.py b/simplecasts/views/users.py similarity index 90% rename from simplecasts/users/views.py rename to simplecasts/views/users.py index c2fbd9908a..86356c3325 100644 --- a/simplecasts/users/views.py +++ b/simplecasts/views/users.py @@ -13,22 +13,22 @@ from django.utils import timezone from django.views.decorators.http import require_safe -from simplecasts.http import require_form_methods -from simplecasts.partials import render_partial_response -from simplecasts.podcasts.models import Podcast, Subscription -from simplecasts.podcasts.parsers.opml_parser import parse_opml -from simplecasts.request import ( - AuthenticatedHttpRequest, - HttpRequest, - is_authenticated_request, -) -from simplecasts.response import RenderOrRedirectResponse -from simplecasts.users.forms import ( +from simplecasts.forms import ( AccountDeletionConfirmationForm, OpmlUploadForm, UserPreferencesForm, ) -from simplecasts.users.notifications import get_unsubscribe_signer +from simplecasts.http.decorators import require_form_methods +from simplecasts.http.request import ( + AuthenticatedHttpRequest, + HttpRequest, + is_authenticated_request, +) +from simplecasts.http.response import RenderOrRedirectResponse +from simplecasts.models import Podcast, Subscription +from simplecasts.services.notifications import get_unsubscribe_signer +from simplecasts.services.opml_parser import parse_opml +from simplecasts.views.partials import render_partial_response class UserStat(TypedDict): @@ -141,25 +141,25 @@ def user_stats(request: AuthenticatedHttpRequest) -> TemplateResponse: label="Subscribed", value=subscriptions.count(), unit="podcast", - url=reverse("podcasts:subscriptions"), + url=reverse("subscriptions:index"), ), UserStat( label="Private Feeds", value=subscriptions.filter(podcast__private=True).count(), unit="podcast", - url=reverse("podcasts:private_feeds"), + url=reverse("private_feeds:index"), ), UserStat( label="Bookmarks", value=request.user.bookmarks.count(), unit="episode", - url=reverse("episodes:bookmarks"), + url=reverse("bookmarks:index"), ), UserStat( label="Listened", value=request.user.audio_logs.count(), unit="episode", - url=reverse("episodes:history"), + url=reverse("history:index"), ), ] return TemplateResponse(request, "account/stats.html", {"stats": stats}) diff --git a/templates/account/podcast_feeds.html b/templates/account/podcast_feeds.html index ab5bf43f32..efd1bda2db 100644 --- a/templates/account/podcast_feeds.html +++ b/templates/account/podcast_feeds.html @@ -19,7 +19,7 @@

Export feeds

- Note: private feeds will not be included. You can add a private feed here. + Note: private feeds will not be included. You can add a private feed here.


diff --git a/templates/audio_player.html b/templates/audio_player.html index ae68937e54..beed9c8787 100644 --- a/templates/audio_player.html +++ b/templates/audio_player.html @@ -1,5 +1,5 @@ {% load heroicons static %} -{% load get_media_metadata from episodes %} +{% load get_media_metadata from audio_player %} {% if request.user.is_authenticated %}
{% if audio_log and action != "close" %} @@ -15,7 +15,7 @@ metadataTag='{{ metadata_tag }}', currentTime={{ audio_log.current_time|default:0 }}, startPlayer={% if action == "play" %}true{% else %}false{% endif %}, - timeUpdateUrl='{% url "episodes:player_time_update" %}', + timeUpdateUrl='{% url "player:time_update" %}', )"{# fmt:on #} @keydown.window="shortcuts">

- Note: private feeds will not be included. You can add a private feed here. + Note: private feeds will not be included. You can add a private feed here.


diff --git a/templates/podcasts/detail.html b/templates/podcasts/detail.html index e4a3aef7f8..10e5356426 100644 --- a/templates/podcasts/detail.html +++ b/templates/podcasts/detail.html @@ -24,7 +24,7 @@