Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
db16210
Introduce __check_all_contributor_facilities method
VadimKovalenkoSNF Aug 5, 2025
3427b1f
Merge branch 'main' into OSDEV-2068-users-can-download-their-lists-fo…
VadimKovalenkoSNF Aug 5, 2025
ee2b807
Check same contributor on BE, add tests
VadimKovalenkoSNF Aug 5, 2025
2fd38f7
Update release notes
VadimKovalenkoSNF Aug 5, 2025
ae3f51a
Update Release instructions section
VadimKovalenkoSNF Aug 5, 2025
31bfd72
Minor optimization for is_same_contributor, test fixes
VadimKovalenkoSNF Aug 6, 2025
a9c7525
Fix release notes
VadimKovalenkoSNF Aug 6, 2025
ca844d8
Minor refactoring of release notes
VadimKovalenkoSNF Aug 6, 2025
8615b4a
Merge branch 'main' into OSDEV-2068-users-can-download-their-lists-fo…
VadimKovalenkoSNF Aug 6, 2025
57a15de
Merge branch 'main' into OSDEV-2068-users-can-download-their-lists-fo…
VadimKovalenkoSNF Aug 13, 2025
cff28f6
Merge branch 'main' into OSDEV-2068-users-can-download-their-lists-fo…
VadimKovalenkoSNF Aug 15, 2025
a5763ab
Merge branch 'main' into OSDEV-2068-users-can-download-their-lists-fo…
VadimKovalenkoSNF Aug 18, 2025
fe87bed
Make variables name more explicit
VadimKovalenkoSNF Aug 18, 2025
83ee7d6
Users can download their list items even if they reach download limit
VadimKovalenkoSNF Aug 18, 2025
a729d01
Update FE to handle download limit
VadimKovalenkoSNF Aug 18, 2025
1c2eac1
Fix Download button conditional rendering (partial impl)
VadimKovalenkoSNF Aug 19, 2025
401f697
Add is_same_contributor to the GET api/facility
VadimKovalenkoSNF Aug 19, 2025
51ca50e
Update tooltip for free countributor downloads
VadimKovalenkoSNF Aug 19, 2025
3ce912a
Add FE tests, update serialization in api/facilities
VadimKovalenkoSNF Aug 19, 2025
9de8402
Merge branch 'main' into OSDEV-2068-users-can-download-their-lists-fo…
VadimKovalenkoSNF Aug 19, 2025
b20697a
Refactor LoginRequiredDialog component
VadimKovalenkoSNF Aug 19, 2025
62be8c1
Remove redundant hook
VadimKovalenkoSNF Aug 19, 2025
9100059
Add props validation
VadimKovalenkoSNF Aug 19, 2025
dcd7366
Update unit test, fix lint issues
VadimKovalenkoSNF Aug 19, 2025
b41c20a
feat(api, fe): add is_same_contributor support, enforce download caps…
VadimKovalenkoSNF Aug 20, 2025
729f8fb
Refactor GET api/facilities
VadimKovalenkoSNF Aug 20, 2025
e8adcfb
Update DownloadButtonWithFlags
VadimKovalenkoSNF Aug 20, 2025
55a6a6f
Merge branch 'main' into OSDEV-2068-users-can-download-their-lists-fo…
VadimKovalenkoSNF Sep 2, 2025
b725c55
Fix list query
VadimKovalenkoSNF Sep 2, 2025
94bd8d4
Fix register_download_if_needed
VadimKovalenkoSNF Sep 2, 2025
90c88e4
Minor code prettify
VadimKovalenkoSNF Sep 2, 2025
3828351
Merge branch 'main' into OSDEV-2068-users-can-download-their-lists-fo…
roman-stolar Sep 2, 2025
fbe14e3
Pass actual returned row count
VadimKovalenkoSNF Sep 3, 2025
7d8a6aa
Fix multipage downloads quota
VadimKovalenkoSNF Sep 3, 2025
e9522a9
Fix unit tests
VadimKovalenkoSNF Sep 3, 2025
2ac4fab
Fix flake8 issues
VadimKovalenkoSNF Sep 3, 2025
f97dbdd
Update unit tests for exhausted quota
VadimKovalenkoSNF Sep 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/release/RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html

### Code/API changes
* [OSDEV-2137](https://opensupplyhub.atlassian.net/browse/OSDEV-2137) - Switched to a custom, page-compatible keyset for the `/facilities-downloads` endpoint, enabling more efficient, cursor-based pagination and improved download performance and compatibility.
* [OSDEV-2068](https://opensupplyhub.atlassian.net/browse/OSDEV-2068) - Enabled users to download their own data without impacting free & purchased data-download allowances. Introduced `is_same_contributor` field in the GET `/api/facilities-downloads` response.


### Release instructions
* Ensure that the following commands are included in the `post_deployment` command:
Expand Down
45 changes: 31 additions & 14 deletions src/django/api/facilities_download_view_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
FacilityDownloadSerializerEmbedMode
from api.serializers.utils import get_embed_contributor_id_from_query_params
from api.services.facilities_download_service import FacilitiesDownloadService
from api.serializers.facility.utils import is_same_contributor_for_queryset
from api.constants import PaginationConfig


Expand Down Expand Up @@ -44,10 +45,17 @@ def list(self, request):

base_qs = FacilitiesDownloadService.get_filtered_queryset(request)

is_same_contributor = is_same_contributor_for_queryset(
base_qs,
request
)

limit = None

if (
not switch_is_active('private_instance')
and not self.__is_embed_mode()
and not is_same_contributor
):
limit = FacilitiesDownloadService.get_download_limit(request)

Expand Down Expand Up @@ -78,9 +86,14 @@ def list(self, request):
)

list_serializer = self.get_serializer(items)
rows = [f['row'] for f in list_serializer.data]
rows = [facility_data['row'] for facility_data in list_serializer.data]
headers = list_serializer.child.get_headers()
data = {'rows': rows, 'headers': headers}

data = {
'rows': rows,
'headers': headers,
'is_same_contributor': is_same_contributor
}

payload = {
'next': next_link,
Expand All @@ -95,17 +108,21 @@ def list(self, request):

if is_last_page and limit:
total_records = (page - 1) * page_size + len(items)
prev_free = getattr(limit, 'free_download_records', 0)
prev_paid = getattr(limit, 'paid_download_records', 0)
FacilitiesDownloadService.register_download_if_needed(
limit,
total_records
)
FacilitiesDownloadService.send_email_if_needed(
request,
limit,
prev_free,
prev_paid
)

prev_free_amount = getattr(limit, 'free_download_records', 0)
prev_paid_amount = getattr(limit, 'paid_download_records', 0)

if total_records > 0:
FacilitiesDownloadService.register_download_if_needed(
limit,
total_records,
is_same_contributor
)
FacilitiesDownloadService.send_email_if_needed(
request,
limit,
prev_free_amount,
prev_paid_amount
)

return Response(payload)
27 changes: 22 additions & 5 deletions src/django/api/models/facility_download_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,34 @@ class FacilityDownloadLimit(models.Model):
objects = FacilityDownloadLimitManager()

@transaction.atomic
def register_download(self, records_to_subtract):
def register_download(
self,
records_to_subtract: int,
):
self.refresh_from_db()

if self.free_download_records >= records_to_subtract:
self.free_download_records -= records_to_subtract
# Prevent overdrafts by capping to remaining quota
remaining_quota = (self.free_download_records or 0) + \
(self.paid_download_records or 0)
to_subtract = min(
max(records_to_subtract, 0),
remaining_quota
)

if to_subtract == 0:
return

if self.free_download_records >= to_subtract:
self.free_download_records -= to_subtract
else:
remaining_records = (
records_to_subtract - self.free_download_records
to_subtract - self.free_download_records
)
self.free_download_records = 0
self.paid_download_records -= remaining_records
self.paid_download_records = max(
self.paid_download_records - remaining_records,
0
)

self.save()

Expand Down
21 changes: 20 additions & 1 deletion src/django/api/serializers/facility/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Union
from typing import (Iterable, Union)
from itertools import groupby

from api.constants import FacilityClaimStatuses
Expand Down Expand Up @@ -346,3 +346,22 @@ def add_http_prefix_to_url(value: str) -> str:
):
value = f"https://{value}"
return value


def is_same_contributor_for_queryset(queryset: Iterable, request) -> bool:
contributor = getattr(request.user, 'contributor', None)
if not contributor:
return False
current_user_contributor_id = contributor.id

found_any_facility = False
for facility in queryset:
found_any_facility = True
facility_contributor_ids = [
contributor.get('id') for contributor in facility.contributors
if contributor.get('id') is not None
]
if current_user_contributor_id not in facility_contributor_ids:
return False

return found_any_facility
17 changes: 14 additions & 3 deletions src/django/api/services/facilities_download_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,20 @@ def enforce_limits(qs, limit, is_first_page):
)

@staticmethod
def register_download_if_needed(limit, record_count):
if limit:
limit.register_download(record_count)
def check_pagination(page_queryset):
if page_queryset is None:
raise ValidationError("Invalid pageSize parameter")
return page_queryset

@staticmethod
def register_download_if_needed(
limit: FacilityDownloadLimit,
record_count: int,
is_same_contributor: bool = False
):
if is_same_contributor or not limit:
return
limit.register_download(record_count)

@staticmethod
def send_email_if_needed(
Expand Down
182 changes: 176 additions & 6 deletions src/django/api/tests/test_facilities_download_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
from rest_framework.test import APITestCase
from django.urls import reverse
from django.contrib.auth.models import Group
from unittest.mock import patch
from unittest.mock import patch, MagicMock

from api.models.user import User
from api.models.contributor.contributor import Contributor
from api.constants import FeatureGroups
from api.models.facility_download_limit import FacilityDownloadLimit
from django.utils import timezone
Expand Down Expand Up @@ -418,7 +419,7 @@ def test_query_parameters(self):
user = self.create_user()
self.login_user(user)

response = self.get_facility_downloads({"countries": "IN"})
response = self.get_facility_downloads({"countries": ["IN"]})

self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_data = [
Expand Down Expand Up @@ -483,9 +484,7 @@ def test_new_user_has_current_date_in_updated_at(self):
self.assertEqual(limit.updated_at.date(), current_date.date())

def test_old_user_has_release_date_in_updated_at(self):
# The record has been added to FacilityDownloadLimit.
user = self.create_user()
# Simulation old user.
FacilityDownloadLimit.objects.filter(user=user).delete()
self.login_user(user)
release_date = make_aware(datetime(2025, 7, 12))
Expand Down Expand Up @@ -518,8 +517,179 @@ def test_api_user_not_limited_by_download_count(self):
user = self.create_user(is_api_user=True)
self.login_user(user)

# Make multiple downloads that would exceed the limit for regular
# users.
for _ in range(5):
response = self.get_facility_downloads()
self.assertEqual(response.status_code, status.HTTP_200_OK)

def test_is_same_contributor_true_when_all_facilities_belong_to_user(self):
user = self.create_user()
self.login_user(user)

contributor = Contributor.objects.create(
admin=user,
name="Test Contributor",
contrib_type="Brand / Retailer"
)

with patch(
'api.services.facilities_download_service.'
'FacilitiesDownloadService.get_filtered_queryset'
) as mock_get_queryset:
mock_queryset = MagicMock()
mock_facility = MagicMock()
mock_facility.contributors = [{'id': contributor.id}]
mock_queryset.__iter__.return_value = [mock_facility]
mock_queryset.count.return_value = 1
mock_get_queryset.return_value = mock_queryset

response = self.get_facility_downloads(
{'contributors': [str(contributor.id)]}
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
response.data['results']['is_same_contributor']
)

def test_is_same_contributor_false_when_mixed_contributors(self):
user = self.create_user()
self.login_user(user)

contributor = Contributor.objects.create(
admin=user,
name="Test Contributor",
contrib_type="Brand / Retailer"
)

with patch(
'api.services.facilities_download_service.'
'FacilitiesDownloadService.get_filtered_queryset'
) as mock_get_queryset:
mock_queryset = MagicMock()
mock_facility1 = MagicMock()
mock_facility1.contributors = [{'id': contributor.id}]
mock_facility2 = MagicMock()
mock_facility2.contributors = [{'id': 999}]
mock_queryset.__iter__.return_value = [
mock_facility1,
mock_facility2
]
mock_queryset.count.return_value = 2
mock_get_queryset.return_value = mock_queryset

response = self.get_facility_downloads(
{'contributors': [str(contributor.id)]}
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(
response.data['results']['is_same_contributor']
)

def test_is_same_contributor_false_when_user_has_no_contributor(self):
user = self.create_user()
self.login_user(user)

response = self.get_facility_downloads({'contributors': ['123']})

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(
response.data['results']['is_same_contributor']
)

def test_is_same_contributor_with_combine_contributors_and_logic(self):
user = self.create_user()
self.login_user(user)

contributor = Contributor.objects.create(
admin=user,
name="Test Contributor",
contrib_type="Brand / Retailer"
)

with patch(
'api.services.facilities_download_service.'
'FacilitiesDownloadService.get_filtered_queryset'
) as mock_get_queryset:
mock_queryset = MagicMock()
mock_facility = MagicMock()
mock_facility.contributors = [
{'id': contributor.id},
{'id': 456}
]
mock_queryset.__iter__.return_value = [mock_facility]
mock_queryset.count.return_value = 1
mock_get_queryset.return_value = mock_queryset

response = self.get_facility_downloads({
'contributors': [str(contributor.id), '456'],
'combine_contributors': 'AND'
})

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
response.data['results']['is_same_contributor']
)

def test_is_same_contributor_with_empty_queryset(self):
"""Test is_same_contributor with empty queryset."""
user = self.create_user()
self.login_user(user)

contributor = Contributor.objects.create(
admin=user,
name="Test Contributor",
contrib_type="Brand / Retailer"
)

with patch(
'api.services.facilities_download_service.'
'FacilitiesDownloadService.get_filtered_queryset'
) as mock_get_queryset:
mock_queryset = MagicMock()
mock_queryset.__iter__.return_value = []
mock_queryset.count.return_value = 0
mock_get_queryset.return_value = mock_queryset

response = self.get_facility_downloads(
{'contributors': [str(contributor.id)]}
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(
response.data['results']['is_same_contributor']
)

def test_is_same_contributor_with_multiple_contributors_or_logic(self):
user = self.create_user()
self.login_user(user)

contributor = Contributor.objects.create(
admin=user,
name="Test Contributor",
contrib_type="Brand / Retailer"
)

with patch(
'api.services.facilities_download_service.'
'FacilitiesDownloadService.get_filtered_queryset'
) as mock_get_queryset:
mock_queryset = MagicMock()
mock_facility = MagicMock()
mock_facility.contributors = [
{'id': contributor.id},
{'id': 456},
{'id': 789}
]
mock_queryset.__iter__.return_value = [mock_facility]
mock_queryset.count.return_value = 1
mock_get_queryset.return_value = mock_queryset

response = self.get_facility_downloads({
'contributors': [str(contributor.id), '456']
})

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
response.data['results']['is_same_contributor']
)
Loading
Loading