├── .prettierignore
├── phpstan.neon
├── sql
└── tables
│ ├── InquisitionResponse.sql
│ ├── QuizReport.sql
│ ├── CMEFrontMatterProviderBinding.sql
│ ├── EvaluationReport.sql
│ ├── CMEProvider.sql
│ ├── AccountCMEProgressCreditBinding.sql
│ ├── AccountCMEProgress.sql
│ ├── AccountAttestedCMEFrontMatter.sql
│ ├── CMECredit.sql
│ ├── CMEFrontMatter.sql
│ └── AccountEarnedCMECredit.sql
├── phpstan.dist.neon
├── prettier.config.js
├── www
├── admin
│ ├── styles
│ │ └── cme-front-matter-edit.css
│ └── inquisition-edit.css
└── javascript
│ ├── cme-certificate-page.js
│ ├── cme-front-matter-display.js
│ └── cme-evaluation-page.js
├── CME
├── dataobjects
│ ├── CMEEvaluation.php
│ ├── CMEAccountCMEProgressWrapper.php
│ ├── CMEAccountEarnedCMECreditWrapper.php
│ ├── CMECreditWrapper.php
│ ├── CMEQuizWrapper.php
│ ├── CMEProviderWrapper.php
│ ├── CMEQuizReportWrapper.php
│ ├── CMEEvaluationWrapper.php
│ ├── CMEEvaluationReportWrapper.php
│ ├── CMEQuizResponseWrapper.php
│ ├── CMEEvaluationResponseWrapper.php
│ ├── CMEAccountEarnedCMECredit.php
│ ├── CMEAccountCMEProgress.php
│ ├── CMEEvaluationResponse.php
│ ├── CMEQuiz.php
│ ├── CMEQuizReport.php
│ ├── CMEEvaluationReport.php
│ ├── CMEProvider.php
│ ├── CMEQuizResponse.php
│ ├── CMECredit.php
│ ├── CMEFrontMatter.php
│ └── CMEFrontMatterWrapper.php
├── pages
│ ├── cme-evaluation.xml
│ ├── cme-certificate.xml
│ ├── cme-quiz.xml
│ ├── CMEFrontMatterAttestationServerPage.php
│ ├── CMECertificatePage.php
│ └── CMEQuizResponseServer.php
├── admin
│ ├── components
│ │ ├── Credit
│ │ │ ├── details-credit-fields.xml
│ │ │ ├── edit.xml
│ │ │ ├── Delete.php
│ │ │ ├── Details.php
│ │ │ └── Edit.php
│ │ ├── Question
│ │ │ ├── Import.php
│ │ │ ├── Delete.php
│ │ │ ├── Order.php
│ │ │ ├── Add.php
│ │ │ ├── HintDelete.php
│ │ │ ├── ImageDelete.php
│ │ │ ├── Edit.php
│ │ │ ├── HintEdit.php
│ │ │ ├── HintOrder.php
│ │ │ ├── ImageOrder.php
│ │ │ ├── ImageUpload.php
│ │ │ ├── CorrectOption.php
│ │ │ ├── Details.php
│ │ │ └── include
│ │ │ │ └── CMEQuestionHelper.php
│ │ ├── QuizReport
│ │ │ ├── index.xml
│ │ │ ├── Download.php
│ │ │ └── Index.php
│ │ ├── EvaluationReport
│ │ │ ├── index.xml
│ │ │ ├── Download.php
│ │ │ └── Index.php
│ │ ├── Option
│ │ │ ├── Order.php
│ │ │ ├── Details.php
│ │ │ ├── Delete.php
│ │ │ ├── ImageOrder.php
│ │ │ ├── ImageUpload.php
│ │ │ ├── ImageDelete.php
│ │ │ ├── Edit.php
│ │ │ └── include
│ │ │ │ └── CMEOptionHelper.php
│ │ ├── Evaluation
│ │ │ └── Details.php
│ │ └── FrontMatter
│ │ │ ├── Delete.php
│ │ │ └── edit.xml
│ └── CMEQuizResponseResetDependency.php
├── CMEQuizReportUpdater.php
├── CMEEvaluationReportUpdater.php
├── certificates
│ └── CMECertificate.php
├── CME.php
├── CMEFrontMatterCompleteMailMessage.php
├── CMEFrontMatterDisplay.php
├── CMECertificateFactory.php
└── CMEReportUpdater.php
├── .gitignore
├── .editorconfig
├── README.md
├── package.json
├── pnpm-lock.yaml
├── dependencies
└── cme.yaml
├── .github
├── pull_request_template.md
└── workflows
│ └── pull-requests.yml
├── Jenkinsfile
├── LICENSE
├── phpstan-baseline.neon
├── composer.json
└── .php-cs-fixer.php
/.prettierignore:
--------------------------------------------------------------------------------
1 | /composer.lock
2 | /pnpm-lock.yaml
3 | /.pnpm-store
4 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - phpstan.dist.neon
3 |
4 | parameters:
5 | # reportUnmatchedIgnoredErrors: false
6 |
--------------------------------------------------------------------------------
/sql/tables/InquisitionResponse.sql:
--------------------------------------------------------------------------------
1 | alter table InquisitionResponse add account integer not null
2 | references Account(id) on delete cascade;
3 |
4 | alter table InquisitionResponse add reset_date timestamp;
5 |
--------------------------------------------------------------------------------
/phpstan.dist.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - phpstan-baseline.neon
3 |
4 | parameters:
5 | phpVersion: 80200
6 | level: 0
7 | paths:
8 | - CME
9 | editorUrl: '%%file%%:%%line%%'
10 | editorUrlTitle: '%%file%%:%%line%%'
11 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @see https://prettier.io/docs/en/configuration.html
3 | * @type {import("prettier").Config}
4 | */
5 | const config = {
6 | singleQuote: true,
7 | tabWidth: 2,
8 | trailingComma: 'none'
9 | };
10 |
11 | export default config;
12 |
--------------------------------------------------------------------------------
/sql/tables/QuizReport.sql:
--------------------------------------------------------------------------------
1 | create table QuizReport (
2 | id serial,
3 |
4 | provider integer not null references CMEProvider(id),
5 |
6 | filename varchar(255) not null,
7 | quarter timestamp not null,
8 | createdate timestamp not null,
9 |
10 | primary key (id)
11 | );
12 |
--------------------------------------------------------------------------------
/sql/tables/CMEFrontMatterProviderBinding.sql:
--------------------------------------------------------------------------------
1 | create table CMEFrontMatterProviderBinding (
2 | front_matter integer not null references CMEFrontMatter(id) on delete cascade,
3 | provider integer not null references CMEProvider(id) on delete cascade,
4 |
5 | primary key(front_matter, provider)
6 | );
7 |
--------------------------------------------------------------------------------
/sql/tables/EvaluationReport.sql:
--------------------------------------------------------------------------------
1 | create table EvaluationReport (
2 | id serial,
3 |
4 | provider integer not null references CMEProvider(id),
5 |
6 | filename varchar(255) not null,
7 | quarter timestamp not null,
8 | createdate timestamp not null,
9 |
10 | primary key (id)
11 | );
12 |
--------------------------------------------------------------------------------
/www/admin/styles/cme-front-matter-edit.css:
--------------------------------------------------------------------------------
1 | #email_help_text table {
2 | font-size: 11px;
3 | }
4 |
5 | #email_help_text th {
6 | text-align: right;
7 | padding-right: 4px;
8 | }
9 |
10 | #email_help_text td {
11 | padding-right: 20px;
12 | }
13 |
14 | textarea {
15 | width: 96%;
16 | }
17 |
--------------------------------------------------------------------------------
/sql/tables/CMEProvider.sql:
--------------------------------------------------------------------------------
1 | create table CMEProvider (
2 | id serial,
3 |
4 | shortname varchar(255) not null,
5 | title varchar(255) not null,
6 | credit_title varchar(255) not null,
7 | credit_title_plural varchar(255) not null,
8 | displayorder integer not null default 0,
9 |
10 | primary key (id)
11 | );
12 |
13 | create index CMEProvider_shortname_index on CMEProvider(shortname);
14 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEEvaluation.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEAccountCMEProgress::class);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/sql/tables/CMECredit.sql:
--------------------------------------------------------------------------------
1 | create table CMECredit (
2 | id serial,
3 |
4 | front_matter integer not null references CMEFrontMatter(id) on delete cascade,
5 | quiz integer not null references Inquisition(id) on delete cascade,
6 |
7 | hours numeric(5, 2) not null,
8 | displayorder integer not null default 0,
9 | is_free boolean not null default false,
10 | expiry_date timestamp not null,
11 |
12 | primary key (id)
13 | );
14 |
15 | create index CMECredit_front_matter_index on CMECredit(front_matter);
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CME
2 |
3 | Continuing Medical Education certification objects and utilities. The CME
4 | package is responsible for the following data objects:
5 |
6 | - CMECredit
7 | - CMEFrontMatter
8 | - CMEProvider
9 | - CMEEvaluation
10 | - CMEEvaluationReport
11 | - CMEQuiz
12 | - CMEQuizReport
13 |
14 | ## Installation
15 |
16 | Make sure the silverorange composer repository is added to the `composer.json`
17 | for the project and then run:
18 |
19 | ```sh
20 | composer require silverorange/cme
21 | ```
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@silverorange/cme",
3 | "private": true,
4 | "engines": {
5 | "node": "^22.14"
6 | },
7 | "type": "module",
8 | "scripts": {
9 | "prettier": "prettier --check .",
10 | "prettier:write": "prettier --write ."
11 | },
12 | "devDependencies": {
13 | "prettier": "^3.5.3"
14 | },
15 | "packageManager": "pnpm@9.12.2+sha512.22721b3a11f81661ae1ec68ce1a7b879425a1ca5b991c975b074ac220b187ce56c708fe5db69f4c962c989452eee76c82877f4ee80f474cebd61ee13461b6228"
16 | }
17 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEAccountEarnedCMECreditWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEAccountEarnedCMECredit::class);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | devDependencies:
11 | prettier:
12 | specifier: ^3.5.3
13 | version: 3.6.0
14 |
15 | packages:
16 |
17 | prettier@3.6.0:
18 | resolution: {integrity: sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==}
19 | engines: {node: '>=14'}
20 | hasBin: true
21 |
22 | snapshots:
23 |
24 | prettier@3.6.0: {}
25 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMECreditWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMECredit::class);
17 | $this->index_field = 'id';
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEQuizWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEQuiz::class);
17 | $this->index_field = 'id';
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEProviderWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEProvider::class);
17 | $this->index_field = 'id';
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEQuizReportWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEQuizReport::class);
17 | $this->index_field = 'id';
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEEvaluationWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEEvaluation::class);
17 | $this->index_field = 'id';
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/dependencies/cme.yaml:
--------------------------------------------------------------------------------
1 | ##
2 | ## Static Web-resource dependencies for CME
3 | ##
4 | ## Copyright (c) 2011-2014 silverorange
5 | ##
6 |
7 | CME:
8 | Depends:
9 | - Inquisiton
10 |
11 | Provides:
12 | # Style-Sheets
13 | packages/cme/styles/cme-front-matter-display.css:
14 | admin/packages/cme/admin/styles/cme-credit-edit.css:
15 |
16 | # JavaScript
17 | packages/cme/javascript/cme-certificate-page.js:
18 | packages/cme/javascript/cme-evaluation-page.js:
19 | packages/cme/javascript/cme-front-matter-display.js:
20 | packages/cme/javascript/cme-quiz-page.js:
21 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEEvaluationReportWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEEvaluationReport::class);
17 | $this->index_field = 'id';
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/sql/tables/CMEFrontMatter.sql:
--------------------------------------------------------------------------------
1 | create table CMEFrontMatter (
2 | id serial,
3 |
4 | evaluation integer references Inquisition(id) on delete set null,
5 |
6 | enabled boolean not null default true,
7 | objectives text,
8 | planning_committee_no_disclosures text,
9 | planning_committee_with_disclosures text,
10 | support_staff_no_disclosures text,
11 | support_staff_with_disclosures text,
12 | release_date timestamp,
13 | review_date timestamp,
14 |
15 | passing_grade decimal(5, 2),
16 | email_content_pass text,
17 | email_content_fail text,
18 | resettable boolean not null default true,
19 |
20 | primary key(id)
21 | );
22 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEQuizResponseWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEQuizResponse::class);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEEvaluationResponseWrapper.php:
--------------------------------------------------------------------------------
1 | row_wrapper_class = SwatDBClassMap::get(CMEEvaluationResponse::class);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/sql/tables/AccountEarnedCMECredit.sql:
--------------------------------------------------------------------------------
1 | create table AccountEarnedCMECredit (
2 | id serial,
3 | account integer not null references Account(id) on delete cascade,
4 | credit integer not null references CMECredit(id) on delete cascade,
5 | earned_date timestamp not null,
6 | primary key (id)
7 | );
8 |
9 | create index AccountEarnedCMECredit_account_index
10 | on AccountEarnedCMECredit(account);
11 |
12 | create index AccountEarnedCMECredit_credit_index
13 | on AccountEarnedCMECredit(credit);
14 |
15 | create index AccountEarnedCMECredit_earned_date_index
16 | on AccountEarnedCMECredit(earned_date);
17 |
18 | create index AccountEarnedCMECredit_earned_date_los_angeles_index
19 | on AccountEarnedCMECredit(convertTZ(earned_date, 'America/Los_Angeles'));
20 |
--------------------------------------------------------------------------------
/CME/pages/cme-evaluation.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/CME/admin/components/Credit/details-credit-fields.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 | Expiry Date
13 |
14 | SwatDate::DF_DATE
15 | expiry_date
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/Import.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
21 | $this->helper->initInternal();
22 | }
23 |
24 | protected function getQuestionHelper()
25 | {
26 | return new CMEQuestionHelper($this->app, $this->inquisition);
27 | }
28 |
29 | // build phase
30 |
31 | protected function buildNavBar()
32 | {
33 | parent::buildNavBar();
34 |
35 | $this->helper->buildNavBar($this->navbar);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEAccountEarnedCMECredit.php:
--------------------------------------------------------------------------------
1 | table = 'AccountEarnedCMECredit';
27 | $this->id_field = 'integer:id';
28 |
29 | $this->registerInternalProperty(
30 | 'account',
31 | SwatDBClassMap::get(CMEAccount::class)
32 | );
33 |
34 | $this->registerInternalProperty(
35 | 'credit',
36 | SwatDBClassMap::get(CMECredit::class)
37 | );
38 |
39 | $this->registerDateProperty('earned_date');
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Description
2 |
3 | Add a description of new changes, the reason for new changes, and how the new
4 | changes work here.
5 |
6 | # Testing Instructions (optional)
7 |
8 | Add step-by-step instructions for testing the PR, if necessary.
9 |
10 | 1. Check out this PR
11 | 2. …
12 |
13 | # Developer Checklist
14 |
15 | Before requesting review for this PR, make sure the following tasks are
16 | complete:
17 |
18 | - [ ] I added a link to the relevant Shortcut story, if applicable
19 | - [ ] I added testing instructions, if any
20 | - [ ] I made sure existing CI checks pass
21 | - [ ] I checked that all requirements of the ticket are fulfilled
22 |
23 | # Reviewer Checklist
24 |
25 | Before merging this PR, make sure the following tasks are complete:
26 |
27 | - [ ] I made sure there are no active labels that block merge
28 | - [ ] I followed the testing instructions
29 | - [ ] I made sure the CI checks pass
30 | - [ ] I reviewed the file changes on GitHub
31 | - [ ] I checked that all requirements of the ticket (if any) are fulfilled
32 |
--------------------------------------------------------------------------------
/CME/admin/components/QuizReport/index.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Quiz Reports
7 |
8 |
9 |
10 | year
11 |
12 | date
13 | yyyy
14 |
15 |
16 |
17 | Date
18 |
19 | quarter_title
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/CME/admin/components/EvaluationReport/index.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Evaluation Reports
7 |
8 |
9 |
10 | year
11 |
12 | date
13 | yyyy
14 |
15 |
16 |
17 | Date
18 |
19 | quarter_title
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEAccountCMEProgress.php:
--------------------------------------------------------------------------------
1 | table = 'AccountCMEProgress';
23 | $this->id_field = 'integer:id';
24 |
25 | $this->registerInternalProperty(
26 | 'account',
27 | SwatDBClassMap::get(CMEAccount::class)
28 | );
29 |
30 | $this->registerInternalProperty(
31 | 'quiz',
32 | SwatDBClassMap::get(CMEQuiz::class)
33 | );
34 |
35 | $this->registerInternalProperty(
36 | 'evaluation',
37 | SwatDBClassMap::get(CMEEvaluation::class)
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/CME/CMEQuizReportUpdater.php:
--------------------------------------------------------------------------------
1 | db,
18 | 'select * from QuizReport order by quarter',
19 | SwatDBClassMap::get(CMEQuizReportWrapper::class)
20 | );
21 | }
22 |
23 | protected function getReportClassName()
24 | {
25 | return SwatDBClassMap::get(CMEQuizReport::class);
26 | }
27 |
28 | protected function getReportGenerator(
29 | CMEProvider $provider,
30 | $year,
31 | $quarter
32 | ) {
33 | return new CMEQuizReportGenerator(
34 | $this,
35 | $provider,
36 | $year,
37 | $quarter
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEEvaluationResponse.php:
--------------------------------------------------------------------------------
1 | checkDB();
16 |
17 | $inquisition_id = $this->getInternalValue('inquisition');
18 |
19 | $sql = sprintf(
20 | 'select * from CMEFrontMatter where evaluation = %s',
21 | $this->db->quote($inquisition_id, 'integer')
22 | );
23 |
24 | return SwatDB::query(
25 | $this->db,
26 | $sql,
27 | SwatDBClassMap::get(CMEFrontMatterWrapper::class)
28 | )->getFirst();
29 | }
30 |
31 | protected function init()
32 | {
33 | parent::init();
34 | $this->registerInternalProperty(
35 | 'account',
36 | SwatDBClassMap::get(CMEAccount::class)
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/CME/pages/cme-certificate.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | text/xml
9 |
10 |
11 |
12 |
13 |
14 |
19 |
20 |
21 |
22 | text/xml
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | pipeline {
2 | agent any
3 | stages {
4 | stage('Install Composer Dependencies') {
5 | steps {
6 | sh 'rm -rf composer.lock vendor/'
7 | sh 'composer install'
8 | }
9 | }
10 |
11 | stage('Install NPM Dependencies') {
12 | environment {
13 | PNPM_CACHE_FOLDER = "${env.WORKSPACE}/pnpm-cache/${env.BUILD_NUMBER}"
14 | }
15 | steps {
16 | sh 'n -d exec engine corepack enable pnpm'
17 | sh 'n -d exec engine pnpm install'
18 | }
19 | }
20 |
21 |
22 | stage('Check PHP Coding Style') {
23 | steps {
24 | sh 'composer run phpcs:ci'
25 | }
26 | }
27 |
28 | stage('Check PHP Static Analysis') {
29 | steps {
30 | sh 'composer run phpstan:ci'
31 | }
32 | }
33 |
34 | stage('Check Formating') {
35 | steps {
36 | sh 'n -d exec engine pnpm prettier'
37 | }
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/CME/admin/components/Option/Order.php:
--------------------------------------------------------------------------------
1 | helper = $this->getOptionHelper();
21 | $this->helper->initInternal();
22 | }
23 |
24 | protected function getOptionHelper()
25 | {
26 | $question_helper = new CMEQuestionHelper(
27 | $this->app,
28 | $this->inquisition
29 | );
30 |
31 | return new CMEOptionHelper(
32 | $this->app,
33 | $question_helper,
34 | $this->question
35 | );
36 | }
37 |
38 | // build phase
39 |
40 | protected function buildNavBar()
41 | {
42 | parent::buildNavBar();
43 |
44 | $this->helper->buildNavBar($this->navbar);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/CME/admin/components/Option/Details.php:
--------------------------------------------------------------------------------
1 | helper = $this->getOptionHelper();
21 | $this->helper->initInternal();
22 | }
23 |
24 | protected function getOptionHelper()
25 | {
26 | $question_helper = new CMEQuestionHelper(
27 | $this->app,
28 | $this->inquisition
29 | );
30 |
31 | return new CMEOptionHelper(
32 | $this->app,
33 | $question_helper,
34 | $this->question
35 | );
36 | }
37 |
38 | // build phase
39 |
40 | protected function buildNavBar()
41 | {
42 | parent::buildNavBar();
43 |
44 | $this->helper->buildNavBar($this->navbar);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2011-2014 silverorange
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/CME/admin/components/Option/Delete.php:
--------------------------------------------------------------------------------
1 | helper = $this->getOptionHelper();
21 | $this->helper->initInternal();
22 | }
23 |
24 | protected function getOptionHelper()
25 | {
26 | $question_helper = new CMEQuestionHelper(
27 | $this->app,
28 | $this->inquisition
29 | );
30 |
31 | return new CMEOptionHelper(
32 | $this->app,
33 | $question_helper,
34 | $this->question
35 | );
36 | }
37 |
38 | // build phase
39 |
40 | protected function buildNavBar()
41 | {
42 | parent::buildNavBar();
43 |
44 | $this->helper->buildNavBar($this->navbar);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEQuiz.php:
--------------------------------------------------------------------------------
1 | checkDB();
16 |
17 | $sql = sprintf(
18 | 'select * from InquisitionResponse
19 | where account = %s and inquisition = %s and reset_date is null',
20 | $this->db->quote($account->id, 'integer'),
21 | $this->db->quote($this->id, 'integer')
22 | );
23 |
24 | $wrapper = $this->getResolvedResponseWrapperClass();
25 | $response = SwatDB::query($this->db, $sql, $wrapper)->getFirst();
26 |
27 | if ($response instanceof CMEQuizResponse) {
28 | $response->inquisition = $this;
29 | }
30 |
31 | return $response;
32 | }
33 |
34 | protected function getResponseWrapperClass()
35 | {
36 | return CMEQuizResponseWrapper::class;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/Delete.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
23 | $this->helper->initInternal();
24 | }
25 |
26 | protected function getQuestionHelper()
27 | {
28 | return new CMEQuestionHelper($this->app, $this->inquisition);
29 | }
30 |
31 | // process phase
32 |
33 | protected function relocate()
34 | {
35 | $uri = $this->helper->getRelocateURI();
36 |
37 | if ($uri == '') {
38 | parent::relocate();
39 | } else {
40 | $this->app->relocate($uri);
41 | }
42 | }
43 |
44 | // build phase
45 |
46 | protected function buildNavBar()
47 | {
48 | parent::buildNavBar();
49 |
50 | $this->helper->buildNavBar($this->navbar);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/CME/admin/components/Option/ImageOrder.php:
--------------------------------------------------------------------------------
1 | helper = $this->getOptionHelper();
21 | $this->helper->initInternal();
22 | }
23 |
24 | protected function getOptionHelper()
25 | {
26 | $question_helper = new CMEQuestionHelper(
27 | $this->app,
28 | $this->inquisition
29 | );
30 |
31 | return new CMEOptionHelper(
32 | $this->app,
33 | $question_helper,
34 | $this->question
35 | );
36 | }
37 |
38 | // build phase
39 |
40 | protected function buildNavBar()
41 | {
42 | parent::buildNavBar();
43 |
44 | // put edit entry at the end
45 | $title = $this->navbar->popEntry();
46 |
47 | $this->helper->buildNavBar($this->navbar);
48 |
49 | $this->navbar->addEntry($title);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/CME/admin/components/Option/ImageUpload.php:
--------------------------------------------------------------------------------
1 | helper = $this->getOptionHelper();
21 | $this->helper->initInternal();
22 | }
23 |
24 | protected function getOptionHelper()
25 | {
26 | $question_helper = new CMEQuestionHelper(
27 | $this->app,
28 | $this->inquisition
29 | );
30 |
31 | return new CMEOptionHelper(
32 | $this->app,
33 | $question_helper,
34 | $this->question
35 | );
36 | }
37 |
38 | // build phase
39 |
40 | protected function buildNavBar()
41 | {
42 | parent::buildNavBar();
43 |
44 | // put edit entry at the end
45 | $title = $this->navbar->popEntry();
46 |
47 | $this->helper->buildNavBar($this->navbar);
48 |
49 | $this->navbar->addEntry($title);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/phpstan-baseline.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | ignoreErrors:
3 | -
4 | message: "#^Access to an undefined property CMEOptionDetails\\:\\:\\$question\\.$#"
5 | count: 1
6 | path: CME/admin/components/Option/Details.php
7 |
8 | -
9 | message: "#^Access to an undefined property CMEOptionImageDelete\\:\\:\\$question\\.$#"
10 | count: 1
11 | path: CME/admin/components/Option/ImageDelete.php
12 |
13 | -
14 | message: "#^Access to an undefined property CMEOptionImageOrder\\:\\:\\$question\\.$#"
15 | count: 1
16 | path: CME/admin/components/Option/ImageOrder.php
17 |
18 | -
19 | message: "#^Access to an undefined property CMEOptionImageUpload\\:\\:\\$question\\.$#"
20 | count: 1
21 | path: CME/admin/components/Option/ImageUpload.php
22 |
23 | -
24 | message: "#^Access to an undefined property CMEOptionHelper\\:\\:\\$credit\\.$#"
25 | count: 1
26 | path: CME/admin/components/Option/include/CMEOptionHelper.php
27 |
28 | -
29 | message: "#^Access to an undefined property CMEOptionHelper\\:\\:\\$inquisition\\.$#"
30 | count: 1
31 | path: CME/admin/components/Option/include/CMEOptionHelper.php
32 |
33 | -
34 | message: "#^Access to an undefined property CMEQuestionImport\\:\\:\\$inquisition\\.$#"
35 | count: 1
36 | path: CME/admin/components/Question/Import.php
37 |
--------------------------------------------------------------------------------
/CME/CMEEvaluationReportUpdater.php:
--------------------------------------------------------------------------------
1 | db,
18 | 'select * from EvaluationReport order by quarter',
19 | SwatDBClassMap::get(CMEEvaluationReportWrapper::class)
20 | );
21 | }
22 |
23 | protected function getReportClassName()
24 | {
25 | return SwatDBClassMap::get(CMEEvaluationReport::class);
26 | }
27 |
28 | protected function getReportGenerator(
29 | CMEProvider $provider,
30 | $year,
31 | $quarter
32 | ) {
33 | $generator_class_name = $this->getReportGeneratorClassName();
34 |
35 | return new $generator_class_name(
36 | $this,
37 | $provider,
38 | $year,
39 | $quarter
40 | );
41 | }
42 |
43 | protected function getReportGeneratorClassName()
44 | {
45 | return CMEEvaluationReportGenerator::class;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/CME/admin/CMEQuizResponseResetDependency.php:
--------------------------------------------------------------------------------
1 | getTitle($count)
20 | );
21 | break;
22 |
23 | case self::NODELETE:
24 | $message = sprintf(
25 | CME::ngettext(
26 | 'The following %s can not be reset:',
27 | 'The following %s can not be reset:',
28 | $count
29 | ),
30 | $this->getTitle($count)
31 | );
32 | break;
33 |
34 | default:
35 | $message = parent::getStatusLevelText($status_level, $count);
36 | break;
37 | }
38 |
39 | return $message;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/CME/admin/components/Option/ImageDelete.php:
--------------------------------------------------------------------------------
1 | helper = $this->getOptionHelper();
23 | $this->helper->initInternal();
24 | }
25 |
26 | protected function getOptionHelper()
27 | {
28 | $question_helper = new CMEQuestionHelper(
29 | $this->app,
30 | $this->inquisition
31 | );
32 |
33 | return new CMEOptionHelper(
34 | $this->app,
35 | $question_helper,
36 | $this->question
37 | );
38 | }
39 |
40 | // build phase
41 |
42 | protected function buildNavBar()
43 | {
44 | parent::buildNavBar();
45 |
46 | // put edit entry at the end
47 | $title = $this->navbar->popEntry();
48 |
49 | $this->helper->buildNavBar($this->navbar);
50 |
51 | $this->navbar->addEntry($title);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/Order.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
21 | $this->helper->initInternal();
22 | }
23 |
24 | protected function getQuestionHelper()
25 | {
26 | return new CMEQuestionHelper($this->app, $this->inquisition);
27 | }
28 |
29 | // process phase
30 |
31 | protected function relocate()
32 | {
33 | $uri = $this->helper->getRelocateURI();
34 |
35 | if ($uri == '') {
36 | parent::relocate();
37 | } else {
38 | $this->app->relocate($uri);
39 | }
40 | }
41 |
42 | // build phase
43 |
44 | protected function buildForm()
45 | {
46 | parent::buildForm();
47 |
48 | $form = $this->ui->getWidget('order_form');
49 | $form->addHiddenField('inquisition', $this->inquisition->id);
50 | }
51 |
52 | protected function buildNavBar()
53 | {
54 | parent::buildNavBar();
55 |
56 | $this->helper->buildNavBar($this->navbar);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/Add.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper($this->inquisition);
23 | $this->helper->initInternal();
24 |
25 | // for evaluations, hide correct option column
26 | if ($this->helper->isEvaluation()) {
27 | $view = $this->ui->getWidget('question_option_table_view');
28 | $correct_column = $view->getColumn('correct_option');
29 | $correct_column->visible = false;
30 | }
31 | }
32 |
33 | protected function getQuestionHelper()
34 | {
35 | return new CMEQuestionHelper($this->app, $this->inquisition);
36 | }
37 |
38 | // process phase
39 |
40 | protected function relocate()
41 | {
42 | $uri = $this->helper->getRelocateURI();
43 |
44 | if ($uri == '') {
45 | parent::relocate();
46 | } else {
47 | $this->app->relocate($uri);
48 | }
49 | }
50 |
51 | // build phase
52 |
53 | protected function buildNavBar()
54 | {
55 | parent::buildNavBar();
56 |
57 | $this->helper->buildNavBar($this->navbar);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/CME/admin/components/Credit/edit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | CME Credit
7 |
8 |
9 | false
10 |
11 |
12 |
13 | Questions File
14 |
15 | text/csv
16 | text/plain
17 |
18 |
19 |
20 | Credit Hours
21 |
22 | true
23 |
24 |
25 |
26 | Expiry Date
27 |
28 | true
29 |
30 |
31 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/CME/admin/components/Option/Edit.php:
--------------------------------------------------------------------------------
1 | helper = $this->getOptionHelper();
21 | $this->helper->initInternal();
22 | }
23 |
24 | protected function getOptionHelper()
25 | {
26 | $question_helper = new CMEQuestionHelper(
27 | $this->app,
28 | $this->inquisition
29 | );
30 |
31 | return new CMEOptionHelper(
32 | $this->app,
33 | $question_helper,
34 | $this->question
35 | );
36 | }
37 |
38 | // build phase
39 |
40 | protected function buildNavBar()
41 | {
42 | parent::buildNavBar();
43 |
44 | // put add/edit title entry at the end
45 | $title = $this->navbar->popEntry();
46 |
47 | // Add dummy entry. The CMEOptionHelper will remove this. All other
48 | // option admin components have a details component in the nav bar.
49 | if ($this->isNew()) {
50 | $this->navbar->createEntry('');
51 | }
52 |
53 | $this->helper->buildNavBar($this->navbar);
54 |
55 | // remove dummy entry.
56 | if ($this->isNew()) {
57 | $this->navbar->popEntry();
58 | }
59 |
60 | $this->navbar->addEntry($title);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEQuizReport.php:
--------------------------------------------------------------------------------
1 | file_base = $file_base;
36 | }
37 |
38 | public function getFileDirectory()
39 | {
40 | $path = [
41 | $this->file_base,
42 | 'reports',
43 | ];
44 |
45 | return implode(DIRECTORY_SEPARATOR, $path);
46 | }
47 |
48 | public function getFilePath()
49 | {
50 | $path = [
51 | $this->getFileDirectory(),
52 | $this->filename,
53 | ];
54 |
55 | return implode(DIRECTORY_SEPARATOR, $path);
56 | }
57 |
58 | protected function init()
59 | {
60 | parent::init();
61 |
62 | $this->table = 'QuizReport';
63 | $this->id_field = 'integer:id';
64 |
65 | $this->registerInternalProperty(
66 | 'provider',
67 | SwatDBClassMap::get(CMEProvider::class)
68 | );
69 |
70 | $this->registerDateProperty('quarter');
71 | $this->registerDateProperty('createdate');
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEEvaluationReport.php:
--------------------------------------------------------------------------------
1 | file_base = $file_base;
36 | }
37 |
38 | public function getFileDirectory()
39 | {
40 | $path = [
41 | $this->file_base,
42 | 'reports',
43 | ];
44 |
45 | return implode(DIRECTORY_SEPARATOR, $path);
46 | }
47 |
48 | public function getFilePath()
49 | {
50 | $path = [
51 | $this->getFileDirectory(),
52 | $this->filename,
53 | ];
54 |
55 | return implode(DIRECTORY_SEPARATOR, $path);
56 | }
57 |
58 | protected function init()
59 | {
60 | parent::init();
61 |
62 | $this->table = 'EvaluationReport';
63 | $this->id_field = 'integer:id';
64 |
65 | $this->registerInternalProperty(
66 | 'provider',
67 | SwatDBClassMap::get(CMEProvider::class)
68 | );
69 |
70 | $this->registerDateProperty('quarter');
71 | $this->registerDateProperty('createdate');
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/www/javascript/cme-certificate-page.js:
--------------------------------------------------------------------------------
1 | YAHOO.util.Event.onDOMReady(function () {
2 | var checkbox_list_els = YAHOO.util.Dom.getElementsByClassName(
3 | 'swat-checkbox-list',
4 | 'div'
5 | );
6 |
7 | for (var i = 0; i < checkbox_list_els.length; i++) {
8 | var checkboxes = YAHOO.util.Dom.getElementsBy(
9 | function (el) {
10 | return el.type === 'checkbox';
11 | },
12 | 'input',
13 | checkbox_list_els[i]
14 | );
15 |
16 | if (checkboxes.length > 0) {
17 | for (var j = 0; j < checkboxes.length; j++) {
18 | (function () {
19 | var item = YAHOO.util.Dom.getAncestorByTagName(checkboxes[j], 'li');
20 | var checkbox = checkboxes[j];
21 |
22 | var the_checkboxes = checkboxes;
23 | YAHOO.util.Event.on(checkbox, 'click', function (e) {
24 | updateListSelection(the_checkboxes);
25 | });
26 |
27 | // passthrough click on list item to radio button
28 | YAHOO.util.Event.on(item, 'click', function (e) {
29 | var target = YAHOO.util.Event.getTarget(e);
30 | if (target === item) {
31 | checkbox.checked = !checkbox.checked;
32 | updateListSelection(the_checkboxes);
33 | }
34 | });
35 | })();
36 | }
37 |
38 | updateListSelection(checkboxes);
39 | }
40 | }
41 |
42 | function updateListSelection(list) {
43 | for (var i = 0; i < list.length; i++) {
44 | var li = YAHOO.util.Dom.getAncestorByTagName(list[i], 'li');
45 | if (list[i].checked) {
46 | YAHOO.util.Dom.addClass(li, 'selected');
47 | } else {
48 | YAHOO.util.Dom.removeClass(li, 'selected');
49 | }
50 | }
51 | }
52 | });
53 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/HintDelete.php:
--------------------------------------------------------------------------------
1 | inquisition instanceof InquisitionInquisition) {
23 | // if we got here from the question index, load the inquisition
24 | // from the binding as we only have one inquisition per question
25 | $sql = sprintf(
26 | 'select inquisition from InquisitionInquisitionQuestionBinding
27 | where question = %s',
28 | $this->app->db->quote($this->question->id)
29 | );
30 |
31 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
32 |
33 | $this->inquisition = $this->loadInquisition($inquisition_id);
34 | }
35 |
36 | $this->helper = $this->getQuestionHelper();
37 | $this->helper->initInternal();
38 | }
39 |
40 | protected function getQuestionHelper()
41 | {
42 | return new CMEQuestionHelper($this->app, $this->inquisition);
43 | }
44 |
45 | // build phase
46 |
47 | protected function buildNavBar()
48 | {
49 | parent::buildNavBar();
50 |
51 | // put edit entry at the end
52 | $title = $this->navbar->popEntry();
53 |
54 | $this->helper->buildNavBar($this->navbar);
55 |
56 | $this->navbar->addEntry($title);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/ImageDelete.php:
--------------------------------------------------------------------------------
1 | inquisition instanceof InquisitionInquisition) {
23 | // if we got here from the question index, load the inquisition
24 | // from the binding as we only have one inquisition per question
25 | $sql = sprintf(
26 | 'select inquisition from InquisitionInquisitionQuestionBinding
27 | where question = %s',
28 | $this->app->db->quote($this->question->id)
29 | );
30 |
31 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
32 |
33 | $this->inquisition = $this->loadInquisition($inquisition_id);
34 | }
35 |
36 | $this->helper = $this->getQuestionHelper();
37 | $this->helper->initInternal();
38 | }
39 |
40 | protected function getQuestionHelper()
41 | {
42 | return new CMEQuestionHelper($this->app, $this->inquisition);
43 | }
44 |
45 | // build phase
46 |
47 | protected function buildNavBar()
48 | {
49 | parent::buildNavBar();
50 |
51 | // put edit entry at the end
52 | $title = $this->navbar->popEntry();
53 |
54 | $this->helper->buildNavBar($this->navbar);
55 |
56 | $this->navbar->addEntry($title);
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/Edit.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
23 | $this->helper->initInternal();
24 | }
25 |
26 | protected function initInquisition()
27 | {
28 | parent::initInquisition();
29 |
30 | if (!$this->inquisition instanceof InquisitionInquisition) {
31 | // if we got here from the question index, load the inquisition
32 | // from the binding as we only have one inquisition per question
33 | $sql = sprintf(
34 | 'select inquisition from InquisitionInquisitionQuestionBinding
35 | where question = %s',
36 | $this->app->db->quote($this->question->id)
37 | );
38 |
39 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
40 |
41 | $this->inquisition = $this->loadInquisition($inquisition_id);
42 | }
43 | }
44 |
45 | protected function getQuestionHelper()
46 | {
47 | return new CMEQuestionHelper($this->app, $this->inquisition);
48 | }
49 |
50 | // build phase
51 |
52 | protected function buildNavBar()
53 | {
54 | parent::buildNavBar();
55 |
56 | // put edit entry at the end
57 | $title = $this->navbar->popEntry();
58 |
59 | $this->helper->buildNavBar($this->navbar);
60 |
61 | $this->navbar->addEntry($title);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/HintEdit.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
23 | $this->helper->initInternal();
24 | }
25 |
26 | protected function initInquisition()
27 | {
28 | parent::initInquisition();
29 |
30 | if (!$this->inquisition instanceof InquisitionInquisition) {
31 | // if we got here from the question index, load the inquisition
32 | // from the binding as we only have one inquisition per question
33 | $sql = sprintf(
34 | 'select inquisition from InquisitionInquisitionQuestionBinding
35 | where question = %s',
36 | $this->app->db->quote($this->question->id)
37 | );
38 |
39 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
40 |
41 | $this->inquisition = $this->loadInquisition($inquisition_id);
42 | }
43 | }
44 |
45 | protected function getQuestionHelper()
46 | {
47 | return new CMEQuestionHelper($this->app, $this->inquisition);
48 | }
49 |
50 | // build phase
51 |
52 | protected function buildNavBar()
53 | {
54 | parent::buildNavBar();
55 |
56 | // put edit entry at the end
57 | $title = $this->navbar->popEntry();
58 |
59 | $this->helper->buildNavBar($this->navbar);
60 |
61 | $this->navbar->addEntry($title);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/HintOrder.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
23 | $this->helper->initInternal();
24 | }
25 |
26 | protected function initInquisition()
27 | {
28 | parent::initInquisition();
29 |
30 | if (!$this->inquisition instanceof InquisitionInquisition) {
31 | // if we got here from the question index, load the inquisition
32 | // from the binding as we only have one inquisition per question
33 | $sql = sprintf(
34 | 'select inquisition from InquisitionInquisitionQuestionBinding
35 | where question = %s',
36 | $this->app->db->quote($this->question->id)
37 | );
38 |
39 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
40 |
41 | $this->inquisition = $this->loadInquisition($inquisition_id);
42 | }
43 | }
44 |
45 | protected function getQuestionHelper()
46 | {
47 | return new CMEQuestionHelper($this->app, $this->inquisition);
48 | }
49 |
50 | // build phase
51 |
52 | protected function buildNavBar()
53 | {
54 | parent::buildNavBar();
55 |
56 | // put edit entry at the end
57 | $title = $this->navbar->popEntry();
58 |
59 | $this->helper->buildNavBar($this->navbar);
60 |
61 | $this->navbar->addEntry($title);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/ImageOrder.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
23 | $this->helper->initInternal();
24 | }
25 |
26 | protected function initInquisition()
27 | {
28 | parent::initInquisition();
29 |
30 | if (!$this->inquisition instanceof InquisitionInquisition) {
31 | // if we got here from the question index, load the inquisition
32 | // from the binding as we only have one inquisition per question
33 | $sql = sprintf(
34 | 'select inquisition from InquisitionInquisitionQuestionBinding
35 | where question = %s',
36 | $this->app->db->quote($this->question->id)
37 | );
38 |
39 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
40 |
41 | $this->inquisition = $this->loadInquisition($inquisition_id);
42 | }
43 | }
44 |
45 | protected function getQuestionHelper()
46 | {
47 | return new CMEQuestionHelper($this->app, $this->inquisition);
48 | }
49 |
50 | // build phase
51 |
52 | protected function buildNavBar()
53 | {
54 | parent::buildNavBar();
55 |
56 | // put edit entry at the end
57 | $title = $this->navbar->popEntry();
58 |
59 | $this->helper->buildNavBar($this->navbar);
60 |
61 | $this->navbar->addEntry($title);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/ImageUpload.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
23 | $this->helper->initInternal();
24 | }
25 |
26 | protected function initInquisition()
27 | {
28 | parent::initInquisition();
29 |
30 | if (!$this->inquisition instanceof InquisitionInquisition) {
31 | // if we got here from the question index, load the inquisition
32 | // from the binding as we only have one inquisition per question
33 | $sql = sprintf(
34 | 'select inquisition from InquisitionInquisitionQuestionBinding
35 | where question = %s',
36 | $this->app->db->quote($this->question->id)
37 | );
38 |
39 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
40 |
41 | $this->inquisition = $this->loadInquisition($inquisition_id);
42 | }
43 | }
44 |
45 | protected function getQuestionHelper()
46 | {
47 | return new CMEQuestionHelper($this->app, $this->inquisition);
48 | }
49 |
50 | // build phase
51 |
52 | protected function buildNavBar()
53 | {
54 | parent::buildNavBar();
55 |
56 | // put edit entry at the end
57 | $title = $this->navbar->popEntry();
58 |
59 | $this->helper->buildNavBar($this->navbar);
60 |
61 | $this->navbar->addEntry($title);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/CorrectOption.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
23 | $this->helper->initInternal();
24 | }
25 |
26 | protected function initInquisition()
27 | {
28 | parent::initInquisition();
29 |
30 | if (!$this->inquisition instanceof InquisitionInquisition) {
31 | // if we got here from the question index, load the inquisition
32 | // from the binding as we only have one inquisition per question
33 | $sql = sprintf(
34 | 'select inquisition from InquisitionInquisitionQuestionBinding
35 | where question = %s',
36 | $this->app->db->quote($this->question->id)
37 | );
38 |
39 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
40 |
41 | $this->inquisition = $this->loadInquisition($inquisition_id);
42 | }
43 | }
44 |
45 | protected function getQuestionHelper()
46 | {
47 | return new CMEQuestionHelper($this->app, $this->inquisition);
48 | }
49 |
50 | // build phase
51 |
52 | protected function buildNavBar()
53 | {
54 | parent::buildNavBar();
55 |
56 | // put edit entry at the end
57 | $title = $this->navbar->popEntry();
58 |
59 | $this->helper->buildNavBar($this->navbar);
60 |
61 | $this->navbar->addEntry($title);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "silverorange/cme",
3 | "description": "Continuing Medical Education certification framework.",
4 | "type": "library",
5 | "keywords": [
6 | "cme",
7 | "medical",
8 | "education"
9 | ],
10 | "homepage": "https://github.com/silverorange/cme",
11 | "license": "MIT",
12 | "authors": [
13 | {
14 | "name": "Charles Waddell",
15 | "email": "charles@silverorange.com"
16 | },
17 | {
18 | "name": "Isaac Grant",
19 | "email": "isaac@silverorange.com"
20 | },
21 | {
22 | "name": "Michael Gauthier",
23 | "email": "mike@silverorange.com"
24 | },
25 | {
26 | "name": "Nick Burka",
27 | "email": "nick@silverorange.com"
28 | }
29 | ],
30 | "repositories": [
31 | {
32 | "type": "composer",
33 | "url": "https://composer.silverorange.com",
34 | "only": [
35 | "silverorange/*"
36 | ]
37 | }
38 | ],
39 | "require": {
40 | "php": ">=8.1.0",
41 | "ext-mbstring": "*",
42 | "dompdf/dompdf": "^2.0.0",
43 | "silverorange/admin": "^7.0.3",
44 | "silverorange/inquisition": "^4.4.2",
45 | "silverorange/site": "^15.3.2",
46 | "silverorange/store": "^10.2.2",
47 | "silverorange/swat": "^7.9.2"
48 | },
49 | "require-dev": {
50 | "friendsofphp/php-cs-fixer": "3.64.0",
51 | "phpstan/phpstan": "^1.12"
52 | },
53 | "scripts": {
54 | "phpcs": "./vendor/bin/php-cs-fixer check -v",
55 | "phpcs:ci": "./vendor/bin/php-cs-fixer check --config=.php-cs-fixer.php --no-interaction --show-progress=none --diff --using-cache=no -vvv",
56 | "phpcs:write": "./vendor/bin/php-cs-fixer fix -v",
57 | "phpstan": "./vendor/bin/phpstan analyze",
58 | "phpstan:ci": "./vendor/bin/phpstan analyze -vvv --no-progress --memory-limit 2G",
59 | "phpstan:baseline": "./vendor/bin/phpstan analyze --generate-baseline"
60 | },
61 | "autoload": {
62 | "classmap": [
63 | "CME/"
64 | ]
65 | },
66 | "config": {
67 | "sort-packages": true
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/.github/workflows/pull-requests.yml:
--------------------------------------------------------------------------------
1 | name: Pull Requests
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | runner:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Setup Tailscale
14 | uses: tailscale/github-action@v3
15 | with:
16 | oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
17 | oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
18 | tags: tag:silverorange-gh-actions
19 | version: latest
20 |
21 | - name: Check out repository code
22 | uses: actions/checkout@v4
23 |
24 | # Can maybe be replaced with pnpm/action-setup@v4 in the future
25 | - name: Setup pnpm/corepack
26 | uses: actions/setup-node@v4
27 | with:
28 | node-version-file: 'package.json'
29 | - run: npm i -g --force corepack && corepack enable
30 |
31 | - name: Setup Node
32 | uses: actions/setup-node@v4
33 | with:
34 | node-version-file: 'package.json'
35 | cache: 'pnpm'
36 |
37 | - name: Install dependencies
38 | run: pnpm install --frozen-lockfile
39 |
40 | - name: Setup PHP with tools
41 | uses: silverorange/actions-setup-php@v2
42 | with:
43 | php-version: '8.2'
44 | extensions: gd, imagick
45 |
46 | - name: Get composer cache directory
47 | id: composer-cache
48 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
49 |
50 | - name: Cache dependencies
51 | uses: actions/cache@v4
52 | with:
53 | path: ${{ steps.composer-cache.outputs.dir }}
54 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
55 | restore-keys: ${{ runner.os }}-composer-
56 |
57 | - name: Install PHP dependencies
58 | run: 'composer install'
59 |
60 | - name: Run tests
61 | timeout-minutes: 5
62 | run: |
63 | pnpm prettier
64 | composer run phpcs:ci
65 | composer run phpstan:ci
66 |
--------------------------------------------------------------------------------
/.php-cs-fixer.php:
--------------------------------------------------------------------------------
1 | in(__DIR__);
9 |
10 | return (new Config())
11 | ->setParallelConfig(ParallelConfigFactory::detect(null, null, 2**18-1))
12 | ->setRules([
13 | '@PhpCsFixer' => true,
14 | '@PHP82Migration' => true,
15 | 'indentation_type' => true,
16 |
17 | // Overrides for (opinionated) @PhpCsFixer and @Symfony rules:
18 |
19 | // Align "=>" in multi-line array definitions, unless a blank line exists between elements
20 | 'binary_operator_spaces' => ['operators' => ['=>' => 'align_single_space_minimal']],
21 |
22 | // Subset of statements that should be proceeded with blank line
23 | 'blank_line_before_statement' => ['statements' => ['case', 'continue', 'declare', 'default', 'return', 'throw', 'try', 'yield', 'yield_from']],
24 |
25 | // Enforce space around concatenation operator
26 | 'concat_space' => ['spacing' => 'one'],
27 |
28 | // Use {} for empty loop bodies
29 | 'empty_loop_body' => ['style' => 'braces'],
30 |
31 | // Don't change any increment/decrement styles
32 | 'increment_style' => false,
33 |
34 | // Forbid multi-line whitespace before the closing semicolon
35 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'],
36 |
37 | // Clean up PHPDocs, but leave @inheritDoc entries alone
38 | 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'remove_inheritdoc' => false],
39 |
40 | // Ensure that traits are listed first in classes
41 | // (it would be nice to enforce more, but we'll start simple)
42 | 'ordered_class_elements' => ['order' => ['use_trait']],
43 |
44 | // Ensure that param and return types are sorted consistently, with null at end
45 | 'phpdoc_types_order' => ['sort_algorithm' => 'alpha', 'null_adjustment' => 'always_last'],
46 |
47 | // Yoda style is too weird
48 | 'yoda_style' => false,
49 | ])
50 | ->setIndent(' ')
51 | ->setLineEnding("\n")
52 | ->setFinder($finder);
53 |
--------------------------------------------------------------------------------
/CME/pages/cme-quiz.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | false
6 |
7 |
8 |
9 | false
10 |
11 | Retake Quiz
12 | button
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | quiz-info
21 |
22 |
23 |
24 | quiz-container
25 |
26 | quiz-question-container
27 |
28 |
29 |
36 |
37 |
38 |
39 | quiz-keyboard-help
40 |
41 | text/xml
42 | ABCDEF choose answer, Enter or → next question]]>
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/CME/certificates/CMECertificate.php:
--------------------------------------------------------------------------------
1 | app = $app;
28 | }
29 |
30 | public function setAccount(CMEAccount $account)
31 | {
32 | $this->account = $account;
33 | }
34 |
35 | public function setEarnedCredits(CMEAccountEarnedCMECreditWrapper $credits)
36 | {
37 | $this->credits = $credits;
38 | }
39 |
40 | public function display()
41 | {
42 | if (!$this->visible) {
43 | return;
44 | }
45 |
46 | if (!$this->app instanceof SiteApplication) {
47 | throw new SwatException(
48 | 'Application must be set to display certificate.'
49 | );
50 | }
51 |
52 | if (!$this->account instanceof CMEAccount) {
53 | throw new SwatException(
54 | 'Account must be set to display certificate.'
55 | );
56 | }
57 |
58 | if (!$this->credits instanceof CMEAccountEarnedCMECreditWrapper) {
59 | throw new SwatException(
60 | 'Earned credits must be set to display certificate.'
61 | );
62 | }
63 |
64 | parent::display();
65 |
66 | $certificate_div = new SwatHtmlTag('div');
67 | $certificate_div->id = $this->id;
68 | $certificate_div->class = $this->getCSSClassString();
69 | $certificate_div->open();
70 |
71 | $this->displayCertificateContent();
72 |
73 | $certificate_div->close();
74 | }
75 |
76 | abstract protected function displayCertificateContent();
77 |
78 | abstract protected function isPhysician();
79 |
80 | protected function getCSSClassNames()
81 | {
82 | return array_merge(
83 | ['cme-certificate'],
84 | parent::getCSSClassNames()
85 | );
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEProvider.php:
--------------------------------------------------------------------------------
1 | checkDB();
42 |
43 | $row = null;
44 |
45 | if ($this->table !== null) {
46 | $sql = sprintf(
47 | 'select * from %s where shortname = %s',
48 | $this->table,
49 | $this->db->quote($shortname, 'text')
50 | );
51 |
52 | $rs = SwatDB::query($this->db, $sql, null);
53 | $row = $rs->fetchRow(MDB2_FETCHMODE_ASSOC);
54 | }
55 |
56 | if ($row === null) {
57 | return false;
58 | }
59 |
60 | $this->initFromRow($row);
61 | $this->generatePropertyHashes();
62 |
63 | return true;
64 | }
65 |
66 | public function getCreditTitle($hours, $credit_count = 1, $is_free = false)
67 | {
68 | $locale = SwatI18NLocale::get();
69 |
70 | return sprintf(
71 | SwatString::minimizeEntities(
72 | ($is_free)
73 | ? CME::_('%s Free %s%s%s certified by %s')
74 | : CME::_('%s %s%s%s certified by %s')
75 | ),
76 | SwatString::minimizeEntities($locale->formatNumber($hours)),
77 | '',
78 | (abs($hours - 1.0) < 0.01)
79 | ? SwatString::minimizeEntities($this->credit_title)
80 | : SwatString::minimizeEntities($this->credit_title_plural),
81 | '',
82 | SwatString::minimizeEntities($this->title)
83 | );
84 | }
85 |
86 | protected function init()
87 | {
88 | $this->table = 'CMEProvider';
89 | $this->id_field = 'integer:id';
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/Details.php:
--------------------------------------------------------------------------------
1 | helper = $this->getQuestionHelper();
23 | $this->helper->initInternal();
24 |
25 | // for evaluations, hide correct option column
26 | if ($this->helper->isEvaluation()) {
27 | $view = $this->ui->getWidget('option_view');
28 | $view->getColumn('correct_option')->visible = false;
29 |
30 | $toollink = $this->ui->getWidget('correct_option');
31 | $toollink->visible = false;
32 | }
33 | }
34 |
35 | protected function initInquisition()
36 | {
37 | parent::initInquisition();
38 |
39 | if (!$this->inquisition instanceof InquisitionInquisition) {
40 | // if we got here from the question index, load the inquisition
41 | // from the binding as we only have one inquisition per question
42 | $sql = 'select inquisition
43 | from InquisitionInquisitionQuestionBinding where question = %s';
44 |
45 | $sql = sprintf(
46 | $sql,
47 | $this->app->db->quote($this->question->id)
48 | );
49 |
50 | $inquisition_id = SwatDB::queryOne($this->app->db, $sql);
51 |
52 | $this->inquisition = $this->loadInquisition($inquisition_id);
53 | }
54 | }
55 |
56 | protected function getQuestionHelper()
57 | {
58 | return new CMEQuestionHelper($this->app, $this->inquisition);
59 | }
60 |
61 | // build phase
62 |
63 | protected function buildInternal()
64 | {
65 | parent::buildInternal();
66 |
67 | // hide hints frame for CME quizzes and evaluations
68 | if ($this->helper->isEvaluation() || $this->helper->isQuiz()) {
69 | $this->ui->getWidget('hints_frame')->visible = false;
70 | }
71 | }
72 |
73 | protected function buildNavBar()
74 | {
75 | parent::buildNavBar();
76 |
77 | $this->helper->buildNavBar($this->navbar);
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEQuizResponse.php:
--------------------------------------------------------------------------------
1 | values as $value) {
24 | $question = $value->question_binding->question;
25 | $correct_option_id = $question->getInternalValue('correct_option');
26 | $response_option_id = $value->getInternalValue('question_option');
27 | if ($response_option_id == $correct_option_id) {
28 | $correct++;
29 | }
30 | }
31 |
32 | return $correct;
33 | }
34 |
35 | public function getGrade()
36 | {
37 | return $this->grade;
38 | }
39 |
40 | public function isPassed()
41 | {
42 | return
43 | $this->getGrade() >=
44 | $this->credits->getFirst()->front_matter->passing_grade;
45 | }
46 |
47 | public function getCredits()
48 | {
49 | return $this->credits;
50 | }
51 |
52 | protected function init()
53 | {
54 | parent::init();
55 | $this->registerDateProperty('reset_date');
56 | $this->registerInternalProperty(
57 | 'account',
58 | SwatDBClassMap::get(CMEAccount::class)
59 | );
60 | }
61 |
62 | public function loadCredits()
63 | {
64 | $this->checkDB();
65 |
66 | $inquisition_id = $this->getInternalValue('inquisition');
67 | $account_id = $this->getInternalValue('account');
68 |
69 | $sql = sprintf(
70 | 'select CMECredit.* from CMECredit
71 | inner join AccountCMEProgressCreditBinding on
72 | AccountCMEProgressCreditBinding.credit = CMECredit.id
73 | inner join AccountCMEProgress on
74 | AccountCMEProgress.id =
75 | AccountCMEProgressCreditBinding.progress
76 | where AccountCMEProgress.quiz = %s
77 | and AccountCMEProgress.account = %s',
78 | $this->db->quote($inquisition_id, 'integer'),
79 | $this->db->quote($account_id, 'integer')
80 | );
81 |
82 | return SwatDB::query(
83 | $this->db,
84 | $sql,
85 | SwatDBClassMap::get(CMECreditWrapper::class)
86 | );
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/CME/admin/components/Evaluation/Details.php:
--------------------------------------------------------------------------------
1 | front_matter->getProviderTitleList()
18 | );
19 | }
20 |
21 | // init phase
22 |
23 | protected function initInternal()
24 | {
25 | parent::initInternal();
26 | $this->initFrontMatter();
27 |
28 | // Hide question import link as question importer only works with
29 | // Inquisitions that have correct answers.
30 | $this->ui->getWidget('question_import')->visible = false;
31 | }
32 |
33 | protected function initFrontMatter()
34 | {
35 | $sql = sprintf(
36 | 'select * from CMEFrontMatter where evaluation = %s',
37 | $this->app->db->quote($this->inquisition->id, 'integer')
38 | );
39 |
40 | $this->front_matter = SwatDB::query(
41 | $this->app->db,
42 | $sql,
43 | SwatDBClassMap::get(CMEFrontMatterWrapper::class)
44 | )->getFirst();
45 |
46 | if (!$this->front_matter instanceof CMEFrontMatter) {
47 | throw new AdminNotFoundException(
48 | sprintf(
49 | 'Evaluation with id of %s not found.',
50 | $this->id
51 | )
52 | );
53 | }
54 | }
55 |
56 | // build phase
57 |
58 | protected function buildInternal()
59 | {
60 | parent::buildInternal();
61 |
62 | $details_frame = $this->ui->getWidget('details_frame');
63 | $details_frame->title = $this->getTitle();
64 |
65 | // Hide details view. All details are displayed on previous screen with
66 | // front matter.
67 | $view = $this->ui->getWidget('details_view');
68 | $view->visible = false;
69 |
70 | // move question frame to top-level
71 | $question_frame = $this->ui->getWidget('question_frame');
72 | $question_frame->visible = false;
73 | foreach ($question_frame->getChildren() as $child) {
74 | $question_frame->remove($child);
75 | $details_frame->packEnd($child);
76 | }
77 | }
78 |
79 | protected function buildToolbars()
80 | {
81 | parent::buildToolbars();
82 |
83 | $this->ui->getWidget('details_toolbar')->visible = false;
84 | }
85 |
86 | protected function buildNavBar()
87 | {
88 | parent::buildNavBar();
89 |
90 | $this->navbar->popEntry();
91 | $this->navbar->createEntry($this->getTitle());
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/CME/admin/components/Credit/Delete.php:
--------------------------------------------------------------------------------
1 | getItemList('integer');
18 | $sql = sprintf($sql, $item_list);
19 | $num = SwatDB::exec($this->app->db, $sql);
20 |
21 | $locale = SwatI18NLocale::get();
22 |
23 | $message = new SwatMessage(
24 | sprintf(
25 | CME::ngettext(
26 | 'One CME credit has been deleted.',
27 | '%s CME credits have been deleted.',
28 | $num
29 | ),
30 | $locale->formatNumber($num)
31 | )
32 | );
33 |
34 | $this->app->messages->add($message);
35 | }
36 |
37 | // build phase
38 |
39 | protected function buildInternal()
40 | {
41 | parent::buildInternal();
42 |
43 | $locale = SwatI18NLocale::get();
44 |
45 | $item_list = $this->getItemList('integer');
46 |
47 | $dep = new AdminListDependency();
48 | $dep->setTitle(CME::_('CME credit'), CME::_('CME credits'));
49 |
50 | $sql = sprintf(
51 | 'select CMECredit.*
52 | from CMECredit
53 | where CMECredit.id in (%s)',
54 | $item_list
55 | );
56 |
57 | $credits = SwatDB::query(
58 | $this->app->db,
59 | $sql,
60 | SwatDBClassMap::get(CMECreditWrapper::class)
61 | );
62 |
63 | foreach ($credits as $credit) {
64 | $data = new stdClass();
65 | $data->id = $credit->id;
66 | $data->status_level = AdminDependency::DELETE;
67 | $data->parent = null;
68 | $data->title = $credit->getTitle();
69 | $dep->entries[] = new AdminDependencyEntry($data);
70 | }
71 |
72 | $message = $this->ui->getWidget('confirmation_message');
73 | $message->content = $dep->getMessage();
74 | $message->content_type = 'text/xml';
75 |
76 | if ($dep->getStatusLevelCount(AdminDependency::DELETE) === 0) {
77 | $this->switchToCancelButton();
78 | }
79 | }
80 |
81 | protected function buildNavBar()
82 | {
83 | parent::buildNavBar();
84 |
85 | $this->navbar->popEntries(1);
86 |
87 | $this->navbar->createEntry(
88 | CME::ngettext(
89 | 'Delete CME Credit',
90 | 'Delete CME Credits',
91 | count($this->items)
92 | )
93 | );
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/www/javascript/cme-front-matter-display.js:
--------------------------------------------------------------------------------
1 | function CMEFrontMatterDisplay(
2 | id,
3 | class_name,
4 | server,
5 | title,
6 | content,
7 | cancel_uri
8 | ) {
9 | this.id = id;
10 | this.class_name = class_name;
11 | this.server = server;
12 | this.content = content;
13 | this.title = title;
14 | this.cancel_uri = cancel_uri;
15 |
16 | YAHOO.util.Event.onDOMReady(this.init, this, true);
17 | }
18 |
19 | CMEFrontMatterDisplay.accept_text =
20 | 'I Have Read the CME Information / Continue';
21 |
22 | CMEFrontMatterDisplay.cancel_text = 'Cancel and Return';
23 |
24 | CMEFrontMatterDisplay.confirm_text =
25 | 'Before you view %s, please attest to reading the following:';
26 |
27 | CMEFrontMatterDisplay.prototype.init = function () {
28 | this.dialog = new SiteDialog(this.id, {
29 | dismissable: false,
30 | class_name: 'cme-front-matter-display'
31 | });
32 |
33 | // build dialog header
34 | this.dialog.appendToHeader(
35 | document.createTextNode(
36 | CMEFrontMatterDisplay.confirm_text.replace(/%s/, this.title)
37 | )
38 | );
39 |
40 | // build dialog body
41 | var content = document.createElement('div');
42 | content.className = 'cme-front-matter-display-content';
43 | content.innerHTML = this.content;
44 | this.dialog.appendToBody(content);
45 |
46 | // build dialog footer
47 | var continue_button = document.createElement('button');
48 | continue_button.setAttribute('type', 'button');
49 | continue_button.appendChild(
50 | document.createTextNode(CMEFrontMatterDisplay.accept_text)
51 | );
52 | continue_button.className =
53 | 'btn btn-primary cme-front-matter-display-accept-button';
54 |
55 | YAHOO.util.Event.on(
56 | continue_button,
57 | 'click',
58 | function (e) {
59 | continue_button.disabled = true;
60 | this.submitCMEPiece();
61 | },
62 | this,
63 | true
64 | );
65 |
66 | var cancel_button = document.createElement('button');
67 | cancel_button.setAttribute('type', 'button');
68 | cancel_button.appendChild(
69 | document.createTextNode(CMEFrontMatterDisplay.cancel_text)
70 | );
71 | cancel_button.className =
72 | 'btn btn-default cme-front-matter-display-cancel-button';
73 |
74 | YAHOO.util.Event.on(
75 | cancel_button,
76 | 'click',
77 | function (e) {
78 | var base = document.getElementsByTagName('base')[0];
79 | window.location = base.href + this.cancel_uri;
80 | },
81 | this,
82 | true
83 | );
84 |
85 | this.dialog.appendToFooter(continue_button);
86 | this.dialog.appendToFooter(cancel_button);
87 |
88 | // open dialog by default
89 | this.dialog.open();
90 | };
91 |
92 | CMEFrontMatterDisplay.prototype.submitCMEPiece = function () {
93 | var callback = {
94 | success: function (o) {},
95 | failure: function (o) {}
96 | };
97 |
98 | YAHOO.util.Connect.asyncRequest('POST', this.server, callback);
99 |
100 | this.dialog.closeWithAnimation();
101 | };
102 |
--------------------------------------------------------------------------------
/CME/admin/components/Option/include/CMEOptionHelper.php:
--------------------------------------------------------------------------------
1 | app = $app;
30 | $this->question = $question;
31 | $this->question_helper = $question_helper;
32 | }
33 |
34 | public function isEvaluation()
35 | {
36 | return $this->question_helper->isEvaluation();
37 | }
38 |
39 | public function isQuiz()
40 | {
41 | return $this->question_helper->isQuiz();
42 | }
43 |
44 | // init phase
45 |
46 | public function initInternal()
47 | {
48 | $this->question_helper->initInternal();
49 | }
50 |
51 | // process phase
52 |
53 | public function getRelocateURI()
54 | {
55 | $uri = null;
56 |
57 | if ($this->isQuiz()) {
58 | $uri = sprintf(
59 | 'Credit/Details?id=%s',
60 | $this->credit->id
61 | );
62 | } elseif ($this->isEvaluation()) {
63 | $uri = sprintf(
64 | 'Evaluation/Details?id=%s',
65 | $this->inquisition->id
66 | );
67 | }
68 |
69 | return $uri;
70 | }
71 |
72 | // build phase
73 |
74 | public function buildNavBar(SwatNavBar $navbar)
75 | {
76 | // save add/edit title defined in Inquisition package
77 | $title = $navbar->popEntry();
78 |
79 | $this->question_helper->buildNavBar($navbar);
80 |
81 | // remove question defined in Inquisition package
82 | $question = $navbar->popEntry();
83 |
84 | // add question
85 | $inquisition = $this->question_helper->getInquisition();
86 | if ($inquisition instanceof InquisitionInquisition) {
87 | $navbar->createEntry(
88 | $this->getQuestionTitle(),
89 | sprintf(
90 | 'Question/Details?id=%s&inquisition=%s',
91 | $this->question->id,
92 | $inquisition->id
93 | )
94 | );
95 | } else {
96 | $navbar->createEntry(
97 | $this->getQuestionTitle(),
98 | sprintf(
99 | 'Question/Details?id=%s',
100 | $this->question->id
101 | )
102 | );
103 | }
104 |
105 | // add back edit/add title
106 | $navbar->addEntry($title);
107 | }
108 |
109 | protected function getQuestionTitle()
110 | {
111 | // TODO: Update this with some version of getPosition().
112 | return CME::_('Question');
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/CME/admin/components/QuizReport/Download.php:
--------------------------------------------------------------------------------
1 | initReport();
20 | }
21 |
22 | protected function initReport()
23 | {
24 | $quarter = SiteApplication::initVar(
25 | 'quarter',
26 | null,
27 | SiteApplication::VAR_GET
28 | );
29 |
30 | if ($quarter === null
31 | || preg_match('/^2[0-9]{3}-0[1-4]$/', $quarter) === 0) {
32 | throw new AdminNotFoundException('Invalid quarter.');
33 | }
34 |
35 | [$year, $quarter] = explode('-', $quarter, 2);
36 |
37 | $start_month = ((intval($quarter) - 1) * 3) + 1;
38 |
39 | $quarter = new SwatDate();
40 | $quarter->setTime(0, 0, 0);
41 | $quarter->setDate($year, $start_month, 1);
42 | $quarter->setTZ($this->app->default_time_zone);
43 | $quarter->toUTC();
44 |
45 | $type = SiteApplication::initVar(
46 | 'type',
47 | null,
48 | SiteApplication::VAR_GET
49 | );
50 |
51 | $provider = new CMEProvider();
52 | $provider->setDatabase($this->app->db);
53 | if (!$provider->loadByShortname($type)) {
54 | throw new AdminNotFoundException('Invalid CME provider.');
55 | }
56 |
57 | $sql = sprintf(
58 | 'select * from QuizReport
59 | where quarter = %s and provider = %s',
60 | $this->app->db->quote($quarter->getDate(), 'date'),
61 | $this->app->db->quote($provider->id, 'integer')
62 | );
63 |
64 | $this->report = SwatDB::query(
65 | $this->app->db,
66 | $sql,
67 | SwatDBClassMap::get(CMEQuizReportWrapper::class)
68 | )->getFirst();
69 |
70 | if (!$this->report instanceof CMEQuizReport) {
71 | throw new AdminNotFoundException(
72 | sprintf(
73 | 'Report not found for quarter %s.',
74 | $quarter->getDate()
75 | )
76 | );
77 | }
78 |
79 | $this->report->setFileBase('../../system/quiz-report-updater');
80 | if (!file_exists($this->report->getFilePath())) {
81 | throw new AdminNotFoundException(
82 | sprintf(
83 | 'Report file ‘%s’ not found',
84 | $this->report->getFilePath()
85 | )
86 | );
87 | }
88 | }
89 |
90 | // build phase
91 |
92 | protected function buildInternal()
93 | {
94 | header(
95 | sprintf(
96 | 'Content-Disposition: attachment;filename="%s"',
97 | $this->report->filename
98 | )
99 | );
100 |
101 | header('Content-Type: text/csv');
102 |
103 | readfile($this->report->getFilePath());
104 |
105 | exit;
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMECredit.php:
--------------------------------------------------------------------------------
1 | 4.0
41 | // 4.5 -> 4.5
42 | // 4.50 -> 4.5
43 | // 4.25 -> 4.25
44 | $fractional_digits = mb_substr(mb_strrchr($hours, '.'), 1);
45 | $decimal_places = (
46 | mb_strlen($fractional_digits) === 2
47 | && mb_substr($hours, -1) !== '0'
48 | )
49 | ? 2
50 | : 1;
51 |
52 | return $locale->formatNumber($hours, $decimal_places);
53 | }
54 |
55 | public function getFormattedHours()
56 | {
57 | return static::formatCreditHours($this->hours);
58 | }
59 |
60 | public function hasQuiz()
61 | {
62 | return $this->getInternalValue('quiz') !== null
63 | && $this->quiz instanceof CMEQuiz
64 | && count($this->quiz->question_bindings) > 0;
65 | }
66 |
67 | public function isEarned(CMEAccount $account)
68 | {
69 | // assume the evaluation is always required
70 | return
71 | $account->hasAttested($this->front_matter)
72 | && (
73 | !$this->hasQuiz()
74 | || $account->isQuizPassed($this)
75 | ) && (
76 | !$this->front_matter->evaluation instanceof CMEEvaluation
77 | || $account->isEvaluationComplete($this)
78 | );
79 | }
80 |
81 | public function isExpired()
82 | {
83 | $now = new SwatDate();
84 | $now->toUTC();
85 |
86 | return $now->after($this->expiry_date);
87 | }
88 |
89 | public function getTitle()
90 | {
91 | return sprintf(
92 | CME::_('%s CME Credit'),
93 | $this->front_matter->getProviderTitleList()
94 | );
95 | }
96 |
97 | abstract protected function getQuizLink();
98 |
99 | protected function init()
100 | {
101 | $this->table = 'CMECredit';
102 | $this->id_field = 'integer:id';
103 | $this->registerDateProperty('expiry_date');
104 |
105 | $this->registerInternalProperty(
106 | 'front_matter',
107 | SwatDBClassMap::get(CMEFrontMatter::class)
108 | );
109 |
110 | $this->registerInternalProperty(
111 | 'quiz',
112 | SwatDBClassMap::get(CMEQuiz::class)
113 | );
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/CME/admin/components/EvaluationReport/Download.php:
--------------------------------------------------------------------------------
1 | initReport();
20 | }
21 |
22 | protected function initReport()
23 | {
24 | $quarter = SiteApplication::initVar(
25 | 'quarter',
26 | null,
27 | SiteApplication::VAR_GET
28 | );
29 |
30 | if ($quarter === null
31 | || preg_match('/^2[0-9]{3}-0[1-4]$/', $quarter) === 0) {
32 | throw new AdminNotFoundException('Invalid quarter.');
33 | }
34 |
35 | [$year, $quarter] = explode('-', $quarter, 2);
36 |
37 | $start_month = ((intval($quarter) - 1) * 3) + 1;
38 |
39 | $quarter = new SwatDate();
40 | $quarter->setTime(0, 0, 0);
41 | $quarter->setDate($year, $start_month, 1);
42 | $quarter->setTZ($this->app->default_time_zone);
43 | $quarter->toUTC();
44 |
45 | $type = SiteApplication::initVar(
46 | 'type',
47 | null,
48 | SiteApplication::VAR_GET
49 | );
50 |
51 | $provider = SwatDBClassMap::new(CMEProvider::class);
52 | $provider->setDatabase($this->app->db);
53 | if (!$provider->loadByShortname($type)) {
54 | throw new AdminNotFoundException('Invalid CME provider.');
55 | }
56 |
57 | $sql = sprintf(
58 | 'select * from EvaluationReport
59 | where quarter = %s and provider = %s',
60 | $this->app->db->quote($quarter->getDate(), 'date'),
61 | $this->app->db->quote($provider->id, 'integer')
62 | );
63 |
64 | $this->report = SwatDB::query(
65 | $this->app->db,
66 | $sql,
67 | SwatDBClassMap::get(CMEEvaluationReportWrapper::class)
68 | )->getFirst();
69 |
70 | if (!$this->report instanceof CMEEvaluationReport) {
71 | throw new AdminNotFoundException(
72 | sprintf(
73 | 'Report not found for quarter %s.',
74 | $quarter->getDate()
75 | )
76 | );
77 | }
78 |
79 | $this->report->setFileBase('../../system/evaluation-report-updater');
80 | if (!file_exists($this->report->getFilePath())) {
81 | throw new AdminNotFoundException(
82 | sprintf(
83 | 'Report file ‘%s’ not found',
84 | $this->report->getFilePath()
85 | )
86 | );
87 | }
88 | }
89 |
90 | // build phase
91 |
92 | protected function buildInternal()
93 | {
94 | header(
95 | sprintf(
96 | 'Content-Disposition: attachment;filename="%s"',
97 | $this->report->filename
98 | )
99 | );
100 |
101 | header('Content-Type: application/pdf');
102 |
103 | readfile($this->report->getFilePath());
104 |
105 | exit;
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/CME/CME.php:
--------------------------------------------------------------------------------
1 | account = $account;
36 | $this->front_matter = $front_matter;
37 | $this->response = $response;
38 |
39 | if ($this->response->complete_date === null) {
40 | throw new SiteMailException(
41 | sprintf(
42 | 'Quiz is not completed for "%s".',
43 | $account->email
44 | )
45 | );
46 | }
47 |
48 | parent::__construct($app, $account);
49 |
50 | $this->from_name = $this->getFromName();
51 | $this->from_address = $this->getFromAddress();
52 | $this->to_name = $account->getFullName();
53 | $this->to_address = $account->email;
54 | }
55 |
56 | abstract protected function getCertificateLinkURI();
57 |
58 | protected function getFromName()
59 | {
60 | return $this->app->config->site->title;
61 | }
62 |
63 | protected function getFromAddress()
64 | {
65 | return $this->app->config->email->service_address;
66 | }
67 |
68 | protected function getSubject()
69 | {
70 | return sprintf(
71 | CME::_('%s Quiz Completed'),
72 | $this->front_matter->getProviderTitleList()
73 | );
74 | }
75 |
76 | protected function getBodyText()
77 | {
78 | if ($this->response->isPassed()) {
79 | $bodytext = $this->front_matter->email_content_pass;
80 | } else {
81 | $bodytext = $this->front_matter->email_content_fail;
82 | }
83 |
84 | return $bodytext;
85 | }
86 |
87 | protected function getReplacementMarkerText($marker_id)
88 | {
89 | $locale = SwatI18NLocale::get();
90 |
91 | switch ($marker_id) {
92 | case 'account-full-name':
93 | return $this->account->getFullName();
94 |
95 | case 'cme-certificate-link':
96 | return $this->getCertificateLinkURI();
97 |
98 | case 'quiz-passing-grade':
99 | return $locale->formatNumber(
100 | $this->front_matter->passing_grade * 100
101 | ) . '%';
102 |
103 | case 'quiz-grade':
104 | $grade = $this->response->getGrade();
105 |
106 | return $locale->formatNumber(round($grade * 1000) / 10) . '%';
107 |
108 | default:
109 | return parent::getReplacementMarkerText($marker_id);
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/CME/admin/components/FrontMatter/Delete.php:
--------------------------------------------------------------------------------
1 | getItemList('integer');
18 | $sql = sprintf($sql, $item_list);
19 | $num = SwatDB::exec($this->app->db, $sql);
20 |
21 | $locale = SwatI18NLocale::get();
22 |
23 | $message = new SwatMessage(
24 | sprintf(
25 | CME::ngettext(
26 | 'One CME front matter has been deleted.',
27 | '%s CME front matters have been deleted.',
28 | $num
29 | ),
30 | $locale->formatNumber($num)
31 | )
32 | );
33 |
34 | $this->app->messages->add($message);
35 | }
36 |
37 | // build phase
38 |
39 | protected function buildInternal()
40 | {
41 | parent::buildInternal();
42 |
43 | $locale = SwatI18NLocale::get();
44 |
45 | $item_list = $this->getItemList('integer');
46 |
47 | $dep = new AdminListDependency();
48 | $dep->setTitle(
49 | CME::_('CME front matter'),
50 | CME::_('CME front matters')
51 | );
52 |
53 | $sql = sprintf(
54 | 'select CMEFrontMatter.id, sum(CMECredit.hours) as hours
55 | from CMEFrontMatter
56 | left outer join CMECredit
57 | on CMECredit.front_matter = CMEFrontMatter.id
58 | where CMEFrontMatter.id in (%s)
59 | group by CMEFrontMatter.id',
60 | $item_list
61 | );
62 |
63 | $rs = SwatDB::query($this->app->db, $sql);
64 |
65 | foreach ($rs as $row) {
66 | $front_matter = SwatDBClassMap::new(CMEFrontMatter::class, $row);
67 | $front_matter->setDatabase($this->app->db);
68 |
69 | $row->status_level = AdminDependency::DELETE;
70 | $row->parent = null;
71 |
72 | // not using ngettext because hours is a float
73 | $row->title = sprintf(
74 | ($row->hours == 1)
75 | ? CME::_('%s (1 hour)')
76 | : CME::_('%s (%s hours)'),
77 | $front_matter->getProviderTitleList(),
78 | $locale->formatNumber($row->hours)
79 | );
80 | $dep->entries[] = new AdminDependencyEntry($row);
81 | }
82 |
83 | $message = $this->ui->getWidget('confirmation_message');
84 | $message->content = $dep->getMessage();
85 | $message->content_type = 'text/xml';
86 |
87 | if ($dep->getStatusLevelCount(AdminDependency::DELETE) === 0) {
88 | $this->switchToCancelButton();
89 | }
90 | }
91 |
92 | protected function buildNavBar()
93 | {
94 | parent::buildNavBar();
95 |
96 | $this->navbar->popEntries(1);
97 |
98 | $this->navbar->createEntry(
99 | CME::ngettext(
100 | 'Delete CME Front Matter',
101 | 'Delete CME Front Matters',
102 | count($this->items)
103 | )
104 | );
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEFrontMatter.php:
--------------------------------------------------------------------------------
1 | providers as $provider) {
82 | $titles[] = $provider->title;
83 | }
84 |
85 | return SwatString::toList($titles);
86 | }
87 |
88 | abstract protected function getAttestationLink();
89 |
90 | abstract protected function getEvaluationLink();
91 |
92 | protected function init()
93 | {
94 | $this->table = 'CMEFrontMatter';
95 | $this->id_field = 'integer:id';
96 |
97 | $this->registerInternalProperty(
98 | 'evaluation',
99 | SwatDBClassMap::get(CMEEvaluation::class)
100 | );
101 |
102 | $this->registerDateProperty('release_date');
103 | $this->registerDateProperty('review_date');
104 | }
105 |
106 | protected function loadCredits()
107 | {
108 | $sql = sprintf(
109 | 'select * from CMECredit where front_matter = %s
110 | order by displayorder asc, hours desc',
111 | $this->db->quote($this->id, 'integer')
112 | );
113 |
114 | return SwatDB::query(
115 | $this->db,
116 | $sql,
117 | SwatDBClassMap::get(CMECreditWrapper::class)
118 | );
119 | }
120 |
121 | protected function loadProviders()
122 | {
123 | $sql = sprintf(
124 | 'select CMEProvider.*
125 | from CMEProvider
126 | inner join CMEFrontMatterProviderBinding on
127 | CMEFrontMatterProviderBinding.provider = CMEProvider.id
128 | where CMEFrontMatterProviderBinding.front_matter = %s
129 | order by CMEProvider.displayorder, CMEProvider.id',
130 | $this->db->quote($this->id, 'integer')
131 | );
132 |
133 | return SwatDB::query(
134 | $this->db,
135 | $sql,
136 | SwatDBClassMap::get(CMEProviderWrapper::class)
137 | );
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/CME/dataobjects/CMEFrontMatterWrapper.php:
--------------------------------------------------------------------------------
1 | setOptions('read_only', $read_only);
17 |
18 | $credits = SwatDB::query(
19 | $this->db,
20 | sprintf(
21 | 'select * from CMECredit
22 | where front_matter in (%s)
23 | order by front_matter, displayorder, hours',
24 | $this->db->implodeArray(
25 | $this->getIndexes(),
26 | 'integer'
27 | )
28 | ),
29 | $credits_wrapper
30 | );
31 |
32 | $this->attachSubRecordset(
33 | 'credits',
34 | SwatDBClassMap::get(CMECreditWrapper::class),
35 | 'front_matter',
36 | $credits
37 | );
38 |
39 | // efficiently link back to front-matter from credit
40 | foreach ($credits as $credit) {
41 | $credit->front_matter = $this->getByIndex(
42 | $credit->getInternalValue('front_matter')
43 | );
44 | }
45 |
46 | return $credits;
47 | }
48 |
49 | public function loadProviders($read_only = true)
50 | {
51 | $providers_wrapper_class = SwatDBClassMap::get(CMEProviderWrapper::class);
52 | $providers_wrapper = new $providers_wrapper_class();
53 | $providers_wrapper->setOptions('read_only', $read_only);
54 |
55 | $providers = SwatDB::query(
56 | $this->db,
57 | 'select * from CMEProvider',
58 | $providers_wrapper
59 | );
60 |
61 | $sql = sprintf(
62 | 'select CMEFrontMatterProviderBinding.front_matter,
63 | CMEFrontMatterProviderBinding.provider
64 | from CMEFrontMatterProviderBinding
65 | inner join CMEProvider on
66 | CMEProvider.id = CMEFrontMatterProviderBinding.provider
67 | where CMEFrontMatterProviderBinding.front_matter in (%s)
68 | order by CMEFrontMatterProviderBinding.front_matter,
69 | CMEProvider.displayorder, CMEProvider.id',
70 | $this->db->implodeArray(
71 | $this->getIndexes(),
72 | 'integer'
73 | )
74 | );
75 |
76 | $rows = SwatDB::query($this->db, $sql);
77 | $front_matter_id = null;
78 | foreach ($rows as $row) {
79 | if ($row->front_matter !== $front_matter_id) {
80 | $front_matter_id = $row->front_matter;
81 | $front_matter = $this->getByIndex(
82 | $row->front_matter
83 | );
84 | $front_matter->providers = new $providers_wrapper_class();
85 | $front_matter->providers->setOptions('read_only', $read_only);
86 | }
87 |
88 | $provider = $providers->getByIndex($row->provider);
89 | $front_matter->providers->add($provider);
90 | }
91 |
92 | return $providers;
93 | }
94 |
95 | protected function init()
96 | {
97 | parent::init();
98 | $this->row_wrapper_class = SwatDBClassMap::get(CMEFrontMatter::class);
99 | $this->index_field = 'id';
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/CME/admin/components/FrontMatter/edit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | CME Front Matter
7 |
8 |
9 | CME Providers
10 |
11 | true
12 |
13 |
14 |
15 | Objectives
16 |
17 | 32
18 |
19 |
20 |
21 | CME Content Release Date
22 | The front matter’s displayed release date will default to its published date if this is not set.
23 |
24 |
25 |
26 | Date of Latest Review by CME Provider
27 |
28 | true
29 |
30 |
31 |
32 | Enabled
33 | CME credit can only be earned for enabled front matter. Existing certificates for disabled front matter may still be printed.
34 |
35 |
36 |
37 | CME Quiz Settings
38 |
39 | Passing Grade
40 |
41 | true
42 | 0.0
43 | 1.0
44 |
45 |
46 |
47 | CME quizzes can be retaken by users
48 |
49 | true
50 |
51 |
52 |
53 |
54 | Pass Email Content
55 |
56 | true
57 |
58 |
59 |
60 | Fail Email Content
61 |
62 | true
63 |
64 |
65 |
66 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/CME/CMEFrontMatterDisplay.php:
--------------------------------------------------------------------------------
1 | html_head_entry_set->addEntrySet($yui->getHtmlHeadEntrySet());
43 |
44 | $this->addJavaScript(
45 | 'packages/swat/javascript/swat-z-index-manager.js'
46 | );
47 | $this->addJavaScript(
48 | 'packages/site/javascript/site-dialog.js'
49 | );
50 | $this->addJavaScript(
51 | 'packages/cme/javascript/cme-front-matter-display.js'
52 | );
53 | }
54 |
55 | public function display()
56 | {
57 | if (!$this->visible) {
58 | return;
59 | }
60 |
61 | parent::display();
62 |
63 | Swat::displayInlineJavaScript($this->getInlineJavaScript());
64 | }
65 |
66 | protected function getCSSClassNames()
67 | {
68 | return array_merge(
69 | ['cme-front-matter-display'],
70 | parent::getCSSClassNames()
71 | );
72 | }
73 |
74 | protected function getInlineJavaScript()
75 | {
76 | static $shown = false;
77 |
78 | if (!$shown) {
79 | $javascript = $this->getInlineJavaScriptTranslations();
80 | $shown = true;
81 | } else {
82 | $javascript = '';
83 | }
84 |
85 | $javascript .= sprintf(
86 | '%s_obj = new %s(%s, %s, %s, %s, %s, %s);',
87 | $this->id,
88 | $this->getJavaScriptClassName(),
89 | SwatString::quoteJavaScriptString($this->id),
90 | SwatString::quoteJavaScriptString($this->getCSSClassString()),
91 | SwatString::quoteJavaScriptString($this->server),
92 | SwatString::quoteJavaScriptString($this->title),
93 | SwatString::quoteJavaScriptString($this->content),
94 | SwatString::quoteJavaScriptString($this->cancel_uri)
95 | );
96 |
97 | return $javascript;
98 | }
99 |
100 | protected function getInlineJavaScriptTranslations()
101 | {
102 | $accept_text = CME::_('I Have Read the CME Information / Continue');
103 | $cancel_text = CME::_('Cancel and Return');
104 | $confirm_text = CME::_(
105 | 'Before you view %s, please attest to reading the following:'
106 | );
107 |
108 | return sprintf(
109 | "CMEFrontMatterDisplay.accept_text = %s;\n" .
110 | "CMEFrontMatterDisplay.cancel_text = %s;\n" .
111 | "CMEFrontMatterDisplay.confirm_text = %s;\n",
112 | SwatString::quoteJavaScriptString($accept_text),
113 | SwatString::quoteJavaScriptString($cancel_text),
114 | SwatString::quoteJavaScriptString($confirm_text)
115 | );
116 | }
117 |
118 | protected function getJavaScriptClassName()
119 | {
120 | return 'CMEFrontMatterDisplay';
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/CME/admin/components/Credit/Details.php:
--------------------------------------------------------------------------------
1 | id = SiteApplication::initVar('id');
25 |
26 | if (is_numeric($this->id)) {
27 | $this->id = intval($this->id);
28 | }
29 |
30 | $this->initCredit();
31 | $this->initInquisition();
32 |
33 | $this->ui->loadFromXML($this->getUiXml());
34 |
35 | $local_ui = new SwatUI();
36 | $local_ui->loadFromXML($this->getCreditDetailsViewXml());
37 |
38 | $provider_titles = [];
39 | foreach ($this->credit->front_matter->providers as $provider) {
40 | $provider_titles[] = $provider->credit_title_plural;
41 | }
42 |
43 | $local_ui->getWidget('details_view')->getField('hour')->title =
44 | SwatString::toList($provider_titles);
45 |
46 | $view = $this->ui->getWidget('details_view');
47 | foreach ($local_ui->getWidget('details_view')->getFields() as $field) {
48 | $view->appendField($field);
49 | }
50 | }
51 |
52 | protected function initInquisition()
53 | {
54 | $this->inquisition = $this->credit->quiz;
55 |
56 | $bindings = $this->inquisition->question_bindings;
57 |
58 | // efficiently load questions
59 | $questions = $bindings->loadAllSubDataObjects(
60 | 'question',
61 | $this->app->db,
62 | 'select * from InquisitionQuestion where id in (%s)',
63 | SwatDBClassMap::get(InquisitionQuestionWrapper::class)
64 | );
65 |
66 | // efficiently load question options
67 | if ($questions instanceof InquisitionQuestionWrapper) {
68 | $questions->loadAllSubRecordsets(
69 | 'options',
70 | SwatDBClassMap::get(InquisitionQuestionOptionWrapper::class),
71 | 'InquisitionQuestionOption',
72 | 'question',
73 | '',
74 | 'displayorder, id'
75 | );
76 | }
77 | }
78 |
79 | protected function initCredit()
80 | {
81 | $this->credit = SwatDBClassMap::new(CMECredit::class);
82 | $this->credit->setDatabase($this->app->db);
83 |
84 | if (!$this->credit->load($this->id)) {
85 | throw new AdminNotFoundException(
86 | sprintf(
87 | 'A CME credit with the id of ‘%s’ does not exist.',
88 | $this->id
89 | )
90 | );
91 | }
92 | }
93 |
94 | // build phase
95 |
96 | protected function buildInternal()
97 | {
98 | parent::buildInternal();
99 |
100 | $this->ui->getWidget('details_frame')->title =
101 | $this->credit->getTitle();
102 |
103 | $view = $this->ui->getWidget('details_view');
104 | $view->getField('title')->visible = false;
105 | $view->getField('createdate')->visible = false;
106 |
107 | // set default time zone
108 | $expiry_date_field = $view->getField('expiry_date');
109 | $expiry_date_renderer = $expiry_date_field->getFirstRenderer();
110 | $expiry_date_renderer->display_time_zone =
111 | $this->app->default_time_zone;
112 | }
113 |
114 | protected function buildToolbars()
115 | {
116 | parent::buildToolbars();
117 |
118 | $this->ui->getWidget('edit_link')->link = sprintf(
119 | 'Credit/Edit?id=%s',
120 | $this->credit->id
121 | );
122 |
123 | $this->ui->getWidget('delete_link')->link = sprintf(
124 | 'Credit/Delete?id=%s',
125 | $this->credit->id
126 | );
127 | }
128 |
129 | protected function buildNavBar()
130 | {
131 | parent::buildNavBar();
132 |
133 | $this->navbar->popEntry();
134 | $this->navbar->createEntry($this->credit->getTitle());
135 | }
136 |
137 | protected function getDetailsStore(InquisitionInquisition $inquisition)
138 | {
139 | $ds = parent::getDetailsStore($inquisition);
140 | $ds->hours = $this->credit->hours;
141 | $ds->expiry_date = $this->credit->expiry_date;
142 |
143 | return $ds;
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/CME/pages/CMEFrontMatterAttestationServerPage.php:
--------------------------------------------------------------------------------
1 | setLayout(
13 | new SiteLayout(
14 | $this->app,
15 | SiteJSONTemplate::class
16 | )
17 | );
18 | }
19 |
20 | protected function getArgumentMap()
21 | {
22 | return [
23 | 'front_matter' => [0, null],
24 | ];
25 | }
26 |
27 | // build phase
28 |
29 | public function build()
30 | {
31 | $this->layout->startCapture('content');
32 | echo json_encode($this->getJSONResponse());
33 | $this->layout->endCapture();
34 | }
35 |
36 | protected function getFrontMatter()
37 | {
38 | $front_matter_id = $this->getArgument('front_matter');
39 |
40 | $sql = sprintf(
41 | 'select * from CMEFrontMatter where id = %s and enabled = %s',
42 | $this->app->db->quote($front_matter_id, 'integer'),
43 | $this->app->db->quote(true, 'boolean')
44 | );
45 |
46 | return SwatDB::query(
47 | $this->app->db,
48 | $sql,
49 | SwatDBClassMap::get(CMEFrontMatterWrapper::class)
50 | )->getFirst();
51 | }
52 |
53 | protected function getJSONResponse()
54 | {
55 | $transaction = new SwatDBTransaction($this->app->db);
56 |
57 | try {
58 | if (!$this->app->session->isLoggedIn()) {
59 | return $this->getErrorResponse('Not logged in.');
60 | }
61 |
62 | $account = $this->app->session->account;
63 |
64 | $front_matter = $this->getFrontMatter();
65 | if (!$front_matter instanceof CMEFrontMatter) {
66 | return $this->getErrorResponse('CME front matter not found.');
67 | }
68 |
69 | // only save on a POST request
70 | if ($_SERVER['REQUEST_METHOD'] === 'POST') {
71 | $this->saveAccountAttestedCMEFrontMatter(
72 | $account,
73 | $front_matter
74 | );
75 | }
76 |
77 | $transaction->commit();
78 | } catch (Throwable $e) {
79 | $transaction->rollback();
80 |
81 | throw $e;
82 | }
83 |
84 | return [
85 | 'status' => [
86 | 'code' => 'ok',
87 | 'message' => '',
88 | ],
89 | ];
90 | }
91 |
92 | protected function getErrorResponse($message)
93 | {
94 | return [
95 | 'status' => [
96 | 'code' => 'error',
97 | 'message' => $message,
98 | ],
99 | ];
100 | }
101 |
102 | protected function saveAccountAttestedCMEFrontMatter(
103 | CMEAccount $account,
104 | CMEFrontMatter $front_matter
105 | ) {
106 | $sql = sprintf(
107 | 'delete from AccountAttestedCMEFrontMatter
108 | where account = %s and front_matter = %s',
109 | $this->app->db->quote($account->id, 'integer'),
110 | $this->app->db->quote($front_matter->id, 'integer')
111 | );
112 |
113 | SwatDB::exec($this->app->db, $sql);
114 |
115 | $now = new SwatDate();
116 | $now->toUTC();
117 |
118 | $sql = sprintf(
119 | 'insert into AccountAttestedCMEFrontMatter (
120 | account, front_matter, attested_date
121 | ) values (%s, %s, %s)',
122 | $this->app->db->quote($account->id, 'integer'),
123 | $this->app->db->quote($front_matter->id, 'integer'),
124 | $this->app->db->quote($now, 'date')
125 | );
126 |
127 | SwatDB::exec($this->app->db, $sql);
128 |
129 | $this->saveEarnedCredits($account, $front_matter);
130 | }
131 |
132 | protected function saveEarnedCredits(
133 | CMEAccount $account,
134 | CMEFrontMatter $front_matter
135 | ) {
136 | $earned_credits = SwatDBClassMap::new(CMEAccountEarnedCMECreditWrapper::class);
137 | $now = new SwatDate();
138 | $now->toUTC();
139 | foreach ($front_matter->credits as $credit) {
140 | if ($credit->isEarned($account)) {
141 | // check for existing earned credit before saving
142 | $sql = sprintf(
143 | 'select count(1)
144 | from AccountEarnedCMECredit
145 | where credit = %s and account = %s',
146 | $this->app->db->quote($credit->id, 'integer'),
147 | $this->app->db->quote($account->id, 'integer')
148 | );
149 |
150 | if (SwatDB::queryOne($this->app->db, $sql) == 0) {
151 | $earned_credit = SwatDBClassMap::new(CMEAccountEarnedCMECredit::class);
152 | $earned_credit->account = $account->id;
153 | $earned_credit->credit = $credit->id;
154 | $earned_credit->earned_date = $now;
155 | $earned_credits->add($earned_credit);
156 | }
157 | }
158 | }
159 | $earned_credits->setDatabase($this->app->db);
160 | $earned_credits->save();
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/CME/admin/components/Question/include/CMEQuestionHelper.php:
--------------------------------------------------------------------------------
1 | app = $app;
39 | $this->inquisition = $inquisition;
40 | }
41 |
42 | public function isEvaluation()
43 | {
44 | return $this->type == 'evaluation';
45 | }
46 |
47 | public function isQuiz()
48 | {
49 | return $this->type == 'quiz';
50 | }
51 |
52 | public function getInquisition()
53 | {
54 | return $this->inquisition;
55 | }
56 |
57 | // init phase
58 |
59 | public function initInternal()
60 | {
61 | $this->initCredit();
62 | $this->initFrontMatter();
63 | $this->initType();
64 | }
65 |
66 | protected function initCredit()
67 | {
68 | if ($this->inquisition instanceof InquisitionInquisition) {
69 | $sql = sprintf(
70 | 'select * from CMECredit where quiz = %s',
71 | $this->app->db->quote($this->inquisition->id, 'integer')
72 | );
73 |
74 | $this->credit = SwatDB::query(
75 | $this->app->db,
76 | $sql,
77 | SwatDBClassMap::get(CMECreditWrapper::class)
78 | )->getFirst();
79 | }
80 | }
81 |
82 | protected function initFrontMatter()
83 | {
84 | if ($this->inquisition instanceof InquisitionInquisition) {
85 | if ($this->credit instanceof CMECredit) {
86 | $this->front_matter = $this->credit->front_matter;
87 | } else {
88 | $sql = sprintf(
89 | 'select * from CMEFrontMatter where evaluation = %s',
90 | $this->app->db->quote($this->inquisition->id, 'integer')
91 | );
92 |
93 | $this->front_matter = SwatDB::query(
94 | $this->app->db,
95 | $sql,
96 | SwatDBClassMap::get(CMEFrontMatterWrapper::class)
97 | )->getFirst();
98 | }
99 | }
100 | }
101 |
102 | protected function initType()
103 | {
104 | if ($this->credit instanceof CMECredit) {
105 | $this->type = 'quiz';
106 | } elseif ($this->front_matter instanceof CMEFrontMatter) {
107 | $this->type = 'evaluation';
108 | }
109 | }
110 |
111 | // process phase
112 |
113 | public function getRelocateURI()
114 | {
115 | $uri = null;
116 |
117 | if ($this->isQuiz()) {
118 | $uri = sprintf(
119 | 'Credit/Details?id=%s',
120 | $this->credit->id
121 | );
122 | } elseif ($this->isEvaluation()) {
123 | $uri = sprintf(
124 | 'Evaluation/Details?id=%s',
125 | $this->inquisition->id
126 | );
127 | }
128 |
129 | return $uri;
130 | }
131 |
132 | // build phase
133 |
134 | public function buildNavBar(SwatNavBar $navbar)
135 | {
136 | // save add/edit title defined in Inquisition package
137 | $title = $navbar->popEntry();
138 |
139 | // pop inquisition title defined in Inquisition package
140 | $navbar->popEntry();
141 |
142 | // pop question component
143 | $navbar->popEntry();
144 |
145 | // add inquisition
146 | if ($this->isQuiz()) {
147 | $navbar->createEntry(
148 | $this->getCreditNavBarTitle(),
149 | sprintf(
150 | 'Credit/Details?id=%s',
151 | $this->credit->id
152 | )
153 | );
154 | } elseif ($this->isEvaluation()) {
155 | $navbar->createEntry(
156 | $this->getEvaluationNavBarTitle(),
157 | sprintf(
158 | 'Evaluation/Details?id=%s',
159 | $this->inquisition->id
160 | )
161 | );
162 | } elseif ($this->inquisition instanceof InquisitionInquisition) {
163 | $navbar->createEntry(
164 | $this->inquisition->title,
165 | sprintf(
166 | 'Inquisition/Details?id=%s',
167 | $this->inquisition->id
168 | )
169 | );
170 | }
171 |
172 | // add back edit/add title
173 | $navbar->addEntry($title);
174 | }
175 |
176 | protected function getCreditNavBarTitle()
177 | {
178 | return sprintf(
179 | CME::_('%s Credit'),
180 | $this->credit->front_matter->getProviderTitleList()
181 | );
182 | }
183 |
184 | protected function getEvaluationNavBarTitle()
185 | {
186 | return sprintf(
187 | CME::_('%s Evaluation'),
188 | $this->front_matter->getProviderTitleList()
189 | );
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/www/javascript/cme-evaluation-page.js:
--------------------------------------------------------------------------------
1 | YAHOO.util.Event.onDOMReady(function () {
2 | var radio_list_els = YAHOO.util.Dom.getElementsByClassName(
3 | 'swat-radio-list',
4 | 'ul'
5 | );
6 |
7 | var submit_button = document.getElementById('submit_button');
8 |
9 | submit_button.addEventListener('click', function (e) {
10 | if (typeof amplitude !== 'undefined') {
11 | amplitude.track('CME_Evaluation_Submitted', {
12 | episode_id: CMEEvaluationPage.episode_id
13 | });
14 | }
15 | });
16 |
17 | for (var i = 0; i < radio_list_els.length; i++) {
18 | var radio_buttons = YAHOO.util.Dom.getElementsBy(
19 | function (el) {
20 | return el.type === 'radio';
21 | },
22 | 'input',
23 | radio_list_els[i]
24 | );
25 |
26 | if (radio_buttons.length > 0) {
27 | for (var j = 0; j < radio_buttons.length; j++) {
28 | (function () {
29 | var item = YAHOO.util.Dom.getAncestorByTagName(
30 | radio_buttons[j],
31 | 'li'
32 | );
33 | var radio = radio_buttons[j];
34 |
35 | var the_radio_buttons = radio_buttons;
36 | YAHOO.util.Event.on(radio, 'click', function (e) {
37 | updateListSelection(the_radio_buttons);
38 | });
39 |
40 | // passthrough click on list item to radio button
41 | YAHOO.util.Event.on(item, 'click', function (e) {
42 | var target = YAHOO.util.Event.getTarget(e);
43 | if (target === item) {
44 | if (!radio.disabled) {
45 | radio.checked = true;
46 | updateListSelection(the_radio_buttons);
47 | }
48 | }
49 | });
50 | })();
51 | }
52 |
53 | updateListSelection(radio_buttons);
54 | }
55 | }
56 |
57 | var checkbox_list_els = YAHOO.util.Dom.getElementsByClassName(
58 | 'swat-checkbox-list',
59 | 'div'
60 | );
61 |
62 | for (var i = 0; i < checkbox_list_els.length; i++) {
63 | var checkboxes = YAHOO.util.Dom.getElementsBy(
64 | function (el) {
65 | return el.type === 'checkbox';
66 | },
67 | 'input',
68 | checkbox_list_els[i]
69 | );
70 |
71 | if (checkboxes.length > 0) {
72 | for (var j = 0; j < checkboxes.length; j++) {
73 | (function () {
74 | var item = YAHOO.util.Dom.getAncestorByTagName(checkboxes[j], 'li');
75 | var checkbox = checkboxes[j];
76 |
77 | var the_checkboxes = checkboxes;
78 | YAHOO.util.Event.on(checkbox, 'click', function (e) {
79 | updateListSelection(the_checkboxes);
80 | });
81 |
82 | // passthrough click on list item to radio button
83 | YAHOO.util.Event.on(item, 'click', function (e) {
84 | var target = YAHOO.util.Event.getTarget(e);
85 | if (target === item) {
86 | checkbox.checked = !checkbox.checked;
87 | updateListSelection(the_checkboxes);
88 | }
89 | });
90 | })();
91 | }
92 |
93 | updateListSelection(checkboxes);
94 | }
95 | }
96 |
97 | function updateListSelection(list) {
98 | for (var i = 0; i < list.length; i++) {
99 | var li = YAHOO.util.Dom.getAncestorByTagName(list[i], 'li');
100 | if (list[i].checked) {
101 | YAHOO.util.Dom.addClass(li, 'selected');
102 | } else {
103 | YAHOO.util.Dom.removeClass(li, 'selected');
104 | }
105 | }
106 | }
107 | });
108 |
109 | function CMEEvaluationPage(questions) {
110 | this.questions = questions;
111 |
112 | YAHOO.util.Event.onDOMReady(this.init, this, true);
113 | }
114 |
115 | CMEEvaluationPage.episode_id = null;
116 |
117 | /* {{{ CMEEvaluationPage.prototype.init() */
118 |
119 | CMEEvaluationPage.prototype.init = function () {
120 | for (var i = 0; i < this.questions.length; i++) {
121 | var question = this.questions[i];
122 | var name = 'question' + question.binding + '_' + question.question;
123 |
124 | YAHOO.util.Event.on(
125 | document.getElementsByName(name),
126 | 'click',
127 | function (e) {
128 | this.updateView();
129 | },
130 | this,
131 | true
132 | );
133 | }
134 |
135 | this.updateView();
136 | };
137 |
138 | /* }}} */
139 | /* {{{ CMEEvaluationPage.prototype.updateView() */
140 |
141 | CMEEvaluationPage.prototype.updateView = function () {
142 | for (var i = 0; i < this.questions.length; i++) {
143 | var show = true;
144 | var question = this.questions[i];
145 |
146 | var element = document.getElementById(
147 | 'question' + question.binding + '_' + question.question
148 | );
149 |
150 | for (var j = 0; j < question.dependencies.length; j++) {
151 | var selected = false;
152 | var dependency = question.dependencies[j];
153 |
154 | for (var k = 0; k < dependency.options.length; k++) {
155 | var option = document.getElementById(
156 | 'question' +
157 | dependency.binding +
158 | '_' +
159 | dependency.question +
160 | '_' +
161 | dependency.options[k]
162 | );
163 |
164 | // Dependant options can be not visible, so if they don't exist
165 | // in the DOM skip trying to show them.
166 | if (option !== null) {
167 | selected = selected || option.checked;
168 | }
169 | }
170 |
171 | show = show && selected;
172 | }
173 |
174 | var parentEl = YAHOO.util.Dom.getAncestorBy(element, function (el) {
175 | return YAHOO.util.Dom.hasClass(el, 'question');
176 | });
177 |
178 | parentEl.style.display = show ? 'block' : 'none';
179 | }
180 | };
181 |
182 | /* }}} */
183 |
--------------------------------------------------------------------------------
/CME/admin/components/QuizReport/Index.php:
--------------------------------------------------------------------------------
1 | ui->loadFromXML($this->getUiXml());
35 | $this->initStartDate();
36 | $this->initProviders();
37 | $this->initReportsByQuarter();
38 | $this->initTableViewColumns();
39 | }
40 |
41 | protected function initStartDate()
42 | {
43 | $oldest_date_string = SwatDB::queryOne(
44 | $this->app->db,
45 | 'select min(complete_date) from InquisitionResponse
46 | where complete_date is not null
47 | and reset_date is null
48 | and inquisition in (select quiz from AccountCMEProgress)'
49 | );
50 |
51 | $this->start_date = new SwatDate($oldest_date_string);
52 | $this->start_date->setTimezone($this->app->default_time_zone);
53 | }
54 |
55 | protected function initProviders()
56 | {
57 | $this->providers = SwatDB::query(
58 | $this->app->db,
59 | 'select * from CMEProvider order by title, id',
60 | SwatDBClassMap::get(CMEProviderWrapper::class)
61 | );
62 | }
63 |
64 | protected function initReportsByQuarter()
65 | {
66 | $sql = 'select * from QuizReport order by quarter';
67 | $reports = SwatDB::query(
68 | $this->app->db,
69 | $sql,
70 | SwatDBClassMap::get(CMEQuizReportWrapper::class)
71 | );
72 |
73 | $reports->attachSubDataObjects(
74 | 'provider',
75 | $this->providers
76 | );
77 |
78 | foreach ($reports as $report) {
79 | $quarter = clone $report->quarter;
80 | $quarter->setTimezone($this->app->default_time_zone);
81 | $quarter = $quarter->formatLikeIntl('yyyy-qq');
82 | $provider = $report->provider->shortname;
83 | if (!isset($this->reports_by_quarter[$quarter])) {
84 | $this->reports_by_quarter[$quarter] = [];
85 | }
86 | $this->reports_by_quarter[$quarter][$provider] = $report;
87 | }
88 | }
89 |
90 | protected function initTableViewColumns()
91 | {
92 | $view = $this->ui->getWidget('index_view');
93 | foreach ($this->providers as $provider) {
94 | $renderer = new AdminTitleLinkCellRenderer();
95 | $renderer->link = sprintf(
96 | 'QuizReport/Download?type=%s&quarter=%%s',
97 | $provider->shortname
98 | );
99 | $renderer->stock_id = 'download';
100 | $renderer->text = $provider->title;
101 |
102 | $column = new SwatTableViewColumn();
103 | $column->id = 'provider_' . $provider->shortname;
104 | $column->addRenderer($renderer);
105 | $column->addMappingToRenderer(
106 | $renderer,
107 | 'quarter',
108 | 'link_value'
109 | );
110 |
111 | $column->addMappingToRenderer(
112 | $renderer,
113 | 'is_' . $provider->shortname . '_sensitive',
114 | 'sensitive'
115 | );
116 |
117 | $view->appendColumn($column);
118 | }
119 | }
120 |
121 | // build phase
122 |
123 | protected function getTableModel(SwatView $view): ?SwatTableModel
124 | {
125 | $now = new SwatDate();
126 | $now->setTimezone($this->app->default_time_zone);
127 |
128 | $year = $this->start_date->getYear();
129 |
130 | $start_date = new SwatDate();
131 | $start_date->setTime(0, 0, 0);
132 | $start_date->setDate($year, 1, 1);
133 | $start_date->setTZ($this->app->default_time_zone);
134 |
135 | $end_date = clone $start_date;
136 | $end_date->addMonths(3);
137 |
138 | $display_end_date = clone $end_date;
139 | $display_end_date->subtractMonths(1);
140 |
141 | $quarters = [];
142 |
143 | while ($end_date->before($now)) {
144 | for ($i = 1; $i <= 4; $i++) {
145 | // Only add the quarter to the table model if the start date
146 | // is within or prior to that quarter.
147 | if ($this->start_date->before($end_date)) {
148 | $ds = new SwatDetailsStore();
149 |
150 | $quarter = $start_date->formatLikeIntl('yyyy-qq');
151 |
152 | $ds->date = clone $start_date;
153 | $ds->year = $year;
154 | $ds->quarter = $quarter;
155 |
156 | $ds->quarter_title = sprintf(
157 | CME::_('Q%s - %s to %s'),
158 | $i,
159 | $start_date->formatLikeIntl('MMMM yyyy'),
160 | $display_end_date->formatLikeIntl('MMMM yyyy')
161 | );
162 |
163 | foreach ($this->providers as $provider) {
164 | $shortname = $provider->shortname;
165 | $sensitive = isset(
166 | $this->reports_by_quarter[$quarter][$shortname]
167 | );
168 |
169 | $ds->{'is_' . $shortname . '_sensitive'} = $sensitive;
170 | }
171 |
172 | // reverse the order so we can display newest to oldest
173 | array_unshift($quarters, $ds);
174 | }
175 |
176 | $start_date->addMonths(3);
177 | $end_date->addMonths(3);
178 | $display_end_date->addMonths(3);
179 | }
180 |
181 | $year++;
182 | }
183 |
184 | // display the quarters in reversed order
185 | $store = new SwatTableStore();
186 | foreach ($quarters as $ds) {
187 | $store->add($ds);
188 | }
189 |
190 | return $store;
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/CME/admin/components/EvaluationReport/Index.php:
--------------------------------------------------------------------------------
1 | ui->loadFromXML($this->getUiXml());
36 |
37 | $this->initStartDate();
38 | $this->initProviders();
39 | $this->initReportsByQuarter();
40 | $this->initTableViewColumns();
41 | }
42 |
43 | protected function initStartDate()
44 | {
45 | $oldest_date_string = SwatDB::queryOne(
46 | $this->app->db,
47 | 'select min(complete_date) from InquisitionResponse
48 | where complete_date is not null
49 | and inquisition in (select evaluation from AccountCMEProgress)'
50 | );
51 |
52 | $this->start_date = new SwatDate($oldest_date_string);
53 | $this->start_date->setTimezone($this->app->default_time_zone);
54 | }
55 |
56 | protected function initProviders()
57 | {
58 | $this->providers = SwatDB::query(
59 | $this->app->db,
60 | 'select * from CMEProvider order by title, id',
61 | SwatDBClassMap::get(CMEProviderWrapper::class)
62 | );
63 | }
64 |
65 | protected function initReportsByQuarter()
66 | {
67 | $sql = 'select * from EvaluationReport order by quarter';
68 | $reports = SwatDB::query(
69 | $this->app->db,
70 | $sql,
71 | SwatDBClassMap::get(CMEEvaluationReportWrapper::class)
72 | );
73 |
74 | $reports->attachSubDataObjects(
75 | 'provider',
76 | $this->providers
77 | );
78 |
79 | foreach ($reports as $report) {
80 | $quarter = clone $report->quarter;
81 | $quarter->setTimezone($this->app->default_time_zone);
82 | $quarter = $quarter->formatLikeIntl('yyyy-qq');
83 | $provider = $report->provider->shortname;
84 | if (!isset($this->reports_by_quarter[$quarter])) {
85 | $this->reports_by_quarter[$quarter] = [];
86 | }
87 | $this->reports_by_quarter[$quarter][$provider] = $report;
88 | }
89 | }
90 |
91 | protected function initTableViewColumns()
92 | {
93 | $view = $this->ui->getWidget('index_view');
94 | foreach ($this->providers as $provider) {
95 | $renderer = new AdminTitleLinkCellRenderer();
96 | $renderer->link = sprintf(
97 | 'EvaluationReport/Download?type=%s&quarter=%%s',
98 | $provider->shortname
99 | );
100 | $renderer->stock_id = 'download';
101 | $renderer->text = $provider->title;
102 |
103 | $column = new SwatTableViewColumn();
104 | $column->id = 'provider_' . $provider->shortname;
105 | $column->addRenderer($renderer);
106 | $column->addMappingToRenderer(
107 | $renderer,
108 | 'quarter',
109 | 'link_value'
110 | );
111 |
112 | $column->addMappingToRenderer(
113 | $renderer,
114 | 'is_' . $provider->shortname . '_sensitive',
115 | 'sensitive'
116 | );
117 |
118 | $view->appendColumn($column);
119 | }
120 | }
121 |
122 | // build phase
123 |
124 | protected function getTableModel(SwatView $view): ?SwatTableModel
125 | {
126 | $now = new SwatDate();
127 | $now->setTimezone($this->app->default_time_zone);
128 |
129 | $year = $this->start_date->getYear();
130 |
131 | $start_date = new SwatDate();
132 | $start_date->setTime(0, 0, 0);
133 | $start_date->setDate($year, 1, 1);
134 | $start_date->setTZ($this->app->default_time_zone);
135 |
136 | $end_date = clone $start_date;
137 | $end_date->addMonths(3);
138 |
139 | $display_end_date = clone $end_date;
140 | $display_end_date->subtractMonths(1);
141 |
142 | $quarters = [];
143 |
144 | while ($end_date->before($now)) {
145 | for ($i = 1; $i <= 4; $i++) {
146 | // Only add the quarter to the table model if the start date
147 | // is within or prior to that quarter.
148 | if ($this->start_date->before($end_date)) {
149 | $ds = new SwatDetailsStore();
150 |
151 | $quarter = $start_date->formatLikeIntl('yyyy-qq');
152 |
153 | $ds->date = clone $start_date;
154 | $ds->year = $year;
155 | $ds->quarter = $quarter;
156 |
157 | $ds->quarter_title = sprintf(
158 | CME::_('Q%s - %s to %s'),
159 | $i,
160 | $start_date->formatLikeIntl('MMMM yyyy'),
161 | $display_end_date->formatLikeIntl('MMMM yyyy')
162 | );
163 |
164 | foreach ($this->providers as $provider) {
165 | $shortname = $provider->shortname;
166 | $sensitive = isset(
167 | $this->reports_by_quarter[$quarter][$shortname]
168 | );
169 |
170 | $ds->{'is_' . $shortname . '_sensitive'} = $sensitive;
171 | }
172 |
173 | // reverse the order so we can display newest to oldest
174 | array_unshift($quarters, $ds);
175 | }
176 |
177 | $start_date->addMonths(3);
178 | $end_date->addMonths(3);
179 | $display_end_date->addMonths(3);
180 | }
181 |
182 | $year++;
183 | }
184 |
185 | // display the quarters in reversed order
186 | $store = new SwatTableStore();
187 | foreach ($quarters as $ds) {
188 | $store->add($ds);
189 | }
190 |
191 | return $store;
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/CME/CMECertificateFactory.php:
--------------------------------------------------------------------------------
1 |
12 | * app, 'aaem');
16 | *
17 | * // register a new certificate class for AAEM
18 | * CMECertificateFactory::registerCertificate('aaem', 'MyCertificate');
19 | *
20 | * // create a new certificate of class 'MyCertificate'
21 | * $certificate = CMECertificateFactory::get($this->app, 'aaem');
22 | *
23 | * ?>
24 | *
25 | *
26 | * When an undefined certificate class is requested, the factory attempts to
27 | * find and require a class-definition file for the certificate class using the
28 | * factory search path. All search paths are relative to the PHP include path.
29 | * The search path 'CME/certificates' is included by default.
30 | * Search paths can be added and removed using the
31 | * {@link CMECertificateFactory::addPath()} and
32 | * {@link CMECertificateFactory::removePath()} methods.
33 | *
34 | * @copyright 2014-2016 silverorange
35 | * @license http://www.opensource.org/licenses/mit-license.html MIT License
36 | *
37 | * @see CMECertificate
38 | */
39 | class CMECertificateFactory extends SwatObject
40 | {
41 | /**
42 | * List of registered certificate classes indexed by the certificate type.
43 | *
44 | * @var array
45 | */
46 | private static $certificate_class_names_by_type = [];
47 |
48 | /**
49 | * Paths to search for class-definition files.
50 | *
51 | * @var array
52 | */
53 | private static $search_paths = ['CME/certificates'];
54 |
55 | /**
56 | * Gets a certificate of the specified type.
57 | *
58 | * @param SiteApplication $app the application in which to get the
59 | * certificate
60 | * @param string $type the type of certificate to get. There must
61 | * be a certificate class registered for this
62 | * type.
63 | *
64 | * @return CMECertificate the certificate of the specified type. The
65 | * certificate will be an instance of whatever class
66 | * was registered for the certificate type.
67 | *
68 | * @throws InvalidArgumentException if there is no certificate registered
69 | * for the requested $type
70 | */
71 | public static function get(SiteApplication $app, $type)
72 | {
73 | $type = strval($type);
74 | if (!array_key_exists($type, self::$certificate_class_names_by_type)) {
75 | throw new InvalidArgumentException(
76 | sprintf(
77 | 'No certificates are registered with the type "%s".',
78 | $type
79 | )
80 | );
81 | }
82 |
83 | $certificate_class_name = self::$certificate_class_names_by_type[$type];
84 | self::loadCertificateClass($certificate_class_name);
85 |
86 | $certificate = new $certificate_class_name();
87 | $certificate->setApplication($app);
88 |
89 | return $certificate;
90 | }
91 |
92 | /**
93 | * Registers a certificate class with the factory.
94 | *
95 | * Certificate classes must be registed with the factory before they are
96 | * used. When a certificate class is registered for a particular type, an
97 | * instance of the certificate class is returned whenever a certificate of
98 | * that type is requested.
99 | *
100 | * @param string $type the certificate type
101 | * @param string $certificate_class_name the class name of the certificate.
102 | * The class does not need to be
103 | * defined until a certificate of the
104 | * specified type is requested.
105 | */
106 | public static function registerCertificate($type, $certificate_class_name)
107 | {
108 | $type = strval($type);
109 | self::$certificate_class_names_by_type[$type] = $certificate_class_name;
110 | }
111 |
112 | /**
113 | * Adds a search path for class-definition files.
114 | *
115 | * When an undefined certificate class is requested, the factory attempts
116 | * to find and require a class-definition file for the certificate class.
117 | *
118 | * All search paths are relative to the PHP include path. The search path
119 | * 'CME/certificates' is included by default.
120 | *
121 | * @param string $search_path the path to search for certificate
122 | * class-definition files
123 | *
124 | * @see CMECertificateFactory::removePath()
125 | */
126 | public static function addPath($search_path)
127 | {
128 | if (!in_array($search_path, self::$search_paths, true)) {
129 | // add path to front of array since it is more likely we will find
130 | // class-definitions in manually added search paths
131 | array_unshift(self::$search_paths, $search_path);
132 | }
133 | }
134 |
135 | /**
136 | * Removes a search path for certificate class-definition files.
137 | *
138 | * @param string $path the path to remove
139 | *
140 | * @see CMECertificateFactory::addPath()
141 | */
142 | public static function removePath($path)
143 | {
144 | $index = array_search($path, self::$search_paths);
145 | if ($index !== false) {
146 | array_splice(self::$search_paths, $index, 1);
147 | }
148 | }
149 |
150 | /**
151 | * Loads a certificate class-definition if it is not defined.
152 | *
153 | * This checks the factory search path for an appropriate source file.
154 | *
155 | * @param string $certificate_class_name the name of the certificate class
156 | *
157 | * @throws SwatClassNotFoundException if the certificate class is not
158 | * defined and no suitable file in the
159 | * certificate search path contains the
160 | * class definition
161 | */
162 | private static function loadCertificateClass($certificate_class_name)
163 | {
164 | if (!class_exists($certificate_class_name)) {
165 | throw new SwatClassNotFoundException(
166 | sprintf(
167 | 'Certificate class "%s" does not exist and could not ' .
168 | 'be found in the search path.',
169 | $certificate_class_name
170 | ),
171 | 0,
172 | $certificate_class_name
173 | );
174 | }
175 | }
176 |
177 | /**
178 | * This class contains only static methods and should not be instantiated.
179 | */
180 | private function __construct() {}
181 | }
182 |
--------------------------------------------------------------------------------
/CME/admin/components/Credit/Edit.php:
--------------------------------------------------------------------------------
1 | initCredit();
31 | $this->initInquisition();
32 | $this->initFrontMatter();
33 |
34 | $this->ui->loadFromXML($this->getUiXml());
35 |
36 | // hide question import field when editing an existing credit
37 | if ($this->credit->quiz instanceof CMEQuiz) {
38 | $this->ui->getWidget('questions_field')->visible = false;
39 | }
40 |
41 | $this->setDefaultValues();
42 | }
43 |
44 | protected function initInquisition()
45 | {
46 | if ($this->credit->quiz instanceof CMEQuiz) {
47 | $this->inquisition = $this->credit->quiz;
48 | } else {
49 | $this->inquisition = SwatDBClassMap::new(CMEQuiz::class);
50 | $this->inquisition->setDatabase($this->app->db);
51 | }
52 | }
53 |
54 | protected function initCredit()
55 | {
56 | $this->credit = SwatDBClassMap::new(CMECredit::class);
57 | $this->credit->setDatabase($this->app->db);
58 |
59 | if (!$this->isNew()) {
60 | if (!$this->credit->load($this->id)) {
61 | throw new AdminNotFoundException(
62 | sprintf(
63 | 'A CME credit with the id of ‘%s’ does not exist.',
64 | $this->id
65 | )
66 | );
67 | }
68 | $this->credit->expiry_date->convertTZ($this->app->default_time_zone);
69 | } else {
70 | $this->credit->is_free =
71 | ($this->app->initVar('credit_type') === 'free');
72 | }
73 | }
74 |
75 | protected function initFrontMatter()
76 | {
77 | if ($this->isNew()) {
78 | $front_matter_id = SiteApplication::initVar('front-matter');
79 | $this->credit->front_matter = SwatDBClassMap::new(CMEFrontMatter::class);
80 | $this->credit->front_matter->setDatabase($this->app->db);
81 | if (!$this->credit->front_matter->load($front_matter_id)) {
82 | throw new AdminNotFoundException(
83 | sprintf(
84 | 'A CME front matter with the id of ‘%s’ does not ' .
85 | 'exist.',
86 | $front_matter_id
87 | )
88 | );
89 | }
90 | }
91 | }
92 |
93 | protected function getDefaultCreditHours()
94 | {
95 | return 1;
96 | }
97 |
98 | protected function getDefaultCreditExpiryDate()
99 | {
100 | return new SwatDate('+3 years');
101 | }
102 |
103 | protected function setDefaultValues()
104 | {
105 | $this->ui->getWidget('hours')->value = $this->getDefaultCreditHours();
106 | $this->ui->getWidget('expiry_date')->value =
107 | $this->getDefaultCreditExpiryDate();
108 | }
109 |
110 | // process phase
111 |
112 | protected function validate(): void
113 | {
114 | parent::validate();
115 |
116 | // Import questions file in validate step so we can show error
117 | // messages. The importer only modifies the inquisition object and does
118 | // not save it to the database.
119 | $questions_file = $this->ui->getWidget('questions_file');
120 | if ($questions_file->isUploaded()) {
121 | $this->importInquisition($questions_file->getTempFileName());
122 | }
123 | }
124 |
125 | protected function importInquisition($filename)
126 | {
127 | try {
128 | $file = new InquisitionFileParser($filename);
129 | $importer = new InquisitionImporter($this->app);
130 | $importer->importInquisition($this->inquisition, $file);
131 | } catch (InquisitionImportException $e) {
132 | $this->ui->getWidget('questions_file')->addMessage(
133 | new SwatMessage($e->getMessage())
134 | );
135 | }
136 | }
137 |
138 | protected function saveDBData(): void
139 | {
140 | $this->updateInquisition();
141 | $modified = $this->inquisition->isModified();
142 | $this->inquisition->save();
143 |
144 | $this->updateCredit();
145 | $modified = $modified || $this->credit->isModified();
146 | $this->credit->save();
147 |
148 | if ($modified) {
149 | $this->app->messages->add($this->getSavedMessage());
150 | }
151 | }
152 |
153 | protected function updateCredit()
154 | {
155 | $values = $this->ui->getValues(
156 | [
157 | 'hours',
158 | 'expiry_date',
159 | ]
160 | );
161 |
162 | $this->credit->hours = $values['hours'];
163 | $this->credit->expiry_date = $values['expiry_date'];
164 | $this->credit->expiry_date->setTZ($this->app->default_time_zone)->toUTC();
165 | $this->credit->quiz = $this->inquisition;
166 | $this->credit->front_matter = $this->credit->front_matter->id;
167 |
168 | // if hours updated, clear all cached hours for accounts
169 | if (!$this->isNew()
170 | && $this->credit->hours != $values['hours']) {
171 | $this->app->memcache->flushNs('cme-hours');
172 | }
173 | }
174 |
175 | protected function getSavedMessage()
176 | {
177 | return new SwatMessage(
178 | sprintf(
179 | CME::_('%s has been saved.'),
180 | $this->credit->getTitle()
181 | )
182 | );
183 | }
184 |
185 | protected function relocate()
186 | {
187 | $this->app->relocate(
188 | sprintf(
189 | 'Credit/Details?id=%s',
190 | $this->credit->id
191 | )
192 | );
193 | }
194 |
195 | // build phase
196 |
197 | protected function buildInternal()
198 | {
199 | parent::buildInternal();
200 |
201 | $this->ui->getWidget('edit_frame')->title = $this->credit->getTitle();
202 |
203 | $provider_titles = [];
204 | foreach ($this->credit->front_matter->providers as $provider) {
205 | $provider_titles[] = $provider->credit_title_plural;
206 | }
207 |
208 | $this->ui->getWidget('hours_field')->title =
209 | SwatString::toList($provider_titles);
210 | }
211 |
212 | protected function buildNavBar()
213 | {
214 | AdminDBEdit::buildNavBar();
215 |
216 | $this->navbar->popEntry();
217 |
218 | $title = $this->isNew()
219 | ? CME::_('New %s')
220 | : CME::_('Edit %s');
221 |
222 | $this->navbar->createEntry(
223 | sprintf(
224 | $title,
225 | $this->credit->getTitle()
226 | )
227 | );
228 | }
229 |
230 | protected function buildForm()
231 | {
232 | parent::buildForm();
233 |
234 | if ($this->isNew()) {
235 | $this->ui->getWidget('edit_form')->addHiddenField(
236 | 'front-matter',
237 | $this->credit->front_matter->id
238 | );
239 | }
240 | }
241 |
242 | protected function loadDBData()
243 | {
244 | parent::loadDBData();
245 |
246 | $this->ui->setValues($this->credit->getAttributes());
247 | }
248 | }
249 |
--------------------------------------------------------------------------------
/CME/CMEReportUpdater.php:
--------------------------------------------------------------------------------
1 | initModules();
27 | $this->parseCommandLineArguments();
28 |
29 | $this->initStartDate();
30 | $this->initProviders();
31 | $this->initReportsByQuarter();
32 |
33 | $this->debug($this->getStatusLine(), true);
34 |
35 | $this->lock();
36 |
37 | foreach ($this->providers as $provider) {
38 | $this->debug("{$provider->title}:\n", true);
39 | $shortname = $provider->shortname;
40 |
41 | foreach ($this->getQuarters($provider) as $quarter) {
42 | $quarter_id = $quarter->formatLikeIntl('qqq-yyyy');
43 | $this->debug("=> Quarter {$quarter_id}:\n");
44 | if (isset($this->reports_by_quarter[$quarter_id][$shortname])) {
45 | $this->debug(" => report exists\n");
46 | } else {
47 | // Make the dataobject first so we can use its file path
48 | // methods but only save after the file has been
49 | // generated.
50 | $report = $this->getDataObject(
51 | $quarter,
52 | $provider,
53 | $this->getFilename($quarter, $provider)
54 | );
55 |
56 | $this->debug(' => generating report ... ');
57 | $this->saveReport(
58 | $quarter,
59 | $provider,
60 | $report->getFilePath()
61 | );
62 | $this->debug("[done]\n");
63 |
64 | $this->debug(' => saving data object ... ');
65 | $report->save();
66 | $this->debug("[done]\n");
67 | }
68 |
69 | $this->debug("\n");
70 | }
71 |
72 | $this->debug("\n");
73 | }
74 |
75 | $this->unlock();
76 |
77 | $this->debug("All done.\n", true);
78 | }
79 |
80 | /**
81 | * @return string
82 | */
83 | abstract protected function getStatusLine();
84 |
85 | abstract protected function getReports();
86 |
87 | abstract protected function getReportClassName();
88 |
89 | abstract protected function getReportGenerator(
90 | CMEProvider $provider,
91 | $year,
92 | $quarter
93 | );
94 |
95 | abstract protected function getFileBase();
96 |
97 | abstract protected function getFilenamePattern();
98 |
99 | protected function initStartDate()
100 | {
101 | $oldest_date_string = SwatDB::queryOne(
102 | $this->db,
103 | 'select min(earned_date) from AccountEarnedCMECredit
104 | inner join CMECredit
105 | on AccountEarnedCMECredit.credit = CMECredit.id
106 | inner join CMEFrontMatter
107 | on CMECredit.front_matter = CMEFrontMatter.id'
108 | );
109 |
110 | $this->start_date = new SwatDate($oldest_date_string);
111 | }
112 |
113 | protected function initProviders()
114 | {
115 | $this->providers = SwatDB::query(
116 | $this->db,
117 | 'select * from CMEProvider order by title, id',
118 | SwatDBClassMap::get(CMEProviderWrapper::class)
119 | );
120 | }
121 |
122 | protected function initReportsByQuarter()
123 | {
124 | $this->reports_by_quarter = [];
125 |
126 | $reports = $this->getReports();
127 | $reports->attachSubDataObjects(
128 | 'provider',
129 | $this->providers
130 | );
131 |
132 | foreach ($reports as $report) {
133 | $quarter = clone $report->quarter;
134 | $quarter->convertTZ($this->default_time_zone);
135 | $quarter = $quarter->formatLikeIntl('qqq-yyyy');
136 | $provider = $report->provider->shortname;
137 | if (!isset($this->reports_by_quarter[$quarter])) {
138 | $this->reports_by_quarter[$quarter] = [];
139 | }
140 | $this->reports_by_quarter[$quarter][$provider] = $report;
141 | }
142 | }
143 |
144 | protected function getQuarters(CMEProvider $provider)
145 | {
146 | $quarters = [];
147 |
148 | $now = new SwatDate();
149 | $now->convertTZ($this->default_time_zone);
150 |
151 | $year = $this->start_date->getYear();
152 |
153 | $start_date = new SwatDate();
154 | $start_date->setTime(0, 0, 0);
155 | $start_date->setDate($year, 1, 1);
156 | $start_date->setTZ($this->default_time_zone);
157 |
158 | $end_date = clone $start_date;
159 | $end_date->addMonths(3);
160 |
161 | $display_end_date = clone $end_date;
162 | $display_end_date->subtractMonths(1);
163 |
164 | while ($end_date->before($now)) {
165 | for ($quarter = 1; $quarter <= 4; $quarter++) {
166 | // Make sure the quarter has ended before generating the
167 | // report. Reports are cached and are not regenerated when new
168 | // data is available. If reports are generated for partial
169 | // quarters, the partial report is cached until the cache is
170 | // manually cleared.
171 | if ($end_date->after($now)) {
172 | break;
173 | }
174 |
175 | $num_credits = $this->getQuarterEarnedCredits(
176 | $provider,
177 | $year,
178 | $quarter
179 | );
180 |
181 | if ($num_credits > 0) {
182 | $quarters[] = clone $start_date;
183 | }
184 |
185 | $start_date->addMonths(3);
186 | $end_date->addMonths(3);
187 | $display_end_date->addMonths(3);
188 | }
189 |
190 | $year++;
191 | }
192 |
193 | return $quarters;
194 | }
195 |
196 | protected function getQuarterEarnedCredits(
197 | CMEProvider $provider,
198 | $year,
199 | $quarter
200 | ) {
201 | $start_month = (($quarter - 1) * 3) + 1;
202 |
203 | $start_date = new SwatDate();
204 | $start_date->setTime(0, 0, 0);
205 | $start_date->setDate($year, $start_month, 1);
206 | $start_date->setTZ($this->default_time_zone);
207 |
208 | $end_date = clone $start_date;
209 | $end_date->addMonths(3);
210 |
211 | $sql = sprintf(
212 | 'select count(1)
213 | from AccountCMEProgressCreditBinding
214 | inner join AccountCMEProgress on
215 | AccountCMEProgressCreditBinding.progress = AccountCMEProgress.id
216 | inner join AccountEarnedCMECredit on
217 | AccountEarnedCMECredit.account = AccountCMEProgress.account
218 | and AccountCMEProgressCreditBinding.credit =
219 | AccountEarnedCMECredit.credit
220 | inner join CMECredit on
221 | CMECredit.id = AccountEarnedCMECredit.credit
222 | inner join Account on AccountCMEProgress.account = Account.id
223 | where CMECredit.front_matter in (
224 | select CMEFrontMatterProviderBinding.front_matter
225 | from CMEFrontMatterProviderBinding
226 | where CMEFrontMatterProviderBinding.provider = %s
227 | )
228 | and convertTZ(earned_date, %s) >= %s
229 | and convertTZ(earned_date, %s) < %s
230 | and Account.delete_date is null',
231 | $this->db->quote($provider->id, 'integer'),
232 | $this->db->quote($this->config->date->time_zone, 'text'),
233 | $this->db->quote($start_date->getDate(), 'date'),
234 | $this->db->quote($this->config->date->time_zone, 'text'),
235 | $this->db->quote($end_date->getDate(), 'date')
236 | );
237 |
238 | return SwatDB::queryOne($this->db, $sql);
239 | }
240 |
241 | protected function getDataObject(
242 | SwatDate $quarter,
243 | CMEProvider $provider,
244 | $filename
245 | ) {
246 | $class_name = $this->getReportClassName();
247 | $report = new $class_name();
248 | $report->setDatabase($this->db);
249 | $report->setFileBase($this->getFileBase());
250 |
251 | $quarter = clone $quarter;
252 | $quarter->toUTC();
253 |
254 | $report->quarter = $quarter;
255 | $report->provider = $provider;
256 | $report->filename = $filename;
257 | $report->createdate = new SwatDate();
258 | $report->createdate->toUTC();
259 |
260 | return $report;
261 | }
262 |
263 | protected function saveReport(
264 | SwatDate $quarter,
265 | CMEProvider $provider,
266 | $filepath
267 | ) {
268 | $year = $quarter->getYear();
269 | $quarter = intval($quarter->formatLikeIntl('qq'));
270 |
271 | $report = $this->getReportGenerator(
272 | $provider,
273 | $year,
274 | $quarter
275 | );
276 |
277 | $report->saveFile($filepath);
278 | }
279 |
280 | protected function getFilename(
281 | SwatDate $quarter,
282 | CMEProvider $provider
283 | ) {
284 | // replace spaces with dashes
285 | $title = str_replace(' ', '-', $provider->title);
286 |
287 | // strip non-word or dash characters
288 | $title = preg_replace('/[^\w-]/', '', $title);
289 |
290 | return sprintf(
291 | $this->getFilenamePattern(),
292 | $title,
293 | $quarter->formatLikeIntl('QQQ-yyyy')
294 | );
295 | }
296 |
297 | /**
298 | * Gets the list of modules to load for this search indexer.
299 | *
300 | * @return array the list of modules to load for this application
301 | *
302 | * @see SiteApplication::getDefaultModuleList()
303 | */
304 | protected function getDefaultModuleList()
305 | {
306 | return array_merge(
307 | parent::getDefaultModuleList(),
308 | [
309 | 'config' => SiteCommandLineConfigModule::class,
310 | 'database' => SiteDatabaseModule::class,
311 | ]
312 | );
313 | }
314 | }
315 |
--------------------------------------------------------------------------------
/CME/pages/CMECertificatePage.php:
--------------------------------------------------------------------------------
1 | array(
17 | * 'front_matter' => CMEFrontMatter,
18 | * 'credits' => CMEAccountEarnedCMECreditWrapper,
19 | * )
20 | * ) …
21 | */
22 | protected $credits_by_front_matter;
23 |
24 | /**
25 | * @var bool
26 | */
27 | protected $has_pre_selection = false;
28 |
29 | protected function getUiXml()
30 | {
31 | return __DIR__ . '/cme-certificate.xml';
32 | }
33 |
34 | // init phase
35 |
36 | public function init()
37 | {
38 | if (!$this->app->session->isLoggedIn()) {
39 | $uri = sprintf(
40 | '%s?relocate=%s',
41 | $this->app->config->uri->account_login,
42 | $this->source
43 | );
44 |
45 | $this->app->relocate($uri);
46 | }
47 | $account = $this->app->session->account;
48 |
49 | $key = 'cme-hours-' . $account->id;
50 |
51 | $hours = $this->app->getCacheValue($key, 'cme-hours');
52 | if ($hours === false) {
53 | $hours = $account->getEarnedCMECreditHours();
54 | $this->app->addCacheValue($hours, $key, 'cme-hours');
55 | }
56 |
57 | // If no hours are earned and no CME access is available, go to account
58 | // details. Not using strict equality because $hours can be a float
59 | // value.
60 | if (!$account->hasCMEAccess() && $hours == 0) {
61 | $this->app->relocate('account');
62 | }
63 |
64 | parent::init();
65 | }
66 |
67 | protected function initInternal()
68 | {
69 | parent::initInternal();
70 | $this->initCredits();
71 | $this->initList();
72 | }
73 |
74 | protected function initCredits()
75 | {
76 | $account = $this->app->session->account;
77 | $this->credits_by_front_matter = [];
78 |
79 | $wrapper_class = SwatDBClassMap::get(CMEAccountEarnedCMECreditWrapper::class);
80 |
81 | foreach ($account->earned_cme_credits as $credit) {
82 | $front_matter = $credit->credit->front_matter;
83 | if (!isset($this->credits_by_front_matter[$front_matter->id])) {
84 | $wrapper = new $wrapper_class();
85 | $wrapper->setDatabase(
86 | $this->app->db
87 | );
88 |
89 | $this->credits_by_front_matter[$front_matter->id] = [
90 | 'front_matter' => $front_matter,
91 | 'credits' => new $wrapper(),
92 | ];
93 | }
94 |
95 | $this->credits_by_front_matter[$front_matter->id]['credits']->add(
96 | $credit
97 | );
98 | }
99 | }
100 |
101 | protected function getEpisodeIds()
102 | {
103 | $episode_ids = [];
104 | $selected_front_matter_ids =
105 | $this->ui->getWidget('front_matters')->values;
106 |
107 | foreach ($this->credits_by_front_matter as $id => $array) {
108 | $front_matter = $array['front_matter'];
109 | if (in_array($id, $selected_front_matter_ids)) {
110 | $episode_ids[] = $front_matter->episode->id;
111 | }
112 | }
113 |
114 | return $episode_ids;
115 | }
116 |
117 | protected function initList()
118 | {
119 | $values = [];
120 | $list = $this->ui->getWidget('front_matters');
121 |
122 | foreach ($this->credits_by_front_matter as $array) {
123 | $front_matter = $array['front_matter'];
124 |
125 | $list->addOption(
126 | $this->getListOption($front_matter),
127 | $this->getListOptionMetaData($front_matter)
128 | );
129 |
130 | if ($this->isPreSelected($front_matter)) {
131 | $this->has_pre_selection = true;
132 | $values[] = $front_matter->id;
133 | }
134 | }
135 |
136 | $list->values = $values;
137 | }
138 |
139 | protected function getListOption(CMEFrontMatter $front_matter)
140 | {
141 | return new SwatOption(
142 | $front_matter->id,
143 | $this->getListOptionTitle($front_matter),
144 | 'text/xml'
145 | );
146 | }
147 |
148 | protected function getListOptionMetaData(CMEFrontMatter $front_matter)
149 | {
150 | return [];
151 | }
152 |
153 | protected function getListOptionTitle(CMEFrontMatter $front_matter)
154 | {
155 | $account = $this->app->session->account;
156 | $hours = $account->getEarnedCMECreditHoursByFrontMatter($front_matter);
157 | $locale = SwatI18NLocale::get();
158 |
159 | ob_start();
160 |
161 | $this->displayTitle($front_matter);
162 |
163 | $field = (abs($hours - 1.0) < 0.01)
164 | ? 'credit_title'
165 | : 'credit_title_plural';
166 |
167 | $titles = [];
168 | foreach ($front_matter->providers as $provider) {
169 | $em_tag = new SwatHtmlTag('em');
170 | $em_tag->setContent($provider->{$field});
171 | $titles[] = $em_tag->__toString();
172 | }
173 | $formatted_provider_credit_title = SwatString::toList($titles);
174 |
175 | $hours_span = new SwatHtmlTag('span');
176 | $hours_span->class = 'hours';
177 | $hours_span->setContent(
178 | sprintf(
179 | CME::_('%s %s from %s'),
180 | SwatString::minimizeEntities($locale->formatNumber($hours)),
181 | $formatted_provider_credit_title,
182 | SwatString::minimizeEntities(
183 | $front_matter->getProviderTitleList()
184 | )
185 | ),
186 | 'text/xml'
187 | );
188 | $hours_span->display();
189 |
190 | $details = $this->getFrontMatterDetails($front_matter);
191 | if ($details != '') {
192 | $details_span = new SwatHtmlTag('span');
193 | $details_span->class = 'details';
194 | $details_span->setContent($details);
195 | $details_span->display();
196 | }
197 |
198 | return ob_get_clean();
199 | }
200 |
201 | protected function displayTitle(CMEFrontMatter $front_matter)
202 | {
203 | $title_span = new SwatHtmlTag('span');
204 | $title_span->class = 'title';
205 | $title_span->setContent($this->getFrontMatterTitle($front_matter));
206 | $title_span->display();
207 | }
208 |
209 | abstract protected function getFrontMatterTitle(
210 | CMEFrontMatter $credit
211 | );
212 |
213 | protected function getFrontMatterDetails(CMEFrontMatter $front_matter)
214 | {
215 | return '';
216 | }
217 |
218 | protected function isPreSelected(CMEFrontMatter $front_matter)
219 | {
220 | $selected = SiteApplication::initVar(
221 | 'selected',
222 | null,
223 | SiteApplication::VAR_GET
224 | );
225 |
226 | return is_array($selected) && in_array($front_matter->id, $selected);
227 | }
228 |
229 | // process phase
230 |
231 | protected function processInternal()
232 | {
233 | $front_matter_ids = $this->ui->getWidget('front_matters')->values;
234 |
235 | $wrapper = SwatDBClassMap::get(CMEAccountEarnedCMECreditWrapper::class);
236 |
237 | $form = $this->ui->getWidget('certificate_form');
238 | if ($form->isProcessed() && count($front_matter_ids) === 0) {
239 | $this->ui->getWidget('message_display')->add(
240 | new SwatMessage(
241 | CME::_('No credits were selected to print.')
242 | ),
243 | SwatMessageDisplay::DISMISS_OFF
244 | );
245 | }
246 | }
247 |
248 | protected function isProcessed()
249 | {
250 | $form = $this->ui->getWidget('certificate_form');
251 |
252 | return $this->has_pre_selection || $form->isProcessed();
253 | }
254 |
255 | // build phase
256 |
257 | protected function buildInternal()
258 | {
259 | parent::buildInternal();
260 |
261 | $form = $this->ui->getWidget('certificate_form');
262 | $form->action = $this->getSource();
263 |
264 | if ($this->isProcessed()) {
265 | $this->buildCertificates();
266 | ob_start();
267 | Swat::displayInlineJavaScript($this->getInlineJavaScript());
268 | $this->ui->getWidget('certificate')->content = ob_get_clean();
269 | }
270 | }
271 |
272 | protected function buildContent()
273 | {
274 | $content = $this->layout->data->content;
275 | $this->layout->data->content = '';
276 |
277 | $this->ui->getWidget('article_bodytext')->content = $content;
278 | $this->ui->getWidget('article_bodytext')->content_type = 'text/xml';
279 |
280 | parent::buildContent();
281 | }
282 |
283 | protected function buildTitle()
284 | {
285 | parent::buildTitle();
286 | $this->layout->data->title = CME::_('Print CME Certificates');
287 | }
288 |
289 | abstract protected function buildCertificates();
290 |
291 | protected function getInlineJavaScript()
292 | {
293 | $episode_array = json_encode($this->getEpisodeIds());
294 |
295 | return << 0) {
310 | window.print();
311 | }
312 | });
313 | JAVASCRIPT;
314 | }
315 |
316 | // finalize phase
317 |
318 | public function finalize()
319 | {
320 | parent::finalize();
321 |
322 | $this->layout->addBodyClass('cme-certificate-page');
323 |
324 | $yui = new SwatYUI(['dom', 'event']);
325 | $this->layout->addHtmlHeadEntrySet($yui->getHtmlHeadEntrySet());
326 |
327 | $this->layout->addHtmlHeadEntry(
328 | 'packages/cme/javascript/cme-certificate-page.js'
329 | );
330 | }
331 | }
332 |
--------------------------------------------------------------------------------
/CME/pages/CMEQuizResponseServer.php:
--------------------------------------------------------------------------------
1 | setLayout(
13 | new SiteLayout(
14 | $this->app,
15 | SiteJSONTemplate::class
16 | )
17 | );
18 | }
19 |
20 | protected function getArgumentMap()
21 | {
22 | return [
23 | 'credits' => [0, null],
24 | ];
25 | }
26 |
27 | // build phase
28 |
29 | public function build()
30 | {
31 | $this->layout->startCapture('content');
32 | echo json_encode($this->getJSONResponse());
33 | $this->layout->endCapture();
34 | }
35 |
36 | protected function getJSONResponse()
37 | {
38 | // remember per-question timestamps to handle race conditions
39 | if (!isset($this->app->session->quiz_question_timestamp)) {
40 | $this->app->session->quiz_question_timestamp = new ArrayObject();
41 | }
42 | $quiz_question_timestamp = $this->app->session->quiz_question_timestamp;
43 |
44 | $transaction = new SwatDBTransaction($this->app->db);
45 |
46 | try {
47 | if (!$this->app->session->isLoggedIn()) {
48 | return $this->getErrorResponse('Not logged in.');
49 | }
50 |
51 | $account = $this->app->session->account;
52 |
53 | $quiz = $this->getQuiz($this->getProgress($this->getCredits()));
54 | if (!$quiz instanceof CMEQuiz) {
55 | return $this->getErrorResponse('Quiz not found.');
56 | }
57 |
58 | $binding_id = SiteApplication::initVar(
59 | 'binding_id',
60 | null,
61 | SiteApplication::VAR_POST
62 | );
63 |
64 | if ($binding_id === null) {
65 | return $this->getErrorResponse(
66 | 'Question binding not specified.'
67 | );
68 | }
69 |
70 | $binding = $this->getQuestionBinding($quiz, $binding_id);
71 |
72 | $timestamp = SiteApplication::initVar(
73 | 'timestamp',
74 | null,
75 | SiteApplication::VAR_POST
76 | );
77 |
78 | if ($timestamp === null) {
79 | return $this->getErrorResponse('Timestamp not specified.');
80 | }
81 |
82 | if (isset($quiz_question_timestamp[$binding_id])
83 | && $timestamp < $quiz_question_timestamp[$binding_id]) {
84 | return $this->getErrorResponse('Request is out of sequence.');
85 | }
86 |
87 | $quiz_question_timestamp[$binding_id] = $timestamp;
88 |
89 | $option_id = SiteApplication::initVar(
90 | 'option_id',
91 | null,
92 | SiteApplication::VAR_POST
93 | );
94 |
95 | if ($option_id === null) {
96 | return $this->getErrorResponse(
97 | 'Response option id not specified.'
98 | );
99 | }
100 |
101 | $response = $this->getResponse($quiz);
102 | $response_value = $this->getResponseValue(
103 | $quiz,
104 | $response,
105 | $binding,
106 | $option_id
107 | );
108 |
109 | if ($response_value === null) {
110 | return $this->getErrorResponse(
111 | 'Response option id not valid for the specified question.'
112 | );
113 | }
114 |
115 | $this->saveResponseValue($response, $response_value);
116 |
117 | $transaction->commit();
118 | } catch (Throwable $e) {
119 | $transaction->rollback();
120 |
121 | throw $e;
122 | }
123 |
124 | return [
125 | 'status' => [
126 | 'code' => 'ok',
127 | 'message' => '',
128 | ],
129 | 'timestamp' => time(),
130 | ];
131 | }
132 |
133 | protected function getErrorResponse($message)
134 | {
135 | return [
136 | 'status' => [
137 | 'code' => 'error',
138 | 'message' => $message,
139 | ],
140 | 'timestamp' => time(),
141 | ];
142 | }
143 |
144 | protected function getCredits()
145 | {
146 | $ids = [];
147 | foreach (explode('-', $this->getArgument('credits')) as $id) {
148 | if ($id != '') {
149 | $ids[] = $this->app->db->quote($id, 'integer');
150 | }
151 | }
152 |
153 | if (count($ids) === 0) {
154 | throw new SiteNotFoundException('A CME credit must be provided.');
155 | }
156 |
157 | $sql = sprintf(
158 | 'select CMECredit.* from CMECredit
159 | inner join CMEFrontMatter
160 | on CMECredit.front_matter = CMEFrontMatter.id
161 | where CMECredit.id in (%s) and CMEFrontMatter.enabled = %s',
162 | implode(',', $ids),
163 | $this->app->db->quote(true, 'boolean')
164 | );
165 |
166 | $credits = SwatDB::query(
167 | $this->app->db,
168 | $sql,
169 | SwatDBClassMap::get(CMECreditWrapper::class)
170 | );
171 |
172 | if (count($credits) === 0) {
173 | throw new SiteNotFoundException(
174 | 'No CME credits found for the ids provided.'
175 | );
176 | }
177 |
178 | return $credits;
179 | }
180 |
181 | protected function getProgress(CMECreditWrapper $credits)
182 | {
183 | $first_run = true;
184 | $progress1 = null;
185 |
186 | foreach ($credits as $credit) {
187 | $progress2 = $this->app->session->account->getCMEProgress($credit);
188 |
189 | if ($first_run) {
190 | $first_run = false;
191 |
192 | $progress1 = $progress2;
193 | }
194 |
195 | if ($progress1 instanceof CMEAccountCMEProgress
196 | && $progress2 instanceof CMEAccountCMEProgress
197 | && $progress1->id === $progress2->id) {
198 | $progress1 = $progress2;
199 | } else {
200 | throw new SiteNotFoundException(
201 | 'CME credits do not share the same progress.'
202 | );
203 | }
204 | }
205 |
206 | return $progress1;
207 | }
208 |
209 | protected function getQuiz(CMEAccountCMEProgress $progress)
210 | {
211 | $quiz = $this->app->getCacheValue(
212 | $this->getCacheKey($progress)
213 | );
214 |
215 | if ($quiz === false) {
216 | $quiz = $progress->quiz;
217 | } else {
218 | $quiz->setDatabase($this->app->db);
219 | }
220 |
221 | return $quiz;
222 | }
223 |
224 | protected function getQuestionBinding(
225 | InquisitionInquisition $quiz,
226 | $binding_id
227 | ) {
228 | $sql = sprintf(
229 | 'select * from InquisitionInquisitionQuestionBinding
230 | where inquisition = %s and id = %s',
231 | $this->app->db->quote($quiz->id, 'integer'),
232 | $this->app->db->quote($binding_id, 'integer')
233 | );
234 |
235 | return SwatDB::query(
236 | $this->app->db,
237 | $sql,
238 | SwatDBClassMap::get(InquisitionInquisitionQuestionBindingWrapper::class)
239 | )->getFirst();
240 | }
241 |
242 | protected function getResponse(InquisitionInquisition $quiz)
243 | {
244 | $response = $quiz->getResponseByAccount($this->app->session->account);
245 |
246 | // get new response
247 | if (!$response instanceof CMEQuizResponse) {
248 | $response = SwatDBClassMap::new(CMEQuizResponse::class);
249 |
250 | $response->account = $this->app->session->account;
251 | $response->inquisition = $quiz;
252 | $response->createdate = new SwatDate();
253 | $response->createdate->toUTC();
254 |
255 | $response->values = SwatDBClassMap::new(InquisitionResponseValueWrapper::class);
256 |
257 | $response->setDatabase($this->app->db);
258 | }
259 |
260 | return $response;
261 | }
262 |
263 | protected function getResponseValue(
264 | CMEQuiz $quiz,
265 | CMEQuizResponse $response,
266 | InquisitionInquisitionQuestionBinding $question_binding,
267 | $option_id
268 | ) {
269 | $response_value = null;
270 |
271 | $question_id = $question_binding->getInternalValue('question');
272 |
273 | // make sure option is valid for question
274 | $sql = sprintf(
275 | 'select count(1) from InquisitionQuestionOption
276 | where question = %s and id = %s',
277 | $this->app->db->quote($question_id, 'integer'),
278 | $this->app->db->quote($option_id, 'integer')
279 | );
280 |
281 | if (SwatDB::queryOne($this->app->db, $sql) === 1) {
282 | // check for existing response
283 | $sql = sprintf(
284 | 'select *
285 | from InquisitionResponseValue
286 | where response = %s and question_binding = %s',
287 | $this->app->db->quote($response->id, 'integer'),
288 | $this->app->db->quote($question_binding->id, 'integer')
289 | );
290 |
291 | $response_value = SwatDB::query(
292 | $this->app->db,
293 | $sql,
294 | SwatDBClassMap::get(InquisitionResponseValueWrapper::class)
295 | )->getFirst();
296 |
297 | // if no existing response, make a new one
298 | if ($response_value === null) {
299 | $response_value = SwatDBClassMap::new(InquisitionResponseValue::class);
300 | $response_value->setDatabase($this->app->db);
301 | }
302 |
303 | // set question option and question
304 | $response_value->question_option = $option_id;
305 | $response_value->question_binding = $question_binding->id;
306 | }
307 |
308 | return $response_value;
309 | }
310 |
311 | protected function saveResponseValue(
312 | CMEQuizResponse $response,
313 | InquisitionResponseValue $response_value
314 | ) {
315 | // save new response object if it wasn't already saved
316 | $response->save();
317 |
318 | // set response on value and save value
319 | $response_value->response = $response->id;
320 | $response_value->save();
321 | }
322 |
323 | protected function getCacheKey(CMEAccountCMEProgress $progress)
324 | {
325 | return 'cme-quiz-page-' . $progress->id;
326 | }
327 | }
328 |
--------------------------------------------------------------------------------