# Copyright 2025 ICube (University of Strasbourg - CNRS)
# author: Julien PONTABRY (ICube)
#
# This software is a computer program whose purpose is to provide a toolkit
# to model, process and analyze the longitudinal reorganization of brain
# connectivity data, as functional MRI for instance.
#
# This software is governed by the CeCILL-B license under French law and
# abiding by the rules of distribution of free software. You can use,
# modify and/or redistribute the software under the terms of the CeCILL-B
# license as circulated by CEA, CNRS and INRIA at the following URL
# "http://www.cecill.info".
#
# As a counterpart to the access to the source code and rights to copy,
# modify and redistribute granted by the license, users are provided only
# with a limited warranty and the software's author, the holder of the
# economic rights, and the successive licensors have only limited
# liability.
#
# In this respect, the user's attention is drawn to the risks associated
# with loading, using, modifying and/or developing or reproducing the
# software by the user in light of its specific status of free software,
# that may mean that it is complicated to manipulate, and that also
# therefore means that it is reserved for developers and experienced
# professionals having in-depth computer knowledge. Users are therefore
# encouraged to load and test the software's suitability as regards their
# requirements in conditions enabling the security of their systems and/or
# data to be ensured and, more generally, to use and operate it in the
# same conditions as regards security.
#
# The fact that you are presently reading this means that you have had
# knowledge of the CeCILL-B license and that you accept its terms.
import logging
import traceback as tb
from pathlib import Path
import dash
import dash_bootstrap_components as dbc
import dash_uploader as du
import plotly.io as pio
from dash import Dash, set_props, html, dcc, callback, Input, Output, State
from dash_breakpoints import WindowBreakpoints
from flask import send_from_directory, abort
from .core.config import config
from .core.datafilesdb import get_data_file_db
logger = logging.getLogger()
# use orsjon to make JSON 5-10x faster
pio.json.config.default_engine = 'orjson'
# handling of errors messages
[docs]
def callback_error(err):
set_props('message-toast', {
'is_open': True, 'header': "Error", 'icon': "danger", 'duration': None,
'children': str(err)})
logger.error(str(err))
if err_tb := getattr(err, '__traceback__', None):
logger.debug(''.join(tb.format_tb(err_tb)))
# app's definition
app = Dash(__name__, title="fSTG-View - A web-based viewer for spatio-temporal graphs of fMRI data",
external_stylesheets=[dbc.themes.BOOTSTRAP, dbc.icons.BOOTSTRAP],
assets_folder=str(Path(__file__).parent / 'assets'),
on_error=callback_error,
use_pages=True, pages_folder=str(Path(__file__).parent / 'pages'),
suppress_callback_exceptions=True)
# configuration of the uploader system
if config.is_configured('upload_path'):
du.configure_upload(app, str(config.upload_path))
# app's main layout
app.layout = html.Div([
dash.page_container,
dcc.Store(id='store-break-width', storage_type='memory'),
# setup event on window's width breakpoints
WindowBreakpoints(
id='window-width-break',
widthBreakpointThresholdsPx=[576, 768, 992, 1200, 1400],
widthBreakpointNames=['xsm', 'sm', 'md', 'lg', 'xl', 'xxl'],
),
# message display as toasts
dbc.Toast('', id='message-toast', header='', icon='primary', duration=4_000, is_open=False,
dismissable=True, style={'position': 'fixed', 'bottom': 10, 'right': 10, 'width': 350}),
])
[docs]
@callback(
Output('store-break-width', 'data'),
Input('window-width-break', 'widthBreakpoint'),
State('window-width-break', 'width')
)
def store_current_break_width(breakpoint_name, breakpoint_width):
return {'name': breakpoint_name, 'width': breakpoint_width}
# NOTE the following callbacks are supposed to be within the submit page,
# but dash-uploader requires the custom callbacks to be registered after app's initialization.
# Since those callbacks are not useful practical, they are used to pass the callbacks to stores
# and other callbacks.
if config.is_configured('upload_path'):
@du.callback(
output=Output('store-last-uploaded-areas-file', 'data'),
id='upload-areas-file'
)
def on_areas_upload(status: du.uploadstatus):
return str(status.uploaded_files[0]) if status.uploaded_files else None
@du.callback(
output=Output('store-last-uploaded-matrices-files', 'data'),
id='upload-matrices-files'
)
def on_matrices_upload(status: du.uploadstatus):
return [str(f) for f in status.uploaded_files]
# NOTE the following are additional routes
# FIXME the download system relies on two independent SQLite tables (`files` and `jobs`) staying
# in sync: `_process_dataset` writes the token→path mapping to `files`, while `on_job_completed`
# writes the same token to `jobs.result`. If the server is restarted with a different `db_path`,
# or if the `DataFilesDB` singleton was already initialised before `serve` sets its `db_path`
# (causing the new path to be silently dropped), the `files` table will be empty while the `jobs`
# table still shows COMPLETED entries — making all download links return 404. A robust fix would
# be to re-populate the `files` table on startup by scanning `config.data_path` for existing ZIPs.
[docs]
@app.server.route('/download/<token>')
def download_dataset(token):
file_path = get_data_file_db().get(token)
if file_path is None:
logger.warning(f"Download requested for unknown token '{token}'.")
abort(404)
if not file_path.exists():
logger.warning(f"Download requested for token '{token}' but file '{file_path}' does not exist.")
abort(404)
return send_from_directory(config.data_path, file_path.relative_to(config.data_path))