Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
74c726a
Add can_partially_update_production_location field to User model
VadimKovalenkoSNF Aug 29, 2025
b4651eb
Merge branch 'main' into OSDEV-2122-patch-v1-allow-name-address-country
VadimKovalenkoSNF Aug 29, 2025
68a60c0
Apply unit tests
VadimKovalenkoSNF Aug 29, 2025
c86aa50
Fix lint issues
VadimKovalenkoSNF Aug 29, 2025
951332a
Merge branch 'main' into OSDEV-2122-patch-v1-allow-name-address-country
VadimKovalenkoSNF Sep 1, 2025
edbe34f
Add condition depending on contributor verification status
VadimKovalenkoSNF Sep 1, 2025
603e020
Add signal to disable can_partially_update_production_location field
VadimKovalenkoSNF Sep 1, 2025
6390470
Disable cursor for prohibited Can partially update production locatio…
VadimKovalenkoSNF Sep 1, 2025
8e4d1d5
Merge branch 'main' into OSDEV-2122-patch-v1-allow-name-address-country
VadimKovalenkoSNF Sep 1, 2025
a44fa9f
Add test for unverified user
VadimKovalenkoSNF Sep 2, 2025
f2b19da
Fix flake8 issues
VadimKovalenkoSNF Sep 2, 2025
2de94eb
Merge branch 'main' into OSDEV-2122-patch-v1-allow-name-address-country
VadimKovalenkoSNF Sep 2, 2025
a168162
Remove can_patch checkbox from admin panel
VadimKovalenkoSNF Sep 3, 2025
3b2dba6
Inherit ProductionLocationSchemaSerializer
VadimKovalenkoSNF Sep 3, 2025
189066e
Create location productions lookup
VadimKovalenkoSNF Sep 4, 2025
8ec07b6
Remove redundant test cases
VadimKovalenkoSNF Sep 4, 2025
123365d
Add tests for production locations lookup service
VadimKovalenkoSNF Sep 4, 2025
6fa08e1
Bring back tests for parent company field
VadimKovalenkoSNF Sep 4, 2025
2c9ba7f
Remove leftovers
VadimKovalenkoSNF Sep 4, 2025
605093f
Minor fixes
VadimKovalenkoSNF Sep 4, 2025
13c23a7
Replace serializer init into static private method
VadimKovalenkoSNF Sep 4, 2025
8fa5fcb
Fix flake8 issue
VadimKovalenkoSNF Sep 4, 2025
6060304
Remove redundant comment
VadimKovalenkoSNF Sep 5, 2025
68fbe84
Add logs for lookup service
VadimKovalenkoSNF Sep 5, 2025
ea6fe03
Add error handling for missing fields if partly applied
VadimKovalenkoSNF Sep 5, 2025
613b76e
Separate method for required field error throwing
VadimKovalenkoSNF Sep 5, 2025
425357a
Handle coordinates field (partial impl)
VadimKovalenkoSNF Sep 5, 2025
6c13448
Add coordinates check
VadimKovalenkoSNF Sep 8, 2025
e35fc64
Refactor serializer preparation method and lookup helper functions
VadimKovalenkoSNF Sep 8, 2025
e9ec054
Merge branch 'main' into OSDEV-2122-patch-v1-allow-name-address-country
VadimKovalenkoSNF Sep 8, 2025
d0705d2
Add Python cursor rules, update lookup tests
VadimKovalenkoSNF Sep 8, 2025
1541b08
Add tests for handling required fields
VadimKovalenkoSNF Sep 8, 2025
88c2be8
Update release notes
VadimKovalenkoSNF Sep 8, 2025
bb23361
Refactor backfill condition
VadimKovalenkoSNF Sep 8, 2025
c078829
Update cursor rules
VadimKovalenkoSNF Sep 8, 2025
8d380bf
Fix tests
VadimKovalenkoSNF Sep 8, 2025
06e3721
Fix flake8 issues
VadimKovalenkoSNF Sep 8, 2025
06ed969
Use raw_data to handle errors
VadimKovalenkoSNF Sep 9, 2025
a109819
Return deep copy of backfilled data
VadimKovalenkoSNF Sep 9, 2025
cf5e11f
Refactor backfill processing
VadimKovalenkoSNF Sep 9, 2025
df957c6
Remove cursor-rules
VadimKovalenkoSNF Sep 9, 2025
6ce8f57
Remove failed tests
VadimKovalenkoSNF Sep 9, 2025
90ea656
Fix flake8 issues
VadimKovalenkoSNF Sep 9, 2025
335591f
Minor fix
VadimKovalenkoSNF Sep 9, 2025
4a3e135
Remove redundant error checks
VadimKovalenkoSNF Sep 12, 2025
0986538
Make ProductionLocationSchemaSerializer as abstract class
VadimKovalenkoSNF Sep 12, 2025
f6e6ff6
Remove redundant test attachments
VadimKovalenkoSNF Sep 12, 2025
5c29c72
Merge branch 'main' into OSDEV-2122-patch-v1-allow-name-address-country
VadimKovalenkoSNF Sep 12, 2025
ac545f3
Pass only request type instead of entire dto
VadimKovalenkoSNF Sep 12, 2025
37c1440
Fix flake8 issue
VadimKovalenkoSNF Sep 12, 2025
5c9ddbd
Handle empty PATCH body error
VadimKovalenkoSNF Sep 12, 2025
9c79f93
Remove redundant try-catch, minor refactoring
VadimKovalenkoSNF Sep 12, 2025
8a56423
Merge branch 'main' into OSDEV-2122-patch-v1-allow-name-address-country
VadimKovalenkoSNF Sep 15, 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
5 changes: 3 additions & 2 deletions doc/release/RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* [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-2089](https://opensupplyhub.atlassian.net/browse/OSDEV-2089) - Added `geocoded_location_type` and `geocoded_address` fields to GET `/api/v1/production-locations/` and GET `/api/v1/production-locations/{os_id}/` endpoints.
* [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.
* [OSDEV-2122](https://opensupplyhub.atlassian.net/browse/OSDEV-2122) - Enhanced PATCH `/api/v1/production-locations/{os_id}/` endpoint validation to enforce required field constraints when coordinates are provided. Implemented automatic backfill of missing required fields (name, address, country) from existing facility data when no required fields are provided in PATCH requests.

### What's new
* [OSDEV-2164](https://opensupplyhub.atlassian.net/browse/OSDEV-2164) - Added search functionality for user email and contributor name in the Facility Download Limits admin page.
Expand Down Expand Up @@ -89,7 +90,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* 0176_introduce_enable_dromo_uploading_switch.py - This migration introduces a new feature flag called `enable_dromo_uploading`, which controls the visibility of the "Beta Self Service Upload" button on the Upload Multiple Locations page.

### Code/API changes
* [OSDEV-2062](https://opensupplyhub.atlassian.net/browse/OSDEV-2062) - Updated GET `v1/production-locations` API endpoint to query production locations by claim status. Introduced `claimed_at` response field which is taken from `updated_at` column in the `api_facilityclaim` table. Added these query parameters:
* [OSDEV-2062](https://opensupplyhub.atlassian.net/browse/OSDEV-2062) - Updated GET `v1/production-locations` API endpoint to query production locations by claim status. Introduced `claimed_at` response field which is taken from `updated_at` column in the `api_facilityclaim` table. Added these query parameters:
* `claim_status` - filter by the claim status (`claimed`, `unclaimed`, `pending`).
* `claimed_at_gt` - starting date to filter by production location claim timestamp.
* `claimed_at_lt` - ending date to filter by production location claim timestamp.
Expand Down Expand Up @@ -144,7 +145,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* [OSDEV-2036](https://opensupplyhub.atlassian.net/browse/OSDEV-2036) - Connected DarkVisitors trackers to the Open Supply Hub site. Both "client analytics" (for JavaScript-capable sessions, including browser-based AI agents) and "server analytics" (for bots that don’t execute JavaScript) were enabled.

### Architecture/Environment changes
* [OSDEV-2123](https://opensupplyhub.atlassian.net/browse/OSDEV-2123) - Updated VPN EC2 instance configuration to use a specific Amazon Linux 2023 AMI (`ami-0940c95b23a1f7cac`) instead of dynamically selecting the most recent AMI. This change ensures consistent AMI usage across deployments and prevents unnecessary reboots of the VPN server that could result in loss of VPN access through WireGuard.
* [OSDEV-2123](https://opensupplyhub.atlassian.net/browse/OSDEV-2123) - Updated VPN EC2 instance configuration to use a specific Amazon Linux 2023 AMI (`ami-0940c95b23a1f7cac`) instead of dynamically selecting the most recent AMI. This change ensures consistent AMI usage across deployments and prevents unnecessary reboots of the VPN server that could result in loss of VPN access through WireGuard.

### What's new
* [OSDEV-1881](https://opensupplyhub.atlassian.net/browse/OSDEV-1881) - Implemented automated email notifications for registered users in three scenarios: when nearing the 5,000-record annual download limit, upon reaching that limit, and after exhausting all purchased downloads.
Expand Down
6 changes: 6 additions & 0 deletions src/django/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ class ServiceUnavailableException(APIException):
default_detail = ('Service is temporarily unavailable due to maintenance'
'work. Please try again later.')
default_code = 'service_unavailable'


class MissingRequiredFieldsException(APIException):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
default_detail = 'The request body is invalid.'
default_code = 'unprocessable_entity'
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from contricleaner.lib.contri_cleaner import ContriCleaner
from contricleaner.lib.exceptions.handler_not_set_error \
import HandlerNotSetError
from api.exceptions import MissingRequiredFieldsException
from api.moderation_event_actions.creation.location_contribution \
.processors.contribution_processor import ContributionProcessor
from api.moderation_event_actions.creation.dtos.create_moderation_event_dto \
Expand All @@ -17,8 +18,19 @@
APIV1CommonErrorMessages,
NON_FIELD_ERRORS_KEY
)
from api.serializers.v1.production_location_schema_serializer \
import ProductionLocationSchemaSerializer
from api.models.moderation_event import ModerationEvent
from api.services.production_locations_lookup \
import (
fetch_required_fields,
get_missing_required_fields,
has_all_required_fields,
has_some_required_fields,
is_coordinates_without_all_required_fields,
)
from api.serializers.v1.production_location_post_schema_serializer \
import ProductionLocationPostSchemaSerializer
from api.serializers.v1.production_location_patch_schema_serializer \
import ProductionLocationPatchSchemaSerializer
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import ErrorDetail

Expand All @@ -35,7 +47,22 @@ def process(
event_dto.raw_data
)

serializer = ProductionLocationSchemaSerializer(data=cc_ready_data)
# Handle PATCH request backfill BEFORE ContriCleaner validation.
if event_dto.request_type == ModerationEvent.RequestType.UPDATE.value:
try:
cc_ready_data = self.__validate_and_backfill_patch_data(
cc_ready_data, event_dto
)
except MissingRequiredFieldsException as e:
event_dto.errors = e.detail
event_dto.status_code = e.status_code
return event_dto

# Choose serializer per request type (POST vs PATCH).
serializer = self.__prepare_serializer(
cc_ready_data,
event_dto.request_type
)

try:
serializer.is_valid(raise_exception=True)
Expand Down Expand Up @@ -94,12 +121,122 @@ def process(

return event_dto

# Save the cleaned data in case of successful ContriCleaner
# serialization.
# Save the cleaned data in case of successful
# ContriCleaner serialization.
event_dto.cleaned_data = dict(processed_location_object._asdict())

return super().process(event_dto)

@staticmethod
def __prepare_serializer(cc_ready_data: Dict, request_type: str):
if request_type == ModerationEvent.RequestType.CREATE.value:
return ProductionLocationPostSchemaSerializer(data=cc_ready_data)

# Handle v1 PATCH requests (backfill already happened earlier).
return ProductionLocationPatchSchemaSerializer(data=cc_ready_data)

@staticmethod
def __validate_and_backfill_patch_data(
cc_ready_data: Dict,
event_dto: CreateModerationEventDTO
) -> Dict:

'''
If the client provided no updatable fields, do not backfill here;
let the PATCH serializer enforce "No fields provided".
'''
if not cc_ready_data:
return cc_ready_data
# Check original raw_data to decide on backfill strategy.
raw_data = event_dto.raw_data

ProductionLocationDataProcessor.__handle_all_required_fields_errors(
raw_data
)

# If all required fields are missing, perform backfill.
if not has_all_required_fields(raw_data):
# Create a deep copy to avoid mutating the original data.
backfilled_data = copy.deepcopy(cc_ready_data)
default_required_fields = fetch_required_fields(event_dto.os.id)

# Add the required fields directly
# (before ContriCleaner processing).
for field in ('name', 'address', 'country'):
if (field not in backfilled_data or
not backfilled_data.get(field)):
backfilled_data[field] = default_required_fields.get(
field, ''
)

return backfilled_data

# Return original data if no backfill is needed.
return cc_ready_data

@staticmethod
def __handle_all_required_fields_errors(raw_data: Dict):
missing_fields = get_missing_required_fields(raw_data)

if not missing_fields:
return

if is_coordinates_without_all_required_fields(raw_data):
ProductionLocationDataProcessor. \
__raise_coordinates_validation_error(missing_fields)

if has_some_required_fields(raw_data):
ProductionLocationDataProcessor. \
__raise_partial_fields_validation_error(
raw_data, missing_fields
)

@staticmethod
def __raise_coordinates_validation_error(missing_fields: List[str]):
raise MissingRequiredFieldsException(
detail={
"detail": APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR,
"errors": [
{
"field": field,
"detail": (
f"Field {field} is required when coordinates "
f"are provided."
),
}
for field in missing_fields
]
}
)

@staticmethod
def __raise_partial_fields_validation_error(
raw_data: Dict, missing_fields: List[str]
):
required_fields = ('name', 'address', 'country')
provided_fields = [
field for field in required_fields if raw_data.get(field)
]

verb = 'is' if len(provided_fields) == 1 else 'are'
provided_list = ', '.join(provided_fields)

raise MissingRequiredFieldsException(
detail={
"detail": APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR,
"errors": [
{
"field": field,
"detail": (
f"Field {field} is required when {provided_list} "
f"{verb} provided."
),
}
for field in missing_fields
]
}
)

@staticmethod
def __extract_data_for_contri_cleaner(input_raw_data: Dict) -> Dict:
copied_raw_data = copy.deepcopy(input_raw_data)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from rest_framework import serializers
from api.serializers.v1.production_location_schema_serializer \
import ProductionLocationSchemaSerializer


class ProductionLocationPatchSchemaSerializer(
ProductionLocationSchemaSerializer
):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._set_core_required(False)

def validate(self, data):
if not data:
raise serializers.ValidationError([
{
'field': 'non_field_errors',
'detail': 'No fields provided.'
}
])
return super().validate(data)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from api.serializers.v1.production_location_schema_serializer \
import ProductionLocationSchemaSerializer


class ProductionLocationPostSchemaSerializer(
ProductionLocationSchemaSerializer
):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._set_core_required(True)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@


class ProductionLocationSchemaSerializer(serializers.Serializer):
core_fields = ('name', 'address', 'country')

name = serializers.CharField(
max_length=200,
required=True,
Expand Down Expand Up @@ -43,9 +45,7 @@ class ProductionLocationSchemaSerializer(serializers.Serializer):
product_type = StringOrListField(required=False)
location_type = StringOrListField(required=False)
processing_type = StringOrListField(required=False)
number_of_workers = NumberOfWorkersSerializer(
required=False,
)
number_of_workers = NumberOfWorkersSerializer(required=False)
coordinates = CoordinatesSerializer(
required=False,
error_messages={
Expand All @@ -54,28 +54,43 @@ class ProductionLocationSchemaSerializer(serializers.Serializer):
},
)

# Use only subclasses.
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.__class__ is ProductionLocationSchemaSerializer:
raise TypeError(
'ProductionLocationSchemaSerializer is abstract;'
' use a concrete subclass.'
)

def _set_core_required(self, required: bool) -> None:
for field in self.core_fields:
if field in self.fields:
self.fields[field].required = required

def _validate_string_field(self, data, field_name):
if field_name in data and data[field_name].isdigit():
if (
field_name in data and
isinstance(data[field_name], str) and
data[field_name].isdigit()
):
return {
"field": field_name,
"detail": f"Field {field_name} must be a string, not a number."
}

return None

def validate(self, data):
errors = []
for field in self.core_fields:
err = self._validate_string_field(data, field)
if err:
errors.append(err)

for field in ['name', 'address', 'country']:
error = self._validate_string_field(data, field)
if error:
errors.append(error)
err = self._validate_string_field(data, 'parent_company')
if err:
errors.append(err)

error = self._validate_string_field(data, 'parent_company')
if error:
errors.append(error)

if len(errors) > 0:
if errors:
raise serializers.ValidationError(errors)

return data
63 changes: 63 additions & 0 deletions src/django/api/services/production_locations_lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from typing import Dict, List
from api.models.facility.facility import Facility
from api.models.facility.facility_list_item import FacilityListItem


def fetch_required_fields(os_id: str) -> Dict[str, str]:
promoted = (
FacilityListItem.objects
.filter(facility_id=os_id)
.filter(
processing_results__contains=[{'action': 'promote_match'}]
)
.order_by('-created_at')
.first()
)
if promoted and promoted.name:
return {
'name': promoted.name,
'address': promoted.address or '',
'country': promoted.country_code or '',
}

latest = (
FacilityListItem.objects
.filter(facility_id=os_id)
.order_by('-created_at')
.first()
)
if latest:
return {
'name': latest.name or '',
'address': latest.address or '',
'country': latest.country_code or '',
}

os = Facility.objects.get(id=os_id)
return {
'name': os.name,
'address': os.address,
'country': os.country_code,
}


def has_coordinates(data: Dict) -> bool:
return bool(data.get('coordinates'))


def get_missing_required_fields(data: Dict) -> List[str]:
required_fields = ('name', 'address', 'country')
return [field for field in required_fields if not data.get(field)]


def has_all_required_fields(data: Dict) -> bool:
return len(get_missing_required_fields(data)) == 0


def has_some_required_fields(data: Dict) -> bool:
missing_count = len(get_missing_required_fields(data))
return 0 < missing_count < 3


def is_coordinates_without_all_required_fields(data: Dict) -> bool:
return has_coordinates(data) and not has_all_required_fields(data)
Loading
Loading