Skip to content

Commit

Permalink
Merge pull request #557 from GSA/feature/21-verify-gspc-course-requie…
Browse files Browse the repository at this point in the history
…rments

Feature/21 verify gspc course requierments
  • Loading branch information
john-labbate authored May 10, 2024
2 parents 79bcc7a + f7f0dbc commit c5c878c
Show file tree
Hide file tree
Showing 25 changed files with 378 additions and 61 deletions.
35 changes: 35 additions & 0 deletions alembic/versions/a5fbdcd0e719_add_gspc_completions_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""add gspc_completions table
Revision ID: a5fbdcd0e719
Revises: 51b251b1ec2a
Create Date: 2024-05-08 10:43:09.569708
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql


# revision identifiers, used by Alembic.
revision = 'a5fbdcd0e719'
down_revision = '51b251b1ec2a'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
'gspc_completions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('passed', sa.Boolean(), nullable=False),
sa.Column('certification_expiration_date', sa.Date, nullable=False),
sa.Column('submit_ts', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('responses', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)


def downgrade() -> None:
op.drop_table('gspc_completions')
Binary file added data/blank_certificates/c_gspc.pdf
Binary file not shown.
2 changes: 1 addition & 1 deletion training-front-end/src/components/CertificateTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
</td>
<td>
<form
:action="`${api_url}/api/v1/certificate/${cert.id}`"
:action="`${api_url}/api/v1/certificate/quiz/${cert.id}`"
method="post"
>
<input
Expand Down
6 changes: 3 additions & 3 deletions training-front-end/src/components/GspcQuestions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,16 @@
v-if="show_intro"
class="usa-prose margin-y-1 usa-prose"
>
<h2>GSA SmartPay® Program Certification (GSPC) Requirements</h2>
<h2>GSA SmartPay Program Certification (GSPC) Requirements</h2>
<p>
To earn your GSA SmartPay ® Program Certification you will need to:
To earn your GSA SmartPay Program Certification you will need to:

<ul>
<li>Complete a minimum of seven classes, including two GSA-qualifying classes and five Bank/brand-qualifying classes, during the annual GSA SmartPay Training forum.</li>
<li>Have a minimum of six months of continuous, hands-on experience working with the GSA SmartPay program.</li>
</ul>

You can complete the verification steps to receive your GSA SmartPay® Program Certification if you meet these requirements.
You can complete the verification steps to receive your GSA SmartPay Program Certification if you meet these requirements.
</p>
<button
id="start-button"
Expand Down
45 changes: 30 additions & 15 deletions training-front-end/src/components/GspcRegistration.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import USWDSAlert from './USWDSAlert.vue'
import Loginless from './LoginlessFlow.vue';
import GspcQuestions from './GspcQuestions.vue';
import FileDownLoad from "./icons/FileDownload.vue"
onErrorCaptured((err) => {
setError(err)
Expand All @@ -17,6 +18,11 @@
required: false,
default: false
},
'certId': {
type: Number,
required: false,
default: null
},
'certFailed': {
type: Boolean,
required: false,
Expand All @@ -32,6 +38,7 @@
const user = useStore(profile)
const base_url = import.meta.env.PUBLIC_API_BASE_URL
const certPassed = ref(props.certPassed)
const certId = ref(props.certId)
const certFailed = ref(props.certFailed)
const quizStarted = ref(false)
const quizSubmitted = ref(false)
Expand Down Expand Up @@ -65,10 +72,6 @@
error.value = event
}
function downloadCert(){
//console.log('todo')
}
function startQuiz() {
quizStarted.value = true
}
Expand All @@ -85,7 +88,7 @@
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.value.jwt}`
},
body: JSON.stringify( {'responses': user_answers, 'expiration_date': expirationDate})
body: JSON.stringify({'responses':{'responses': user_answers}, 'expiration_date': expirationDate})
})
} catch(e) {
const err = new Error("There was a problem connecting with the server")
Expand All @@ -104,6 +107,7 @@
var result = await res.json()
if(result.passed){
certPassed.value = true
certId.value = result.cert_id
} else{
certFailed.value = true
}
Expand Down Expand Up @@ -137,7 +141,7 @@
page-id="gspc_registration"
title="gspc_registration"
:header="header"
link-destination-text="the GSA SmartPay Program Certification (GSPCS)"
link-destination-text="the GSA SmartPay Program Certification (GSPC)"
:parameters="redirectExpirationDateString"
@start-loading="startLoading"
@error="setError"
Expand All @@ -154,24 +158,35 @@
<div v-if="certPassed">
<h2>Congratulations You Earned Your GSA SmartPay Program Certificate (GSPC)</h2>
<p>You have met the requirements to earn a GSA SmartPay Program Certificate (GSPC). Your certificate has been emailed to you. Or, you may download your certificate below.</p>
<button
class="usa-button"
@click="downloadCert"
<form
:action="`${base_url}/api/v1/certificate/gspc/${certId}`"
method="post"
>
Download your certificate
</button>
<br><br>
<a href="/">Return to the GSA SmartPay Training Home Page</a>
<input
type="hidden"
name="jwtToken"
:value="user.jwt"
>
<button
class="usa-button"
type="submit"
>
<FileDownLoad /> Download your certificate
</button>
<br><br>
<a :href="base_url">Return to the GSA SmartPay Training Home Page</a>
</form>
</div>
<div v-else-if="certFailed">
<h2>You Don't Meet the Requirements for GSA SmartPay Program Certification (GSPC)</h2>
<p>Once you have met the coursework and experience requirement of six months of continuous, hands-on experience working with the GSA SmartPay program please return to the link in your email to reapply.</p>
<p>If you have any questions ,please reference <a href="">Smart Bulletin No. 022</a> or contact the GSPC Program Manager at <a href="mailto:[email protected]">[email protected]</a>.</p>
<a href="/">Return to the GSA SmartPay Training Home Page</a>
<p>If you have any questions, please reference <a href="https://smartpay.gsa.gov/policies-and-audits/smart-bulletins/022">Smart Bulletin No. 022</a> or contact the GSPC Program Manager at <a href="mailto:[email protected]">[email protected]</a>.</p>
<a :href="base_url">Return to the GSA SmartPay Training Home Page</a>
</div>
<div v-else>
<GspcQuestions
:questions="questions"
class="desktop:grid-col-8"
@submit-gspc-registration="submitGspcRegistration"
@start-quiz="startQuiz"
/>
Expand Down
2 changes: 1 addition & 1 deletion training-front-end/src/components/QuizResults.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
const result_string = computed(() => `${props.quizResults.correct_count} of ${props.quizResults.question_count}`)
const percentage = computed(() => (props.quizResults.percentage * 100).toFixed(0))
const quiz_certificate_url = computed(() => `${api_base}/api/v1/certificate/${props.quizResults.quiz_completion_id}`)
const quiz_certificate_url = computed(() => `${api_base}/api/v1/certificate/quiz/${props.quizResults.quiz_completion_id}`)
function windowStateListener() {
window.location = import.meta.env.BASE_URL
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ describe('CertificateTable', async () => {
expect(rows.length).toBe(3)

const anchorOne = rows[1].find('form')
expect(anchorOne.attributes('action')).toBe("http://localhost:8000/api/v1/certificate/2")
expect(anchorOne.attributes('action')).toBe("http://localhost:8000/api/v1/certificate/quiz/2")

const anchorTwo = rows[2].find('form')
expect(anchorTwo.attributes('action')).toBe("http://localhost:8000/api/v1/certificate/68")
expect(anchorTwo.attributes('action')).toBe("http://localhost:8000/api/v1/certificate/quiz/68")
})

it('show correct message when the user has not taken a quiz', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('GspcRegistration', () => {
setUserCredentials()
const wrapper = await mount(GspcRegistration)
await flushPromises()
expect(wrapper.text()).toContain("GSA SmartPay® Program Certification (GSPC) Requirements")
expect(wrapper.text()).toContain("GSA SmartPay Program Certification (GSPC) Requirements")
})

it('renders USWDSAlert when error is present', async () => {
Expand Down
64 changes: 46 additions & 18 deletions training/api/api_v1/certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,54 @@ def get_certificates_by_userid(
return db_user_certificates


@router.post("/certificate/{id}", response_model=UserCertificate)
def get_certificate_by_id(
id: int,
repo: CertificateRepository = Depends(certificate_repository),
certificate: Certificate = Depends(Certificate),
user=Depends(user_from_form)
@router.post("/certificate/{certType}/{id}", response_model=UserCertificate)
def get_certificate_by_type_and_id(
id: int,
certType: str,
certificateRepo: CertificateRepository = Depends(certificate_repository),
certificateService: Certificate = Depends(Certificate),
user=Depends(user_from_form)
):
db_user_certificate = repo.get_certificate_by_id(id)
pdf_bytes = None
filename = ''

if db_user_certificate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if (certType == 'quiz'):
db_user_certificate = certificateRepo.get_certificate_by_id(id)

if db_user_certificate.user_id != user["id"]:
raise HTTPException(status_code=401, detail="Not Authorized")
verify_certificate_is_valid(db_user_certificate, user["id"])

pdf_bytes = certificateService.generate_pdf(
db_user_certificate.quiz_name,
db_user_certificate.user_name,
db_user_certificate.agency,
db_user_certificate.completion_date
)

filename = "SmartPayTraining.pdf"
elif (certType == 'gspc'):
certificate = certificateRepo.get_gspc_certificate_by_id(id)

verify_certificate_is_valid(certificate, user["id"])

pdf_bytes = certificateService.generate_gspc_pdf(
certificate.user_name,
certificate.agency,
certificate.completion_date,
certificate.certification_expiration_date
)

pdf_bytes = certificate.generate_pdf(
db_user_certificate.quiz_name,
db_user_certificate.user_name,
db_user_certificate.agency,
db_user_certificate.completion_date
)
headers = {'Content-Disposition': 'attachment; filename="SmartPayTraining.pdf"'}
filename = "GSA SmartPay Program Certification.pdf"
else:
# type not implemented
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

headers = {'Content-Disposition': f'attachment; filename="{filename}"'}
return Response(pdf_bytes, headers=headers, media_type='application/pdf')


def verify_certificate_is_valid(cert: object, user_id: int):
if cert is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

if cert.user_id != user_id:
raise HTTPException(status_code=401, detail="Not Authorized")
6 changes: 5 additions & 1 deletion training/api/deps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from collections.abc import Generator
from fastapi import Depends
from training.repositories import AgencyRepository, UserRepository, QuizRepository, CertificateRepository, GspcInviteRepository
from training.repositories import AgencyRepository, UserRepository, QuizRepository, CertificateRepository, GspcInviteRepository, GspcCompletionRepository
from training.services import QuizService, GspcService
from training.database import SessionLocal
from sqlalchemy.orm import Session
Expand Down Expand Up @@ -45,5 +45,9 @@ def gspc_invite_repository(db: Session = Depends(db)) -> GspcInviteRepository:
return GspcInviteRepository(db)


def gspc_completion_repository(db: Session = Depends(db)) -> GspcCompletionRepository:
return GspcCompletionRepository(db)


def gspc_service(db: Session = Depends(db)) -> GspcService:
return GspcService(db)
7 changes: 5 additions & 2 deletions training/api/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p>
If you did not submit this request, you may be receiving this message in error.
Please disregard this email. If you have any questions or need further assistance,
email us at [email protected].
email us at <a href="mailto:$mailto">$mailto</a>.
</p>
<p>Thank you.</p>
''')
Expand Down Expand Up @@ -52,6 +52,8 @@
# Todo move email function from quiz.py and turn this into a service so that it can be mocked
def send_email(to_email: EmailStr, name: str, link: str, training_title: str) -> None:
# Todo clean this up
mailto = "[email protected]"

if training_title and "certificate" in training_title.lower():
subject = "GSA SmartPay® training certificate(s)"
email_subject = "Access your GSA SmartPay training certificate(s)"
Expand All @@ -61,11 +63,12 @@ def send_email(to_email: EmailStr, name: str, link: str, training_title: str) ->
elif training_title and "gspc_registration" in training_title.lower():
subject = "GSA SmartPay® GSPC Registration form"
email_subject = "Access to GSA SmartPay GSPC Registration"
mailto = "[email protected]"
else:
subject = f"GSA SmartPay® {training_title} quiz"
email_subject = f"Access GSA SmartPay {training_title} quiz"

body = EMAIL_TEMPLATE.substitute({"name": name, "link": link, "subject": subject})
body = EMAIL_TEMPLATE.substitute({"name": name, "link": link, "subject": subject, "mailto": mailto})
message = EmailMessage()
message.set_content(body, subtype="html")
message["Subject"] = email_subject
Expand Down
1 change: 1 addition & 0 deletions training/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
from .user_x_role import UserXRole
from .report_user_x_agency import ReportUserXAgency
from .gspc_invite import GspcInvite
from .gspc_completion import GspcCompletion
16 changes: 16 additions & 0 deletions training/models/gspc_completion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import Any
from datetime import datetime
from training.models import Base
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Column, Date, ForeignKey, func


class GspcCompletion(Base):
__tablename__ = "gspc_completions"

id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
passed: Mapped[bool] = mapped_column()
certification_expiration_date = Column(Date(), nullable=False)
submit_ts: Mapped[datetime] = mapped_column(server_default=func.now())
responses: Mapped[dict[str, Any]] = mapped_column()
1 change: 1 addition & 0 deletions training/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from .certificate import CertificateRepository
from .role import RoleRepository
from .gspc_invite import GspcInviteRepository
from .gspc_completion import GspcCompletionRepository
14 changes: 13 additions & 1 deletion training/repositories/certificate.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from sqlalchemy.orm import Session
from training import models
from training.schemas.user_certificate import UserCertificate
from training.schemas import UserCertificate, GspcCertificate
from .base import BaseRepository


Expand Down Expand Up @@ -33,3 +33,15 @@ def get_certificates_by_userid(self, user_id: int) -> list[UserCertificate]:
.join(models.Quiz, models.QuizCompletion.quiz_id == models.Quiz.id)
.filter(models.QuizCompletion.passed, models.User.id == user_id).all())
return results

def get_gspc_certificate_by_id(self, id: int) -> GspcCertificate | None:

result = (self._session.query(models.GspcCompletion.id.label("id"), models.User.id.label("user_id"),
models.User.name.label("user_name"), models.Agency.name.label("agency"),
models.GspcCompletion.submit_ts.label("completion_date"),
models.GspcCompletion.certification_expiration_date.label("certification_expiration_date"))
.join(models.User, models.GspcCompletion.user_id == models.User.id)
.join(models.Agency, models.User.agency_id == models.Agency.id)
.filter(models.GspcCompletion.passed, models.GspcCompletion.id == id)
.first())
return result
17 changes: 17 additions & 0 deletions training/repositories/gspc_completion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from sqlalchemy.orm import Session
from training import models, schemas
from .base import BaseRepository


class GspcCompletionRepository(BaseRepository[models.GspcCompletion]):

def __init__(self, session: Session):
super().__init__(session, models.GspcCompletion)

def create(self, gspc_completion: schemas.GspcCompletion) -> models.GspcCompletion:
return self.save(models.GspcCompletion(
user_id=gspc_completion.user_id,
passed=gspc_completion.passed,
certification_expiration_date=gspc_completion.certification_expiration_date,
responses=gspc_completion.responses
))
Loading

0 comments on commit c5c878c

Please sign in to comment.