diff --git a/django/thunderstore/api/cyberstorm/tests/test_package_listing_list.py b/django/thunderstore/api/cyberstorm/tests/test_package_listing_list.py index 942a3464b..02d1251bb 100644 --- a/django/thunderstore/api/cyberstorm/tests/test_package_listing_list.py +++ b/django/thunderstore/api/cyberstorm/tests/test_package_listing_list.py @@ -1,6 +1,8 @@ +from datetime import datetime, timedelta from unittest.mock import patch import pytest +from django.utils import timezone from rest_framework.test import APIClient, APIRequestFactory from thunderstore.api.cyberstorm.views.package_listing_list import ( @@ -609,6 +611,247 @@ def test_listing_by_community_view__when_package_listed_in_multiple_communities_ ) +@pytest.mark.django_db +def test_listing_by_community_view__returns_created_recent( + api_client: APIClient, + community: Community, +) -> None: + now = timezone.now() + recent = PackageListingFactory( + community_=community, + package_kwargs={"date_created": now - timedelta(days=0)}, + ) + old = PackageListingFactory( + community_=community, + package_kwargs={"date_created": now - timedelta(days=6)}, + ) + PackageListingFactory( + community_=community, + package_kwargs={"date_created": now - timedelta(days=12)}, + ) + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?created_recent=1", + ) + result = response.json() + + assert result["count"] == 1 + assert any(item["name"] == recent.package.name for item in result["results"]) + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?created_recent=7", + ) + result1 = response.json() + + assert result1["count"] == 2 + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?created_recent=999", + ) + result = response.json() + + assert result["count"] == 3 + + +@pytest.mark.django_db +def test_listing_by_community_view__returns_updated_recent( + api_client: APIClient, + community: Community, +) -> None: + now = timezone.now() + p1v1 = PackageVersionFactory( + version_number="1.0.0", + ) + p1v1.date_created = now - timedelta(days=1) + p1v1.save() + p1v2 = PackageVersionFactory( + version_number="2.0.0", + package=p1v1.package, + ) + p1v2.date_created = now - timedelta(days=10) + p1v2.save() + p1v3 = PackageVersionFactory( + version_number="3.0.0", + package=p1v1.package, + ) + p1v3.date_created = now - timedelta(days=20) + p1v3.save() + + p2v1 = PackageVersionFactory( + version_number="1.0.0", + ) + p2v1.date_created = now - timedelta(days=20) + p2v1.save() + p2v2 = PackageVersionFactory( + version_number="2.0.0", + package=p2v1.package, + ) + p2v2.date_created = now - timedelta(days=30) + p2v2.save() + + p3v1 = PackageVersionFactory( + version_number="1.0.0", + ) + p3v1.date_created = now - timedelta(days=365) + p3v1.save() + + PackageListingFactory( + community_=community, + package=p1v1.package, + ) + PackageListingFactory( + community_=community, + package=p2v1.package, + ) + PackageListingFactory( + community_=community, + package=p3v1.package, + ) + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?updated_recent=7", + ) + result = response.json() + + assert result["count"] == 1 + assert p1v2.package.name == result["results"][0]["name"] + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?updated_recent=25", + ) + result = response.json() + + assert result["count"] == 2 + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/??updated_recent=999", + ) + result = response.json() + + assert result["count"] == 3 + + +@pytest.mark.django_db +def test_listing_by_community_view__returns_created_within_specified_date_range( + api_client: APIClient, + community: Community, +) -> None: + PackageListingFactory( + community_=community, + package_kwargs={"date_created": "2024-01-01 01:23:45Z"}, + ) + day7 = PackageListingFactory( + community_=community, + package_kwargs={"date_created": "2024-01-07 00:00:01Z"}, + ) + day14 = PackageListingFactory( + community_=community, + package_kwargs={"date_created": "2024-01-14 23:59:59Z"}, + ) + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?created_before=2024-01-14&created_after=2024-01-07", + ) + result1 = response.json() + + assert result1["count"] == 2 + assert any(item["name"] == day7.package.name for item in result1["results"]) + assert any(item["name"] == day14.package.name for item in result1["results"]) + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?created_after=2024-01-07", + ) + result2 = response.json() + + assert result2["count"] == 2 + assert any(item["name"] == day7.package.name for item in result2["results"]) + assert any(item["name"] == day14.package.name for item in result2["results"]) + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?created_before=2024-01-06", + ) + result3 = response.json() + + assert result3["count"] == 1 + + +@pytest.mark.django_db +def test_listing_by_community_view__returns_updated_within_specified_date_range( + api_client: APIClient, + community: Community, +) -> None: + p1v1 = PackageVersionFactory( + version_number="1.0.0", + ) + p1v1.date_created = datetime(2024, 1, 1, 0, 0, 0, 0, timezone.utc) + p1v1.save() + p1v2 = PackageVersionFactory( + version_number="2.0.0", + package=p1v1.package, + ) + p1v2.date_created = datetime(2024, 1, 7, 0, 0, 0, 0, timezone.utc) + p1v2.save() + p1v3 = PackageVersionFactory( + version_number="3.0.0", + package=p1v1.package, + ) + p1v3.date_created = datetime(2024, 1, 14, 0, 0, 0, 0, timezone.utc) + p1v3.save() + + p2v1 = PackageVersionFactory( + version_number="1.0.0", + ) + p2v1.date_created = datetime(2024, 1, 3, 0, 0, 0, 0, timezone.utc) + p2v1.save() + p2v2 = PackageVersionFactory( + version_number="2.0.0", + package=p2v1.package, + ) + p2v2.date_created = datetime(2024, 1, 14, 0, 0, 0, 0, timezone.utc) + p2v2.save() + + p3v1 = PackageVersionFactory( + version_number="1.0.0", + ) + p3v1.date_created = datetime(2024, 2, 1, 0, 0, 0, 0, timezone.utc) + p3v1.save() + + PackageListingFactory( + community_=community, + package=p1v1.package, + ) + PackageListingFactory( + community_=community, + package=p2v1.package, + ) + PackageListingFactory( + community_=community, + package=p3v1.package, + ) + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?updated_before=2024-01-10&updated_after=2024-01-05", + ) + result = response.json() + + assert result["count"] == 1 + assert p1v2.package.name == result["results"][0]["name"] + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?updated_before=2024-01-02", + ) + result = response.json() + + assert result["count"] == 1 + + response = api_client.get( + f"/api/cyberstorm/listing/{community.identifier}/?updated_after=2024-01-05", + ) + result = response.json() + + assert result["count"] == 3 + + @pytest.mark.django_db def test_listing_by_community_view__does_not_return_rejected_packages( api_client: APIClient, diff --git a/django/thunderstore/api/cyberstorm/views/package_listing_list.py b/django/thunderstore/api/cyberstorm/views/package_listing_list.py index eec1f8e4e..f3438c118 100644 --- a/django/thunderstore/api/cyberstorm/views/package_listing_list.py +++ b/django/thunderstore/api/cyberstorm/views/package_listing_list.py @@ -1,11 +1,13 @@ from copy import deepcopy +from datetime import datetime, time, timedelta from typing import List, Optional, OrderedDict, Tuple from urllib.parse import urlencode from django.conf import settings from django.core.paginator import Page -from django.db.models import Count, OuterRef, Q, QuerySet, Subquery, Sum +from django.db.models import Count, Exists, OuterRef, Q, QuerySet, Subquery, Sum from django.urls import reverse +from django.utils import timezone from django.utils.decorators import method_decorator from rest_framework import serializers from rest_framework.generics import ListAPIView, get_object_or_404 @@ -19,7 +21,12 @@ PackageListing, PackageListingSection, ) -from thunderstore.repository.models import Namespace, Package, get_package_dependants +from thunderstore.repository.models import ( + Namespace, + Package, + PackageVersion, + get_package_dependants, +) # Keys are values expected in requests, values are args for .order_by(). ORDER_ARGS = { @@ -52,6 +59,12 @@ class PackageListRequestSerializer(serializers.Serializer): page = serializers.IntegerField(default=1, min_value=1) q = serializers.CharField(required=False, help_text="Free text search") section = serializers.UUIDField(required=False) + created_recent = serializers.IntegerField(required=False, min_value=1) + updated_recent = serializers.IntegerField(required=False, min_value=1) + created_after = serializers.DateField(required=False) + created_before = serializers.DateField(required=False) + updated_after = serializers.DateField(required=False) + updated_before = serializers.DateField(required=False) class PackageListResponseSerializer(serializers.Serializer): @@ -151,6 +164,24 @@ def filter_queryset(self, queryset: QuerySet[Package]) -> QuerySet[Package]: qs = filter_not_in_categories(params["excluded_categories"], qs) qs = filter_by_section(params.get("section"), qs) qs = filter_by_query(params.get("q"), qs) + if created_recent := params.get("created_recent"): + qs = filter_by_recent_creation_date(created_recent, qs) + if updated_recent := params.get("updated_recent"): + qs = filter_by_recent_update_date(updated_recent, qs) + if any( + [ + (created_after := params.get("created_after")), + (created_before := params.get("created_before")), + ], + ): + qs = filter_by_creation_date(created_after, created_before, qs) + if any( + [ + (updated_after := params.get("updated_after")), + (updated_before := params.get("updated_before")), + ], + ): + qs = filter_by_update_date(updated_after, updated_before, qs) return qs.order_by( "-is_pinned", @@ -463,6 +494,101 @@ def filter_by_query( return queryset.exclude(icontains_query).distinct() +def filter_by_recent_creation_date( + days: int, + queryset: QuerySet[Package], +) -> QuerySet[Package]: + + return queryset.filter( + date_created__gte=(timezone.now() - timedelta(days=days)) + ).distinct() + + +def filter_by_recent_update_date( + days: int, + queryset: QuerySet[Package], +) -> QuerySet[Package]: + + versions = PackageVersion.objects.filter( + package=OuterRef("pk"), + date_created__gte=(timezone.now() - timedelta(days=days)), + ) + + return ( + queryset.annotate(has_matching_versions=Exists(versions)) + .filter(has_matching_versions=True) + .distinct() + ) + + +def filter_by_creation_date( + after: Optional[datetime], + before: Optional[datetime], + queryset: QuerySet[Package], +) -> QuerySet[Package]: + """ + Filter out packages that have not been created in the given date constraints + """ + + q = Q() + + # From 2024-01-01 to 2024-01-02 should include all packages uploaded on 2024-01-02 + if after: + q.add( + Q(date_created__gte=datetime.combine(after, time.min, tzinfo=timezone.utc)), + Q.AND, + ) + if before: + q.add( + Q( + date_created__lte=datetime.combine( + before, + time.max, + tzinfo=timezone.utc, + ), + ), + Q.AND, + ) + return queryset.filter(q).distinct() + + +def filter_by_update_date( + after: Optional[datetime], + before: Optional[datetime], + queryset: QuerySet[Package], +) -> QuerySet[Package]: + """ + Filter out packages that have not been updated in the given date constraints + """ + + q = Q(package=OuterRef("pk")) + + # From 2024-01-01 to 2024-01-02 should include all packages uploaded on 2024-01-02 + if after: + q.add( + Q(date_created__gte=datetime.combine(after, time.min, tzinfo=timezone.utc)), + Q.AND, + ) + if before: + q.add( + Q( + date_created__lte=datetime.combine( + before, + time.max, + tzinfo=timezone.utc, + ), + ), + Q.AND, + ) + + versions = PackageVersion.objects.filter(q) + return ( + queryset.annotate(has_matching_versions=Exists(versions)) + .filter(has_matching_versions=True) + .distinct() + ) + + def filter_by_review_status( require_approval: bool, queryset: QuerySet[Package],