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 @@
+
+
+
+ Download GSPC Report
+
+ We’ve created a report for you in CSV format. You can open it in the spreadsheet
+ application of your choice (e.g. Microsoft Excel, Google Sheets, Apple Numbers).
+
+
+ Download Report
+
+
+
+
\ 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";
+
+
+
+
+
+ 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