diff --git a/client/src/features/SiteList/SiteList.tsx b/client/src/features/SiteList/SiteList.tsx index 1fe040635..82efe4759 100644 --- a/client/src/features/SiteList/SiteList.tsx +++ b/client/src/features/SiteList/SiteList.tsx @@ -9,7 +9,7 @@ import { Link } from 'react-router-dom'; * @constructor */ export function SiteList() { - const [siteData, loading, error] = useHtApi>('site/'); + const [siteData, loading, error] = useHtApi>('site'); const [showErrorModal, setShowErrorModal] = useState(false); useEffect(() => { diff --git a/client/src/features/home/Home.spec.tsx b/client/src/features/home/Home.spec.tsx index 4a229a16d..73fcbc518 100644 --- a/client/src/features/home/Home.spec.tsx +++ b/client/src/features/home/Home.spec.tsx @@ -10,7 +10,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vite const USERNAME = 'testuser1'; const myAPIHandlers = [ - rest.get(`${API_BASE_URL}/api/profile/${USERNAME}`, (req, res, ctx) => { + rest.get(`${API_BASE_URL}/api/user/${USERNAME}/rcra/profile`, (req, res, ctx) => { return res( // Respond with a 200 status code ctx.status(200), diff --git a/client/src/features/profile/RcraProfile.tsx b/client/src/features/profile/RcraProfile.tsx index 0b92fa299..c2900c5ef 100644 --- a/client/src/features/profile/RcraProfile.tsx +++ b/client/src/features/profile/RcraProfile.tsx @@ -48,7 +48,7 @@ export function RcraProfile({ profile }: ProfileViewProps) { setProfileLoading(!profileLoading); setEditable(!editable); htApi - .put(`/profile/${profile.user}`, data) + .put(`/user/${profile.user}/rcra/profile`, data) .then((r) => { dispatch(updateProfile(r.data)); }) diff --git a/client/src/features/profile/UserProfile.spec.tsx b/client/src/features/profile/UserProfile.spec.tsx index 3eeefe670..6bd31a4b0 100644 --- a/client/src/features/profile/UserProfile.spec.tsx +++ b/client/src/features/profile/UserProfile.spec.tsx @@ -18,7 +18,7 @@ const DEFAULT_USER: HaztrakUser = { }; const server = setupServer( - rest.put(`${API_BASE_URL}/api/user/`, (req, res, ctx) => { + rest.put(`${API_BASE_URL}/api/user`, (req, res, ctx) => { const user: HaztrakUser = { ...DEFAULT_USER }; // @ts-ignore return res(ctx.status(200), ctx.json({ ...user, ...req.body })); diff --git a/client/src/features/profile/UserProfile.tsx b/client/src/features/profile/UserProfile.tsx index 9c0c852b8..c2efc0ebe 100644 --- a/client/src/features/profile/UserProfile.tsx +++ b/client/src/features/profile/UserProfile.tsx @@ -35,7 +35,7 @@ export function UserProfile({ user }: UserProfileProps) { const onSubmit = (data: any) => { setEditable(!editable); htApi - .put('/user/', data) + .put('/user', data) .then((r) => { dispatch(updateUserProfile(r.data)); }) diff --git a/client/src/services/HtApi.ts b/client/src/services/HtApi.ts index 4de5c209f..6a24509a2 100644 --- a/client/src/services/HtApi.ts +++ b/client/src/services/HtApi.ts @@ -26,7 +26,6 @@ htApi.interceptors.request.use( config.headers['Authorization'] = `Token ${token}`; } return config; - // ToDo: if token does not exist }, (error) => { return Promise.reject(error); diff --git a/client/src/store/notificationSlice/notification.slice.ts b/client/src/store/notificationSlice/notification.slice.ts index e8cf8b330..81f9c602b 100644 --- a/client/src/store/notificationSlice/notification.slice.ts +++ b/client/src/store/notificationSlice/notification.slice.ts @@ -1,5 +1,5 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import axios from 'axios'; +import { htApi } from 'services'; /** * Schema of a user's alerts stored in the Redux store @@ -32,7 +32,7 @@ const initialState: NotificationState = { export const launchExampleTask = createAsyncThunk( 'notification/getExampleTask', async () => { - const response = await axios.get(`${import.meta.env.VITE_HT_API_URL}/api/task/example`); + const response = await htApi.get('/task/example'); const newNotification: HtNotification = { inProgress: false, message: `Background task launched. Task ID: ${response.data.task}`, diff --git a/client/src/store/rcraProfileSlice/rcraProfile.slice.ts b/client/src/store/rcraProfileSlice/rcraProfile.slice.ts index 722e008e2..a8df7ac49 100644 --- a/client/src/store/rcraProfileSlice/rcraProfile.slice.ts +++ b/client/src/store/rcraProfileSlice/rcraProfile.slice.ts @@ -96,7 +96,7 @@ export const getProfile = createAsyncThunk( async (arg, thunkAPI) => { const state = thunkAPI.getState() as RootState; const username = state.user.user?.username; - const response = await htApi.get(`/profile/${username}`); + const response = await htApi.get(`/user/${username}/rcra/profile`); const { rcraSites, ...rest } = response.data as RcraProfileResponse; // Convert the array of RcraSite permissions we get from our backend // to an object which each key corresponding to the RcraSite's ID number diff --git a/client/src/store/site.slice.ts b/client/src/store/site.slice.ts index 11bc7ecf5..b247ed104 100644 --- a/client/src/store/site.slice.ts +++ b/client/src/store/site.slice.ts @@ -14,19 +14,19 @@ interface RcrainfoSiteSearch { export const siteApi = createApi({ reducerPath: 'siteApi', baseQuery: htApiBaseQuery({ - baseUrl: `${import.meta.env.VITE_HT_API_URL}/api/site/`, + baseUrl: `${import.meta.env.VITE_HT_API_URL}/api/site`, }), endpoints: (build) => ({ searchRcrainfoSites: build.query, RcrainfoSiteSearch>({ query: (data: RcrainfoSiteSearch) => ({ - url: 'rcrainfo/search', + url: '/rcrainfo/search', method: 'post', data: data, }), }), searchRcraSites: build.query, RcrainfoSiteSearch>({ query: (data: RcrainfoSiteSearch) => ({ - url: 'rcra-site/search', + url: '/search', method: 'get', params: { epaId: data.siteId, siteType: data.siteType }, }), diff --git a/client/src/store/userSlice/user.slice.ts b/client/src/store/userSlice/user.slice.ts index 333966e48..249c005de 100644 --- a/client/src/store/userSlice/user.slice.ts +++ b/client/src/store/userSlice/user.slice.ts @@ -33,7 +33,7 @@ const initialState: UserState = { export const login = createAsyncThunk( 'user/login', async ({ username, password }: { username: string; password: string }) => { - const response = await axios.post(`${import.meta.env.VITE_HT_API_URL}/api/user/login/`, { + const response = await axios.post(`${import.meta.env.VITE_HT_API_URL}/api/user/login`, { username, password, }); @@ -46,7 +46,7 @@ export const login = createAsyncThunk( ); export const getHaztrakUser = createAsyncThunk('user/getHaztrakUser', async (arg, thunkAPI) => { - const response = await htApi.get(`${import.meta.env.VITE_HT_API_URL}/api/user`); + const response = await htApi.get('/user'); if (response.status >= 200 && response.status < 300) { return response.data as HaztrakUser; } else { diff --git a/client/src/test-utils/mock/handlers.ts b/client/src/test-utils/mock/handlers.ts index c3ee9fb59..40b13db66 100644 --- a/client/src/test-utils/mock/handlers.ts +++ b/client/src/test-utils/mock/handlers.ts @@ -28,7 +28,7 @@ export const handlers = [ /** * User RcraProfile data */ - rest.get(`${API_BASE_URL}/api/profile/${mockUsername}`, (req, res, ctx) => { + rest.get(`${API_BASE_URL}/api/user/${mockUsername}/rcra/profile`, (req, res, ctx) => { return res( // Respond with a 200 status code ctx.status(200), diff --git a/server/apps/conftest.py b/server/apps/conftest.py index 9032bdb32..25e57914a 100644 --- a/server/apps/conftest.py +++ b/server/apps/conftest.py @@ -11,17 +11,15 @@ from django.contrib.auth.models import User from rest_framework.test import APIClient -from apps.core.models import HaztrakUser, RcraProfile -from apps.sites.models import ( +from apps.core.models import HaztrakUser, RcraProfile # type: ignore +from apps.sites.models import ( # type: ignore Address, Contact, RcraPhone, RcraSite, Site, ) -from apps.trak.models import ( - ManifestPhone, -) +from apps.trak.models import ManifestPhone # type: ignore @pytest.fixture @@ -52,7 +50,7 @@ def user_factory(db): """Abstract factory for Django's User model""" def create_user( - username: Optional[str] = f"{''.join(random.choices(string.ascii_letters, k=9))}", + username: str = f"{''.join(random.choices(string.ascii_letters, k=9))}", first_name: Optional[str] = "John", last_name: Optional[str] = "Doe", email: Optional[str] = "testuser1@haztrak.net", diff --git a/server/apps/core/services/rcrainfo_service.py b/server/apps/core/services/rcrainfo_service.py index 3663ccd32..7c361fbbb 100644 --- a/server/apps/core/services/rcrainfo_service.py +++ b/server/apps/core/services/rcrainfo_service.py @@ -1,11 +1,14 @@ import logging import os +from typing import Optional from django.db import IntegrityError -from emanifest import RcrainfoClient, RcrainfoResponse +from emanifest import RcrainfoClient, RcrainfoResponse # type: ignore -from apps.core.models import RcraProfile -from apps.trak.models import WasteCode +from apps.core.models import RcraProfile # type: ignore +from apps.trak.models import WasteCode # type: ignore + +logger = logging.getLogger(__name__) class RcrainfoService(RcrainfoClient): @@ -16,9 +19,8 @@ class RcrainfoService(RcrainfoClient): datetime_format = "%Y-%m-%dT%H:%M:%S.%f%z" - def __init__(self, *, api_username: str, rcrainfo_env: str = None, **kwargs): + def __init__(self, *, api_username: str, rcrainfo_env: Optional[str] = None, **kwargs): self.api_user = api_username - self.logger = logging.getLogger(__name__) if RcraProfile.objects.filter(user__username=self.api_user).exists(): self.profile = RcraProfile.objects.get(user__username=self.api_user) else: @@ -54,7 +56,7 @@ def retrieve_key(self, api_key=None) -> str: return super().retrieve_key(self.profile.rcra_api_key) return super().retrieve_key() - def get_user_profile(self, username: str = None): + def get_user_profile(self, username: Optional[str] = None): """ Retrieve a user's site permissions from RCRAInfo, It expects the haztrak user to have their unique RCRAInfo user and API credentials in their @@ -90,13 +92,13 @@ def sign_manifest(self, **sign_data): def search_mtn( self, reg: bool = False, - site_id: str = None, - start_date: str = None, - end_date: str = None, - status: str = None, + site_id: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + status: Optional[str] = None, date_type: str = "UpdatedDate", - state_code: str = None, - site_type: str = None, + state_code: Optional[str] = None, + site_type: Optional[str] = None, ) -> RcrainfoResponse: # map our python friendly keyword arguments to RCRAInfo expected fields search_params = { @@ -110,7 +112,7 @@ def search_mtn( } # Remove arguments that are None filtered_params = {k: v for k, v in search_params.items() if v is not None} - self.logger.debug(f"rcrainfo manifest search parameters {filtered_params}") + logger.debug(f"rcrainfo manifest search parameters {filtered_params}") return super().search_mtn(**filtered_params) def __bool__(self): diff --git a/server/apps/core/services/task_service.py b/server/apps/core/services/task_service.py index 0999ab6f7..2a6f3e63b 100644 --- a/server/apps/core/services/task_service.py +++ b/server/apps/core/services/task_service.py @@ -6,8 +6,8 @@ from rest_framework.exceptions import ValidationError from rest_framework.utils.serializer_helpers import ReturnDict -from apps.core.serializers import TaskStatusSerializer -from apps.core.tasks import example_task +from apps.core.serializers import TaskStatusSerializer # type: ignore +from apps.core.tasks import example_task # type: ignore logger = logging.getLogger(__name__) @@ -17,11 +17,13 @@ class TaskService: Service class for interacting with the Task model layer and celery tasks. """ - def __init__(self, task_id, task_name, status="PENDING", result=None): + def __init__( + self, task_id: str, task_name: str, status: str = "PENDING", result: Optional[dict] = None + ): self.task_id = task_id self.task_name = task_name self.status = status - self.result: dict | None = result + self.result = result @classmethod def get_task_status(cls, task_id) -> ReturnDict: @@ -35,7 +37,7 @@ def get_task_status(cls, task_id) -> ReturnDict: return cls.get_task_results(task_id) @staticmethod - def get_task_results(task_id): + def get_task_results(task_id: str) -> ReturnDict: """ Gets the results of a long-running celery task stored in the database """ @@ -51,7 +53,7 @@ def _parse_status(task_status: dict) -> ReturnDict: raise ValidationError(task_serializer.errors) @staticmethod - def _get_cached_status(task_id) -> dict | None: + def _get_cached_status(task_id: str) -> dict | None: """ Gets the status of a long-running celery task from our key-value store if not found or error, returns None @@ -67,7 +69,7 @@ def _get_cached_status(task_id) -> dict | None: return None @staticmethod - def launch_example_task(): + def launch_example_task() -> str | None: """ Launches an example long-running celery task """ @@ -77,7 +79,7 @@ def launch_example_task(): except KeyError: return None - def update_task_status(self, status: str, results: Optional = None) -> object | None: + def update_task_status(self, status: str, results: Optional[dict] = None) -> object | None: """ Updates the status of a long-running celery task in our key-value store returns an error or None diff --git a/server/apps/core/urls.py b/server/apps/core/urls.py index 538773a20..3153fa0c4 100644 --- a/server/apps/core/urls.py +++ b/server/apps/core/urls.py @@ -4,19 +4,17 @@ HaztrakUserView, LaunchExampleTaskView, Login, + RcraProfileSyncView, RcraProfileView, - RcraSitePermissionView, - SyncProfileView, TaskStatusView, ) urlpatterns = [ # Rcra Profile - path("profile//sync", SyncProfileView.as_view()), - path("profile/", RcraProfileView.as_view()), - path("site/permission/", RcraSitePermissionView.as_view()), - path("user/", HaztrakUserView.as_view()), - path("user/login/", Login.as_view()), + path("user//rcra/profile/sync", RcraProfileSyncView.as_view()), + path("user//rcra/profile", RcraProfileView.as_view()), + path("user", HaztrakUserView.as_view()), + path("user/login", Login.as_view()), path("task/example", LaunchExampleTaskView.as_view()), path("task/", TaskStatusView.as_view()), ] diff --git a/server/apps/core/views/__init__.py b/server/apps/core/views/__init__.py index 19378f182..21de75f62 100644 --- a/server/apps/core/views/__init__.py +++ b/server/apps/core/views/__init__.py @@ -1,8 +1,7 @@ from .auth_view import Login from .profile_views import ( HaztrakUserView, + RcraProfileSyncView, RcraProfileView, - RcraSitePermissionView, - SyncProfileView, ) from .task_views import LaunchExampleTaskView, TaskStatusView diff --git a/server/apps/core/views/profile_views.py b/server/apps/core/views/profile_views.py index 0372939a7..454e19b5d 100644 --- a/server/apps/core/views/profile_views.py +++ b/server/apps/core/views/profile_views.py @@ -1,16 +1,11 @@ from celery.exceptions import CeleryError -from django.contrib.auth.models import User from rest_framework import status -from rest_framework.generics import GenericAPIView, RetrieveAPIView, RetrieveUpdateAPIView +from rest_framework.generics import GenericAPIView, RetrieveUpdateAPIView from rest_framework.request import Request from rest_framework.response import Response from apps.core.models import HaztrakUser, RcraProfile from apps.core.serializers import HaztrakUserSerializer, RcraProfileSerializer -from apps.sites.models import RcraSitePermission -from apps.sites.serializers import ( - RcraSitePermissionSerializer, -) class HaztrakUserView(RetrieveUpdateAPIView): @@ -20,7 +15,6 @@ class HaztrakUserView(RetrieveUpdateAPIView): serializer_class = HaztrakUserSerializer def get_object(self): - # return HaztrakUser.objects.get(username="testuser1") return self.request.user @@ -35,36 +29,27 @@ class RcraProfileView(RetrieveUpdateAPIView): serializer_class = RcraProfileSerializer response = Response lookup_field = "user__username" - lookup_url_kwarg = "user" + lookup_url_kwarg = "username" -class SyncProfileView(GenericAPIView): +class RcraProfileSyncView(GenericAPIView): """ This endpoint launches a task to sync the logged-in user's RCRAInfo profile with their haztrak (Rcra)profile. """ - queryset = None + queryset = RcraProfile.objects.all() response = Response - def get(self, request: Request, user: str = None) -> Response: + def get(self, request: Request) -> Response: """Sync Profile GET method rcra_site""" try: profile = RcraProfile.objects.get(user=request.user) task = profile.sync() return self.response({"task": task.id}) - except (User.DoesNotExist, CeleryError) as exc: - return self.response(data=exc, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class RcraSitePermissionView(RetrieveAPIView): - """ - For Viewing the RcraSite Permissions for the given user - """ - - queryset = RcraSitePermission.objects.all() - serializer_class = RcraSitePermissionSerializer - - def get_queryset(self): - user = self.request.user - return RcraSitePermission.objects.filter(profile__user=user) + except RcraProfile.DoesNotExist as exc: + return self.response(data={"error": str(exc)}, status=status.HTTP_404_NOT_FOUND) + except CeleryError as exc: + return self.response( + data={"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/server/apps/core/views/task_views.py b/server/apps/core/views/task_views.py index 739635da9..d891e510d 100644 --- a/server/apps/core/views/task_views.py +++ b/server/apps/core/views/task_views.py @@ -5,7 +5,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.core.services.task_service import TaskService +from apps.core.services.task_service import TaskService # type: ignore class CeleryTaskResultSerializer(serializers.ModelSerializer): @@ -29,15 +29,14 @@ class LaunchExampleTaskView(APIView): Launches an example long-running background task """ - response = Response permission_classes = [permissions.AllowAny] def get(self, request, *args, **kwargs): try: task_id = TaskService.launch_example_task() - return self.response(data={"task": task_id}, status=status.HTTP_200_OK) + return Response(data={"task": task_id}, status=status.HTTP_200_OK) except KeyError: - return self.response( + return Response( data={"error": "malformed payload"}, status=status.HTTP_400_BAD_REQUEST ) @@ -49,17 +48,16 @@ class TaskStatusView(APIView): """ queryset = TaskResult.objects.all() - response = Response def get(self, request: Request, task_id): try: data = TaskService.get_task_status(task_id) - return self.response(data=data, status=status.HTTP_200_OK) + return Response(data=data, status=status.HTTP_200_OK) except KeyError as exc: - return self.response(data={"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return Response(data={"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) except ValidationError as exc: - return self.response( + return Response( data={"error": exc.detail}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) except TaskResult.DoesNotExist as exc: - return self.response(data={"error": str(exc)}, status=status.HTTP_404_NOT_FOUND) + return Response(data={"error": str(exc)}, status=status.HTTP_404_NOT_FOUND) diff --git a/server/apps/sites/services/profile_services.py b/server/apps/sites/services/profile_services.py index d6c1500fd..4eda293dc 100644 --- a/server/apps/sites/services/profile_services.py +++ b/server/apps/sites/services/profile_services.py @@ -1,13 +1,16 @@ import logging +from typing import Optional from django.db import transaction -from apps.core.services import RcrainfoService -from apps.sites.models import RcraSitePermission, Site -from apps.sites.serializers import RcraPermissionSerializer +from apps.core.services import RcrainfoService # type: ignore +from apps.sites.models import RcraSitePermission, Site # type: ignore +from apps.sites.serializers import RcraPermissionSerializer # type: ignore -from ...core.models import RcraProfile -from .site_services import RcraSiteService, SiteService +from ...core.models import RcraProfile # type: ignore +from .site_services import RcraSiteService, SiteService # type: ignore + +logger = logging.getLogger(__name__) # ToDo, may be better to have a service level module exception. @@ -25,11 +28,10 @@ class RcraProfileService: of a and exposes method corresponding to use cases. """ - def __init__(self, *, username: str, rcrainfo: RcrainfoService = None): + def __init__(self, *, username: str, rcrainfo: Optional[RcrainfoService] = None): self.username = username self.profile, created = RcraProfile.objects.get_or_create(user__username=self.username) self.rcrainfo = rcrainfo or RcrainfoService(api_username=self.username) - self.logger = logging.getLogger(__name__) @property def can_access_rcrainfo(self) -> bool: @@ -45,7 +47,7 @@ def __repr__(self): f"<{self.__class__.__name__}(username='{self.username}', rcrainfo='{self.rcrainfo}')>" ) - def pull_rcra_profile(self, *, username: str = None): + def pull_rcra_profile(self, *, username: Optional[str] = None): """ This high level function makes several requests to RCRAInfo to pull... 1. A user's rcrainfo site permissions, it creates a RcraSitePermission for each @@ -85,7 +87,8 @@ def _create_or_update_rcra_permission( ) -> RcraSitePermission: permission_serializer = RcraPermissionSerializer(data=epa_permission) if permission_serializer.is_valid(): - return RcraSitePermission.objects.update_or_create( + obj, created = RcraSitePermission.objects.update_or_create( **permission_serializer.validated_data, site=site, profile=self.profile ) + return obj raise Exception("Error Attempting to create RcraSitePermission") diff --git a/server/apps/sites/services/site_services.py b/server/apps/sites/services/site_services.py index 734108cb4..ea058c1a8 100644 --- a/server/apps/sites/services/site_services.py +++ b/server/apps/sites/services/site_services.py @@ -1,14 +1,15 @@ import logging -from typing import Dict, List +from typing import Dict, List, Optional from django.core.cache import cache from django.db import transaction from rest_framework.exceptions import ValidationError -from apps.core.services import RcrainfoService -from apps.sites.models import RcraSite, Site -from apps.sites.serializers import RcraSiteSerializer -from apps.trak.services import ManifestService +from apps.core.services import RcrainfoService # type: ignore +from apps.sites.models import RcraSite, Site # type: ignore +from apps.sites.serializers import RcraSiteSerializer # type: ignore +from apps.trak.services import ManifestService # type: ignore +from apps.trak.services.manifest_service import PullManifestsResult # type: ignore logger = logging.getLogger(__name__) @@ -18,13 +19,19 @@ class SiteService: SiteService encapsulates the Haztrak site subdomain business logic and use cases. """ - def __init__(self, *, username: str, site_id: str = None, rcrainfo: RcrainfoService = None): + def __init__( + self, + *, + username: str, + site_id: Optional[str] = None, + rcrainfo: Optional[RcrainfoService] = None, + ): self.username = username self.rcrainfo = rcrainfo or RcrainfoService(api_username=username) if site_id: self.site = Site.objects.get(rcra_site__epa_id=site_id) - def sync_rcra_manifest(self, *, site_id: str = None) -> Dict[str, List[str]]: + def sync_rcra_manifest(self, *, site_id: Optional[str] = None) -> PullManifestsResult: """ Retrieve a site's manifest from Rcrainfo and save to the database. @@ -43,7 +50,7 @@ def sync_rcra_manifest(self, *, site_id: str = None) -> Dict[str, List[str]]: # limit the number of manifest to sync at a time tracking_numbers = tracking_numbers[:30] logger.info(f"Pulling {tracking_numbers} from RCRAInfo") - results: Dict[str, List[str]] = manifest_service.pull_manifests( + results: PullManifestsResult = manifest_service.pull_manifests( tracking_numbers=tracking_numbers ) # ToDo: uncomment this after we have manifest development fixtures @@ -56,7 +63,9 @@ def sync_rcra_manifest(self, *, site_id: str = None) -> Dict[str, List[str]]: raise Exception @transaction.atomic - def create_or_update_site(self, *, rcra_site: RcraSite, site_name: str = None) -> Site: + def create_or_update_site( + self, *, rcra_site: RcraSite, site_name: Optional[str] = None + ) -> Site: """ Retrieve a site from the database or create. @@ -79,10 +88,9 @@ class RcraSiteService: directly relate to use cases. """ - def __init__(self, *, username: str, rcrainfo: RcrainfoService = None): + def __init__(self, *, username: str, rcrainfo: Optional[RcrainfoService] = None): self.username = username self.rcrainfo = rcrainfo or RcrainfoService(api_username=self.username) - self.logger = logging.getLogger(__name__) def __repr__(self): return ( @@ -106,20 +114,20 @@ def get_or_pull_rcra_site(self, site_id: str) -> RcraSite: This may be trying to do too much """ if RcraSite.objects.filter(epa_id=site_id).exists(): - self.logger.debug(f"using existing rcra_site {site_id}") + logger.debug(f"using existing rcra_site {site_id}") return RcraSite.objects.get(epa_id=site_id) new_rcra_site = self.pull_rcra_site(site_id=site_id) - self.logger.debug(f"pulled new rcra_site {new_rcra_site}") + logger.debug(f"pulled new rcra_site {new_rcra_site}") return new_rcra_site - def search_rcra_site(self, **search_parameters): + def search_rcra_site(self, **search_parameters) -> dict: """ Search RCRAInfo for a site by name or EPA ID """ try: data = cache.get(f'{search_parameters["epaSiteId"]}-{search_parameters["siteType"]}') if not data: - data: Dict = self.rcrainfo.search_sites(**search_parameters).json() + data = self.rcrainfo.search_sites(**search_parameters).json() cache.set( f'{search_parameters["epaSiteId"]}-{search_parameters["siteType"]}', data, @@ -133,7 +141,7 @@ def _deserialize_rcra_site(self, *, rcra_site_data: dict) -> RcraSiteSerializer: serializer = RcraSiteSerializer(data=rcra_site_data) if serializer.is_valid(): return serializer - self.logger.error(serializer.errors) + logger.error(serializer.errors) raise ValidationError(serializer.errors) @transaction.atomic diff --git a/server/apps/sites/tests/test_epa_profile_views.py b/server/apps/sites/tests/test_epa_profile_views.py index f17f09b12..6a22149fa 100644 --- a/server/apps/sites/tests/test_epa_profile_views.py +++ b/server/apps/sites/tests/test_epa_profile_views.py @@ -11,7 +11,7 @@ class TestRcraProfileView: Tests the for the endpoints related to the user's RcraProfile """ - URL = "/api/profile" + URL = "/api/user" id_field = "rcraAPIID" key_field = "rcraAPIKey" username_field = "rcraUsername" @@ -28,7 +28,7 @@ def user_and_client(self, rcra_profile_factory, user_factory, api_client_factory def rcra_profile_request(self, user_and_client): factory = APIRequestFactory() request = factory.put( - f"{self.URL}/{self.user.username}", + f"{self.URL}/{self.user.username}/rcra/profile", { self.id_field: self.new_api_id, self.username_field: self.new_username, @@ -43,7 +43,7 @@ def test_returns_a_user_profile(self, user_and_client, rcra_profile_factory): # Arrange rcra_profile_factory(user=self.user) # Act - response: Response = self.client.get(f"{self.URL}/{self.user.username}") + response: Response = self.client.get(f"{self.URL}/{self.user.username}/rcra/profile") # Assert assert response.headers["Content-Type"] == "application/json" assert response.status_code == status.HTTP_200_OK @@ -54,7 +54,7 @@ def test_profile_updates(self, rcra_profile_factory, rcra_profile_request): rcra_profile_factory(user=self.user) request = rcra_profile_request # Act - response = RcraProfileView.as_view()(request, user=self.user.username) + response = RcraProfileView.as_view()(request, username=self.user.username) assert response.status_code == status.HTTP_200_OK assert response.data[self.id_field] == self.new_api_id assert response.data[self.username_field] == self.new_username @@ -64,6 +64,6 @@ def test_update_does_not_return_api_key(self, rcra_profile_factory, rcra_profile rcra_profile_factory(user=self.user) request = rcra_profile_request # Act - response = RcraProfileView.as_view()(request, user=self.user.username) + response = RcraProfileView.as_view()(request, username=self.user.username) # Assert assert self.key_field not in response.data diff --git a/server/apps/sites/tests/test_epa_site_views.py b/server/apps/sites/tests/test_epa_site_views.py index 77e351b10..0dcfb9b76 100644 --- a/server/apps/sites/tests/test_epa_site_views.py +++ b/server/apps/sites/tests/test_epa_site_views.py @@ -4,7 +4,7 @@ from rest_framework.test import APIClient, APIRequestFactory, force_authenticate from apps.sites.models import RcraSiteType -from apps.sites.views import RcraSiteSearchView +from apps.sites.views import SiteSearchView class TestEpaSiteView: @@ -37,7 +37,7 @@ class TestEpaSiteSearchView: Tests for the RcraSite Search endpoint """ - URL = "/api/site/rcra-site/search" + URL = "/api/site/search" @pytest.fixture(autouse=True) def generator(self, rcra_site_factory): @@ -60,7 +60,7 @@ def test_view_returns_array_of_handlers(self, user, generator): ) force_authenticate(request, user) # Act - response = RcraSiteSearchView.as_view()(request) + response = SiteSearchView.as_view()(request) # Assert assert len(response.data) > 0 for handler_data in response.data: @@ -85,7 +85,7 @@ def test_view_filters_by_handler_type(self, user, rcra_site_factory): ) force_authenticate(request, user) # Act - response = RcraSiteSearchView.as_view()(request) + response = SiteSearchView.as_view()(request) # Assert for handler_data in response.data: # ToDo: serialize based on display name diff --git a/server/apps/sites/tests/test_site_views.py b/server/apps/sites/tests/test_site_views.py index 76e8f5e85..ae03ad549 100644 --- a/server/apps/sites/tests/test_site_views.py +++ b/server/apps/sites/tests/test_site_views.py @@ -32,7 +32,7 @@ def _api_client( ) self.other_site = site_factory(rcra_site=rcra_site_factory(epa_id="VA12345678")) - base_url = "/api/site/" + base_url = "/api/site" def test_responds_with_site_in_json_format(self): response = self.client.get(f"{self.base_url}") diff --git a/server/apps/sites/urls.py b/server/apps/sites/urls.py index 34dfc08c8..4bd860c61 100644 --- a/server/apps/sites/urls.py +++ b/server/apps/sites/urls.py @@ -1,20 +1,20 @@ from django.urls import path from apps.sites.views import ( - RcraSiteSearchView, RcraSiteView, SiteDetailView, SiteListView, SiteMtnListView, + SiteSearchView, rcrainfo_site_search_view, ) urlpatterns = [ # Site - path("site/", SiteListView.as_view()), + path("site", SiteListView.as_view()), + path("site/search", SiteSearchView.as_view()), path("site/", SiteDetailView.as_view()), path("site//manifest", SiteMtnListView.as_view()), - path("site/rcra-site/search", RcraSiteSearchView.as_view()), path("site/rcra-site/", RcraSiteView.as_view()), path("site/rcrainfo/search", rcrainfo_site_search_view), ] diff --git a/server/apps/sites/views/__init__.py b/server/apps/sites/views/__init__.py index 3bebcb04e..114b635c8 100644 --- a/server/apps/sites/views/__init__.py +++ b/server/apps/sites/views/__init__.py @@ -1,8 +1,8 @@ from .site_views import ( - RcraSiteSearchView, RcraSiteView, SiteDetailView, SiteListView, SiteMtnListView, + SiteSearchView, rcrainfo_site_search_view, ) diff --git a/server/apps/sites/views/site_views.py b/server/apps/sites/views/site_views.py index 71bcaf0b1..1e64525c6 100644 --- a/server/apps/sites/views/site_views.py +++ b/server/apps/sites/views/site_views.py @@ -109,7 +109,7 @@ class RcraSiteView(RetrieveAPIView): permission_classes = [permissions.IsAuthenticated] -class RcraSiteSearchView(ListAPIView): +class SiteSearchView(ListAPIView): """ Search for locally saved hazardous waste sites ("Generators", "Transporters", "Tsdf's") """ diff --git a/server/apps/trak/services/README.md b/server/apps/trak/services/README.md index c28e2c7b6..bc6d539db 100644 --- a/server/apps/trak/services/README.md +++ b/server/apps/trak/services/README.md @@ -8,25 +8,21 @@ case will have public method as its entry point. Try to follow the following conventions when naming public service methods: 1. `_` - The standard naming convention for our services, where is usually what's being done to . For - example, + The standard naming convention for our services, where is usually what's being done to . For example, -```python -def sign_manifest(self, mtn: str): - pass -``` + ```python + def sign_manifest(self, mtn: str): + pass + ``` -2. `_rcra_` is used to indicate that this use case will be sending requests to RCRAInfo. so we would - expect the following function to try to sign a manifest through the RCRAInfo/e-Manifest web services. +2. `_rcra_` is used to indicate that this use case will be sending requests to RCRAInfo. so we would expect the following function to try to sign a manifest through the RCRAInfo/e-Manifest web services. -```python -def sign_rcra_manifest(self, mtn: str): - pass -``` + ```python + def sign_rcra_manifest(self, mtn: str): + pass + ``` -We also reserve the term `push` and `pull` for `` that make requests to RCRAInfo, usually to indicate that -we're either updating/creating a local (haztrak) or remote (RCRAInfo) resource. + We also reserve the term `push` and `pull` for `` that make requests to RCRAInfo, usually to indicate that + we're either updating/creating a local (haztrak) or remote (RCRAInfo) resource. -3. There are exceptions. For example, the RcrainfoService is really just a wrapper (inherits) around the emanifest PyPI - package's RcrainfoClient class used to send http requests to RCRAInfo/e-Manifest. This service is only used a - dependency for other use cases. +3. There are exceptions. For example, the RcrainfoService is really just a wrapper (inherits) around the emanifest PyPI package's RcrainfoClient class used to send http requests to RCRAInfo/e-Manifest. This service is only used a dependency for other use cases. diff --git a/server/apps/trak/services/manifest_service.py b/server/apps/trak/services/manifest_service.py index d69e1d803..384023ea2 100644 --- a/server/apps/trak/services/manifest_service.py +++ b/server/apps/trak/services/manifest_service.py @@ -1,26 +1,33 @@ import logging from datetime import datetime, timedelta, timezone -from typing import Dict, List +from typing import List, Optional, TypedDict from django.db import transaction -from emanifest import RcrainfoResponse -from requests import RequestException +from emanifest import RcrainfoResponse # type: ignore +from requests import RequestException # type: ignore -from apps.core.services import RcrainfoService -from apps.trak.models import Manifest, QuickerSign -from apps.trak.serializers import ManifestSerializer, QuickerSignSerializer -from apps.trak.tasks import pull_manifest +from apps.core.services import RcrainfoService # type: ignore +from apps.trak.models import Manifest, QuickerSign # type: ignore +from apps.trak.serializers import ManifestSerializer, QuickerSignSerializer # type: ignore +from apps.trak.tasks import pull_manifest # type: ignore logger = logging.getLogger(__name__) +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: RcrainfoService = None): + def __init__(self, *, username: str, rcrainfo: Optional[RcrainfoService] = None): self.username = username self.rcrainfo = rcrainfo or RcrainfoService(api_username=self.username) @@ -53,13 +60,13 @@ def _save_manifest(self, manifest_json: dict) -> Manifest: def search_rcra_mtn( self, *, - site_id: str = None, - start_date: datetime = None, - end_date: datetime = None, - status: str = None, + 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: str = None, - site_type: str = None, + state_code: Optional[str] = None, + site_type: Optional[str] = None, ) -> List[str]: """ Search RCRAInfo for manifests, an abstraction of RcrainfoService's search_mtn @@ -75,11 +82,11 @@ def search_rcra_mtn( """ date_format = "%Y-%m-%dT%H:%M:%SZ" if end_date: - end_date = end_date.replace(tzinfo=timezone.utc).strftime(date_format) + end_date_str = end_date.replace(tzinfo=timezone.utc).strftime(date_format) else: - end_date = datetime.utcnow().replace(tzinfo=timezone.utc).strftime(date_format) + end_date_str = datetime.utcnow().replace(tzinfo=timezone.utc).strftime(date_format) if start_date: - start_date = start_date.replace(tzinfo=timezone.utc).strftime(date_format) + 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.utcnow().replace(tzinfo=timezone.utc) - timedelta( @@ -88,14 +95,14 @@ def search_rcra_mtn( * 30 # 30 days/1month * 36 # 36 months/3years = 3/years ) - start_date = start_date.strftime(date_format) + start_date_str = start_date.strftime(date_format) response = self.rcrainfo.search_mtn( - site_id=site_id, + site_id=site_id, # type: ignore site_type=site_type, state_code=state_code, - start_date=start_date, - end_date=end_date, + start_date=start_date_str, + end_date=end_date_str, status=status, date_type=date_type, ) @@ -104,7 +111,7 @@ def search_rcra_mtn( return response.json() return [] - def pull_manifests(self, tracking_numbers: List[str]) -> Dict[str, List[str]]: + def pull_manifests(self, tracking_numbers: List[str]) -> PullManifestsResult: """ Pull a list of manifest from RCRAInfo @@ -112,7 +119,7 @@ def pull_manifests(self, tracking_numbers: List[str]) -> Dict[str, List[str]]: 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 = {"success": [], "error": []} + results: PullManifestsResult = {"success": [], "error": []} logger.info(f"pulling manifests {tracking_numbers}") for mtn in tracking_numbers: try: @@ -125,7 +132,7 @@ def pull_manifests(self, tracking_numbers: List[str]) -> Dict[str, List[str]]: logger.info(f"pull manifests results: {results}") return results - def sign_manifest(self, signature: QuickerSign) -> dict[str, list[str]]: + def sign_manifest(self, signature: QuickerSign) -> PullManifestsResult: """ Electronically sign manifests in RCRAInfo through the RESTful API. Returns the results by manifest tracking number (MTN) in a Dict. @@ -177,8 +184,8 @@ def create_rcra_manifest(self, *, manifest: dict) -> RcrainfoResponse: raise ValueError("malformed payload") @staticmethod - def _filter_mtn(signature: QuickerSign) -> dict[str, list[str]]: - results = {"success": [], "error": []} + def _filter_mtn(signature: QuickerSign) -> PullManifestsResult: + results: PullManifestsResult = {"success": [], "error": []} site_filter = Manifest.objects.get_handler_query(signature.site_id, signature.site_type) existing_mtn = Manifest.objects.existing_mtn(site_filter, mtn=signature.mtn) results["success"] = [manifest.mtn for manifest in existing_mtn] diff --git a/server/apps/trak/urls.py b/server/apps/trak/urls.py index bdaa100e3..42e34ffc2 100644 --- a/server/apps/trak/urls.py +++ b/server/apps/trak/urls.py @@ -12,7 +12,7 @@ SyncSiteManifestView, ) -manifest_router = routers.SimpleRouter() +manifest_router = routers.SimpleRouter(trailing_slash=False) manifest_router.register(r"manifest", ManifestView) urlpatterns = [ diff --git a/server/apps/trak/views/manifest_view.py b/server/apps/trak/views/manifest_view.py index 62a7754e2..7c91bd00e 100644 --- a/server/apps/trak/views/manifest_view.py +++ b/server/apps/trak/views/manifest_view.py @@ -138,15 +138,13 @@ def post(self, request: Request) -> Response: """The Body of the POST request should contain the complete and valid manifest object""" manifest_serializer = self.serializer_class(data=request.data) if manifest_serializer.is_valid(): - logger.info( - f"valid manifest data submitted for creation in RCRAInfo: " - f"{datetime.datetime.utcnow()}" + logger.debug( + f"manifest data submitted for creation in RCRAInfo: {manifest_serializer.data}" ) task: AsyncResult = create_rcra_manifest.delay( manifest=manifest_serializer.data, username=str(request.user) ) - task_status = TaskService(task_id=task.id, task_name=task.name, status="STARTED") - task_status.update_task_status(status="PENDING") + TaskService(task_id=task.id, task_name=task.name).update_task_status("PENDING") return self.response(data={"taskId": task.id}, status=status.HTTP_201_CREATED) else: logger.error("manifest_serializer errors: ", manifest_serializer.errors) diff --git a/server/haztrak/settings.py b/server/haztrak/settings.py index 60927b1f1..5834685d5 100644 --- a/server/haztrak/settings.py +++ b/server/haztrak/settings.py @@ -80,7 +80,7 @@ # URLs ROOT_URLCONF = "haztrak.urls" -APPEND_SLASH = True +APPEND_SLASH = False TEMPLATES = [ { @@ -165,6 +165,10 @@ "management software can integrate with EPA's RCRAInfo", "VERSION": HAZTRAK_VERSION, "SERVE_INCLUDE_SCHEMA": False, + "EXTERNAL_DOCS": { + "description": "Haztrak Documentation", + "url": "https://usepa.github.io/haztrak/", + }, "SWAGGER_UI_SETTINGS": { "deepLinking": True, "persistAuthorization": True, diff --git a/server/haztrak/urls.py b/server/haztrak/urls.py index 4bb76c9e3..5f5380c06 100644 --- a/server/haztrak/urls.py +++ b/server/haztrak/urls.py @@ -32,7 +32,7 @@ path("", include("apps.sites.urls")), path("schema/", SpectacularAPIView.as_view(), name="schema"), path( - "schema/swagger-ui/", + "schema/swagger-ui", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui", ), diff --git a/server/pyproject.toml b/server/pyproject.toml index 0893dc02e..c7bb9570b 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -42,3 +42,47 @@ ignore = ["F401"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "haztrak.settings" + + +[tool.mypy] +plugins = [ + "mypy_django_plugin.main", + "mypy_drf_plugin.main" +] +exclude = [ + "**/migrations/*.py" +] +enable_error_code = [ + "truthy-bool", + "truthy-iterable", + "redundant-expr", + "unused-awaitable", + "ignore-without-code", + "possibly-undefined", + "redundant-self", +] +allow_redefinition = false +check_untyped_defs = true +disallow_untyped_decorators = true +disallow_any_explicit = true +disallow_any_generics = true +disallow_untyped_calls = true +disallow_incomplete_defs = true +explicit_package_bases = true +ignore_errors = false +ignore_missing_imports = true +implicit_reexport = false +local_partial_types = true +strict_equality = true +strict_optional = true +show_error_codes = true +no_implicit_optional = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unreachable = true +warn_no_return = true + +[tool.django-stubs] +django_settings_module = "haztrak.settings" +strict_settings = false diff --git a/server/requirements_dev.txt b/server/requirements_dev.txt index e75cf31d1..0c0b7c96a 100644 --- a/server/requirements_dev.txt +++ b/server/requirements_dev.txt @@ -3,6 +3,8 @@ pre-commit==3.4.0 pytest==7.4.2 pytest-django==4.5.2 responses==0.23.3 -ruff==0.0.291 +ruff==0.0.292 black==23.9.1 +celery-types==0.20.0 +djangorestframework-stubs==3.14.2 -r requirements.txt