diff --git a/client/src/components/RcraProfile/RcraProfile.tsx b/client/src/components/RcraProfile/RcraProfile.tsx index abcc14703..5b9f27b3a 100644 --- a/client/src/components/RcraProfile/RcraProfile.tsx +++ b/client/src/components/RcraProfile/RcraProfile.tsx @@ -33,6 +33,7 @@ export function RcraProfile({ profile }: ProfileViewProps) { }); useEffect(() => { + // ToDo: invalidating tags should be done in the slice dispatch(userApi.util?.invalidateTags(['rcrainfoProfile'])); }, [inProgress]); @@ -48,6 +49,7 @@ export function RcraProfile({ profile }: ProfileViewProps) { /** submitting the RcrainfoProfile form (RCRAInfo API ID, Key, username, etc.)*/ const onSubmit = (data: RcraProfileForm) => { + console.log('submitting form'); setProfileLoading(!profileLoading); setEditable(!editable); updateRcrainfoProfile({ username: profile.user, data: data }); @@ -107,37 +109,21 @@ export function RcraProfile({ profile }: ProfileViewProps) { -
- {!editable ? ( - <> - - - ) : ( - <> - - - - )} +
+ +
diff --git a/client/src/store/htApi.slice.ts b/client/src/store/htApi.slice.ts index 6a7a410aa..8579ed58b 100644 --- a/client/src/store/htApi.slice.ts +++ b/client/src/store/htApi.slice.ts @@ -115,7 +115,7 @@ export const haztrakApi = createApi({ providesTags: ['code'], }), getOrgSites: build.query, string>({ - query: (id) => ({ url: `org/${id}/site`, method: 'get' }), + query: (id) => ({ url: `org/${id}/sites`, method: 'get' }), providesTags: ['site'], }), getUserHaztrakSites: build.query, void>({ diff --git a/server/apps/conftest.py b/server/apps/conftest.py index e9a6048ad..a383ab5bc 100644 --- a/server/apps/conftest.py +++ b/server/apps/conftest.py @@ -272,6 +272,15 @@ def mock_responses(): yield mock_responses +@pytest.fixture() +def mock_emanifest_auth_response(request, mock_responses): + api_id, api_key = request.param + mock_responses.get( + f"https://rcrainfopreprod.epa.gov/rcrainfo/rest/api/v1/auth/{api_id}/{api_key}", + body='{"token": "mocK_token", "expiration": "2021-01-01T00:00:00.000000Z"}', + ) + + @pytest.fixture def mocker(mocker: pytest_mock.MockerFixture): """ diff --git a/server/apps/core/admin.py b/server/apps/core/admin.py index 881bf7f1e..6ff0f1dc7 100644 --- a/server/apps/core/admin.py +++ b/server/apps/core/admin.py @@ -44,27 +44,6 @@ def has_delete_permission(self, request, obj=None): return False -# @admin.register(HaztrakProfile) -# class HaztrakProfileAdmin(admin.ModelAdmin): -# list_display = ["__str__", "number_of_sites", "rcrainfo_integrated_org"] -# search_fields = ["user__username"] -# inlines = [SitePermissionsInline] -# raw_id_fields = ["user", "rcrainfo_profile"] -# readonly_fields = ["rcrainfo_integrated_org"] -# -# def rcrainfo_integrated_org(self, profile: HaztrakProfile) -> bool: -# if profile.org: -# return profile.rcrainfo_integrated_org -# return False -# -# rcrainfo_integrated_org.boolean = True -# -# @staticmethod -# def number_of_sites(profile: HaztrakProfile) -> str: -# # return ", ".join([str(site) for site in profile.sit]) -# return str(profile.site_permissions.all().count()) - - @admin.register(RcrainfoProfile) class RcraProfileAdmin(admin.ModelAdmin): list_display = ["__str__", "related_user", "rcra_username", "api_user"] diff --git a/server/apps/core/services/rcrainfo_service.py b/server/apps/core/services/rcrainfo_service.py index 2db6c7727..0852314ac 100644 --- a/server/apps/core/services/rcrainfo_service.py +++ b/server/apps/core/services/rcrainfo_service.py @@ -94,32 +94,6 @@ def sign_manifest(self, **sign_data): sign_data = {k: v for k, v in sign_data.items() if v is not None} return super().sign_manifest(**sign_data) - def search_mtn( - self, - reg: bool = False, - site_id: Optional[str] = None, - start_date: Optional[str] = None, - end_date: Optional[str] = None, - status: Optional[str] = None, - date_type: str = "UpdatedDate", - state_code: Optional[str] = None, - site_type: Optional[str] = None, - ) -> RcrainfoResponse: - # map our python friendly keyword arguments to RCRAInfo expected fields - search_params = { - "stateCode": state_code, - "siteId": site_id, - "status": status, - "dateType": date_type, - "siteType": site_type, - "endDate": end_date, - "startDate": start_date, - } - # Remove arguments that are None - filtered_params = {k: v for k, v in search_params.items() if v is not None} - logger.debug(f"rcrainfo manifest search parameters {filtered_params}") - return super().search_mtn(**filtered_params) - def __bool__(self): """ This Overrides the RcrainfoClient bool diff --git a/server/apps/handler/admin.py b/server/apps/handler/admin.py index 4b76e6449..0ed26115d 100644 --- a/server/apps/handler/admin.py +++ b/server/apps/handler/admin.py @@ -56,7 +56,6 @@ def related_manifest(self, obj: Handler): return obj.generator.get() if obj.designated_facility: return obj.designated_facility.get() - # return obj.manifest.__str__() if obj.manifest else None related_manifest.short_description = "Manifest" diff --git a/server/apps/manifest/services/emanifest.py b/server/apps/manifest/services/emanifest.py index 10fcee4da..2f5d79b07 100644 --- a/server/apps/manifest/services/emanifest.py +++ b/server/apps/manifest/services/emanifest.py @@ -1,5 +1,5 @@ import logging -from datetime import UTC, datetime, timedelta, timezone +from datetime import datetime from typing import List, Literal, NotRequired, Optional, TypedDict from django.db import transaction @@ -28,6 +28,18 @@ class QuickerSignData(TypedDict): transporter_order: NotRequired[int] +class SearchManifestData(TypedDict, total=False): + """Type definition for the data required to search for manifests""" + + site_id: Optional[str] + start_date: Optional[datetime] + end_date: Optional[datetime] + status: Optional[str] + date_type: Optional[str] + state_code: Optional[str] + site_type: Optional[str] + + class TaskResponse(TypedDict): """Type definition for the response returned from starting a task""" @@ -61,49 +73,6 @@ def is_available(self) -> bool: """Check if e-Manifest is available""" return self.rcrainfo.has_rcrainfo_credentials - def search( - self, - *, - site_id: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - status: Optional[str] = None, - date_type: str = "UpdatedDate", - state_code: Optional[str] = None, - site_type: Optional[str] = None, - ) -> List[str]: - """Search for manifests from e-Manifest, an abstraction of RcrainfoService's search_mtn""" - date_format = "%Y-%m-%dT%H:%M:%SZ" - if end_date: - end_date_str = end_date.replace(tzinfo=timezone.utc).strftime(date_format) - else: - end_date_str = datetime.now(UTC).strftime(date_format) - if start_date: - start_date_str = start_date.replace(tzinfo=timezone.utc).strftime(date_format) - else: - # If no start date is specified, retrieve for last ~3 years - start_date = datetime.now(UTC) - timedelta( - minutes=60 # 60 seconds/1minutes - * 24 # 24 hours/1day - * 30 # 30 days/1month - * 36 # 36 months/3years = 3/years - ) - start_date_str = start_date.strftime(date_format) - - response = self.rcrainfo.search_mtn( - site_id=site_id, - site_type=site_type, - state_code=state_code, - start_date=start_date_str, - end_date=end_date_str, - status=status, - date_type=date_type, - ) - - if response.ok: - return response.json() - return [] - def pull(self, tracking_numbers: List[str]) -> PullManifestsResult: """Retrieve manifests from e-Manifest and save to database""" results: PullManifestsResult = {"success": [], "error": []} diff --git a/server/apps/manifest/services/emanifest_search.py b/server/apps/manifest/services/emanifest_search.py new file mode 100644 index 000000000..5e44d9df7 --- /dev/null +++ b/server/apps/manifest/services/emanifest_search.py @@ -0,0 +1,154 @@ +from datetime import UTC, datetime, timedelta, timezone +from typing import Literal, Optional, get_args + +from apps.core.services import RcrainfoService + +EmanifestStatus = Literal[ + "Pending", + "Scheduled", + "InTransit", + "ReadyForSignature", + "Signed", + "SignedComplete", + "UnderCorrection", + "Corrected", +] + +SiteType = Literal["Generator", "Tsdf", "Transporter", "RejectionInfo_AlternateTsdf"] + +CorrectionRequestStatus = Literal["NotSent", "Sent", "IndustryResponded", "Cancelled"] + +DateType = Literal["CertifiedDate", "ReceivedDate", "ShippedDate", "UpdatedDate"] + + +class EmanifestSearch: + def __init__(self, rcra_client: Optional[RcrainfoService] = None): + self._rcra_client = rcra_client or RcrainfoService() + self.state_code: Optional[str] = None + self.site_id: Optional[str] = None + self.status: Optional[EmanifestStatus] = None + self.site_type: Optional[SiteType] = None + self.date_type: Optional[DateType] = None + self.start_date: Optional[datetime] = None + self.end_date: Optional[datetime] = None + self.correction_request_status: Optional[CorrectionRequestStatus] = None + + @property + def rcra_client(self): + if not self._rcra_client: + self._rcra_client = RcrainfoService() + return self._rcra_client + + @rcra_client.setter + def rcra_client(self, value): + self._rcra_client = value + + @staticmethod + def __validate_literal(value, literal) -> bool: + return value in get_args(literal) + + @staticmethod + def _valid_state_code(state_code) -> bool: + return len(state_code) == 2 and state_code.isalpha() + + @staticmethod + def _valid_site_id(site_id: str) -> bool: + return len(site_id) > 2 and site_id.isalnum() + + @classmethod + def _emanifest_status(cls, status) -> bool: + return cls.__validate_literal(status, EmanifestStatus) + + @classmethod + def _emanifest_site_type(cls, site_type) -> bool: + return cls.__validate_literal(site_type, SiteType) + + @classmethod + def _emanifest_date_type(cls, date_type) -> bool: + return cls.__validate_literal(date_type, DateType) + + @classmethod + def _emanifest_correction_request_status(cls, correction_request_status) -> bool: + return cls.__validate_literal(correction_request_status, CorrectionRequestStatus) + + def _date_or_three_years_past(self, start_date: Optional[datetime]) -> str: + return ( + start_date.replace(tzinfo=timezone.utc).strftime(self.rcra_client.datetime_format) + if start_date + else ( + datetime.now(UTC) + - timedelta( + minutes=60 # 60 seconds/1minutes + * 24 # 24 hours/1day + * 30 # 30 days/1month + * 36 # 36 months/3years = 3/years + ) + ).strftime(self.rcra_client.datetime_format) + ) + + def _date_or_now(self, end_date: Optional[datetime]) -> str: + return ( + end_date.replace(tzinfo=timezone.utc).strftime(self.rcra_client.datetime_format) + if end_date + else datetime.now(UTC).strftime(self.rcra_client.datetime_format) + ) + + def build_search_args(self): + search_params = { + "stateCode": self.state_code, + "siteId": self.site_id, + "status": self.status, + "dateType": self.date_type, + "siteType": self.site_type, + "endDate": self.end_date, + "startDate": self.start_date, + } + return {k: v for k, v in search_params.items() if v is not None} + + def add_state_code(self, state: str): + if not self._valid_state_code(state): + raise ValueError("Invalid State code") + self.state_code = state + return self + + def add_site_id(self, site_id: str): + if not self._valid_site_id(site_id): + raise ValueError("Invalid Site ID") + self.site_id = site_id + return self + + def add_status(self, status: EmanifestStatus): + if not self._emanifest_status(status): + raise ValueError("Invalid Status") + self.status = status + return self + + def add_site_type(self, site_type: SiteType): + if not self._emanifest_site_type(site_type): + raise ValueError("Invalid Site Type") + self.site_type = site_type + return self + + def add_date_type(self, date_type: DateType): + if not self._emanifest_date_type(date_type): + raise ValueError("Invalid Date Type") + self.date_type = date_type + return self + + def add_correction_request_status(self, correction_request_status: CorrectionRequestStatus): + if not self._emanifest_correction_request_status(correction_request_status): + raise ValueError("Invalid Correction Request Status") + self.correction_request_status = correction_request_status + return self + + def add_start_date(self, start_date: datetime = None): + self.start_date = self._date_or_three_years_past(start_date) + return self + + def add_end_date(self, end_date: datetime = None): + self.end_date = self._date_or_now(end_date) + return self + + def execute(self): + search_args = self.build_search_args() + return self._rcra_client.search_mtn(**search_args) diff --git a/server/apps/manifest/tasks.py b/server/apps/manifest/tasks.py index f1b0c72f5..79734ca76 100644 --- a/server/apps/manifest/tasks.py +++ b/server/apps/manifest/tasks.py @@ -57,10 +57,10 @@ def sign_manifest( @shared_task(name="sync site manifests", bind=True) def sync_site_manifests(self, *, site_id: str, username: str): """asynchronous task to sync an EPA site's manifests""" - from apps.rcrasite.services import HaztrakSiteService + from apps.site.services import TrakSiteService try: - site_service = HaztrakSiteService(username=username) + site_service = TrakSiteService(username=username) results = site_service.sync_manifests(site_id=site_id) return results except Exception as exc: diff --git a/server/apps/manifest/tests/test_search_emanifest.py b/server/apps/manifest/tests/test_search_emanifest.py new file mode 100644 index 000000000..245e2e5ef --- /dev/null +++ b/server/apps/manifest/tests/test_search_emanifest.py @@ -0,0 +1,118 @@ +import json +from datetime import datetime + +import pytest + +from apps.core.services import RcrainfoService +from apps.manifest.services.emanifest_search import EmanifestSearch + + +class TestEmanifestSearchClass: + def test_defaults_to_unauthenticated_rcra_client(self): + search = EmanifestSearch() + assert search.rcra_client is not None + assert search.rcra_client.is_authenticated is False + + @pytest.mark.parametrize("mock_emanifest_auth_response", [["foo", "foo"]], indirect=True) + def test_execute_sends_a_request_to_rcrainfo( + self, mock_responses, mock_emanifest_auth_response + ): + stub_rcra_client = RcrainfoService(rcrainfo_env="preprod", api_key="foo", api_id="foo") + mock_responses.post( + "https://rcrainfopreprod.epa.gov/rcrainfo/rest/api/v1/emanifest/search" + ) + result = EmanifestSearch(stub_rcra_client).execute() + assert result.status_code == 200 + + @pytest.mark.parametrize("mock_emanifest_auth_response", [["foo", "foo"]], indirect=True) + def test_search_builds_json(self, mock_responses, mock_emanifest_auth_response): + stub_rcra_client = RcrainfoService(rcrainfo_env="preprod", api_key="foo", api_id="foo") + mock_responses.post( + "https://rcrainfopreprod.epa.gov/rcrainfo/rest/api/v1/emanifest/search" + ) + EmanifestSearch(stub_rcra_client).add_state_code("CA").add_site_type( + "Generator" + ).add_site_id("VATESTGEN001").execute() + for call in mock_responses.calls: + if "emanifest/search" in call.request.url: + request_body = json.loads(call.request.body) + assert request_body["stateCode"] == "CA" + assert request_body["siteType"] == "Generator" + assert request_body["siteId"] == "VATESTGEN001" + + class TestBuildSearchWithStateCode: + def test_add_state_code(self): + search = EmanifestSearch().add_state_code("CA") + assert search.state_code == "CA" + + def test_state_code_only_accepts_two_letters(self): + with pytest.raises(ValueError): + EmanifestSearch().add_state_code("California") + + def test_state_code_alphabetical(self): + with pytest.raises(ValueError): + EmanifestSearch().add_state_code("12") + + class TestBuildSearchWithStatus: + def test_add_status(self): + search = EmanifestSearch().add_status("Pending") + assert search.status == "Pending" + + def test_error_raised_with_invalid_status(self): + with pytest.raises(ValueError): + EmanifestSearch().add_status("InvalidStatus") # noqa + + class TestBuildSearchWithSiteId: + def test_add_site_id(self): + search = EmanifestSearch().add_site_id("test") + assert search.site_id == "test" + search_2 = EmanifestSearch().add_site_id("test123") + assert search_2.site_id == "test123" + + def test_site_id_is_alphanumeric(self): + with pytest.raises(ValueError): + EmanifestSearch().add_site_id("test!") + + class TestBuildSearchWithSiteType: + def test_add_site_type(self): + search = EmanifestSearch().add_site_type("Generator") + assert search.site_type == "Generator" + + def test_error_raised_with_invalid_site_type(self): + with pytest.raises(ValueError): + EmanifestSearch().add_site_type("InvalidSiteType") # noqa + + class TestBuildSearchWithDateType: + def test_add_date_type(self): + search = EmanifestSearch().add_date_type("CertifiedDate") + assert search.date_type == "CertifiedDate" + + def test_raises_error_with_invalid_date_type(self): + with pytest.raises(ValueError): + EmanifestSearch().add_date_type("InvalidDateType") # noqa + + class TestBuildSearchWithDates: + def test_add_start_date(self): + search = EmanifestSearch().add_start_date(datetime.now()) + assert search.start_date is not None + + def test_add_end_date(self): + search = EmanifestSearch().add_end_date(datetime.now()) + assert search.end_date is not None + + def test_add_end_date_defaults_to_now(self): + now = datetime.now() + search = EmanifestSearch().add_end_date() + end_date = datetime.strptime(search.end_date, RcrainfoService.datetime_format) + assert end_date.day == now.day + assert end_date.month == now.month + assert end_date.year == now.year + + class TestBuildSearchWithCorrectionRequestStatus: + def test_add_correction_request_status(self): + search = EmanifestSearch().add_correction_request_status("Sent") + assert search.correction_request_status == "Sent" + + def test_error_raised_with_invalid_correction_request_status(self): + with pytest.raises(ValueError): + EmanifestSearch().add_correction_request_status("InvalidStatus") # noqa diff --git a/server/apps/org/admin.py b/server/apps/org/admin.py index 46cc85be4..4fe16a383 100644 --- a/server/apps/org/admin.py +++ b/server/apps/org/admin.py @@ -1,11 +1,9 @@ from django.contrib import admin -from apps.org.models import TrakOrg +from apps.org.models import TrakOrg, TrakOrgAccess from apps.site.models import TrakSite -# class HaztrakProfileInline(admin.TabularInline): -# model = HaztrakProfile -# extra = 0 +admin.site.register(TrakOrgAccess) @admin.register(TrakOrg) diff --git a/server/apps/profile/serializers/rcrasite_access.py b/server/apps/profile/serializers/rcrasite_access.py index 89018e3c1..6c6955f69 100644 --- a/server/apps/profile/serializers/rcrasite_access.py +++ b/server/apps/profile/serializers/rcrasite_access.py @@ -87,23 +87,15 @@ class RcraPermissionField(serializers.Field): """Serializer for communicating with RCRAInfo, translates internal to RCRAInfo field names.""" def to_representation(self, value): - if value: - # convert boolean to 'Active' or 'Inactive' when talking to RcraInfo - value = "Active" - elif not value: - value = "InActive" + value = "Active" if value else "InActive" # RcraInfo gives us an array of object with module and level keys - ret = {"module": f"{self.field_name}", "level": value} - return ret + return {"module": f"{self.field_name}", "level": value} def to_internal_value(self, data): try: - data = data["level"] - if data == "Active": - data = True - elif data == "InActive": - data = False - return data + passed_value = data["level"] + # if 'Active' or 'InActive' is passed, convert it to True or False + return {"Active": True, "InActive": False}.get(passed_value, passed_value) except KeyError as exc: raise ValidationError(f"malformed JSON: {exc}") diff --git a/server/apps/site/services.py b/server/apps/site/services.py index 57b454ad9..24ca924df 100644 --- a/server/apps/site/services.py +++ b/server/apps/site/services.py @@ -7,6 +7,7 @@ from apps.core.services import RcrainfoService, get_rcrainfo_client from apps.manifest.services import EManifest, PullManifestsResult, TaskResponse +from apps.manifest.services.emanifest_search import EmanifestSearch from apps.manifest.tasks import sync_site_manifests from apps.site.models import TrakSite @@ -56,8 +57,13 @@ def sync_manifests(self, *, site_id: str) -> PullManifestsResult: def _get_updated_mtn(self, site_id: str, last_sync_date: datetime) -> list[str]: logger.info(f"retrieving updated MTN for site {site_id}") - emanifest = EManifest(username=self.username, rcrainfo=self.rcrainfo) - return emanifest.search(site_id=site_id, start_date=last_sync_date) + return ( + EmanifestSearch(self.rcrainfo) + .add_site_id(site_id) + .add_start_date(last_sync_date) + .add_end_date() + .execute() + ) class TrakSiteServiceError(Exception): diff --git a/server/haztrak/urls.py b/server/haztrak/urls.py index 09e5d5b0f..5d176992a 100644 --- a/server/haztrak/urls.py +++ b/server/haztrak/urls.py @@ -13,8 +13,9 @@ 1. Import the 'include()' function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin -from django.urls import include, path +from django.urls import include, path, re_path from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView # Change the Django Admin page title @@ -22,6 +23,7 @@ urlpatterns = [ path("admin/", admin.site.urls), + re_path(r"^api-auth/", include("rest_framework.urls", namespace="rest_framework")), path( "api/", include(