diff --git a/deployment/clear_opensearch/clear_opensearch.sh.tpl b/deployment/clear_opensearch/clear_opensearch.sh.tpl index 817f141d1..b2ba93770 100644 --- a/deployment/clear_opensearch/clear_opensearch.sh.tpl +++ b/deployment/clear_opensearch/clear_opensearch.sh.tpl @@ -5,11 +5,6 @@ # new index mappings or to refresh the OpenSearch cluster after # restarting Logstash, with the lock files deleted from EFS # storage for each pipeline. -# -# The script can be modified to delete only specific templates, -# indexes, and Logstash pipeline lock files, allowing for a more -# targeted refresh without affecting the entire OpenSearch cluster. -# This can help speed up the deployment process of new changes. echo -e "\nDelete the custom OpenSearch indexes\n" curl -X DELETE https://$OPENSEARCH_DOMAIN/production-locations --aws-sigv4 "aws:amz:eu-west-1:es" --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" diff --git a/deployment/environments/terraform-development.tfvars b/deployment/environments/terraform-development.tfvars index 5b0fa3cc0..8a593143b 100644 --- a/deployment/environments/terraform-development.tfvars +++ b/deployment/environments/terraform-development.tfvars @@ -27,8 +27,8 @@ rds_deletion_protection = true app_ecs_desired_count = "1" app_ecs_deployment_min_percent = "100" app_ecs_deployment_max_percent = "400" -app_fargate_cpu = "1024" -app_fargate_memory = "8192" +app_fargate_cpu = "512" +app_fargate_memory = "1024" app_dd_fargate_cpu = "2048" app_dd_fargate_memory = "4096" diff --git a/deployment/environments/terraform-preprod.tfvars b/deployment/environments/terraform-preprod.tfvars index 84bb7337d..380714645 100644 --- a/deployment/environments/terraform-preprod.tfvars +++ b/deployment/environments/terraform-preprod.tfvars @@ -16,17 +16,17 @@ bastion_instance_type = "t3.nano" rds_allocated_storage = "256" rds_engine_version = "12" rds_parameter_group_family = "postgres12" -rds_instance_type = "db.m6in.8xlarge" +rds_instance_type = "db.m6in.4xlarge" rds_database_identifier = "opensupplyhub-enc-pp" rds_database_name = "opensupplyhub" rds_multi_az = false rds_storage_encrypted = true -app_ecs_desired_count = "12" +app_ecs_desired_count = "10" app_ecs_deployment_min_percent = "100" app_ecs_deployment_max_percent = "400" app_fargate_cpu = "2048" -app_fargate_memory = "8192" +app_fargate_memory = "4096" cli_fargate_cpu = "2048" cli_fargate_memory = "8192" diff --git a/deployment/environments/terraform-production.tfvars b/deployment/environments/terraform-production.tfvars index 19748c6b0..933cc1ebb 100644 --- a/deployment/environments/terraform-production.tfvars +++ b/deployment/environments/terraform-production.tfvars @@ -15,18 +15,18 @@ bastion_instance_type = "t3.nano" rds_allocated_storage = "256" rds_engine_version = "12" rds_parameter_group_family = "postgres12" -rds_instance_type = "db.m6in.8xlarge" +rds_instance_type = "db.m6in.4xlarge" rds_database_identifier = "opensupplyhub-enc-prd" rds_database_name = "opensupplyhub" rds_multi_az = false rds_storage_encrypted = true -app_ecs_desired_count = "12" +app_ecs_desired_count = "10" app_ecs_deployment_min_percent = "100" app_ecs_deployment_max_percent = "400" app_ecs_grace_period_seconds = "420" app_fargate_cpu = "2048" -app_fargate_memory = "8192" +app_fargate_memory = "4096" cli_fargate_cpu = "2048" cli_fargate_memory = "8192" diff --git a/deployment/environments/terraform-staging.tfvars b/deployment/environments/terraform-staging.tfvars index cec644e83..fd586e28c 100644 --- a/deployment/environments/terraform-staging.tfvars +++ b/deployment/environments/terraform-staging.tfvars @@ -24,7 +24,7 @@ app_ecs_desired_count = "4" app_ecs_deployment_min_percent = "100" app_ecs_deployment_max_percent = "400" app_fargate_cpu = "1024" -app_fargate_memory = "8192" +app_fargate_memory = "2048" cli_fargate_cpu = "1024" cli_fargate_memory = "8192" diff --git a/deployment/environments/terraform-test.tfvars b/deployment/environments/terraform-test.tfvars index a0c80cbbb..60c7fa0f0 100644 --- a/deployment/environments/terraform-test.tfvars +++ b/deployment/environments/terraform-test.tfvars @@ -32,7 +32,7 @@ app_ecs_desired_count = "2" app_ecs_deployment_min_percent = "100" app_ecs_deployment_max_percent = "400" app_fargate_cpu = "2048" -app_fargate_memory = "8192" +app_fargate_memory = "4096" app_dd_fargate_cpu = "4096" app_dd_fargate_memory = "8192" diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index 5911e5326..2d01ea885 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -15,6 +15,9 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html #### Scheme changes ### Code/API changes +* [OSDEV-1409](https://opensupplyhub.atlassian.net/browse/OSDEV-1409) - Introduced a new PATCH `/api/v1/moderation-events/{moderation_id}/production-locations/{os_id}/` endpoint. This endpoint allows the creation of a new contribution for an existing production location based on the provided moderation event. +* [OSDEV-1336](https://opensupplyhub.atlassian.net/browse/OSDEV-1336) - Introduced a new PATCH `/api/v1/production-locations/{os_id}/` endpoint based on the API v1 specification. This endpoint allows the creation of a new moderation event for updating the production location with the given details. Basically, the endpoint can be used to contribute to an existing location. +* [OSDEV-1336](https://opensupplyhub.atlassian.net/browse/OSDEV-1336) - Dynamic mapping for the new fields in the `moderation-events` index has been disabled for those that don't have an explicit mapping defined. This change helps avoid indexing conflicts, such as when a field is initially indexed with one data type (e.g., long), but later an entry with a different data type for the same field is indexed, causing the entire entry to fail indexing. After this change, fields with an explicit mapping will be indexed, while other fields will not be indexed or searchable, but will still be displayed in the document. ### Architecture/Environment changes @@ -23,11 +26,15 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html * [OSDEV-1493](https://opensupplyhub.atlassian.net/browse/OSDEV-1493) - Fixed an issue where the backend sorts countries not by `name` but by their `alpha-2 codes` in `GET /api/v1/moderation-events/` endpoint. ### What's new +* [OSDEV-1376](https://opensupplyhub.atlassian.net/browse/OSDEV-1376) - Updated automated emails for closure reports (report_result) to remove the term "Rejected" for an improved user experience. Added link to Closure Policy and instructions for submitting a Reopening Report to make the process easier to understand for users. +* [OSDEV-1383](https://opensupplyhub.atlassian.net/browse/OSDEV-1383) - Edited text of the automated email that notifies a contributor when one of their facilities has been claimed. The new text provides more information to the contributor to understand the claim process and how they can encourage more of their facilities to claim their profile. +* [OSDEV-1474](https://opensupplyhub.atlassian.net/browse/OSDEV-1474) - Added contributor type value to response of `/api/contributors/` endpoint. ### Release instructions: * Ensure that the following commands are included in the `post_deployment` command: * `migrate` * `reindex_database` +* Run `[Release] Deploy` pipeline for the target environment with the flag `Clear the custom OpenSearch indexes and templates` set to true - to refresh the index mappings for the `moderation-events` index after disabling dynamic mapping for the new fields that don't have an explicit mapping defined. The `production-locations` will also be affected since it will clean all of our custom indexes and templates within the OpenSearch cluster ## Release 1.26.0 @@ -74,6 +81,20 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html ### Architecture/Environment changes * [OSDEV-1170](https://opensupplyhub.atlassian.net/browse/OSDEV-1170) - Added the ability to automatically create a dump from the latest shared snapshot of the anonymized database from Production environment for use in the Test and Pre-Prod environments. * In light of recent instances(on 12/03/2024 UTC and 12/04/2024 UTC) where the current RDS disk storage space limit was reached in Production, the RDS storage size has been increased to `256 GB` in the Production, Test, and Pre-prod environments to accommodate the processing of larger volumes of data. The configurations for the Test and Pre-prod environments have also been updated to maintain parity with the Production environment. +* Right-sized the resources for Django containers across all environments and the RDS instance in the Production and Preprod environments. This will result in a savings of approximately $2,481. The following changes have been made: + - Production: + - RDS instance type was changed from `db.m6in.8xlarge` to `db.m6in.4xlarge`. + - ECS tasks for Django containers: the number was reduced from `12` to `10`, and memory was reduced from `8GB` to `4GB`. + - Preprod: + - RDS instance type was changed from `db.m6in.8xlarge` to `db.m6in.4xlarge`. + - ECS tasks for Django containers: the number was reduced from `12` to `10`, and memory was reduced from `8GB` to `4GB`. + - These changes were made to maintain parity with the Production environment, as it is a copy of that environment. + - Staging: + - ECS tasks for Django containers: memory was reduced from `8GB` to `2GB`. + - Test: + - ECS tasks for Django containers: memory was reduced from `8GB` to `4GB`. + - Development: + - ECS tasks for Django containers: memory was reduced from `8GB` to `1GB`, and CPU was reduced from `1 vCPU` to `0.5 vCPU`. ### Bugfix * [OSDEV-1388](https://opensupplyhub.atlassian.net/browse/OSDEV-1388) - The waiter from boto3 cannot wait more than half an hour so we replaced it with our own. @@ -91,7 +112,7 @@ This issue has been fixed by adding additional requests to delete the appropriat * Ensure that the following commands are included in the `post_deployment` command: * `migrate` * `reindex_database` -* Run `[Release] Deploy` pipeline for the target environment with the flag `Clear custom OpenSearch indexes and templates` set to true - to refresh the index mappings for the `production-locations` and `moderation-events` indexes after fixing the process of clearing the custom OpenSearch indexes. +* Run `[Release] Deploy` pipeline for the target environment with the flag `Clear the custom OpenSearch indexes and templates` set to true - to refresh the index mappings for the `production-locations` and `moderation-events` indexes after fixing the process of clearing the custom OpenSearch indexes. It will clean all of our custom indexes and templates within the OpenSearch cluster. ## Release 1.25.0 diff --git a/src/django/api/constants.py b/src/django/api/constants.py index 96170b2f8..51799cfb5 100644 --- a/src/django/api/constants.py +++ b/src/django/api/constants.py @@ -218,6 +218,8 @@ class APIV1CommonErrorMessages: 'Open Supply Hub is undergoing maintenance and not accepting new data ' 'at the moment. Please try again in a few minutes.' ) + LOCATION_NOT_FOUND = 'The location with the given id was not found.' + LOCATION_ID_NOT_VALID = 'The value must be a valid id.' class APIV1LocationContributionErrorMessages: @@ -233,6 +235,12 @@ def invalid_data_type_error(data_type: str) -> str: f'but got {data_type}.') +class APIV1ModerationEventErrorMessages: + EVENT_NOT_FOUND = 'Moderation event not found.' + EVENT_NOT_PENDING = 'The moderation event should be in PENDING status.' + INVALID_UUID_FORMAT = 'Invalid UUID format.' + + # If the error isn’t field-specific, the non_field_errors key will be used # for issues spanning multiple fields or related to the overall data # object. @@ -242,3 +250,13 @@ def invalid_data_type_error(data_type: str) -> str: class APIV1LocationContributionKeys: LNG = 'lng' LAT = 'lat' + + +class APIV1MatchTypes: + NEW_PRODUCTION_LOCATION = 'moderation_event_new_production_location' + CONFIRMED_MATCH = 'moderation_event_confirmed_match' + + +LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX = ( + '[API V1 Location Contribution Approval]' +) diff --git a/src/django/api/moderation_event_actions/approval/add_production_location.py b/src/django/api/moderation_event_actions/approval/add_production_location.py index 4812686b8..c444c0746 100644 --- a/src/django/api/moderation_event_actions/approval/add_production_location.py +++ b/src/django/api/moderation_event_actions/approval/add_production_location.py @@ -1,267 +1,58 @@ import logging -from datetime import datetime -from typing import Dict, KeysView, Type, Union - -from django.contrib.gis.geos import Point -from django.db import transaction from django.utils import timezone -from api.constants import ProcessingAction -from api.extended_fields import create_extendedfields_for_single_item -from api.models.contributor.contributor import Contributor -from api.models.extended_field import ExtendedField +from api.constants import ( + LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX, + APIV1MatchTypes, +) from api.models.facility.facility import Facility from api.models.facility.facility_list_item import FacilityListItem -from api.models.facility.facility_list_item_temp import FacilityListItemTemp from api.models.facility.facility_match import FacilityMatch -from api.models.facility.facility_match_temp import FacilityMatchTemp from api.models.moderation_event import ModerationEvent -from api.models.source import Source -from api.moderation_event_actions.approval.event_approval_strategy import ( - EventApprovalStrategy, -) -from api.views.fields.create_nonstandard_fields import ( - create_nonstandard_fields, +from api.moderation_event_actions.approval.event_approval_template import ( + EventApprovalTemplate, ) from api.os_id import make_os_id log = logging.getLogger(__name__) -class AddProductionLocation(EventApprovalStrategy): - ''' - Class defines the strategy for processing a moderation event for adding a - production location. - ''' - +class AddProductionLocation(EventApprovalTemplate): def __init__(self, moderation_event: ModerationEvent) -> None: - self.__event = moderation_event - - def process_moderation_event(self) -> FacilityListItem: - with transaction.atomic(): - data: Dict = self.__event.cleaned_data - log.info(f'[Moderation Event] Processing event with data: {data}') - - contributor: Contributor = self.__event.contributor - log.info(f'[Moderation Event] Contributor: {contributor}') - - source: Source = self.__create_source(contributor) - log.info(f'[Moderation Event] Source created. Id: {source.id}') - - header_row_keys: KeysView[str] = data["raw_json"].keys() - create_nonstandard_fields(header_row_keys, contributor) - log.info('[Moderation Event] Nonstandard fields created.') - - header_str: str = ','.join(header_row_keys) - item: FacilityListItem = self.__create_facility_list_item( - source, data, header_str, FacilityListItem.MATCHED - ) - log.info( - f'[Moderation Event] FacilityListItem created. Id: ' - f'{item.id}' - ) - - create_extendedfields_for_single_item(item, data["fields"]) - log.info('[Moderation Event] Extended fields created.') - - self.__set_geocoded_location(item, data, self.__event) - log.info('[Moderation Event] Geocoded location set.') - - facility_id = make_os_id(item.country_code) - log.info(f'[Moderation Event] Facility ID created: {facility_id}') + super().__init__(moderation_event) - self.__create_new_facility(item, facility_id) - log.info(f'[Moderation Event] Facility created. Id: {facility_id}') - - self.__update_item_with_facility_id_and_processing_results( - item, facility_id - ) - log.info( - '[Moderation Event] FacilityListItem updated with facility ID.' - ) - - FacilityListItemTemp.copy(item) - log.info('[Moderation Event] FacilityListItemTemp created.') - - self.__update_extended_fields(item) - log.info( - '[Moderation Event] Extended fields updated with facility ID.' - ) - - self.__create_facility_match_temp(item) - log.info('[Moderation Event] FacilityMatchTemp created.') - - self.__create_facility_match(item) - log.info('[Moderation Event] FacilityMatch created.') - - self.__update_event(self.__event, item) - log.info( - '[Moderation Event] Status and os_id of Moderation Event ' - 'updated.' - ) - - return item - - @staticmethod - def __create_source(contributor: Contributor) -> Source: - return Source.objects.create( - contributor=contributor, - source_type=Source.SINGLE, - is_public=True, - create=True, - ) - - @staticmethod - def __create_facility_list_item( - source: Source, data: Dict, header_str: str, status: str - ) -> FacilityListItem: - return FacilityListItem.objects.create( - source=source, - row_index=0, - raw_data=','.join( - f'"{value}"' for value in data["raw_json"].values() - ), - raw_json=data["raw_json"], - raw_header=header_str, - name=data["name"], - clean_name=data["clean_name"], - address=data["address"], - clean_address=data["clean_address"], - country_code=data["country_code"], - sector=data["sector"], - status=status, - processing_results=[ - { - 'action': ProcessingAction.PARSE, - 'started_at': str(timezone.now()), - 'error': False, - 'finished_at': str(timezone.now()), - 'is_geocoded': False, - } - ], + def _get_os_id(self, country_code: str) -> str: + os_id = make_os_id(country_code) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} OS ID was ' + f'generated: {os_id}' ) - def __set_geocoded_location( - self, item: FacilityListItem, data: Dict, event: ModerationEvent - ) -> None: - geocode_result = event.geocode_result - fields = data["fields"] + return os_id - if geocode_result: - self.__apply_geocode_result(item, geocode_result) + def _get_facilitylistitem_status(self) -> str: + return FacilityListItem.MATCHED - else: - self.__apply_manual_location(item, fields) + def _get_match_type(self) -> str: + return APIV1MatchTypes.NEW_PRODUCTION_LOCATION - item.save() + def _get_match_status(self) -> str: + return FacilityMatch.AUTOMATIC @staticmethod - def __apply_geocode_result( - item: FacilityListItem, geocode_result: Dict - ) -> None: - item.geocoded_point = Point( - geocode_result["geocoded_point"]["lng"], - geocode_result["geocoded_point"]["lat"], - ) - item.geocoded_address = geocode_result["geocoded_address"] - item.processing_results.append( - { - "action": ProcessingAction.GEOCODE, - "started_at": str(timezone.now()), - "error": False, - "skipped_geocoder": False, - "data": geocode_result["full_response"], - "finished_at": str(timezone.now()), - } - ) - - @staticmethod - def __apply_manual_location(item: FacilityListItem, fields: Dict) -> None: - item.geocoded_point = Point(fields["lng"], fields["lat"]) - item.processing_results.append( - { - "action": ProcessingAction.GEOCODE, - "started_at": str(timezone.now()), - "error": False, - "skipped_geocoder": True, - "finished_at": str(timezone.now()), - } - ) - - @staticmethod - def __create_new_facility( - item: FacilityListItem, facility_id: str - ) -> Facility: - return Facility.objects.create( + def _create_new_facility(item: FacilityListItem, facility_id: str) -> None: + Facility.objects.create( id=facility_id, name=item.name, address=item.address, country_code=item.country_code, location=item.geocoded_point, created_from_id=item.id, - created_at=datetime.now(), - updated_at=datetime.now(), - ) - - @staticmethod - def __update_item_with_facility_id_and_processing_results( - item: FacilityListItem, facility_id: str - ) -> None: - item.facility_id = facility_id - item.processing_results.append( - { - 'action': ProcessingAction.MATCH, - 'started_at': str(timezone.now()), - 'error': False, - 'finished_at': str(timezone.now()), - } - ) - item.save() - - @staticmethod - def __update_extended_fields(item: FacilityListItem) -> None: - extended_fields = ExtendedField.objects.filter( - facility_list_item=item.id - ) - for field in extended_fields: - field.facility_id = item.facility_id - field.save() - - def __create_facility_match_temp( - self, item: FacilityListItem - ) -> FacilityMatchTemp: - return self.__create_facility_match_record( - model=FacilityMatchTemp, - item=item, - status=FacilityMatchTemp.AUTOMATIC, - ) - - def __create_facility_match(self, item: FacilityListItem) -> FacilityMatch: - return self.__create_facility_match_record( - model=FacilityMatch, - item=item, - status=FacilityMatch.AUTOMATIC, - ) - - @staticmethod - def __create_facility_match_record( - model: Union[Type[FacilityMatchTemp], Type[FacilityMatch]], - item: FacilityListItem, - status: str, - ) -> Union[FacilityMatchTemp, FacilityMatch]: - return model.objects.create( - facility_id=item.facility_id, - confidence=1.0, - facility_list_item_id=item.id, - status=status, - results={"match_type": "moderation_event"}, created_at=timezone.now(), updated_at=timezone.now(), ) - - @staticmethod - def __update_event(event: ModerationEvent, item: FacilityListItem) -> None: - event.status = ModerationEvent.Status.APPROVED - event.os_id = item.facility_id - event.save() + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Facility created. ' + f'Id: {facility_id}' + ) diff --git a/src/django/api/moderation_event_actions/approval/event_approval_context.py b/src/django/api/moderation_event_actions/approval/event_approval_context.py deleted file mode 100644 index 650064457..000000000 --- a/src/django/api/moderation_event_actions/approval/event_approval_context.py +++ /dev/null @@ -1,19 +0,0 @@ -from api.models.facility.facility_list_item import FacilityListItem -from api.moderation_event_actions.approval.event_approval_strategy import ( - EventApprovalStrategy, -) - - -class EventApprovalContext: - ''' - Class defines which interface execute for the processing of a - moderation event. - ''' - - def __init__(self, strategy: EventApprovalStrategy) -> None: - self.__strategy = strategy - - def run_processing(self) -> FacilityListItem: - result = self.__strategy.process_moderation_event() - - return result diff --git a/src/django/api/moderation_event_actions/approval/event_approval_strategy.py b/src/django/api/moderation_event_actions/approval/event_approval_strategy.py deleted file mode 100644 index 916953570..000000000 --- a/src/django/api/moderation_event_actions/approval/event_approval_strategy.py +++ /dev/null @@ -1,16 +0,0 @@ -from abc import ABC, abstractmethod - -from api.models.facility.facility_list_item import FacilityListItem - - -class EventApprovalStrategy(ABC): - ''' - Abstract class for approval moderation event strategies. - ''' - - @abstractmethod - def process_moderation_event(self) -> FacilityListItem: - ''' - Abstract method to process a moderation event. - ''' - pass diff --git a/src/django/api/moderation_event_actions/approval/event_approval_template.py b/src/django/api/moderation_event_actions/approval/event_approval_template.py new file mode 100644 index 000000000..8ffa590ad --- /dev/null +++ b/src/django/api/moderation_event_actions/approval/event_approval_template.py @@ -0,0 +1,309 @@ +import logging +from abc import ABC, abstractmethod +from typing import Dict, KeysView, Type, Union + +from django.contrib.gis.geos import Point +from django.db import transaction +from django.utils import timezone + +from api.constants import ( + LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX, + ProcessingAction, +) +from api.extended_fields import ( + create_extendedfields_for_single_item, + update_extendedfields_for_list_item, +) +from api.models.contributor.contributor import Contributor +from api.models.facility.facility_list_item import FacilityListItem +from api.models.facility.facility_list_item_temp import FacilityListItemTemp +from api.models.facility.facility_match import FacilityMatch +from api.models.facility.facility_match_temp import FacilityMatchTemp +from api.models.moderation_event import ModerationEvent +from api.models.source import Source +from api.views.fields.create_nonstandard_fields import ( + create_nonstandard_fields, +) + +log = logging.getLogger(__name__) + + +class EventApprovalTemplate(ABC): + """ + A template method class that defines the overall steps for processing + approval-type moderation event actions. It could be applied only for + creating a new production location or adding an additional contribution to + an existing production location. + Subclasses provide the specifics by overriding + abstract methods and hooks. + """ + + def __init__(self, moderation_event: ModerationEvent) -> None: + self.__event = moderation_event + + @transaction.atomic + def process_moderation_event(self) -> FacilityListItem: + data: Dict = self.__event.cleaned_data + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Processing event ' + f'with data: {data}' + ) + + contributor: Contributor = self.__event.contributor + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Contributor: ' + f'{contributor}' + ) + + source: Source = self.__create_source(contributor) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Source created. ' + f'Id: {source.id}' + ) + + header_row_keys: KeysView[str] = data["raw_json"].keys() + create_nonstandard_fields(header_row_keys, contributor) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Nonstandard fields ' + 'created.' + ) + + header_str: str = ','.join(header_row_keys) + item: FacilityListItem = self.__create_facility_list_item( + source, data, header_str + ) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} FacilityListItem ' + f'created. Id: {item.id}' + ) + + create_extendedfields_for_single_item(item, data["fields"]) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Extended fields ' + 'created.' + ) + + self.__set_geocoded_location(item, data, self.__event) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Geocoded ' + 'location set.' + ) + + facility_id = self._get_os_id(item.country_code) + + self._create_new_facility(item, facility_id) + + self.__update_item_with_facility_id_and_processing_results( + item, facility_id + ) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} FacilityListItem ' + 'updated with facility ID.' + ) + + self.__create_list_item_temp(item) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} ' + 'FacilityListItemTemp created.' + ) + + update_extendedfields_for_list_item(item) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Extended fields ' + 'updated with facility ID.' + ) + + self.__create_facility_match_temp(item) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} FacilityMatchTemp ' + 'created.' + ) + + self.__create_facility_match(item) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} FacilityMatch ' + 'created.' + ) + + self._update_event(self.__event, item) + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} Status and os_id of ' + 'Moderation Event updated.' + ) + + return item + + @staticmethod + def __create_source(contributor: Contributor) -> Source: + return Source.objects.create( + contributor=contributor, + source_type=Source.SINGLE, + is_public=True, + create=True, + ) + + def __create_facility_list_item( + self, source: Source, data: Dict, header_str: str + ) -> FacilityListItem: + status = self._get_facilitylistitem_status() + + return FacilityListItem.objects.create( + source=source, + row_index=0, + raw_data=','.join( + f'"{value}"' for value in data["raw_json"].values() + ), + raw_json=data["raw_json"], + raw_header=header_str, + name=data["name"], + clean_name=data["clean_name"], + address=data["address"], + clean_address=data["clean_address"], + country_code=data["country_code"], + sector=data["sector"], + status=status, + processing_results=[ + { + 'action': ProcessingAction.PARSE, + 'started_at': str(timezone.now()), + 'error': False, + 'finished_at': str(timezone.now()), + 'is_geocoded': False, + } + ], + ) + + def __set_geocoded_location( + self, item: FacilityListItem, data: Dict, event: ModerationEvent + ) -> None: + geocode_result = event.geocode_result + + if geocode_result: + self.__apply_geocode_result(item, geocode_result) + else: + lat = data["fields"]["lat"] + lng = data["fields"]["lng"] + self.__apply_manual_location(item, lat, lng) + + item.save() + + @staticmethod + def __apply_geocode_result( + item: FacilityListItem, geocode_result: Dict + ) -> None: + item.geocoded_point = Point( + geocode_result["geocoded_point"]["lng"], + geocode_result["geocoded_point"]["lat"], + ) + item.geocoded_address = geocode_result["geocoded_address"] + item.processing_results.append( + { + "action": ProcessingAction.GEOCODE, + "started_at": str(timezone.now()), + "error": False, + "skipped_geocoder": False, + "data": geocode_result["full_response"], + "finished_at": str(timezone.now()), + } + ) + + @staticmethod + def __apply_manual_location( + item: FacilityListItem, lat: float, lng: float + ) -> None: + item.geocoded_point = Point(lng, lat) + item.processing_results.append( + { + "action": ProcessingAction.GEOCODE, + "started_at": str(timezone.now()), + "error": False, + "skipped_geocoder": True, + "finished_at": str(timezone.now()), + } + ) + + @staticmethod + def __update_item_with_facility_id_and_processing_results( + item: FacilityListItem, facility_id: str + ) -> None: + item.facility_id = facility_id + item.processing_results.append( + { + 'action': ProcessingAction.MATCH, + 'started_at': str(timezone.now()), + 'error': False, + 'finished_at': str(timezone.now()), + } + ) + item.save() + + @staticmethod + def __create_list_item_temp(item: FacilityListItem) -> None: + FacilityListItemTemp.copy(item) + + def __create_facility_match_temp(self, item: FacilityListItem) -> None: + self.__create_facility_match_record( + model=FacilityMatchTemp, + item=item, + ) + + def __create_facility_match(self, item: FacilityListItem) -> None: + self.__create_facility_match_record(model=FacilityMatch, item=item) + + def __create_facility_match_record( + self, + model: Union[Type[FacilityMatchTemp], Type[FacilityMatch]], + item: FacilityListItem, + ) -> None: + match_type = self._get_match_type() + status = self._get_match_status() + + model.objects.create( + facility_id=item.facility_id, + confidence=1.0, + facility_list_item_id=item.id, + status=status, + results={"match_type": match_type}, + created_at=timezone.now(), + updated_at=timezone.now(), + ) + + @staticmethod + def _update_event(event: ModerationEvent, item: FacilityListItem) -> None: + event.status = ModerationEvent.Status.APPROVED + event.os_id = item.facility_id + event.save() + + @abstractmethod + def _get_os_id(self, country_code: str) -> str: + """Return the os_id.""" + raise NotImplementedError + + @abstractmethod + def _get_facilitylistitem_status(self) -> str: + """ + Return the status that should be used when creating + facility list items. + """ + raise NotImplementedError + + @abstractmethod + def _get_match_type(self) -> str: + """ + Return the match_type that should be used when creating + facility matches. + """ + raise NotImplementedError + + @abstractmethod + def _get_match_status(self) -> str: + """ + Return the status that should be used when creating + facility matches. + """ + raise NotImplementedError + + @staticmethod + def _create_new_facility(item: FacilityListItem, facility_id: str) -> None: + """Hook method to create a new facility.""" + pass diff --git a/src/django/api/moderation_event_actions/approval/update_production_location.py b/src/django/api/moderation_event_actions/approval/update_production_location.py new file mode 100644 index 000000000..5018585b8 --- /dev/null +++ b/src/django/api/moderation_event_actions/approval/update_production_location.py @@ -0,0 +1,36 @@ +import logging + +from api.constants import ( + LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX, + APIV1MatchTypes, +) +from api.models.facility.facility_list_item import FacilityListItem +from api.models.facility.facility_match import FacilityMatch +from api.models.moderation_event import ModerationEvent +from api.moderation_event_actions.approval.event_approval_template import ( + EventApprovalTemplate, +) + +log = logging.getLogger(__name__) + + +class UpdateProductionLocation(EventApprovalTemplate): + def __init__(self, moderation_event: ModerationEvent, os_id: str) -> None: + super().__init__(moderation_event) + self.__os_id = os_id + + def _get_os_id(self, _) -> str: + log.info( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} OS ID received from ' + f'request: {self.__os_id}' + ) + return self.__os_id + + def _get_facilitylistitem_status(self) -> str: + return FacilityListItem.CONFIRMED_MATCH + + def _get_match_type(self) -> str: + return APIV1MatchTypes.CONFIRMED_MATCH + + def _get_match_status(self) -> str: + return FacilityMatch.CONFIRMED diff --git a/src/django/api/moderation_event_actions/creation/dtos/create_moderation_event_dto.py b/src/django/api/moderation_event_actions/creation/dtos/create_moderation_event_dto.py index 21c9f7173..cfbc9b9a6 100644 --- a/src/django/api/moderation_event_actions/creation/dtos/create_moderation_event_dto.py +++ b/src/django/api/moderation_event_actions/creation/dtos/create_moderation_event_dto.py @@ -4,13 +4,16 @@ from rest_framework import status from api.models.moderation_event import ModerationEvent +from api.models.contributor.contributor import Contributor +from api.models.facility.facility import Facility @dataclass class CreateModerationEventDTO: - contributor_id: int + contributor: Contributor raw_data: Dict request_type: str + os: Facility = None cleaned_data: Dict = field(default_factory=dict) source: str = '' geocode_result: Dict = field(default_factory=dict) diff --git a/src/django/api/moderation_event_actions/creation/moderation_event_creator.py b/src/django/api/moderation_event_actions/creation/moderation_event_creator.py index e56ca5eb5..4c6d02c9c 100644 --- a/src/django/api/moderation_event_actions/creation/moderation_event_creator.py +++ b/src/django/api/moderation_event_actions/creation/moderation_event_creator.py @@ -20,12 +20,13 @@ def perform_event_creation( return event_dto event_dto.moderation_event = ModerationEvent.objects.create( - contributor=processed_event.contributor_id, + contributor=processed_event.contributor, request_type=processed_event.request_type, raw_data=processed_event.raw_data, cleaned_data=processed_event.cleaned_data, geocode_result=processed_event.geocode_result, - source=processed_event.source + source=processed_event.source, + os=processed_event.os ) return event_dto diff --git a/src/django/api/permissions.py b/src/django/api/permissions.py index 616e68195..1a3ff6917 100644 --- a/src/django/api/permissions.py +++ b/src/django/api/permissions.py @@ -78,6 +78,8 @@ def has_permission(self, request, view): class IsSuperuser(IsRegisteredAndConfirmed): + message = 'Only the moderator can perform this action.' + def has_permission(self, request, view): is_registered = super().has_permission(request, view) return is_registered and request.user.is_superuser diff --git a/src/django/api/serializers/v1/moderation_event_update_serializer.py b/src/django/api/serializers/v1/moderation_event_update_serializer.py index ad1669522..1452390d6 100644 --- a/src/django/api/serializers/v1/moderation_event_update_serializer.py +++ b/src/django/api/serializers/v1/moderation_event_update_serializer.py @@ -14,7 +14,7 @@ class ModerationEventUpdateSerializer(ModelSerializer): contributor_id = IntegerField(source='contributor.id', read_only=True) contributor_name = CharField(source='contributor.name', read_only=True) source = CharField(read_only=True) - os_id = IntegerField(source='os.id', read_only=True) + os_id = CharField(source='os.id', read_only=True) claim_id = IntegerField(source='claim.id', read_only=True) class Meta: diff --git a/src/django/api/services/moderation_events_service.py b/src/django/api/services/moderation_events_service.py index 51adb9a25..bc438ad2a 100644 --- a/src/django/api/services/moderation_events_service.py +++ b/src/django/api/services/moderation_events_service.py @@ -1,9 +1,16 @@ import logging -from rest_framework.exceptions import PermissionDenied, ParseError, NotFound +from rest_framework.exceptions import NotFound, ParseError +from api.constants import ( + LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX, + APIV1CommonErrorMessages, + APIV1ModerationEventErrorMessages, +) from api.exceptions import GoneException, InternalServerErrorException +from api.models.facility.facility import Facility from api.models.moderation_event import ModerationEvent +from api.os_id import validate_os_id from api.serializers.v1.opensearch_common_validators.moderation_id_validator \ import ModerationIdValidator from api.views.v1.utils import create_error_detail @@ -12,20 +19,15 @@ class ModerationEventsService: - @staticmethod - def validate_user_permissions(request): - if not (request.user.is_superuser or request.user.is_staff): - raise PermissionDenied( - detail="Only the Moderator can perform this action." - ) - @staticmethod def validate_uuid(value): if not ModerationIdValidator.is_valid_uuid(value): raise ParseError( create_error_detail( field="moderation_id", - detail="Invalid UUID format." + detail=( + APIV1ModerationEventErrorMessages.INVALID_UUID_FORMAT + ), ) ) @@ -37,7 +39,7 @@ def fetch_moderation_event_by_uuid(uuid): raise NotFound( create_error_detail( field="moderation_id", - detail="Moderation event not found." + detail=APIV1ModerationEventErrorMessages.EVENT_NOT_FOUND, ) ) @@ -45,13 +47,31 @@ def fetch_moderation_event_by_uuid(uuid): def validate_moderation_status(status): if status != ModerationEvent.Status.PENDING: raise GoneException( + detail=APIV1ModerationEventErrorMessages.EVENT_NOT_PENDING + ) + + @staticmethod + def validate_location_os_id(os_id): + if not validate_os_id(os_id, raise_on_invalid=False): + raise ParseError( + create_error_detail( + field="os_id", + detail=APIV1CommonErrorMessages.LOCATION_ID_NOT_VALID, + ) + ) + + if not Facility.objects.filter(id=os_id).exists(): + raise NotFound( create_error_detail( - field="status", - detail="The moderation event should be in PENDING status." + field="os_id", + detail=APIV1CommonErrorMessages.LOCATION_NOT_FOUND, ) ) @staticmethod def handle_processing_error(error_message): - log.error(f'[Moderation Event] Error: {str(error_message)}') + log.error( + f'{LOCATION_CONTRIBUTION_APPROVAL_LOG_PREFIX} ' + f'Error: {str(error_message)}' + ) raise InternalServerErrorException() diff --git a/src/django/api/templates/mail/approved_facility_claim_contributor_notice_body.html b/src/django/api/templates/mail/approved_facility_claim_contributor_notice_body.html index 99a97b5f9..0ab140cf2 100644 --- a/src/django/api/templates/mail/approved_facility_claim_contributor_notice_body.html +++ b/src/django/api/templates/mail/approved_facility_claim_contributor_notice_body.html @@ -2,30 +2,41 @@ - An OS Hub facility on one of your facility lists has been claimed + A facility you contributed to OS Hub has been claimed!

- Hi, + Hello,

- You're receiving this email because we approved a facility claim - for a facility appearing on one of your facility lists. + One of the facilities you have contributed to Open Supply Hub (OS Hub) has been claimed!

- The facility is: + The facility is:

- Best wishes, + This means that the facility owner/management (the claimant) is now sharing data about their operations on OS Hub directly. + To claim their facility, the claimant had to provide information allowing the OS Hub team to confirm the facility name, address, and the claimant’s affiliation with it. +

+

+ This now allows them to add additional details like parent company, description, MOQs, products, number of workers, and contact info on the OS Hub facility profile. + All data contributed by the claimant will have a special icon next to it, to show it is coming directly from the facility. +

+

+ If you want to encourage more facilities to complete this process, you can reach out to them to let them know that they are listed on the Open Supply Hub and can claim their profile. + Check out some sample text you can use here. +

+

+ Best Regards,

{% include "mail/signature_block.html" %} diff --git a/src/django/api/templates/mail/approved_facility_claim_contributor_notice_body.txt b/src/django/api/templates/mail/approved_facility_claim_contributor_notice_body.txt index 8b3b4acd2..cf66e3827 100644 --- a/src/django/api/templates/mail/approved_facility_claim_contributor_notice_body.txt +++ b/src/django/api/templates/mail/approved_facility_claim_contributor_notice_body.txt @@ -1,14 +1,19 @@ {% block content %} -Hi, +Hello, -You're receiving this email because we approved a facility claim for a facility -appearing on one of your facility lists. +One of the facilities you have contributed to Open Supply Hub (OS Hub) has been claimed! -The facility is: +The facility is: - - Facility: {{ facility_name }}, {{ facility_address }}, {{ facility_country }} - - Facility URL: {{ facility_url }} + - Facility: {{ facility_name }}, {{ facility_address }}, {{ facility_country }} + - Facility URL: {{ facility_url }} -Best wishes, +This means that the facility owner/management (the claimant) is now sharing data about their operations on OS Hub directly. To claim their facility, the claimant had to provide information allowing the OS Hub team to confirm the facility name, address, and the claimant’s affiliation with it. + +This now allows them to add additional details like parent company, description, MOQs, products, number of workers, and contact info on the OS Hub facility profile. All data contributed by the claimant will have a special icon next to it, to show it is coming directly from the facility. + +If you want to encourage more facilities to complete this process, you can reach out to them to let them know that they are listed on the Open Supply Hub and can claim their profile. Check out some sample text you can use here. + +Best Regards, {% include "mail/signature_block.txt" %} {% endblock content %} diff --git a/src/django/api/templates/mail/approved_facility_claim_contributor_notice_subject.txt b/src/django/api/templates/mail/approved_facility_claim_contributor_notice_subject.txt index e240e7a1f..db1a02968 100644 --- a/src/django/api/templates/mail/approved_facility_claim_contributor_notice_subject.txt +++ b/src/django/api/templates/mail/approved_facility_claim_contributor_notice_subject.txt @@ -1 +1 @@ -An OS Hub facility on one of your facility lists has been claimed +A facility you contributed to OS Hub has been claimed! diff --git a/src/django/api/templates/mail/report_result_body.html b/src/django/api/templates/mail/report_result_body.html index 56e68e2aa..40d0b0999 100644 --- a/src/django/api/templates/mail/report_result_body.html +++ b/src/django/api/templates/mail/report_result_body.html @@ -7,11 +7,14 @@

- Hi there, + Hello,

- Thanks for reporting {{ facility_name }} facility as {{ closure_state|lower }}. - {% if is_closure and is_rejected %}We have rejected this report and the facility's profile in OS Hub has not been changed.{% endif %}{% if is_closure and is_confirmed %}The facility profile in Open Supply Hub has now been updated and the facility is marked as closed.{% endif %}{% if is_reopening and is_rejected %}We have rejected this report and the facility's profile in OS Hub has not been changed.{% endif %}{% if is_reopening and is_confirmed %}The facility profile in Open Supply Hub has now been updated and the facility is no longer marked as closed.{% endif %} + Thank you for reporting the facility {{ facility_name }} as {{ closure_state|lower }}. + {% if is_closure and is_rejected %}We did not approve this report and the facility's profile on OS Hub has not been changed. For more details, view our Facility Closure Policy.{% endif %} + {% if is_closure and is_confirmed %}The facility profile on OS Hub has now been updated and the facility is marked as closed.{% endif %} + {% if is_reopening and is_rejected %}We did not approve this report and the facility's profile on OS Hub has not been changed.{% endif %} + {% if is_reopening and is_confirmed %}The facility profile on OS Hub has now been updated and the facility is no longer marked as closed.{% endif %}

{% if status_change_reason|length %}

@@ -20,17 +23,17 @@ {% endif %} {% if is_rejected %}

- If you have additional evidence that can be used to verify the facility has {{ closure_state|lower }}, this can be shared with: data@opensupplyhub.org + If you have additional evidence that can be used to verify that the facility is {{ closure_state|lower }}, email the relevant documents to: data@opensupplyhub.org

{% endif %} {% if is_closure and is_confirmed %}

- Should you receive reports that the facility is re-opened in the future, you can share this update with the OS Hub Team. + Should you receive reports that the facility is re-opened in the future, please send us an report clicking the “Report” button (flag icon) on the facility profile and choosing “Report as Reopened”

{% endif %}

- Best wishes, + Best Regards,

{% include "mail/signature_block.html" %} diff --git a/src/django/api/templates/mail/report_result_body.txt b/src/django/api/templates/mail/report_result_body.txt index 2d173cf4d..510aaace2 100644 --- a/src/django/api/templates/mail/report_result_body.txt +++ b/src/django/api/templates/mail/report_result_body.txt @@ -1,15 +1,15 @@ {% block content %} -Hi there, +Hello, -Thanks for reporting {{ facility_name }} facility as {{ closure_state|lower }}. +Thank you for reporting the facility {{ facility_name }} as {{ closure_state|lower }} on Open Supply Hub (OS Hub). -{% if is_closure and is_rejected %}We have rejected this report and the facility's profile in OS Hub has not been changed.{% endif %}{% if is_closure and is_confirmed %}The facility profile in the Open Supply Hub has now been updated and the facility is marked as closed.{% endif %}{% if is_reopening and is_rejected %}We have rejected this report and the facility's profile in OS Hub has not been changed.{% endif %}{% if is_reopening and is_confirmed %}The facility profile in the Open Supply Hub has now been updated and the facility is no longer marked as closed.{% endif %} +{% if is_closure and is_rejected %}We did not approve this report and the facility's profile in OS Hub has not been changed. For more details, view our Facility Closure Policy: https://info.opensupplyhub.org/governance-policies.{% endif %}{% if is_closure and is_confirmed %}The facility profile on OS Hub has now been updated and the facility is marked as closed.{% endif %}{% if is_reopening and is_rejected %}We did not approve this report and the facility's profile on OS Hub has not been changed.{% endif %}{% if is_reopening and is_confirmed %}The facility profile on OS Hub has now been updated and the facility is no longer marked as closed.{% endif %} {% if status_change_reason|length %}OS Hub Team review notes: {{ status_change_reason }}{% endif %} -{% if is_rejected %}If you have additional evidence that can be used to verify the facility has {{ closure_state|lower }}, this can be shared with: data@opensupplyhub.org{% endif %}{% if is_closure and is_confirmed %}Should you receive reports that the facility is re-opened in the future, you can share this update with the OS Hub Team.{% endif %} +{% if is_rejected %}If you have additional evidence that can be used to verify that the facility is {{ closure_state|lower }}, email the relevant documents to: data@opensupplyhub.org{% endif %}{% if is_closure and is_confirmed %}Should you receive reports that the facility is re-opened in the future, please send us an report clicking the “Report” button (flag icon) on the facility profile and choosing “Report as Reopened”.{% endif %} -Best wishes, +Best Regards, {% include "mail/signature_block.txt" %} {% endblock content %} diff --git a/src/django/api/tests/base_moderation_events_production_location_test.py b/src/django/api/tests/base_moderation_events_production_location_test.py new file mode 100644 index 000000000..94c3dfd55 --- /dev/null +++ b/src/django/api/tests/base_moderation_events_production_location_test.py @@ -0,0 +1,314 @@ +from django.test import override_settings +from django.utils.timezone import now + +from rest_framework.test import APITestCase + +from api.constants import APIV1ModerationEventErrorMessages +from api.models import Contributor, ModerationEvent, User +from api.models.extended_field import ExtendedField +from api.models.facility.facility_list_item import FacilityListItem +from api.models.nonstandard_field import NonstandardField +from api.models.source import Source + + +@override_settings(DEBUG=True) +class BaseModerationEventsProductionLocationTest(APITestCase): + def setUp(self): + super().setUp() + + self.email = "test@example.com" + self.password = "example123" + self.user = User.objects.create(email=self.email) + self.user.set_password(self.password) + self.user.save() + + self.contributor = Contributor.objects.create( + admin=self.user, + name="test contributor", + contrib_type=Contributor.OTHER_CONTRIB_TYPE, + ) + + self.superuser_email = "admin@example.com" + self.superuser_password = "example123" + self.superuser = User.objects.create_superuser( + email=self.superuser_email, password=self.superuser_password + ) + + self.moderation_event_id = 'f65ec710-f7b9-4f50-b960-135a7ab24ee6' + self.latitude = -53 + self.longitude = 142 + self.country = "United Kingdom" + self.name = "Test Name" + self.address = "Test Address, United Kingdom" + self.moderation_event = ModerationEvent.objects.create( + uuid=self.moderation_event_id, + created_at=now(), + updated_at=now(), + request_type='UPDATE', + raw_data={ + "country": self.country, + "name": self.name, + "address": self.address, + }, + cleaned_data={ + "raw_json": { + "country": self.country, + "name": self.name, + "address": self.address, + }, + "name": self.name, + "clean_name": "test name", + "address": self.address, + "clean_address": "test address united kingdom", + "country_code": "GB", + "sector": ["Apparel"], + "fields": { + "country": self.country, + }, + "errors": [], + }, + geocode_result={ + "geocoded_point": { + "lat": self.latitude, + "lng": self.longitude, + }, + "geocoded_address": "Geocoded Address", + "full_response": { + "status": "OK", + "results": [ + { + "address_components": [ + { + "long_name": "Long Geocoded Address", + "short_name": "Short Geocoded Address", + "types": ["street_address"], + }, + ], + "formatted_address": "Formatted Geocoded Address", + } + ], + }, + }, + status='PENDING', + source='API', + contributor=self.contributor, + ) + + def login_as_superuser(self): + self.client.login( + email=self.superuser_email, password=self.superuser_password + ) + + def login_as_regular_user(self): + self.client.login(email=self.email, password=self.password) + + def assert_not_authenticated(self, response): + self.assertEqual(401, response.status_code) + self.assertEqual( + "Authentication credentials were not provided.", + response.data["detail"], + ) + + def assert_permission_denied(self, response): + self.assertEqual(403, response.status_code) + self.assertEqual( + "Only the moderator can perform this action.", + response.data["detail"], + ) + + def assert_invalid_uuid_error(self, response): + self.assertEqual(400, response.status_code) + self.assertEqual( + "The request path parameter is invalid.", response.data["detail"] + ) + self.assertEqual("moderation_id", response.data["errors"][0]["field"]) + self.assertEqual( + APIV1ModerationEventErrorMessages.INVALID_UUID_FORMAT, + response.data["errors"][0]["detail"] + ) + + def assert_moderation_event_not_found(self, response): + self.assertEqual(404, response.status_code) + self.assertEqual( + "The request path parameter is invalid.", response.data["detail"] + ) + self.assertEqual("moderation_id", response.data["errors"][0]["field"]) + self.assertEqual( + APIV1ModerationEventErrorMessages.EVENT_NOT_FOUND, + response.data["errors"][0]["detail"], + ) + + def assert_moderation_event_not_pending(self, response): + self.assertEqual(410, response.status_code) + self.assertEqual( + APIV1ModerationEventErrorMessages.EVENT_NOT_PENDING, + response.data["detail"], + ) + + def assert_success_response(self, response, status_code): + self.assertEqual(status_code, response.status_code) + self.assertIn("os_id", response.data) + + moderation_event = ModerationEvent.objects.get( + uuid=self.moderation_event_id + ) + self.assertEqual(moderation_event.status, 'APPROVED') + self.assertEqual(moderation_event.os_id, response.data["os_id"]) + + def assert_source_creation(self, source): + self.assertIsNotNone(source) + self.assertEqual(source.source_type, Source.SINGLE) + self.assertEqual(source.is_active, True) + self.assertEqual(source.is_public, True) + self.assertEqual(source.create, True) + + def assert_successful_add_production_location_without_geocode_result( + self, response, status_code + ): + self.assertEqual(status_code, response.status_code) + + facility_list_item = FacilityListItem.objects.get( + facility_id=response.data["os_id"] + ) + self.assertEqual(facility_list_item.geocoded_point.x, self.longitude) + self.assertEqual(facility_list_item.geocoded_point.y, self.latitude) + self.assertIsNone(facility_list_item.geocoded_address) + + def add_nonstandard_fields_data(self): + self.moderation_event.cleaned_data["raw_json"][ + "nonstandard_field_one" + ] = "Nonstandard Field One" + self.moderation_event.cleaned_data["raw_json"][ + "nonstandard_field_two" + ] = "Nonstandard Field Two" + + def assert_creation_of_nonstandard_fields(self, response, status_code): + self.assertEqual(status_code, response.status_code) + + nonstandard_fields = NonstandardField.objects.filter( + contributor=self.contributor + ) + created_fields = nonstandard_fields.values_list( + 'column_name', flat=True + ) + + self.assertIn('nonstandard_field_one', created_fields) + self.assertIn('nonstandard_field_two', created_fields) + self.assertNotIn('country', created_fields) + self.assertNotIn('name', created_fields) + self.assertNotIn('address', created_fields) + + def assert_facilitylistitem_creation(self, response, status_code, status): + self.assertEqual(status_code, response.status_code) + + facility_list_item = FacilityListItem.objects.get( + facility_id=response.data["os_id"] + ) + self.assertIsNotNone(facility_list_item) + self.assertEqual(facility_list_item.row_index, 0) + self.assertEqual(facility_list_item.status, status) + self.assertEqual( + facility_list_item.name, self.moderation_event.cleaned_data["name"] + ) + self.assertEqual( + facility_list_item.address, + self.moderation_event.cleaned_data["address"], + ) + self.assertEqual( + facility_list_item.country_code, + self.moderation_event.cleaned_data["country_code"], + ) + self.assertEqual( + facility_list_item.clean_name, + self.moderation_event.cleaned_data["clean_name"], + ) + self.assertEqual( + facility_list_item.clean_address, + self.moderation_event.cleaned_data["clean_address"], + ) + self.assertEqual(facility_list_item.geocoded_point.x, self.longitude) + self.assertEqual(facility_list_item.geocoded_point.y, self.latitude) + self.assertEqual( + facility_list_item.geocoded_address, + self.moderation_event.geocode_result["geocoded_address"], + ) + + def add_extended_fields_data(self): + self.moderation_event.cleaned_data['fields'][ + 'number_of_workers' + ] = '100' + self.moderation_event.cleaned_data['fields'][ + 'native_language_name' + ] = 'Native Language Name' + self.moderation_event.cleaned_data['fields'][ + 'parent_company' + ] = 'Parent Company' + self.moderation_event.cleaned_data['fields']['product_type'] = [ + "Product Type" + ] + self.moderation_event.cleaned_data['fields']['facility_type'] = { + "raw_values": "Facility Type", + "processed_values": ["Facility Type"], + } + self.moderation_event.cleaned_data['fields']['processing_type'] = { + "raw_values": "Processing Type", + "processed_values": ["Processing Type"], + } + + def assert_extended_fields_creation(self, response, status_code): + self.assertEqual(status_code, response.status_code) + + item = FacilityListItem.objects.get(facility_id=response.data["os_id"]) + extended_fields = ExtendedField.objects.filter( + facility_list_item=item.id + ) + self.assertEqual(6, extended_fields.count()) + + field_names = [field.field_name for field in extended_fields] + self.assertIn(ExtendedField.NUMBER_OF_WORKERS, field_names) + self.assertIn(ExtendedField.NATIVE_LANGUAGE_NAME, field_names) + self.assertIn(ExtendedField.PARENT_COMPANY, field_names) + self.assertIn(ExtendedField.PRODUCT_TYPE, field_names) + self.assertIn(ExtendedField.FACILITY_TYPE, field_names) + self.assertIn(ExtendedField.PROCESSING_TYPE, field_names) + + for extended_field in extended_fields: + self.assertEqual(extended_field.facility_id, item.facility_id) + + def assert_facilitymatch_creation( + self, response, status_code, match_type, match_status, model + ): + self.assertEqual(status_code, response.status_code) + + facility_list_item = FacilityListItem.objects.get( + facility_id=response.data["os_id"] + ) + facility_match = model.objects.get( + facility_list_item=facility_list_item.id + ) + self.assertIsNotNone(facility_match) + self.assertEqual(facility_match.status, match_status) + self.assertEqual( + facility_match.facility_id, facility_list_item.facility_id + ) + self.assertEqual(facility_match.confidence, 1) + self.assertEqual( + facility_match.results, + { + "match_type": match_type, + }, + ) + + def assert_processing_error(self, response): + self.assertEqual(response.status_code, 500) + self.assertEqual( + response.data, + { + "detail": "An unexpected error occurred while processing the " + "request." + }, + ) + self.assertEqual( + ModerationEvent.objects.get(uuid=self.moderation_event_id).status, + ModerationEvent.Status.PENDING, + ) diff --git a/src/django/api/tests/test_contributors_list_api_endpoint.py b/src/django/api/tests/test_contributors_list_api_endpoint.py index 450c7f0e0..b206671df 100644 --- a/src/django/api/tests/test_contributors_list_api_endpoint.py +++ b/src/django/api/tests/test_contributors_list_api_endpoint.py @@ -37,6 +37,14 @@ def setUp(self): self.contrib_six_name = "contributor with one good and one error item" self.contrib_seven_name = "contributor with create=False API source" + self.contrib_one_type = "Brand / Retailer" + self.contrib_two_type = "Multi-Stakeholder Initiative" + self.contrib_three_type = "Union" + self.contrib_four_type = "Brand / Retailer" + self.contrib_five_type = "Multi-Stakeholder Initiative" + self.contrib_six_type = "Test" + self.contrib_seven_type = "Union" + self.country_code = "US" self.list_one_name = "one" self.list_one_b_name = "one-b" @@ -57,43 +65,44 @@ def setUp(self): self.contrib_one = Contributor.objects.create( admin=self.user_one, name=self.contrib_one_name, - contrib_type=Contributor.OTHER_CONTRIB_TYPE, + contrib_type=self.contrib_one_type, ) self.contrib_two = Contributor.objects.create( admin=self.user_two, name=self.contrib_two_name, - contrib_type=Contributor.OTHER_CONTRIB_TYPE, + contrib_type=self.contrib_two_type, ) self.contrib_three = Contributor.objects.create( admin=self.user_three, name=self.contrib_three_name, - contrib_type=Contributor.OTHER_CONTRIB_TYPE, + contrib_type=self.contrib_three_type, ) self.contrib_four = Contributor.objects.create( admin=self.user_four, name=self.contrib_four_name, - contrib_type=Contributor.OTHER_CONTRIB_TYPE, + contrib_type=self.contrib_four_type, ) self.contrib_five = Contributor.objects.create( admin=self.user_five, name=self.contrib_five_name, - contrib_type=Contributor.OTHER_CONTRIB_TYPE, + contrib_type=self.contrib_five_type, ) self.contrib_six = Contributor.objects.create( admin=self.user_six, name=self.contrib_six_name, contrib_type=Contributor.OTHER_CONTRIB_TYPE, + other_contrib_type=self.contrib_six_type, ) self.contrib_seven = Contributor.objects.create( admin=self.user_seven, name=self.contrib_seven_name, - contrib_type=Contributor.OTHER_CONTRIB_TYPE, + contrib_type=self.contrib_seven_type, ) self.list_one = FacilityList.objects.create( @@ -216,12 +225,18 @@ def test_contributors_list_has_only_contributors_with_active_lists(self): response = self.client.get("/api/contributors/") response_data = response.json() contributor_names = list(zip(*response_data))[1] + contributor_types = list(zip(*response_data))[2] self.assertIn( self.contrib_one_name, contributor_names, ) + self.assertIn( + self.contrib_one_type, + contributor_types, + ) + self.assertNotIn( self.contrib_two_name, contributor_names, @@ -247,6 +262,11 @@ def test_contributors_list_has_only_contributors_with_active_lists(self): contributor_names, ) + self.assertIn( + self.contrib_six_type, + contributor_types, + ) + self.assertEqual( 2, len(contributor_names), diff --git a/src/django/api/tests/test_location_contribution_strategy.py b/src/django/api/tests/test_location_contribution_strategy.py index 8fdde3e8b..116b5455a 100644 --- a/src/django/api/tests/test_location_contribution_strategy.py +++ b/src/django/api/tests/test_location_contribution_strategy.py @@ -4,10 +4,16 @@ from unittest.mock import Mock, patch from rest_framework.test import APITestCase +from allauth.account.models import EmailAddress +from django.contrib.gis.geos import Point from api.models.moderation_event import ModerationEvent from api.models.contributor.contributor import Contributor from api.models.user import User +from api.models.facility.facility_list import FacilityList +from api.models.facility.facility_list_item import FacilityListItem +from api.models.facility.facility import Facility +from api.models.source import Source from api.tests.test_data import ( geocoding_data, geocoding_no_results @@ -70,7 +76,7 @@ def test_source_set_as_api_regardless_of_whether_passed(self, mock_get): self.assertNotIn('source', self.common_valid_input_data) event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=self.common_valid_input_data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -137,7 +143,7 @@ def test_invalid_source_value_cannot_be_accepted(self, mock_get): # Check the length validation. event_dto_1 = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=invalid_input_data_1, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -150,7 +156,7 @@ def test_invalid_source_value_cannot_be_accepted(self, mock_get): # Check validation of accepted values. event_dto_2 = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=invalid_input_data_2, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -163,7 +169,7 @@ def test_invalid_source_value_cannot_be_accepted(self, mock_get): # Check the accepted data type validation for the source field. event_dto_3 = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=invalid_input_data_3, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -189,7 +195,7 @@ def test_mapping_of_unsupported_fields_by_contricleaner_with_valid_data( } event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=input_data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -256,7 +262,7 @@ def test_mapping_of_unsupported_fields_by_contricleaner_with_invalid_data( } event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=input_data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -289,7 +295,7 @@ def test_handling_of_cc_list_level_errors(self): } event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=input_data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -332,7 +338,7 @@ def test_handling_of_cc_handler_not_set_exception(self, mock_process_data): } event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=input_data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -368,7 +374,7 @@ def test_handling_geocoded_no_results_error(self, mock_get): } event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=input_data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -397,7 +403,7 @@ def test_handling_geocoding_internal_error(self, mock_geocode_address): } event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=input_data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -408,8 +414,7 @@ def test_handling_geocoding_internal_error(self, mock_geocode_address): self.assertIsNone(result.moderation_event) self.assertEqual(result.errors, expected_error_result) - def test_moderation_event_is_created_with_coordinates_properly(self): - + def test_moderation_event_creation_with_coordinates_for_create(self): input_data = { 'source': 'SLC', 'name': 'Blue Horizon Facility', @@ -454,7 +459,7 @@ def test_moderation_event_is_created_with_coordinates_properly(self): } event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=input_data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -499,9 +504,153 @@ def test_moderation_event_is_created_with_coordinates_properly(self): # was provided during the creation of the moderation event. self.assertIsNone(moderation_event.os) + def test_moderation_event_creation_with_valid_data_for_update(self): + # Create a new user and contributor for the production location that + # already exists in the system while processing the location + # contribution. + existing_location_user_email = 'test2@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 + ) + + existing_location_contributor = Contributor.objects.create( + admin=existing_location_user, + name='test contributor 2', + contrib_type=Contributor.OTHER_CONTRIB_TYPE, + ) + + # Create the production location to ensure the existing location is in + # place before processing the contribution. + 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': 'SLC', + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US', + 'sector': ['Apparel', 'Equipment'], + 'coordinates': { + 'lat': 51.078389, + 'lng': 16.978477 + }, + 'product_type': ['Random product type'] + } + + expected_raw_data = deepcopy(input_data) + expected_cleaned_data = { + 'raw_json': { + 'lat': 51.078389, + 'lng': 16.978477, + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US', + 'sector': ['Apparel', 'Equipment'], + 'product_type': ['Random product type'] + }, + 'name': 'Blue Horizon Facility', + 'clean_name': 'blue horizon facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'clean_address': '990 spring garden st. philadelphia pa 19123', + 'country_code': 'US', + 'sector': ['Unspecified'], + 'fields': { + 'product_type': [ + 'Apparel', + 'Equipment', + 'Random product type' + ], + 'lat': 51.078389, + 'lng': 16.978477, + 'country': 'US' + }, + 'errors': [] + } + + event_dto = CreateModerationEventDTO( + contributor=self.contributor, + raw_data=input_data, + request_type=ModerationEvent.RequestType.UPDATE.value, + os=production_location + ) + result = self.moderation_event_creator.perform_event_creation( + event_dto + ) + self.assertEqual(result.status_code, 202) + + moderation_event = result.moderation_event + + self.assertIsNotNone(moderation_event) + self.assertTrue(self.is_valid_uuid(moderation_event.uuid)) + + stringified_created_at = moderation_event.created_at.strftime( + '%Y-%m-%dT%H:%M:%S.%f' + ) + 'Z' + self.assertTrue( + self.is_valid_date_with_microseconds(stringified_created_at) + ) + + stringified_updated_at = moderation_event.updated_at.strftime( + '%Y-%m-%dT%H:%M:%S.%f' + ) + 'Z' + self.assertTrue( + self.is_valid_date_with_microseconds(stringified_updated_at) + ) + + self.assertIsNone(moderation_event.status_change_date) + self.assertEqual(moderation_event.request_type, 'UPDATE') + self.assertEqual(moderation_event.raw_data, expected_raw_data) + self.assertEqual(moderation_event.cleaned_data, expected_cleaned_data) + # The geocode result should be empty because the coordinates provided + # did not trigger the Google API geocoding. + self.assertEqual(moderation_event.geocode_result, {}) + self.assertEqual(moderation_event.status, 'PENDING') + self.assertEqual(moderation_event.source, 'SLC') + # The claim field should be None because no claim relation was + # provided during the creation of the moderation event. + self.assertIsNone(moderation_event.claim) + self.assertEqual(moderation_event.contributor, self.contributor) + self.assertEqual(moderation_event.os.id, production_location.id) + @patch('api.geocoding.requests.get') def test_moderation_event_is_created_without_coordinates_properly( self, mock_get): + # This test focuses on testing the case when the coordinates were not + # passed, and geocoding should be performed for the particular + # contribution. mock_get.return_value = Mock(ok=True, status_code=200) mock_get.return_value.json.return_value = geocoding_data @@ -629,7 +778,7 @@ def test_moderation_event_is_created_without_coordinates_properly( } event_dto = CreateModerationEventDTO( - contributor_id=self.contributor, + contributor=self.contributor, raw_data=input_data, request_type=ModerationEvent.RequestType.CREATE.value ) diff --git a/src/django/api/tests/test_moderation_events_add_production_location.py b/src/django/api/tests/test_moderation_events_add_production_location.py index 5a8391738..fead4811a 100644 --- a/src/django/api/tests/test_moderation_events_add_production_location.py +++ b/src/django/api/tests/test_moderation_events_add_production_location.py @@ -2,172 +2,91 @@ from unittest.mock import patch from django.test import override_settings -from django.utils.timezone import now -from rest_framework.test import APITestCase - -from api.models import ModerationEvent, User, Contributor +from api.constants import APIV1MatchTypes from api.models.facility.facility import Facility from api.models.facility.facility_list_item import FacilityListItem from api.models.facility.facility_match import FacilityMatch from api.models.facility.facility_match_temp import FacilityMatchTemp -from api.models.nonstandard_field import NonstandardField from api.models.source import Source +from api.tests.base_moderation_events_production_location_test import ( + BaseModerationEventsProductionLocationTest, +) @override_settings(DEBUG=True) -class ModerationEventsAddProductionLocationTest(APITestCase): - def setUp(self): - super().setUp() - - self.email = "test@example.com" - self.password = "example123" - self.user = User.objects.create(email=self.email) - self.user.set_password(self.password) - self.user.save() - - self.contributor = Contributor.objects.create( - admin=self.user, - name="test contributor", - contrib_type=Contributor.OTHER_CONTRIB_TYPE, +class ModerationEventsAddProductionLocationTest( + BaseModerationEventsProductionLocationTest +): + def get_url(self): + return "/api/v1/moderation-events/{}/production-locations/".format( + self.moderation_event_id ) - self.superuser_email = "admin@example.com" - self.superuser_password = "example123" - self.superuser = User.objects.create_superuser( - email=self.superuser_email, password=self.superuser_password + def test_not_authenticated(self): + response = self.client.post( + self.get_url(), + data=json.dumps({}), + content_type="application/json", ) - self.moderation_event_id = 'f65ec710-f7b9-4f50-b960-135a7ab24ee6' - self.latitude = -53 - self.longitude = 142 - self.moderation_event = ModerationEvent.objects.create( - uuid=self.moderation_event_id, - created_at=now(), - updated_at=now(), - request_type='UPDATE', - raw_data={ - "country": "United Kingdom", - "name": "Test Name", - "address": "Test Address, United Kingdom", - }, - cleaned_data={ - "raw_json": { - "country": "United Kingdom", - "name": "Test Name", - "address": "Test Address, United Kingdom", - }, - "name": "Test Name", - "clean_name": "test name", - "address": "Test Address, United Kingdom", - "clean_address": "test address united kingdom", - "country_code": "GB", - "sector": ["Apparel"], - "fields": { - "country": "United Kingdom", - }, - "errors": [], - }, - geocode_result={ - "geocoded_point": { - "lat": self.latitude, - "lng": self.longitude, - }, - "geocoded_address": "Geocoded Address", - "full_response": { - "status": "OK", - "results": [ - { - "address_components": [ - { - "long_name": "Geocoded Address", - "short_name": "Geocoded Address", - "types": ["street_address"], - }, - ], - "formatted_address": "Geocoded Address", - } - ], - }, - }, - status='PENDING', - source='API', - contributor=self.contributor, - ) + self.assert_not_authenticated(response) def test_permission_denied(self): - self.client.login(email=self.email, password=self.password) + self.login_as_regular_user() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event_id - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(403, response.status_code) + + self.assert_permission_denied(response) def test_invalid_uuid_format(self): - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - "invalid_uuid" - ), + self.get_url().replace(self.moderation_event_id, "invalid_uuid"), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(400, response.status_code) + + self.assert_invalid_uuid_error(response) def test_moderation_event_not_found(self): - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - "f65ec710-f7b9-4f50-b960-135a7ab24ee7" + self.get_url().replace( + self.moderation_event_id, + "f65ec710-f7b9-4f50-b960-135a7ab24ee7", ), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(404, response.status_code) + + self.assert_moderation_event_not_found(response) def test_moderation_event_not_pending(self): self.moderation_event.status = 'RESOLVED' self.moderation_event.save() - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event_id - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(410, response.status_code) + + self.assert_moderation_event_not_pending(response) def test_successful_add_production_location(self): - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event_id - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(201, response.status_code) - self.assertIn("os_id", response.data) - - moderation_event = ModerationEvent.objects.get( - uuid=self.moderation_event_id - ) - self.assertEqual(moderation_event.status, 'APPROVED') - self.assertEqual(moderation_event.os_id, response.data["os_id"]) + self.assert_success_response(response, 201) def test_successful_add_production_location_without_geocode_result(self): self.moderation_event.cleaned_data["fields"]["lat"] = self.latitude @@ -176,127 +95,67 @@ def test_successful_add_production_location_without_geocode_result(self): self.moderation_event.geocode_result = {} self.moderation_event.save() - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event_id - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(201, response.status_code) + self.assert_successful_add_production_location_without_geocode_result( + response, 201 + ) def test_creation_of_source(self): - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event.uuid - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) self.assertEqual(201, response.status_code) source = Source.objects.get(contributor=self.contributor) - self.assertIsNotNone(source) - self.assertEqual(source.source_type, Source.SINGLE) - self.assertEqual(source.is_active, True) - self.assertEqual(source.is_public, True) - self.assertEqual(source.create, True) - def test_creation_of_nonstandard_fields(self): - self.moderation_event.cleaned_data["raw_json"][ - "nonstandard_field_one" - ] = "Nonstandard Field One" - self.moderation_event.cleaned_data["raw_json"][ - "nonstandard_field_two" - ] = "Nonstandard Field Two" + self.assert_source_creation(source) + def test_creation_of_nonstandard_fields(self): + self.add_nonstandard_fields_data() self.moderation_event.save() - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event.uuid - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(201, response.status_code) - - nonstandard_fields = NonstandardField.objects.filter( - contributor=self.contributor - ) - created_fields = nonstandard_fields.values_list( - 'column_name', flat=True - ) - self.assertIn('nonstandard_field_one', created_fields) - self.assertIn('nonstandard_field_two', created_fields) - self.assertNotIn('country', created_fields) - self.assertNotIn('name', created_fields) - self.assertNotIn('address', created_fields) + self.assert_creation_of_nonstandard_fields(response, 201) def test_creation_of_facilitylistitem(self): - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event.uuid - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(201, response.status_code) - facility_list_item = FacilityListItem.objects.get( - facility_id=response.data["os_id"] - ) - self.assertIsNotNone(facility_list_item) - self.assertEqual(facility_list_item.row_index, 0) - self.assertEqual(facility_list_item.status, FacilityListItem.MATCHED) - self.assertEqual( - facility_list_item.name, self.moderation_event.cleaned_data["name"] - ) - self.assertEqual( - facility_list_item.address, - self.moderation_event.cleaned_data["address"], - ) - self.assertEqual( - facility_list_item.country_code, - self.moderation_event.cleaned_data["country_code"], - ) - self.assertEqual( - facility_list_item.clean_name, - self.moderation_event.cleaned_data["clean_name"], - ) - self.assertEqual( - facility_list_item.clean_address, - self.moderation_event.cleaned_data["clean_address"], + self.assert_facilitylistitem_creation( + response, 201, FacilityListItem.MATCHED ) def test_creation_of_facility(self): - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event.uuid - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) + self.assertEqual(201, response.status_code) facility = Facility.objects.get(id=response.data["os_id"]) + self.assertIsNotNone(facility) self.assertEqual( facility.name, self.moderation_event.cleaned_data["name"] @@ -309,68 +168,49 @@ def test_creation_of_facility(self): self.moderation_event.cleaned_data["country_code"], ) - def test_creation_of_facilitymatch(self): - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + def test_creation_of_extended_fields(self): + self.add_extended_fields_data() + self.moderation_event.save() + + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event.uuid - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(201, response.status_code) - facility_list_item = FacilityListItem.objects.get( - facility_id=response.data["os_id"] - ) - facility_match = FacilityMatch.objects.get( - facility_list_item=facility_list_item.id - ) - self.assertIsNotNone(facility_match) - self.assertEqual(facility_match.status, FacilityMatch.AUTOMATIC) - self.assertEqual( - facility_match.facility_id, facility_list_item.facility_id + self.assert_extended_fields_creation(response, 201) + + def test_creation_of_facilitymatch(self): + self.login_as_superuser() + response = self.client.post( + self.get_url(), + data=json.dumps({}), + content_type="application/json", ) - self.assertEqual(facility_match.confidence, 1) - self.assertEqual( - facility_match.results, - { - "match_type": "moderation_event", - }, + + self.assert_facilitymatch_creation( + response, + 201, + APIV1MatchTypes.NEW_PRODUCTION_LOCATION, + FacilityMatch.AUTOMATIC, + FacilityMatch, ) def test_creation_of_facilitymatchtemp(self): - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) + self.login_as_superuser() response = self.client.post( - "/api/v1/moderation-events/{}/production-locations/".format( - self.moderation_event.uuid - ), + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(201, response.status_code) - facility_list_item = FacilityListItem.objects.get( - facility_id=response.data["os_id"] - ) - facility_match_temp = FacilityMatchTemp.objects.get( - facility_list_item=facility_list_item.id - ) - self.assertIsNotNone(facility_match_temp) - self.assertEqual(facility_match_temp.status, FacilityMatch.AUTOMATIC) - self.assertEqual( - facility_match_temp.facility_id, facility_list_item.facility_id - ) - self.assertEqual(facility_match_temp.confidence, 1) - self.assertEqual( - facility_match_temp.results, - { - "match_type": "moderation_event", - }, + self.assert_facilitymatch_creation( + response, + 201, + APIV1MatchTypes.NEW_PRODUCTION_LOCATION, + FacilityMatch.AUTOMATIC, + FacilityMatchTemp, ) @patch( @@ -385,26 +225,11 @@ def test_error_handling_during_processing( "Mocked processing error" ) - self.client.login( - email=self.superuser_email, password=self.superuser_password - ) - + self.login_as_superuser() response = self.client.post( - f"/api/v1/moderation-events/{self.moderation_event_id}/" - "production-locations/", + self.get_url(), data=json.dumps({}), content_type="application/json", ) - self.assertEqual(response.status_code, 500) - self.assertEqual( - response.data, - { - "detail": "An unexpected error occurred while processing the " - "request." - }, - ) - self.assertEqual( - ModerationEvent.objects.get(uuid=self.moderation_event_id).status, - ModerationEvent.Status.PENDING, - ) + self.assert_processing_error(response) diff --git a/src/django/api/tests/test_moderation_events_update.py b/src/django/api/tests/test_moderation_events_update.py index 155be6f87..e54e696d1 100644 --- a/src/django/api/tests/test_moderation_events_update.py +++ b/src/django/api/tests/test_moderation_events_update.py @@ -69,7 +69,6 @@ def test_moderation_event_permission(self): data=json.dumps({"status": "APPROVED"}), content_type="application/json" ) - print(response.json()) self.assertEqual(200, response.status_code) def test_moderation_event_not_found(self): diff --git a/src/django/api/tests/test_moderation_events_update_production_location.py b/src/django/api/tests/test_moderation_events_update_production_location.py new file mode 100644 index 000000000..3f3006e3a --- /dev/null +++ b/src/django/api/tests/test_moderation_events_update_production_location.py @@ -0,0 +1,284 @@ +import json +from unittest.mock import patch + +from django.contrib.gis.geos import Point +from django.test import override_settings + +from api.constants import APIV1CommonErrorMessages, APIV1MatchTypes +from api.models.facility.facility import Facility +from api.models.facility.facility_list_item import FacilityListItem +from api.models.facility.facility_match import FacilityMatch +from api.models.facility.facility_match_temp import FacilityMatchTemp +from api.models.source import Source +from api.tests.base_moderation_events_production_location_test import ( + BaseModerationEventsProductionLocationTest, +) + + +@override_settings(DEBUG=True) +class ModerationEventsUpdateProductionLocationTest( + BaseModerationEventsProductionLocationTest +): + def setUp(self): + super().setUp() + + self.source = Source.objects.create( + source_type=Source.SINGLE, + is_active=True, + is_public=True, + contributor=self.contributor, + ) + + self.list_item = FacilityListItem.objects.create( + name="Item", + address="Address", + country_code="GB", + sector=["Apparel"], + row_index=1, + geocoded_point=Point(0, 0), + status=FacilityListItem.MATCHED, + source=self.source, + ) + + self.os_id = "GB2024338H7FA8R" + self.facility_one = Facility.objects.create( + id=self.os_id, + name="Name", + address="Address", + country_code="GB", + location=Point(0, 0), + created_from=self.list_item, + ) + + def get_url(self): + return "/api/v1/moderation-events/{}/production-locations/{}/".format( + self.moderation_event_id, self.os_id + ) + + def test_not_authenticated(self): + response = self.client.post( + self.get_url(), + data=json.dumps({}), + content_type="application/json", + ) + + self.assert_not_authenticated(response) + + def test_permission_denied(self): + self.client.login(email=self.email, password=self.password) + response = self.client.patch( + self.get_url(), + data=json.dumps({}), + content_type="application/json", + ) + + self.assert_permission_denied(response) + + def test_invalid_uuid_format(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url().replace(self.moderation_event_id, "invalid_uuid"), + data=json.dumps({}), + content_type="application/json", + ) + + self.assert_invalid_uuid_error(response) + + def test_moderation_event_not_found(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url().replace( + self.moderation_event_id, + "f65ec710-f7b9-4f50-b960-135a7ab24ee7", + ), + data=json.dumps({}), + content_type="application/json", + ) + + self.assert_moderation_event_not_found(response) + + def test_moderation_event_not_pending(self): + self.moderation_event.status = 'RESOLVED' + self.moderation_event.save() + + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({}), + content_type="application/json", + ) + + self.assert_moderation_event_not_pending(response) + + def test_invalid_os_id_format(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url().replace(self.os_id, "invalid_os_id"), + data=json.dumps({}), + content_type="application/json", + ) + + self.assertEqual(400, response.status_code) + self.assertEqual( + "The request path parameter is invalid.", response.data["detail"] + ) + self.assertEqual("os_id", response.data["errors"][0]["field"]) + self.assertEqual( + APIV1CommonErrorMessages.LOCATION_ID_NOT_VALID, + response.data["errors"][0]["detail"], + ) + + def test_no_production_location_found_with_os_id(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url().replace(self.os_id, "UA2024341550R5D"), + data=json.dumps({}), + content_type="application/json", + ) + + self.assertEqual(404, response.status_code) + self.assertEqual( + "The request path parameter is invalid.", response.data["detail"] + ) + self.assertEqual("os_id", response.data["errors"][0]["field"]) + self.assertEqual( + APIV1CommonErrorMessages.LOCATION_NOT_FOUND, + response.data["errors"][0]["detail"], + ) + + def test_successful_update_production_location(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + + self.assert_success_response(response, 200) + + def test_successful_add_production_location_without_geocode_result(self): + self.moderation_event.cleaned_data["fields"]["lat"] = self.latitude + self.moderation_event.cleaned_data["fields"]["lng"] = self.longitude + + self.moderation_event.geocode_result = {} + self.moderation_event.save() + + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + + self.assert_successful_add_production_location_without_geocode_result( + response, 200 + ) + + def test_creation_of_source(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + self.assertEqual(200, response.status_code) + + sources = Source.objects.filter(contributor=self.contributor).order_by( + "-created_at" + ) + self.assertEqual(sources.count(), 2) + + source = sources.first() + + self.assert_source_creation(source) + + def test_creation_of_nonstandard_fields(self): + self.add_nonstandard_fields_data() + self.moderation_event.save() + + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + + self.assert_creation_of_nonstandard_fields(response, 200) + + def test_creation_of_facilitylistitem(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + + self.assert_facilitylistitem_creation( + response, 200, FacilityListItem.CONFIRMED_MATCH + ) + + def test_creation_of_extended_fields(self): + self.add_extended_fields_data() + self.moderation_event.save() + + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + + self.assert_extended_fields_creation(response, 200) + + def test_creation_of_facilitymatch(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + + self.assert_facilitymatch_creation( + response, + 200, + APIV1MatchTypes.CONFIRMED_MATCH, + FacilityMatch.CONFIRMED, + FacilityMatch, + ) + + def test_creation_of_facilitymatchtemp(self): + self.login_as_superuser() + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + self.assert_facilitymatch_creation( + response, + 200, + APIV1MatchTypes.CONFIRMED_MATCH, + FacilityMatch.CONFIRMED, + FacilityMatchTemp, + ) + + @patch( + 'api.moderation_event_actions.approval.' + 'update_production_location.UpdateProductionLocation.' + 'process_moderation_event' + ) + def test_error_handling_during_processing( + self, mock_process_moderation_event + ): + mock_process_moderation_event.side_effect = Exception( + "Mocked processing error" + ) + + self.login_as_superuser() + + response = self.client.patch( + self.get_url(), + data=json.dumps({"os_id": self.os_id}), + content_type="application/json", + ) + + self.assert_processing_error(response) diff --git a/src/django/api/tests/test_production_locations_partial_update.py b/src/django/api/tests/test_production_locations_partial_update.py new file mode 100644 index 000000000..fe4443757 --- /dev/null +++ b/src/django/api/tests/test_production_locations_partial_update.py @@ -0,0 +1,336 @@ +import json + +from unittest.mock import Mock, patch +from rest_framework.test import APITestCase +from django.urls import reverse +from allauth.account.models import EmailAddress +from waffle.testutils import override_switch +from django.contrib.gis.geos import Point + +from api.models.moderation_event import ModerationEvent +from api.models.contributor.contributor import Contributor +from api.models.facility.facility_list import FacilityList +from api.models.facility.facility_list_item import FacilityListItem +from api.models.source import Source +from api.models.user import User +from api.models.facility.facility import Facility +from api.views.v1.url_names import URLNames +from api.tests.test_data import geocoding_data + + +class TestProductionLocationsPartialUpdate(APITestCase): + def setUp(self): + # Create a valid Contributor specifically for this test. + user_email = 'test@example.com' + user_password = 'example123' + self.user = User.objects.create(email=user_email) + self.user.set_password(user_password) + self.user.save() + + EmailAddress.objects.create( + user=self.user, email=user_email, verified=True, primary=True + ) + + contributor = Contributor.objects.create( + admin=self.user, + name='test contributor 1', + contrib_type=Contributor.OTHER_CONTRIB_TYPE + ) + + self.login(user_email, user_password) + + # Create a valid Facility entry specifically for this test, to be used + # for making PATCH requests for this production location. + 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=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 + ) + self.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 + ) + + self.url = reverse( + URLNames.PRODUCTION_LOCATIONS + '-detail', + args=[self.production_location.id]) + self.common_valid_req_body = json.dumps({ + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US' + }) + + def login(self, email: str, password: str) -> None: + self.client.logout() + self.client.login(email=email, password=password) + + def test_only_registered_and_confirmed_has_access(self): + expected_response_body = { + 'detail': ( + 'User must be registered and have confirmed their email to ' + 'access.' + ) + } + + saved_email_address = EmailAddress.objects.get_primary(self.user) + # Purposely make the email address unverified to trigger a permission + # error. + saved_email_address.verified = False + saved_email_address.save() + + response = self.client.patch( + self.url, + self.common_valid_req_body, + content_type='application/json' + ) + self.assertEqual(response.status_code, 403) + self.assertEqual( + json.loads(response.content), + expected_response_body + ) + + @patch('api.geocoding.requests.get') + def test_default_throttling_is_applied(self, mock_get): + mock_get.return_value = Mock(ok=True, status_code=200) + mock_get.return_value.json.return_value = geocoding_data + + # Simulate 30 requests. + for _ in range(30): + response = self.client.patch( + self.url, + self.common_valid_req_body, + content_type='application/json' + ) + self.assertEqual(response.status_code, 202) + + response_body_dict = json.loads(response.content) + response_moderation_id = response_body_dict.get('moderation_id') + moderation_event = ModerationEvent.objects.get( + pk=response_moderation_id + ) + stringified_created_at = moderation_event.created_at.strftime( + '%Y-%m-%dT%H:%M:%S.%f' + ) + 'Z' + + self.assertEqual( + response_body_dict.get('moderation_status'), + 'PENDING' + ) + self.assertEqual( + response_body_dict.get('created_at'), + stringified_created_at + ) + self.assertEqual( + response_body_dict.get('os_id'), + self.production_location.id + ) + self.assertEqual(len(response_body_dict), 4) + + # Now simulate the 31st request, which should be throttled. + throttled_response = self.client.patch( + self.url, + self.common_valid_req_body, + content_type='application/json' + ) + throttled_response_body_dict = json.loads(throttled_response.content) + self.assertEqual(throttled_response.status_code, 429) + self.assertEqual(len(throttled_response_body_dict), 1) + + @override_switch('disable_list_uploading', active=True) + def test_client_cannot_patch_when_upload_is_blocked(self): + expected_error = ( + 'Open Supply Hub is undergoing maintenance and not accepting new ' + 'data at the moment. Please try again in a few minutes.' + ) + + response = self.client.patch( + self.url, + self.common_valid_req_body, + content_type='application/json' + ) + self.assertEqual(response.status_code, 503) + + response_body_dict = json.loads(response.content) + error = response_body_dict.get('detail') + self.assertEqual(error, expected_error) + self.assertEqual(len(response_body_dict), 1) + + def test_location_not_found(self): + expected_error = 'The location with the given id was not found.' + url_with_nonexistent_id = reverse( + URLNames.PRODUCTION_LOCATIONS + '-detail', + args=['TT11111111111TT'] + ) + + response = self.client.patch( + url_with_nonexistent_id, + self.common_valid_req_body, + content_type='application/json' + ) + self.assertEqual(response.status_code, 404) + + response_body_dict = json.loads(response.content) + error = response_body_dict.get('detail') + self.assertEqual(error, expected_error) + self.assertEqual(len(response_body_dict), 1) + + def test_endpoint_supports_only_dictionary_structure(self): + expected_general_error = ( + 'The request body is invalid.' + ) + expected_specific_error = ( + 'Invalid data. Expected a dictionary (object), but got list.' + ) + expected_error_field = 'non_field_errors' + + response = self.client.patch( + self.url, + [1, 2, 3], + content_type='application/json' + ) + self.assertEqual(response.status_code, 400) + + response_body_dict = json.loads(response.content) + self.assertEqual(len(response_body_dict), 2) + + general_error = response_body_dict['detail'] + errors_list_length = len(response_body_dict['errors']) + specific_error = response_body_dict['errors'][0]['detail'] + error_field = response_body_dict['errors'][0]['field'] + self.assertEqual(general_error, expected_general_error) + self.assertEqual(errors_list_length, 1) + self.assertEqual(specific_error, expected_specific_error) + self.assertEqual(error_field, expected_error_field) + + @patch('api.geocoding.requests.get') + def test_moderation_event_created_with_valid_data( + self, + mock_get): + mock_get.return_value = Mock(ok=True, status_code=200) + mock_get.return_value.json.return_value = geocoding_data + + valid_req_body = json.dumps({ + 'source': 'SLC', + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US', + 'location_type': 'Coating', + 'coordinates': { + 'lat': 51.078389, + 'lng': 16.978477 + } + }) + + response = self.client.patch( + self.url, + valid_req_body, + content_type='application/json' + ) + self.assertEqual(response.status_code, 202) + + response_body_dict = json.loads(response.content) + response_moderation_id = response_body_dict.get('moderation_id') + moderation_event = ModerationEvent.objects.get( + pk=response_moderation_id + ) + stringified_created_at = moderation_event.created_at.strftime( + '%Y-%m-%dT%H:%M:%S.%f' + ) + 'Z' + + # Check the response. + self.assertEqual( + response_body_dict.get('moderation_status'), + 'PENDING' + ) + self.assertEqual( + response_body_dict.get('created_at'), + stringified_created_at + ) + self.assertEqual( + response_moderation_id, + str(moderation_event.uuid) + ) + self.assertEqual( + response_body_dict.get('os_id'), + self.production_location.id + ) + self.assertEqual(len(response_body_dict), 4) + + @patch('api.geocoding.requests.get') + def test_moderation_event_not_created_with_invalid_data( + self, + mock_get): + mock_get.return_value = Mock(ok=True, status_code=200) + mock_get.return_value.json.return_value = geocoding_data + + expected_response_body = { + 'detail': 'The request body is invalid.', + 'errors': [ + { + 'field': 'sector', + 'detail': ('Expected value for sector to be a string or a ' + "list of strings but got {'some_key': 1135}.") + }, + { + 'field': 'location_type', + 'detail': ( + 'Expected value for location_type to be a ' + 'string or a list of strings but got ' + "{'some_key': 1135}." + ) + } + ] + } + initial_moderation_event_count = ModerationEvent.objects.count() + + invalid_req_body = json.dumps({ + 'source': 'API', + 'name': 'Blue Horizon Facility', + 'address': '990 Spring Garden St., Philadelphia PA 19123', + 'country': 'US', + 'sector': {'some_key': 1135}, + 'parent_company': 'string', + 'product_type': [ + 'string' + ], + 'location_type': {'some_key': 1135}, + 'processing_type': [ + 'string' + ], + 'number_of_workers': { + 'min': 0, + 'max': 0 + }, + 'coordinates': { + 'lat': 10, + 'lng': 20 + } + }) + + response = self.client.patch( + self.url, + invalid_req_body, + content_type='application/json' + ) + self.assertEqual(response.status_code, 422) + + response_body_dict = json.loads(response.content) + self.assertEqual(response_body_dict, expected_response_body) + # Ensure that no ModerationEvent record has been created. + self.assertEqual(ModerationEvent.objects.count(), + initial_moderation_event_count) diff --git a/src/django/api/views/contributor/all_contributors.py b/src/django/api/views/contributor/all_contributors.py index 16f97c5ac..380ef477c 100644 --- a/src/django/api/views/contributor/all_contributors.py +++ b/src/django/api/views/contributor/all_contributors.py @@ -8,19 +8,25 @@ @throttle_classes([]) def all_contributors(_): """ - Returns list contributors as a list of tuples of contributor IDs and names. + Returns list contributors as a list of tuples of + contributor IDs, names and types. ## Sample Response [ - [1, "Contributor One"] - [2, "Contributor Two"] + [1, "Contributor One", "Brand/Retailer"] + [2, "Contributor Two", "Service Provider"] ] """ response_data = [ - (contributor.id, contributor.name) - for contributor - in active_contributors().order_by('name') + ( + contributor.id, + contributor.name, + contributor.other_contrib_type + if contributor.contrib_type == "Other" + else contributor.contrib_type + ) + for contributor in active_contributors().order_by('name') ] return Response(response_data) diff --git a/src/django/api/views/v1/moderation_events.py b/src/django/api/views/v1/moderation_events.py index dd7702f96..6905d18e8 100644 --- a/src/django/api/views/v1/moderation_events.py +++ b/src/django/api/views/v1/moderation_events.py @@ -3,14 +3,19 @@ from django.http import QueryDict from rest_framework import status from rest_framework.response import Response +from rest_framework.exceptions import ValidationError, NotFound from rest_framework.viewsets import ViewSet from rest_framework.decorators import action +from api.constants import ( + APIV1CommonErrorMessages, + APIV1ModerationEventErrorMessages +) from api.moderation_event_actions.approval.add_production_location \ import AddProductionLocation -from api.moderation_event_actions.approval.event_approval_context \ - import EventApprovalContext -from api.permissions import IsRegisteredAndConfirmed +from api.moderation_event_actions.approval.update_production_location \ + import UpdateProductionLocation +from api.permissions import IsRegisteredAndConfirmed, IsSuperuser from api.serializers.v1.moderation_event_update_serializer \ import ModerationEventUpdateSerializer from api.serializers.v1.moderation_events_serializer \ @@ -22,9 +27,10 @@ import ModerationEventsQueryBuilder from api.views.v1.opensearch_query_builder.opensearch_query_director import \ OpenSearchQueryDirector + from api.views.v1.utils import ( handle_errors_decorator, - serialize_params + serialize_params, ) @@ -32,6 +38,18 @@ class ModerationEvents(ViewSet): swagger_schema = None permission_classes = [IsRegisteredAndConfirmed] + def get_permissions(self): + action_permissions = { + 'partial_update': [IsSuperuser], + 'add_production_location': [IsSuperuser], + 'update_production_location': [IsSuperuser], + } + + permission_classes = action_permissions.get( + self.action, self.permission_classes + ) + return [permission() for permission in permission_classes] + @staticmethod def __init_opensearch() -> Tuple[OpenSearchService, OpenSearchQueryDirector]: @@ -86,69 +104,78 @@ def retrieve(self, _, pk=None): events = response.get("data", []) if len(events) == 0: - return Response( - data={ - "detail": 'The moderation event with the ' - 'given uuid was not found.', - }, - status=status.HTTP_404_NOT_FOUND, + raise NotFound( + detail=APIV1ModerationEventErrorMessages.EVENT_NOT_FOUND ) return Response(events[0]) - @handle_errors_decorator def partial_update(self, request, pk=None): - ModerationEventsService.validate_user_permissions(request) - ModerationEventsService.validate_uuid(pk) - event = ModerationEventsService.fetch_moderation_event_by_uuid( - pk - ) + event = ModerationEventsService.fetch_moderation_event_by_uuid(pk) serializer = ModerationEventUpdateSerializer( - event, - data=request.data, - partial=True + event, data=request.data, partial=True ) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + if not serializer.is_valid(): + raise ValidationError( + { + "detail": APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR, + "errors": [serializer.errors], + } + ) - return Response( - { - "detail": 'The request body contains ' - 'invalid or missing fields.', - "error": [serializer.errors] - }, - status=status.HTTP_400_BAD_REQUEST - ) + serializer.save() - @handle_errors_decorator - @action(detail=True, methods=['POST'], url_path='production-locations') - def add_production_location(self, request, pk=None): - ModerationEventsService.validate_user_permissions(request) + return Response(serializer.data, status=status.HTTP_200_OK) + @action( + detail=True, + methods=['POST'], + url_path='production-locations', + ) + def add_production_location(self, _, pk=None): ModerationEventsService.validate_uuid(pk) - event = ModerationEventsService.fetch_moderation_event_by_uuid( - pk - ) - + event = ModerationEventsService.fetch_moderation_event_by_uuid(pk) ModerationEventsService.validate_moderation_status(event.status) - add_production_location = EventApprovalContext( - AddProductionLocation(event) - ) + add_production_location_processor = AddProductionLocation(event) try: - item = add_production_location.run_processing() + item = add_production_location_processor.process_moderation_event() except Exception as error: - return ModerationEventsService.handle_processing_error( - error - ) + return ModerationEventsService.handle_processing_error(error) return Response( {"os_id": item.facility_id}, status=status.HTTP_201_CREATED ) + + @action( + detail=True, + methods=['PATCH'], + url_path='production-locations/(?P[^/.]+)', + ) + def update_production_location(self, _, pk=None, os_id=None): + ModerationEventsService.validate_uuid(pk) + + event = ModerationEventsService.fetch_moderation_event_by_uuid(pk) + + ModerationEventsService.validate_moderation_status(event.status) + + ModerationEventsService.validate_location_os_id(os_id) + + update_production_location_processor = UpdateProductionLocation( + event, os_id + ) + + try: + item = ( + update_production_location_processor.process_moderation_event() + ) + except Exception as error: + return ModerationEventsService.handle_processing_error(error) + + return Response({"os_id": item.facility_id}, status=status.HTTP_200_OK) diff --git a/src/django/api/views/v1/production_locations.py b/src/django/api/views/v1/production_locations.py index c8aa76969..5034a5a05 100644 --- a/src/django/api/views/v1/production_locations.py +++ b/src/django/api/views/v1/production_locations.py @@ -28,6 +28,7 @@ from api.moderation_event_actions.creation.dtos.create_moderation_event_dto \ import CreateModerationEventDTO from api.models.moderation_event import ModerationEvent +from api.models.facility.facility import Facility from api.throttles import DataUploadThrottle from api.constants import ( APIV1CommonErrorMessages, @@ -69,12 +70,14 @@ def __get_action_permissions(self) -> List: ''' Returns the list of permissions specific to the current action. ''' - if self.action == 'create': + if (self.action == 'create' + or self.action == 'partial_update'): return [IsRegisteredAndConfirmed] return [] def get_throttles(self): - if self.action == 'create': + if (self.action == 'create' + or self.action == 'partial_update'): return [DataUploadThrottle()] # Call the parent method to use the default throttling setup in the @@ -85,9 +88,10 @@ def get_parsers(self): ''' Override the default parser classes for specific actions. ''' - if self.request.method == 'POST': - # Use JSONParser for the 'create' action to restrict all media - # types except 'application/json'. + if (self.request.method == 'POST' + or self.request.method == 'PATCH'): + # Use JSONParser for the 'create' and 'partial_update' actions to + # restrict all media types except 'application/json'. return [JSONParser()] # Call the parent method to use the default parsers setup in the @@ -165,7 +169,7 @@ def create(self, request): location_contribution_strategy ) event_dto = CreateModerationEventDTO( - contributor_id=request.user.contributor, + contributor=request.user.contributor, raw_data=request.data, request_type=ModerationEvent.RequestType.CREATE.value ) @@ -184,3 +188,57 @@ def create(self, request): }, status=result.status_code ) + + @transaction.atomic + def partial_update(self, request, pk=None): + if switch_is_active('disable_list_uploading'): + raise ServiceUnavailableException( + APIV1CommonErrorMessages.MAINTENANCE_MODE + ) + if not Facility.objects.filter(id=pk).exists(): + specific_error = APIV1CommonErrorMessages.LOCATION_NOT_FOUND + return Response( + {'detail': specific_error}, + status=status.HTTP_404_NOT_FOUND + ) + if not isinstance(request.data, dict): + data_type = type(request.data).__name__ + specific_error = APIV1LocationContributionErrorMessages \ + .invalid_data_type_error(data_type) + return Response( + { + 'detail': APIV1CommonErrorMessages.COMMON_REQ_BODY_ERROR, + 'errors': [{ + 'field': NON_FIELD_ERRORS_KEY, + 'detail': specific_error + }] + }, + status=status.HTTP_400_BAD_REQUEST + ) + + location_contribution_strategy = LocationContribution() + moderation_event_creator = ModerationEventCreator( + location_contribution_strategy + ) + event_dto = CreateModerationEventDTO( + contributor=request.user.contributor, + os=Facility.objects.get(id=pk), + raw_data=request.data, + request_type=ModerationEvent.RequestType.UPDATE.value + ) + result = moderation_event_creator.perform_event_creation(event_dto) + + if result.errors: + return Response( + result.errors, + status=result.status_code) + + return Response( + { + 'os_id': result.os.id, + 'moderation_id': result.moderation_event.uuid, + 'moderation_status': result.moderation_event.status, + 'created_at': result.moderation_event.created_at + }, + status=result.status_code + ) diff --git a/src/logstash/indexes/moderation_events.json b/src/logstash/indexes/moderation_events.json index 35f9a1d95..518e87b1f 100644 --- a/src/logstash/indexes/moderation_events.json +++ b/src/logstash/indexes/moderation_events.json @@ -8,6 +8,7 @@ "number_of_replicas": 1 }, "mappings": { + "dynamic": false, "properties": { "moderation_id": { "type": "keyword"