-
Notifications
You must be signed in to change notification settings - Fork 1
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
1432: transfers grid #2451
Merged
+849
−34
Merged
1432: transfers grid #2451
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
16fbd3f
chore: build transfers grid components and page
BCerki 75c0a0e
chore: transfer events BE work
BCerki e2e3666
chore: migration to change transfer statuses
BCerki 197a654
chore: spec work
BCerki 539bac1
chore: fix filtering
BCerki b83b73e
chore: spec work - facilities into one row
Sepehr-Sobhani 749c576
chore: format transfers grid
BCerki f5ed515
chore: be cleanup
BCerki 2da6ecc
chore: fix uuid in ninja schema
BCerki 32d4a1f
chore: add sorting to date columns
BCerki 411e6c9
test: BE tests for transfers
BCerki 4834c9b
chore: FE work
BCerki b43d2e2
test: vitests for transfers
BCerki 1be2603
chore: fix transfer endpoint permissions
BCerki 67cb363
chore: make pre-commit happy
BCerki d6c84a9
chore: fix filtering for N/A
BCerki 3713d55
chore: fix filtering for N/A
BCerki adf3f93
chore: post-rebase updates
BCerki 7be260a
chore: apply suggestions from code review
BCerki File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
from typing import List, Literal, Optional | ||
from registration.models.event.transfer_event import TransferEvent | ||
from registration.schema.v1.transfer_event import TransferEventFilterSchema, TransferEventListOut | ||
from service.transfer_event_service import TransferEventService | ||
from common.permissions import authorize | ||
from django.http import HttpRequest | ||
from registration.utils import CustomPagination | ||
from registration.constants import TRANSFER_EVENT_TAGS | ||
from ninja.pagination import paginate | ||
from registration.decorators import handle_http_errors | ||
from ..router import router | ||
from service.error_service.custom_codes_4xx import custom_codes_4xx | ||
from ninja import Query | ||
from registration.schema.generic import Message | ||
from django.db.models import QuerySet | ||
|
||
|
||
@router.get( | ||
"/transfer-events", | ||
response={200: List[TransferEventListOut], custom_codes_4xx: Message}, | ||
tags=TRANSFER_EVENT_TAGS, | ||
description="""Retrieves a paginated list of transfer events based on the provided filters. | ||
The endpoint allows authorized users to view and sort transfer events filtered by various criteria such as operation, facility, and status.""", | ||
auth=authorize("authorized_irc_user"), | ||
) | ||
@handle_http_errors() | ||
@paginate(CustomPagination) | ||
def list_transfer_events( | ||
request: HttpRequest, | ||
filters: TransferEventFilterSchema = Query(...), | ||
sort_field: Optional[str] = "status", | ||
sort_order: Optional[Literal["desc", "asc"]] = "desc", | ||
paginate_result: bool = Query(True, description="Whether to paginate the results"), | ||
) -> QuerySet[TransferEvent]: | ||
# NOTE: PageNumberPagination raises an error if we pass the response as a tuple (like 200, ...) | ||
return TransferEventService.list_transfer_events(sort_field, sort_order, filters) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
47 changes: 47 additions & 0 deletions
47
...ion/migrations/0056_remove_historicaltransferevent_future_designated_operator_and_more.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
# Generated by Django 5.0.9 on 2024-11-15 23:20 | ||
|
||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('registration', '0055_V1_14_0'), | ||
] | ||
|
||
operations = [ | ||
migrations.RemoveField( | ||
model_name='historicaltransferevent', | ||
name='future_designated_operator', | ||
), | ||
migrations.RemoveField( | ||
model_name='transferevent', | ||
name='future_designated_operator', | ||
), | ||
migrations.AlterField( | ||
model_name='historicaltransferevent', | ||
name='status', | ||
field=models.CharField( | ||
choices=[ | ||
('Complete', 'Complete'), | ||
('To be transferred', 'To Be Transferred'), | ||
('Transferred', 'Transferred'), | ||
], | ||
default='To be transferred', | ||
max_length=100, | ||
), | ||
), | ||
migrations.AlterField( | ||
model_name='transferevent', | ||
name='status', | ||
field=models.CharField( | ||
choices=[ | ||
('Complete', 'Complete'), | ||
('To be transferred', 'To Be Transferred'), | ||
('Transferred', 'Transferred'), | ||
], | ||
default='To be transferred', | ||
max_length=100, | ||
), | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
from typing import Optional | ||
from uuid import UUID | ||
|
||
from registration.models.event.transfer_event import TransferEvent | ||
from ninja import ModelSchema, Field, FilterSchema | ||
from django.db.models import Q | ||
import re | ||
from typing import Dict, Any | ||
|
||
|
||
class TransferEventListOut(ModelSchema): | ||
operation__id: Optional[UUID] = None | ||
operation__name: Optional[str] = Field(None, alias="operation__name") | ||
facilities__name: Optional[str] = Field(None, alias="facilities__name") | ||
facility__id: Optional[UUID] = Field(None, alias="facilities__id") | ||
id: UUID | ||
|
||
@staticmethod | ||
def resolve_id(obj: Dict[str, Any]) -> UUID: | ||
operation_id = obj.get('operation__id', None) | ||
facility_id = obj.get('facilities__id', None) | ||
|
||
record_id = operation_id if operation_id else facility_id | ||
if not isinstance(record_id, UUID): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To make mypy happy but also probably a good check |
||
raise Exception('Missing valid UUID') | ||
return record_id | ||
|
||
class Meta: | ||
model = TransferEvent | ||
fields = ['effective_date', 'status', 'created_at'] | ||
|
||
|
||
class TransferEventFilterSchema(FilterSchema): | ||
# NOTE: we could simply use the `q` parameter to filter by related fields but, | ||
# due to this issue: https://github.com/vitalik/django-ninja/issues/1037 mypy is unhappy so I'm using the `json_schema_extra` parameter | ||
# If we want to achieve more by using the `q` parameter, we should use it and ignore the mypy error | ||
effective_date: Optional[str] = Field(None, json_schema_extra={'q': 'effective_date__icontains'}) | ||
operation__name: Optional[str] = None | ||
facilities__name: Optional[str] = Field(None, json_schema_extra={'q': 'facilities__name__icontains'}) | ||
status: Optional[str] = Field(None, json_schema_extra={'q': 'status__icontains'}) | ||
|
||
@staticmethod | ||
def filtering_including_not_applicable(field: str, value: str) -> Q: | ||
if value and re.search(value, 'n/a', re.IGNORECASE): | ||
return Q(**{f"{field}__icontains": value}) | Q(**{f"{field}__isnull": True}) | ||
return Q(**{f"{field}__icontains": value}) if value else Q() | ||
|
||
def filter_operation__name(self, value: str) -> Q: | ||
return self.filtering_including_not_applicable('operation__name', value) | ||
|
||
def filter_facilities__name(self, value: str) -> Q: | ||
return self.filtering_including_not_applicable('facilities__name', value) |
138 changes: 138 additions & 0 deletions
138
bc_obps/registration/tests/endpoints/v1/test_transfer_events.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
from datetime import datetime, timedelta | ||
from django.utils import timezone | ||
from bc_obps.settings import NINJA_PAGINATION_PER_PAGE | ||
from model_bakery import baker | ||
from registration.tests.utils.helpers import CommonTestSetup, TestUtils | ||
|
||
from registration.utils import custom_reverse_lazy | ||
|
||
|
||
class TestTransferEventEndpoint(CommonTestSetup): | ||
url = custom_reverse_lazy('list_transfer_events') | ||
# GET | ||
def test_list_transfer_events_unpaginated(self): | ||
# transfer of an operation | ||
baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) | ||
# transfer of 50 facilities | ||
baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=50)) | ||
for role in ['cas_admin', 'cas_analyst']: | ||
response = TestUtils.mock_get_with_auth_role(self, role, self.url + "?paginate_result=False") | ||
assert response.status_code == 200 | ||
assert response.json().keys() == {'count', 'items'} | ||
assert response.json()['count'] == 51 | ||
|
||
items = response.json().get('items', []) | ||
for item in items: | ||
assert set(item.keys()) == { | ||
'operation__name', | ||
'operation__id', | ||
'effective_date', | ||
'status', | ||
'id', | ||
'facilities__name', | ||
'facility__id', | ||
'created_at', | ||
} | ||
|
||
def test_list_transfer_events_paginated(self): | ||
# transfer of an operation | ||
baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation')) | ||
# transfer of 50 facilities | ||
baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=50)) | ||
# Get the default page 1 response | ||
response = TestUtils.mock_get_with_auth_role(self, "cas_admin", custom_reverse_lazy("list_transfer_events")) | ||
assert response.status_code == 200 | ||
|
||
response_items_1 = response.json().get('items') | ||
response_count_1 = response.json().get('count') | ||
# save the id of the first paginated response item | ||
page_1_response_id = response_items_1[0].get('id') | ||
assert len(response_items_1) == NINJA_PAGINATION_PER_PAGE | ||
assert response_count_1 == 51 # total count of transfers | ||
# Get the page 2 response | ||
response = TestUtils.mock_get_with_auth_role( | ||
self, | ||
"cas_admin", | ||
self.url + "?page=2&sort_field=created_at&sort_order=desc", | ||
) | ||
assert response.status_code == 200 | ||
response_items_2 = response.json().get('items') | ||
response_count_2 = response.json().get('count') | ||
# save the id of the first paginated response item | ||
page_2_response_id = response_items_2[0].get('id') | ||
assert len(response_items_2) == NINJA_PAGINATION_PER_PAGE | ||
# assert that the first item in the page 1 response is not the same as the first item in the page 2 response | ||
assert page_1_response_id != page_2_response_id | ||
assert response_count_2 == response_count_1 # total count of transfer_events should be the same | ||
# Get the page 2 response but with a different sort order | ||
response = TestUtils.mock_get_with_auth_role( | ||
self, | ||
"cas_admin", | ||
self.url + "?page=2&sort_field=created_at&sort_order=asc", | ||
) | ||
assert response.status_code == 200 | ||
response_items_2_reverse = response.json().get('items') | ||
# save the id of the first paginated response item | ||
page_2_response_id_reverse = response_items_2_reverse[0].get('id') | ||
assert len(response_items_2_reverse) == NINJA_PAGINATION_PER_PAGE | ||
# assert that the first item in the page 2 response is not the same as the first item in the page 2 response with reversed order | ||
assert page_2_response_id != page_2_response_id_reverse | ||
|
||
def test_transfer_events_endpoint_list_transfer_events_with_sorting(self): | ||
today = timezone.make_aware(datetime.now()) | ||
yesterday = today - timedelta(days=1) | ||
Comment on lines
+82
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More lovely codes 😍 |
||
# transfer of an operation | ||
baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation'), effective_date=today) | ||
# transfer of 50 facilities | ||
baker.make_recipe( | ||
'utils.transfer_event', | ||
effective_date=yesterday, | ||
facilities=baker.make_recipe('utils.facility', _quantity=50), | ||
) | ||
|
||
response_ascending = TestUtils.mock_get_with_auth_role( | ||
self, "cas_admin", self.url + "?page=1&sort_field=effective_date&sort_order=asc" | ||
) | ||
# save the id of the first paginated response item | ||
first_item_ascending = response_ascending.json()['items'][0] | ||
|
||
# # Sort created at descending | ||
response_descending = TestUtils.mock_get_with_auth_role( | ||
self, "cas_admin", self.url + "?page=1&sort_field=effective_date&sort_order=desc" | ||
) | ||
first_item_descending = response_descending.json()['items'][0] | ||
assert first_item_descending['effective_date'] > first_item_ascending['effective_date'] | ||
|
||
def test_transfer_events_endpoint_list_transfer_events_with_filter(self): | ||
# transfer of an operation | ||
baker.make_recipe('utils.transfer_event', operation=baker.make_recipe('utils.operation', name='Test Operation')) | ||
# transfer of 50 facilities | ||
baker.make_recipe('utils.transfer_event', facilities=baker.make_recipe('utils.facility', _quantity=50)) | ||
|
||
# Get the default page 1 response | ||
response = TestUtils.mock_get_with_auth_role( | ||
self, "cas_admin", self.url + "?facilities__name=010" | ||
) # filtering facilities__name | ||
assert response.status_code == 200 | ||
response_items_1 = response.json().get('items') | ||
for item in response_items_1: | ||
assert item.get('facilities__name') == "Facility 010" | ||
|
||
# Test with a status filter that doesn't exist | ||
response = TestUtils.mock_get_with_auth_role(self, "cas_admin", self.url + "?status=unreal") | ||
assert response.status_code == 200 | ||
assert response.json().get('count') == 0 | ||
|
||
# Test with two filters | ||
facilities__name_to_filter, status_to_filter = response_items_1[0].get('facilities__name'), response_items_1[ | ||
0 | ||
].get('status') | ||
response = TestUtils.mock_get_with_auth_role( | ||
self, "cas_admin", self.url + f"?facilities__name={facilities__name_to_filter}&status={status_to_filter}" | ||
) | ||
assert response.status_code == 200 | ||
response_items_2 = response.json().get('items') | ||
assert len(response_items_2) == 1 | ||
assert response.json().get('count') == 1 | ||
assert response_items_2[0].get('facilities__name') == facilities__name_to_filter | ||
assert response_items_2[0].get('status') == status_to_filter |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is correct--even though we're in reg2, we didn't have events in reg2, so I modelled this after facility