diff --git a/labotel/MANIFEST.in b/labotel/MANIFEST.in new file mode 100644 index 00000000..9a0f0252 --- /dev/null +++ b/labotel/MANIFEST.in @@ -0,0 +1,5 @@ +graft indico_labotel/migrations +graft indico_labotel/static +graft indico_labotel/template_overrides + +global-exclude *.pyc __pycache__ .keep diff --git a/labotel/README.md b/labotel/README.md new file mode 100644 index 00000000..5dceb1af --- /dev/null +++ b/labotel/README.md @@ -0,0 +1,13 @@ +# Labotel + +**Labotel** is a CERN-specific [Indico](https://getindico.io) plugin which leverages the Room Booking system in order +to allow for the management of lab spaces/equipment. It is built as a thin layer on top of the original booking system, +with some additional customizations, constraints and integrations. + +## CLI + +The `indico labotel` command can be used to perform a series of maintenance operations: + + * **`export`** - Export desk list to a CSV file. + * **`geocode`** - Set geographical location for all desks/buildings. + * **`update`** - Update the Labotels from a CSV file. diff --git a/labotel/indico_labotel/__init__.py b/labotel/indico_labotel/__init__.py new file mode 100644 index 00000000..a0206348 --- /dev/null +++ b/labotel/indico_labotel/__init__.py @@ -0,0 +1,11 @@ +# This file is part of the CERN Indico plugins. +# Copyright (C) 2014 - 2024 CERN +# +# The CERN Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; see +# the LICENSE file for more details. + +from indico.util.i18n import make_bound_gettext + + +_ = make_bound_gettext('labotel') diff --git a/labotel/indico_labotel/blueprint.py b/labotel/indico_labotel/blueprint.py new file mode 100644 index 00000000..bc5db0fc --- /dev/null +++ b/labotel/indico_labotel/blueprint.py @@ -0,0 +1,20 @@ +# This file is part of the CERN Indico plugins. +# Copyright (C) 2014 - 2024 CERN +# +# The CERN Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; see +# the LICENSE file for more details. + +from indico.core.plugins import IndicoPluginBlueprint + +from indico_labotel.controllers import RHLabotelStats, RHLabotelStatsCSV, RHUserExperiment + + +blueprint = IndicoPluginBlueprint('labotel', __name__, url_prefix='/rooms') + +blueprint.add_url_rule('/api/user/experiment', 'user_experiment', RHUserExperiment, methods=('GET', 'POST')) +blueprint.add_url_rule('/api/labotel-stats', 'stats', RHLabotelStats) +blueprint.add_url_rule('/labotel-stats.csv', 'stats_csv', RHLabotelStatsCSV) + + +# XXX: RHLanding is not handled here on purpose! diff --git a/labotel/indico_labotel/cli.py b/labotel/indico_labotel/cli.py new file mode 100644 index 00000000..375a1ed3 --- /dev/null +++ b/labotel/indico_labotel/cli.py @@ -0,0 +1,248 @@ +# This file is part of the CERN Indico plugins. +# Copyright (C) 2014 - 2024 CERN +# +# The CERN Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; see +# the LICENSE file for more details. + +import csv + +import click +import requests +from flask_pluginengine import current_plugin +from pyproj import Proj, transform + +from indico.cli.core import cli_group +from indico.core.db import db +from indico.core.db.sqlalchemy.protection import ProtectionMode +from indico.modules.groups import GroupProxy +from indico.modules.rb.models.locations import Location +from indico.modules.rb.models.rooms import Room +from indico.modules.users.util import get_user_by_email +from indico.util.console import cformat + + +GIS_URL = 'https://maps.cern.ch/arcgis/rest/services/Batiments/GeocodeServer/findAddressCandidates?postal={}&f=json' +ROOM_FIELDS = ('id', 'division', 'building', 'floor', 'number', 'verbose_name', 'owner', 'acl_entries') +group_cache = {} +latlon_cache = {} +user_cache = {} + + +@cli_group(name='labotel') +def cli(): + """Manage the Labotel plugin.""" + + +def check_changed_fields(original, new): + diff = [] + for field in ROOM_FIELDS: + if field == 'acl_entries': + orig_value = {e.principal for e in original.acl_entries} + target_value = new['acl_entries'] + else: + orig_value = getattr(original, field) + target_value = new[field] + if orig_value != target_value: + diff.append((field, orig_value, target_value)) + return diff + + +def get_location(building): + location = Location.query.filter(Location.name == f'Area {building}', ~Location.is_deleted).first() + if not location: + location = Location(name=f'Area {building}') + print(cformat('%{green!}+%{reset} Adding new location for building {}').format(building)) + db.session.add(location) + db.session.flush() + return location + + +def get_user(email): + if email not in user_cache: + user_cache[email] = get_user_by_email(email) + return user_cache[email] + + +def get_principal(name): + if '@' in name: + return get_user(name) + + # otherwise we assume it's a group's name + cern_ident_provider = current_plugin.settings.get('cern_identity_provider') + group = group_cache.setdefault(name, GroupProxy(name, provider=cern_ident_provider)) + if not group or not group.group: + group = None + print(cformat("%{red}!%{reset} Group %{cyan}{}%{reset} doesn't seem to exist!").format(name)) + return group + + +def get_room(room_id): + room = Room.get(room_id) + if not room: + print(cformat('%{yellow}! Desk with ID {} not found.').format(room_id)) + return room + + +def change_room(room, changes): + for field, __, new_value in changes: + if field == 'acl_entries': + # clear the ACL and add the new principals + room.acl_entries.clear() + db.session.flush() + for p in new_value: + room.update_principal(p, full_access=True) + else: + setattr(room, field, new_value) + + +def _print_changes(room, changes): + print(f'[{room}]:') + for field, old, new in changes: + if field == 'acl_entries': + old = {e.name for e in old} + new = {e.name for e in new} + print(cformat(' %{yellow}>%{reset} %{cyan}{}%{reset}: %{red}{}%{reset} -> %{green}{}%{reset}') + .format(field, old, new)) + print() + + +def _principal_repr(p): + return getattr(p.principal, 'email', p.principal.name) + + +def get_latlon_building(building_num): + if building_num not in latlon_cache: + # this API request should get the positions of a building's + # entrance doors + data = requests.get(GIS_URL.format(building_num)).json() + + # local EPSG reference used in results + epsg_ref = Proj(init='epsg:{}'.format(data['spatialReference']['wkid'])) + + counter = 0 + x, y = 0, 0 + + for c in data['candidates']: + x += c['location']['x'] + y += c['location']['y'] + counter += 1 + + # average position of entrance doors + x /= counter + y /= counter + + # these coordinates are relative to a local EPSG reference. + # we'll have to convert them to EPSG:4326, used by GPS + latlon_ref = Proj(init='epsg:4326') + lon, lat = transform(epsg_ref, latlon_ref, x, y) + + latlon_cache[building_num] = (lat, lon) + print(cformat('%{cyan}{}%{reset}: %{green}{}%{reset}, %{green}{}%{reset}').format(building_num, lat, lon)) + return latlon_cache[building_num] + + +@cli.command() +@click.argument('csv_file', type=click.File('r')) +@click.option('--add-missing', is_flag=True, help='Add UPDATE rooms that do not exist locally') +@click.option('--dry-run', is_flag=True, help="Don't actually change the database, just report on the changes") +def update(csv_file, add_missing, dry_run): + """Update the Labotels from a CSV file.""" + num_changes = 0 + num_adds = 0 + num_removes = 0 + r = csv.reader(csv_file) + + valid_ids = {id_ for id_, in db.session.query(Room.id)} + for room_id, division, building, floor, number, verbose_name, owner_email, acl_row, action in r: + owner = get_user(owner_email) + acl = {get_principal(principal) for principal in acl_row.split(';')} if acl_row else None + + data = { + 'id': int(room_id.decode('utf-8-sig')) if room_id else None, + 'division': division, + 'building': building, + 'floor': floor, + 'number': number, + 'verbose_name': verbose_name, + 'owner': owner, + 'acl_entries': ({owner} | acl) if acl else {owner}, + 'action': action or 'UPDATE' + } + if not data['id'] and action != 'ADD': + print(cformat('%{yellow}! Only ADD lines can have an empty Desk ID. Ignoring line.')) + continue + + if add_missing and data['action'] == 'UPDATE' and data['id'] not in valid_ids: + data['action'] = 'ADD' + print(cformat('%{yellow}! Desk with ID {} not found; adding it.').format(room_id)) + + if data['action'] == 'UPDATE': + room = get_room(room_id) + if not room: + continue + changes = check_changed_fields(room, data) + if changes: + num_changes += 1 + _print_changes(room, changes) + if not dry_run: + change_room(room, changes) + elif data['action'] == 'ADD': + existing_room = Room.query.filter(Room.building == building, + Room.floor == floor, + Room.number == number, + Room.verbose_name == verbose_name).first() + if existing_room: + # a room with the exact same designation already exists + print(cformat('%{yellow}!%{reset} A lab with the name %{cyan}{}%{reset} already exists') + .format(existing_room.full_name)) + continue + print(cformat('%{green!}+%{reset} New lab %{cyan}{}/{}-{} {}').format( + building, floor, number, verbose_name)) + num_adds += 1 + if not dry_run: + room = Room(building=building, floor=floor, number=number, division=division, + verbose_name=verbose_name, owner=owner, location=get_location(building), + protection_mode=ProtectionMode.protected, reservations_need_confirmation=True) + room.update_principal(owner, full_access=True) + if acl: + for principal in acl: + room.update_principal(principal, full_access=True) + db.session.add(room) + elif data['action'] == 'REMOVE': + room = get_room(room_id) + if not room: + continue + print(cformat('%{red}-%{reset} {}').format(room.full_name)) + if not dry_run: + room.is_deleted = True + num_removes += 1 + + print(cformat('\n%{cyan}Total:%{reset} %{green}+%{reset}{} %{yellow}\u00b1%{reset}{} %{red}-%{reset}{} ') + .format(num_adds, num_changes, num_removes)) + + if not dry_run: + db.session.commit() + + +@cli.command() +@click.argument('csv_file', type=click.File('w')) +def export(csv_file): + """Export lab list to a CSV file.""" + writer = csv.writer(csv_file) + for desk in Room.query.filter(~Room.is_deleted).order_by(Room.building, Room.floor, Room.number, Room.verbose_name): + groups = ';'.join(_principal_repr(p) for p in desk.acl_entries) + writer.writerow((desk.id, desk.division, desk.building, desk.floor, desk.number, desk.verbose_name, + desk.owner.email, groups, '')) + + +@cli.command() +@click.option('--dry-run', is_flag=True, help="Don't actually change the database, just report on the changes") +def geocode(dry_run): + """Set geographical location for all labs/buildings.""" + for desk in Room.query.filter(~Room.is_deleted): + latlon = get_latlon_building(desk.building) + if not dry_run: + desk.latitude, desk.longitude = latlon + if not dry_run: + db.session.commit() diff --git a/labotel/indico_labotel/client/js/components/BootstrapOptions.jsx b/labotel/indico_labotel/client/js/components/BootstrapOptions.jsx new file mode 100644 index 00000000..9ad73684 --- /dev/null +++ b/labotel/indico_labotel/client/js/components/BootstrapOptions.jsx @@ -0,0 +1,63 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +import defaultExperimentURL from 'indico-url:plugin_labotel.user_experiment'; + +import PropTypes from 'prop-types'; +import React from 'react'; +import {Button} from 'semantic-ui-react'; + +import {Translate} from 'indico/react/i18n'; +import {indicoAxios, handleAxiosError} from 'indico/utils/axios'; + +export const EXPERIMENTS = ['ATLAS', 'CMS', 'ALICE', 'LHCb', 'HSE']; + +export default class BootstrapOptions extends React.Component { + static propTypes = { + setOptions: PropTypes.func.isRequired, + options: PropTypes.object.isRequired, + }; + + handleExperimentClick = async experiment => { + const {setOptions} = this.props; + setOptions({division: experiment}); + try { + await indicoAxios.post(defaultExperimentURL(), {value: experiment}); + } catch (error) { + handleAxiosError(error); + } + }; + + render() { + const { + options: {division}, + } = this.props; + + return ( + + {EXPERIMENTS.map(experiment => ( + + ))} + + + ); + } +} diff --git a/labotel/indico_labotel/client/js/components/ExtraFilters.jsx b/labotel/indico_labotel/client/js/components/ExtraFilters.jsx new file mode 100644 index 00000000..d80a5222 --- /dev/null +++ b/labotel/indico_labotel/client/js/components/ExtraFilters.jsx @@ -0,0 +1,83 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {Form} from 'semantic-ui-react'; + +import {FilterDropdownFactory} from 'indico/modules/rb/common/filters/FilterBar'; +import FilterFormComponent from 'indico/modules/rb/common/filters/FilterFormComponent'; +import {Translate} from 'indico/react/i18n'; + +import {EXPERIMENTS} from './BootstrapOptions'; + +// eslint-disable-next-line react/prop-types +const divisionRenderer = ({division}) => (!division ? null : {division}); + +class ExtraFilterForm extends FilterFormComponent { + state = { + division: null, + }; + + setDivision(division) { + const {setParentField} = this.props; + + setParentField('division', division); + this.setState({ + division, + }); + } + + render() { + const {division} = this.state; + return ( + + {EXPERIMENTS.map(div => ( + { + this.setDivision(div); + }} + /> + ))} + this.setDivision(null)} + /> + + ); + } +} + +export default class ExtraFilters extends React.Component { + static propTypes = { + setFilter: PropTypes.func.isRequired, + filters: PropTypes.object.isRequired, + disabled: PropTypes.bool.isRequired, + }; + + render() { + const {setFilter, filters, disabled} = this.props; + return ( + Experiment} + form={({division}, setParentField) => ( + + )} + setGlobalState={({division}) => setFilter('division', division)} + initialValues={filters} + renderValue={divisionRenderer} + disabled={disabled} + /> + ); + } +} diff --git a/labotel/indico_labotel/client/js/components/LabotelLanding.jsx b/labotel/indico_labotel/client/js/components/LabotelLanding.jsx new file mode 100644 index 00000000..6b3da285 --- /dev/null +++ b/labotel/indico_labotel/client/js/components/LabotelLanding.jsx @@ -0,0 +1,38 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +import defaultExperimentURL from 'indico-url:plugin_labotel.user_experiment'; + +import React from 'react'; + +import {Landing} from 'indico/modules/rb/modules/landing/Landing'; +import {indicoAxios, handleAxiosError} from 'indico/utils/axios'; + +export default class LabotelLanding extends React.Component { + constructor(props) { + super(props); + this.landing = React.createRef(); + } + + async componentDidMount() { + let response; + try { + response = await indicoAxios.get(defaultExperimentURL()); + } catch (error) { + handleAxiosError(error); + return; + } + const experiment = response.data.value; + if (this.landing.current && experiment) { + this.landing.current.setExtraState({division: experiment}); + } + } + + render() { + return ; + } +} diff --git a/labotel/indico_labotel/client/js/components/Stats.jsx b/labotel/indico_labotel/client/js/components/Stats.jsx new file mode 100644 index 00000000..005055f1 --- /dev/null +++ b/labotel/indico_labotel/client/js/components/Stats.jsx @@ -0,0 +1,151 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +import getLabotelStats from 'indico-url:plugin_labotel.stats'; + +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {Chart} from 'react-charts'; +import {Table, Loader} from 'semantic-ui-react'; + +import {useIndicoAxios} from 'indico/react/hooks'; +import {Translate} from 'indico/react/i18n'; + +import './StatsPage.module.scss'; + +function interpolateRGB(color1, color2, ratio) { + return _.zip(color1, color2).map(([c1, c2]) => Math.round(c1 * ratio + c2 * (1 - ratio))); +} + +function toHex(color) { + return `#${_.map(color, c => c.toString(16).padStart(2, '0')).join('')}`; +} + +const PALETTE = [[204, 0, 0], [0, 200, 81]]; + +const AXES = [ + {primary: true, type: 'ordinal', position: 'bottom'}, + {type: 'linear', position: 'left'}, +]; + +function calculateChartData(data, monthInfo) { + const rows = data.reduce((accum, [building, data]) => { + data.forEach(([experiment, expData]) => { + accum.push([building, experiment, expData]); + }); + return accum; + }, []); + const experimentsByMonth = _.mapValues(_.groupBy(rows, ([, experiment]) => experiment), rows_ => + _.zip(...rows_.map(([, , {months}]) => months)).map(a => _.sum(a)) + ); + return Object.entries(experimentsByMonth).map(([k, months]) => ({ + label: k, + data: months.map((m, i) => [monthInfo[i].name, m]), + })); +} + +const toPercent = number => (Math.round(number * 10000) / 100).toFixed(2); + +function PercentCell({value, total, highlight}) { + const ratio = value / total; + const color = interpolateRGB(PALETTE[1], PALETTE[0], ratio); + return ( + + {`${toPercent(ratio)}%`} +
{value}
+
+ ); +} + +PercentCell.propTypes = { + value: PropTypes.number.isRequired, + total: PropTypes.number.isRequired, + highlight: PropTypes.bool, +}; + +PercentCell.defaultProps = { + highlight: false, +}; + +function StatsTable({data, numDays, months}) { + return ( +
+
+
+ +
+
+ + + + + Building + + + Experiment + + + Number of desks + + {months.map(({name}) => ( + {name} + ))} + + Total + + + + + {data.map(([building, experiments]) => + experiments.map(([experiment, {bookings, deskCount, months: monthData}]) => ( + + {building} + {experiment} + {deskCount} + {monthData.map((value, i) => ( + + ))} + + + )) + )} + +
+
+ ); +} + +StatsTable.propTypes = { + numDays: PropTypes.number.isRequired, + data: PropTypes.arrayOf(PropTypes.array).isRequired, + months: PropTypes.arrayOf(PropTypes.object).isRequired, +}; + +export default function Stats({startDate, endDate}) { + const {loading, data} = useIndicoAxios( + getLabotelStats({ + start_month: startDate.format('YYYY-MM'), + end_month: endDate.format('YYYY-MM'), + }), + {camelize: true} + ); + + return loading || !data ? : ; +} + +Stats.propTypes = { + startDate: PropTypes.object.isRequired, + endDate: PropTypes.object.isRequired, +}; diff --git a/labotel/indico_labotel/client/js/components/StatsPage.jsx b/labotel/indico_labotel/client/js/components/StatsPage.jsx new file mode 100644 index 00000000..18477923 --- /dev/null +++ b/labotel/indico_labotel/client/js/components/StatsPage.jsx @@ -0,0 +1,121 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +import getLabotelStatsCSV from 'indico-url:plugin_labotel.stats_csv'; + +import _ from 'lodash'; +import moment from 'moment'; +import React, {useState} from 'react'; +import {Button, Dropdown, Form} from 'semantic-ui-react'; + +import {Translate} from 'indico/react/i18n'; + +import Stats from './Stats'; +import './StatsPage.module.scss'; + +const CURRENT_YEAR = moment().year(); +const CURRENT_MONTH = moment().month(); +const YEARS = _.times(10, i => { + const year = CURRENT_YEAR - i; + return { + key: year, + text: year, + value: year, + }; +}); +const NUMBER_MONTHS = _.times(12, i => ({ + key: i, + text: i + 1, + value: i + 1, +})); +const DEFAULT_MONTHS = 12; + +export default function StatsPage() { + const [year, setYear] = useState(CURRENT_YEAR); + const [month, setMonth] = useState(CURRENT_MONTH); + const [numMonths, setNumMonths] = useState(DEFAULT_MONTHS); + + const months = _.times(12, m => { + return { + key: m, + value: m, + text: moment() + .month(m) + .format('MMMM'), + disabled: year === CURRENT_YEAR && m > moment().month(), + }; + }); + + const endDate = moment() + .month(month) + .endOf('month') + .year(year); + const startDate = endDate + .clone() + .subtract(numMonths - 1, 'months') + .startOf('month'); + + return ( +
+

+ Labotel Statistics +

+
+ + + + setNumMonths(value)} + /> + + + + setMonth(value)} + /> + + + { + setYear(value); + if (value === CURRENT_YEAR && month > CURRENT_MONTH) { + setMonth(CURRENT_MONTH); + } + }} + value={year} + /> + +
+ ); +} diff --git a/labotel/indico_labotel/client/js/components/StatsPage.module.scss b/labotel/indico_labotel/client/js/components/StatsPage.module.scss new file mode 100644 index 00000000..1c844b38 --- /dev/null +++ b/labotel/indico_labotel/client/js/components/StatsPage.module.scss @@ -0,0 +1,44 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +@import 'rb:styles/palette'; + +.stats-page { + padding: 5em; +} + +.stats-chart-wrapper { + padding: 5em; + background-color: rgba(0, 27, 45, 0.9); +} + +.stats-chart { + width: 100%; + height: 500px; +} + +:global(.ui.table td) { + &.cell-normal { + :global(.percentage) { + color: $dark-gray; + font-size: 0.8em; + } + } + + &.cell-highlight { + :global(.percentage) { + color: $dark-gray; + font-size: 0.8em; + } + font-weight: bold; + background-color: $pastel-gray; + } +} + +.stats-csv-button:global(.ui.button) { + margin-left: auto; +} diff --git a/labotel/indico_labotel/client/js/index.js b/labotel/indico_labotel/client/js/index.js new file mode 100644 index 00000000..e0c6697f --- /dev/null +++ b/labotel/indico_labotel/client/js/index.js @@ -0,0 +1,13 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +import setup from 'indico/modules/rb/setup'; + +import overrides from './overrides'; +import parametrized from './parametrized'; + +setup({...parametrized, ...overrides}); diff --git a/labotel/indico_labotel/client/js/overrides.js b/labotel/indico_labotel/client/js/overrides.js new file mode 100644 index 00000000..01efc65d --- /dev/null +++ b/labotel/indico_labotel/client/js/overrides.js @@ -0,0 +1,16 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +import BootstrapOptions from './components/BootstrapOptions'; +import LabotelLanding from './components/LabotelLanding'; +import ExtraFilters from './components/ExtraFilters'; + +export default { + 'Landing': LabotelLanding, + 'Landing.bootstrapOptions': BootstrapOptions, + 'RoomFilterBar.extraFilters': ExtraFilters, +}; diff --git a/labotel/indico_labotel/client/js/parametrized.jsx b/labotel/indico_labotel/client/js/parametrized.jsx new file mode 100644 index 00000000..eed33c64 --- /dev/null +++ b/labotel/indico_labotel/client/js/parametrized.jsx @@ -0,0 +1,111 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +import {push as pushRoute} from 'connected-react-router'; +import React from 'react'; +import {parametrize} from 'react-overridable'; + +// Import defaults that will be parametrized +import DefaultRoomDetailsModal from 'indico/modules/rb/common/rooms/RoomDetailsModal'; +import DefaultApp from 'indico/modules/rb/components/App'; +import DefaultMenu from 'indico/modules/rb/components/Menu'; +import DefaultSidebarMenu from 'indico/modules/rb/components/SidebarMenu'; +import DefaultBookRoom from 'indico/modules/rb/modules/bookRoom/BookRoom'; +import DefaultBookRoomModal from 'indico/modules/rb/modules/bookRoom/BookRoomModal'; +import DefaultLanding from 'indico/modules/rb/modules/landing/Landing'; +import DefaultLandingStatistics from 'indico/modules/rb/modules/landing/LandingStatistics'; +import {RoomFilterBarBase} from 'indico/modules/rb/modules/roomList/RoomFilterBar'; +import DefaultRoomList from 'indico/modules/rb/modules/roomList/RoomList'; +import {Translate} from 'indico/react/i18n'; +import {ConditionalRoute} from 'indico/react/util'; + +import StatsPage from './components/StatsPage'; + +const App = parametrize(DefaultApp, { + title: Translate.string('Labotel'), + iconName: 'wrench', + renderExtraRoutes(isInitializing) { + // add statistics page + return ; + }, +}); + +const RoomFilterBar = parametrize(RoomFilterBarBase, { + hideOptions: { + capacity: true, + }, +}); + +const BookRoom = parametrize(DefaultBookRoom, { + showSuggestions: false, + labels: { + bookButton: Translate.string('Book Lab'), + preBookButton: Translate.string('Pre-Book Lab'), + detailsButton: Translate.string('See details'), + }, +}); + +const Landing = parametrize(DefaultLanding, { + showUpcomingBookings: false, +}); + +const LandingStatistics = parametrize(DefaultLandingStatistics, () => ({ + labels: { + activeRooms: Translate.string('Labs in use'), + }, +})); + +const Menu = parametrize(DefaultMenu, () => ({ + labels: { + bookRoom: Translate.string('Book a Lab'), + roomList: Translate.string('List of Spaces'), + }, +})); + +const RoomDetailsModal = parametrize(DefaultRoomDetailsModal, () => ({ + title: Translate.string('Lab Details'), +})); + +const BookRoomModal = parametrize(DefaultBookRoomModal, () => ({ + defaultTitles: { + booking: Translate.string('Book a Lab'), + preBooking: Translate.string('Pre-book a Lab'), + }, +})); + +const SidebarMenu = parametrize(DefaultSidebarMenu, ({dispatch}) => ({ + hideOptions: { + myBlockings: true, + }, + extraOptions: [ + { + key: 'stats', + icon: 'chart bar outline', + text: Translate.string('Statistics'), + onClick: () => { + dispatch(pushRoute('/stats')); + }, + }, + ], +})); + +const RoomList = parametrize(DefaultRoomList, { + hideActionsDropdown: true, +}); + +export default { + App, + BookRoom, + BookRoomModal, + Landing, + LandingStatistics, + Menu, + RoomDetailsModal, + RoomFilterBar, + SidebarMenu, + RoomList, +}; diff --git a/labotel/indico_labotel/client/styles/basePalette.scss b/labotel/indico_labotel/client/styles/basePalette.scss new file mode 100644 index 00000000..4920afbe --- /dev/null +++ b/labotel/indico_labotel/client/styles/basePalette.scss @@ -0,0 +1,11 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +@import '~base/palette'; + +$highlight-color: #cf816d; +$dark-highlight-color: #b1604b; diff --git a/labotel/indico_labotel/client/styles/palette.scss b/labotel/indico_labotel/client/styles/palette.scss new file mode 100644 index 00000000..3fbe3176 --- /dev/null +++ b/labotel/indico_labotel/client/styles/palette.scss @@ -0,0 +1,18 @@ +// This file is part of the CERN Indico plugins. +// Copyright (C) 2014 - 2024 CERN +// +// The CERN Indico plugins are free software; you can redistribute +// them and/or modify them under the terms of the MIT License; see +// the LICENSE file for more details. + +@import 'rb:~styles/palette'; + +$white: #fff; +$highlight-color: #cf816d; +$dark-highlight-color: #b1604b; +$primary-color: #2185d0; + +@mixin rb-splash-background { + background-image: url('labotel:images/splash.jpg'); + background-size: cover; +} diff --git a/labotel/indico_labotel/controllers.py b/labotel/indico_labotel/controllers.py new file mode 100644 index 00000000..61f062d4 --- /dev/null +++ b/labotel/indico_labotel/controllers.py @@ -0,0 +1,110 @@ +# This file is part of the CERN Indico plugins. +# Copyright (C) 2014 - 2024 CERN +# +# The CERN Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; see +# the LICENSE file for more details. + +from dateutil.relativedelta import relativedelta +from flask import jsonify, session +from webargs import fields, validate +from webargs.flaskparser import use_kwargs + +from indico.modules.rb.controllers import RHRoomBookingBase +from indico.util.date_time import format_datetime +from indico.util.spreadsheets import send_csv +from indico.web.rh import RHProtected +from indico.web.views import WPNewBase + +from indico_labotel import _ +from indico_labotel.util import calculate_monthly_stats + + +def get_month_dates(start_month, end_month): + start_dt = start_month.replace(day=1, hour=0, minute=0) + end_dt = end_month.replace(hour=23, minute=59) + relativedelta(months=1, days=-1) + return start_dt, end_dt + + +class WPLabotelBase(WPNewBase): + template_prefix = 'rb/' + title = _('Labotel') + bundles = ('common.js', 'common.css', 'react.js', 'react.css', 'jquery.js', 'semantic-ui.js', 'semantic-ui.css') + + +class RHLanding(RHRoomBookingBase): + def _process(self): + return WPLabotelBase.display('room_booking.html') + + +class RHUserExperiment(RHProtected): + def _process_GET(self): + from indico_labotel.plugin import LabotelPlugin + return jsonify(value=LabotelPlugin.user_settings.get(session.user, 'default_experiment')) + + @use_kwargs({ + 'value': fields.String(validate=validate.OneOf({'ATLAS', 'CMS', 'ALICE', 'LHCb', 'HSE'}), allow_none=True) + }) + def _process_POST(self, value): + from indico_labotel.plugin import LabotelPlugin + LabotelPlugin.user_settings.set(session.user, 'default_experiment', value) + + +class RHLabotelStats(RHProtected): + @use_kwargs({ + 'start_month': fields.DateTime('%Y-%m'), + 'end_month': fields.DateTime('%Y-%m') + }, location='query') + def process(self, start_month, end_month): + start_dt, end_dt = get_month_dates(start_month, end_month) + result, months = calculate_monthly_stats(start_dt, end_dt) + # number of days within the boundary dates (inclusive) + num_days = ((end_dt - start_dt).days + 1) + + return jsonify( + data=result, + num_days=num_days, + months=[{ + 'name': format_datetime(m, 'MMMM YYYY', locale=session.lang), + 'id': format_datetime(m, 'YYYY-M'), + 'num_days': ((m + relativedelta(months=1, days=-1)) - m).days + 1 + } for m in months] + ) + + +class RHLabotelStatsCSV(RHProtected): + @use_kwargs({ + 'start_month': fields.DateTime('%Y-%m'), + 'end_month': fields.DateTime('%Y-%m') + }, location='query') + def process(self, start_month, end_month): + start_dt, end_dt = get_month_dates(start_month, end_month) + result, months = calculate_monthly_stats(start_dt, end_dt) + # number of days within the boundary dates (inclusive) + num_days = ((end_dt - start_dt).days + 1) + + headers = ['Building', 'Experiment', 'Number of labs'] + for m in months: + headers += [m.strftime('%b %Y'), m.strftime('%b %Y (%%)')] + headers.append('Total') + headers.append('Total (%)') + + rows = [] + for building, experiments in result: + for experiment, row_data in experiments: + row = { + 'Building': building, + 'Experiment': experiment, + 'Number of labs': row_data['desk_count'] + } + for i, m in enumerate(row_data['months']): + month_dt = months[i] + month_duration = ((months[i] + relativedelta(months=1, days=-1)) - months[i]).days + 1 + percent = float(m) / (row_data['desk_count'] * month_duration) * 100 + row[month_dt.strftime('%b %Y')] = m + row[month_dt.strftime('%b %Y (%%)')] = f'{percent:.2f}%' + row['Total'] = row_data['bookings'] + percent = float(row_data['bookings']) / (row_data['desk_count'] * num_days) * 100 + row['Total (%)'] = f'{percent:.2f}%' + rows.append(row) + return send_csv('labotel_stats.csv', headers, rows) diff --git a/labotel/indico_labotel/migrations/.no-header b/labotel/indico_labotel/migrations/.no-header new file mode 100644 index 00000000..e69de29b diff --git a/labotel/indico_labotel/migrations/20240430_1420_3be5a6e16966_create_count_weekdays_function.py b/labotel/indico_labotel/migrations/20240430_1420_3be5a6e16966_create_count_weekdays_function.py new file mode 100644 index 00000000..f8fa72f5 --- /dev/null +++ b/labotel/indico_labotel/migrations/20240430_1420_3be5a6e16966_create_count_weekdays_function.py @@ -0,0 +1,40 @@ +"""Create count_weekdays function + +Revision ID: 3be5a6e16966 +Revises: +Create Date: 2024-04-30 14:20:00.000000 +""" + +import textwrap + +from alembic import op +from sqlalchemy.sql.ddl import CreateSchema, DropSchema + + +# revision identifiers, used by Alembic. +revision = '3be5a6e16966' +down_revision = None +branch_labels = None +depends_on = None + + +SQL_FUNCTION_COUNT_WEEKDAYS = textwrap.dedent(''' + CREATE FUNCTION plugin_labotel.count_weekdays(from_date date, to_date date) + RETURNS bigint + AS $$ + SELECT COUNT(*) + FROM generate_series(from_date, to_date, '1 day'::interval) d + WHERE extract('dow' FROM d) NOT IN (0, 6) + $$ + LANGUAGE SQL IMMUTABLE STRICT; +''') + + +def upgrade(): + op.execute(CreateSchema('plugin_labotel')) + op.execute(SQL_FUNCTION_COUNT_WEEKDAYS) + + +def downgrade(): + op.execute('DROP FUNCTION plugin_labotel.count_weekdays(from_date date, to_date date)') + op.execute(DropSchema('plugin_labotel')) diff --git a/labotel/indico_labotel/models/__init__.py b/labotel/indico_labotel/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/labotel/indico_labotel/models/count_weekdays.py b/labotel/indico_labotel/models/count_weekdays.py new file mode 100644 index 00000000..54ff9f1f --- /dev/null +++ b/labotel/indico_labotel/models/count_weekdays.py @@ -0,0 +1,52 @@ +# This file is part of the CERN Indico plugins. +# Copyright (C) 2014 - 2024 CERN +# +# The CERN Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; see +# the LICENSE file for more details. + +import textwrap + +from sqlalchemy import DDL, text +from sqlalchemy.event import listens_for +from sqlalchemy.sql.ddl import CreateSchema + +from indico.core import signals +from indico.core.db import db +from indico.core.db.sqlalchemy.core import _schema_exists + + +SCHEMA_NAME = 'plugin_labotel' +SQL_FUNCTION_COUNT_WEEKDAYS = textwrap.dedent(''' + CREATE FUNCTION plugin_labotel.count_weekdays(from_date date, to_date date) + RETURNS bigint + AS $$ + SELECT COUNT(*) + FROM generate_series(from_date, to_date, '1 day'::interval) d + WHERE extract('dow' FROM d) NOT IN (0, 6) + $$ + LANGUAGE SQL IMMUTABLE STRICT; +''') + + +def _should_create_function(ddl, target, connection, **kw): + sql = ''' + SELECT COUNT(*) + FROM information_schema.routines + WHERE routine_schema = 'plugin_labotel' AND routine_name = 'count_weekdays' + ''' + count = connection.execute(text(sql)).scalar() + return not count + + +@listens_for(db.Model.metadata, 'before_create') +def _create_plugin_schema(target, connection, **kw): + # We do not have any actual models, so we have to manually create our schema... + if not _schema_exists(connection, SCHEMA_NAME): + CreateSchema(SCHEMA_NAME).execute(connection) + signals.core.db_schema_created.send(SCHEMA_NAME, connection=connection) + + +@signals.core.db_schema_created.connect_via(SCHEMA_NAME) +def _create_count_weekdays_func(sender, connection, **kwargs): + DDL(SQL_FUNCTION_COUNT_WEEKDAYS).execute_if(callable_=_should_create_function).execute(connection) diff --git a/labotel/indico_labotel/plugin.py b/labotel/indico_labotel/plugin.py new file mode 100644 index 00000000..c2fae2c1 --- /dev/null +++ b/labotel/indico_labotel/plugin.py @@ -0,0 +1,71 @@ +# This file is part of the CERN Indico plugins. +# Copyright (C) 2014 - 2024 CERN +# +# The CERN Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; see +# the LICENSE file for more details. + +import os + +from flask import current_app, redirect, request, url_for +from wtforms.fields import SelectField +from wtforms.validators import DataRequired + +from indico.core import signals +from indico.core.auth import multipass +from indico.core.plugins import IndicoPlugin +from indico.web.flask.util import make_view_func +from indico.web.forms.base import IndicoForm + +from indico_labotel import _ +from indico_labotel.blueprint import blueprint +from indico_labotel.cli import cli +from indico_labotel.controllers import RHLanding, WPLabotelBase + + +class SettingsForm(IndicoForm): + cern_identity_provider = SelectField(_('CERN Identity Provider'), validators=[DataRequired()]) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cern_identity_provider.choices = [(k, p.title) for k, p in multipass.identity_providers.items()] + + +class LabotelPlugin(IndicoPlugin): + """Labotel + + Provides labotel-specific functionality + """ + + configurable = True + settings_form = SettingsForm + default_settings = { + 'cern_identity_provider': '' + } + default_user_settings = { + 'default_experiment': None, + } + + def init(self): + super().init() + current_app.before_request(self._before_request) + self.connect(signals.plugin.cli, self._extend_indico_cli) + self.connect(signals.plugin.get_template_customization_paths, self._override_templates) + self.inject_bundle('labotel.js', WPLabotelBase) + self.inject_bundle('labotel.css', WPLabotelBase) + + def get_blueprints(self): + return blueprint + + def _before_request(self): + if request.endpoint == 'categories.display': + return redirect(url_for('rb.roombooking')) + elif request.endpoint == 'rb.roombooking': + # render our own landing page instead of the original RH + return make_view_func(RHLanding)() + + def _extend_indico_cli(self, sender, **kwargs): + return cli + + def _override_templates(self, sender, **kwargs): + return os.path.join(self.root_path, 'template_overrides') diff --git a/labotel/indico_labotel/static/images/splash.jpg b/labotel/indico_labotel/static/images/splash.jpg new file mode 100644 index 00000000..17ba853c Binary files /dev/null and b/labotel/indico_labotel/static/images/splash.jpg differ diff --git a/labotel/indico_labotel/template_overrides/core/rb/emails/reservations/creation_email_to_user.txt b/labotel/indico_labotel/template_overrides/core/rb/emails/reservations/creation_email_to_user.txt new file mode 100644 index 00000000..48d63698 --- /dev/null +++ b/labotel/indico_labotel/template_overrides/core/rb/emails/reservations/creation_email_to_user.txt @@ -0,0 +1,10 @@ +{% extends '~rb/emails/reservations/creation_email_to_user.txt' %} + +{% block booking_details -%} +The lab {{ reservation.room.full_name }} +has been {% block prebooked_prefix %}{% endblock %}booked for {{ reservation.booked_for_name }} +from {{ reservation.start_dt | format_date('EEEE dd/MM/yyyy', locale='en_GB') }} to {{ reservation.end_dt | format_date('EEEE dd/MM/yyyy', locale='en_GB') }}. +Reason: {{ reservation.booking_reason }} +{%- endblock -%} + +{% block confirmed_booking %}{% endblock %} diff --git a/labotel/indico_labotel/template_overrides/core/rb/emails/reservations/reminders/finishing_bookings.html b/labotel/indico_labotel/template_overrides/core/rb/emails/reservations/reminders/finishing_bookings.html new file mode 100644 index 00000000..6bd53ba6 --- /dev/null +++ b/labotel/indico_labotel/template_overrides/core/rb/emails/reservations/reminders/finishing_bookings.html @@ -0,0 +1,17 @@ +{% extends '~rb/emails/reservations/reminders/finishing_bookings.html' %} + +{% block subject -%} + {% if reservations|length == 1 -%} + One of your lab reservations is finishing soon + {%- else -%} + Some of your lab reservations are finishing soon + {%- endif %} +{%- endblock %} + +{% block message -%} + {%- if reservations|length == 1 -%} + There is a lab booking under your name that will soon come to an end:
+ {%- else -%} + There are lab bookings under your name that will soon come to an end:
+ {%- endif %} +{%- endblock %} diff --git a/labotel/indico_labotel/util.py b/labotel/indico_labotel/util.py new file mode 100644 index 00000000..e1ff9c5e --- /dev/null +++ b/labotel/indico_labotel/util.py @@ -0,0 +1,110 @@ +# This file is part of the CERN Indico plugins. +# Copyright (C) 2014 - 2024 CERN +# +# The CERN Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; see +# the LICENSE file for more details. + +import itertools + +from dateutil.relativedelta import relativedelta +from dateutil.rrule import MONTHLY, rrule +from sqlalchemy.orm import aliased +from sqlalchemy.sql.expression import bindparam + +from indico.core.db import db +from indico.modules.rb.models.reservations import Reservation, ReservationOccurrence +from indico.modules.rb.models.rooms import Room +from indico.util.string import natural_sort_key + + +def _build_per_building_query(*query_results): + return (db.session + .query(*query_results) + .select_from(ReservationOccurrence) + .filter(Room.is_reservable, ~Room.is_deleted) + .filter(ReservationOccurrence.is_valid, Reservation.is_accepted) + .join(Reservation) + .join(Room) + .group_by(Room.building, Room.division)) + + +def calculate_monthly_stats(start_dt, end_dt): + """Calculate monthly stats for the Labotel system, based on a date range.""" + + room = aliased(Room) + months = list(rrule(freq=MONTHLY, dtstart=start_dt, until=end_dt)) + + desk_count = (db.session.query(db.func.count(room.id)) + .filter( + Room.building == room.building, + Room.division == room.division, + room.is_reservable, + ~room.is_deleted) + ).label('desk_count') + + # a first query which retrieves building data as well as the total number of bookings + building_query = _build_per_building_query( + Room.building.label('number'), + Room.division.label('experiment'), + desk_count, + db.func.count( + db.func.concat(Reservation.id, ReservationOccurrence.start_dt)).label('bookings') + ).filter(ReservationOccurrence.start_dt >= start_dt, ReservationOccurrence.end_dt <= end_dt).order_by('number') + + parts = [] + for n, month_start in enumerate(months): + month_end = (month_start + relativedelta(months=1, days=-1)).replace(hour=23, minute=59) + parts.append( + _build_per_building_query( + Room.building.label('number'), + Room.division.label('experiment'), + bindparam(f'month-{n}', n).label('month'), + db.func.count(db.func.concat(Reservation.id, ReservationOccurrence.start_dt)).label('bookings') + ).filter(ReservationOccurrence.start_dt >= month_start, ReservationOccurrence.end_dt <= month_end) + ) + + # create a union with all month queries. this will return a (second) query which will provide + # separate totals for each month + month_query = parts[0].union(*parts[1:]) + + # rearrange the returned rows in a more processable format + bldg_exp_map = [ + ((building, experiment), {'bookings': bookings, 'desk_count': count, 'months': [0] * len(months)}) + for building, experiment, count, bookings in building_query + ] + + # convert the previous list in to a nested dict object + bldg_map = { + k: {bldg_exp[1]: data for bldg_exp, data in v} + for k, v in itertools.groupby( + bldg_exp_map, + lambda w: w[0][0] + ) + } + + # merge the "month query" into the "building query" + for number, experiment, month, bookings in month_query: + bldg_map[number][experiment]['months'][month] = bookings + + # this is a third query which adds in buildings/experiments not matched in the previous ones + unmatched_query = (db.session + .query(Room.building, Room.division, desk_count) + .filter(Room.is_reservable, ~Room.is_deleted) + .group_by(Room.building, Room.division)) + + # let's add all "unmatched" buildings/experiments with zeroed totals + for building, experiment, desk_count in unmatched_query: + if not bldg_map.get(building, {}).get(experiment): + bldg_map.setdefault(building, {}) + bldg_map[building][experiment] = { + 'bookings': 0, + 'desk_count': desk_count, + 'months': [0] * len(months) + } + + # resulted sorted by building/experiment + result = [(number, sorted(v.items())) + for number, v in sorted(bldg_map.items(), key=lambda x: natural_sort_key(x[0]))] + + return result, months diff --git a/labotel/package-lock.json b/labotel/package-lock.json new file mode 100644 index 00000000..ebac4a20 --- /dev/null +++ b/labotel/package-lock.json @@ -0,0 +1,339 @@ +{ + "name": "indico-plugin-labotel", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "indico-plugin-labotel", + "dependencies": { + "react-charts": "^2.0.0-beta.6" + } + }, + "node_modules/@reach/observe-rect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz", + "integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==" + }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==" + }, + "node_modules/d3-delaunay": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-5.3.0.tgz", + "integrity": "sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w==", + "dependencies": { + "delaunator": "4" + } + }, + "node_modules/d3-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", + "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" + }, + "node_modules/d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "dependencies": { + "d3-color": "1 - 2" + } + }, + "node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "node_modules/d3-scale": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", + "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==", + "dependencies": { + "d3-array": "^2.3.0", + "d3-format": "1 - 2", + "d3-interpolate": "1.2.0 - 2", + "d3-time": "^2.1.1", + "d3-time-format": "2 - 3" + } + }, + "node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" + }, + "node_modules/delaunator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz", + "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==" + }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "peer": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "peer": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-charts": { + "version": "2.0.0-beta.7", + "resolved": "https://registry.npmjs.org/react-charts/-/react-charts-2.0.0-beta.7.tgz", + "integrity": "sha512-iUspg9rnx7kD0H/wsK67HNUioOgKgJ8WRXr/Tk3EGP2qcFb9Vo7pjDk4oz1jH12TC+mqL+HFxNYraMkhWd6CUw==", + "dependencies": { + "@reach/observe-rect": "^1.1.0", + "d3-delaunay": "^5.2.1", + "d3-scale": "^3.2.1", + "d3-shape": "^1.3.7", + "d3-voronoi": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.6.3" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "peer": true + } + }, + "dependencies": { + "@reach/observe-rect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz", + "integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==" + }, + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + }, + "d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==" + }, + "d3-delaunay": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-5.3.0.tgz", + "integrity": "sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w==", + "requires": { + "delaunator": "4" + } + }, + "d3-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", + "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" + }, + "d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "requires": { + "d3-color": "1 - 2" + } + }, + "d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "d3-scale": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", + "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==", + "requires": { + "d3-array": "^2.3.0", + "d3-format": "1 - 2", + "d3-interpolate": "1.2.0 - 2", + "d3-time": "^2.1.1", + "d3-time-format": "2 - 3" + } + }, + "d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "requires": { + "d3-path": "1" + } + }, + "d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "requires": { + "d3-array": "2" + } + }, + "d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "requires": { + "d3-time": "1 - 2" + } + }, + "d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" + }, + "delaunator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz", + "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==" + }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "peer": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "peer": true + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "peer": true, + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "react": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", + "peer": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, + "react-charts": { + "version": "2.0.0-beta.7", + "resolved": "https://registry.npmjs.org/react-charts/-/react-charts-2.0.0-beta.7.tgz", + "integrity": "sha512-iUspg9rnx7kD0H/wsK67HNUioOgKgJ8WRXr/Tk3EGP2qcFb9Vo7pjDk4oz1jH12TC+mqL+HFxNYraMkhWd6CUw==", + "requires": { + "@reach/observe-rect": "^1.1.0", + "d3-delaunay": "^5.2.1", + "d3-scale": "^3.2.1", + "d3-shape": "^1.3.7", + "d3-voronoi": "^1.1.2" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "peer": true + } + } +} diff --git a/labotel/package.json b/labotel/package.json new file mode 100644 index 00000000..a0cfbe82 --- /dev/null +++ b/labotel/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "name": "indico-plugin-labotel", + "main": "indico_labotel/client/index.js", + "dependencies": { + "react-charts": "^2.0.0-beta.6" + } +} diff --git a/labotel/setup.cfg b/labotel/setup.cfg new file mode 100644 index 00000000..75763b71 --- /dev/null +++ b/labotel/setup.cfg @@ -0,0 +1,30 @@ +[metadata] +name = indico-plugin-labotel +version = 3.3-dev +url = https://github.com/indico/indico-plugins-cern +license = MIT +author = Indico Team +author_email = indico-team@cern.ch +classifiers = + Environment :: Plugins + Environment :: Web Environment + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3.12 + +[options] +packages = find: +zip_safe = false +include_package_data = true +python_requires = >=3.12.2, <3.13 +install_requires = + indico>=3.3.dev0 + pyproj>=3.0.0.post1,<4 + +[options.entry_points] +indico.plugins = + labotel = indico_labotel.plugin:LabotelPlugin + + + +[pydocstyle] +ignore = D100,D101,D102,D103,D104,D105,D107,D203,D213 diff --git a/labotel/setup.py b/labotel/setup.py new file mode 100644 index 00000000..a7f41746 --- /dev/null +++ b/labotel/setup.py @@ -0,0 +1,11 @@ +# This file is part of the CERN Indico plugins. +# Copyright (C) 2014 - 2024 CERN +# +# The CERN Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; see +# the LICENSE file for more details. + +from setuptools import setup + + +setup() diff --git a/labotel/webpack-bundles.json b/labotel/webpack-bundles.json new file mode 100644 index 00000000..00fc6224 --- /dev/null +++ b/labotel/webpack-bundles.json @@ -0,0 +1,9 @@ +{ + "entry": { + "labotel": "./js/index.js" + }, + "sassOverrides": { + "rb:styles/palette": "./styles/palette.scss", + "base/palette": "./styles/basePalette.scss" + } +}