diff --git a/deployment/terraform/cdn.tf b/deployment/terraform/cdn.tf index 003b14727..e9d2375e2 100644 --- a/deployment/terraform/cdn.tf +++ b/deployment/terraform/cdn.tf @@ -674,6 +674,28 @@ resource "aws_cloudfront_distribution" "cdn" { max_ttl = 300 } + ordered_cache_behavior { + path_pattern = "/static/*jsoneditor/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + logging_config { include_cookies = false bucket = aws_s3_bucket.logs.bucket_domain_name diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index 984174ac3..a02a570cb 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -9,12 +9,21 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html * Product name: Open Supply Hub * Release date: November 29, 2025 +### Database changes + +#### Migrations +* 0186_add_json_schema_to_partner_field.py - This migration adds a `json_schema` JSONField to the `PartnerField` model, allowing administrators to define JSON schemas for validating object type partner fields. The field is optional and is used to validate the structure and content of contributed data when the partner field type is `object`. + +#### Schema changes +* [OSDEV-2266](https://opensupplyhub.atlassian.net/browse/OSDEV-2266) - The `PartnerField` model has been updated. Field `json_schema` added with a type JSONField. + ### Architecture/Environment changes * [OSDEV-2244](https://opensupplyhub.atlassian.net/browse/OSDEV-2244) - Added `backfill_isic_4_extended_fields.py` to insert `isic_4` to `api_extendedfield` table of RBA instance. Show `isic_4` field in `GET api/facilities` response. ### What's new * [OSDEV-2112](https://opensupplyhub.atlassian.net/browse/OSDEV-2112) - Moved "Recruitment Agency" (previously classified as a location type) under the "Office / HQ" location type as a processing type. Also introduced a new processing type, "Union Headquarters/Office", under the "Office / HQ" location type. This update affects both search and newly contributed data: from now on, "Union Headquarters/Office" and "Recruitment Agency" will appear under the "Office / HQ" location type when displayed in search dropdowns or shown on location profiles for **newly** added locations. * [OSDEV-2244](https://opensupplyhub.atlassian.net/browse/OSDEV-2244) - Implemented frontend formatting for the ISIC 4 extended field to display Section, Division, Group, and Class as separate labeled entries, building the full ISIC 4 hierarchy on production location profile pages. +* [OSDEV-2266](https://opensupplyhub.atlassian.net/browse/OSDEV-2266) - Added JSON schema validation support for object type partner fields. Partner fields with object type can now have an associated JSON schema that validates the structure and content of contributed data, providing detailed error messages for validation failures. ### Release instructions * Ensure that the following commands are included in the `post_deployment` command: @@ -87,11 +96,8 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html * [OSDEV-2259](https://opensupplyhub.atlassian.net/browse/OSDEV-2259) - Fixed an issue where the Company Phone field was being saved to the incorrect `office_phone_number` column instead of the `facility_phone_number` when submitting a claim. The Company Phone field now properly stores the value in the correct `facility_phone_number` column in `api_facilityclaim` table. * [OSDEV-2262](https://opensupplyhub.atlassian.net/browse/OSDEV-2262) - Prevented unintended submission of the last-step claim form when pressing Enter in an input while the Submit button is not focused. Updated `src/react/src/components/InitialClaimFlow/ClaimForm/ClaimForm.jsx` to remove implicit form submission and trigger Formik submission explicitly via the Submit button, aligning with Material UI semantics. * [OSDEV-2231](https://opensupplyhub.atlassian.net/browse/OSDEV-2231) - Fixed Django admin panel not displaying location type values for claims when they didn't match the predefined taxonomy. Removed the restrictive `choices` constraint to allow all location types to be visible. -<<<<<<< HEAD * [OSDEV-2260](https://opensupplyhub.atlassian.net/browse/OSDEV-2260) - Removed the unnecessary `facility_type` checks in the `facilities_view_set.py` to support proper saving of the data. The function assumed that the value is an array and that caused errors in the creation of extended fields. On front-end data format has been changed to send array values as a pipe-separated (`|`) string instead of a comma-separated one. The `extended_fields.py` file has been updated to correctly parse this new pipe-delimited format, ensuring data is handled correctly. -======= * [OSDEV-2264](https://opensupplyhub.atlassian.net/browse/OSDEV-2264) - Fixed an issue where the processing type field was being saved to the database (`api_extendedfield` table) as an empty list. Now it's not saved if the list is empty. ->>>>>>> d009243c ([OSDEV-2264] Prevent saving empty lists for facility production types (#805)) ### What's new * [OSDEV-2200](https://opensupplyhub.atlassian.net/browse/OSDEV-2200) - Implements a new claim introduction page for the new facility claiming process, accessible via `/claim/:osId`, which can be enabled or activated through a feature flag. diff --git a/src/django/api/admin.py b/src/django/api/admin.py index 6cbe21a2d..e5e16ec62 100644 --- a/src/django/api/admin.py +++ b/src/django/api/admin.py @@ -17,6 +17,7 @@ from waffle.models import Flag, Sample, Switch from waffle.admin import FlagAdmin, SampleAdmin, SwitchAdmin from ckeditor.widgets import CKEditorWidget +from jsoneditor.forms import JSONEditor from api import models @@ -249,10 +250,22 @@ class PartnerFieldAdminForm(forms.ModelForm): required=False, widget=CKEditorWidget() ) + json_schema = forms.JSONField( + required=False, + widget=JSONEditor( + init_options={"mode": "code", "modes": ["code", "tree"]}, + attrs={ + 'style': 'width: 800px; height: 400px;' + } + ) + ) class Meta: model = PartnerField - fields = ['name', 'type', 'unit', 'label', 'source_by'] + fields = ['name', 'type', 'unit', 'label', 'source_by', 'json_schema'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) class PartnerFieldAdmin(admin.ModelAdmin): @@ -261,6 +274,12 @@ class PartnerFieldAdmin(admin.ModelAdmin): search_fields = ('name', 'type', 'label', 'unit', 'source_by') readonly_fields = ('uuid', 'created_at', 'updated_at') + class Media: + js = ( + 'admin/js/jquery.init.js', + 'admin/js/partner_field_admin.js', + ) + class EmailAddressAdmin(admin.ModelAdmin): list_display = ('email', 'user', 'primary', 'verified') diff --git a/src/django/api/migrations/0186_add_json_schema_to_partner_field.py b/src/django/api/migrations/0186_add_json_schema_to_partner_field.py new file mode 100644 index 000000000..281606421 --- /dev/null +++ b/src/django/api/migrations/0186_add_json_schema_to_partner_field.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.17 on 2025-01-18 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0185_add_source_by_to_partner_field'), + ] + + operations = [ + migrations.AddField( + model_name='partnerfield', + name='json_schema', + field=models.JSONField(blank=True, null=True, help_text='JSON Schema for validating object type partner fields. Used when type is "object".'), + ), + ] + diff --git a/src/django/api/models/partner_field.py b/src/django/api/models/partner_field.py index 72e32630c..5474740d7 100644 --- a/src/django/api/models/partner_field.py +++ b/src/django/api/models/partner_field.py @@ -60,6 +60,15 @@ class Meta: ) ) + json_schema = models.JSONField( + blank=True, + null=True, + help_text=( + 'JSON Schema for validating object type partner fields. ' + 'Used when type is "object".' + ) + ) + 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/location_contribution.py b/src/django/api/moderation_event_actions/creation/location_contribution/location_contribution.py index 3033bce43..c45a40419 100644 --- a/src/django/api/moderation_event_actions/creation/location_contribution/location_contribution.py +++ b/src/django/api/moderation_event_actions/creation/location_contribution/location_contribution.py @@ -9,6 +9,8 @@ import ProductionLocationDataProcessor from api.moderation_event_actions.creation.location_contribution \ .processors.geocoding_processor import GeocodingProcessor +from api.moderation_event_actions.creation.location_contribution.processors \ + .partner_field_type_processor import PartnerFieldTypeProcessor from api.moderation_event_actions.creation.location_contribution \ .processors.permission_processor import PermissionProcessor from api.moderation_event_actions.creation.dtos.create_moderation_event_dto \ @@ -37,6 +39,7 @@ def serialize( def __setup_location_data_processors() -> ContributionProcessor: location_data_processors = ( PermissionProcessor(), + PartnerFieldTypeProcessor(), SourceProcessor(), ProductionLocationDataProcessor(), GeocodingProcessor() diff --git a/src/django/api/moderation_event_actions/creation/location_contribution/processors/partner_field_type_processor.py b/src/django/api/moderation_event_actions/creation/location_contribution/processors/partner_field_type_processor.py new file mode 100644 index 000000000..754e864b2 --- /dev/null +++ b/src/django/api/moderation_event_actions/creation/location_contribution/processors/partner_field_type_processor.py @@ -0,0 +1,218 @@ +import json +from typing import Dict, List, Mapping, Optional, Tuple + +import jsonschema +from jsonschema.exceptions import ValidationError as JsonSchemaValidationError +from jsonschema.validators import Draft202012Validator +from rest_framework import status + +from api.moderation_event_actions.creation.location_contribution \ + .processors.contribution_processor import ContributionProcessor +from api.moderation_event_actions.creation.dtos.create_moderation_event_dto \ + import CreateModerationEventDTO +from api.models.partner_field import PartnerField +from api.constants import APIV1CommonErrorMessages + + +class PartnerFieldTypeProcessor(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) + ), + } + + FORMAT_CHECKER = jsonschema.FormatChecker() + + def process( + self, + event_dto: CreateModerationEventDTO + ) -> CreateModerationEventDTO: + + raw = event_dto.raw_data or {} + if not raw: + return super().process(event_dto) + + incoming_keys = set(raw.keys()) + + partner_fields_qs = PartnerField.objects \ + .filter(name__in=incoming_keys) \ + .values("name", "type", "json_schema") + partner_fields_data: Dict[str, Dict] = { + field["name"]: { + "type": field["type"], + "json_schema": ( + PartnerFieldTypeProcessor.__parse_json_schema( + field["json_schema"] + ) + ) + } + for field in partner_fields_qs + } + + if not partner_fields_data: + return super().process(event_dto) + + validation_errors = self.__validate_partner_fields( + raw, + partner_fields_data + ) + + if validation_errors: + event_dto.errors = self.__transform_validation_errors( + validation_errors + ) + event_dto.status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + return event_dto + + return super().process(event_dto) + + @staticmethod + def __validate_partner_fields( + raw: Mapping[str, object], + partner_fields_data: Dict[str, Dict] + ) -> List[Tuple[str, str]]: + + validation_errors: List[Tuple[str, str]] = [] + + for field_name, field_info in partner_fields_data.items(): + value = raw.get(field_name) + if value is None: + continue + + field_type = field_info.get("type") + json_schema = field_info.get("json_schema") + + error = PartnerFieldTypeProcessor.__validate_single_field( + field_name, value, field_type, json_schema + ) + if error: + validation_errors.append(error) + + return validation_errors + + @staticmethod + def __validate_single_field( + field_name: str, + value: object, + field_type: str, + json_schema: object + ) -> Optional[Tuple[str, str]]: + + use_json_schema = ( + field_type == PartnerField.OBJECT and json_schema + ) + + if use_json_schema: + return PartnerFieldTypeProcessor.__validate_with_json_schema( + field_name, value, json_schema + ) + + return PartnerFieldTypeProcessor.__validate_with_type_validator( + field_name, value, field_type + ) + + @staticmethod + def __validate_with_json_schema( + field_name: str, + value: object, + json_schema: object + ) -> Optional[Tuple[str, str]]: + + try: + validator = Draft202012Validator( + schema=json_schema, + format_checker=PartnerFieldTypeProcessor.FORMAT_CHECKER + ) + validator.validate(instance=value) + return None + except JsonSchemaValidationError as e: + error_message = PartnerFieldTypeProcessor \ + .__format_json_schema_error( + e + ) + return (field_name, error_message) + except Exception as e: + return (field_name, f"Schema validation error: {str(e)}") + + @staticmethod + def __format_json_schema_error( + error: JsonSchemaValidationError + ) -> str: + + error_message = error.message + error_path = PartnerFieldTypeProcessor.__extract_error_path(error) + + if error_path: + return f"{error_path}: {error_message}" + + return error_message + + @staticmethod + def __extract_error_path( + error: JsonSchemaValidationError + ) -> Optional[str]: + + if hasattr(error, 'absolute_path') and error.absolute_path: + return ".".join(str(p) for p in error.absolute_path) + + if hasattr(error, 'path') and error.path: + return ".".join(str(p) for p in error.path) + + return None + + @staticmethod + def __validate_with_type_validator( + field_name: str, + value: object, + field_type: str + ) -> Optional[Tuple[str, str]]: + + if field_type not in PartnerFieldTypeProcessor.TYPE_VALIDATORS: + return None + + validator = PartnerFieldTypeProcessor.TYPE_VALIDATORS[field_type] + if validator(value): + return None + + return ( + field_name, + f'Field {field_name} must be {field_type}, ' + f'not {type(value).__name__}.' + ) + + @staticmethod + def __parse_json_schema(json_schema: object) -> Optional[dict]: + if json_schema is None: + return None + + if isinstance(json_schema, dict): + return json_schema + + if isinstance(json_schema, str): + try: + return json.loads(json_schema) + except (json.JSONDecodeError, TypeError): + return None + + return None + + @staticmethod + def __transform_validation_errors( + validation_errors: List[Tuple[str, str]] + ) -> Dict: + return { + 'detail': APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR, + 'errors': [ + { + 'field': field_name, + 'detail': error_message, + } + for field_name, error_message in validation_errors + ], + } 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 761588d62..f72c69c86 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, Mapping, Tuple, Iterable +from typing import Dict, Iterable from rest_framework import status @@ -12,17 +12,6 @@ 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 @@ -34,19 +23,20 @@ def process( incoming_keys = set(raw.keys()) - partner_fields_qs = PartnerField.objects \ - .filter(name__in=incoming_keys) \ - .values_list("name", "type") - partner_fields: Dict[str, str] = dict(partner_fields_qs) + partner_field_names = list( + PartnerField.objects + .filter(name__in=incoming_keys) + .values_list("name", flat=True) + ) - if not partner_fields: + if not partner_field_names: return super().process(event_dto) # 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()) + requested_partner_field_names: set[str] = set(partner_field_names) unauthorized: set[str] = requested_partner_field_names \ - contributor_allowed @@ -55,22 +45,6 @@ def process( 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 super().process(event_dto) @staticmethod @@ -88,41 +62,3 @@ def __transform_permission_errors( 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 {expected}, ' - f'not {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 invalid_fields diff --git a/src/django/api/static/admin/js/partner_field_admin.js b/src/django/api/static/admin/js/partner_field_admin.js new file mode 100644 index 000000000..b035c1b6d --- /dev/null +++ b/src/django/api/static/admin/js/partner_field_admin.js @@ -0,0 +1,63 @@ +(function($) { + 'use strict'; + + function findJsonSchemaFieldRow() { + const jsonSchemaField = $('#id_json_schema'); + + if (jsonSchemaField.length) { + return jsonSchemaField.closest('.field-json_schema, tr, .form-row').first(); + } + + return $('.field-json_schema').first(); + } + + function toggleJsonSchemaField() { + const typeField = $('#id_type'); + const jsonSchemaFieldRow = findJsonSchemaFieldRow(); + + if (!typeField.length) { + return false; + } + + if (!jsonSchemaFieldRow.length) { + return false; + } + + const currentType = typeField.val(); + + if (currentType === 'object') { + jsonSchemaFieldRow.show(); + return true; + } else { + jsonSchemaFieldRow.hide(); + return true; + } + } + + function setupJsonSchemaToggle() { + const typeField = $('#id_type'); + const jsonSchemaField = $('#id_json_schema'); + + if (!typeField.length || !jsonSchemaField.length) { + return false; + } + + const jsonSchemaFieldRow = findJsonSchemaFieldRow(); + if (!jsonSchemaFieldRow.length) { + return false; + } + + toggleJsonSchemaField(); + + typeField.on('change', function() { + toggleJsonSchemaField(); + }); + + return true; + } + + $(document).ready(function() { + setupJsonSchemaToggle(); + }); +})(django.jQuery || jQuery); + diff --git a/src/django/api/tests/test_location_contribution_strategy.py b/src/django/api/tests/test_location_contribution_strategy.py index 8d4338ec3..38c1fc0f5 100644 --- a/src/django/api/tests/test_location_contribution_strategy.py +++ b/src/django/api/tests/test_location_contribution_strategy.py @@ -1872,3 +1872,367 @@ def test_partner_field_object_type_validation(self): 'Field object_field must be object, not str.', detail ) + + def test_partner_field_json_schema_with_valid_data(self): + + existing_location_user_email = 'test7@example.com' + existing_location_user_password = '4567test' + existing_location_user = User.objects.create( + email=existing_location_user_email + ) + existing_location_user.set_password( + existing_location_user_password + ) + existing_location_user.save() + + EmailAddress.objects.create( + user=existing_location_user, + email=existing_location_user_email, + verified=True, + primary=True + ) + + json_schema = { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'age': {'type': 'integer'}, + 'email': { + 'type': 'string', + 'format': 'email' + } + }, + 'required': ['name', 'age'] + } + + schema_field = PartnerField.objects.create( + name='schema_field', + type=PartnerField.OBJECT, + label='Schema Field', + json_schema=json_schema + ) + + existing_location_contributor = Contributor.objects.create( + admin=existing_location_user, + name='test contributor 7', + contrib_type=Contributor.OTHER_CONTRIB_TYPE, + ) + + existing_location_contributor.partner_fields.add(schema_field) + existing_location_contributor.save() + + list = FacilityList.objects.create( + header='header', file_name='one', name='New List Test' + ) + source = Source.objects.create( + source_type=Source.LIST, + facility_list=list, + contributor=existing_location_contributor + ) + list_item = FacilityListItem.objects.create( + name='Gamma Tech Manufacturing Plant', + address='1574 Quantum Avenue, Building 4B, Technopolis', + country_code='YT', + sector=['Apparel'], + row_index=1, + status=FacilityListItem.CONFIRMED_MATCH, + source=source + ) + production_location = Facility.objects.create( + name=list_item.name, + address=list_item.address, + country_code=list_item.country_code, + location=Point(0, 0), + created_from=list_item + ) + + input_data = { + 'source': 'API', + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US', + 'schema_field': { + 'name': 'John Doe', + 'age': 30, + 'email': 'john@example.com' + }, + 'coordinates': { + 'lat': 51.078389, + 'lng': 16.978477 + } + } + + event_dto = CreateModerationEventDTO( + contributor=existing_location_contributor, + raw_data=input_data, + request_type=ModerationEvent.RequestType.CREATE.value, + os=production_location + ) + + result = self.moderation_event_creator.perform_event_creation( + event_dto + ) + self.assertEqual(result.status_code, status.HTTP_202_ACCEPTED) + self.assertIsNotNone(result.moderation_event) + + def test_partner_field_json_schema_with_invalid_data(self): + existing_location_user_email = 'test8@example.com' + existing_location_user_password = '4567test' + existing_location_user = User.objects.create( + email=existing_location_user_email + ) + existing_location_user.set_password( + existing_location_user_password + ) + existing_location_user.save() + + EmailAddress.objects.create( + user=existing_location_user, + email=existing_location_user_email, + verified=True, + primary=True + ) + + json_schema = { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'age': {'type': 'integer'}, + 'email': {'type': 'string', 'format': 'email'} + }, + 'required': ['name', 'age'] + } + + schema_field = PartnerField.objects.create( + name='schema_field', + type=PartnerField.OBJECT, + label='Schema Field', + json_schema=json_schema + ) + + existing_location_contributor = Contributor.objects.create( + admin=existing_location_user, + name='test contributor 8', + contrib_type=Contributor.OTHER_CONTRIB_TYPE, + ) + + existing_location_contributor.partner_fields.add(schema_field) + existing_location_contributor.save() + + list = FacilityList.objects.create( + header='header', file_name='one', name='New List Test' + ) + source = Source.objects.create( + source_type=Source.LIST, + facility_list=list, + contributor=existing_location_contributor + ) + list_item = FacilityListItem.objects.create( + name='Gamma Tech Manufacturing Plant', + address='1574 Quantum Avenue, Building 4B, Technopolis', + country_code='YT', + sector=['Apparel'], + row_index=1, + status=FacilityListItem.CONFIRMED_MATCH, + source=source + ) + production_location = Facility.objects.create( + name=list_item.name, + address=list_item.address, + country_code=list_item.country_code, + location=Point(0, 0), + created_from=list_item + ) + + input_data_missing_required = { + 'source': 'API', + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US', + 'schema_field': { + 'name': 'John Doe' + }, + 'coordinates': { + 'lat': 51.078389, + 'lng': 16.978477 + } + } + + event_dto = CreateModerationEventDTO( + contributor=existing_location_contributor, + raw_data=input_data_missing_required, + request_type=ModerationEvent.RequestType.CREATE.value, + os=production_location + ) + + result = self.moderation_event_creator.perform_event_creation( + event_dto + ) + self.assertEqual( + result.status_code, + status.HTTP_422_UNPROCESSABLE_ENTITY + ) + self.assertIn('errors', result.errors) + self.assertEqual(len(result.errors['errors']), 1) + self.assertEqual(result.errors['errors'][0]['field'], 'schema_field') + self.assertIn('age', result.errors['errors'][0]['detail']) + + input_data_wrong_type = { + 'source': 'API', + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US', + 'schema_field': { + 'name': 123, + 'age': 30 + }, + 'coordinates': { + 'lat': 51.078389, + 'lng': 16.978477 + } + } + + event_dto = CreateModerationEventDTO( + contributor=existing_location_contributor, + raw_data=input_data_wrong_type, + request_type=ModerationEvent.RequestType.CREATE.value, + os=production_location + ) + + result = self.moderation_event_creator.perform_event_creation( + event_dto + ) + self.assertEqual( + result.status_code, + status.HTTP_422_UNPROCESSABLE_ENTITY + ) + self.assertIn('errors', result.errors) + self.assertEqual(len(result.errors['errors']), 1) + self.assertEqual(result.errors['errors'][0]['field'], 'schema_field') + self.assertIn('name', result.errors['errors'][0]['detail']) + + def test_partner_field_json_schema_with_nested_errors(self): + existing_location_user_email = 'test9@example.com' + existing_location_user_password = '4567test' + existing_location_user = User.objects.create( + email=existing_location_user_email + ) + existing_location_user.set_password( + existing_location_user_password + ) + existing_location_user.save() + + EmailAddress.objects.create( + user=existing_location_user, + email=existing_location_user_email, + verified=True, + primary=True + ) + + json_schema = { + 'type': 'object', + 'properties': { + 'user': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'contact': { + 'type': 'object', + 'properties': { + 'email': { + 'type': 'string', + 'format': 'email' + } + } + } + } + } + }, + 'required': ['user'] + } + + schema_field = PartnerField.objects.create( + name='nested_schema_field', + type=PartnerField.OBJECT, + label='Nested Schema Field', + json_schema=json_schema + ) + + existing_location_contributor = Contributor.objects.create( + admin=existing_location_user, + name='test contributor 9', + contrib_type=Contributor.OTHER_CONTRIB_TYPE, + ) + + existing_location_contributor.partner_fields.add(schema_field) + existing_location_contributor.save() + + list = FacilityList.objects.create( + header='header', file_name='one', name='New List Test' + ) + source = Source.objects.create( + source_type=Source.LIST, + facility_list=list, + contributor=existing_location_contributor + ) + list_item = FacilityListItem.objects.create( + name='Gamma Tech Manufacturing Plant', + address='1574 Quantum Avenue, Building 4B, Technopolis', + country_code='YT', + sector=['Apparel'], + row_index=1, + status=FacilityListItem.CONFIRMED_MATCH, + source=source + ) + production_location = Facility.objects.create( + name=list_item.name, + address=list_item.address, + country_code=list_item.country_code, + location=Point(0, 0), + created_from=list_item + ) + + input_data = { + 'source': 'API', + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US', + 'nested_schema_field': { + 'user': { + 'name': 'John Doe', + 'contact': { + 'email': 'invalid-email' + } + } + }, + 'coordinates': { + 'lat': 51.078389, + 'lng': 16.978477 + } + } + + event_dto = CreateModerationEventDTO( + contributor=existing_location_contributor, + raw_data=input_data, + request_type=ModerationEvent.RequestType.CREATE.value, + os=production_location + ) + + result = self.moderation_event_creator.perform_event_creation( + event_dto + ) + self.assertEqual( + result.status_code, + status.HTTP_422_UNPROCESSABLE_ENTITY + ) + self.assertIn('errors', result.errors) + self.assertEqual(len(result.errors['errors']), 1) + self.assertEqual( + result.errors['errors'][0]['field'], + 'nested_schema_field' + ) + + detail = result.errors['errors'][0]['detail'] + self.assertTrue( + 'email' in detail.lower() or 'contact' in detail.lower() + ) diff --git a/src/django/oar/settings.py b/src/django/oar/settings.py index f4afdaf39..73a4edc18 100644 --- a/src/django/oar/settings.py +++ b/src/django/oar/settings.py @@ -136,6 +136,7 @@ 'ecsmanage', 'django_bleach', 'ckeditor', + 'jsoneditor', ] # For allauth diff --git a/src/django/requirements.txt b/src/django/requirements.txt index 0d243d69c..280404c09 100644 --- a/src/django/requirements.txt +++ b/src/django/requirements.txt @@ -46,3 +46,6 @@ google-auth-httplib2==0.2.0 google-auth-oauthlib==1.2.1 django-bleach==2.0.0 stripe==12.2.0 +jsonschema==4.17.3 +rfc3987==1.3.8 +django-jsoneditor==0.2.4