Skip to content

Commit

Permalink
Handling changing registration purpose (#2436)
Browse files Browse the repository at this point in the history
* Chore: added handle_change_of_registration_purpose (including unit and integration tests). Backend logic to delete irrelevant data when an operation's registration purpose is changed by industry_user
  • Loading branch information
andrea-williams authored Jan 17, 2025
1 parent abc0ed9 commit a287ae4
Show file tree
Hide file tree
Showing 10 changed files with 1,208 additions and 56 deletions.
15 changes: 12 additions & 3 deletions bc_obps/registration/schema/v2/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ class OperationInformationIn(ModelSchema):
registration_purpose: Optional[Operation.Purposes] = None
regulated_products: Optional[List[int]] = None
activities: List[int]
boundary_map: str
process_flow_diagram: str
naics_code_id: int
boundary_map: Optional[str] = None
process_flow_diagram: Optional[str] = None
naics_code_id: Optional[int] = None
opt_in: Optional[bool] = False
secondary_naics_code_id: Optional[int] = None
tertiary_naics_code_id: Optional[int] = None
multiple_operators_array: Optional[List[MultipleOperatorIn]] = None
Expand Down Expand Up @@ -258,6 +259,14 @@ class Meta:
fields = ['date_of_first_shipment']


class OperationNewEntrantApplicationRemove(ModelSchema):
id: int

class Meta:
model = Operation
fields = ['id']


class OperationRepresentativeOut(ModelSchema):
class Meta:
model = Contact
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,14 @@ def test_operation_registration_workflow(self, operation_type, purpose):
assert self.operation.name == f'{purpose} name'
assert self.operation.type == operation_type
assert self.operation.operator_id == self.approved_user_operator.operator_id
assert self.operation.naics_code_id == 1
assert self.operation.secondary_naics_code_id == 2
assert self.operation.tertiary_naics_code_id == 3
assert self.operation.bcghg_id_id == self.bcghg_id.id
assert self.operation.activities.count() == 2
assert list(self.operation.activities.values_list('id', flat=True)) == [1, 2]

if purpose != Operation.Purposes.ELECTRICITY_IMPORT_OPERATION:
assert self.operation.naics_code_id == 1
assert self.operation.secondary_naics_code_id == 2
assert self.operation.tertiary_naics_code_id == 3
assert self.operation.activities.count() == 2
assert list(self.operation.activities.values_list('id', flat=True)) == [1, 2]

if purpose == Operation.Purposes.NEW_ENTRANT_OPERATION:
assert self.operation.date_of_first_shipment == "On or after April 1, 2024"
Expand All @@ -228,16 +230,18 @@ def test_operation_registration_workflow(self, operation_type, purpose):
assert self.operation.opted_in_operation.updated_by == self.user
assert self.operation.opted_in_operation.updated_at is not None
else:
assert self.operation.opt_in is None
assert self.operation.opt_in is False
assert self.operation.opted_in_operation_id is None

# make sure we have the two required documents
assert (
self.operation.documents.filter(Q(type__name='process_flow_diagram') | Q(type__name='boundary_map'))
.distinct()
.count()
== 2
)
# (unless the registration_purpose is EIO, in which case no documents are required)
if purpose != Operation.Purposes.ELECTRICITY_IMPORT_OPERATION:
assert (
self.operation.documents.filter(Q(type__name='process_flow_diagram') | Q(type__name='boundary_map'))
.distinct()
.count()
== 2
)

# make sure we have the facility
if operation_type == OperationTypes.LFO:
Expand Down
1 change: 0 additions & 1 deletion bc_obps/service/data_access_service/document_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
class DocumentDataAccessService:
@classmethod
def get_operation_document_by_type(cls, operation_id: UUID, document_type: str) -> Document | None:

operation = OperationDataAccessService.get_by_id(operation_id=operation_id)

try:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from uuid import UUID
from service.operation_service import OperationService
from registration.schema.v2.operation import OptedInOperationDetailIn
from registration.models.opted_in_operation_detail import OptedInOperationDetail
from registration.models.operation import Operation
from registration.utils import update_model_instance


Expand All @@ -12,6 +14,9 @@ def update_opted_in_operation_detail(
opted_in_operation_detail_id: int,
opted_in_operation_detail_data: OptedInOperationDetailIn,
) -> OptedInOperationDetail:
"""
Updates an existing OptedInOperationDetail instance.
"""
opted_in_operation_detail = OptedInOperationDetail.objects.get(id=opted_in_operation_detail_id)
updated_opted_in_operation_detail_instance = update_model_instance(
opted_in_operation_detail,
Expand All @@ -21,3 +26,14 @@ def update_opted_in_operation_detail(
updated_opted_in_operation_detail_instance.save()
updated_opted_in_operation_detail_instance.set_create_or_update(user_guid)
return updated_opted_in_operation_detail_instance

@classmethod
def archive_or_delete_opted_in_operation_detail(cls, user_guid: UUID, operation_id: UUID) -> None:
operation = OperationService.get_if_authorized(user_guid, operation_id)
if operation.opted_in_operation:
opted_in_operation_detail_id = operation.opted_in_operation.id
opted_in_operation_detail = OptedInOperationDetail.objects.get(pk=opted_in_operation_detail_id)
if operation.status == Operation.Statuses.REGISTERED:
opted_in_operation_detail.set_archive(user_guid)
else:
opted_in_operation_detail.delete()
19 changes: 19 additions & 0 deletions bc_obps/service/document_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,22 @@ def create_or_replace_operation_document(
# if there is no existing document, create a new one
document = DocumentDataAccessService.create_document(user_guid, file_data, document_type)
return document, True

@classmethod
def archive_or_delete_operation_document(cls, user_guid: UUID, operation_id: UUID, document_type: str) -> bool:
"""
This function receives an operation ID and document type.
If the operation's status != "Registered", the specified document will be deleted.
If the operation's status == "Registered", and the specified document_type for the operation_id can be found, this
function will archive that document.
:returns: bool to indicate whether the document was successfully archived or deleted.
"""
operation = OperationDataAccessService.get_by_id(operation_id)
document = DocumentDataAccessService.get_operation_document_by_type(operation_id, document_type)
if document and operation.status == Operation.Statuses.REGISTERED:
document.set_archive(user_guid)
return True
elif document:
document.delete()
return True
return False
163 changes: 132 additions & 31 deletions bc_obps/service/operation_service_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,17 @@ def create_operation_representative(

@classmethod
@transaction.atomic()
def create_opted_in_operation_detail(cls, user_guid: UUID, operation: Operation) -> Operation:
def create_opted_in_operation_detail(cls, user_guid: UUID, operation_id: UUID) -> Operation:
"""
Creates an empty OptedInOperationDetail instance for the specified operation.
This method is called before any opt-in data is available.
"""
operation = OperationDataAccessService.get_by_id(operation_id)

# industry users can only edit operations that belong to their operator
if not operation.user_has_access(user_guid):
raise Exception(UNAUTHORIZED_MESSAGE)

operation.opt_in = True
operation.opted_in_operation = OptedInOperationDetail.objects.create(created_by_id=user_guid)
operation.save(update_fields=['opted_in_operation', 'opt_in'])
Expand All @@ -168,6 +178,17 @@ def remove_operation_representative(
operation.set_create_or_update(user_guid)
return OperationRepresentativeRemove(id=payload.id)

@classmethod
@transaction.atomic()
def remove_opted_in_operation_detail(cls, user_guid: UUID, operation_id: UUID) -> Operation:
operation: Operation = OperationService.get_if_authorized(user_guid, operation_id)
operation.opt_in = False
OptedInOperationDataAccessService.archive_or_delete_opted_in_operation_detail(user_guid, operation_id)
operation.opted_in_operation = None
operation.save(update_fields=['opt_in', 'opted_in_operation'])

return operation

@classmethod
@transaction.atomic()
def create_or_update_operation_v2(
Expand All @@ -177,6 +198,12 @@ def create_or_update_operation_v2(
operation_id: UUID | None = None,
) -> Operation:
user_operator: UserOperator = UserDataAccessService.get_user_operator_by_user(user_guid)
# will need to retrieve operation as it exists currently in DB first, to determine whether there's been a change to the RP

if operation_id:
operation = OperationService.get_if_authorized(user_guid, operation_id)
if payload.registration_purpose != operation.registration_purpose:
payload = cls.handle_change_of_registration_purpose(user_guid, operation, payload)

operation_data = payload.dict(
include={
Expand All @@ -187,35 +214,47 @@ def create_or_update_operation_v2(
'tertiary_naics_code_id',
'date_of_first_shipment',
'registration_purpose',
'opt_in',
}
)
operation_data['pk'] = operation_id if operation_id else None
operation_data['operator_id'] = user_operator.operator_id
if operation_id:
operation_data['pk'] = operation_id

operation: Operation
operation, _ = Operation.custom_update_or_create(Operation, user_guid, **operation_data)

# set m2m relationships
operation.activities.set(payload.activities)
if payload.regulated_products:
operation.regulated_products.set(payload.regulated_products)
operation.activities.set(payload.activities) if payload.activities else operation.activities.clear()
operation.regulated_products.set(
payload.regulated_products
) if payload.regulated_products else operation.regulated_products.clear()

# create or replace documents
operation_documents = [
doc
for doc, created in [
DocumentService.create_or_replace_operation_document(
user_guid,
operation.id,
payload.boundary_map, # type: ignore # mypy is not aware of the schema validator
'boundary_map',
*(
[
DocumentService.create_or_replace_operation_document(
user_guid,
operation.id,
payload.boundary_map, # type: ignore # mypy is not aware of the schema validator
'boundary_map',
)
]
if payload.boundary_map
else []
),
DocumentService.create_or_replace_operation_document(
user_guid,
operation.id,
payload.process_flow_diagram, # type: ignore # mypy is not aware of the schema validator
'process_flow_diagram',
*(
[
DocumentService.create_or_replace_operation_document(
user_guid,
operation.id,
payload.process_flow_diagram, # type: ignore # mypy is not aware of the schema validator
'process_flow_diagram',
)
]
if payload.process_flow_diagram
else []
),
*(
[
Expand All @@ -235,7 +274,7 @@ def create_or_update_operation_v2(
operation.documents.add(*operation_documents)

if operation.registration_purpose == Operation.Purposes.OPTED_IN_OPERATION:
operation = cls.create_opted_in_operation_detail(user_guid, operation)
operation = cls.create_opted_in_operation_detail(user_guid, operation.id)

# handle multiple operators
multiple_operators_data = payload.multiple_operators_array
Expand All @@ -261,10 +300,10 @@ def register_operation_information(
)

if operation.registration_purpose == Operation.Purposes.OPTED_IN_OPERATION:
# TODO in ticket 2169 - replace this with create_or_update_-----
operation = cls.create_opted_in_operation_detail(user_guid, operation)
operation = cls.create_opted_in_operation_detail(user_guid, operation.id)

cls.update_status(user_guid, operation.id, Operation.Statuses.DRAFT)
if operation.status == Operation.Statuses.NOT_STARTED:
cls.update_status(user_guid, operation.id, Operation.Statuses.DRAFT)
operation.set_create_or_update(user_guid)

return operation
Expand Down Expand Up @@ -361,6 +400,18 @@ def is_operation_opt_in_information_complete(cls, operation: Operation) -> bool:

return all(getattr(opted_in_operation, field) is not None for field in required_fields)

@classmethod
def is_operation_new_entrant_information_complete(cls, operation: Operation) -> bool:
"""
This function checks whether the expected data for new-entrant operations has been saved.
"""
if (
operation.date_of_first_shipment is None
or not operation.documents.filter(type=DocumentType.objects.get(name="new_entrant_application")).exists()
):
return False
return True

@classmethod
def raise_exception_if_operation_missing_registration_information(cls, operation: Operation) -> None:
"""
Expand All @@ -384,24 +435,32 @@ def check_conditions() -> Generator[Tuple[Callable[[], bool], str], None, None]:
lambda: FacilityDesignatedOperationTimeline.objects.filter(operation=operation).exists(),
"Operation must have at least one facility.",
)
yield lambda: operation.activities.exists(), "Operation must have at least one reporting activity."
# unless the registration purpose is Electricity Import Operation, the operation should have at least 1 reporting activity
yield (
lambda: not (
operation.registration_purpose != Operation.Purposes.ELECTRICITY_IMPORT_OPERATION
and not operation.activities.exists()
),
"Operation must have at least one reporting activity.",
)

# Check if the operation has both a process flow diagram and a boundary map
# Check if the operation has both a process flow diagram and a boundary map (unless it is an EIO)
yield (
lambda: operation.documents.filter(Q(type__name='process_flow_diagram') | Q(type__name='boundary_map'))
.distinct()
.count()
== 2,
lambda: not (
operation.registration_purpose != Operation.Purposes.ELECTRICITY_IMPORT_OPERATION
and operation.documents.filter(Q(type__name='process_flow_diagram') | Q(type__name='boundary_map'))
.distinct()
.count()
< 2
),
"Operation must have a process flow diagram and a boundary map.",
)
yield (
lambda: not (
operation.registration_purpose == Operation.Purposes.NEW_ENTRANT_OPERATION
and not operation.documents.filter(
type=DocumentType.objects.get(name='new_entrant_application')
).exists()
and not cls.is_operation_new_entrant_information_complete(operation)
),
"Operation must have a signed statutory declaration if it is a new entrant.",
"Operation must have a signed statutory declaration and date of first shipment if it is a new entrant.",
)
yield (
lambda: not (
Expand Down Expand Up @@ -462,3 +521,45 @@ def update_operator(cls, user_guid: UUID, operation: Operation, operator_id: UUI
operation.save(update_fields=["operator_id"])
operation.set_create_or_update(user_guid)
return operation

@classmethod
def handle_change_of_registration_purpose(
cls, user_guid: UUID, operation: Operation, payload: OperationInformationIn
) -> OperationInformationIn:
"""
Logic to handle the situation when an industry user changes the selected registration purpose (RP) for their operation.
Changing the RP can happen during or after submitting the operation's registration info.
Depending on what the old RP was, some operation data may need to be removed.
Generally, if the operation was already registered when the RP changed, the original data will be archived.
If the operation wasn't yet registered when the selected RP changed, the original data will be deleted.
"""
old_purpose = operation.registration_purpose
if old_purpose == Operation.Purposes.OPTED_IN_OPERATION:
payload.opt_in = False
opted_in_detail = operation.opted_in_operation
if opted_in_detail:
OperationServiceV2.remove_opted_in_operation_detail(user_guid, operation.id)
elif old_purpose == Operation.Purposes.NEW_ENTRANT_OPERATION:
payload.date_of_first_shipment = None
DocumentService.archive_or_delete_operation_document(user_guid, operation.id, 'new_entrant_application')

new_purpose = payload.registration_purpose
if new_purpose == Operation.Purposes.ELECTRICITY_IMPORT_OPERATION:
# remove operation data that's no longer relevant (because operation is now an EIO)
payload.activities = []
payload.regulated_products = []
payload.naics_code_id = None
payload.secondary_naics_code_id = None
payload.tertiary_naics_code_id = None
payload.boundary_map = None
payload.process_flow_diagram = None
DocumentService.archive_or_delete_operation_document(user_guid, operation.id, 'process_flow_diagram')
DocumentService.archive_or_delete_operation_document(user_guid, operation.id, 'boundary_map')
elif new_purpose in [
Operation.Purposes.REPORTING_OPERATION,
Operation.Purposes.POTENTIAL_REPORTING_OPERATION,
]:
# remove regulated products - they're not relevant to Reporting/Potential Reporting operations
payload.regulated_products = []

return payload
Loading

0 comments on commit a287ae4

Please sign in to comment.