diff --git a/SQL/0000-00-00-schema.sql b/SQL/0000-00-00-schema.sql index ee5061d0c42..19579e6d70c 100644 --- a/SQL/0000-00-00-schema.sql +++ b/SQL/0000-00-00-schema.sql @@ -1496,6 +1496,35 @@ INSERT INTO StatisticsTabs (ModuleName, SubModuleName, Description, OrderNo) VAL ('statistics', 'stats_behavioural', 'Behavioural Statistics', 3), ('statistics', 'stats_MRI', 'Imaging Statistics', 4); + +-- ******************************** +-- statistics +-- ******************************** + + +CREATE TABLE `cached_data_type` ( + `CachedDataTypeID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `Name` VARCHAR(255) UNIQUE NOT NULL, + PRIMARY KEY (`CachedDataTypeID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +INSERT INTO `cached_data_type` (`Name`) SELECT 'projects_disk_space'; + + +CREATE TABLE `cached_data` ( + `CachedDataID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `CachedDataTypeID` INT(10) UNSIGNED NOT NULL, + `Value` TEXT NOT NULL, + `LastUpdate` TIMESTAMP NOT NULL + DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`CachedDataID`), + CONSTRAINT `FK_cached_data_type` FOREIGN KEY (`CachedDataTypeID`) + REFERENCES `cached_data_type` (`CachedDataTypeID`) + ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + -- ******************************** -- server_processes tables -- ******************************** @@ -2688,4 +2717,4 @@ CREATE TABLE `redcap_notification` ( `handled_dt` datetime NULL, PRIMARY KEY (`id`), KEY `i_redcap_notif_received_dt` (`received_dt`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/SQL/New_patches/2025_09_11_add_data_cache_table.sql b/SQL/New_patches/2025_09_11_add_data_cache_table.sql new file mode 100644 index 00000000000..aaab6cc3c6d --- /dev/null +++ b/SQL/New_patches/2025_09_11_add_data_cache_table.sql @@ -0,0 +1,21 @@ +-- Create cached_data_type table to track different types of cached data +CREATE TABLE `cached_data_type` ( + `CachedDataTypeID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `Name` VARCHAR(255) UNIQUE NOT NULL, + PRIMARY KEY (`CachedDataTypeID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create cached_data table to track cached data +CREATE TABLE `cached_data` ( + `CachedDataID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `CachedDataTypeID` INT(10) UNSIGNED NOT NULL, + `Value` TEXT NOT NULL, + `LastUpdate` TIMESTAMP NOT NULL + DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`CachedDataID`), + CONSTRAINT `FK_cached_data_type` FOREIGN KEY (`CachedDataTypeID`) + REFERENCES `cached_data_type` (`CachedDataTypeID`) + ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +INSERT INTO `cached_data_type` (`Name`) SELECT 'projects_disk_space'; diff --git a/modules/electrophysiology_browser/php/module.class.inc b/modules/electrophysiology_browser/php/module.class.inc index 98c1719eaa7..f6e4fbe4425 100644 --- a/modules/electrophysiology_browser/php/module.class.inc +++ b/modules/electrophysiology_browser/php/module.class.inc @@ -63,4 +63,85 @@ class Module extends \Module { return dgettext("electrophysiology_browser", "Electrophysiology Browser"); } + + /** + * {@inheritDoc} + * + * @param string $type The type of widgets to get. + * @param \User $user The user widgets are being retrieved for. + * @param array $options A type dependent list of options to provide + * to the widget. + * + * @return \LORIS\GUI\Widget[] + */ + public function getWidgets(string $type, \User $user, array $options) : array + { + $factory = \NDB_Factory::singleton(); + $baseURL = $factory->settings()->getBaseURL(); + + switch ($type) { + case 'study-progression': + $DB = $factory->database(); + $sessionData = $DB->pselectWithIndexKey( + "SELECT + p.ProjectID, + p.Name AS ProjectName, + COUNT(s.ID) AS count, + CONCAT('" + . $baseURL . + "/electrophysiology_browser/?project=', p.Name + ) as url + FROM session s + JOIN Project p ON p.ProjectID = s.ProjectID + JOIN physiological_file pf ON pf.SessionID = s.ID + WHERE s.Active <> 'N' + AND s.CenterID <> 1 + GROUP BY p.Name", + [], + 'ProjectID' + ); + $eventData = $DB->pselectWithIndexKey( + "SELECT + p.ProjectID, + p.Name AS ProjectName, + COUNT(pte.PhysiologicalTaskEventID) AS count, + CONCAT('" + . $baseURL . + "/electrophysiology_browser/?project=', p.Name + ) as url + FROM session s + JOIN Project p ON p.ProjectID = s.ProjectID + JOIN physiological_file pf ON pf.SessionID = s.ID + JOIN physiological_task_event pte USING (PhysiologicalFileID) + WHERE s.Active <> 'N' + AND s.CenterID <> 1 + GROUP BY p.Name", + [], + 'ProjectID' + ); + return [ + new \LORIS\dashboard\DataWidget( + new \LORIS\GUI\LocalizableString( + "dqt", + "EEG Session", + "EEG Sessions", + ), + $sessionData, + "", + 'rgb(186,255,201)', + ), + new \LORIS\dashboard\DataWidget( + new \LORIS\GUI\LocalizableString( + "dqt", + "EEG Event", + "EEG Events", + ), + $eventData, + "", + 'rgb(186,255,201)', + ) + ]; + } + return []; + } } diff --git a/modules/statistics/css/recruitment.css b/modules/statistics/css/recruitment.css index 1aaccf5781d..b7c535030dc 100644 --- a/modules/statistics/css/recruitment.css +++ b/modules/statistics/css/recruitment.css @@ -11,7 +11,7 @@ background-color: #2FA4E7; } -.study-progression-container { +.study-progression-container, .eeg-data-container { max-height: 415px; overflow-y: auto; } @@ -42,4 +42,4 @@ transform: translateY(-10px); background-color: #f9fdff; box-shadow: 0 12px 12px rgba(0, 0, 0, 0.25); -} \ No newline at end of file +} diff --git a/modules/statistics/jsx/WidgetIndex.js b/modules/statistics/jsx/WidgetIndex.js index 3839ae04916..a6fb9a60421 100644 --- a/modules/statistics/jsx/WidgetIndex.js +++ b/modules/statistics/jsx/WidgetIndex.js @@ -3,6 +3,7 @@ import React, {useEffect, useState} from 'react'; import PropTypes from 'prop-types'; import Recruitment from './widgets/recruitment'; import StudyProgression from './widgets/studyprogression'; +import Electrophysiology from './widgets/electrophysiology'; import {fetchData} from './Fetch'; import Modal from 'Modal'; import Loader from 'Loader'; @@ -23,6 +24,7 @@ import jaStrings from '../locale/ja/LC_MESSAGES/statistics.json'; const WidgetIndex = (props) => { const [recruitmentData, setRecruitmentData] = useState({}); const [studyProgressionData, setStudyProgressionData] = useState({}); + const [electrophysiologyData, setElectrophysiologyData] = useState({}); const [modalChart, setModalChart] = useState(null); const {t, i18n} = useTranslation(); useEffect( () => { @@ -253,6 +255,7 @@ const WidgetIndex = (props) => { ); setRecruitmentData(data); setStudyProgressionData(data); + setElectrophysiologyData(data); }; setup().catch( (error) => { @@ -348,6 +351,12 @@ const WidgetIndex = (props) => { showChart ={showChart} updateFilters ={updateFilters} /> + ); }; diff --git a/modules/statistics/jsx/widgets/electrophysiology.js b/modules/statistics/jsx/widgets/electrophysiology.js new file mode 100644 index 00000000000..fea86cb1cdb --- /dev/null +++ b/modules/statistics/jsx/widgets/electrophysiology.js @@ -0,0 +1,279 @@ +import React, {useEffect, useState} from 'react'; +import PropTypes from 'prop-types'; +import Loader from 'Loader'; +import Panel from 'Panel'; +import {QueryChartForm} from './helpers/queryChartForm'; +import {setupCharts} from './helpers/chartBuilder'; +import {useTranslation} from 'react-i18next'; + +/** + * Electrophysiology - a widget containing statistics for EEG data. + * + * @param {object} props + * @return {JSX.Element} + */ +const Electrophysiology = (props) => { + const {t} = useTranslation(); + const [loading, setLoading] = useState(true); + const [showFiltersBreakdown, setShowFiltersBreakdown] = useState(false); + + let json = props.data; + + const [chartDetails, setChartDetails] = useState({ + 'project_recordings': { + 'eeg_recordings_by_project': { + sizing: 11, + title: t('EEG Recordings by project', {ns: 'statistics'}), + filters: '', + chartType: 'pie', + dataType: 'pie', + label: t('EEG Recordings', {ns: 'statistics'}), + units: null, + showPieLabelRatio: true, + legend: '', + options: {pie: 'pie', bar: 'bar'}, + chartObject: null, + yLabel: t('EEG Recordings', {ns: 'statistics'}), + titlePrefix: t('Project', {ns: 'loris'}), + }, + }, + 'site_recordings': { + 'eeg_recordings_by_site': { + sizing: 11, + title: t('EEG Recordings by site', {ns: 'statistics'}), + filters: '', + chartType: 'pie', + dataType: 'pie', + label: t('EEG Recordings', {ns: 'statistics'}), + units: null, + showPieLabelRatio: true, + legend: '', + options: {pie: 'pie', bar: 'bar'}, + chartObject: null, + yLabel: t('EEG Recordings', {ns: 'statistics'}), + titlePrefix: t('Site', {ns: 'loris'}), + }, + }, + 'project_events': { + 'eeg_events_by_project': { + sizing: 11, + title: t('EEG Events by project', {ns: 'statistics'}), + filters: '', + chartType: 'pie', + dataType: 'pie', + label: t('EEG Events', {ns: 'statistics'}), + units: null, + showPieLabelRatio: true, + legend: '', + options: {pie: 'pie', bar: 'bar'}, + chartObject: null, + yLabel: t('EEG Events', {ns: 'statistics'}), + titlePrefix: t('Project', {ns: 'loris'}), + }, + }, + }); + + const showChart = ((section, chartID) => { + return props.showChart(section, chartID, + chartDetails, setChartDetails); + }); + + /** + * useEffect - modified to run when props.data updates. + */ + useEffect(() => { + if (json && Object.keys(json).length !== 0) { + setupCharts( + t, + false, + chartDetails, + t('Total', {ns: 'loris'}) + ).then((data) => { + setChartDetails(data); + }); + json = props.data; + setLoading(false); + } + }, [props.data]); + + const updateFilters = (formDataObj, section) => { + props.updateFilters(formDataObj, section, + chartDetails, setChartDetails); + }; + + // Helper function to calculate total recruitment + const getTotalRecordings = () => { + return json['eeg_data']['total_recordings'] || -1; + }; + const getTotalEvents = () => { + return json['eeg_data']['total_events'] || -1; + }; + const title = (subtitle) => t('EEG data', {ns: 'statistics'}) + + ' — ' + t(subtitle, {ns: 'statistics'}); + const filterLabel = (hide) => hide ? + t('Hide Filters', {ns: 'loris'}) + : t('Show Filters', {ns: 'loris'}); + return loading ? : ( + <> + { + setupCharts(t, false, chartDetails, t('Total', {ns: 'loris'})); + + // reset filters when switching views + setShowFiltersBreakdown(false); + }} + views={[ + { + content: + getTotalRecordings() > 0 ? ( +
+
+ +
+ {showFiltersBreakdown && ( + { + updateFilters(formDataObj, 'project_recordings'); + }} + /> + )} + {showChart('project_recordings', 'eeg_recordings_by_project')} +
+ ) : ( +

{t('There is no data yet.', {ns: 'statistics'})}

+ ), + + title: title('Recordings by Project'), + subtitle: t( + 'Total Recordings: {{count}}', + { + ns: 'statistics', + count: getTotalRecordings(), + } + ), + onToggleFilters: () => setShowFiltersBreakdown((prev) => !prev), + }, + { + content: + getTotalRecordings() > 0 ? ( +
+
+ +
+ {showFiltersBreakdown && ( + { + updateFilters(formDataObj, 'site_recordings'); + }} + /> + )} + {showChart('site_recordings', 'eeg_recordings_by_site')} +
+ ) : ( +

{t('There is no data yet.', {ns: 'statistics'})}

+ ), + title: title('Recordings by Site'), + subtitle: t( + 'Total Recordings: {{count}}', + { + ns: 'statistics', + count: getTotalRecordings(), + } + ), + onToggleFilters: () => setShowFiltersBreakdown((prev) => !prev), + }, + { + content: + getTotalEvents() > 0 ? ( +
+
+ +
+ {showFiltersBreakdown && ( + { + updateFilters(formDataObj, 'project_events'); + }} + /> + )} + {showChart('project_events', 'eeg_events_by_project')} +
+ ) : ( +

{t('There is no data yet.', {ns: 'statistics'})}

+ ), + title: title('Events by Project'), + subtitle: t( + 'Total Events: {{count}}', + { + ns: 'statistics', + count: getTotalEvents(), + } + ), + onToggleFilters: () => setShowFiltersBreakdown((prev) => !prev), + }, + ]} + /> + + ); +}; +Electrophysiology.propTypes = { + data: PropTypes.object, + baseURL: PropTypes.string, + updateFilters: PropTypes.func, + showChart: PropTypes.func, +}; +Electrophysiology.defaultProps = { + data: {}, +}; + +export default Electrophysiology; diff --git a/modules/statistics/jsx/widgets/helpers/chartBuilder.js b/modules/statistics/jsx/widgets/helpers/chartBuilder.js index b3b51d1f2c3..89a9e1f6c43 100644 --- a/modules/statistics/jsx/widgets/helpers/chartBuilder.js +++ b/modules/statistics/jsx/widgets/helpers/chartBuilder.js @@ -76,7 +76,7 @@ const formatBarData = (data) => { return processedData; }; -const createPieChart = (columns, id, targetModal, colours) => { +const createPieChart = (columns, id, targetModal, colours, units = null, showPieLabelRatio = true) => { let newChart = c3.generate({ bindto: targetModal ? targetModal : id, data: { @@ -92,13 +92,22 @@ const createPieChart = (columns, id, targetModal, colours) => { pie: { label: { format: function(value, ratio, id) { - return value + "("+Math.round(100*ratio)+"%)"; + if (units) { + value = `${value} ${units}`; + } + if (showPieLabelRatio) { + value = `${value} (${(ratio * 100).toFixed(0)}%)`; + } + return value; } } }, tooltip: { format: { value: function (value, ratio) { + if (units) { + value = `${value} ${units}`; + } return `${value} (${(ratio * 100).toFixed(0)}%)`; }, }, @@ -107,7 +116,7 @@ const createPieChart = (columns, id, targetModal, colours) => { return newChart; } -const createBarChart = (t, labels, columns, id, targetModal, colours, dataType) => { +const createBarChart = (labels, columns, id, targetModal, colours, dataType, yLabel) => { let newChart = c3.generate({ bindto: targetModal ? targetModal : id, data: { @@ -130,11 +139,11 @@ const createBarChart = (t, labels, columns, id, targetModal, colours, dataType) axis: { x: { type: 'category', - categories: labels, + categories: labels, }, y: { label: { - text: t('Candidates registered', { ns: 'statistics'}), + text: yLabel, position: 'inner-top' }, }, @@ -167,6 +176,7 @@ const createLineChart = (data, columns, id, label, targetModal, titlePrefix) => } } } + let newChart = c3.generate({ size: { height: targetModal && 500, @@ -226,10 +236,8 @@ const createLineChart = (data, columns, id, label, targetModal, titlePrefix) => name = nameFormat(d[i].name); value = valueFormat(d[i].value, d[i].ratio, d[i].id, d[i].index); - // Calculate percentage based on grand total of entire dataset let percentage = grandTotal > 0 ? ((d[i].value / grandTotal) * 100).toFixed(1) : 0; - bgcolor = $$.levelColor ? $$.levelColor(d[i].value) : color(d[i].id); text += ""; @@ -292,6 +300,7 @@ const setupCharts = async (t, targetIsModal, chartDetails, totalLabel) => { let labels = []; let colours = []; if (chart.dataType === 'pie') { + console.log('charDetails', chartDetails); columns = formatPieData(chartData); colours = siteColours; // reformating the columns for a bar chart when it was originally pie data @@ -313,9 +322,9 @@ const setupCharts = async (t, targetIsModal, chartDetails, totalLabel) => { } let chartObject = null; if (chart.chartType === 'pie') { - chartObject = createPieChart(columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours); + chartObject = createPieChart(columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours, chart.units, chart.showPieLabelRatio); } else if (chart.chartType === 'bar') { - chartObject = createBarChart(t, labels, columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours, chart.dataType); + chartObject = createBarChart(labels, columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours, chart.dataType, chart.yLabel); } else if (chart.chartType === 'line') { chartObject = createLineChart(chartData, columns, `#${chartID}`, chart.label, targetIsModal && '#dashboardModal', chart.titlePrefix); } diff --git a/modules/statistics/jsx/widgets/recruitment.js b/modules/statistics/jsx/widgets/recruitment.js index 77812611baa..77348e35313 100644 --- a/modules/statistics/jsx/widgets/recruitment.js +++ b/modules/statistics/jsx/widgets/recruitment.js @@ -32,6 +32,7 @@ const Recruitment = (props) => { dataType: 'pie', label: 'Age (Years)', options: {pie: 'pie', bar: 'bar'}, + yLabel: t('Candidates registered', {ns: 'statistics'}), legend: 'under', chartObject: null, }, @@ -42,6 +43,7 @@ const Recruitment = (props) => { dataType: 'pie', label: 'Ethnicity', options: {pie: 'pie', bar: 'bar'}, + yLabel: t('Candidates registered', {ns: 'statistics'}), legend: 'under', chartObject: null, }, @@ -55,6 +57,7 @@ const Recruitment = (props) => { label: 'Participants', legend: '', options: {pie: 'pie', bar: 'bar'}, + yLabel: t('Candidates registered', {ns: 'statistics'}), chartObject: null, }, 'siterecruitment_bysex': { @@ -64,6 +67,7 @@ const Recruitment = (props) => { dataType: 'bar', legend: 'under', options: {bar: 'bar', pie: 'pie'}, + yLabel: t('Candidates registered', {ns: 'statistics'}), chartObject: null, }, }, @@ -75,6 +79,7 @@ const Recruitment = (props) => { dataType: 'line', legend: '', options: {line: 'line'}, + yLabel: t('Candidates registered', {ns: 'statistics'}), chartObject: null, }, }, @@ -149,6 +154,7 @@ const Recruitment = (props) => { setChartDetails); }; + // Helper functions to calculate totals for each view const getTotalProjectsCount = () => { return Object.keys(json['recruitment'] || {}) .filter((key) => key !== 'overall').length; diff --git a/modules/statistics/jsx/widgets/studyprogression.js b/modules/statistics/jsx/widgets/studyprogression.js index 70d6962c1bc..32df8255495 100644 --- a/modules/statistics/jsx/widgets/studyprogression.js +++ b/modules/statistics/jsx/widgets/studyprogression.js @@ -45,7 +45,8 @@ const StudyProgression = (props) => { legend: 'under', options: {line: 'line'}, chartObject: null, - titlePrefix: 'Month', + yLabel: t('Candidates registered', {ns: 'statistics'}), + titlePrefix: t('Month', {ns: 'loris'}), }, }, 'total_recruitment': { @@ -58,7 +59,25 @@ const StudyProgression = (props) => { legend: '', options: {line: 'line'}, chartObject: null, - titlePrefix: 'Month', + yLabel: t('Candidates registered', {ns: 'statistics'}), + titlePrefix: t('Month', {ns: 'loris'}), + }, + }, + 'project_sizes': { + 'size_byproject': { + sizing: 11, + title: t('Dataset size breakdown by project', {ns: 'statistics'}), + filters: '', + chartType: 'pie', + dataType: 'pie', + label: t('Size (GB)', {ns: 'statistics'}), + units: t('GB', {ns: 'loris'}), + showPieLabelRatio: false, + legend: '', + options: {pie: 'pie', bar: 'bar'}, + chartObject: null, + yLabel: t('Size (GB)', {ns: 'statistics'}), + titlePrefix: t('Project', {ns: 'loris'}), }, }, }); @@ -173,9 +192,16 @@ const StudyProgression = (props) => { {showChart('total_scans', 'scans_bymonth')} ) : ( -

There have been no scans yet.

+

{t('There have been no scans yet.', {ns: 'statistics'})}

), title: title('Site Scans'), + subtitle: t( + 'Total Scans: {{count}}', + { + ns: 'statistics', + count: json['studyprogression']['total_scans'], + } + ), onToggleFilters: () => setShowFiltersScans((prev) => !prev), }, { @@ -212,11 +238,53 @@ const StudyProgression = (props) => { {showChart('total_recruitment', 'siterecruitment_bymonth')} ) : ( -

There have been no candidates registered yet.

+

+ {t( + 'There have been no candidates registered yet.', + {ns: 'statistics'} + )} +

), title: title('Site Recruitment'), onToggleFilters: () => showFiltersBreakdown((prev) => !prev), }, + { + content: + Object.keys(json['options']['projects']).length > 0 ? ( +
+
+
+ {showFiltersBreakdown && ( + { + updateFilters(formDataObj, 'project_sizes'); + }} + /> + )} + {showChart('project_sizes', 'size_byproject')} +
+ ) : ( +

{t('There is no data yet.', {ns: 'statistics'})}

+ ), + title: title('Project Dataset Sizes'), + subtitle: t( + 'Total Size: {{count}} GB', + { + ns: 'statistics', + count: json['studyprogression']['total_size'] ?? -1, + } + ), + }, ]} /> diff --git a/modules/statistics/php/charts.class.inc b/modules/statistics/php/charts.class.inc index a9d39d04cb1..3bdd6540c62 100644 --- a/modules/statistics/php/charts.class.inc +++ b/modules/statistics/php/charts.class.inc @@ -102,6 +102,14 @@ class Charts extends \NDB_Page return $this->_handleSiteLineData($request); case 'agedistribution_line': return $this->_handleAgeDistributionByProject($request); + case 'eeg_recordings_by_site': + return $this->_handleSiteEEGRecordingsBreakdown($request); + case 'eeg_recordings_by_project': + return $this->_handleProjectEEGRecordingsBreakdown($request); + case 'eeg_events_by_project': + return $this->_handleProjectEEGEventsBreakdown($request); + case 'size_byproject': + return $this->_handleProjectSizeBreakdown(); default: return new \LORIS\Http\Response\JSON\NotFound(); } @@ -831,4 +839,229 @@ class Charts extends \NDB_Page } return $data; } + + /** + * Handle an incoming request for project size breakdown. + * + * @return ResponseInterface + */ + private function _handleProjectSizeBreakdown() + { + $DB = \NDB_Factory::singleton()->database(); + $user = \NDB_Factory::singleton()->user(); + $projects = $user->getProjects(); + + $cachedSizeData = json_decode( + html_entity_decode( + $DB->pselectOne( + "SELECT Value + FROM cached_data + JOIN cached_data_type USING (CachedDataTypeID) + WHERE Name='projects_disk_space'", + [] + ) ?? '' + ), + true + ); + + $projectData = []; + if (!is_null($cachedSizeData)) { + foreach ($projects as $project) { + $projectName = $project->getName(); + if (in_array($projectName, array_keys($cachedSizeData))) { + $projectData[] = [ + 'label' => $projectName, + ...$cachedSizeData[$projectName], + ]; + } + } + } + return (new \LORIS\Http\Response\JsonResponse($projectData)); + } + + /** + * Handle an incoming request for EEG recordings by site breakdown. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface + */ + private function _handleSiteEEGRecordingsBreakdown( + ServerRequestInterface $request + ) { + $DB = \NDB_Factory::singleton()->database(); + $user = \NDB_Factory::singleton()->user(); + $sites = $user->getSites(); + + $conditions = $this->_buildQueryConditions($request); + + $data = iterator_to_array( + $DB->pselect( + "SELECT + s.CenterID, + psc.Name, + COUNT(distinct s.ID) as count + FROM physiological_file pf + JOIN session s ON (s.ID=pf.SessionID) + JOIN candidate c ON (c.ID=s.CandidateID) + JOIN psc USING (CenterID) + {$conditions['participantStatusJoin']} + WHERE 1 = 1 + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} + GROUP BY s.CenterID + ORDER BY s.CenterID", + $conditions['params'] + ) + ); + + $processedData = []; + foreach ($data as $row) { + $processedData[$row['CenterID']] = [ + 'CenterID' => $row['CenterID'], + 'Name' => $row['Name'], + 'count' => intval($row['count']), + ]; + } + + $siteData = []; + foreach ($sites as $siteID => $site) { + if (in_array($siteID, array_keys($processedData))) { + $siteData[] = [ + 'label' => $site->getCenterName(), + 'total' => $processedData[$siteID]['count'], + ]; + } + } + + return (new \LORIS\Http\Response\JsonResponse($siteData)); + } + + /** + * Handle an incoming request for EEG recordings by project breakdown. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface + */ + private function _handleProjectEEGRecordingsBreakdown( + ServerRequestInterface $request + ) { + $DB = \NDB_Factory::singleton()->database(); + $user = \NDB_Factory::singleton()->user(); + $projects = $user->getProjects(); + + $conditions = $this->_buildQueryConditions($request); + + $data = iterator_to_array( + $DB->pselect( + "SELECT + s.ProjectID, + p.Name, + COUNT(distinct s.ID) as count + FROM physiological_file pf + JOIN session s ON (s.ID=pf.SessionID) + JOIN Project p USING (ProjectID) + JOIN candidate c ON (c.ID=s.CandidateID) + JOIN psc ON (s.CenterID = psc.CenterID) + {$conditions['participantStatusJoin']} + WHERE 1 = 1 + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} + GROUP BY s.CenterID + ORDER BY s.CenterID", + $conditions['params'] + ) + ); + + $processedData = []; + foreach ($data as $row) { + $processedData[$row['ProjectID']] = [ + 'ProjectID' => $row['ProjectID'], + 'Name' => $row['Name'], + 'count' => intval($row['count']), + ]; + } + + $projectData = []; + foreach ($projects as $projectID => $projectName) { + if (in_array($projectID, array_keys($processedData))) { + $projectData[] = [ + 'label' => $projectName, + 'total' => $processedData[$projectID]['count'], + ]; + } + } + + return (new \LORIS\Http\Response\JsonResponse($projectData)); + } + + /** + * Handle an incoming request for EEG events by project breakdown. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface + */ + private function _handleProjectEEGEventsBreakdown( + ServerRequestInterface $request + ) { + $DB = \NDB_Factory::singleton()->database(); + $user = \NDB_Factory::singleton()->user(); + $projects = $user->getProjects(); + + $conditions = $this->_buildQueryConditions($request); + + $data = iterator_to_array( + $DB->pselect( + "SELECT + s.ProjectID, + p.Name, + COUNT(pte.PhysiologicalTaskEventID) as count + FROM physiological_task_event pte + JOIN physiological_file pf USING (PhysiologicalFileID) + JOIN session s ON (s.ID=pf.SessionID) + JOIN Project p USING (ProjectID) + JOIN candidate c ON (c.ID=s.CandidateID) + JOIN psc ON (s.CenterID = psc.CenterID) + {$conditions['participantStatusJoin']} + WHERE 1 = 1 + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} + GROUP BY s.CenterID + ORDER BY s.CenterID", + $conditions['params'] + ) + ); + + $processedData = []; + foreach ($data as $row) { + $processedData[$row['ProjectID']] = [ + 'ProjectID' => $row['ProjectID'], + 'Name' => $row['Name'], + 'count' => intval($row['count']), + ]; + } + + $projectData = []; + foreach ($projects as $projectID => $projectName) { + if (in_array($projectID, array_keys($processedData))) { + $projectData[] = [ + 'label' => $projectName, + 'total' => $processedData[$projectID]['count'], + ]; + } + } + + return (new \LORIS\Http\Response\JsonResponse($projectData)); + } } diff --git a/modules/statistics/php/module.class.inc b/modules/statistics/php/module.class.inc index ef2b360998b..67942806db7 100644 --- a/modules/statistics/php/module.class.inc +++ b/modules/statistics/php/module.class.inc @@ -71,11 +71,11 @@ class Module extends \Module */ public function getWidgets(string $type, \User $user, array $options) : array { + $factory = \NDB_Factory::singleton(); + $baseURL = $factory->settings()->getBaseURL(); switch ($type) { case 'dashboard': - $factory = \NDB_Factory::singleton(); - $baseURL = $factory->settings()->getBaseURL(); - $widget = new \LORIS\dashboard\Widget( + $widget = new \LORIS\dashboard\Widget( new \LORIS\dashboard\WidgetContent( '', '', @@ -100,6 +100,56 @@ class Module extends \Module $widget->setTemplateVariables(['id' => 'statistics_widgets']); return [$widget]; + case 'study-progression': + $DB = $factory->database(); + $cachedSizeData = json_decode( + html_entity_decode( + $DB->pselectOne( + "SELECT Value + FROM cached_data + JOIN cached_data_type USING (CachedDataTypeID) + WHERE Name='projects_disk_space'", + [] + ) ?? '' + ), + true + ); + + $data = []; + + $projects = $user->getProjects(); + if (!is_null($cachedSizeData)) { + foreach ($projects as $project) { + $projectName = $project->getName(); + if (!in_array($projectName, array_keys($cachedSizeData))) { + continue; + } + + $datasetSize = sprintf( + dgettext('statistics', '%s GB'), + $cachedSizeData[$projectName]['total'] + ); + $data[] = [ + 'ProjectID' => $project->getId(), + 'ProjectName' => $projectName, + 'count' => $datasetSize, + 'url' => "$baseURL/dqt", + ]; + } + } + + return [ + new \LORIS\dashboard\DataWidget( + new \LORIS\GUI\LocalizableString( + "dqt", + "Dataset Size", + "Dataset Size", + ), + $data, + "", + 'rgb(186,225,255)', + ) + ]; } return []; } diff --git a/modules/statistics/php/widgets.class.inc b/modules/statistics/php/widgets.class.inc index 56d307b0cf6..672739a4b26 100644 --- a/modules/statistics/php/widgets.class.inc +++ b/modules/statistics/php/widgets.class.inc @@ -119,6 +119,21 @@ class Widgets extends \NDB_Page implements ETagCalculator $studyWidgets ); + $totalSizeOfProjectsGB = 0; + $projectsSizes = []; + $cachedSizeData = json_decode( + html_entity_decode( + $db->pselectOne( + "SELECT Value + FROM cached_data + JOIN cached_data_type USING (CachedDataTypeID) + WHERE Name='projects_disk_space'", + [] + ) ?? '' + ), + true + ); + foreach ($projects as $pid) { // Set project recruitment data $projectInfo = $config->getProjectSettings(intval(strval($pid))); @@ -129,9 +144,10 @@ class Widgets extends \NDB_Page implements ETagCalculator 'project ID ' . intval(strval($pid)) ); } + $projectName = $projectInfo['Name']; $recruitment[intval(strval($pid))] = $this->_createProjectProgressBar( strval($pid), - $projectInfo['Name'], + $projectName, $projectInfo['recruitmentTarget'], $this->getTotalRecruitmentByProject( $recruitmentRaw, @@ -174,6 +190,14 @@ class Widgets extends \NDB_Page implements ETagCalculator $visitOptions[$visitLabel] = $visitName; } } + + if (!is_null($cachedSizeData)) { + if (in_array($projectName, array_keys($cachedSizeData))) { + $projectSize = $cachedSizeData[$projectName]['total']; + $projectsSizes[$projectName] = $projectSize; + $totalSizeOfProjectsGB += floatval($projectSize); + } + } } $siteOptions = []; @@ -183,6 +207,26 @@ class Widgets extends \NDB_Page implements ETagCalculator } } + $eeg_recordings = $db->pselectOneInt( + "SELECT COUNT(distinct s.ID) as total_recordings + FROM physiological_file pf + JOIN session s ON (s.ID=pf.SessionID) + WHERE s.ProjectID IN (" . + join(',', $user->getProjectIDs()) + . ")", + [] + ); + $eeg_events = $db->pselectOneInt( + "SELECT COUNT(pte.PhysiologicalTaskEventID) as total_events + FROM physiological_task_event pte + JOIN physiological_file pf USING (PhysiologicalFileID) + JOIN session s ON (s.ID=pf.SessionID) + WHERE s.ProjectID IN (" . + join(',', $user->getProjectIDs()) + . ")", + [] + ); + $participantStatusOptions = \Candidate::getParticipantStatusOptions(); @@ -201,10 +245,18 @@ class Widgets extends \NDB_Page implements ETagCalculator 'total_scans' => $totalScans, 'recruitment' => $recruitment, 'progressionData' => $studyProgressionProjects, + 'total_size' => $totalSizeOfProjectsGB, ]; $values['options'] = $options; $values['recruitmentcohorts'] = $recruitmentCohorts; + $values['size_byproject'] = $projectsSizes; + + $values['eeg_data'] = [ + 'total_recordings' => $eeg_recordings, + 'total_events' => $eeg_events + ]; + $this->_cache = new \LORIS\Http\Response\JsonResponse($values); return $this->_cache; @@ -599,7 +651,9 @@ class Widgets extends \NDB_Page implements ETagCalculator && isset($value['ProjectID']) && strval($value['ProjectID']) === $projectID ) { - $title = $widgetConfig['ref']->label()->getN($value['count']); + $title = $widgetConfig['ref']->label()->getN( + intval(preg_split('/\s+/', strval($value['count']))[0]) + ); $projectData[] = [ 'title' => $title, diff --git a/raisinbread/RB_files/RB_cached_data.sql b/raisinbread/RB_files/RB_cached_data.sql new file mode 100644 index 00000000000..cabb8271eed --- /dev/null +++ b/raisinbread/RB_files/RB_cached_data.sql @@ -0,0 +1,6 @@ +SET FOREIGN_KEY_CHECKS=0; +TRUNCATE TABLE `cached_data`; +LOCK TABLES `cached_data` WRITE; +INSERT INTO `cached_data` (`CachedDataID`, `CachedDataTypeID`, `Value`, `LastUpdate`) VALUES (1,1,'{"Pumpernickel":{"total":0.8}}','2025-09-10 11:12:13'); +UNLOCK TABLES; +SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_cached_data_type.sql b/raisinbread/RB_files/RB_cached_data_type.sql new file mode 100644 index 00000000000..e1f1ec608fb --- /dev/null +++ b/raisinbread/RB_files/RB_cached_data_type.sql @@ -0,0 +1,6 @@ +SET FOREIGN_KEY_CHECKS=0; +TRUNCATE TABLE `cached_data_type`; +LOCK TABLES `cached_data_type` WRITE; +INSERT INTO `cached_data_type` (`CachedDataTypeID`, `Name`) VALUES (1,'projects_disk_space'); +UNLOCK TABLES; +SET FOREIGN_KEY_CHECKS=1; diff --git a/tools/update_projects_disk_space.php b/tools/update_projects_disk_space.php new file mode 100644 index 00000000000..508f430d34d --- /dev/null +++ b/tools/update_projects_disk_space.php @@ -0,0 +1,95 @@ +database(); +$config = \NDB_Config::singleton(); + +$dataDir = $config->getSetting('dataDirBasepath'); +$projects = Utility::getProjectList(); + +$projectData = []; + +foreach ($projects as $pid => $project) { + $projectDir = $db->pselectOne( + "SELECT DISTINCT( + SUBSTRING(SUBSTRING_INDEX(FilePath, '/', 2), 1) + ) + FROM ( + SELECT FilePath, SessionID + FROM physiological_file pf + UNION + SELECT File, SessionID as FilePath FROM files f + ) file_table + LEFT JOIN session s ON s.ID = file_table.SessionID + WHERE FilePath LIKE 'bids_imports%' + AND s.ProjectID=:PID LIMIT 1;", + ['PID' => $pid] + ); + if (!is_null($projectDir)) { + $fullPath = $dataDir . $projectDir; + $dirSizeGB = round( + getDirSize($fullPath) / pow(10, 9), + 1 + ); + $projectData[$project]['total'] = $dirSizeGB; + } +} + +$cachedDataTypeID = $db->pselectOneInt( + "SELECT `CachedDataTypeID` + FROM `cached_data_type` + WHERE `Name` = 'projects_disk_space'", + [] +); + +$rowExists = $db->pselectOne( + "SELECT Value FROM cached_data + WHERE CachedDataTypeID = :CDTID;", + ['CDTID' => $cachedDataTypeID] +); + +if ($rowExists) { + $db->update( + 'cached_data', + ['Value' => json_encode($projectData)], + ['CachedDataTypeID' => $cachedDataTypeID] + ); +} else { + $db->insert( + 'cached_data', + [ + 'CachedDataTypeID' => $cachedDataTypeID, + 'Value' => json_encode($projectData) + ] + ); +} + +echo "cached_data:projects_disk_space updated\r\n"; + +/** + * Calculate directory size, recursively, skipping .tgz files + * + * @param string $directory Target directory + * + * @return int + */ +function getDirSize(string $directory): int +{ + $size = 0; + $files = glob($directory . '/*'); + foreach ($files as $path) { + if (!str_ends_with($path, '.tgz')) { + is_file($path) && $size += filesize($path); + is_dir($path) && $size += getDirSize($path); + } + } + return $size; +}