Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2427 internal users can transfer a facility or operation #2557

Merged
merged 53 commits into from
Dec 14, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
c2cb573
chore: add new user role to permissions
Sepehr-Sobhani Dec 6, 2024
6e62445
chore: admin panel tweaks
Sepehr-Sobhani Dec 6, 2024
a8361b5
chore: fix type hints
Sepehr-Sobhani Dec 6, 2024
c443150
chore: add new api endpoint tag
Sepehr-Sobhani Dec 6, 2024
3d59799
chore: exclude `all` key from generic errors
Sepehr-Sobhani Dec 6, 2024
ad4ced0
chore: operation and designated operator fixture tweaks
Sepehr-Sobhani Dec 6, 2024
d62b410
chore: new fixture for cas analyst user
Sepehr-Sobhani Dec 6, 2024
b4a908d
chore: one more approved industry user admin
Sepehr-Sobhani Dec 6, 2024
6b99ddc
chore: more data model methods
Sepehr-Sobhani Dec 6, 2024
6db5193
chore: remove unused jsonschema file
Sepehr-Sobhani Dec 6, 2024
d1b51f7
chore: tweak endpoint, schema and types to fetch non paginated data a…
Sepehr-Sobhani Dec 6, 2024
e11ba88
chore: fix dashboard tile href
Sepehr-Sobhani Dec 6, 2024
e584d84
chore: renaming - using ts instead of tsx
Sepehr-Sobhani Dec 6, 2024
4599f90
chore: update transfer event fixtures
Sepehr-Sobhani Dec 6, 2024
58ce5fa
chore: update transfer event data model
Sepehr-Sobhani Dec 6, 2024
062942a
chore: add status to operation designated operator timeline table
Sepehr-Sobhani Dec 6, 2024
2816b5d
feat: add transfer event feature
Sepehr-Sobhani Dec 6, 2024
afd6221
chore: add transfer event endpoint and schema
Sepehr-Sobhani Dec 6, 2024
49a8a56
chore: handle server errors when rows is undefined
Sepehr-Sobhani Dec 6, 2024
47c7222
chore: resolve a new uuid when we have similar transfer events - caus…
Sepehr-Sobhani Dec 6, 2024
0e9fb44
chore: add the new fetch api to the index file
Sepehr-Sobhani Dec 6, 2024
e9474f4
chore: fix import
Sepehr-Sobhani Dec 6, 2024
5196f68
chore: post-rebase migration fix
Sepehr-Sobhani Dec 6, 2024
dc7d1e0
chore: fix tests after latest transfer event modifications
Sepehr-Sobhani Dec 8, 2024
f0b06bb
chore: remove v2 ref after endpoint refactor
Sepehr-Sobhani Dec 8, 2024
43b2aae
chore: transfer event is a v2 feature
Sepehr-Sobhani Dec 8, 2024
5e854ad
chore: post-rebase fix
Sepehr-Sobhani Dec 8, 2024
8ef2f99
chore: add cas analyst to endpoint tests
Sepehr-Sobhani Dec 8, 2024
aba9f4f
chore: add extra check to prevent creating wrong transfer event
Sepehr-Sobhani Dec 8, 2024
35bc6df
chore: make eslint happy
Sepehr-Sobhani Dec 8, 2024
0780dce
chore: add more baker recipes
Sepehr-Sobhani Dec 11, 2024
93826a2
chore: make test name singular
Sepehr-Sobhani Dec 11, 2024
29f4edc
chore: fix schema name
Sepehr-Sobhani Dec 11, 2024
0f2b1c4
chore: renaming tweaks
Sepehr-Sobhani Dec 11, 2024
2f22c75
test: add backend tests
Sepehr-Sobhani Dec 11, 2024
72de680
test: add frontend tests
Sepehr-Sobhani Dec 11, 2024
2d15e58
test: raise error if response is undefined
Sepehr-Sobhani Dec 11, 2024
f1ad03d
chore: renaming tweaks and cover edge cases
Sepehr-Sobhani Dec 11, 2024
ddf8676
chore: return undefined if pageData is undefined
Sepehr-Sobhani Dec 11, 2024
8c95f93
chore: fix dashboard tile allowedRules issue
Sepehr-Sobhani Dec 12, 2024
4134e7f
chore: remove leftover
Sepehr-Sobhani Dec 12, 2024
b0ee873
chore: fix vitest tests after using the session util
Sepehr-Sobhani Dec 12, 2024
2668fbf
docs: add docs about transfer events
Sepehr-Sobhani Dec 12, 2024
acbf3dd
chore: make pre-commit happy
Sepehr-Sobhani Dec 12, 2024
1f9a07a
chore: make reg1 e2e tests happy
Sepehr-Sobhani Dec 12, 2024
8c20d4d
chore: make sonarcloud happy
Sepehr-Sobhani Dec 12, 2024
20306cd
chore: remove transfer sub-dashboard for internal users
Sepehr-Sobhani Dec 13, 2024
fa1a690
chore: remove transfer sub-dashboard for internal users
Sepehr-Sobhani Dec 13, 2024
21bc2e9
chore: implement review suggestions, remove redundant endpoints and u…
Sepehr-Sobhani Dec 13, 2024
f59d3f2
chore: using ts instead of tsx
Sepehr-Sobhani Dec 13, 2024
eabb344
chore: remove unused mock
Sepehr-Sobhani Dec 13, 2024
2439d76
chore: fix post-rebase migration issue
Sepehr-Sobhani Dec 13, 2024
0a49409
chore: fix vitest
Sepehr-Sobhani Dec 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,6 @@ class TestEndpointPermissions(TestCase):
},
],
"cas_analyst": [
{"method": "get", "endpoint_name": "list_operations_by_operator_id", "kwargs": {"operator_id": mock_uuid}},
{"method": "post", "endpoint_name": "create_transfer_event"},
],
}
Expand Down
2 changes: 0 additions & 2 deletions bc_obps/registration/api/v2/_operators/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +0,0 @@
# ruff: noqa: F401
from ._operator_id.operations import list_operations_by_operator_id
29 changes: 0 additions & 29 deletions bc_obps/registration/api/v2/_operators/_operator_id/operations.py

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class FacilityDesignatedOperationTimelineOut(ModelSchema):
facility__type: str = Field(..., alias="facility.type")
facility__bcghg_id__id: Optional[str] = Field(None, alias="facility.bcghg_id.id")
facility__id: UUID = Field(..., alias="facility.id")
# Using two below fields for rendering a list of facilities alogn with their locations for transfer event
# Using two below fields for rendering a list of facilities along with their locations for transfer event
facility__latitude_of_largest_emissions: Optional[float] = Field(
None, alias="facility.latitude_of_largest_emissions"
)
Expand Down

This file was deleted.

This file was deleted.

1 change: 1 addition & 0 deletions bc_obps/registration/tests/utils/baker_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
operator=foreign_key(operator),
status=cycle([status for status in OperationDesignatedOperatorTimeline.Statuses]),
start_date=datetime.now(ZoneInfo("UTC")),
end_date=datetime.now(ZoneInfo("UTC")),
)


Expand Down
13 changes: 0 additions & 13 deletions bc_obps/service/operation_designated_operator_timeline_service.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,10 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from django.db.models import QuerySet
from registration.models import OperationDesignatedOperatorTimeline


class OperationDesignatedOperatorTimelineService:
@classmethod
def list_timeline_by_operator_id(
cls,
operator_id: UUID,
) -> QuerySet[OperationDesignatedOperatorTimeline]:
"""
List active timelines belonging to a specific operator.
"""
return OperationDesignatedOperatorTimeline.objects.filter(
operator_id=operator_id, end_date__isnull=True
).distinct()

@classmethod
def get_current_timeline(
cls, operator_id: UUID, operation_id: UUID
Expand Down
16 changes: 16 additions & 0 deletions bc_obps/service/operation_service_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,19 @@ def generate_bcghg_id(cls, user_guid: UUID, operation_id: UUID) -> BcGreenhouseG
operation.save(update_fields=['bcghg_id'])

return operation.bcghg_id

@classmethod
@transaction.atomic()
def update_operator(cls, user_guid: UUID, operation: Operation, operator_id: UUID) -> Operation:
"""
Update the operator for the operation
At the time of implementation, this is only used for transferring operations between operators and,
is only available to cas_analyst users
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the docs

user = UserDataAccessService.get_by_guid(user_guid)
if not user.is_cas_analyst():
raise Exception(UNAUTHORIZED_MESSAGE)
operation.operator_id = operator_id
operation.save(update_fields=["operator_id"])
operation.set_create_or_update(user_guid)
return operation
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ class TestFacilityDesignatedOperationTimelineService:
@staticmethod
def test_get_current_timeline():
timeline_with_no_end_date = baker.make_recipe('utils.facility_designated_operation_timeline', end_date=None)
Sepehr-Sobhani marked this conversation as resolved.
Show resolved Hide resolved
# another timeline for the same facility to make sure it is not returned
baker.make_recipe('utils.facility_designated_operation_timeline', facility=timeline_with_no_end_date.facility)
result_found = FacilityDesignatedOperationTimelineService.get_current_timeline(
timeline_with_no_end_date.operation_id, timeline_with_no_end_date.facility_id
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from itertools import cycle
from unittest.mock import patch, MagicMock
from zoneinfo import ZoneInfo
import pytest
Expand All @@ -12,37 +11,17 @@


class TestOperationDesignatedOperatorTimelineService:
@staticmethod
def test_list_timeline_by_operator_id():
operator = baker.make_recipe('utils.operator')
# timeline records without end date
baker.make_recipe(
'utils.operation_designated_operator_timeline',
operator=operator,
operation=cycle(baker.make_recipe('utils.operation', _quantity=2)),
_quantity=2,
)
# timeline records with end date
baker.make_recipe(
'utils.operation_designated_operator_timeline', operator=operator, end_date=datetime.now(ZoneInfo("UTC"))
)

timelines = OperationDesignatedOperatorTimelineService.list_timeline_by_operator_id(operator.id)
assert timelines.count() == 2
assert all(timeline.end_date is None for timeline in timelines)
assert all(timeline.operator_id == operator.id for timeline in timelines)

@staticmethod
def test_get_current_timeline():
Sepehr-Sobhani marked this conversation as resolved.
Show resolved Hide resolved
timeline_with_no_end_date = baker.make_recipe('utils.operation_designated_operator_timeline')
timeline_with_no_end_date = baker.make_recipe('utils.operation_designated_operator_timeline', end_date=None)
# another timeline for the same operation to make sure it is not returned
baker.make_recipe('utils.operation_designated_operator_timeline', operation=timeline_with_no_end_date.operation)
result_found = OperationDesignatedOperatorTimelineService.get_current_timeline(
timeline_with_no_end_date.operator_id, timeline_with_no_end_date.operation_id
)
assert result_found == timeline_with_no_end_date

timeline_with_end_date = baker.make_recipe(
'utils.operation_designated_operator_timeline', end_date=datetime.now(ZoneInfo("UTC"))
)
timeline_with_end_date = baker.make_recipe('utils.operation_designated_operator_timeline')
result_not_found = OperationDesignatedOperatorTimelineService.get_current_timeline(
timeline_with_end_date.operator_id, timeline_with_end_date.operation_id
)
Expand Down
26 changes: 26 additions & 0 deletions bc_obps/service/tests/test_operation_service_v2.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
from uuid import uuid4
from zoneinfo import ZoneInfo

from registration.models.contact import Contact
Expand Down Expand Up @@ -883,3 +885,27 @@ def test_removes_operation_representative():
assert operation.updated_by == approved_user_operator.user
# confirm the contact was only removed from the operation, not removed from the db
assert Contact.objects.filter(id=2).exists()


class TestUpdateOperationsOperator:
@staticmethod
@patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid")
def test_unauthorized_user_cannot_update_operations_operator(mock_get_by_guid):
cas_admin = baker.make_recipe('utils.cas_admin')
mock_get_by_guid.return_value = cas_admin
operation = MagicMock()
operator_id = uuid4()
with pytest.raises(Exception, match="Unauthorized."):
OperationServiceV2.update_operator(cas_admin.user_guid, operation, operator_id)

@staticmethod
@patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid")
@patch("registration.models.Operation.set_create_or_update")
def test_update_operations_operator_success(mock_get_by_guid, mock_set_create_or_update):
cas_analyst = baker.make_recipe('utils.cas_analyst')
mock_get_by_guid.return_value = cas_analyst
operation = baker.make_recipe('utils.operation')
operator = baker.make_recipe('utils.operator')
OperationServiceV2.update_operator(cas_analyst.user_guid, operation, operator.id)
mock_set_create_or_update.assert_called_once()
assert operation.operator == operator
69 changes: 56 additions & 13 deletions bc_obps/service/tests/test_transfer_event_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,6 @@ def test_list_transfer_events():

@staticmethod
def test_validate_no_overlapping_transfer_events():
operation = baker.make_recipe('utils.operation')
baker.make_recipe(
'utils.transfer_event',
operation=operation,
status=TransferEvent.Statuses.TO_BE_TRANSFERRED,
)
facilities = baker.make_recipe('utils.facility', _quantity=2)
baker.make_recipe(
'utils.transfer_event',
facilities=facilities,
status=TransferEvent.Statuses.COMPLETE,
)

# Scenario 1: No overlapping operation or facility
new_operation = baker.make_recipe('utils.operation')
new_facilities = baker.make_recipe('utils.facility', _quantity=2)
Expand All @@ -52,10 +39,22 @@ def test_validate_no_overlapping_transfer_events():
pytest.fail(f"Unexpected exception raised: {e}")

# Scenario 2: Overlapping operation
Sepehr-Sobhani marked this conversation as resolved.
Show resolved Hide resolved
operation = baker.make_recipe('utils.operation')
baker.make_recipe(
'utils.transfer_event',
operation=operation,
status=TransferEvent.Statuses.TO_BE_TRANSFERRED,
)
with pytest.raises(Exception, match="An active transfer event already exists for the selected operation."):
TransferEventService.validate_no_overlapping_transfer_events(operation_id=operation.id)

# Scenario 3: Overlapping facilities
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK so horrible question, are there situations where we could have overlap between operations and facilities? Like Fruit Operation has facilities Apple and Orange, and Vegetable Operation has Carrot and Spinach. There's a future transfer that's Apple to Vegetable Operation. But then someone wants to transfer Vegetable Operation to a new operator, so what happens to Apple?

I don't suggest addressing that in this PR, but if it's a possibility, we could make a card. Probably an edge case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that’s an excellent question—and also a terrifying one! Definitely worth discussing this 😵‍💫

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created a ticket for this: #2588
Feel free to refine it.

facilities = baker.make_recipe('utils.facility', _quantity=2)
baker.make_recipe(
'utils.transfer_event',
facilities=facilities,
status=TransferEvent.Statuses.COMPLETE,
)
with pytest.raises(
Exception,
match="One or more facilities in this transfer event are already part of an active transfer event.",
Expand Down Expand Up @@ -106,6 +105,26 @@ def test_create_transfer_event_operation_missing_operation(cls, mock_get_by_guid
with pytest.raises(Exception, match="Operation is required for operation transfer events."):
TransferEventService.create_transfer_event(cas_analyst.user_guid, payload)

@classmethod
@patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events")
@patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid")
def test_create_transfer_event_operation_using_the_same_operator(cls, mock_get_by_guid, mock_validate_no_overlap):
cas_analyst = baker.make_recipe("utils.cas_analyst")
payload_with_same_from_operator_and_to_operator = cls._get_transfer_event_payload_for_operation()
payload_with_same_from_operator_and_to_operator.to_operator = (
payload_with_same_from_operator_and_to_operator.from_operator
)

mock_user = MagicMock()
mock_user.is_cas_analyst.return_value = True
mock_get_by_guid.return_value = cas_analyst
mock_validate_no_overlap.return_value = None

with pytest.raises(Exception, match="Operations cannot be transferred within the same operator."):
TransferEventService.create_transfer_event(
cas_analyst.user_guid, payload_with_same_from_operator_and_to_operator
)

@classmethod
@patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events")
@patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid")
Expand Down Expand Up @@ -188,6 +207,22 @@ def test_create_transfer_event_facility_missing_required_fields(cls, mock_get_by
):
TransferEventService.create_transfer_event(cas_analyst.user_guid, payload_without_to_operation)

@classmethod
@patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events")
@patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid")
def test_create_transfer_event_facility_between_the_same_operation(cls, mock_get_by_guid, mock_validate_no_overlap):
cas_analyst = baker.make_recipe("utils.cas_analyst")
payload_with_same_from_and_to_operation = cls._get_transfer_event_payload_for_facility()
payload_with_same_from_and_to_operation.to_operation = payload_with_same_from_and_to_operation.from_operation

mock_user = MagicMock()
mock_user.is_cas_analyst.return_value = True
mock_get_by_guid.return_value = cas_analyst
mock_validate_no_overlap.return_value = None

with pytest.raises(Exception, match="Facilities cannot be transferred within the same operation."):
TransferEventService.create_transfer_event(cas_analyst.user_guid, payload_with_same_from_and_to_operation)

@classmethod
@patch("service.transfer_event_service.TransferEventService.validate_no_overlapping_transfer_events")
@patch("service.data_access_service.user_service.UserDataAccessService.get_by_guid")
Expand Down Expand Up @@ -448,8 +483,10 @@ def test_process_facilities_transfer(
@patch(
"service.transfer_event_service.OperationDesignatedOperatorTimelineDataAccessService.create_operation_designated_operator_timeline"
)
@patch("service.operation_service_v2.OperationServiceV2.update_operator")
def test_process_operation_transfer(
self,
mock_update_operator,
mock_create_timeline,
mock_set_timeline,
mock_get_current_timeline,
Expand Down Expand Up @@ -483,6 +520,7 @@ def test_process_operation_transfer(
mock_get_current_timeline.return_value = None
mock_set_timeline.reset_mock() # Reset mock for the next call
mock_create_timeline.reset_mock() # Reset mock for the next call
mock_update_operator.reset_mock() # Reset mock for the next call

# Call the method under test for the second scenario (no existing timeline)
TransferEventService._process_operation_transfer(transfer_event, user_guid)
Expand All @@ -500,3 +538,8 @@ def test_process_operation_transfer(

# Verify that set_timeline_status_and_end_date was not called, since the timeline did not exist
mock_set_timeline.assert_not_called()
mock_update_operator.assert_called_once_with(
user_guid,
transfer_event.operation,
transfer_event.to_operator.id,
)
12 changes: 12 additions & 0 deletions bc_obps/service/transfer_event_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from service.data_access_service.user_service import UserDataAccessService
from service.facility_designated_operation_timeline_service import FacilityDesignatedOperationTimelineService
from service.operation_designated_operator_timeline_service import OperationDesignatedOperatorTimelineService
from service.operation_service_v2 import OperationServiceV2

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -107,6 +108,10 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn)
if not payload.operation:
raise Exception("Operation is required for operation transfer events.")

# make sure that the from_operator and to_operator are different(we can't transfer operations within the same operator)
if payload.from_operator == payload.to_operator:
raise Exception("Operations cannot be transferred within the same operator.")

prepared_payload.update(
{
"operation_id": payload.operation,
Expand All @@ -120,6 +125,10 @@ def create_transfer_event(cls, user_guid: UUID, payload: TransferEventCreateIn)
"Facilities, from_operation, and to_operation are required for facility transfer events."
)

# make sure that the from_operation and to_operation are different(we can't transfer facilities within the same operation)
if payload.from_operation == payload.to_operation:
raise Exception("Facilities cannot be transferred within the same operation.")

prepared_payload.update(
{
"from_operation_id": payload.from_operation,
Expand Down Expand Up @@ -248,3 +257,6 @@ def _process_operation_transfer(cls, event: TransferEvent, user_guid: UUID) -> N
"status": OperationDesignatedOperatorTimeline.Statuses.ACTIVE,
},
)

# update the operation's operator
OperationServiceV2.update_operator(user_guid, event.operation, event.to_operator.id) # type: ignore # we are sure that operation is not None
Loading