From 9d2677521368c474e53de1b5a4ecaa50355ff446 Mon Sep 17 00:00:00 2001 From: thespbgamer Date: Wed, 22 Apr 2026 18:49:44 +0100 Subject: [PATCH 1/3] Add logic to split in progress and upcoming --- src/app/models.py | 6 +- src/app/tests/views/test_home.py | 77 ++++++++ src/app/views.py | 187 +++++++++++++++--- src/templates/app/components/home_grid.html | 21 +- .../app/components/home_section.html | 40 ++-- src/templates/users/preferences.html | 23 +++ .../0051_user_home_separate_incoming.py | 18 ++ src/users/models.py | 4 + src/users/tests/views/test_sidebar.py | 2 + src/users/views.py | 1 + 10 files changed, 328 insertions(+), 51 deletions(-) create mode 100644 src/users/migrations/0051_user_home_separate_incoming.py diff --git a/src/app/models.py b/src/app/models.py index 3189319d3..c02bfca38 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -414,7 +414,7 @@ def get_home_status( user, status, sort_by, - items_limit, + items_limit=None, specific_media_type=None, ): """Get a home media list for a specific status grouped by media type.""" @@ -442,7 +442,9 @@ def get_home_status( # Apply pagination total_count = len(sorted_list) - if specific_media_type: + if items_limit is None: + paginated_list = sorted_list + elif specific_media_type: paginated_list = sorted_list[items_limit:] else: paginated_list = sorted_list[:items_limit] diff --git a/src/app/tests/views/test_home.py b/src/app/tests/views/test_home.py index e8b75c209..f9e834524 100644 --- a/src/app/tests/views/test_home.py +++ b/src/app/tests/views/test_home.py @@ -15,6 +15,7 @@ Sources, Status, ) +from events.models import Event from users.models import HomeSortChoices @@ -142,6 +143,16 @@ def mock_get_media_metadata( status=Status.IN_PROGRESS.value, progress=10, ) + Event.objects.create( + item=anime_item, + content_number=16, + datetime=timezone.now() - timezone.timedelta(days=1), + ) + Event.objects.create( + item=anime_item, + content_number=17, + datetime=timezone.now() + timezone.timedelta(days=1), + ) movie_item = Item.objects.create( media_id="10", @@ -155,6 +166,11 @@ def mock_get_media_metadata( user=self.user, status=Status.PLANNING.value, ) + Event.objects.create( + item=season_item, + content_number=6, + datetime=timezone.now() + timezone.timedelta(days=1), + ) def test_home_view(self): """Test the home view displays in-progress and planning media.""" @@ -201,6 +217,67 @@ def test_home_view_with_sort(self): self.user.refresh_from_db() self.assertEqual(self.user.home_sort, "completion") + def test_home_view_separates_incoming_when_enabled(self): + """Test home view splits incoming media from in-progress.""" + self.user.home_separate_incoming = True + self.user.save(update_fields=["home_separate_incoming"]) + + response = self.client.get(reverse("home")) + + self.assertEqual(response.status_code, 200) + + sections_by_key = { + section["key"]: section for section in response.context["home_sections"] + } + self.assertIn("incoming", sections_by_key) + self.assertIn(Status.IN_PROGRESS.value, sections_by_key) + + incoming_section = sections_by_key["incoming"] + in_progress_section = sections_by_key[Status.IN_PROGRESS.value] + + self.assertEqual(incoming_section["count"], 2) + self.assertEqual(in_progress_section["count"], 1) + self.assertIn(MediaTypes.SEASON.value, incoming_section["media_types"]) + self.assertIn(MediaTypes.ANIME.value, incoming_section["media_types"]) + self.assertIn(MediaTypes.ANIME.value, in_progress_section["media_types"]) + + def test_home_view_htmx_load_more_for_incoming(self): + """Test HTMX load more for incoming section.""" + self.user.home_separate_incoming = True + self.user.save(update_fields=["home_separate_incoming"]) + + for i in range(6, 20): + anime_item = Item.objects.create( + media_id=f"incoming-{i}", + source=Sources.MAL.value, + media_type=MediaTypes.ANIME.value, + title=f"Incoming Anime {i}", + image="http://example.com/image.jpg", + ) + Anime.objects.create( + item=anime_item, + user=self.user, + status=Status.IN_PROGRESS.value, + progress=1, + ) + Event.objects.create( + item=anime_item, + content_number=2, + datetime=timezone.now() + timezone.timedelta(days=1), + ) + + response = self.client.get( + reverse("home") + + f"?load_status=incoming&load_media_type={MediaTypes.ANIME.value}", + headers={"hx-request": "true"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "app/components/home_grid.html") + self.assertEqual(response.context["home_status"], "incoming") + self.assertEqual(len(response.context["media_list"]["items"]), 1) + self.assertEqual(response.context["media_list"]["total"], 15) + @patch("app.providers.services.get_media_metadata") def test_home_view_htmx_load_more(self, mock_get_media_metadata): """Test the HTMX load more functionality.""" diff --git a/src/app/views.py b/src/app/views.py index 43f35298d..7151c8c76 100644 --- a/src/app/views.py +++ b/src/app/views.py @@ -36,49 +36,180 @@ logger = logging.getLogger(__name__) +HOME_SECTION_INCOMING = "incoming" + + +def _build_home_section(key, media_types): + """Build home section payload.""" + return { + "key": key, + "id": slugify(key), + "media_types": media_types, + "count": sum(media_list["total"] for media_list in media_types.values()), + } + + +def _filter_home_media_types(media_types, predicate): + """Filter home media entries by predicate.""" + filtered_media_types = {} + for media_type, media_list in media_types.items(): + filtered_items = [media for media in media_list["items"] if predicate(media)] + if filtered_items: + filtered_media_types[media_type] = { + "items": filtered_items, + "total": len(filtered_items), + } + return filtered_media_types + + +def _paginate_home_media_types(media_types, items_limit, page_start=0): + """Paginate already-grouped home media entries.""" + paginated_media_types = {} + for media_type, media_list in media_types.items(): + items = media_list["items"][page_start : page_start + items_limit] + + if items: + paginated_media_types[media_type] = { + "items": items, + "total": media_list["total"], + } + return paginated_media_types + + +def _is_incoming_media(media): + """Return True when media has a real upcoming release.""" + return bool(media.next_event and not media.next_event.is_max_datetime) + + +def _is_active_in_progress_media(media): + """Return True when media still has released backlog.""" + if not _is_incoming_media(media): + return True + + return media.max_progress is not None and media.progress < media.max_progress + + +def _get_split_in_progress_media_types(request, sort_by, items_limit, page_start=0): + """Return incoming and in-progress media types split by upcoming event.""" + all_in_progress_media_types = BasicMedia.objects.get_home_status( + user=request.user, + status=Status.IN_PROGRESS.value, + sort_by=sort_by, + items_limit=None, + ) + incoming_media_types = _filter_home_media_types( + all_in_progress_media_types, + _is_incoming_media, + ) + active_media_types = _filter_home_media_types( + all_in_progress_media_types, + _is_active_in_progress_media, + ) + + return { + HOME_SECTION_INCOMING: _paginate_home_media_types( + incoming_media_types, + items_limit, + page_start=page_start, + ), + Status.IN_PROGRESS.value: _paginate_home_media_types( + active_media_types, + items_limit, + page_start=page_start, + ), + } + + +def _uses_split_home_sections(user, section_key): + """Return True when section uses split in-progress home data.""" + return user.home_separate_incoming and section_key in ( + HOME_SECTION_INCOMING, + Status.IN_PROGRESS.value, + ) + + +def _get_home_section_media_types(request, sort_by, section_key, items_limit): + """Return media types for home section, including virtual incoming section.""" + if _uses_split_home_sections(request.user, section_key): + return _get_split_in_progress_media_types(request, sort_by, items_limit)[ + section_key + ] + + return BasicMedia.objects.get_home_status( + user=request.user, + status=section_key, + sort_by=sort_by, + items_limit=items_limit, + ) + + +def _get_home_load_more_media_types( + request, + sort_by, + section_key, + items_limit, + media_type_to_load, +): + """Return load-more payload for a specific home section/media type.""" + if _uses_split_home_sections(request.user, section_key): + return _get_split_in_progress_media_types( + request, + sort_by, + items_limit, + page_start=items_limit, + )[section_key] + + return BasicMedia.objects.get_home_status( + user=request.user, + status=section_key, + sort_by=sort_by, + items_limit=items_limit, + specific_media_type=media_type_to_load, + ) + + +def _get_home_section_keys(user): + """Return ordered home section keys for current user.""" + if user.home_separate_incoming: + return [Status.IN_PROGRESS.value, HOME_SECTION_INCOMING, Status.PLANNING.value] + + return [Status.IN_PROGRESS.value, Status.PLANNING.value] + @require_GET def home(request): """Home page with media items in progress and planning.""" sort_by = request.user.update_preference("home_sort", request.GET.get("sort")) media_type_to_load = request.GET.get("load_media_type") - status_to_load = request.GET.get("load_status", Status.IN_PROGRESS.value) + section_to_load = request.GET.get("load_status", Status.IN_PROGRESS.value) items_limit = 14 # If this is an HTMX request to load more items for a specific media type if request.headers.get("HX-Request") and media_type_to_load: - list_by_type = BasicMedia.objects.get_home_status( - user=request.user, - status=status_to_load, - sort_by=sort_by, - items_limit=items_limit, - specific_media_type=media_type_to_load, + list_by_type = _get_home_load_more_media_types( + request, + sort_by, + section_to_load, + items_limit, + media_type_to_load, ) - context = { - "media_list": list_by_type.get(media_type_to_load, []), - "home_status": status_to_load, - } - return render(request, "app/components/home_grid.html", context) - - home_sections = [] - for status in (Status.IN_PROGRESS.value, Status.PLANNING.value): - media_types = BasicMedia.objects.get_home_status( - user=request.user, - status=status, - sort_by=sort_by, - items_limit=items_limit, - ) - home_sections.append( + return render( + request, + "app/components/home_grid.html", { - "key": status, - "id": slugify(status), - "media_types": media_types, - "count": sum( - media_list["total"] for media_list in media_types.values() - ), + "media_list": list_by_type.get(media_type_to_load, []), + "home_status": section_to_load, }, ) + home_sections = [ + _build_home_section( + section_key, + _get_home_section_media_types(request, sort_by, section_key, items_limit), + ) + for section_key in _get_home_section_keys(request.user) + ] + context = { "home_sections": home_sections, "current_sort": sort_by, diff --git a/src/templates/app/components/home_grid.html b/src/templates/app/components/home_grid.html index 983fa29f7..eb3c9218a 100644 --- a/src/templates/app/components/home_grid.html +++ b/src/templates/app/components/home_grid.html @@ -1,6 +1,9 @@ {% load app_tags %} {% for media in media_list.items %} + {% component_id 'track' media.item media.id as track_component_id %} + {% component_id 'lists' media.item as lists_component_id %} + {% component_id 'history' media.item as history_component_id %}
@@ -28,14 +31,14 @@ {% endif %}
- + -
+
@@ -43,7 +46,7 @@ title="Add to custom lists" hx-get="{% media_view_url 'lists_modal' media.item %}" hx-vals='{"return_url": "{{ request.get_full_path|urlencode }}"}' - hx-target="#{% component_id 'lists' media.item %}" + hx-target="#{{ lists_component_id }}{% if home_status == 'incoming' %}-incoming{% endif %}" hx-trigger="click once" @click="listsOpen = true">{% include "app/icons/list-add.svg" with classes="w-4 h-4" %} @@ -51,7 +54,7 @@ title="View your activity history" hx-get="{% media_view_url 'history_modal' media.item %}" hx-vals='{"return_url": "{{ request.get_full_path|urlencode }}"}' - hx-target="#{% component_id 'history' media.item %}" + hx-target="#{{ history_component_id }}{% if home_status == 'incoming' %}-incoming{% endif %}" hx-trigger="click once" @click="historyOpen = true">{% include "app/icons/history.svg" with classes="w-4 h-4" %}
@@ -64,7 +67,7 @@ title="{{ media }}">{{ media }}
- {% if home_status == Status.PLANNING.value %} + {% if home_status == Status.PLANNING.value or home_status == 'incoming' %} {% if user.progress_bar %}
{% endif %} {% else %}
@@ -77,7 +80,7 @@ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
-
+
@@ -86,7 +89,7 @@ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
-
+
@@ -95,7 +98,7 @@ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
-
+
diff --git a/src/templates/app/components/home_section.html b/src/templates/app/components/home_section.html index 68d440a80..d7b8b459a 100644 --- a/src/templates/app/components/home_section.html +++ b/src/templates/app/components/home_section.html @@ -4,19 +4,29 @@
-
- {% if section.key == Status.IN_PROGRESS.value %} +
+ {% if section.key == 'incoming' %} + {% include "app/icons/clock-reversing.svg" with classes="h-5 w-5" %} + {% elif section.key == Status.IN_PROGRESS.value %} {% include "app/icons/states/in-progress.svg" with classes="h-5 w-5" %} {% else %} {% include "app/icons/states/planning.svg" with classes="h-5 w-5" %} {% endif %}
-

{{ section.key|media_status_readable|title }}

+

+ {% if section.key == 'incoming' %} + Incoming + {% else %} + {{ section.key|media_status_readable|title }} + {% endif %} +

- {% if section.key == Status.IN_PROGRESS.value %} + {% if section.key == 'incoming' %} + {% include "app/icons/clock.svg" with classes="h-4 w-4 text-indigo-400" %} + {% elif section.key == Status.IN_PROGRESS.value %} {% include "app/icons/pulse.svg" with classes="h-4 w-4 text-indigo-400" %} {% else %} {% include "app/icons/leaf.svg" with classes="h-4 w-4 text-sky-400" %} @@ -25,9 +35,9 @@

{{ section.key|media_status_readab

-
+
-
+
@@ -59,7 +69,7 @@

{{ media_type|media_type_readable_p