Skip to content

Commit

Permalink
Feature/436 gspc report (#570)
Browse files Browse the repository at this point in the history
* polish and front end test.

* Rework post to use ajax and finish api testing, pending repo tests.

* finish repo testing.
  • Loading branch information
john-labbate authored May 31, 2024
1 parent 6c4fcbb commit ec0e11d
Show file tree
Hide file tree
Showing 13 changed files with 471 additions and 21 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"Loginless",
"nanostores",
"pydantic",
"sqlalchemy",
"USWDS",
"Vuelidate"
]
Expand Down
73 changes: 73 additions & 0 deletions training-front-end/src/components/AdminReportDownload.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup>
import { useStore } from '@nanostores/vue'
import { profile} from '../stores/user'
import { computed } from "vue"
import USWDSAlert from './USWDSAlert.vue'
const user = useStore(profile)
const isAdminUser = computed(() => user.value.roles.includes('Admin'))

Check failure on line 8 in training-front-end/src/components/AdminReportDownload.vue

View workflow job for this annotation

GitHub Actions / unit-tests-and-lint

Unhandled error

TypeError: Cannot read properties of undefined (reading 'includes') ❯ src/components/AdminReportDownload.vue:8:55 ❯ ReactiveEffect.fn node_modules/@vue/reactivity/dist/reactivity.cjs.js:998:13 ❯ ReactiveEffect.run node_modules/@vue/reactivity/dist/reactivity.cjs.js:181:19 ❯ ComputedRefImpl.get value [as value] node_modules/@vue/reactivity/dist/reactivity.cjs.js:1010:109 ❯ triggerComputed node_modules/@vue/reactivity/dist/reactivity.cjs.js:200:19 ❯ ReactiveEffect.get dirty [as dirty] node_modules/@vue/reactivity/dist/reactivity.cjs.js:153:11 ❯ instance.update node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6078:18 ❯ callWithErrorHandling node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:195:33 ❯ flushJobs node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:400:9 This error originated in "src/components/__tests__/AdminReportIndex.spec.js" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.

Check failure on line 8 in training-front-end/src/components/AdminReportDownload.vue

View workflow job for this annotation

GitHub Actions / unit-tests-and-lint

Unhandled error

TypeError: Cannot read properties of undefined (reading 'includes') ❯ src/components/AdminReportDownload.vue:8:55 ❯ ReactiveEffect.fn node_modules/@vue/reactivity/dist/reactivity.cjs.js:998:13 ❯ ReactiveEffect.run node_modules/@vue/reactivity/dist/reactivity.cjs.js:181:19 ❯ ComputedRefImpl.get value [as value] node_modules/@vue/reactivity/dist/reactivity.cjs.js:1010:109 ❯ triggerComputed node_modules/@vue/reactivity/dist/reactivity.cjs.js:200:19 ❯ ReactiveEffect.get dirty [as dirty] node_modules/@vue/reactivity/dist/reactivity.cjs.js:153:11 ❯ instance.update node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6078:18 ❯ callWithErrorHandling node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:195:33 ❯ flushJobs node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:400:9 This error originated in "src/components/__tests__/AdminReportIndex.spec.js" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
const base_url = import.meta.env.PUBLIC_API_BASE_URL
const gspc_report_url = `${base_url}/api/v1/gspc/download-gspc-completion-report`
async function downloadGspcReport() {
const response = await fetch( gspc_report_url, {
method: 'POST',
headers: { 'Authorization': `Bearer ${user.value.jwt}` },
});
if (response.ok) {
const blob = await response.blob();
downloadBlobAsFile(blob, 'GspcCompletionReport.csv')
} else {
console.error('Failed to download report', response.statusText);
}
}
async function downloadBlobAsFile(blob, filename){
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}
</script>
<template>
<section
v-if="isAdminUser"
class="usa-prose"
>
<h2>Download GSPC Report</h2>
<p>
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).
</p>
<button
class="usa-button"
@click="downloadGspcReport"
>
Download Report
</button>
</section>
<section v-else>
<USWDSAlert
status="error"
class="usa-alert"
heading="You are not authorized to receive reports."
>
Your email account is not authorized to access admin reports. If you should be authorized, you can
<a
class="usa-link"
href="mailto:[email protected]"
>
contact the GSA SmartPay team
</a> to gain access.
</USWDSAlert>
</section>
</template>
42 changes: 42 additions & 0 deletions training-front-end/src/components/AdminReportIndex.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script setup>
import { ref, onErrorCaptured } from "vue"
import AdminReportDownload from "./AdminReportDownload.vue";
import USWDSAlert from './USWDSAlert.vue'
const error = ref()
function setError(event){
error.value = event
}
onErrorCaptured((err) => {
if (err.message == 'Unauthorized'){
err = {
name: 'You are not authorized to receive admin reports.',
message: 'Your email account is not authorized to access admin reports. If you should be authorized, you can <a class="usa-link" href="mailto:[email protected]">contact the GSA SmartPay® team</a> to gain access.'
}
setError(err)
}
return false
})
</script>

<template>
<div class="padding-top-4 padding-bottom-4 grid-container">
<div class="grid-row">
<div class="tablet:grid-col-12">
<USWDSAlert
v-if="error"
class="tablet:grid-col-12 margin-bottom-4"
status="error"
:heading="error.name"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="error.message" />
</USWDSAlert>
<AdminReportDownload />
</div>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -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')
})

})
17 changes: 17 additions & 0 deletions training-front-end/src/pages/admin/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ const pageTitle = "Admin Panel";
</div>
</div>
</li>
<li class="usa-card tablet:grid-col-6">
<div class="usa-card__container">
<div class="usa-card__header">
<h4 class="usa-card__heading">System Reports</h4>
</div>
<div class="usa-card__body">
<p>
Allows administrators to download reports.
</p>
</div>
<div class="usa-card__footer">
<a href=`${import.meta.env.BASE_URL}admin/reports` class="card_link usa-button">
Reports
</a>
</div>
</div>
</li>
</ul>
</div>
</AuthRequired>
Expand Down
16 changes: 16 additions & 0 deletions training-front-end/src/pages/admin/reports.astro
Original file line number Diff line number Diff line change
@@ -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";
---
<BaseLayout title={pageTitle} description="Admin Reports">
<HeroTraining background_class='bg_smartpay_cool_grey'>
Admin Reports
</HeroTraining>
<AuthRequired client:only>
<AdminReportIndex client:only />
</AuthRequired>
</BaseLayout>
29 changes: 26 additions & 3 deletions training/api/api_v1/gspc.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
55 changes: 55 additions & 0 deletions training/repositories/gspc_completion.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from sqlalchemy.orm import Session
from sqlalchemy import desc, literal
from training import models, schemas
from .base import BaseRepository

Expand All @@ -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
3 changes: 2 additions & 1 deletion training/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
22 changes: 22 additions & 0 deletions training/schemas/reports.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 0 additions & 9 deletions training/schemas/user.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit ec0e11d

Please sign in to comment.