From 5a45a0b66fe6a6fd0b2d1fa692c648ca30edc1a9 Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 8 Dec 2023 10:25:24 -0500 Subject: [PATCH 01/12] Rename class 'ManifestService' to 'EManifest'. This class will now be used to encapsulate intereactions with e-Manifest (the RCRAInfo module) we're extracting the e-Manifest parts of the manifest service to help keep the separation of responsiblities clear --- server/apps/sites/services/site_services.py | 21 +- server/apps/trak/services/__init__.py | 5 +- server/apps/trak/services/emanifest.py | 200 +++++++++++++++ .../apps/trak/services/manifest_services.py | 231 +----------------- server/apps/trak/tasks/manifest_task.py | 32 +-- .../tests/services/test_manifest_services.py | 76 +++--- server/apps/trak/views/manifest_view.py | 12 +- 7 files changed, 279 insertions(+), 298 deletions(-) create mode 100644 server/apps/trak/services/emanifest.py diff --git a/server/apps/sites/services/site_services.py b/server/apps/sites/services/site_services.py index 4bf50dc18..09022c5fd 100644 --- a/server/apps/sites/services/site_services.py +++ b/server/apps/sites/services/site_services.py @@ -6,7 +6,7 @@ from apps.core.services import RcrainfoService, get_rcrainfo_client from apps.sites.models import HaztrakSite -from apps.trak.services import ManifestService, PullManifestsResult, TaskResponse +from apps.trak.services import EManifest, PullManifestsResult, TaskResponse from apps.trak.tasks import sync_site_manifests logger = logging.getLogger(__name__) @@ -23,11 +23,11 @@ class HaztrakSiteService: """ def __init__( - self, - *, - username: str, - site_id: Optional[str] = None, - rcrainfo: Optional[RcrainfoService] = None, + self, + *, + username: str, + site_id: Optional[str] = None, + rcrainfo: Optional[RcrainfoService] = None, ): self.username = username self.rcrainfo = rcrainfo or get_rcrainfo_client(username=username) @@ -49,8 +49,8 @@ def sync_manifests(self, *, site_id: str) -> PullManifestsResult: ) updated_mtn = updated_mtn[:15] # temporary limit to 15 logger.info(f"Pulling {updated_mtn} from RCRAInfo") - manifest = ManifestService(username=self.username, rcrainfo=self.rcrainfo) - results: PullManifestsResult = manifest.pull_manifests(tracking_numbers=updated_mtn) + emanifest = EManifest(username=self.username, rcrainfo=self.rcrainfo) + results: PullManifestsResult = emanifest.pull(tracking_numbers=updated_mtn) site.last_rcrainfo_manifest_sync = datetime.now(UTC) site.save() return results @@ -60,9 +60,8 @@ 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}") - manifest = ManifestService(username=self.username, rcrainfo=self.rcrainfo) - return manifest.search_rcrainfo_mtn(site_id=site_id, start_date=last_sync_date) - + emanifest = EManifest(username=self.username, rcrainfo=self.rcrainfo) + return emanifest.search(site_id=site_id, start_date=last_sync_date) # ToDo: all of our current HaztrakSite service class (1) does not need to be a class and (2) should # probably be moved to the manifest service module diff --git a/server/apps/trak/services/__init__.py b/server/apps/trak/services/__init__.py index 43289dbd6..1a1a2d373 100644 --- a/server/apps/trak/services/__init__.py +++ b/server/apps/trak/services/__init__.py @@ -1,8 +1,5 @@ +from .emanifest import EManifest, EManifestError, PullManifestsResult, TaskResponse from .manifest_services import ( - ManifestService, - ManifestServiceError, - PullManifestsResult, - TaskResponse, update_manifest, ) from .waste_services import get_dot_hazard_classes, get_dot_id_numbers, get_dot_shipping_names diff --git a/server/apps/trak/services/emanifest.py b/server/apps/trak/services/emanifest.py new file mode 100644 index 000000000..b09d603db --- /dev/null +++ b/server/apps/trak/services/emanifest.py @@ -0,0 +1,200 @@ +import logging +from datetime import UTC, datetime, timedelta, timezone +from typing import List, Literal, NotRequired, Optional, TypedDict + +from django.db import transaction +from django.db.models import QuerySet +from emanifest import RcrainfoResponse +from requests import RequestException + +from apps.core.services import RcrainfoService, get_rcrainfo_client +from apps.trak.models import Manifest, QuickerSign +from apps.trak.serializers import ManifestSerializer, QuickerSignSerializer +from apps.trak.tasks import pull_manifest, save_rcrainfo_manifest, sign_manifest + +logger = logging.getLogger(__name__) + + +class QuickerSignData(TypedDict): + """Type definition for the data required to sign a manifest""" + + mtn: list[str] + site_id: str + site_type: Literal["Generator", "Tsdf", "Transporter"] + printed_name: str + printed_data: datetime + transporter_order: NotRequired[int] + + +class TaskResponse(TypedDict): + """Type definition for the response returned from starting a task""" + + taskId: str + + +class EManifestError(Exception): + """Base class for EManifest exceptions""" + + def __init__(self, message: str = None, *args): + self.message = message + super().__init__(*args) + + +class PullManifestsResult(TypedDict): + """Type definition for the results returned from pulling manifests from RCRAInfo""" + + success: List[str] + error: List[str] + + +class EManifest: + """IO interface with the e-Manifest system.""" + + def __init__(self, *, username: str, rcrainfo: Optional[RcrainfoService] = None): + self.username = username + self.rcrainfo = rcrainfo or get_rcrainfo_client(username=username) + + 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": []} + logger.info(f"pulling manifests {tracking_numbers}") + for mtn in tracking_numbers: + try: + manifest_json: dict = self._retrieve_manifest(mtn) + manifest = self._save_manifest_json_to_db(manifest_json) + results["success"].append(manifest.mtn) + except Exception as exc: + logger.warning(f"error pulling manifest {mtn}: {exc}") + results["error"].append(mtn) + logger.info(f"pull manifests results: {results}") + return results + + def sign(self, *, signature: QuickerSign) -> TaskResponse: + """validate and Launch an asynchronous task to electronically sign a manifest.""" + signature.mtn = self._filter_mtn( + mtn=signature.mtn, site_id=signature.site_id, site_type=signature.site_type + ) + signature_serializer = QuickerSignSerializer(signature) + task = sign_manifest.delay(username=self.username, **signature_serializer.data) + return {"taskId": task.id} + + def submit_quick_signature(self, signature: dict) -> PullManifestsResult: + results: PullManifestsResult = {"success": [], "error": []} + response = self.rcrainfo.sign_manifest(**signature) + if response.ok: + for manifest in response.json()["manifestReports"]: + pull_manifest.delay( + mtn=[manifest["manifestTrackingNumber"]], username=self.username + ) + else: + logger.warning(f"Error Quicker signing {response.status_code} {response.json()}") + return results + + def create(self, *, manifest: dict) -> dict | TaskResponse: + """Create a manifest in RCRAInfo through the RESTful API.""" + if self.rcrainfo.has_rcrainfo_credentials and manifest.get("status") != "NotAssigned": + logger.info("POSTing manifest to RCRAInfo.") + task = save_rcrainfo_manifest.delay(manifest_data=manifest, username=self.username) + return {"taskId": task.id} + else: + logger.info("Saving manifest manifest to DB without RCRAInfo") + saved_manifest = self._save_manifest_json_to_db(manifest) + return ManifestSerializer(saved_manifest).data + + def save(self, manifest: dict) -> dict: + """Save manifest to e-Manifest""" + logger.info(f"start save manifest to rcrainfo with arguments {manifest}") + create_resp: RcrainfoResponse = self.rcrainfo.save_manifest(manifest) + try: + if create_resp.ok: + logger.info( + f"successfully created manifest " + f"{create_resp.json()['manifestTrackingNumber']} in RCRAInfo" + ) + self.pull([create_resp.json()["manifestTrackingNumber"]]) + return create_resp.json() + raise EManifestError(message=f"error creating manifest: {create_resp.json()}") + except KeyError: + logger.error( + f"error retrieving manifestTrackingNumber from response: {create_resp.json()}" + ) + raise EManifestError("malformed payload") + + @staticmethod + def _filter_mtn( + *, mtn: list[str], site_id: str, site_type: Literal["Generator", "Tsdf", "Transporter"] + ) -> list[str]: + site_filter = Manifest.objects.get_handler_query(site_id, site_type) + existing_mtn = Manifest.objects.existing_mtn(site_filter, mtn=mtn) + return [manifest.mtn for manifest in existing_mtn] + + def _retrieve_manifest(self, mtn: str): + """Retrieve a manifest from RCRAInfo""" + logger.info(f"retrieving manifest from RCRAInfo {mtn}") + response = self.rcrainfo.get_manifest(mtn) + if response.ok: + logger.debug(f"manifest pulled {mtn}") + return response.json() + else: + logger.warning(f"error retrieving manifest {mtn}") + raise RequestException(response.json()) + + @transaction.atomic + def _save_manifest_json_to_db(self, manifest_json: dict) -> Manifest: + """Save manifest to Haztrak database""" + logger.info("saving manifest to DB") + manifest_query: QuerySet = Manifest.objects.filter( + mtn=manifest_json["manifestTrackingNumber"] + ) + if manifest_query.exists(): + serializer = ManifestSerializer(manifest_query.get(), data=manifest_json) + else: + serializer = ManifestSerializer(data=manifest_json) + serializer.is_valid(raise_exception=True) + logger.debug("manifest serializer is valid") + manifest = serializer.save() + logger.info(f"saved manifest {manifest.mtn}") + return manifest diff --git a/server/apps/trak/services/manifest_services.py b/server/apps/trak/services/manifest_services.py index 6af405436..58b88978f 100644 --- a/server/apps/trak/services/manifest_services.py +++ b/server/apps/trak/services/manifest_services.py @@ -1,231 +1,16 @@ import logging -from datetime import UTC, datetime, timedelta, timezone -from typing import List, Literal, NotRequired, Optional, TypedDict +from typing import Literal, Optional from django.db import transaction from django.db.models import Q, QuerySet -from emanifest import RcrainfoResponse -from requests import RequestException -from apps.core.services import RcrainfoService, get_rcrainfo_client from apps.sites.models import HaztrakSite -from apps.trak.models import Manifest, QuickerSign -from apps.trak.serializers import ManifestSerializer, QuickerSignSerializer -from apps.trak.tasks import pull_manifest, save_rcrainfo_manifest, sign_manifest +from apps.trak.models import Manifest +from apps.trak.services import EManifestError logger = logging.getLogger(__name__) -class TaskResponse(TypedDict): - """Type definition for the response returned from starting a task""" - - taskId: str - - -class QuickerSignData(TypedDict): - """Type definition for the data required to sign a manifest""" - - mtn: list[str] - site_id: str - site_type: Literal["Generator", "Tsdf", "Transporter"] - printed_name: str - printed_data: datetime - transporter_order: NotRequired[int] - - -class ManifestServiceError(Exception): - """Base class for ManifestService exceptions""" - - def __init__(self, message: str = None, *args): - self.message = message - super().__init__(*args) - - -class PullManifestsResult(TypedDict): - """Type definition for the results returned from pulling manifests from RCRAInfo""" - - success: List[str] - error: List[str] - - -class ManifestService: - """ - ManifestServices encapsulates the uniform hazardous waste manifest subdomain - business logic and exposes methods corresponding to use cases. - """ - - def __init__(self, *, username: str, rcrainfo: Optional[RcrainfoService] = None): - self.username = username - self.rcrainfo = rcrainfo or get_rcrainfo_client(username=username) - - def search_rcrainfo_mtn( - 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 RCRAInfo for manifests, an abstraction of RcrainfoService's search_mtn - - Keyword Args: - site_id (str): EPA ID a site. - start_date (datetime): start of search window, defaults to 3 years ago. - end_date (datetime): end of search window, defaults to now. - status (str): manifest status in RCRAInfo. - date_type (str): "CertifiedDate|ReceivedDate|ShippedDate|UpdatedDate" - state_code (str): Two-letter code representing a state (e.g., "TX", "CA") - site_type (str): "Generator|Tsdf|Transporter|RejectionInfo_AlternateTsdf" - """ - 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_manifests(self, tracking_numbers: List[str]) -> PullManifestsResult: - """ - Pull a list of manifest from RCRAInfo - - Returns: - results (Dict): with 2 members, 'success' and 'error' each is a list of MTN - that corresponds to what manifest where successfully pulled or not. - """ - results: PullManifestsResult = {"success": [], "error": []} - logger.info(f"pulling manifests {tracking_numbers}") - for mtn in tracking_numbers: - try: - manifest_json: dict = self._retrieve_manifest(mtn) - manifest = self._save_manifest_json_to_db(manifest_json) - results["success"].append(manifest.mtn) - except Exception as exc: - logger.warning(f"error pulling manifest {mtn}: {exc}") - results["error"].append(mtn) - logger.info(f"pull manifests results: {results}") - return results - - def sign_manifests(self, *, signature: QuickerSign) -> TaskResponse: - """ - Launch an asynchronous task to electronically sign a manifest. - """ - signature.mtn = self._filter_mtn( - mtn=signature.mtn, site_id=signature.site_id, site_type=signature.site_type - ) - signature_serializer = QuickerSignSerializer(signature) - task = sign_manifest.delay(username=self.username, **signature_serializer.data) - return {"taskId": task.id} - - def quicker_sign_manifests(self, signature: dict) -> PullManifestsResult: - results: PullManifestsResult = {"success": [], "error": []} - response = self.rcrainfo.sign_manifest(**signature) - if response.ok: - for manifest in response.json()["manifestReports"]: - pull_manifest.delay( - mtn=[manifest["manifestTrackingNumber"]], username=self.username - ) - else: - logger.warning(f"Error Quicker signing {response.status_code} {response.json()}") - return results - - def create_manifest(self, *, manifest: dict) -> dict | TaskResponse: - """ - Create a manifest in RCRAInfo through the RESTful API. - :param manifest: Dict - :return: - """ - if self.rcrainfo.has_rcrainfo_credentials and manifest.get("status") != "NotAssigned": - logger.info("POSTing manifest to RCRAInfo.") - task = save_rcrainfo_manifest.delay(manifest_data=manifest, username=self.username) - return {"taskId": task.id} - else: - logger.info("Saving manifest manifest to DB without RCRAInfo") - saved_manifest = self._save_manifest_json_to_db(manifest) - return ManifestSerializer(saved_manifest).data - - def save_to_rcrainfo(self, manifest: dict) -> dict: - logger.info(f"start save manifest to rcrainfo with arguments {manifest}") - create_resp: RcrainfoResponse = self.rcrainfo.save_manifest(manifest) - try: - if create_resp.ok: - logger.info( - f"successfully created manifest " - f"{create_resp.json()['manifestTrackingNumber']} in RCRAInfo" - ) - self.pull_manifests([create_resp.json()["manifestTrackingNumber"]]) - return create_resp.json() - raise ManifestServiceError(message=f"error creating manifest: {create_resp.json()}") - except KeyError: - logger.error( - f"error retrieving manifestTrackingNumber from response: {create_resp.json()}" - ) - raise ManifestServiceError("malformed payload") - - @staticmethod - def _filter_mtn( - *, mtn: list[str], site_id: str, site_type: Literal["Generator", "Tsdf", "Transporter"] - ) -> list[str]: - site_filter = Manifest.objects.get_handler_query(site_id, site_type) - existing_mtn = Manifest.objects.existing_mtn(site_filter, mtn=mtn) - return [manifest.mtn for manifest in existing_mtn] - - def _retrieve_manifest(self, mtn: str): - """Retrieve a manifest from RCRAInfo""" - logger.info(f"retrieving manifest from RCRAInfo {mtn}") - response = self.rcrainfo.get_manifest(mtn) - if response.ok: - logger.debug(f"manifest pulled {mtn}") - return response.json() - else: - logger.warning(f"error retrieving manifest {mtn}") - raise RequestException(response.json()) - - @transaction.atomic - def _save_manifest_json_to_db(self, manifest_json: dict) -> Manifest: - """Save manifest to Haztrak database""" - logger.info("saving manifest to DB") - manifest_query: QuerySet = Manifest.objects.filter( - mtn=manifest_json["manifestTrackingNumber"] - ) - if manifest_query.exists(): - serializer = ManifestSerializer(manifest_query.get(), data=manifest_json) - else: - serializer = ManifestSerializer(data=manifest_json) - serializer.is_valid(raise_exception=True) - logger.debug("manifest serializer is valid") - manifest = serializer.save() - logger.info(f"saved manifest {manifest.mtn}") - return manifest - - @transaction.atomic def update_manifest(*, mtn: Optional[str], data: dict) -> Manifest: """Update a manifest in the Haztrak database""" @@ -234,14 +19,14 @@ def update_manifest(*, mtn: Optional[str], data: dict) -> Manifest: manifest = Manifest.objects.save(original_manifest, **data) return manifest except Manifest.DoesNotExist: - raise ManifestServiceError(f"manifest {mtn} does not exist") + raise EManifestError(f"manifest {mtn} does not exist") def get_manifests( - *, - username: str, - epa_id: Optional[str] = None, - site_type: Optional[Literal["Generator", "Tsdf", "Transporter"]] = None, + *, + username: str, + epa_id: Optional[str] = None, + site_type: Optional[Literal["Generator", "Tsdf", "Transporter"]] = None, ) -> QuerySet[Manifest]: """Get a list of manifest tracking numbers and select details for a users site""" sites: QuerySet[HaztrakSite] = ( diff --git a/server/apps/trak/tasks/manifest_task.py b/server/apps/trak/tasks/manifest_task.py index b81f15ddf..b4276372d 100644 --- a/server/apps/trak/tasks/manifest_task.py +++ b/server/apps/trak/tasks/manifest_task.py @@ -10,17 +10,17 @@ @shared_task(name="pull manifest", bind=True, acks_late=True) def pull_manifest(self: Task, *, mtn: List[str], username: str) -> dict: """ - This task initiates a call to the ManifestService to pull a manifest by MTN + This task initiates a call to the EManifest to pull a manifest by MTN """ from apps.core.services import TaskService - from apps.trak.services import ManifestService + from apps.trak.services import EManifest logger.info(f"start task {self.name}, manifest {mtn}") task_status = TaskService(task_id=self.request.id, task_name=self.name, status="STARTED") try: - manifest_service = ManifestService(username=username) - results = manifest_service.pull_manifests(tracking_numbers=mtn) + emanifest = EManifest(username=username) + results = emanifest.pull(tracking_numbers=mtn) task_status.update_task_status(status="SUCCESS", results=results) return results except (ConnectionError, TimeoutError): @@ -34,19 +34,19 @@ def pull_manifest(self: Task, *, mtn: List[str], username: str) -> dict: @shared_task(name="sign manifests", bind=True, acks_late=True) def sign_manifest( - self: Task, - *, - username: str, - **signature_data: dict, + self: Task, + *, + username: str, + **signature_data: dict, ) -> Dict: """ a task to Quicker Sign manifest, by MTN, in RCRAInfo """ - from apps.trak.services import ManifestService + from apps.trak.services import EManifest try: - manifest_service = ManifestService(username=username) - return manifest_service.quicker_sign_manifests(**signature_data) + emanifest = EManifest(username=username) + return emanifest.submit_quick_signature(**signature_data) except (ConnectionError, TimeoutError) as exc: raise Reject(exc) # To Do: add retry logic except Exception as exc: @@ -77,18 +77,18 @@ def save_rcrainfo_manifest(self, *, manifest_data: dict, username: str): user who is creating the manifest """ from apps.core.services import TaskService - from apps.trak.services import ManifestService, ManifestServiceError + from apps.trak.services import EManifest, EManifestError logger.info(f"start task: {self.name}") task_status = TaskService(task_id=self.request.id, task_name=self.name, status="STARTED") try: - manifest = ManifestService(username=username) - new_manifest = manifest.save_to_rcrainfo(manifest=manifest_data) + emanifest = EManifest(username=username) + new_manifest = emanifest.save(manifest=manifest_data) if new_manifest: task_status.update_task_status(status="SUCCESS", results=new_manifest) return new_manifest - raise ManifestServiceError("error creating manifest") - except ManifestServiceError as exc: + raise EManifestError("error creating manifest") + except EManifestError as exc: logger.error(f"failed to create manifest ({manifest_data}): {exc.message}") task_status.update_task_status(status="FAILURE", results=exc.message) return {"error": exc.message} diff --git a/server/apps/trak/tests/services/test_manifest_services.py b/server/apps/trak/tests/services/test_manifest_services.py index e501d8346..40b59f90d 100644 --- a/server/apps/trak/tests/services/test_manifest_services.py +++ b/server/apps/trak/tests/services/test_manifest_services.py @@ -4,7 +4,7 @@ from apps.core.services import RcrainfoService, get_rcrainfo_client from apps.trak.models import Manifest -from apps.trak.services import ManifestService +from apps.trak.services import EManifest from apps.trak.services.manifest_services import get_manifests @@ -28,22 +28,22 @@ def manifest_100033134elc_rcra_response(self, haztrak_json, mock_responses): ) def test_pull_manifests( - self, manifest_100033134elc_rcra_response, mocker: pytest_mock.MockerFixture + self, manifest_100033134elc_rcra_response, mocker: pytest_mock.MockerFixture ): """Test retrieves a manifest from RCRAInfo""" rcrainfo = RcrainfoService(auto_renew=False) - manifest_service = ManifestService(username=self.user.username, rcrainfo=rcrainfo) - results = manifest_service.pull_manifests(tracking_numbers=[self.tracking_number]) + emanifest = EManifest(username=self.user.username, rcrainfo=rcrainfo) + results = emanifest.pull(tracking_numbers=[self.tracking_number]) assert self.tracking_number in results["success"] class TestSignManifest: def test_filter_mtn_removed_mtn_not_associated_with_site( - self, - manifest_factory, - rcra_site_factory, - manifest_handler_factory, - haztrak_profile_factory, + self, + manifest_factory, + rcra_site_factory, + manifest_handler_factory, + haztrak_profile_factory, ): # Arrange profile = haztrak_profile_factory() @@ -52,9 +52,9 @@ def test_filter_mtn_removed_mtn_not_associated_with_site( my_manifest = manifest_factory(generator=my_handler) not_my_manifest = manifest_factory(mtn="123456555ELC") - manifest_service = ManifestService(username=profile.user.username) + emanifest = EManifest(username=profile.user.username) # Act - filtered_manifest = manifest_service._filter_mtn( + filtered_manifest = emanifest._filter_mtn( mtn=[my_manifest.mtn, not_my_manifest.mtn], site_id=my_handler.rcra_site.epa_id, site_type=my_handler.rcra_site.site_type, @@ -66,15 +66,15 @@ def test_filter_mtn_removed_mtn_not_associated_with_site( class TestGetManifestService: def test_returns_manifests_from_all_user_sites_by_default( - self, - manifest_factory, - haztrak_profile_factory, - user_factory, - haztrak_site_factory, - rcra_site_factory, - haztrak_site_permission_factory, - manifest_handler_factory, - manifest_transporter_factory, + self, + manifest_factory, + haztrak_profile_factory, + user_factory, + haztrak_site_factory, + rcra_site_factory, + haztrak_site_permission_factory, + manifest_handler_factory, + manifest_transporter_factory, ): # Arrange profile = haztrak_profile_factory() @@ -104,15 +104,15 @@ def test_returns_manifests_from_all_user_sites_by_default( assert manifests.count() == Manifest.objects.count() def test_filters_by_epa_id( - self, - manifest_factory, - haztrak_profile_factory, - user_factory, - haztrak_site_factory, - rcra_site_factory, - haztrak_site_permission_factory, - manifest_handler_factory, - manifest_transporter_factory, + self, + manifest_factory, + haztrak_profile_factory, + user_factory, + haztrak_site_factory, + rcra_site_factory, + haztrak_site_permission_factory, + manifest_handler_factory, + manifest_transporter_factory, ): # Arrange profile = haztrak_profile_factory() @@ -137,15 +137,15 @@ def test_filters_by_epa_id( assert tsdf_manifest.mtn not in returned_mtn def test_filters_by_site_type( - self, - manifest_factory, - haztrak_profile_factory, - user_factory, - haztrak_site_factory, - rcra_site_factory, - haztrak_site_permission_factory, - manifest_handler_factory, - manifest_transporter_factory, + self, + manifest_factory, + haztrak_profile_factory, + user_factory, + haztrak_site_factory, + rcra_site_factory, + haztrak_site_permission_factory, + manifest_handler_factory, + manifest_transporter_factory, ): # Arrange profile = haztrak_profile_factory() diff --git a/server/apps/trak/views/manifest_view.py b/server/apps/trak/views/manifest_view.py index b5e07e2c9..48bc8d92f 100644 --- a/server/apps/trak/views/manifest_view.py +++ b/server/apps/trak/views/manifest_view.py @@ -11,8 +11,8 @@ from apps.trak.models import Manifest from apps.trak.serializers import ManifestSerializer from apps.trak.serializers.signature_serializer import QuickerSignSerializer -from apps.trak.services import ManifestService -from apps.trak.services.manifest_services import TaskResponse, get_manifests +from apps.trak.services import EManifest, TaskResponse +from apps.trak.services.manifest_services import get_manifests logger = logging.getLogger(__name__) @@ -44,8 +44,8 @@ class CreateManifestView(GenericAPIView): def post(self, request: Request) -> Response: manifest_serializer = self.serializer_class(data=request.data) manifest_serializer.is_valid(raise_exception=True) - manifest = ManifestService(username=str(request.user)) - data = manifest.create_manifest(manifest=manifest_serializer.data) + emanifest = EManifest(username=str(request.user)) + data = emanifest.create(manifest=manifest_serializer.data) return Response(data=data, status=status.HTTP_201_CREATED) @@ -109,8 +109,8 @@ def post(self, request: Request) -> Response: quicker_serializer = self.serializer_class(data=request.data) quicker_serializer.is_valid(raise_exception=True) signature = quicker_serializer.save() - manifest = ManifestService(username=str(request.user)) - data: TaskResponse = manifest.sign_manifests(signature=signature) + emanifest = EManifest(username=str(request.user)) + data: TaskResponse = emanifest.sign(signature=signature) return Response(data=data, status=status.HTTP_200_OK) From bd56d3e688ffce16b734629fe4f19ea1f36f5921 Mon Sep 17 00:00:00 2001 From: David Graham Date: Mon, 11 Dec 2023 08:18:14 -0500 Subject: [PATCH 02/12] add a faker library provider for manifest status and use in manifest_factory --- .../apps/trak/services/manifest_services.py | 15 ++++ server/apps/trak/tests/conftest.py | 79 ++++++++++--------- .../tests/services/test_emanifest_service.py | 62 +++++++++++++++ .../tests/services/test_manifest_services.py | 62 --------------- 4 files changed, 120 insertions(+), 98 deletions(-) create mode 100644 server/apps/trak/tests/services/test_emanifest_service.py diff --git a/server/apps/trak/services/manifest_services.py b/server/apps/trak/services/manifest_services.py index 58b88978f..f9c8fa61b 100644 --- a/server/apps/trak/services/manifest_services.py +++ b/server/apps/trak/services/manifest_services.py @@ -17,6 +17,7 @@ def update_manifest(*, mtn: Optional[str], data: dict) -> Manifest: try: original_manifest = Manifest.objects.get(mtn=mtn) manifest = Manifest.objects.save(original_manifest, **data) + # ToDo: update e-Manifest return manifest except Manifest.DoesNotExist: raise EManifestError(f"manifest {mtn} does not exist") @@ -44,3 +45,17 @@ def get_manifests( | Q(tsdf__rcra_site__epa_id__in=sites) | Q(transporters__rcra_site__epa_id__in=sites) ) + +# +# def create_manifest(self, *, username: str, manifest: dict) -> dict | TaskResponse: +# """Save a manifest to Haztrak database and/or e-Manifest/RCRAInfo""" +# emanifest = EManifest(username=username) +# # data = emanifest.create(manifest=manifest_serializer.data) +# if emanifest.has_rcrainfo_credentials and manifest.get("status") != "NotAssigned": +# logger.info("POSTing manifest to RCRAInfo.") +# task = save_rcrainfo_manifest.delay(manifest_data=manifest, username=self.username) +# return {"taskId": task.id} +# else: +# logger.info("Saving manifest manifest to DB without RCRAInfo") +# saved_manifest = self._save_manifest_json_to_db(manifest) +# return ManifestSerializer(saved_manifest).data diff --git a/server/apps/trak/tests/conftest.py b/server/apps/trak/tests/conftest.py index b06d3dceb..c7a843505 100644 --- a/server/apps/trak/tests/conftest.py +++ b/server/apps/trak/tests/conftest.py @@ -21,21 +21,25 @@ from apps.trak.models.waste_models import DotLookupType, WasteLine -class MTNProvider(BaseProvider): +class ManifestProvider(BaseProvider): SUFFIXES = ["ELC", "JJK", "FLE"] + STATUSES = ["NotAssigned", "Pending", "Scheduled", "InTransit", "ReadyForSignature"] NUMBERS = ["".join(random.choices(string.digits, k=9)) for _ in range(100)] def mtn(self): return f"{self.random_element(self.NUMBERS)}{self.random_element(self.SUFFIXES)}" + def status(self): + return f"{self.random_element(self.STATUSES)}" + @pytest.fixture def manifest_handler_factory(db, rcra_site_factory, paper_signature_factory): """Abstract factory for Haztrak Handler model""" def create_manifest_handler( - rcra_site: Optional[RcraSite] = None, - paper_signature: Optional[PaperSignature] = None, + rcra_site: Optional[RcraSite] = None, + paper_signature: Optional[PaperSignature] = None, ) -> Handler: return Handler.objects.create( rcra_site=rcra_site or rcra_site_factory(), @@ -50,10 +54,10 @@ def manifest_transporter_factory(db, rcra_site_factory, paper_signature_factory) """Abstract factory for Haztrak Handler model""" def create_manifest_handler( - rcra_site: Optional[RcraSite] = None, - paper_signature: Optional[PaperSignature] = None, - manifest: Manifest = None, - order: Optional[int] = 1, + rcra_site: Optional[RcraSite] = None, + paper_signature: Optional[PaperSignature] = None, + manifest: Manifest = None, + order: Optional[int] = 1, ) -> Transporter: return Transporter.objects.create( manifest=manifest, @@ -70,8 +74,8 @@ def paper_signature_factory(db, faker: Faker): """Abstract factory for Paper Signature""" def create_signature( - printed_name: Optional[str] = None, - sign_date: Optional[datetime] = None, + printed_name: Optional[str] = None, + sign_date: Optional[datetime] = None, ) -> PaperSignature: return PaperSignature.objects.create( printed_name=printed_name or faker.name(), @@ -86,8 +90,8 @@ def e_signature_factory(db, signer_factory, manifest_handler_factory, faker: Fak """Abstract factory for Haztrak Handler model""" def create_e_signature( - signer: Optional[Signer] = None, - manifest_handler: Optional[Handler] = None, + signer: Optional[Signer] = None, + manifest_handler: Optional[Handler] = None, ) -> ESignature: return ESignature.objects.create( signer=signer or signer_factory(), @@ -106,10 +110,10 @@ def waste_code_factory(db): """Abstract factory for waste codes""" def create_waste_code( - code: Optional[str] = "D001", - description: Optional[str] = "IGNITABLE WASTE", - code_type: Optional[WasteCode.CodeType] = WasteCode.CodeType.FEDERAL, - state_id: Optional[str] = "VA", + code: Optional[str] = "D001", + description: Optional[str] = "IGNITABLE WASTE", + code_type: Optional[WasteCode.CodeType] = WasteCode.CodeType.FEDERAL, + state_id: Optional[str] = "VA", ) -> WasteCode: if code_type == WasteCode.CodeType.STATE: waste_code = WasteCode.objects.create( @@ -134,12 +138,12 @@ def signer_factory(db, faker: Faker): """Abstract factory for Haztrak Signer model""" def creat_signer( - first_name: Optional[str] = None, - middle_initial: Optional[str] = None, - last_name: Optional[str] = None, - signer_role: Optional[str] = "EP", - company_name: Optional[str] = None, - rcra_user_id: Optional[str] = None, + first_name: Optional[str] = None, + middle_initial: Optional[str] = None, + last_name: Optional[str] = None, + signer_role: Optional[str] = "EP", + company_name: Optional[str] = None, + rcra_user_id: Optional[str] = None, ) -> Signer: return Signer.objects.create( first_name=first_name or faker.first_name(), @@ -158,22 +162,25 @@ def manifest_factory(db, manifest_handler_factory, rcra_site_factory): """Abstract factory for Haztrak Manifest model""" def create_manifest( - mtn: Optional[str] = None, - generator: Optional[Handler] = None, - tsdf: Optional[Handler] = None, + mtn: Optional[str] = None, + generator: Optional[Handler] = None, + tsdf: Optional[Handler] = None, + status: Optional[str] = None, ) -> Manifest: fake = Faker() - fake.add_provider(MTNProvider) + fake.add_provider(ManifestProvider) return Manifest.objects.create( mtn=mtn or fake.mtn(), + status=status or fake.status(), created_date=datetime.now(UTC), potential_ship_date=datetime.now(UTC), generator=generator - or manifest_handler_factory( + or manifest_handler_factory( rcra_site=rcra_site_factory(site_type=RcraSiteType.GENERATOR) ), tsdf=tsdf - or manifest_handler_factory(rcra_site=rcra_site_factory(site_type=RcraSiteType.TSDF)), + or manifest_handler_factory( + rcra_site=rcra_site_factory(site_type=RcraSiteType.TSDF)), ) return create_manifest @@ -184,13 +191,13 @@ def waste_line_factory(db, faker: Faker): """Abstract factory for Haztrak DotLookup model""" def create_waste_line( - manifest: Manifest = None, - dot_hazardous: Optional[bool] = True, - quantity: Optional[dict] = None, - line_number: Optional[int] = 1, - br: Optional[bool] = False, - pcb: Optional[bool] = False, - epa_waste: Optional[bool] = True, + manifest: Manifest = None, + dot_hazardous: Optional[bool] = True, + quantity: Optional[dict] = None, + line_number: Optional[int] = 1, + br: Optional[bool] = False, + pcb: Optional[bool] = False, + epa_waste: Optional[bool] = True, ) -> WasteLine: return WasteLine.objects.create( manifest=manifest, @@ -210,8 +217,8 @@ def dot_option_factory(db, faker: Faker): """Abstract factory for Haztrak DotLookup model""" def create_dot_option( - value: Optional[str] = None, - value_type: Optional[DotLookupType] = DotLookupType.ID, + value: Optional[str] = None, + value_type: Optional[DotLookupType] = DotLookupType.ID, ) -> Manifest: return DotLookup.objects.create( value=value or faker.pystr(max_chars=10), value_type=value_type diff --git a/server/apps/trak/tests/services/test_emanifest_service.py b/server/apps/trak/tests/services/test_emanifest_service.py new file mode 100644 index 000000000..8868c5053 --- /dev/null +++ b/server/apps/trak/tests/services/test_emanifest_service.py @@ -0,0 +1,62 @@ +import pytest +import pytest_mock +from rest_framework import status + +from apps.core.services import RcrainfoService, get_rcrainfo_client +from apps.trak.services import EManifest + + +class TestEManifestService: + @pytest.fixture(autouse=True) + def _setup(self, user_factory, haztrak_site_factory, haztrak_json): + self.user = user_factory() + self.gen001 = haztrak_site_factory() + self.json_100031134elc = haztrak_json.MANIFEST.value + self.tracking_number = self.json_100031134elc.get("manifestTrackingNumber", "123456789ELC") + + @pytest.fixture + def manifest_100033134elc_rcra_response(self, haztrak_json, mock_responses): + rcrainfo = get_rcrainfo_client(api_id="my_mock_id", api_key="my_mock_key") + manifest_json = haztrak_json.MANIFEST.value + mock_responses.get( + url=f'{rcrainfo.base_url}v1/emanifest/manifest/{manifest_json.get("manifestTrackingNumber")}', + content_type="application/json", + json=manifest_json, + status=status.HTTP_200_OK, + ) + + def test_pull_manifests( + self, manifest_100033134elc_rcra_response, mocker: pytest_mock.MockerFixture + ): + """Test retrieves a manifest from RCRAInfo""" + rcrainfo = RcrainfoService(auto_renew=False) + emanifest = EManifest(username=self.user.username, rcrainfo=rcrainfo) + results = emanifest.pull(tracking_numbers=[self.tracking_number]) + assert self.tracking_number in results["success"] + + +class TestEManifestSignManifest: + def test_filter_mtn_removed_mtn_not_associated_with_site( + self, + manifest_factory, + rcra_site_factory, + manifest_handler_factory, + haztrak_profile_factory, + ): + # Arrange + profile = haztrak_profile_factory() + my_site = rcra_site_factory() + my_handler = manifest_handler_factory(rcra_site=my_site) + my_manifest = manifest_factory(generator=my_handler) + not_my_manifest = manifest_factory(mtn="123456555ELC") + + emanifest = EManifest(username=profile.user.username) + # Act + filtered_manifest = emanifest._filter_mtn( + mtn=[my_manifest.mtn, not_my_manifest.mtn], + site_id=my_handler.rcra_site.epa_id, + site_type=my_handler.rcra_site.site_type, + ) + # Assert + assert my_manifest.mtn in filtered_manifest + assert not_my_manifest.mtn not in filtered_manifest diff --git a/server/apps/trak/tests/services/test_manifest_services.py b/server/apps/trak/tests/services/test_manifest_services.py index 40b59f90d..144579ca7 100644 --- a/server/apps/trak/tests/services/test_manifest_services.py +++ b/server/apps/trak/tests/services/test_manifest_services.py @@ -1,69 +1,7 @@ -import pytest -import pytest_mock -from rest_framework import status - -from apps.core.services import RcrainfoService, get_rcrainfo_client from apps.trak.models import Manifest -from apps.trak.services import EManifest from apps.trak.services.manifest_services import get_manifests -class TestManifestService: - @pytest.fixture(autouse=True) - def _setup(self, user_factory, haztrak_site_factory, haztrak_json): - self.user = user_factory() - self.gen001 = haztrak_site_factory() - self.json_100031134elc = haztrak_json.MANIFEST.value - self.tracking_number = self.json_100031134elc.get("manifestTrackingNumber", "123456789ELC") - - @pytest.fixture - def manifest_100033134elc_rcra_response(self, haztrak_json, mock_responses): - rcrainfo = get_rcrainfo_client(api_id="my_mock_id", api_key="my_mock_key") - manifest_json = haztrak_json.MANIFEST.value - mock_responses.get( - url=f'{rcrainfo.base_url}v1/emanifest/manifest/{manifest_json.get("manifestTrackingNumber")}', - content_type="application/json", - json=manifest_json, - status=status.HTTP_200_OK, - ) - - def test_pull_manifests( - self, manifest_100033134elc_rcra_response, mocker: pytest_mock.MockerFixture - ): - """Test retrieves a manifest from RCRAInfo""" - rcrainfo = RcrainfoService(auto_renew=False) - emanifest = EManifest(username=self.user.username, rcrainfo=rcrainfo) - results = emanifest.pull(tracking_numbers=[self.tracking_number]) - assert self.tracking_number in results["success"] - - -class TestSignManifest: - def test_filter_mtn_removed_mtn_not_associated_with_site( - self, - manifest_factory, - rcra_site_factory, - manifest_handler_factory, - haztrak_profile_factory, - ): - # Arrange - profile = haztrak_profile_factory() - my_site = rcra_site_factory() - my_handler = manifest_handler_factory(rcra_site=my_site) - my_manifest = manifest_factory(generator=my_handler) - not_my_manifest = manifest_factory(mtn="123456555ELC") - - emanifest = EManifest(username=profile.user.username) - # Act - filtered_manifest = emanifest._filter_mtn( - mtn=[my_manifest.mtn, not_my_manifest.mtn], - site_id=my_handler.rcra_site.epa_id, - site_type=my_handler.rcra_site.site_type, - ) - # Assert - assert my_manifest.mtn in filtered_manifest - assert not_my_manifest.mtn not in filtered_manifest - - class TestGetManifestService: def test_returns_manifests_from_all_user_sites_by_default( self, From 1142a122789c9511d057524fc4c52f8ab20aba07 Mon Sep 17 00:00:00 2001 From: David Graham Date: Mon, 11 Dec 2023 08:18:53 -0500 Subject: [PATCH 03/12] create_manifest service module functionality and utilize in CreateManifestView --- server/apps/conftest.py | 34 ++++++++ server/apps/trak/services/__init__.py | 2 + server/apps/trak/services/emanifest.py | 44 +++++----- .../apps/trak/services/manifest_services.py | 36 ++++----- server/apps/trak/tasks/__init__.py | 2 +- server/apps/trak/tasks/manifest_task.py | 2 +- server/apps/trak/tests/conftest.py | 2 +- .../tests/services/test_manifest_services.py | 80 ++++++++++++------- server/apps/trak/views/manifest_view.py | 8 +- 9 files changed, 133 insertions(+), 77 deletions(-) diff --git a/server/apps/conftest.py b/server/apps/conftest.py index 75364226a..92de5d7dc 100644 --- a/server/apps/conftest.py +++ b/server/apps/conftest.py @@ -314,3 +314,37 @@ def create_permission( ) return create_permission + + +@pytest.fixture +def user_with_org_factory( + db, + user_factory, + haztrak_org_factory, + rcra_profile_factory, + haztrak_profile_factory, +): + """Fixture for creating a user with an org that has set up RCRAInfo integration""" + + def create_fixtures( + user: Optional[User] = None, + org: Optional[HaztrakOrg] = None, + admin_rcrainfo_profile: Optional[RcraProfile] = None, + is_rcrainfo_enabled: Optional[bool] = True, + ): + if is_rcrainfo_enabled: + rcra_profile_data = { + "rcra_api_id": "mock_api_id", + "rcra_api_key": "mock_api_key", + "rcra_username": "mock_username", + } + else: + rcra_profile_data = {"rcra_api_id": None, "rcra_api_key": None, "rcra_username": None} + user = user or user_factory() + admin = user_factory(username="admin") + admin_rcrainfo_profile or rcra_profile_factory(**rcra_profile_data) + org = org or haztrak_org_factory(admin=admin) + haztrak_profile_factory(user=user, org=org) + return user, org + + return create_fixtures diff --git a/server/apps/trak/services/__init__.py b/server/apps/trak/services/__init__.py index 1a1a2d373..ad35a6ea5 100644 --- a/server/apps/trak/services/__init__.py +++ b/server/apps/trak/services/__init__.py @@ -1,5 +1,7 @@ from .emanifest import EManifest, EManifestError, PullManifestsResult, TaskResponse from .manifest_services import ( + create_manifest, + get_manifests, update_manifest, ) from .waste_services import get_dot_hazard_classes, get_dot_id_numbers, get_dot_shipping_names diff --git a/server/apps/trak/services/emanifest.py b/server/apps/trak/services/emanifest.py index b09d603db..a93acab37 100644 --- a/server/apps/trak/services/emanifest.py +++ b/server/apps/trak/services/emanifest.py @@ -10,7 +10,7 @@ from apps.core.services import RcrainfoService, get_rcrainfo_client from apps.trak.models import Manifest, QuickerSign from apps.trak.serializers import ManifestSerializer, QuickerSignSerializer -from apps.trak.tasks import pull_manifest, save_rcrainfo_manifest, sign_manifest +from apps.trak.tasks import pull_manifest, sign_manifest logger = logging.getLogger(__name__) @@ -54,16 +54,21 @@ def __init__(self, *, username: str, rcrainfo: Optional[RcrainfoService] = None) self.username = username self.rcrainfo = rcrainfo or get_rcrainfo_client(username=username) + @property + 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, + 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" @@ -77,9 +82,9 @@ def search( # 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 + * 24 # 24 hours/1day + * 30 # 30 days/1month + * 36 # 36 months/3years = 3/years ) start_date_str = start_date.strftime(date_format) @@ -133,17 +138,6 @@ def submit_quick_signature(self, signature: dict) -> PullManifestsResult: logger.warning(f"Error Quicker signing {response.status_code} {response.json()}") return results - def create(self, *, manifest: dict) -> dict | TaskResponse: - """Create a manifest in RCRAInfo through the RESTful API.""" - if self.rcrainfo.has_rcrainfo_credentials and manifest.get("status") != "NotAssigned": - logger.info("POSTing manifest to RCRAInfo.") - task = save_rcrainfo_manifest.delay(manifest_data=manifest, username=self.username) - return {"taskId": task.id} - else: - logger.info("Saving manifest manifest to DB without RCRAInfo") - saved_manifest = self._save_manifest_json_to_db(manifest) - return ManifestSerializer(saved_manifest).data - def save(self, manifest: dict) -> dict: """Save manifest to e-Manifest""" logger.info(f"start save manifest to rcrainfo with arguments {manifest}") @@ -165,7 +159,7 @@ def save(self, manifest: dict) -> dict: @staticmethod def _filter_mtn( - *, mtn: list[str], site_id: str, site_type: Literal["Generator", "Tsdf", "Transporter"] + *, mtn: list[str], site_id: str, site_type: Literal["Generator", "Tsdf", "Transporter"] ) -> list[str]: site_filter = Manifest.objects.get_handler_query(site_id, site_type) existing_mtn = Manifest.objects.existing_mtn(site_filter, mtn=mtn) diff --git a/server/apps/trak/services/manifest_services.py b/server/apps/trak/services/manifest_services.py index f9c8fa61b..562b75549 100644 --- a/server/apps/trak/services/manifest_services.py +++ b/server/apps/trak/services/manifest_services.py @@ -6,7 +6,8 @@ from apps.sites.models import HaztrakSite from apps.trak.models import Manifest -from apps.trak.services import EManifestError +from apps.trak.services import EManifest, EManifestError, TaskResponse +from apps.trak.tasks import save_to_emanifest as save_to_emanifest_task logger = logging.getLogger(__name__) @@ -24,10 +25,10 @@ def update_manifest(*, mtn: Optional[str], data: dict) -> Manifest: def get_manifests( - *, - username: str, - epa_id: Optional[str] = None, - site_type: Optional[Literal["Generator", "Tsdf", "Transporter"]] = None, + *, + username: str, + epa_id: Optional[str] = None, + site_type: Optional[Literal["Generator", "Tsdf", "Transporter"]] = None, ) -> QuerySet[Manifest]: """Get a list of manifest tracking numbers and select details for a users site""" sites: QuerySet[HaztrakSite] = ( @@ -46,16 +47,15 @@ def get_manifests( | Q(transporters__rcra_site__epa_id__in=sites) ) -# -# def create_manifest(self, *, username: str, manifest: dict) -> dict | TaskResponse: -# """Save a manifest to Haztrak database and/or e-Manifest/RCRAInfo""" -# emanifest = EManifest(username=username) -# # data = emanifest.create(manifest=manifest_serializer.data) -# if emanifest.has_rcrainfo_credentials and manifest.get("status") != "NotAssigned": -# logger.info("POSTing manifest to RCRAInfo.") -# task = save_rcrainfo_manifest.delay(manifest_data=manifest, username=self.username) -# return {"taskId": task.id} -# else: -# logger.info("Saving manifest manifest to DB without RCRAInfo") -# saved_manifest = self._save_manifest_json_to_db(manifest) -# return ManifestSerializer(saved_manifest).data + +# ToDo: replace multiple returns types with 1 uniform type +@transaction.atomic +def create_manifest(*, username: str, data: dict) -> Manifest | TaskResponse: + """Save a manifest to Haztrak database and/or e-Manifest/RCRAInfo""" + manifest = Manifest.objects.save(None, **data) + emanifest = EManifest(username=username) + if emanifest.is_available and data.get("status", "NotAssigned") != "NotAssigned": + task = save_to_emanifest_task.delay(manifest_data=data, username=username) + return {"taskId": task.id} + else: + return manifest diff --git a/server/apps/trak/tasks/__init__.py b/server/apps/trak/tasks/__init__.py index f8e54b308..074b17c94 100644 --- a/server/apps/trak/tasks/__init__.py +++ b/server/apps/trak/tasks/__init__.py @@ -1,7 +1,7 @@ from .lookup_task import pull_federal_codes from .manifest_task import ( pull_manifest, - save_rcrainfo_manifest, + save_to_emanifest, sign_manifest, sync_site_manifests, ) diff --git a/server/apps/trak/tasks/manifest_task.py b/server/apps/trak/tasks/manifest_task.py index b4276372d..ccfce2315 100644 --- a/server/apps/trak/tasks/manifest_task.py +++ b/server/apps/trak/tasks/manifest_task.py @@ -70,7 +70,7 @@ def sync_site_manifests(self, *, site_id: str, username: str): @shared_task(name="save RCRAInfo manifests", bind=True) -def save_rcrainfo_manifest(self, *, manifest_data: dict, username: str): +def save_to_emanifest(self, *, manifest_data: dict, username: str): """ asynchronous task to use the RCRAInfo web services to create an electronic (RCRA) manifest it accepts a Python dict of the manifest data to be submitted as JSON, and the username of the diff --git a/server/apps/trak/tests/conftest.py b/server/apps/trak/tests/conftest.py index c7a843505..7598f0042 100644 --- a/server/apps/trak/tests/conftest.py +++ b/server/apps/trak/tests/conftest.py @@ -187,7 +187,7 @@ def create_manifest( @pytest.fixture -def waste_line_factory(db, faker: Faker): +def waste_line_factory(db): """Abstract factory for Haztrak DotLookup model""" def create_waste_line( diff --git a/server/apps/trak/tests/services/test_manifest_services.py b/server/apps/trak/tests/services/test_manifest_services.py index 144579ca7..78dcf8f3f 100644 --- a/server/apps/trak/tests/services/test_manifest_services.py +++ b/server/apps/trak/tests/services/test_manifest_services.py @@ -1,18 +1,19 @@ from apps.trak.models import Manifest -from apps.trak.services.manifest_services import get_manifests +from apps.trak.serializers import ManifestSerializer +from apps.trak.services import create_manifest, get_manifests class TestGetManifestService: def test_returns_manifests_from_all_user_sites_by_default( - self, - manifest_factory, - haztrak_profile_factory, - user_factory, - haztrak_site_factory, - rcra_site_factory, - haztrak_site_permission_factory, - manifest_handler_factory, - manifest_transporter_factory, + self, + manifest_factory, + haztrak_profile_factory, + user_factory, + haztrak_site_factory, + rcra_site_factory, + haztrak_site_permission_factory, + manifest_handler_factory, + manifest_transporter_factory, ): # Arrange profile = haztrak_profile_factory() @@ -42,15 +43,15 @@ def test_returns_manifests_from_all_user_sites_by_default( assert manifests.count() == Manifest.objects.count() def test_filters_by_epa_id( - self, - manifest_factory, - haztrak_profile_factory, - user_factory, - haztrak_site_factory, - rcra_site_factory, - haztrak_site_permission_factory, - manifest_handler_factory, - manifest_transporter_factory, + self, + manifest_factory, + haztrak_profile_factory, + user_factory, + haztrak_site_factory, + rcra_site_factory, + haztrak_site_permission_factory, + manifest_handler_factory, + manifest_transporter_factory, ): # Arrange profile = haztrak_profile_factory() @@ -75,15 +76,15 @@ def test_filters_by_epa_id( assert tsdf_manifest.mtn not in returned_mtn def test_filters_by_site_type( - self, - manifest_factory, - haztrak_profile_factory, - user_factory, - haztrak_site_factory, - rcra_site_factory, - haztrak_site_permission_factory, - manifest_handler_factory, - manifest_transporter_factory, + self, + manifest_factory, + haztrak_profile_factory, + user_factory, + haztrak_site_factory, + rcra_site_factory, + haztrak_site_permission_factory, + manifest_handler_factory, + manifest_transporter_factory, ): # Arrange profile = haztrak_profile_factory() @@ -109,3 +110,26 @@ def test_filters_by_site_type( assert gen_manifest.mtn in returned_gen_mtn assert tsdf_manifest.mtn not in returned_gen_mtn assert tsdf_manifest.mtn in returned_all_mtn + + +class TestCreateManifest: + def test_create_manifest_saves_drafts_to_db( + self, + manifest_factory, + validated_data_factory, + user_factory, + user_with_org_factory, + ): + # Arrange + new_mtn = "987654321ELC" + user, org = user_with_org_factory(is_rcrainfo_enabled=True) + new_manifest_data = validated_data_factory( + instance=manifest_factory(mtn="123456789ELC", status="NotAssigned"), + serializer=ManifestSerializer, + ) + new_manifest_data["mtn"] = new_mtn + # Act + new_manifest = create_manifest(data=new_manifest_data, username=user.username) + # Assert + assert isinstance(new_manifest, Manifest) + assert new_manifest.mtn == new_mtn diff --git a/server/apps/trak/views/manifest_view.py b/server/apps/trak/views/manifest_view.py index 48bc8d92f..da58a2cf4 100644 --- a/server/apps/trak/views/manifest_view.py +++ b/server/apps/trak/views/manifest_view.py @@ -12,7 +12,7 @@ from apps.trak.serializers import ManifestSerializer from apps.trak.serializers.signature_serializer import QuickerSignSerializer from apps.trak.services import EManifest, TaskResponse -from apps.trak.services.manifest_services import get_manifests +from apps.trak.services.manifest_services import create_manifest, get_manifests logger = logging.getLogger(__name__) @@ -44,8 +44,10 @@ class CreateManifestView(GenericAPIView): def post(self, request: Request) -> Response: manifest_serializer = self.serializer_class(data=request.data) manifest_serializer.is_valid(raise_exception=True) - emanifest = EManifest(username=str(request.user)) - data = emanifest.create(manifest=manifest_serializer.data) + manifest = create_manifest( + username=str(request.user), data=manifest_serializer.validated_data + ) + data = ManifestSerializer(manifest).data return Response(data=data, status=status.HTTP_201_CREATED) From 687d2fb808bc34b28050db3949fe55c43908dc8f Mon Sep 17 00:00:00 2001 From: David Graham Date: Mon, 11 Dec 2023 08:19:02 -0500 Subject: [PATCH 04/12] use different endpoints depending on whether the user is saving a manifest to RCRAInfo or just saving a manifest to haztrak --- .../src/components/Manifest/ManifestForm.tsx | 48 ++++++++++++------- client/src/services/APIs/manifestApi.ts | 6 --- client/src/store/haztrakApiSlice.ts | 20 +++++++- client/src/store/index.ts | 2 + server/apps/trak/services/__init__.py | 1 + .../apps/trak/services/manifest_services.py | 21 ++++---- server/apps/trak/urls.py | 2 + server/apps/trak/views/__init__.py | 1 + server/apps/trak/views/manifest_view.py | 22 +++++++-- 9 files changed, 87 insertions(+), 36 deletions(-) diff --git a/client/src/components/Manifest/ManifestForm.tsx b/client/src/components/Manifest/ManifestForm.tsx index 23da4363b..ab3dea98a 100644 --- a/client/src/components/Manifest/ManifestForm.tsx +++ b/client/src/components/Manifest/ManifestForm.tsx @@ -1,17 +1,20 @@ import { ErrorMessage } from '@hookform/error-message'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AxiosError } from 'axios'; import { AdditionalInfoForm } from 'components/Manifest/AdditionalInfo'; import { UpdateRcra } from 'components/Manifest/UpdateRcra/UpdateRcra'; import { WasteLine } from 'components/Manifest/WasteLine/wasteLineSchema'; import { RcraSiteDetails } from 'components/RcraSite'; import { HtButton, HtCard, HtForm, InfoIconTooltip } from 'components/UI'; -import React, { createContext, useState } from 'react'; +import React, { createContext, useEffect, useState } from 'react'; import { Alert, Button, Col, Form, Row, Stack } from 'react-bootstrap'; import { FormProvider, SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -import { toast } from 'react-toastify'; -import { manifest, manifestApi } from 'services'; +import { manifest } from 'services'; +import { + useAppDispatch, + useCreateManifestMutation, + useSaveElectronicManifestMutation, +} from 'store'; import { ContactForm, PhoneForm } from './Contact'; import { AddHandler, GeneratorForm, Handler } from './Handler'; import { Manifest, manifestSchema, ManifestStatus } from './manifestSchema'; @@ -84,6 +87,8 @@ export function ManifestForm({ const [updatingRcrainfo, setUpdatingRcrainfo] = useState(false); const toggleShowUpdatingRcra = () => setUpdatingRcrainfo(!updatingRcrainfo); const [taskId, setTaskId] = useState(undefined); + const [createManifest, createResult] = useCreateManifestMutation(); + const [saveEmanifest, saveEmanifestResult] = useSaveElectronicManifestMutation(); // React-Hook-Form component state and methods const manifestForm = useForm({ @@ -94,19 +99,30 @@ export function ManifestForm({ formState: { errors }, } = manifestForm; + useEffect(() => { + if (createResult.data) { + if ('manifestTrackingNumber' in createResult.data) { + navigate(`/manifest/${createResult.data.manifestTrackingNumber}/view`); + } + } + if (createResult.isLoading) { + toggleShowUpdatingRcra(); + } + }, [createResult.data, createResult.isLoading, createResult.error]); + + useEffect(() => { + if (saveEmanifestResult.data) { + setTaskId(saveEmanifestResult.data.taskId); + toggleShowUpdatingRcra(); + } + }, [saveEmanifestResult.data, saveEmanifestResult.isLoading, saveEmanifestResult.error]); + const onSubmit: SubmitHandler = (data: Manifest) => { - manifestApi - .createManifest(data) - .then((r) => { - if ('manifestTrackingNumber' in r.data) { - navigate(`/manifest/${r.data.manifestTrackingNumber}/view`); - } - if ('taskId' in r.data) { - setTaskId(r.data.taskId); - toggleShowUpdatingRcra(); - } - }) - .catch((error: AxiosError) => toast.error(error.message)); + if (data.status === 'NotAssigned') { + createManifest(data); + } else { + saveEmanifest(data); + } }; // Generator component state and methods diff --git a/client/src/services/APIs/manifestApi.ts b/client/src/services/APIs/manifestApi.ts index 3ad145a67..465b00999 100644 --- a/client/src/services/APIs/manifestApi.ts +++ b/client/src/services/APIs/manifestApi.ts @@ -1,5 +1,4 @@ import { AxiosResponse } from 'axios'; -import { Manifest } from 'components/Manifest'; import { QuickerSignature } from 'components/Manifest/QuickerSign'; import { TaskStatus } from 'store'; import { htApi } from './htApi'; @@ -12,11 +11,6 @@ export const manifestApi = { return await htApi.post('rcra/manifest/sign', signature); }, - /** Create a manifest either via a proxy endpoint to RCRAInfo or as draft */ - createManifest: async (data: Manifest): Promise> => { - return await htApi.post('/rcra/manifest', data); - }, - /** Sync a sites manifest data with RCRAInfo */ syncManifest: async (siteId: string): Promise> => { return htApi.post('rcra/manifest/sync', { siteId: siteId }); diff --git a/client/src/store/haztrakApiSlice.ts b/client/src/store/haztrakApiSlice.ts index 49a76fb0e..0709e8c00 100644 --- a/client/src/store/haztrakApiSlice.ts +++ b/client/src/store/haztrakApiSlice.ts @@ -1,10 +1,11 @@ +import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import { HaztrakSite } from 'components/HaztrakSite'; +import { Manifest } from 'components/Manifest'; import { Code } from 'components/Manifest/WasteLine/wasteLineSchema'; import { MtnDetails } from 'components/Mtn'; import { RcraSite } from 'components/RcraSite'; import { htApi } from 'services'; -import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; export interface HtApiQueryArgs { url: string; @@ -75,6 +76,7 @@ export const haztrakApi = createApi({ baseUrl: `${import.meta.env.VITE_HT_API_URL}/api/`, }), endpoints: (build) => ({ + // Note: build.query searchRcrainfoSites: build.query, RcrainfoSiteSearch>({ query: (data: RcrainfoSiteSearch) => ({ url: 'rcra/handler/search', @@ -113,6 +115,20 @@ export const haztrakApi = createApi({ getMTN: build.query, string | undefined>({ query: (siteId) => ({ url: siteId ? `rcra/mtn/${siteId}` : 'rcra/mtn', method: 'get' }), }), + createManifest: build.mutation({ + query: (data) => ({ + url: 'rcra/manifest', + method: 'POST', + data, + }), + }), + saveElectronicManifest: build.mutation<{ taskId: string }, Manifest>({ + query: (data) => ({ + url: 'rcra/manifest/emanifest', + method: 'POST', + data, + }), + }), }), }); @@ -127,4 +143,6 @@ export const { useGetMTNQuery, useGetUserHaztrakSitesQuery, useGetUserHaztrakSiteQuery, + useCreateManifestMutation, + useSaveElectronicManifestMutation, } = haztrakApi; diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 49199098b..08dcb3473 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -17,6 +17,8 @@ export { useGetMTNQuery, useGetUserHaztrakSitesQuery, useGetUserHaztrakSiteQuery, + useCreateManifestMutation, + useSaveElectronicManifestMutation, } from 'store/haztrakApiSlice'; // Authentication Slice diff --git a/server/apps/trak/services/__init__.py b/server/apps/trak/services/__init__.py index ad35a6ea5..f45ff63a9 100644 --- a/server/apps/trak/services/__init__.py +++ b/server/apps/trak/services/__init__.py @@ -2,6 +2,7 @@ from .manifest_services import ( create_manifest, get_manifests, + save_emanifest, update_manifest, ) from .waste_services import get_dot_hazard_classes, get_dot_id_numbers, get_dot_shipping_names diff --git a/server/apps/trak/services/manifest_services.py b/server/apps/trak/services/manifest_services.py index 562b75549..225c434a6 100644 --- a/server/apps/trak/services/manifest_services.py +++ b/server/apps/trak/services/manifest_services.py @@ -6,6 +6,7 @@ from apps.sites.models import HaztrakSite from apps.trak.models import Manifest +from apps.trak.serializers import ManifestSerializer from apps.trak.services import EManifest, EManifestError, TaskResponse from apps.trak.tasks import save_to_emanifest as save_to_emanifest_task @@ -48,14 +49,18 @@ def get_manifests( ) -# ToDo: replace multiple returns types with 1 uniform type -@transaction.atomic -def create_manifest(*, username: str, data: dict) -> Manifest | TaskResponse: - """Save a manifest to Haztrak database and/or e-Manifest/RCRAInfo""" - manifest = Manifest.objects.save(None, **data) +def save_emanifest(*, data: dict, username: str) -> TaskResponse: + """Save a manifest to e-Manifest/RCRAInfo""" emanifest = EManifest(username=username) - if emanifest.is_available and data.get("status", "NotAssigned") != "NotAssigned": + if emanifest.is_available: task = save_to_emanifest_task.delay(manifest_data=data, username=username) - return {"taskId": task.id} + return TaskResponse(taskId=task.id) else: - return manifest + raise EManifestError("e-Manifest is not available") + + +@transaction.atomic +def create_manifest(*, username: str, data: dict) -> dict: + """Save a manifest to Haztrak database and/or e-Manifest/RCRAInfo""" + manifest = Manifest.objects.save(None, **data) + return ManifestSerializer(manifest).data diff --git a/server/apps/trak/urls.py b/server/apps/trak/urls.py index 58766f2e7..631f48305 100644 --- a/server/apps/trak/urls.py +++ b/server/apps/trak/urls.py @@ -8,6 +8,7 @@ FederalWasteCodesView, GetManifestView, MtnListView, + SaveElectronicManifestView, SignManifestView, StateWasteCodesView, SyncSiteManifestView, @@ -20,6 +21,7 @@ [ # Manifest path("manifest", CreateManifestView.as_view()), + path("manifest/emanifest", SaveElectronicManifestView.as_view()), path("manifest/sign", SignManifestView.as_view()), path("manifest/sync", SyncSiteManifestView.as_view()), re_path(r"manifest/(?P[0-9]{9}[a-zA-Z]{3})", GetManifestView.as_view()), diff --git a/server/apps/trak/views/__init__.py b/server/apps/trak/views/__init__.py index 4c76da5f0..b5c43b7c9 100644 --- a/server/apps/trak/views/__init__.py +++ b/server/apps/trak/views/__init__.py @@ -2,6 +2,7 @@ CreateManifestView, GetManifestView, MtnListView, + SaveElectronicManifestView, SignManifestView, SyncSiteManifestView, ) diff --git a/server/apps/trak/views/manifest_view.py b/server/apps/trak/views/manifest_view.py index da58a2cf4..9e61d3d48 100644 --- a/server/apps/trak/views/manifest_view.py +++ b/server/apps/trak/views/manifest_view.py @@ -12,7 +12,7 @@ from apps.trak.serializers import ManifestSerializer from apps.trak.serializers.signature_serializer import QuickerSignSerializer from apps.trak.services import EManifest, TaskResponse -from apps.trak.services.manifest_services import create_manifest, get_manifests +from apps.trak.services.manifest_services import create_manifest, get_manifests, save_emanifest logger = logging.getLogger(__name__) @@ -44,10 +44,22 @@ class CreateManifestView(GenericAPIView): def post(self, request: Request) -> Response: manifest_serializer = self.serializer_class(data=request.data) manifest_serializer.is_valid(raise_exception=True) - manifest = create_manifest( - username=str(request.user), data=manifest_serializer.validated_data - ) - data = ManifestSerializer(manifest).data + data = create_manifest(username=str(request.user), data=manifest_serializer.validated_data) + return Response(data=data, status=status.HTTP_201_CREATED) + + +@extend_schema(request=ManifestSerializer) +class SaveElectronicManifestView(GenericAPIView): + """Save a manifest to RCRAInfo.""" + + queryset = None + serializer_class = ManifestSerializer + http_method_names = ["post"] + + def post(self, request: Request) -> Response: + manifest_serializer = self.serializer_class(data=request.data) + manifest_serializer.is_valid(raise_exception=True) + data = save_emanifest(username=str(request.user), data=manifest_serializer.data) return Response(data=data, status=status.HTTP_201_CREATED) From 23180ef93b1fad431e43437d329a626e64115ff6 Mon Sep 17 00:00:00 2001 From: David Paul Graham Date: Mon, 11 Dec 2023 08:19:11 -0500 Subject: [PATCH 05/12] refactor various components to use RTK query hooks instead of dispatching async thunks or custom hook API calls. In effect, we are centralizing our API layer into the redux store --- .../Manifest/Handler/HandlerSearchForm.tsx | 76 +++++++++++-------- .../src/components/Manifest/ManifestForm.tsx | 45 ++++++----- .../Manifest/QuickerSign/QuickerSignForm.tsx | 27 ++++--- .../Manifest/WasteLine/DotIdSelect.tsx | 3 +- .../SyncManifestBtn/SyncManifestBtn.spec.tsx | 2 +- .../SyncManifestBtn/SyncManifestBtn.tsx | 51 +++++++------ client/src/services/APIs/manifestApi.ts | 18 ----- client/src/services/index.ts | 1 - client/src/store/haztrakApiSlice.ts | 36 +++++---- client/src/store/index.ts | 12 +-- .../apps/trak/services/manifest_services.py | 13 ++-- .../trak/tests/views/test_manifest_views.py | 2 +- server/apps/trak/urls.py | 4 +- server/apps/trak/views/manifest_view.py | 5 +- 14 files changed, 154 insertions(+), 141 deletions(-) delete mode 100644 client/src/services/APIs/manifestApi.ts diff --git a/client/src/components/Manifest/Handler/HandlerSearchForm.tsx b/client/src/components/Manifest/Handler/HandlerSearchForm.tsx index fd553e589..712821fb9 100644 --- a/client/src/components/Manifest/Handler/HandlerSearchForm.tsx +++ b/client/src/components/Manifest/Handler/HandlerSearchForm.tsx @@ -2,7 +2,7 @@ import { ManifestContext, ManifestContextType } from 'components/Manifest/Manife import { Manifest, SiteType, Transporter } from 'components/Manifest/manifestSchema'; import { RcraSite } from 'components/RcraSite'; import { HtForm } from 'components/UI'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { Alert, Button } from 'react-bootstrap'; import { Controller, @@ -12,7 +12,12 @@ import { useFormContext, } from 'react-hook-form'; import Select from 'react-select'; -import { haztrakApi, useAppDispatch } from 'store'; +import { + selectHaztrakProfile, + useAppSelector, + useSearchRcrainfoSitesQuery, + useSearchRcraSitesQuery, +} from 'store'; interface Props { handleClose: () => void; @@ -34,9 +39,28 @@ export function HandlerSearchForm({ }: Props) { const { handleSubmit, control } = useForm(); const manifestMethods = useFormContext(); - const [selectedHandler, setSelectedHandler] = useState(null); const [inputValue, setInputValue] = useState(''); - const dispatch = useAppDispatch(); + const [selectedHandler, setSelectedHandler] = useState(null); + const { org } = useAppSelector(selectHaztrakProfile); + const [skip, setSkip] = useState(true); + const { data, error, isLoading } = useSearchRcraSitesQuery( + { + siteType: handlerType, + siteId: inputValue, + }, + { skip } + ); + const { + data: rcrainfoData, + error: rcrainfoError, + isLoading: rcrainfoIsLoading, + } = useSearchRcrainfoSitesQuery( + { + siteType: handlerType, + siteId: inputValue, + }, + { skip: skip && !org?.rcrainfoIntegrated } + ); const { setGeneratorStateCode, setTsdfStateCode } = useContext(ManifestContext); const [searchMessage, setSearchMessage] = useState< @@ -72,34 +96,26 @@ export function HandlerSearchForm({ handleClose(); }; + useEffect(() => { + const inputTooShort = inputValue.length < 5; + setSkip(inputTooShort); + }, [inputValue]); + + useEffect(() => { + const knownSites = data && data.length > 0 ? data : []; + const rcrainfoSites = rcrainfoData && rcrainfoData.length > 0 ? rcrainfoData : []; + const allOptions: RcraSite[] = [...knownSites, ...rcrainfoSites].filter( + (value, index, self) => index === self.findIndex((t) => t.epaSiteId === value.epaSiteId) + ); + setOptions([...allOptions]); + }, [data, rcrainfoData]); + + useEffect(() => { + setRcrainfoSitesLoading(isLoading || rcrainfoIsLoading); + }, [isLoading, rcrainfoIsLoading]); + const handleInputChange = async (value: string) => { setInputValue(value); - if (value.length >= 5) { - setRcrainfoSitesLoading(true); - const rcrainfoSites = await dispatch( - haztrakApi.endpoints?.searchRcrainfoSites.initiate({ - siteType: handlerType, - siteId: value, - }) - ); - if (rcrainfoSites.isError) { - setSearchMessage({ - message: 'Sorry, could not connect to RCRAInfo. Showing known handlers.', - variant: 'danger', - }); - const knownRcraSites = await dispatch( - haztrakApi.endpoints?.searchRcraSites.initiate({ - siteType: handlerType, - siteId: value, - }) - ); - setOptions(knownRcraSites.data as Array); - setRcrainfoSitesLoading(false); - return; - } - setOptions(rcrainfoSites.data as Array); - setRcrainfoSitesLoading(false); - } }; const handleChange = (value: RcraSite | null): void => { diff --git a/client/src/components/Manifest/ManifestForm.tsx b/client/src/components/Manifest/ManifestForm.tsx index ab3dea98a..8cba450d7 100644 --- a/client/src/components/Manifest/ManifestForm.tsx +++ b/client/src/components/Manifest/ManifestForm.tsx @@ -10,17 +10,14 @@ import { Alert, Button, Col, Form, Row, Stack } from 'react-bootstrap'; import { FormProvider, SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { manifest } from 'services'; -import { - useAppDispatch, - useCreateManifestMutation, - useSaveElectronicManifestMutation, -} from 'store'; +import { useCreateManifestMutation, useSaveEManifestMutation } from 'store'; import { ContactForm, PhoneForm } from './Contact'; import { AddHandler, GeneratorForm, Handler } from './Handler'; import { Manifest, manifestSchema, ManifestStatus } from './manifestSchema'; import { QuickerSignData, QuickerSignModal, QuickSignBtn } from './QuickerSign'; import { Transporter, TransporterTable } from './Transporter'; import { EditWasteModal, WasteLineTable } from './WasteLine'; +import { toast } from 'react-toastify'; const defaultValues: Manifest = { transporters: [], @@ -84,11 +81,15 @@ export function ManifestForm({ } // State related to inter-system communications with EPA's RCRAInfo system - const [updatingRcrainfo, setUpdatingRcrainfo] = useState(false); - const toggleShowUpdatingRcra = () => setUpdatingRcrainfo(!updatingRcrainfo); + const [showSpinner, setShowSpinner] = useState(false); + const toggleShowSpinner = () => setShowSpinner(!showSpinner); const [taskId, setTaskId] = useState(undefined); - const [createManifest, createResult] = useCreateManifestMutation(); - const [saveEmanifest, saveEmanifestResult] = useSaveElectronicManifestMutation(); + const [createManifest, { data: createData, error: createError, isLoading: createIsLoading }] = + useCreateManifestMutation(); + const [ + saveEmanifest, + { data: eManifestResult, error: eManifestError, isLoading: eManifestIsLoading }, + ] = useSaveEManifestMutation(); // React-Hook-Form component state and methods const manifestForm = useForm({ @@ -100,22 +101,26 @@ export function ManifestForm({ } = manifestForm; useEffect(() => { - if (createResult.data) { - if ('manifestTrackingNumber' in createResult.data) { - navigate(`/manifest/${createResult.data.manifestTrackingNumber}/view`); + if (createData) { + if ('manifestTrackingNumber' in createData) { + navigate(`/manifest/${createData.manifestTrackingNumber}/view`); } } - if (createResult.isLoading) { - toggleShowUpdatingRcra(); + if (createIsLoading) { + setShowSpinner(true); } - }, [createResult.data, createResult.isLoading, createResult.error]); + if (createError) { + toast.error('Error creating manifest'); + setShowSpinner(false); + } + }, [createData, createIsLoading, createError]); useEffect(() => { - if (saveEmanifestResult.data) { - setTaskId(saveEmanifestResult.data.taskId); - toggleShowUpdatingRcra(); + if (eManifestResult) { + setTaskId(eManifestResult.taskId); + toggleShowSpinner(); } - }, [saveEmanifestResult.data, saveEmanifestResult.isLoading, saveEmanifestResult.error]); + }, [eManifestResult, eManifestIsLoading, eManifestError]); const onSubmit: SubmitHandler = (data: Manifest) => { if (data.status === 'NotAssigned') { @@ -593,7 +598,7 @@ export function ManifestForm({ {/*If taking action that involves updating a manifest in RCRAInfo*/} - {taskId && updatingRcrainfo ? : <>} + {taskId && showSpinner ? : <>} ({ defaultValues: { printedSignatureName: userName, @@ -60,10 +59,18 @@ export function QuickerSignForm({ mtn, mtnHandler, handleClose, siteType }: Quic }); const navigate = useNavigate(); if (!handleClose) { - // If handleClose function is not passed, assume navigate back 1 handleClose = () => navigate(-1); } + useEffect(() => { + if (data) { + toast.success('Signed successfully'); + } + if (error) { + toast.error('Error while signing manifest'); + } + }, [data, error, isLoading]); + const onSubmit: SubmitHandler = (data) => { let signature: QuickerSignature = { printedSignatureDate: data.printedSignatureDate + '.000Z', @@ -78,15 +85,7 @@ export function QuickerSignForm({ mtn, mtnHandler, handleClose, siteType }: Quic transporterOrder: mtnHandler.order, }; } - manifestApi - .createQuickSignature(signature) - .then((response) => { - toast.success('Signing through e-Manifest'); - }) - .catch((error: AxiosError) => toast.error(error.message)); - if (handleClose) { - handleClose(); - } + signManifest(signature); }; return ( diff --git a/client/src/components/Manifest/WasteLine/DotIdSelect.tsx b/client/src/components/Manifest/WasteLine/DotIdSelect.tsx index 178de5ff7..6a349c7ba 100644 --- a/client/src/components/Manifest/WasteLine/DotIdSelect.tsx +++ b/client/src/components/Manifest/WasteLine/DotIdSelect.tsx @@ -2,7 +2,8 @@ import { WasteLine } from 'components/Manifest/WasteLine/wasteLineSchema'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import AsyncSelect from 'react-select/async'; -import { haztrakApi, useAppDispatch } from 'store'; +import { useAppDispatch } from 'store'; +import { haztrakApi } from 'store/haztrakApiSlice'; interface DotIdOption { label: string; diff --git a/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx b/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx index 1f3d99a69..380f5b371 100644 --- a/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx +++ b/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx @@ -11,7 +11,7 @@ const testTaskID = 'testTaskId'; const server = setupServer( ...[ - http.post(`${API_BASE_URL}rcra/manifest/sync`, (info) => { + http.post(`${API_BASE_URL}rcra/manifest/emanifest/sync`, (info) => { // Mock Sync Site Manifests response return HttpResponse.json( { diff --git a/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.tsx b/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.tsx index 11dc4f8bf..95da2255a 100644 --- a/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.tsx +++ b/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.tsx @@ -1,11 +1,9 @@ import { faSync } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { AxiosError } from 'axios'; import { RcraApiUserBtn } from 'components/Rcrainfo/buttons/RcraApiUserBtn/RcraApiUserBtn'; import { useProgressTracker } from 'hooks'; import React, { useEffect, useState } from 'react'; -import { manifestApi } from 'services'; -import { addTask, updateTask, useAppDispatch } from 'store'; +import { addTask, updateTask, useAppDispatch, useSyncManifestMutation } from 'store'; interface SyncManifestProps { siteId?: string; @@ -25,6 +23,7 @@ export function SyncManifestBtn({ setSyncInProgress, syncInProgress, }: SyncManifestProps) { + const [syncSiteManifest, { data, error, isLoading }] = useSyncManifestMutation(); const dispatch = useAppDispatch(); const [taskId, setTaskId] = useState(undefined); const { inProgress } = useProgressTracker({ taskId: taskId }); @@ -33,32 +32,36 @@ export function SyncManifestBtn({ if (setSyncInProgress) setSyncInProgress(inProgress); }, [inProgress]); + useEffect(() => { + if (data?.taskId) { + dispatch( + addTask({ + taskId: data.taskId, + status: 'PENDING', + taskName: `Syncing ${siteId}'s manifests`, + }) + ); + setTaskId(data.taskId); + } + }, [data]); + + useEffect(() => { + if (error && taskId) { + dispatch( + updateTask({ + taskId: taskId, + status: 'FAILURE', + }) + ); + } + }, [error]); + return ( { - if (siteId) - manifestApi - .syncManifest(siteId) - .then((response) => { - dispatch( - addTask({ - taskId: response.data.taskId, - status: 'PENDING', - taskName: 'Syncing RCRAInfo Profile', - }) - ); - setTaskId(response.data.taskId); - }) - .catch((error: AxiosError) => - dispatch( - updateTask({ - taskId: taskId, - status: 'FAILURE', - }) - ) - ); + if (siteId) syncSiteManifest(siteId); }} > {`Sync Manifest `} diff --git a/client/src/services/APIs/manifestApi.ts b/client/src/services/APIs/manifestApi.ts deleted file mode 100644 index 465b00999..000000000 --- a/client/src/services/APIs/manifestApi.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AxiosResponse } from 'axios'; -import { QuickerSignature } from 'components/Manifest/QuickerSign'; -import { TaskStatus } from 'store'; -import { htApi } from './htApi'; - -export const manifestApi = { - /** Sign a manifest through the Haztrak Proxy endpoint */ - createQuickSignature: async ( - signature: QuickerSignature - ): Promise> => { - return await htApi.post('rcra/manifest/sign', signature); - }, - - /** Sync a sites manifest data with RCRAInfo */ - syncManifest: async (siteId: string): Promise> => { - return htApi.post('rcra/manifest/sync', { siteId: siteId }); - }, -}; diff --git a/client/src/services/index.ts b/client/src/services/index.ts index 0f76efe72..27aba813c 100644 --- a/client/src/services/index.ts +++ b/client/src/services/index.ts @@ -1,4 +1,3 @@ export { htApi } from 'services/APIs/htApi'; export { UserApi } from 'services/APIs/UserApi'; -export { manifestApi } from 'services/APIs/manifestApi'; export { manifest } from 'services/manifest/manifest'; diff --git a/client/src/store/haztrakApiSlice.ts b/client/src/store/haztrakApiSlice.ts index 0709e8c00..a5cd00112 100644 --- a/client/src/store/haztrakApiSlice.ts +++ b/client/src/store/haztrakApiSlice.ts @@ -6,6 +6,11 @@ import { Code } from 'components/Manifest/WasteLine/wasteLineSchema'; import { MtnDetails } from 'components/Mtn'; import { RcraSite } from 'components/RcraSite'; import { htApi } from 'services'; +import { QuickerSignature } from 'components/Manifest/QuickerSign'; + +export interface TaskResponse { + taskId: string; +} export interface HtApiQueryArgs { url: string; @@ -122,27 +127,26 @@ export const haztrakApi = createApi({ data, }), }), - saveElectronicManifest: build.mutation<{ taskId: string }, Manifest>({ + saveEManifest: build.mutation({ query: (data) => ({ url: 'rcra/manifest/emanifest', method: 'POST', data, }), }), + syncManifest: build.mutation({ + query: (siteId) => ({ + url: 'rcra/manifest/emanifest/sync', + method: 'POST', + data: { siteId: siteId }, + }), + }), + signElectronicManifest: build.mutation({ + query: (signature) => ({ + url: 'rcra/manifest/emanifest/sign', + method: 'POST', + data: signature, + }), + }), }), }); - -export const { - useSearchRcrainfoSitesQuery, - useSearchRcraSitesQuery, - useGetTaskStatusQuery, - useGetFedWasteCodesQuery, - useGetStateWasteCodesQuery, - useGetDotIdNumbersQuery, - useGetOrgSitesQuery, - useGetMTNQuery, - useGetUserHaztrakSitesQuery, - useGetUserHaztrakSiteQuery, - useCreateManifestMutation, - useSaveElectronicManifestMutation, -} = haztrakApi; diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 08dcb3473..fbec0868a 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -1,12 +1,12 @@ import type { AppDispatch, AppStore, RootState } from './rootStore'; +// Haztrak API - RTK Query +import { haztrakApi } from './haztrakApiSlice'; // Root Store export { rootStore, setupStore, useAppDispatch, useAppSelector } from './rootStore'; export type { RootState, AppDispatch, AppStore }; -// Haztrak API - RTK Query -export { - haztrakApi, +export const { useGetDotIdNumbersQuery, useGetFedWasteCodesQuery, useGetStateWasteCodesQuery, @@ -18,8 +18,10 @@ export { useGetUserHaztrakSitesQuery, useGetUserHaztrakSiteQuery, useCreateManifestMutation, - useSaveElectronicManifestMutation, -} from 'store/haztrakApiSlice'; + useSaveEManifestMutation, + useSyncManifestMutation, + useSignElectronicManifestMutation, +} = haztrakApi; // Authentication Slice export { diff --git a/server/apps/trak/services/manifest_services.py b/server/apps/trak/services/manifest_services.py index 225c434a6..95861bca0 100644 --- a/server/apps/trak/services/manifest_services.py +++ b/server/apps/trak/services/manifest_services.py @@ -26,10 +26,10 @@ def update_manifest(*, mtn: Optional[str], data: dict) -> Manifest: def get_manifests( - *, - username: str, - epa_id: Optional[str] = None, - site_type: Optional[Literal["Generator", "Tsdf", "Transporter"]] = None, + *, + username: str, + epa_id: Optional[str] = None, + site_type: Optional[Literal["Generator", "Tsdf", "Transporter"]] = None, ) -> QuerySet[Manifest]: """Get a list of manifest tracking numbers and select details for a users site""" sites: QuerySet[HaztrakSite] = ( @@ -60,7 +60,6 @@ def save_emanifest(*, data: dict, username: str) -> TaskResponse: @transaction.atomic -def create_manifest(*, username: str, data: dict) -> dict: +def create_manifest(*, username: str, data: dict) -> Manifest: """Save a manifest to Haztrak database and/or e-Manifest/RCRAInfo""" - manifest = Manifest.objects.save(None, **data) - return ManifestSerializer(manifest).data + return Manifest.objects.save(None, **data) diff --git a/server/apps/trak/tests/views/test_manifest_views.py b/server/apps/trak/tests/views/test_manifest_views.py index 370e49b99..cb1cd6104 100644 --- a/server/apps/trak/tests/views/test_manifest_views.py +++ b/server/apps/trak/tests/views/test_manifest_views.py @@ -41,7 +41,7 @@ def test_returns_manifest_by_mtn(self, factory, manifest, manifest_json, user): class TestSignManifestVIew: """Quicker Sign endpoint test suite""" - base_url = "/api/manifest/sign" + base_url = "/api/manifest/emanifest/sign" factory = APIRequestFactory() mtn = ["123456789ELC", "987654321ELC"] diff --git a/server/apps/trak/urls.py b/server/apps/trak/urls.py index 631f48305..d63123e75 100644 --- a/server/apps/trak/urls.py +++ b/server/apps/trak/urls.py @@ -22,8 +22,8 @@ # Manifest path("manifest", CreateManifestView.as_view()), path("manifest/emanifest", SaveElectronicManifestView.as_view()), - path("manifest/sign", SignManifestView.as_view()), - path("manifest/sync", SyncSiteManifestView.as_view()), + path("manifest/emanifest/sign", SignManifestView.as_view()), + path("manifest/emanifest/sync", SyncSiteManifestView.as_view()), re_path(r"manifest/(?P[0-9]{9}[a-zA-Z]{3})", GetManifestView.as_view()), # MT path("mtn", MtnListView.as_view()), diff --git a/server/apps/trak/views/manifest_view.py b/server/apps/trak/views/manifest_view.py index 9e61d3d48..19a169788 100644 --- a/server/apps/trak/views/manifest_view.py +++ b/server/apps/trak/views/manifest_view.py @@ -44,7 +44,10 @@ class CreateManifestView(GenericAPIView): def post(self, request: Request) -> Response: manifest_serializer = self.serializer_class(data=request.data) manifest_serializer.is_valid(raise_exception=True) - data = create_manifest(username=str(request.user), data=manifest_serializer.validated_data) + manifest = create_manifest( + username=str(request.user), data=manifest_serializer.validated_data + ) + data = self.serializer_class(manifest).data return Response(data=data, status=status.HTTP_201_CREATED) From 8b0f460085fb85577a6eb9afacf3a2bd60a7906f Mon Sep 17 00:00:00 2001 From: David Paul Graham Date: Mon, 11 Dec 2023 08:19:34 -0500 Subject: [PATCH 06/12] add badge to show handler search component is searching though the RCRAInfo web services --- .../Handler/Search/HandlerSearchForm.spec.tsx | 18 ++++++ .../{ => Search}/HandlerSearchForm.tsx | 60 +++++++++++++------ .../src/components/Manifest/Handler/index.ts | 2 +- client/src/components/UI/HtSpinner.tsx | 8 +-- .../src/components/UI/HtTooltip/HtTooltip.tsx | 6 +- 5 files changed, 65 insertions(+), 29 deletions(-) create mode 100644 client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx rename client/src/components/Manifest/Handler/{ => Search}/HandlerSearchForm.tsx (71%) diff --git a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx new file mode 100644 index 000000000..94a304bfc --- /dev/null +++ b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { HandlerSearchForm } from './HandlerSearchForm'; +import { cleanup, renderWithProviders, screen } from 'test-utils'; +import { afterEach, describe, expect, test } from 'vitest'; + +afterEach(() => { + cleanup(); +}); + +describe('HandlerSearchForm', () => { + test('renders with basic information inputs', () => { + renderWithProviders( + undefined} handlerType="generator" /> + ); + expect(screen.getByText(/EPA ID/i)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Manifest/Handler/HandlerSearchForm.tsx b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx similarity index 71% rename from client/src/components/Manifest/Handler/HandlerSearchForm.tsx rename to client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx index 712821fb9..21fd0f0df 100644 --- a/client/src/components/Manifest/Handler/HandlerSearchForm.tsx +++ b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx @@ -1,9 +1,9 @@ import { ManifestContext, ManifestContextType } from 'components/Manifest/ManifestForm'; import { Manifest, SiteType, Transporter } from 'components/Manifest/manifestSchema'; import { RcraSite } from 'components/RcraSite'; -import { HtForm } from 'components/UI'; +import { HtForm, HtSpinner, HtTooltip } from 'components/UI'; import React, { useContext, useEffect, useState } from 'react'; -import { Alert, Button } from 'react-bootstrap'; +import { Badge, Button, Col, Row } from 'react-bootstrap'; import { Controller, SubmitHandler, @@ -18,6 +18,8 @@ import { useSearchRcrainfoSitesQuery, useSearchRcraSitesQuery, } from 'store'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons'; interface Props { handleClose: () => void; @@ -53,23 +55,16 @@ export function HandlerSearchForm({ const { data: rcrainfoData, error: rcrainfoError, - isLoading: rcrainfoIsLoading, + isFetching: fetchingFromRcrainfo, } = useSearchRcrainfoSitesQuery( { siteType: handlerType, siteId: inputValue, }, - { skip: skip && !org?.rcrainfoIntegrated } + { skip: skip || !org?.rcrainfoIntegrated } ); const { setGeneratorStateCode, setTsdfStateCode } = useContext(ManifestContext); - const [searchMessage, setSearchMessage] = useState< - | { - message: string; - variant: 'success' | 'danger'; - } - | undefined - >(undefined); const [options, setOptions] = useState([]); const [rcrainfoSitesLoading, setRcrainfoSitesLoading] = useState(false); @@ -111,8 +106,8 @@ export function HandlerSearchForm({ }, [data, rcrainfoData]); useEffect(() => { - setRcrainfoSitesLoading(isLoading || rcrainfoIsLoading); - }, [isLoading, rcrainfoIsLoading]); + setRcrainfoSitesLoading(isLoading || fetchingFromRcrainfo); + }, [isLoading, fetchingFromRcrainfo]); const handleInputChange = async (value: string) => { setInputValue(value); @@ -126,12 +121,39 @@ export function HandlerSearchForm({ <> - {searchMessage && ( -
- {searchMessage.message} -
- )} - EPA ID Number + + + EPA ID Number + + +

+ {fetchingFromRcrainfo ? ( + + Searching RCRAInfo + + + ) : rcrainfoData ? ( + + Sites Retrieved from RCRAInfo + + + ) : rcrainfoError ? ( + + Error finding RCRAInfo sites + + + ) : !org?.rcrainfoIntegrated ? ( + + Org is not EPA integrated + + ) : ( + + Ready to search RCRAInfo + + )} +

+ +
-

- - {message ? message : 'Loading...'} -

+ {/*

*/} + + {message ? message : 'Loading...'} + {/*

*/} ); } diff --git a/client/src/components/UI/HtTooltip/HtTooltip.tsx b/client/src/components/UI/HtTooltip/HtTooltip.tsx index 74e9afff5..c6082483c 100644 --- a/client/src/components/UI/HtTooltip/HtTooltip.tsx +++ b/client/src/components/UI/HtTooltip/HtTooltip.tsx @@ -10,17 +10,13 @@ interface HtToolTipProps extends TooltipProps { children: ReactElement; } -// renderTooltip function passed to OverlayTrigger's overlay property -const renderTooltip = (text: string) => {text}; - export function HtTooltip(props: HtToolTipProps): ReactElement { - // create copy of props intended for the OverlayTrigger const overlayProps = (({ text, children, ...props }: HtToolTipProps) => props)(props); return ( {props.text}} > {props.children} From 190950aa3a5cb0ce561d26d0b92f721885b9d2bf Mon Sep 17 00:00:00 2001 From: David Paul Graham Date: Mon, 11 Dec 2023 08:19:47 -0500 Subject: [PATCH 07/12] fix warning from redux selectors made with createSelector that used output selectors that returned input values. The warning stated that likely would result in memoization errors. See the redux documentation https://redux.js.org/usage/deriving-data-selectors\#writing-memoized-selectors-with-reselect Add new selectHaztrakSiteEpaIds redux selector --- client/src/store/index.ts | 1 + .../src/store/profileSlice/profile.slice.ts | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/client/src/store/index.ts b/client/src/store/index.ts index fbec0868a..d7a12a9c1 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -42,6 +42,7 @@ export { siteByEpaIdSelector, updateProfile, selectHaztrakSites, + selectHaztrakSiteEpaIds, selectHaztrakProfile, } from './profileSlice/profile.slice'; diff --git a/client/src/store/profileSlice/profile.slice.ts b/client/src/store/profileSlice/profile.slice.ts index d5dbfdc8e..b7594b012 100644 --- a/client/src/store/profileSlice/profile.slice.ts +++ b/client/src/store/profileSlice/profile.slice.ts @@ -145,7 +145,7 @@ const profileSlice = createSlice({ error: undefined, }; }) - .addCase(getHaztrakProfile.rejected, (state, action) => { + .addCase(getHaztrakProfile.rejected, (state) => { state.loading = false; state.error = 'error'; return state; @@ -203,6 +203,15 @@ export const selectHaztrakSites = createSelector( } ); +/** Get all sites a user has access to their Haztrak Profile*/ +export const selectHaztrakSiteEpaIds = createSelector( + (state: { profile: ProfileSlice }) => state.profile.sites, + (sites: Record | undefined) => { + if (!sites) return []; + return Object.values(sites).map((site) => site.handler.epaSiteId); + } +); + /** select all RCRAInfo sites a user has access to from their RCRAInfo Profile if they're updated it*/ export const selectRcrainfoSites = createSelector( (state: { profile: ProfileSlice }) => state.profile.rcrainfoProfile?.rcraSites, @@ -215,14 +224,14 @@ export const selectRcrainfoSites = createSelector( /** Retrieve a user's RcraProfile from the Redux store. */ export const selectRcraProfile = createSelector( - (state: RootState) => state.profile, - (rcraProfile: ProfileSlice) => rcraProfile + (state: RootState) => state, + (state: RootState) => state.profile.rcrainfoProfile ); /** Retrieve a user's HaztrakProfile from the Redux store. */ export const selectHaztrakProfile = createSelector( - (state: RootState) => state.profile, - (haztrakProfile: ProfileSlice) => haztrakProfile + (state: RootState) => state, + (state: RootState) => state.profile ); export default profileSlice.reducer; From de60f41c6b67e202ec58fb99852e0cd23a3040f5 Mon Sep 17 00:00:00 2001 From: David Paul Graham Date: Mon, 11 Dec 2023 08:21:05 -0500 Subject: [PATCH 08/12] Refactor rcrainfo search badge, which notifies the user when HazTrak is using the RCRAInfo site search services, into separate component. The badge also shows a warning when a user's organization admin has not set up their RCRAInfo API ID and Keys in RCRAInfo. --- client/src/App.tsx | 3 +- .../Handler/Search/HandlerSearchForm.spec.tsx | 77 ++++++++++++++++++- .../Handler/Search/HandlerSearchForm.tsx | 35 ++------- .../Search/RcrainfoSiteSearchBadge.tsx | 53 +++++++++++++ .../Manifest/SiteSelect/SiteSelect.spec.tsx | 4 +- .../features/NewManifest/NewManifest.spec.tsx | 11 +-- .../src/features/SiteList/SiteList.spec.tsx | 18 ++--- .../store/profileSlice/profile.slice.spec.tsx | 6 +- client/src/test-utils/fixtures/index.ts | 16 ++-- client/src/test-utils/fixtures/mockHandler.ts | 40 +++++++++- client/src/test-utils/mock/handlers.ts | 4 +- .../apps/sites/services/rcra_site_services.py | 5 +- 12 files changed, 199 insertions(+), 73 deletions(-) create mode 100644 client/src/components/Manifest/Handler/Search/RcrainfoSiteSearchBadge.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index e1c9c9562..883171662 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,6 +9,7 @@ import { getHaztrakProfile, getHaztrakUser, getRcraProfile, + selectHaztrakProfile, selectRcraProfile, selectUserName, useAppDispatch, @@ -19,7 +20,7 @@ import { HtSpinner } from 'components/UI'; function App(): ReactElement { const userName = useAppSelector(selectUserName); - const profile = useAppSelector(selectRcraProfile); + const profile = useAppSelector(selectHaztrakProfile); const dispatch = useAppDispatch(); useEffect(() => { diff --git a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx index 94a304bfc..a26b7d658 100644 --- a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx +++ b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx @@ -2,11 +2,40 @@ import React from 'react'; import '@testing-library/jest-dom'; import { HandlerSearchForm } from './HandlerSearchForm'; import { cleanup, renderWithProviders, screen } from 'test-utils'; -import { afterEach, describe, expect, test } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { createMockRcrainfoSite } from 'test-utils/fixtures'; +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; +import { API_BASE_URL } from 'test-utils/mock/handlers'; +import userEvent from '@testing-library/user-event'; +const mockRcraSite1Id = 'VATEST111111111'; +const mockRcraSite2Id = 'VATEST222222222'; +const mockRcrainfoSite1Id = 'VATEST333333333'; +const mockRcrainfoSite2Id = 'VATEST444444444'; + +const mockRcrainfoSite1 = createMockRcrainfoSite({ epaSiteId: mockRcrainfoSite1Id }); +const mockRcrainfoSite2 = createMockRcrainfoSite({ epaSiteId: mockRcrainfoSite2Id }); +const mockRcraSite1 = createMockRcrainfoSite({ epaSiteId: mockRcraSite1Id }); +const mockRcraSite2 = createMockRcrainfoSite({ epaSiteId: mockRcraSite2Id }); +export const testURL = [ + http.get(`${API_BASE_URL}/api/site/search`, (info) => { + return HttpResponse.json([mockRcraSite1, mockRcraSite2], { status: 200 }); + }), + http.get(`${API_BASE_URL}/api/rcra/handler/search`, (info) => { + return HttpResponse.json([mockRcrainfoSite1, mockRcrainfoSite2], { status: 200 }); + }), + http.post(`${API_BASE_URL}/api/rcra/handler/search`, (info) => { + return HttpResponse.json([mockRcrainfoSite1, mockRcrainfoSite2], { status: 200 }); + }), +]; + +const server = setupServer(...testURL); afterEach(() => { cleanup(); }); +beforeAll(() => server.listen()); +afterAll(() => server.close()); // Disable API mocking after the tests are done. describe('HandlerSearchForm', () => { test('renders with basic information inputs', () => { @@ -15,4 +44,50 @@ describe('HandlerSearchForm', () => { ); expect(screen.getByText(/EPA ID/i)).toBeInTheDocument(); }); + test('retrieves rcra sites from haztrak and RCRAInfo', async () => { + renderWithProviders( + undefined} handlerType="generator" />, + { + preloadedState: { + profile: { + user: 'testuser1', + org: { + name: 'my org', + rcrainfoIntegrated: true, + id: '1234', + }, + }, + }, + } + ); + const epaId = screen.getByRole('combobox'); + await userEvent.type(epaId, 'VATEST'); + expect(await screen.findByText(new RegExp(mockRcraSite1Id, 'i'))).toBeInTheDocument(); + expect(await screen.findByText(new RegExp(mockRcraSite2Id, 'i'))).toBeInTheDocument(); + expect(await screen.findByText(new RegExp(mockRcrainfoSite1Id, 'i'))).toBeInTheDocument(); + expect(await screen.findByText(new RegExp(mockRcrainfoSite2Id, 'i'))).toBeInTheDocument(); + }); + test('retrieves rcra sites from haztrak if org not rcrainfo integrated', async () => { + renderWithProviders( + undefined} handlerType="generator" />, + { + preloadedState: { + profile: { + user: 'testuser1', + org: { + name: 'my org', + rcrainfoIntegrated: false, + id: '1234', + }, + }, + }, + } + ); + const epaId = screen.getByRole('combobox'); + await userEvent.type(epaId, 'VATEST'); + expect(await screen.findByText(new RegExp(mockRcraSite1Id, 'i'))).toBeInTheDocument(); + expect(await screen.findByText(new RegExp(mockRcraSite2Id, 'i'))).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(mockRcrainfoSite1Id, 'i'))).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(mockRcrainfoSite2Id, 'i'))).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx index 21fd0f0df..b12b0448e 100644 --- a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx +++ b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx @@ -18,8 +18,7 @@ import { useSearchRcrainfoSitesQuery, useSearchRcraSitesQuery, } from 'store'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { RcrainfoSiteSearchBadge } from 'components/Manifest/Handler/Search/RcrainfoSiteSearchBadge'; interface Props { handleClose: () => void; @@ -126,32 +125,12 @@ export function HandlerSearchForm({ EPA ID Number -

- {fetchingFromRcrainfo ? ( - - Searching RCRAInfo - - - ) : rcrainfoData ? ( - - Sites Retrieved from RCRAInfo - - - ) : rcrainfoError ? ( - - Error finding RCRAInfo sites - - - ) : !org?.rcrainfoIntegrated ? ( - - Org is not EPA integrated - - ) : ( - - Ready to search RCRAInfo - - )} -

+ + + {message} + {isFetching ? ( + + ) : error ? ( + + ) : data ? ( + + ) : ( + <> + )} + + + ); +} diff --git a/client/src/components/Manifest/SiteSelect/SiteSelect.spec.tsx b/client/src/components/Manifest/SiteSelect/SiteSelect.spec.tsx index 65a6892da..d433b84fa 100644 --- a/client/src/components/Manifest/SiteSelect/SiteSelect.spec.tsx +++ b/client/src/components/Manifest/SiteSelect/SiteSelect.spec.tsx @@ -3,7 +3,7 @@ import { SiteSelect } from 'components/Manifest/SiteSelect/SiteSelect'; import React, { useState } from 'react'; import { useForm } from 'react-hook-form'; import { renderWithProviders } from 'test-utils'; -import { createMockRcrainfoSite } from 'test-utils/fixtures'; +import { createMockSite } from 'test-utils/fixtures'; import { createMockRcrainfoPermissions } from 'test-utils/fixtures/mockHandler'; import { describe, expect, test } from 'vitest'; @@ -16,7 +16,7 @@ function TestComponent() { describe('SiteSelect', () => { test('renders', () => { - const mySite = createMockRcrainfoSite(); + const mySite = createMockSite(); renderWithProviders(, { preloadedState: { profile: { diff --git a/client/src/features/NewManifest/NewManifest.spec.tsx b/client/src/features/NewManifest/NewManifest.spec.tsx index 1da9d3a9b..db68e7dbe 100644 --- a/client/src/features/NewManifest/NewManifest.spec.tsx +++ b/client/src/features/NewManifest/NewManifest.spec.tsx @@ -3,17 +3,14 @@ import userEvent from '@testing-library/user-event'; import { NewManifest } from 'features/NewManifest/NewManifest'; import React from 'react'; import { renderWithProviders, screen } from 'test-utils'; -import { - createMockRcrainfoPermissions, - createMockRcrainfoSite, -} from 'test-utils/fixtures/mockHandler'; +import { createMockRcrainfoPermissions, createMockSite } from 'test-utils/fixtures'; import { describe, expect, test } from 'vitest'; describe('NewManifest', () => { test('renders', () => { const mySiteId = 'VATESTGEN001'; const mySiteName = 'My Site'; - const mySite = createMockRcrainfoSite({ + const mySite = createMockSite({ name: mySiteName, // @ts-ignore handler: { epaSiteId: mySiteId, siteType: 'Tsdf' }, @@ -42,7 +39,7 @@ describe('NewManifest', () => { test('site type is initially disabled', () => { const mySiteId = 'VATESTGEN001'; const mySiteName = 'My Site'; - const mySite = createMockRcrainfoSite({ + const mySite = createMockSite({ name: mySiteName, // @ts-ignore handler: { epaSiteId: mySiteId, siteType: 'Tsdf' }, @@ -71,7 +68,7 @@ describe('NewManifest', () => { test('site type is not disabled after selecting a site', async () => { const mySiteId = 'VATESTGEN001'; const mySiteName = 'My Site'; - const mySite = createMockRcrainfoSite({ + const mySite = createMockSite({ name: mySiteName, // @ts-ignore handler: { epaSiteId: mySiteId, siteType: 'Tsdf' }, diff --git a/client/src/features/SiteList/SiteList.spec.tsx b/client/src/features/SiteList/SiteList.spec.tsx index 2621c10ab..54fb6d0db 100644 --- a/client/src/features/SiteList/SiteList.spec.tsx +++ b/client/src/features/SiteList/SiteList.spec.tsx @@ -1,17 +1,16 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import React from 'react'; -import { cleanup, renderWithProviders, screen } from 'test-utils'; -import { createMockHandler, createMockRcrainfoSite } from 'test-utils/fixtures/mockHandler'; -import { API_BASE_URL } from 'test-utils/mock/handlers'; +import { renderWithProviders, screen } from 'test-utils'; +import { createMockHandler, createMockSite } from 'test-utils/fixtures/mockHandler'; import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; import { SiteList } from './SiteList'; const mockHandler1 = createMockHandler({ epaSiteId: 'VAT987654321' }); const mockHandler2 = createMockHandler({ epaSiteId: 'VAT123456789' }); const mockSites = [ - createMockRcrainfoSite({ handler: mockHandler1 }), - createMockRcrainfoSite({ handler: mockHandler2 }), + createMockSite({ handler: mockHandler1 }), + createMockSite({ handler: mockHandler2 }), ]; const server = setupServer( http.get(`${import.meta.env.VITE_HT_API_URL}/api/site`, (info) => { @@ -19,14 +18,9 @@ const server = setupServer( }) ); -// // pre-/post-test hooks +// pre-/post-test hooks beforeAll(() => server.listen()); -// afterEach(() => { -// server.resetHandlers(); -// cleanup(); -// vi.resetAllMocks(); -// }); -// afterAll(() => server.close()); // Disable API mocking after the tests are done. +afterAll(() => server.close()); // Disable API mocking after the tests are done. describe('SiteList component', () => { test('renders', () => { diff --git a/client/src/store/profileSlice/profile.slice.spec.tsx b/client/src/store/profileSlice/profile.slice.spec.tsx index 3914673b7..bcc217b63 100644 --- a/client/src/store/profileSlice/profile.slice.spec.tsx +++ b/client/src/store/profileSlice/profile.slice.spec.tsx @@ -6,7 +6,7 @@ import { screen } from '@testing-library/react'; import React from 'react'; import { useAppSelector } from 'store'; import { renderWithProviders } from 'test-utils'; -import { createMockRcrainfoSite } from 'test-utils/fixtures'; +import { createMockSite } from 'test-utils/fixtures'; import { createMockRcrainfoPermissions } from 'test-utils/fixtures/mockHandler'; import { describe, expect, test } from 'vitest'; import { selectRcrainfoSites, siteByEpaIdSelector } from './profile.slice'; @@ -26,7 +26,7 @@ function TestComponent({ siteId }: TestComponentProps) { describe('RcraProfileSlice selectors', () => { test('Retrieve RcraProfileSite by EPA ID', () => { - const mySite = createMockRcrainfoSite(); + const mySite = createMockSite(); renderWithProviders(, { preloadedState: { profile: { @@ -49,7 +49,7 @@ describe('RcraProfileSlice selectors', () => { expect(screen.getByText(mySite.handler.epaSiteId)).toBeInTheDocument(); }); test('retrieve all RcraProfileSites', () => { - const mySite = createMockRcrainfoSite(); + const mySite = createMockSite(); const TestComp = () => { const myRcraSite = useAppSelector(selectRcrainfoSites); return ( diff --git a/client/src/test-utils/fixtures/index.ts b/client/src/test-utils/fixtures/index.ts index 4bd0f3f52..687b20c00 100644 --- a/client/src/test-utils/fixtures/index.ts +++ b/client/src/test-utils/fixtures/index.ts @@ -1,15 +1,11 @@ -import { +export { createMockHandler, createMockMTNHandler, createMockRcrainfoSite, createMockTransporter, + createMockRcraAddress, + createMockRcraContact, + createMockSite, + createMockRcrainfoPermissions, } from './mockHandler'; -import { createMockManifest } from './mockManifest'; - -export { - createMockHandler, - createMockMTNHandler, - createMockTransporter, - createMockManifest, - createMockRcrainfoSite, -}; +export { createMockManifest } from './mockManifest'; diff --git a/client/src/test-utils/fixtures/mockHandler.ts b/client/src/test-utils/fixtures/mockHandler.ts index ddbe9f24c..239d7770e 100644 --- a/client/src/test-utils/fixtures/mockHandler.ts +++ b/client/src/test-utils/fixtures/mockHandler.ts @@ -1,8 +1,9 @@ import { HaztrakSite } from 'components/HaztrakSite'; import { Handler } from 'components/Manifest'; import { Transporter } from 'components/Manifest/Transporter'; -import { RcraSite } from 'components/RcraSite'; +import { RcraAddress, RcraSite } from 'components/RcraSite'; import { RcrainfoSitePermissions } from 'store'; +import { RcraContact } from 'components/RcraSite/rcraSiteSchema'; /** * A mock handler object for tests @@ -60,6 +61,34 @@ const DEFAULT_HANDLER: Handler = { gisPrimary: false, }; +export function createMockRcraAddress(overWrites?: Partial): RcraAddress { + return { + streetNumber: '123', + address1: 'VA TEST GEN 2021 WAY', + city: 'Arlington', + state: { + code: 'VA', + name: 'Virginia', + }, + country: { + code: 'US', + name: 'United States', + }, + zip: '20022', + ...overWrites, + }; +} + +export function createMockRcraContact(overWrites?: Partial): RcraContact { + return { + phone: { + number: '703-308-0023', + }, + email: 'Testing@EPA.GOV', + ...overWrites, + }; +} + export function createMockHandler(overWrites?: Partial): RcraSite { return { ...DEFAULT_HANDLER, @@ -83,10 +112,13 @@ export function createMockSite(overWrites?: Partial): HaztrakSite { }; } -export function createMockRcrainfoSite(overWrites?: Partial): HaztrakSite { +export function createMockRcrainfoSite(overWrites?: Partial): RcraSite { return { - handler: createMockHandler(overWrites?.handler), - name: 'mySiteName', + epaSiteId: 'testSiteIdNumber', + name: 'TEST Generator 1', + mailingAddress: createMockRcraAddress(), + siteAddress: createMockRcraAddress(), + contact: createMockRcraContact(), ...overWrites, }; } diff --git a/client/src/test-utils/mock/handlers.ts b/client/src/test-utils/mock/handlers.ts index 986049004..ae638dc95 100644 --- a/client/src/test-utils/mock/handlers.ts +++ b/client/src/test-utils/mock/handlers.ts @@ -1,11 +1,11 @@ import { http, HttpResponse } from 'msw'; -import { createMockHandler, createMockManifest, createMockRcrainfoSite } from '../fixtures'; +import { createMockHandler, createMockManifest, createMockSite } from '../fixtures'; export const API_BASE_URL = import.meta.env.VITE_HT_API_URL; const mockMTN = createMockManifest().manifestTrackingNumber; const mockEpaId = createMockHandler().epaSiteId; const mockUsername = 'testuser1'; -const mockSites = [createMockRcrainfoSite(), createMockRcrainfoSite()]; +const mockSites = [createMockSite(), createMockSite()]; export const handlers = [ /** Login endpoint*/ diff --git a/server/apps/sites/services/rcra_site_services.py b/server/apps/sites/services/rcra_site_services.py index fb0c56136..bc97bbf2e 100644 --- a/server/apps/sites/services/rcra_site_services.py +++ b/server/apps/sites/services/rcra_site_services.py @@ -67,9 +67,8 @@ def get_or_pull_rcra_site(self, site_id: str) -> RcraSite: return new_rcra_site def search_rcrainfo_handlers(self, **search_parameters) -> HandlerSearchResults: - """ - Search RCRAInfo for a site by name or EPA ID - """ + """Search RCRAInfo for a site by name or EPA ID""" + search_parameters["epaSiteId"] = search_parameters.get("epaSiteId", "").upper() cache_key = ( f'handlerSearch:epaSiteId:{search_parameters["epaSiteId"]}:siteType:' f'{search_parameters["siteType"]}' From 67782c3dfecb28f87aae551cb3632270bdb9db93 Mon Sep 17 00:00:00 2001 From: David Paul Graham Date: Mon, 11 Dec 2023 08:21:17 -0500 Subject: [PATCH 09/12] refactor DOT ID number select component to use RTK query's useLazyQuery for dot ID number selection. Also, the component fetched and pre-populate DOT ID numbers with options upon mounting --- .../Manifest/WasteLine/DotIdSelect.tsx | 54 ++++++++++++------- client/src/store/index.ts | 2 +- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/client/src/components/Manifest/WasteLine/DotIdSelect.tsx b/client/src/components/Manifest/WasteLine/DotIdSelect.tsx index 6a349c7ba..d585d9c3c 100644 --- a/client/src/components/Manifest/WasteLine/DotIdSelect.tsx +++ b/client/src/components/Manifest/WasteLine/DotIdSelect.tsx @@ -1,9 +1,8 @@ import { WasteLine } from 'components/Manifest/WasteLine/wasteLineSchema'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import AsyncSelect from 'react-select/async'; -import { useAppDispatch } from 'store'; -import { haztrakApi } from 'store/haztrakApiSlice'; +import { useLazyGetDotIdNumbersQuery } from 'store'; +import Select from 'react-select'; interface DotIdOption { label: string; @@ -11,26 +10,36 @@ interface DotIdOption { } export function DotIdSelect() { - const dispatch = useAppDispatch(); const { control, formState: { errors }, } = useFormContext(); + const [getDotIds, { data, isFetching, error: apiError }] = useLazyGetDotIdNumbersQuery(); + const [dotIdNumbers, setDotIdNumbers] = useState([]); - /** - * retrieve a list of DOT ID numbers from the server - * @param inputValue - */ - const getDotIdNumbers = async (inputValue: string) => { - const response = await dispatch(haztrakApi.endpoints.getDotIdNumbers.initiate(inputValue)); - if (response.data) { - return response.data.map((dotIdNumber) => { - return { label: dotIdNumber, value: dotIdNumber } as DotIdOption; - }) as readonly DotIdOption[]; - } - return []; + const dataToOptions = (data: string[]) => { + return data.map((dotIdNumber) => { + return { label: dotIdNumber, value: dotIdNumber } as DotIdOption; + }) as DotIdOption[]; }; + useEffect(() => { + // On mount, fetch and pre-populate DOT ID numbers with some initial options + getDotIds(''); + }, []); + + useEffect(() => { + if (data) { + setDotIdNumbers(dataToOptions(data)); + } + }, [data]); + + useEffect(() => { + if (apiError) { + console.error(apiError); + } + }, [apiError]); + return ( <> { return ( - option.value} - cacheOptions + isLoading={isFetching} isClearable + onInputChange={(inputValue) => { + if (inputValue) { + getDotIds(inputValue); + } + }} onChange={(option) => { field.onChange(option?.value); }} diff --git a/client/src/store/index.ts b/client/src/store/index.ts index d7a12a9c1..982706693 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -7,7 +7,7 @@ export { rootStore, setupStore, useAppDispatch, useAppSelector } from './rootSto export type { RootState, AppDispatch, AppStore }; export const { - useGetDotIdNumbersQuery, + useLazyGetDotIdNumbersQuery, useGetFedWasteCodesQuery, useGetStateWasteCodesQuery, useGetTaskStatusQuery, From aae37988165ceb08a551242cf23d620fd8a40048 Mon Sep 17 00:00:00 2001 From: David Paul Graham Date: Mon, 11 Dec 2023 08:21:21 -0500 Subject: [PATCH 10/12] New UI component, FloatingActionBtn a reusable floating action button, aimed at implementing material design's FAB --- .../AdditionalInfo/AdditionalInfoForm.tsx | 9 +++++-- .../src/components/Manifest/ManifestForm.tsx | 6 ++++- .../src/components/UI/FloatingActionBtn.tsx | 26 +++++++++++++++++++ client/src/components/UI/index.ts | 1 + 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 client/src/components/UI/FloatingActionBtn.tsx diff --git a/client/src/components/Manifest/AdditionalInfo/AdditionalInfoForm.tsx b/client/src/components/Manifest/AdditionalInfo/AdditionalInfoForm.tsx index 706c21fa6..778e26de8 100644 --- a/client/src/components/Manifest/AdditionalInfo/AdditionalInfoForm.tsx +++ b/client/src/components/Manifest/AdditionalInfo/AdditionalInfoForm.tsx @@ -119,11 +119,16 @@ export function AdditionalInfoForm({ readOnly }: AdditionalFormProps) { })} -
+
{readOnly ? ( <> ) : ( - + )}
diff --git a/client/src/components/Manifest/ManifestForm.tsx b/client/src/components/Manifest/ManifestForm.tsx index 8cba450d7..71fedbe62 100644 --- a/client/src/components/Manifest/ManifestForm.tsx +++ b/client/src/components/Manifest/ManifestForm.tsx @@ -18,6 +18,7 @@ import { QuickerSignData, QuickerSignModal, QuickSignBtn } from './QuickerSign'; import { Transporter, TransporterTable } from './Transporter'; import { EditWasteModal, WasteLineTable } from './WasteLine'; import { toast } from 'react-toastify'; +import { FloatingActionBtn } from 'components/UI'; const defaultValues: Manifest = { transporters: [], @@ -426,7 +427,9 @@ export function ManifestForm({
- +
) : showGeneratorForm ? ( @@ -596,6 +599,7 @@ export function ManifestForm({
+ Save
{/*If taking action that involves updating a manifest in RCRAInfo*/} {taskId && showSpinner ? : <>} diff --git a/client/src/components/UI/FloatingActionBtn.tsx b/client/src/components/UI/FloatingActionBtn.tsx new file mode 100644 index 000000000..516838c7e --- /dev/null +++ b/client/src/components/UI/FloatingActionBtn.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Button, ButtonProps } from 'react-bootstrap'; + +interface HtFloatingActionBtnProps extends ButtonProps { + position?: 'bottom-left' | 'bottom-right'; +} + +export function FloatingActionBtn({ + position, + children, + className, + ...props +}: HtFloatingActionBtnProps) { + const positionClasses = + position === undefined || position === 'bottom-right' + ? 'position-fixed bottom-0 end-0 m-5' + : 'position-fixed bottom-0 start-0 m-5'; + const defaultClasses = 'p-3 rounded-5'; + return ( +
+ +
+ ); +} diff --git a/client/src/components/UI/index.ts b/client/src/components/UI/index.ts index fbdb12c17..9193aea01 100644 --- a/client/src/components/UI/index.ts +++ b/client/src/components/UI/index.ts @@ -8,3 +8,4 @@ export { HtModal } from './HtModal/HtModal'; export { HtSpinner } from './HtSpinner'; export { HtTooltip, InfoIconTooltip } from './HtTooltip'; export { FeatureDescription } from './FeatureDescription'; +export { FloatingActionBtn } from './FloatingActionBtn'; From 82fa663a1848f658d79171a9eca157fee306d4fa Mon Sep 17 00:00:00 2001 From: David Paul Graham Date: Mon, 11 Dec 2023 08:21:26 -0500 Subject: [PATCH 11/12] Add floating Action buttons to Manifest, the ManifestActionBtns shows various actions depending on the manifest state --- .../ActionBtns/ManifestActionBtns.tsx | 48 +++++++++++++++++++ .../src/components/Manifest/ManifestForm.tsx | 33 ++++++++----- .../src/components/UI/FloatingActionBtn.tsx | 4 +- 3 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 client/src/components/Manifest/ActionBtns/ManifestActionBtns.tsx diff --git a/client/src/components/Manifest/ActionBtns/ManifestActionBtns.tsx b/client/src/components/Manifest/ActionBtns/ManifestActionBtns.tsx new file mode 100644 index 000000000..fe9f0a29a --- /dev/null +++ b/client/src/components/Manifest/ActionBtns/ManifestActionBtns.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { FloatingActionBtn } from 'components/UI'; +import { ManifestStatus } from 'components/Manifest/manifestSchema'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFloppyDisk, faPen, faPenToSquare } from '@fortawesome/free-solid-svg-icons'; + +interface ManifestActionBtnsProps { + manifestStatus?: ManifestStatus; + readonly?: boolean; + signAble?: boolean; +} + +export function ManifestActionBtns({ + manifestStatus, + readonly, + signAble, +}: ManifestActionBtnsProps) { + let variant = 'success'; + let text = ''; + let icon = faFloppyDisk; + let type: 'button' | 'submit' | 'reset' | undefined = 'button'; + console.log('signAble', signAble); + if (signAble) { + variant = 'primary'; + icon = faPen; + text = 'Sign'; + } else if (readonly) { + variant = 'primary'; + icon = faPenToSquare; + text = 'Edit'; + } else if (!readonly) { + variant = 'success'; + icon = faFloppyDisk; + text = 'Save'; + type = 'submit'; + } else { + return <>; + } + + return ( + + {text} + + + + + ); +} diff --git a/client/src/components/Manifest/ManifestForm.tsx b/client/src/components/Manifest/ManifestForm.tsx index 71fedbe62..e364b6fac 100644 --- a/client/src/components/Manifest/ManifestForm.tsx +++ b/client/src/components/Manifest/ManifestForm.tsx @@ -6,11 +6,16 @@ import { WasteLine } from 'components/Manifest/WasteLine/wasteLineSchema'; import { RcraSiteDetails } from 'components/RcraSite'; import { HtButton, HtCard, HtForm, InfoIconTooltip } from 'components/UI'; import React, { createContext, useEffect, useState } from 'react'; -import { Alert, Button, Col, Form, Row, Stack } from 'react-bootstrap'; +import { Alert, Button, Col, Container, Form, Row, Stack } from 'react-bootstrap'; import { FormProvider, SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { manifest } from 'services'; -import { useCreateManifestMutation, useSaveEManifestMutation } from 'store'; +import { + selectHaztrakSiteEpaIds, + useAppSelector, + useCreateManifestMutation, + useSaveEManifestMutation, +} from 'store'; import { ContactForm, PhoneForm } from './Contact'; import { AddHandler, GeneratorForm, Handler } from './Handler'; import { Manifest, manifestSchema, ManifestStatus } from './manifestSchema'; @@ -18,7 +23,7 @@ import { QuickerSignData, QuickerSignModal, QuickSignBtn } from './QuickerSign'; import { Transporter, TransporterTable } from './Transporter'; import { EditWasteModal, WasteLineTable } from './WasteLine'; import { toast } from 'react-toastify'; -import { FloatingActionBtn } from 'components/UI'; +import { ManifestActionBtns } from 'components/Manifest/ActionBtns/ManifestActionBtns'; const defaultValues: Manifest = { transporters: [], @@ -184,15 +189,17 @@ export function ManifestForm({ manifestData?.status ); - const signAble = - manifestStatus === 'Scheduled' || - manifestStatus === 'InTransit' || - manifestStatus === 'ReadyForSignature'; + const nextSigner = manifest.getNextSigner(manifestData); + const userSiteIds = useAppSelector(selectHaztrakSiteEpaIds); + console.log('userSiteIds', userSiteIds); + console.log('nextSigner', nextSigner); + + const signAble = userSiteIds.includes(nextSigner ?? ''); const isDraft = manifestData?.manifestTrackingNumber === undefined; return ( - <> + {readOnly ? ( - // if readOnly is true, show the generator in a nice read only way and display - // the button to sign for the generator. <>

Emergency Contact Information

@@ -599,7 +604,11 @@ export function ManifestForm({ - Save + {/*If taking action that involves updating a manifest in RCRAInfo*/} {taskId && showSpinner ? : <>} @@ -635,6 +644,6 @@ export function ManifestForm({ />
- +
); } diff --git a/client/src/components/UI/FloatingActionBtn.tsx b/client/src/components/UI/FloatingActionBtn.tsx index 516838c7e..3c66026be 100644 --- a/client/src/components/UI/FloatingActionBtn.tsx +++ b/client/src/components/UI/FloatingActionBtn.tsx @@ -3,10 +3,12 @@ import { Button, ButtonProps } from 'react-bootstrap'; interface HtFloatingActionBtnProps extends ButtonProps { position?: 'bottom-left' | 'bottom-right'; + extended?: boolean; } export function FloatingActionBtn({ position, + extended, children, className, ...props @@ -15,7 +17,7 @@ export function FloatingActionBtn({ position === undefined || position === 'bottom-right' ? 'position-fixed bottom-0 end-0 m-5' : 'position-fixed bottom-0 start-0 m-5'; - const defaultClasses = 'p-3 rounded-5'; + const defaultClasses = `p-2 rounded-5 ${extended ? 'px-4' : ''}`; return (
+ +
+ -
- - -
); diff --git a/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx b/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx index 380f5b371..e280e3078 100644 --- a/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx +++ b/client/src/components/Rcrainfo/buttons/SyncManifestBtn/SyncManifestBtn.spec.tsx @@ -11,7 +11,7 @@ const testTaskID = 'testTaskId'; const server = setupServer( ...[ - http.post(`${API_BASE_URL}rcra/manifest/emanifest/sync`, (info) => { + http.post(`${API_BASE_URL}rcra/manifest/emanifest/sync`, () => { // Mock Sync Site Manifests response return HttpResponse.json( { diff --git a/client/src/components/UI/FloatingActionBtn.tsx b/client/src/components/UI/FloatingActionBtn.tsx index 3c66026be..29edcfbec 100644 --- a/client/src/components/UI/FloatingActionBtn.tsx +++ b/client/src/components/UI/FloatingActionBtn.tsx @@ -17,7 +17,7 @@ export function FloatingActionBtn({ position === undefined || position === 'bottom-right' ? 'position-fixed bottom-0 end-0 m-5' : 'position-fixed bottom-0 start-0 m-5'; - const defaultClasses = `p-2 rounded-5 ${extended ? 'px-4' : ''}`; + const defaultClasses = `p-2 rounded-5 shadow bg-gradient ${extended ? 'px-4' : ''}`; return (
-
- + if (isLoading) return ; + if (data) + return ( + + +
+ +
+ +
-
- - - {isLoading ? ( - - ) : ( - data && - )} - - - - - ); + + + + + + + + ); + else return
Something went wrong
; } diff --git a/client/src/features/SiteList/SiteList.tsx b/client/src/features/SiteList/SiteList.tsx index 193eff17d..e9f093f36 100644 --- a/client/src/features/SiteList/SiteList.tsx +++ b/client/src/features/SiteList/SiteList.tsx @@ -1,5 +1,5 @@ import { SiteListGroup } from 'components/HaztrakSite'; -import { HtCard } from 'components/UI'; +import { HtCard, HtSpinner } from 'components/UI'; import { useTitle } from 'hooks'; import React from 'react'; import { Container } from 'react-bootstrap'; @@ -16,7 +16,7 @@ export function SiteList() { {isLoading && !error ? ( - + ) : data ? ( ) : (