diff --git a/SQL/0000-00-00-schema.sql b/SQL/0000-00-00-schema.sql index ef0a7c39504..3196f384902 100644 --- a/SQL/0000-00-00-schema.sql +++ b/SQL/0000-00-00-schema.sql @@ -1426,6 +1426,38 @@ CREATE TABLE `user_account_history` ( PRIMARY KEY (`ID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- ******************************** +-- tables for policies +-- ******************************** + +CREATE TABLE policies ( + PolicyID INT AUTO_INCREMENT PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + Version INT NOT NULL, + ModuleID INT NOT NULL, + PolicyRenewalTime INT DEFAULT 7, + PolicyRenewalTimeUnit enum('D','Y','M','H') DEFAULT 'D', + Content TEXT NULL, + SwalTitle VARCHAR(255) DEFAULT 'Terms of Use', + HeaderButton enum('Y','N') DEFAULT 'Y', + HeaderButtonText VARCHAR(255) DEFAULT 'Terms of Use', + Active enum('Y','N') DEFAULT 'Y', + AcceptButtonText VARCHAR(255) DEFAULT 'Accept', + DeclineButtonText VARCHAR(255) DEFAULT 'Decline', + CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE user_policy_decision ( + ID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + PolicyID INT NOT NULL, + Decision enum('Accepted','Declined') NOT NULL, + DecisionDate DATETIME DEFAULT CURRENT_TIMESTAMP +); + + -- ******************************** -- user_login_history tables -- ******************************** diff --git a/SQL/0000-00-01-Modules.sql b/SQL/0000-00-01-Modules.sql index 2562b9ae10e..f69435841e6 100644 --- a/SQL/0000-00-01-Modules.sql +++ b/SQL/0000-00-01-Modules.sql @@ -55,5 +55,6 @@ INSERT INTO modules (Name, Active) VALUES ('electrophysiology_uploader', 'Y'); INSERT INTO modules (Name, Active) VALUES ('dataquery', 'Y'); INSERT INTO modules (Name, Active) VALUES ('schedule_module', 'Y'); INSERT INTO modules (Name, Active) VALUES ('redcap', 'N'); +INSERT INTO modules (Name, Active) VALUES ('policy_tracker', 'Y'); ALTER TABLE issues ADD CONSTRAINT `fk_issues_7` FOREIGN KEY (`module`) REFERENCES `modules` (`ID`); diff --git a/SQL/9999-99-99-drop_tables.sql b/SQL/9999-99-99-drop_tables.sql index c5579986fff..fcfbe35409f 100644 --- a/SQL/9999-99-99-drop_tables.sql +++ b/SQL/9999-99-99-drop_tables.sql @@ -133,6 +133,8 @@ DROP TABLE IF EXISTS `server_processes`; DROP TABLE IF EXISTS `StatisticsTabs`; DROP TABLE IF EXISTS `user_login_history`; DROP TABLE IF EXISTS `user_account_history`; +DROP TABLE IF EXISTS `policies`; +DROP TABLE IF EXISTS `user_policy_decision`; DROP TABLE IF EXISTS `data_integrity_flag`; DROP TABLE IF EXISTS `certification_training_quiz_answers`; DROP TABLE IF EXISTS `certification_training_quiz_questions`; diff --git a/SQL/New_patches/2025-05-22-Introduce-Policy-Decisions.sql b/SQL/New_patches/2025-05-22-Introduce-Policy-Decisions.sql new file mode 100644 index 00000000000..a66662c05b8 --- /dev/null +++ b/SQL/New_patches/2025-05-22-Introduce-Policy-Decisions.sql @@ -0,0 +1,27 @@ +CREATE TABLE policies ( + PolicyID INT AUTO_INCREMENT PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + Version INT NOT NULL, + ModuleID INT NOT NULL, -- Show in the header for a module + PolicyRenewalTime INT DEFAULT 7, -- Number of days before the policy is renewed + PolicyRenewalTimeUnit enum('D','Y','M','H') DEFAULT 'D', -- Unit of the renewal time + Content TEXT NULL, + SwalTitle VARCHAR(255) DEFAULT 'Terms of Use', + HeaderButton enum('Y','N') DEFAULT 'Y', + HeaderButtonText VARCHAR(255) DEFAULT 'Terms of Use', + Active enum('Y','N') DEFAULT 'Y', + AcceptButtonText VARCHAR(255) DEFAULT 'Accept', + DeclineButtonText VARCHAR(255) DEFAULT 'Decline', + CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE user_policy_decision ( + ID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + PolicyID INT NOT NULL, + Decision enum('Accepted','Declined') NOT NULL, + DecisionDate DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO modules (`Name`, `Active`) VALUES ('policy_tracker','Y'); \ No newline at end of file diff --git a/SQL/sql_for_abstract.sql b/SQL/sql_for_abstract.sql new file mode 100644 index 00000000000..60626afddb8 --- /dev/null +++ b/SQL/sql_for_abstract.sql @@ -0,0 +1,391 @@ +SELECT count(DISTINCT cr.ResolvedID) +FROM conflicts_resolved cr +JOIN ( + SELECT f.CommentID + FROM flag f + JOIN session s ON f.SessionID = s.ID + WHERE s.Visit_label IN ( + 'Initial_Assessment_Screening', + 'Initial_MRI', + 'Repeat_Initial_Assessment_Screening', + 'Clinical_Assessment', + 'Neuropsychology_Assessment' + ) + -- AND s.SubprojectID IN (1,2,3,4,5,6,7,8,9,10,14,17,18) + AND Test_name NOT IN ( +'Annual_Follow_up_Phone_Call', +'Autopsy_Banked_Tissue_Inventory', +'Autopsy_Neuropathology_Report', +'Clinical_Current_Medication', +'Clinical_Current_Medication_FU', +'Clinical_Diagnosis_Confirmation', +'Clinical_Family_History', +'Clinical_Family_History_FU', +'Clinical_Hobbies_And_Leisure_Activities', +'Clinical_Initial_Symptoms', +'Clinical_Lumbar_Puncture_Procedure_Report', +'Clinical_Medical_History', +'Clinical_Medical_History_FU', +'Clinical_Mental_Health_History', +'Clinical_Mental_Health_History_FU', +'Clinical_Past_Medications', +'Clinical_Past_Medications_FU', +'Clinical_Pre_Gait_Assessment', +'Clinical_Signs_Symptoms', +'Clinical_Sleep_Work_Diary', +'Clinical_Smoking', +'Clinical_Surgical_History', +'Clinical_Surgical_History_FU', +'Clinical_UPDRS_III_IV', +'General_Health_Alcohol_Consumption', +'General_Health_Biosample_Collection', +'General_Health_Caregiving_Assessment', +'General_Health_Falls_Balance', +'General_Health_Gait_Assessment', +'General_Health_Grip_Strength', +'General_Health_Nutrition', +'General_Health_Physical_Activity', +'General_Health_Physical_Activity_FU', +'General_Health_Sleep', +'General_Health_Vision', +'mri_parameter_form', +'PI_Caregiving_Assessment', +'PI_General_Information', +'PI_General_Information_FU', +'Reappraisal_Initial_Diagnosis_Reappraisal', +'Reappraisal_Initial_Diagnosis_Reappraisal_IMPACT_AD', +'Screening_CERAD_Recog', +'Screening_Driving_History', +'Screening_Driving_History_FU', +'Screening_Education', +'Screening_Education_FU', +'Screening_Employment_History', +'Screening_Employment_History_FU', +'Screening_Languages', +'Screening_Logical_Memory_Delayed', +'Screening_MOCA', +'Screening_Reproductive_History' +) +) fs ON fs.CommentID = cr.CommentID1; + +SELECT count(*) +FROM information_schema.columns +WHERE table_name NOT IN ( +'Annual_Follow_up_Phone_Call', +'Autopsy_Banked_Tissue_Inventory', +'Autopsy_Neuropathology_Report', +'Clinical_Current_Medication', +'Clinical_Current_Medication_FU', +'Clinical_Diagnosis_Confirmation', +'Clinical_Family_History', +'Clinical_Family_History_FU', +'Clinical_Hobbies_And_Leisure_Activities', +'Clinical_Initial_Symptoms', +'Clinical_Lumbar_Puncture_Procedure_Report', +'Clinical_Medical_History', +'Clinical_Medical_History_FU', +'Clinical_Mental_Health_History', +'Clinical_Mental_Health_History_FU', +'Clinical_Past_Medications', +'Clinical_Past_Medications_FU', +'Clinical_Pre_Gait_Assessment', +'Clinical_Signs_Symptoms', +'Clinical_Sleep_Work_Diary', +'Clinical_Smoking', +'Clinical_Surgical_History', +'Clinical_Surgical_History_FU', +'Clinical_UPDRS_III_IV', +'General_Health_Alcohol_Consumption', +'General_Health_Biosample_Collection', +'General_Health_Caregiving_Assessment', +'General_Health_Falls_Balance', +'General_Health_Gait_Assessment', +'General_Health_Grip_Strength', +'General_Health_Nutrition', +'General_Health_Physical_Activity', +'General_Health_Physical_Activity_FU', +'General_Health_Sleep', +'General_Health_Vision', +'mri_parameter_form', +'PI_Caregiving_Assessment', +'PI_General_Information', +'PI_General_Information_FU', +'Reappraisal_Initial_Diagnosis_Reappraisal', +'Reappraisal_Initial_Diagnosis_Reappraisal_IMPACT_AD', +'Screening_CERAD_Recog', +'Screening_Driving_History', +'Screening_Driving_History_FU', +'Screening_Education', +'Screening_Education_FU', +'Screening_Employment_History', +'Screening_Employment_History_FU', +'Screening_Languages', +'Screening_Logical_Memory_Delayed', +'Screening_MOCA', +'Screening_Reproductive_History' +) + +SELECT count(DISTINCT cr.ConflictID) +FROM conflicts_unresolved cr +JOIN ( + SELECT f.CommentID + FROM flag f + JOIN session s ON f.SessionID = s.ID + WHERE s.Visit_label LIKE '%Time_3%' + -- AND s.SubprojectID IN (1,2,3,4,5,6,7,8,9,10,14,17,18) +-- AND Test_name NOT IN ( +-- 'Annual_Follow_up_Phone_Call', +-- 'Autopsy_Banked_Tissue_Inventory', +-- 'Autopsy_Neuropathology_Report', +-- 'Clinical_Current_Medication', +-- 'Clinical_Current_Medication_FU', +-- 'Clinical_Diagnosis_Confirmation', +-- 'Clinical_Family_History', +-- 'Clinical_Family_History_FU', +-- 'Clinical_Hobbies_And_Leisure_Activities', +-- 'Clinical_Initial_Symptoms', +-- 'Clinical_Lumbar_Puncture_Procedure_Report', +-- 'Clinical_Medical_History', +-- 'Clinical_Medical_History_FU', +-- 'Clinical_Mental_Health_History', +-- 'Clinical_Mental_Health_History_FU', +-- 'Clinical_Past_Medications', +-- 'Clinical_Past_Medications_FU', +-- 'Clinical_Pre_Gait_Assessment', +-- 'Clinical_Signs_Symptoms', +-- 'Clinical_Sleep_Work_Diary', +-- 'Clinical_Smoking', +-- 'Clinical_Surgical_History', +-- 'Clinical_Surgical_History_FU', +-- 'Clinical_UPDRS_III_IV', +-- 'General_Health_Alcohol_Consumption', +-- 'General_Health_Biosample_Collection', +-- 'General_Health_Caregiving_Assessment', +-- 'General_Health_Falls_Balance', +-- 'General_Health_Gait_Assessment', +-- 'General_Health_Grip_Strength', +-- 'General_Health_Nutrition', +-- 'General_Health_Physical_Activity', +-- 'General_Health_Physical_Activity_FU', +-- 'General_Health_Sleep', +-- 'General_Health_Vision', +-- 'mri_parameter_form', +-- 'PI_Caregiving_Assessment', +-- 'PI_General_Information', +-- 'PI_General_Information_FU', +-- 'Reappraisal_Initial_Diagnosis_Reappraisal', +-- 'Reappraisal_Initial_Diagnosis_Reappraisal_IMPACT_AD', +-- 'Screening_CERAD_Recog', +-- 'Screening_Driving_History', +-- 'Screening_Driving_History_FU', +-- 'Screening_Education', +-- 'Screening_Education_FU', +-- 'Screening_Employment_History', +-- 'Screening_Employment_History_FU', +-- 'Screening_Languages', +-- 'Screening_Logical_Memory_Delayed', +-- 'Screening_MOCA', +-- 'Screening_Reproductive_History' +-- ) +) fs ON fs.CommentID = cr.CommentID1; + +SELECT count(f.CommentID) + FROM flag f + JOIN session s ON f.SessionID = s.ID + WHERE s.Visit_label IN ( + 'Initial_Assessment_Screening', + 'Initial_MRI', + 'Repeat_Initial_Assessment_Screening', + 'Clinical_Assessment', + 'Neuropsychology_Assessment' + ) + AND CommentID LIKE 'DDE_%' + AND Data_entry IS NOT NULL + -- AND s.SubprojectID IN (1,2,3,4,5,6,7,8,9,10,14,17,18) + AND Test_name NOT IN ( + 'Annual_Follow_up_Phone_Call', + 'Autopsy_Banked_Tissue_Inventory' + 'Autopsy_Neuropathology_Report', + 'Clinical_Current_Medication', + 'Clinical_Current_Medication_FU', + 'Clinical_Diagnosis_Confirmation', + 'Clinical_Family_History', + 'Clinical_Family_History_FU', + 'Clinical_Hobbies_And_Leisure_Activities', + 'Clinical_Initial_Symptoms', + 'Clinical_Lumbar_Puncture_Procedure_Report', + 'Clinical_Medical_History', + 'Clinical_Medical_History_FU', + 'Clinical_Mental_Health_History', + 'Clinical_Mental_Health_History_FU', + 'Clinical_Past_Medications', + 'Clinical_Past_Medications_FU', + 'Clinical_Pre_Gait_Assessment', + 'Clinical_Signs_Symptoms', + 'Clinical_Sleep_Work_Diary', + 'Clinical_Smoking', + 'Clinical_Surgical_History', + 'Clinical_Surgical_History_FU', + 'Clinical_UPDRS_III_IV', + 'General_Health_Alcohol_Consumption', + 'General_Health_Biosample_Collection', + 'General_Health_Caregiving_Assessment', + 'General_Health_Falls_Balance', + 'General_Health_Gait_Assessment', + 'General_Health_Grip_Strength', + 'General_Health_Nutrition', + 'General_Health_Physical_Activity', + 'General_Health_Physical_Activity_FU', + 'General_Health_Sleep', + 'General_Health_Vision', + 'mri_parameter_form', + 'PI_Caregiving_Assessment', + 'PI_General_Information', + 'PI_General_Information_FU', + 'Reappraisal_Initial_Diagnosis_Reappraisal', + 'Reappraisal_Initial_Diagnosis_Reappraisal_IMPACT_AD', + 'Screening_CERAD_Recog', + 'Screening_Driving_History', + 'Screening_Driving_History_FU', + 'Screening_Education', + 'Screening_Education_FU', + 'Screening_Employment_History', + 'Screening_Employment_History_FU', + 'Screening_Languages', + 'Screening_Logical_Memory_Delayed', + 'Screening_MOCA', + 'Screening_Reproductive_History' + ); + + SELECT count(f.CommentID) + FROM flag f + JOIN session s ON f.SessionID = s.ID + WHERE s.Visit_label LIKE '%Time_3%' + AND Data_entry IS NOT NULL + AND CommentID LIKE 'DDE_%' + -- AND s.SubprojectID IN (1,2,3,4,5,6,7,8,9,10,14,17,18) + AND Test_name NOT IN ( + 'Annual_Follow_up_Phone_Call', + 'Autopsy_Banked_Tissue_Inventory', + 'Autopsy_Neuropathology_Report', + 'Clinical_Current_Medication', + 'Clinical_Current_Medication_FU', + 'Clinical_Diagnosis_Confirmation', + 'Clinical_Family_History', + 'Clinical_Family_History_FU', + 'Clinical_Hobbies_And_Leisure_Activities', + 'Clinical_Initial_Symptoms', + 'Clinical_Lumbar_Puncture_Procedure_Report', + 'Clinical_Medical_History', + 'Clinical_Medical_History_FU', + 'Clinical_Mental_Health_History', + 'Clinical_Mental_Health_History_FU', + 'Clinical_Past_Medications', + 'Clinical_Past_Medications_FU', + 'Clinical_Pre_Gait_Assessment', + 'Clinical_Signs_Symptoms', + 'Clinical_Sleep_Work_Diary', + 'Clinical_Smoking', + 'Clinical_Surgical_History', + 'Clinical_Surgical_History_FU', + 'Clinical_UPDRS_III_IV', + 'General_Health_Alcohol_Consumption', + 'General_Health_Biosample_Collection', + 'General_Health_Caregiving_Assessment', + 'General_Health_Falls_Balance', + 'General_Health_Gait_Assessment', + 'General_Health_Grip_Strength', + 'General_Health_Nutrition', + 'General_Health_Physical_Activity', + 'General_Health_Physical_Activity_FU', + 'General_Health_Sleep', + 'General_Health_Vision', + 'mri_parameter_form', + 'PI_Caregiving_Assessment', + 'PI_General_Information', + 'PI_General_Information_FU', + 'Reappraisal_Initial_Diagnosis_Reappraisal', + 'Reappraisal_Initial_Diagnosis_Reappraisal_IMPACT_AD', + 'Screening_CERAD_Recog', + 'Screening_Driving_History', + 'Screening_Driving_History_FU', + 'Screening_Education', + 'Screening_Education_FU', + 'Screening_Employment_History', + 'Screening_Employment_History_FU', + 'Screening_Languages', + 'Screening_Logical_Memory_Delayed', + 'Screening_MOCA', + 'Screening_Reproductive_History' + ) + + +SELECT med_name, COUNT(*) AS total_count +FROM ( + SELECT med_name_1 AS med_name FROM Clinical_Current_Medication WHERE med_name_1 IS NOT NULL + UNION ALL + SELECT med_name_2 FROM Clinical_Current_Medication WHERE med_name_2 IS NOT NULL + UNION ALL + SELECT med_name_3 FROM Clinical_Current_Medication WHERE med_name_3 IS NOT NULL + UNION ALL + SELECT med_name_4 FROM Clinical_Current_Medication WHERE med_name_4 IS NOT NULL + UNION ALL + SELECT med_name_5 FROM Clinical_Current_Medication WHERE med_name_5 IS NOT NULL + UNION ALL + SELECT med_name_6 FROM Clinical_Current_Medication WHERE med_name_6 IS NOT NULL + UNION ALL + SELECT med_name_7 FROM Clinical_Current_Medication WHERE med_name_7 IS NOT NULL + UNION ALL + SELECT med_name_8 FROM Clinical_Current_Medication WHERE med_name_8 IS NOT NULL + -- Continue this pattern up to med_name_32 + UNION ALL + SELECT med_name_9 FROM Clinical_Current_Medication WHERE med_name_9 IS NOT NULL + UNION ALL + SELECT med_name_10 FROM Clinical_Current_Medication WHERE med_name_10 IS NOT NULL + UNION ALL + SELECT med_name_11 FROM Clinical_Current_Medication WHERE med_name_11 IS NOT NULL + UNION ALL + SELECT med_name_12 FROM Clinical_Current_Medication WHERE med_name_12 IS NOT NULL + UNION ALL + SELECT med_name_13 FROM Clinical_Current_Medication WHERE med_name_13 IS NOT NULL + UNION ALL + SELECT med_name_14 FROM Clinical_Current_Medication WHERE med_name_14 IS NOT NULL + UNION ALL + SELECT med_name_15 FROM Clinical_Current_Medication WHERE med_name_15 IS NOT NULL + UNION ALL + SELECT med_name_16 FROM Clinical_Current_Medication WHERE med_name_16 IS NOT NULL + UNION ALL + SELECT med_name_17 FROM Clinical_Current_Medication WHERE med_name_17 IS NOT NULL + UNION ALL + SELECT med_name_18 FROM Clinical_Current_Medication WHERE med_name_18 IS NOT NULL + UNION ALL + SELECT med_name_19 FROM Clinical_Current_Medication WHERE med_name_19 IS NOT NULL + UNION ALL + SELECT med_name_20 FROM Clinical_Current_Medication WHERE med_name_20 IS NOT NULL + UNION ALL + SELECT med_name_21 FROM Clinical_Current_Medication WHERE med_name_21 IS NOT NULL + UNION ALL + SELECT med_name_22 FROM Clinical_Current_Medication WHERE med_name_22 IS NOT NULL + UNION ALL + SELECT med_name_23 FROM Clinical_Current_Medication WHERE med_name_23 IS NOT NULL + UNION ALL + SELECT med_name_24 FROM Clinical_Current_Medication WHERE med_name_24 IS NOT NULL + UNION ALL + SELECT med_name_25 FROM Clinical_Current_Medication WHERE med_name_25 IS NOT NULL + UNION ALL + SELECT med_name_26 FROM Clinical_Current_Medication WHERE med_name_26 IS NOT NULL + UNION ALL + SELECT med_name_27 FROM Clinical_Current_Medication WHERE med_name_27 IS NOT NULL + UNION ALL + SELECT med_name_28 FROM Clinical_Current_Medication WHERE med_name_28 IS NOT NULL + UNION ALL + SELECT med_name_29 FROM Clinical_Current_Medication WHERE med_name_29 IS NOT NULL + UNION ALL + SELECT med_name_30 FROM Clinical_Current_Medication WHERE med_name_30 IS NOT NULL + UNION ALL + SELECT med_name_31 FROM Clinical_Current_Medication WHERE med_name_31 IS NOT NULL + UNION ALL + SELECT med_name_32 FROM Clinical_Current_Medication WHERE med_name_32 IS NOT NULL +) AS all_meds +GROUP BY med_name +ORDER BY total_count DESC; diff --git a/jsx/PolicyButton.js b/jsx/PolicyButton.js new file mode 100644 index 00000000000..f31921d7ee6 --- /dev/null +++ b/jsx/PolicyButton.js @@ -0,0 +1,108 @@ +/** + * This file contains React component for the Policy Button. + * + * @author Saagar Arya + * @version 2.0.0 + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import Swal from 'sweetalert2'; + +/** + * PolicyButton Component + * + * @param {object} props - The component props. + * @param {object} props.onClickPolicy - The policy object containing title + * and content that should appear when the button is pressed. + * @param {object} [props.buttonStyle] - Optional style object for the button. + * @param {object} [props.popUpPolicy] - Optional policy object for pop-up + * policy that needs renewal. + * @param {string} [props.buttonText] - Optional text for the button. + * @param {boolean} [props.anon] - Optional flag to indicate if the user is anonymous. + * @param {function} [props.callback] - Optional callback function to execute after the policy decision. + */ +const PolicyButton = ({ + onClickPolicy, + popUpPolicy, + buttonStyle, + buttonText, + anon=false, + callback=() => {}, +}) => { + if (popUpPolicy && popUpPolicy.needsRenewal) { + fireSwal(popUpPolicy); + } + if (onClickPolicy) { + return { + fireSwal(onClickPolicy, anon, callback); + }} + > + {buttonText || onClickPolicy.HeaderButtonText} + ; + } +}; + +const fireSwal = (policy, anon, callback) => { + Swal.fire({ + title: policy.SwalTitle, + html: policy.Content, + confirmButtonText: policy.AcceptButtonText, + cancelButtonText: policy.DeclineButtonText, + showCancelButton: policy.DeclineButtonText, + allowOutsideClick: false, + }).then((decision) => { + if (callback) { + callback(decision); + } + if (!anon) { + fetch( + loris.BaseURL + + '/policy_tracker/policies', + { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ + ...policy, + decision: decision.value == true ? 'Accepted' : 'Declined', + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (decision.value != true) { + window.location.href = loris.BaseURL; + } + } + }); +}; + +PolicyButton.propTypes = { + onClickPolicy: PropTypes.shape({ + SwalTitle: PropTypes.string.isRequired, + Content: PropTypes.string.isRequired, + AcceptButtonText: PropTypes.string.isRequired, + DeclineButtonText: PropTypes.string.isRequired, + }).isRequired, + onClickPolicy: PropTypes.object.isRequired, + popUpPolicy: PropTypes.shape({ + needsRenewal: PropTypes.bool, + SwalTitle: PropTypes.string, + Content: PropTypes.string, + AcceptButtonText: PropTypes.string, + DeclineButtonText: PropTypes.string, + }), + buttonStyle: PropTypes.object, + buttonText: PropTypes.string, + anon: PropTypes.bool, + callback: PropTypes.func, +}; + +window.PolicyButton = PolicyButton; + +export {PolicyButton, fireSwal}; diff --git a/modules/login/jsx/loginIndex.js b/modules/login/jsx/loginIndex.js index 26b4bce3e03..caa2f308911 100644 --- a/modules/login/jsx/loginIndex.js +++ b/modules/login/jsx/loginIndex.js @@ -15,6 +15,7 @@ import { ButtonElement, } from 'jsx/Form'; import SummaryStatistics from './summaryStatistics'; +import {PolicyButton} from 'jsx/PolicyButton'; /** * Login form. @@ -210,6 +211,16 @@ class Login extends Component { class={'col-xs-12 col-sm-12 col-md-12 text-danger'} /> ) : null; + const policy = this.state.component.requestAccount.policy; + const policyButton = policy ? + + : null; const oidc = this.state.oidc ? this.getOIDCLinks() : ''; const login = (
@@ -257,6 +268,8 @@ class Login extends Component {
this.setMode('request')} style={{cursor: 'pointer'}}>Request Account +
+ {policyButton}
{oidc}
diff --git a/modules/login/jsx/requestAccount.js b/modules/login/jsx/requestAccount.js index 501d5c4e6c0..c07f172227e 100644 --- a/modules/login/jsx/requestAccount.js +++ b/modules/login/jsx/requestAccount.js @@ -11,6 +11,7 @@ import { CheckboxElement, ButtonElement, } from 'jsx/Form'; +import PolicyButton from 'jsx/PolicyButton'; /** * Request account form. @@ -45,8 +46,10 @@ class RequestAccount extends Component { ? this.props.data.captcha : '', error: '', + viewedPolicy: false, }, request: false, + policy: this.props.data.policy || null, }; this.setForm = this.setForm.bind(this); this.handleSubmit = this.handleSubmit.bind(this); @@ -75,7 +78,16 @@ class RequestAccount extends Component { */ handleSubmit(e) { e.preventDefault(); - + if (this.props.data.policy && !this.state.form.viewedPolicy) { + let title = this.props.data.policy.SwalTitle; + swal.fire({ + title: title + ' not accepted', + text: 'You must accept the ' + title + ' before requesting an account.', + icon: 'error', + }); + e.stopPropagation(); + return; + } const state = JSON.parse(JSON.stringify(this.state)); fetch( window.location.origin + '/login/Signup', { @@ -156,6 +168,23 @@ class RequestAccount extends Component {
) : null; + const policy = this.state.policy ? ( + { + this.setState({ + form: { + ...this.state.form, + viewedPolicy: true, + }, + }); + }} + /> + ) : null; const request = !this.state.request ? (
+ {policy} {captcha} getAttribute("loris"); + $requestAccountData['policy'] = $this->getHeaderPolicy($loris); + $values['requestAccount'] = $requestAccountData; if ($this->loris->hasModule('oidc')) { $DB = $this->loris->getDatabaseConnection(); diff --git a/modules/login/test/Request_Account_test_plan.md b/modules/login/test/Request_Account_test_plan.md index 34226150693..b06270ab2e1 100644 --- a/modules/login/test/Request_Account_test_plan.md +++ b/modules/login/test/Request_Account_test_plan.md @@ -6,6 +6,7 @@ 3. Verify that verification code is enforced (if and only if re-captcha has been activated by the project) 4. Verify that clicking "Submit" button with valid form data will load page acknowledging receipt (Thank you page) 5. Verify "Return to Loris login page" link on Thank you page works +6. Verify that if a policy is added to the project for module `login`, the policy is displayed on the Request Account form page, and it is required that the policy has been viewed to request an account. ### Approving new User Account Request: 6. Log in as another user who has permission: `user_accounts` (User Management) and does not have permission: `user_accounts_multisite` (Across all sites create and edit users). Verify that new account request notification is counted in Dashboard (count has incremented). diff --git a/modules/policy_tracker/help/policy_tracker.md b/modules/policy_tracker/help/policy_tracker.md new file mode 100644 index 00000000000..244b85992b2 --- /dev/null +++ b/modules/policy_tracker/help/policy_tracker.md @@ -0,0 +1,6 @@ +# Policy Tracker + +This module provides an endpoint for other pages to manage policy acceptance. + +### Future work: +A Filterable Data Table to display the policies that have been accepted by users. \ No newline at end of file diff --git a/modules/policy_tracker/php/module.class.inc b/modules/policy_tracker/php/module.class.inc new file mode 100644 index 00000000000..79cbcd140e3 --- /dev/null +++ b/modules/policy_tracker/php/module.class.inc @@ -0,0 +1,77 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +namespace LORIS\policy_tracker; +use \Psr\Http\Message\ServerRequestInterface; +use \Psr\Http\Message\ResponseInterface; + +/** + * Class module implements the basic LORIS module functionality + * + * @category Core + * @package Main + * @subpackage Login + * @author Saagar Arya + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class Module extends \Module +{ + /** + * Implements the PSR15 RequestHandler interface for this module. The API + * module does some preliminary verification of the request, converts the + * version from the URL to a request attribute, and then falls back on the + * default LORIS page handler. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + public function handle(ServerRequestInterface $request) : ResponseInterface + { + $body = json_decode($request->getBody()->getContents(), true); + switch ($request->getMethod()) { + case 'POST': + $module = $this->loris->getModule($body['ModuleName'] ?? ''); + $page = new \NDB_Page( + $this->loris, + $module, + '', + '', + '', + ); + $page->saveUserPolicyDecision( + $this->loris, + $body['PolicyName'] ?? '', + $body['decision'] ?? '' + ); + return new \LORIS\Http\Response\JSON\OK( + ['message' => 'Policy decision saved successfully'] + ); + default: + return new \LORIS\Http\Response\JSON\MethodNotAllowed(['POST']); + } + } + + /** + * {@inheritDoc} + * + * @return string The human readable name for this module + */ + public function getLongName() : string + { + return dgettext("policy_tracker", "Policy Tracker"); + } +} diff --git a/php/libraries/NDB_Page.class.inc b/php/libraries/NDB_Page.class.inc index 7622e9a433c..52af7425c5e 100644 --- a/php/libraries/NDB_Page.class.inc +++ b/php/libraries/NDB_Page.class.inc @@ -861,6 +861,7 @@ class NDB_Page extends \LORIS\Http\Endpoint implements RequestHandlerInterface $baseurl . '/js/jquery.fileupload.js', $baseurl . '/bootstrap/js/bootstrap.min.js', $baseurl . '/js/components/Breadcrumbs.js', + $baseurl . '/js/components/PolicyButton.js', $baseurl . "/js/util/queryString.js", $baseurl . '/js/components/Help.js', ]; @@ -936,6 +937,171 @@ class NDB_Page extends \LORIS\Http\Endpoint implements RequestHandlerInterface ); } + /** + * Get latest Policy for this module with an optional Policy Name + * + * @param \LORIS\LorisInstance $loris The LORIS instance + * @param string $policyName The policy name + * @param bool $headerButton If true, only return policies that + * have HeaderButton = 'Y' + * + * @return array The policy details + * @throws \DatabaseException + */ + function getPolicy( + \LORIS\LorisInstance $loris, + string $policyName ='', + bool $headerButton = false + ) { + $moduleName = $this->Module->getName(); + $DB = $loris->getDatabaseConnection(); + // If no policy name is given, we return the latest policy for the module + if (!empty($policyName)) { + $policyName = 'AND p.Name = '. $DB->quote($policyName); + } + // If we want just a policy that goes in the header, + // then we need HeaderButton = 'Y' + if ($headerButton == true) { + $headerWhere = "('Y')"; + } else { + $headerWhere = "('Y', 'N')"; + } + return iterator_to_array( + $DB->pselect( + "SELECT + p.Name as PolicyName, + Version, + Content, + SwalTitle, + HeaderButtonText, + AcceptButtonText, + DeclineButtonText, + m.Name as ModuleName, + PolicyID, + PolicyRenewalTime, + PolicyRenewalTimeUnit + FROM policies p + JOIN modules m ON m.ID = p.ModuleID + WHERE m.Name = :moduleName + $policyName + AND p.Active = 'Y' + AND p.HeaderButton IN $headerWhere + ORDER BY p.Version DESC + LIMIT 1 + ", + [ + 'moduleName' => $moduleName + ] + ) + )[0] ?? []; + } + + /** + * Returns the latest Policy for this page with a HeaderButton + * + * @param \LORIS\LorisInstance $loris The LORIS instance + * + * @return array + */ + public function getHeaderPolicy( + \LORIS\LorisInstance $loris + ): array { + return $this->getPolicy( + $loris, + '', + true + ) ?? []; + } + + /** + * Get a User's latest policy decision, and whether or not they need to renew. + * + * @param \LORIS\LorisInstance $loris The LORIS instance + * @param string $policyName The policy name + * @param int|null $userID The user ID, or current user if null + * + * @return array The decision details + * @throws \DatabaseException + * @access public + */ + public function getLatestPolicyDecision( + \LORIS\LorisInstance $loris, + string $policyName, + int|null $userID + ) { + $userID = $userID ?? User::singleton()->getID(); + $DB = $loris->getDatabaseConnection(); + + $policyInfo = $this->getPolicy($loris, $policyName, false) ?? []; + if (empty($policyInfo)) { + return []; + } + $decision = iterator_to_array( + $DB->pselect( + "SELECT Decision, DecisionDate + FROM user_policy_decision + WHERE + UserID = :uid + AND PolicyID=:policyID + ORDER BY DecisionDate DESC + LIMIT 1 + ", + [ + 'uid' => $userID, + 'policyID' => $policyInfo['PolicyID'] + ] + ) + )[0] ?? []; + $needsRenewal = true; + if ($decision + && $decision['Decision'] == 'Accepted' + && $policyInfo['PolicyRenewalTime'] > 0 + ) { + $decisionDate = new \DateTime($decision['DecisionDate']); + $interval = new \DateInterval( + "P{$policyInfo['PolicyRenewalTime']}". + "{$policyInfo['PolicyRenewalTimeUnit']}" + ); + $renewalDate = $decisionDate->add($interval); + if ($renewalDate > new \DateTime()) { + $needsRenewal = false; + } + } + return [ + ...$policyInfo, + 'needsRenewal' => $needsRenewal, + ]; + } + + /** + * Save a User's Policy decision + * + * @param \LORIS\LorisInstance $loris The LORIS instance + * @param string $PolicyName The Policy Name + * @param string $decision The decision made by the current user + * + * @return void + * @throws \DatabaseException + */ + function saveUserPolicyDecision( + \LORIS\LorisInstance $loris, + string $PolicyName, + string $decision + ) { + $user = User::singleton(); + $DB = $this->loris->getDatabaseConnection(); + + $policy = $this->getPolicy($loris, $PolicyName, false); + $DB->insert( + 'user_policy_decision', + [ + 'UserID' => $user->getId(), + 'PolicyID' => $policy['PolicyID'], + 'Decision' => $decision, + ] + ); + } + /** * Converts the results of this form to a JSON format to be retrieved * with ?format=json diff --git a/raisinbread/RB_files/RB_modules.sql b/raisinbread/RB_files/RB_modules.sql index 8d3b64376b6..02c66566447 100644 --- a/raisinbread/RB_files/RB_modules.sql +++ b/raisinbread/RB_files/RB_modules.sql @@ -50,5 +50,6 @@ INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (47,'electrophysiology_upl INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (48,'schedule_module','Y'); INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (49,'dataquery','Y'); INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (50,'oidc','N'); +INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (51,'policy_tracker','Y'); UNLOCK TABLES; SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_policies.sql b/raisinbread/RB_files/RB_policies.sql new file mode 100644 index 00000000000..03ed0ae1f71 --- /dev/null +++ b/raisinbread/RB_files/RB_policies.sql @@ -0,0 +1,7 @@ +SET FOREIGN_KEY_CHECKS=0; +TRUNCATE TABLE `policies`; +LOCK TABLES `policies` WRITE; +INSERT INTO `policies` (`PolicyID`, `Name`, `Version`, `ModuleID`, `PolicyRenewalTime`, `PolicyRenewalTimeUnit`, `Content`, `SwalTitle`, `HeaderButton`, `HeaderButtonText`, `Active`, `AcceptButtonText`, `DeclineButtonText`, `CreatedAt`, `UpdatedAt`) VALUES (1,'dataquery_example',1,49,7,'D','By using this Data Query Tool, you acknowledge that you know it is in beta and may not work as expected. You also agree to use it responsibly and not to misuse the data.','Terms of Use','Y','Terms of Use','Y','Yes, I accept','Decline','2025-05-28 10:54:21','2025-05-28 10:54:21'); +INSERT INTO `policies` (`PolicyID`, `Name`, `Version`, `ModuleID`, `PolicyRenewalTime`, `PolicyRenewalTimeUnit`, `Content`, `SwalTitle`, `HeaderButton`, `HeaderButtonText`, `Active`, `AcceptButtonText`, `DeclineButtonText`, `CreatedAt`, `UpdatedAt`) VALUES (2,'login_example',1,28,7,'D','By using this LORIS instance you acknowledge that you know it is filled with test data, and not real user data.','Terms of Use','Y','Terms of Use','Y','Accept','','2025-05-28 10:54:23','2025-05-28 10:54:23'); +UNLOCK TABLES; +SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_user_policy_decision.sql b/raisinbread/RB_files/RB_user_policy_decision.sql new file mode 100644 index 00000000000..1eb9aa14838 --- /dev/null +++ b/raisinbread/RB_files/RB_user_policy_decision.sql @@ -0,0 +1,5 @@ +SET FOREIGN_KEY_CHECKS=0; +TRUNCATE TABLE `user_policy_decision`; +LOCK TABLES `user_policy_decision` WRITE; +UNLOCK TABLES; +SET FOREIGN_KEY_CHECKS=1; diff --git a/smarty/templates/main.tpl b/smarty/templates/main.tpl index 78f8bc32174..97c7b31303b 100644 --- a/smarty/templates/main.tpl +++ b/smarty/templates/main.tpl @@ -73,6 +73,16 @@ toggleIcon.classList.add('glyphicon-chevron-down'); } }); + + headerPolicyRoot = ReactDOM.createRoot( + document.getElementById("headerPolicyButton") + ); + headerPolicyRoot.render( + React.createElement(PolicyButton, { + onClickPolicy: {$header_policy|json_encode}, + popUpPolicy: {$pop_up_policy|json_encode}, + }) + ); }); @@ -143,6 +153,9 @@ {/if} + {if $header_policy} + + {/if} diff --git a/src/Middleware/UserPageDecorationMiddleware.php b/src/Middleware/UserPageDecorationMiddleware.php index 9f73001ed34..0f232a583fd 100644 --- a/src/Middleware/UserPageDecorationMiddleware.php +++ b/src/Middleware/UserPageDecorationMiddleware.php @@ -271,9 +271,10 @@ function ($a, $b) { $tpl_data['FormAction'] = $page->FormAction ?? ''; if ($page instanceof \NDB_Page) { - $tpl_data['breadcrumbs'] = $page->getBreadcrumbs(); + $tpl_data['breadcrumbs'] = $page->getBreadcrumbs(); + $tpl_data['header_policy'] = $page->getHeaderPolicy($loris); + $tpl_data['pop_up_policy'] = $page->getLatestPolicyDecision($loris, '', $user->getId()); } - // Assign the console template variable as the very, very last thing. $tpl_data['console'] = htmlspecialchars( ob_get_contents(), diff --git a/test/unittests/NDB_PageTest.php b/test/unittests/NDB_PageTest.php index de823fbf69a..bcb73684db7 100644 --- a/test/unittests/NDB_PageTest.php +++ b/test/unittests/NDB_PageTest.php @@ -816,6 +816,7 @@ public function testGetJSDependencies() '/js/jquery.fileupload.js', '/bootstrap/js/bootstrap.min.js', '/js/components/Breadcrumbs.js', + '/js/components/PolicyButton.js', '/js/util/queryString.js', '/js/components/Help.js', ], diff --git a/webpack.config.ts b/webpack.config.ts index 7dbaa0e968b..508d45dd383 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -348,6 +348,7 @@ configs.push({ StaticDataTable: './jsx/StaticDataTable.js', MultiSelectDropdown: './jsx/MultiSelectDropdown.js', Breadcrumbs: './jsx/Breadcrumbs.js', + PolicyButton: './jsx/PolicyButton.js', CSSGrid: './jsx/CSSGrid.js', Help: './jsx/Help.js', ...getModulesEntries(),