├── .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 | 9 | evaluation-footer 10 | 11 | button 12 | Submit Evaluation 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /CME/admin/components/Credit/details-credit-fields.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Credit Hours 7 | 8 | hours 9 | 10 | 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 | 15 | 16 | Print Certificates 17 | 18 | 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 | 32 | 33 | 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 | 30 | quiz-footer 31 | 32 | Submit Quiz 33 | button 34 | 35 | 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 | 67 | 68 | 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 | --------------------------------------------------------------------------------