diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index 5831c913c..8d637997a 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -13,9 +13,13 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html #### Migrations * 0179_introduce_enable_v1_claims_flow.py - This migration introduces a new `enable_v1_claims_flow` feature flag that allows switching to the v1 claims flow. +* 0180_add_unit_to_partner_field.py - This migration added new field `unit` to `PartnerField` model. ### What's new * [OSDEV-2176](https://opensupplyhub.atlassian.net/browse/OSDEV-2176) - Added feature flag for v1 claims flow. +* [OSDEV-2065](https://opensupplyhub.atlassian.net/browse/OSDEV-2065) - Updated v1 production locations `POST/PATCH` endpoints to include partner fields: + * Added `unit` field to `PartnerField` model + * Added type validation for submitted partner fields ### Release instructions * Ensure that the following commands are included in the `post_deployment` command: diff --git a/src/django/api/admin.py b/src/django/api/admin.py index 46c61d326..ab71441e6 100644 --- a/src/django/api/admin.py +++ b/src/django/api/admin.py @@ -242,8 +242,8 @@ def get_ordering(self, request): class PartnerFieldAdmin(admin.ModelAdmin): - list_display = ('name', 'type', 'created_at') - search_fields = ('name', 'type') + list_display = ('name', 'type', 'unit', 'created_at') + search_fields = ('name', 'type', 'unit') readonly_fields = ('uuid', 'created_at', 'updated_at') diff --git a/src/django/api/migrations/0180_add_unit_to_partner_field.py b/src/django/api/migrations/0180_add_unit_to_partner_field.py new file mode 100644 index 000000000..6b0ab5c5f --- /dev/null +++ b/src/django/api/migrations/0180_add_unit_to_partner_field.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import uuid + +class Migration(migrations.Migration): + """ + Migration add unit field to PartnerField model. + """ + + dependencies = [ + ('api', '0179_introduce_enable_v1_claims_flow'), + ] + + operations = [ + migrations.AddField( + model_name='partnerfield', + name='unit', + field=models.CharField( + max_length=200, + blank=True, + help_text=('The partner field unit.') + ), + ), + ] diff --git a/src/django/api/models/partner_field.py b/src/django/api/models/partner_field.py index 65d5ede67..9678b3550 100644 --- a/src/django/api/models/partner_field.py +++ b/src/django/api/models/partner_field.py @@ -38,6 +38,10 @@ class Meta: blank=False, choices=TYPE_CHOICES, help_text=('The partner field type.')) + unit = models.CharField( + max_length=200, + blank=True, + help_text=('The partner field unit.')) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/src/django/api/moderation_event_actions/creation/location_contribution/processors/permission_processor.py b/src/django/api/moderation_event_actions/creation/location_contribution/processors/permission_processor.py index f68076109..edeb30f36 100644 --- a/src/django/api/moderation_event_actions/creation/location_contribution/processors/permission_processor.py +++ b/src/django/api/moderation_event_actions/creation/location_contribution/processors/permission_processor.py @@ -1,4 +1,4 @@ -from typing import Dict, List +from typing import Dict, List, Mapping, Tuple, Iterable from rest_framework import status @@ -12,53 +12,115 @@ class PermissionProcessor(ContributionProcessor): + TYPE_VALIDATORS = { + 'int': lambda value: isinstance(value, int) + and not isinstance(value, bool), + 'float': lambda value: isinstance(value, float) + and not isinstance(value, bool), + 'string': lambda value: isinstance(value, str), + 'object': lambda value: isinstance( + value, (dict, list) + ), + } + def process( self, - event_dto: CreateModerationEventDTO) -> CreateModerationEventDTO: + event_dto: CreateModerationEventDTO + ) -> CreateModerationEventDTO: - partner_field_names = PartnerField.objects \ - .values_list('name', flat=True) + raw = event_dto.raw_data or {} + if not raw: + return super().process(event_dto) - if event_dto.raw_data: - matching_partner_field_names = [ - key for key in event_dto.raw_data.keys() - if key in partner_field_names - ] + incoming_keys = set(raw.keys()) - if matching_partner_field_names: - contributor_partner_field_names = event_dto.contributor \ - .partner_fields.values_list('name', flat=True) + partner_fields_qs = PartnerField.objects \ + .filter(name__in=incoming_keys) \ + .values_list("name", "type") + partner_fields: Dict[str, str] = dict(partner_fields_qs) - unauthorized_partner_fields = [ - name for name in matching_partner_field_names - if name not in contributor_partner_field_names - ] + if not partner_fields: + return super().process(event_dto) - if unauthorized_partner_fields: - validation_errors = self.__transform_fields_errors( - unauthorized_partner_fields - ) - event_dto.errors = validation_errors - event_dto.status_code = status.HTTP_403_FORBIDDEN + # Permission validation. + contributor_allowed: set[str] = set( + event_dto.contributor.partner_fields.values_list("name", flat=True) + ) + requested_partner_field_names: set[str] = set(partner_fields.keys()) + unauthorized: set[str] = requested_partner_field_names \ + - contributor_allowed + + if unauthorized: + event_dto.errors = self.__transform_permission_errors(unauthorized) + event_dto.status_code = status.HTTP_403_FORBIDDEN + return event_dto + + # Type validation. + invalid_type_fields = self.__collect_invalid_type_fields( + raw, + partner_fields, + self.TYPE_VALIDATORS + ) + + if invalid_type_fields: + event_dto.errors = self.__transform_type_errors( + invalid_type_fields + ) + event_dto.status_code = status \ + .HTTP_422_UNPROCESSABLE_ENTITY - return event_dto + return event_dto return super().process(event_dto) @staticmethod - def __transform_fields_errors(fields_errors: List[str]) -> Dict: - validation_errors = { + def __transform_permission_errors( + fields_errors: Iterable[str] + ) -> Dict: + return { 'detail': APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR, - 'errors': [] - } - - for field_name in fields_errors: - validation_errors['errors'].append( + 'errors': [ { 'field': field_name, 'detail': 'You do not have permission ' - 'to contribute to this field.' + 'to contribute to this field.', } - ) + for field_name in fields_errors + ], + } + + @staticmethod + def __transform_type_errors( + invalid_type_fields: List[tuple] + ) -> Dict: + return { + 'detail': APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR, + 'errors': [ + { + 'field': name, + 'detail': f'Field {name} must be of type {expected}, ' + f'but received {type(value).__name__}', + } + for name, expected, value in invalid_type_fields + ], + } + + @staticmethod + def __collect_invalid_type_fields( + raw: Mapping[str, object], + partner_fields: Mapping[str, str], + validators: Mapping[str, callable], + ) -> List[Tuple[str, str, object]]: + + invalid_fields: List[Tuple[str, str, object]] = [] + + for name, field_type in partner_fields.items(): + value = raw.get(name) + + if value is not None and field_type in validators: + if not validators[field_type](value): + invalid_fields.append( + (name, field_type, value) + ) - return validation_errors + return invalid_fields