From ec0e11d8dcdff913fdba26ed5b09e86896de199b Mon Sep 17 00:00:00 2001 From: John Labbate <90406009+john-labbate@users.noreply.github.com> Date: Fri, 31 May 2024 11:22:56 -0400 Subject: [PATCH] Feature/436 gspc report (#570) * polish and front end test. * Rework post to use ajax and finish api testing, pending repo tests. * finish repo testing. --- .vscode/settings.json | 1 + .../src/components/AdminReportDownload.vue | 73 +++++++++ .../src/components/AdminReportIndex.vue | 42 ++++++ .../__tests__/AdminReportIndex.spec.js | 28 ++++ .../src/pages/admin/index.astro | 17 +++ .../src/pages/admin/reports.astro | 16 ++ training/api/api_v1/gspc.py | 29 +++- training/repositories/gspc_completion.py | 55 +++++++ training/schemas/__init__.py | 3 +- training/schemas/reports.py | 22 +++ training/schemas/user.py | 9 -- training/tests/test_api_gspc.py | 57 ++++++- .../tests/test_gspc_completion_repository.py | 140 +++++++++++++++++- 13 files changed, 471 insertions(+), 21 deletions(-) create mode 100644 training-front-end/src/components/AdminReportDownload.vue create mode 100644 training-front-end/src/components/AdminReportIndex.vue create mode 100644 training-front-end/src/components/__tests__/AdminReportIndex.spec.js create mode 100644 training-front-end/src/pages/admin/reports.astro create mode 100644 training/schemas/reports.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 21d45c41..68516a95 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ "Loginless", "nanostores", "pydantic", + "sqlalchemy", "USWDS", "Vuelidate" ] diff --git a/training-front-end/src/components/AdminReportDownload.vue b/training-front-end/src/components/AdminReportDownload.vue new file mode 100644 index 00000000..b703fe1d --- /dev/null +++ b/training-front-end/src/components/AdminReportDownload.vue @@ -0,0 +1,73 @@ + + \ No newline at end of file diff --git a/training-front-end/src/components/AdminReportIndex.vue b/training-front-end/src/components/AdminReportIndex.vue new file mode 100644 index 00000000..e28b5a87 --- /dev/null +++ b/training-front-end/src/components/AdminReportIndex.vue @@ -0,0 +1,42 @@ + + + \ No newline at end of file diff --git a/training-front-end/src/components/__tests__/AdminReportIndex.spec.js b/training-front-end/src/components/__tests__/AdminReportIndex.spec.js new file mode 100644 index 00000000..2fbc38dc --- /dev/null +++ b/training-front-end/src/components/__tests__/AdminReportIndex.spec.js @@ -0,0 +1,28 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { cleanStores } from 'nanostores' +import { profile } from '../../stores/user.js' + +import AdminReportIndex from '../AdminReportIndex.vue' + + +describe("AdminReportIndex", async () => { + afterEach(() => { + vi.restoreAllMocks() + cleanStores() + profile.set({}) + }) + + it('Shows download screen', async () => { + profile.set({name:"Amelia Sedley", jwt:"some-token-value", roles:["Admin"]}) + const wrapper = await mount(AdminReportIndex) + expect(wrapper.text()).toContain('Download GSPC Report') + }) + + it('shows error when user is know but does not have correct roles', async () => { + profile.set({name:"Amelia Sedley", jwt:"some-token-value", roles:["SomeOtherRole"]}) + const wrapper = await mount(AdminReportIndex) + expect(wrapper.text()).toContain('You are not authorized') + }) + +}) \ No newline at end of file diff --git a/training-front-end/src/pages/admin/index.astro b/training-front-end/src/pages/admin/index.astro index 1626b22e..d0ec2b8a 100644 --- a/training-front-end/src/pages/admin/index.astro +++ b/training-front-end/src/pages/admin/index.astro @@ -50,6 +50,23 @@ const pageTitle = "Admin Panel"; +
  • +
    +
    +

    System Reports

    +
    +
    +

    + Allows administrators to download reports. +

    +
    + +
    +
  • diff --git a/training-front-end/src/pages/admin/reports.astro b/training-front-end/src/pages/admin/reports.astro new file mode 100644 index 00000000..bbf12966 --- /dev/null +++ b/training-front-end/src/pages/admin/reports.astro @@ -0,0 +1,16 @@ +--- +import BaseLayout from '@layouts/BaseLayout.astro'; +import HeroTraining from '@components/HeroTraining.astro'; +import AdminReportIndex from '@components/AdminReportIndex.vue'; +import AuthRequired from '@components/AuthRequired.vue'; + +const pageTitle = "Reports"; +--- + + + Admin Reports + + + + + diff --git a/training/api/api_v1/gspc.py b/training/api/api_v1/gspc.py index 6af2c9e3..7e3237b5 100644 --- a/training/api/api_v1/gspc.py +++ b/training/api/api_v1/gspc.py @@ -1,10 +1,12 @@ from typing import Any import logging -from fastapi import APIRouter, status, HTTPException, Depends +import csv +from io import StringIO +from fastapi import APIRouter, status, HTTPException, Response, Depends from training.schemas import GspcInvite, GspcResult, GspcSubmission from training.services import GspcService -from training.repositories import GspcInviteRepository -from training.api.deps import gspc_invite_repository, gspc_service +from training.repositories import GspcInviteRepository, GspcCompletionRepository +from training.api.deps import gspc_invite_repository, gspc_completion_repository, gspc_service from training.api.email import send_gspc_invite_email from training.api.auth import RequireRole from training.config import settings @@ -60,3 +62,24 @@ def submit_gspc_registration( ): result = gspc_service.grade(user_id=user["id"], submission=submission) return result + + +@router.post("/gspc/download-gspc-completion-report") +def download_report_csv( + user=Depends(RequireRole(["Admin"])), + gspc_completion_repo: GspcCompletionRepository = Depends(gspc_completion_repository), +): + results = gspc_completion_repo.get_gspc_completion_report() + + output = StringIO() + writer = csv.writer(output) + + # header row + writer.writerow(['Invited Email', 'Registered Email', 'Name', 'Agency', 'Bureau', 'Passed', 'Registration Completion Date and Time']) + for item in results: + # data row + completion_date_str = item.completionDate.strftime("%m/%d/%Y %H:%M:%S") if item.completionDate is not None else None + writer.writerow([item.invitedEmail, item.registeredEmail, item.username ,item.agency, item.bureau, item.passed, completion_date_str]) # noqa 501 + + headers = {'Content-Disposition': 'attachment; filename="GspcCompletionReport.csv"'} + return Response(output.getvalue(), headers=headers, media_type='application/csv') diff --git a/training/repositories/gspc_completion.py b/training/repositories/gspc_completion.py index fcd39ce9..8b4c2e2a 100644 --- a/training/repositories/gspc_completion.py +++ b/training/repositories/gspc_completion.py @@ -1,4 +1,5 @@ from sqlalchemy.orm import Session +from sqlalchemy import desc, literal from training import models, schemas from .base import BaseRepository @@ -15,3 +16,57 @@ def create(self, gspc_completion: schemas.GspcCompletion) -> models.GspcCompleti certification_expiration_date=gspc_completion.certification_expiration_date, responses=gspc_completion.responses )) + + def get_gspc_completion_report(self): + completed_results = self._get_completed_gspc_results() + uncompleted_results = self._get_uncompleted_gspc_results() + return completed_results + uncompleted_results + + def _get_completed_gspc_results(self): + result = ( + self._session.query( + models.GspcInvite.email.label("invitedEmail"), + models.User.email.label("registeredEmail"), + models.User.name.label("username"), + models.Agency.name.label("agency"), + models.Agency.bureau.label("bureau"), + models.GspcCompletion.passed.label("passed"), + models.GspcCompletion.submit_ts.label("completionDate") + ) + .select_from(models.GspcCompletion) + .join(models.User) + .join(models.Agency) + .outerjoin(models.GspcInvite, models.User.email == models.GspcInvite.email) + .distinct() + .order_by(desc(models.GspcCompletion.passed), models.GspcCompletion.submit_ts) + ).all() + + return result + + def _get_uncompleted_gspc_results(self): + # Subquery to get all emails from User that are referenced in GspcCompletion + subquery = ( + self._session.query(models.User.email) + .select_from(models.User) + .join(models.GspcCompletion, models.GspcCompletion.user_id == models.User.id) + .subquery() + ) + + # Query to get all unique emails from GspcInvite that are not in the subquery + result = ( + self._session.query( + models.GspcInvite.email.label("invitedEmail"), + literal(None).label('registeredEmail'), + literal(None).label('username'), + literal(None).label('agency'), + literal(None).label('bureau'), + literal(None).label('passed'), + literal(None).label('completionDate') + ) + .select_from(models.GspcInvite) + .outerjoin(subquery, models.GspcInvite.email == subquery.c.email) + .filter(subquery.c.email.is_(None)) + .distinct() + ).all() + + return result diff --git a/training/schemas/__init__.py b/training/schemas/__init__.py index 55026b54..5cabe5b8 100644 --- a/training/schemas/__init__.py +++ b/training/schemas/__init__.py @@ -1,6 +1,6 @@ from .agency import Agency, AgencyCreate, AgencyWithBureaus from .temp_user import TempUser, IncompleteTempUser, WebDestination -from .user import User, UserCreate, UserQuizCompletionReportData, UserSearchResult, UserJWT, UserUpdate +from .user import User, UserCreate, UserSearchResult, UserJWT, UserUpdate from .gspc_certificate import GspcCertificate from .gspc_completion import GspcCompletion from .gspc_invite import GspcInvite @@ -17,3 +17,4 @@ from .user_x_role import UserXRole from .report_user_x_agency import ReportUserXAgency from .role import Role, RoleCreate +from .reports import UserQuizCompletionReportData, GspcCompletionReportData diff --git a/training/schemas/reports.py b/training/schemas/reports.py new file mode 100644 index 00000000..98151ce8 --- /dev/null +++ b/training/schemas/reports.py @@ -0,0 +1,22 @@ +from datetime import datetime +from pydantic import BaseModel, ConfigDict +from training.schemas.user import UserBase + + +class UserQuizCompletionReportData(UserBase): + agency: str + bureau: str | None = None + quiz: str + completion_date: datetime + model_config = ConfigDict(from_attributes=True) + + +class GspcCompletionReportData(BaseModel): + invitedEmail: str | None = None + registeredEmail: str | None = None + username: str | None = None + agency: str | None = None + bureau: str | None = None + passed: bool | None = None + completionDate: datetime | None = None + model_config = ConfigDict(from_attributes=True) diff --git a/training/schemas/user.py b/training/schemas/user.py index 99296dc4..4760567c 100644 --- a/training/schemas/user.py +++ b/training/schemas/user.py @@ -1,4 +1,3 @@ -from datetime import datetime from pydantic import ConfigDict, BaseModel, EmailStr, field_validator from training.schemas.agency import Agency from training.schemas.role import Role @@ -43,14 +42,6 @@ def convert_roles(cls, input) -> list[str]: return [role.name for role in input] -class UserQuizCompletionReportData(UserBase): - agency: str - bureau: str | None = None - quiz: str - completion_date: datetime - model_config = ConfigDict(from_attributes=True) - - class UserSearchResult(BaseModel): users: list[User] total_count: int diff --git a/training/tests/test_api_gspc.py b/training/tests/test_api_gspc.py index 39ba3e20..454441e0 100644 --- a/training/tests/test_api_gspc.py +++ b/training/tests/test_api_gspc.py @@ -5,22 +5,31 @@ from training.main import app from datetime import datetime, timedelta, timezone from training.config import settings -from training.api.deps import gspc_invite_repository +from training.api.deps import gspc_invite_repository, gspc_completion_repository +from http import HTTPStatus client = TestClient(app) -ENDPOINT = "/api/v1/gspc-invite" +GSPC_INVITE_ENDPOINT = "/api/v1/gspc-invite" +GSPC_REPORT_ENDPOINT = "/api/v1/gspc/download-gspc-completion-report" def post_gspc_invite(payload, goodJWT,): return client.post( - ENDPOINT, + GSPC_INVITE_ENDPOINT, json=payload, headers={"Authorization": f"Bearer {goodJWT}"} ) +def post_gspc_report(goodJWT): + return client.post( + GSPC_REPORT_ENDPOINT, + headers={"Authorization": f"Bearer {goodJWT}"} + ) + + @pytest.fixture def admin_user(): return { @@ -30,11 +39,25 @@ def admin_user(): } +@pytest.fixture +def standard_user(): + return { + 'name': 'Albus Dumbledore', + 'email': 'dumbledore@hogwarts.edu', + 'roles': [''] + } + + @pytest.fixture def goodJWT(admin_user): return jwt.encode(admin_user, settings.JWT_SECRET, algorithm="HS256") +@pytest.fixture +def badJWT(standard_user): + return jwt.encode(standard_user, settings.JWT_SECRET, algorithm="HS256") + + @pytest.fixture def fake_gspc_invite_repo(): mock = MagicMock() @@ -43,6 +66,14 @@ def fake_gspc_invite_repo(): app.dependency_overrides = {} +@pytest.fixture +def fake_gspc_completion_repository(): + mock = MagicMock() + app.dependency_overrides[gspc_completion_repository] = lambda: mock + yield mock + app.dependency_overrides = {} + + @pytest.fixture def standard_payload(): tomorrows_date = datetime.now(timezone.utc) + timedelta(days=1) @@ -60,7 +91,7 @@ def test_gspc_invite_success(self, send_gspc_invite_email, goodJWT, standard_pay '''Given 2 valid emails it should call the db create method for each''' response = post_gspc_invite(standard_payload, goodJWT) - assert response.status_code == 200 + assert response.status_code == HTTPStatus.OK assert fake_gspc_invite_repo.create.call_count == 2 @patch('training.config.settings', 'JWT_SECRET', 'super_secret') @@ -100,3 +131,21 @@ def test_gspc_invite_logs_emails(self, send_gspc_invite_email, goodJWT, standard with patch('training.api.api_v1.gspc.logging') as logger: post_gspc_invite(standard_payload, goodJWT) assert logger.info.call_count == 2 + + def test_gspc_report(self, goodJWT, fake_gspc_completion_repository): + '''Given a valid request returns a csv''' + response = post_gspc_report(goodJWT) + assert response.status_code == HTTPStatus.OK + assert fake_gspc_completion_repository.get_gspc_completion_report.call_count == 1 + + # Assert the media type + assert response.headers["content-type"] == "application/csv" + + # Assert the Content-Disposition header + assert "Content-Disposition" in response.headers + assert response.headers["Content-Disposition"] == 'attachment; filename="GspcCompletionReport.csv"' + + def test_gspc_report_no_perms(self, badJWT, fake_gspc_completion_repository): + '''Endpoint requires admin role''' + response = post_gspc_report(badJWT) + assert response.status_code == HTTPStatus.UNAUTHORIZED diff --git a/training/tests/test_gspc_completion_repository.py b/training/tests/test_gspc_completion_repository.py index 0042b1cc..9ec3a515 100644 --- a/training/tests/test_gspc_completion_repository.py +++ b/training/tests/test_gspc_completion_repository.py @@ -1,13 +1,25 @@ from datetime import datetime, timedelta import pytest -from training import schemas +from unittest.mock import MagicMock, patch from training.repositories import GspcCompletionRepository +from sqlalchemy.orm import Session +from training import schemas + + +# Define mock models directly in the test function +class MockGspcCompletion: + def __init__(self, invitedEmail, registeredEmail, username, agency, bureau, passed, completionDate): + self.invitedEmail = invitedEmail + self.registeredEmail = registeredEmail + self.username = username + self.agency = agency + self.bureau = bureau + self.passed = passed + self.completionDate = completionDate @pytest.fixture -def valid_gspc_completion( - valid_user_ids: list[int] -) -> schemas.GspcCompletion: +def valid_gspc_completion(valid_user_ids: list[int]) -> schemas.GspcCompletion: return schemas.GspcCompletion( user_id=valid_user_ids[-1], passed=True, @@ -23,8 +35,128 @@ def test_create( gspc_completion_repo_with_data: GspcCompletionRepository, valid_gspc_completion: schemas.GspcCompletion, ): + '''Creates a new record''' dt = datetime.utcnow() db_gspc_completion = gspc_completion_repo_with_data.create(valid_gspc_completion) assert db_gspc_completion.id assert db_gspc_completion.passed assert (dt - timedelta(minutes=5)) <= db_gspc_completion.submit_ts <= (dt + timedelta(minutes=5)) + + +@patch('training.repositories.GspcCompletionRepository._get_completed_gspc_results') +@patch('training.repositories.GspcCompletionRepository._get_uncompleted_gspc_results') +def test_get_gspc_completion_report(mock_get_uncompleted_gspc_results, mock_get_completed_gspc_results): + '''Gets result from both queries and return them in a list''' + # Create mock data for completed and uncompleted results + completedGspcResults = [ + MockGspcCompletion(invitedEmail='invite1@example.com', registeredEmail='user1@example.com', + username='User1', agency='Agency1', bureau='Bureau1', passed=True, completionDate=datetime(2024, 1, 1, 12, 0, 0)), + MockGspcCompletion(invitedEmail='invite2@example.com', registeredEmail='user2@example.com', + username='User2', agency='Agency2', bureau='Bureau2', passed=False, completionDate=datetime(2024, 1, 2, 12, 0, 0)), + ] + + uncompletedGspcResults = [ + MockGspcCompletion(invitedEmail='invite3@example.com', registeredEmail=None, username=None, agency=None, bureau=None, passed=None, completionDate=None), + ] + + # Mock the return values for the sub-methods + mock_get_completed_gspc_results.return_value = completedGspcResults + mock_get_uncompleted_gspc_results.return_value = uncompletedGspcResults + + # Instantiate the repository with a mock session + mock_db_session = MagicMock(spec=Session) + repo = GspcCompletionRepository(mock_db_session) + + # Call the method + results = repo.get_gspc_completion_report() + + # Check the results + assert len(results) == 3 + + # Check the completed results + firstResult = results[0] + assert firstResult.invitedEmail == 'invite1@example.com' + assert firstResult.registeredEmail == 'user1@example.com' + assert firstResult.username == 'User1' + assert firstResult.agency == 'Agency1' + assert firstResult.bureau == 'Bureau1' + assert firstResult.passed + assert firstResult.completionDate == datetime(2024, 1, 1, 12, 0, 0) + + secondRow = results[1] + assert secondRow.invitedEmail == 'invite2@example.com' + assert secondRow.registeredEmail == 'user2@example.com' + assert secondRow.username == 'User2' + assert secondRow.agency == 'Agency2' + assert secondRow.bureau == 'Bureau2' + assert secondRow.passed is False + assert secondRow.completionDate == datetime(2024, 1, 2, 12, 0, 0) + + # Check the uncompleted results + thirdRow = results[2] + assert thirdRow.invitedEmail == 'invite3@example.com' + assert thirdRow.registeredEmail is None + assert thirdRow.username is None + assert thirdRow.agency is None + assert thirdRow.bureau is None + assert thirdRow.passed is None + assert thirdRow.completionDate is None + + +# Testing the private methods directly +def test_get_completed_gspc_results(): + '''Returns completed_gspc_results''' + mock_db_session = MagicMock(spec=Session) + repo = GspcCompletionRepository(mock_db_session) + + completedGspcResults = [ + MockGspcCompletion(invitedEmail='invite1@example.com', registeredEmail='user1@example.com', + username='User1', agency='Agency1', bureau='Bureau1', passed=True, completionDate=datetime(2024, 1, 1, 12, 0, 0)), + MockGspcCompletion(invitedEmail='invite2@example.com', registeredEmail='user2@example.com', + username='User2', agency='Agency2', bureau='Bureau2', passed=False, completionDate=datetime(2024, 1, 2, 12, 0, 0)), + ] + + mock_query = MagicMock() + mock_query.select_from.return_value = mock_query + mock_query.join.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.distinct.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = completedGspcResults + mock_db_session.query.return_value = mock_query + + results = repo._get_completed_gspc_results() + + assert len(results) == 2 + assert results[0].invitedEmail == 'invite1@example.com' + assert results[1].invitedEmail == 'invite2@example.com' + + +def test_get_uncompleted_gspc_results(): + '''Returns uncompleted_gspc_results''' + mock_db_session = MagicMock(spec=Session) + repo = GspcCompletionRepository(mock_db_session) + + uncompletedGspcResults = [ + MockGspcCompletion(invitedEmail='invite3@example.com', registeredEmail=None, username=None, agency=None, bureau=None, passed=None, completionDate=None), + ] + + mock_query = MagicMock() + mock_query.select_from.return_value = mock_query + mock_query.outerjoin.return_value = mock_query + mock_query.distinct.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.all.return_value = uncompletedGspcResults + mock_db_session.query.return_value = mock_query + + results = repo._get_uncompleted_gspc_results() + + # Assert the results + assert len(results) == 1 + assert results[0].invitedEmail == 'invite3@example.com' + assert results[0].registeredEmail is None + assert results[0].username is None + assert results[0].agency is None + assert results[0].bureau is None + assert results[0].passed is None + assert results[0].completionDate is None