From a2593daf3093edba2e0fc7b1a03bab9fa7bca38f Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 7 Jun 2024 17:18:48 +0200 Subject: [PATCH 1/2] Add membership chart --- bundle.ts | 1 + web/blueprints/user/__init__.py | 31 +++-- web/blueprints/user/tables.py | 3 + web/resources/js/memberships-chart.ts | 153 ++++++++++++++++++++++ web/templates/user/_user_show_groups.html | 38 +++++- web/templates/user/user_show.html | 1 + 6 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 web/resources/js/memberships-chart.ts diff --git a/bundle.ts b/bundle.ts index 2a8d35963..2ff02165b 100755 --- a/bundle.ts +++ b/bundle.ts @@ -26,6 +26,7 @@ const entryPoints = [ ['tab-anchor', './js/tab-anchor.ts'], ['user-suite', './js/user-suite.ts'], ['rooms-table', './js/rooms-table.ts'], + ['memberships-chart', './js/memberships-chart.ts'], ].map((x) => ( {out: x[0], in: path.join(src, x[1])} )) diff --git a/web/blueprints/user/__init__.py b/web/blueprints/user/__init__.py index f1fe34241..04dcd057f 100644 --- a/web/blueprints/user/__init__.py +++ b/web/blueprints/user/__init__.py @@ -484,6 +484,7 @@ def user_show_groups_json( return TableResponse[MembershipRow]( items=[ MembershipRow( + id=membership.id, group_name=membership.group.name, begins_at=datetime_format( membership.active_during.begin, @@ -496,13 +497,27 @@ def user_show_groups_json( grants=granted or [], denies=denied or [], active=(active := (session.utcnow() in membership.active_during)), - actions=[ - BtnColResponse( - href=url_for( - ".edit_membership", + url_edit=( + url_edit := url_for( + ".edit_membership", + user_id=user_id, + membership_id=membership.id, + ) + ), + url_end=( + url_end := ( + url_for( + ".end_membership", user_id=user_id, membership_id=membership.id, - ), + ) + if active + else None + ) + ), + actions=[ + BtnColResponse( + href=url_edit, title="Bearbeiten", icon="fa-edit", btn_class="btn-link", @@ -511,11 +526,7 @@ def user_show_groups_json( + ( [ BtnColResponse( - href=url_for( - ".end_membership", - user_id=user_id, - membership_id=membership.id, - ), + href=url_end, title="Beenden", icon="fa-power-off", btn_class="btn-link", diff --git a/web/blueprints/user/tables.py b/web/blueprints/user/tables.py index 705e5573e..2e345163e 100644 --- a/web/blueprints/user/tables.py +++ b/web/blueprints/user/tables.py @@ -54,9 +54,12 @@ class Meta: class MembershipRow(BaseModel): + id: int group_name: str begins_at: DateColResponse ends_at: DateColResponse + url_edit: str + url_end: str | None = None actions: list[BtnColResponse] # used by membershipRowAttributes grants: list[str | None] diff --git a/web/resources/js/memberships-chart.ts b/web/resources/js/memberships-chart.ts new file mode 100644 index 000000000..db9511cce --- /dev/null +++ b/web/resources/js/memberships-chart.ts @@ -0,0 +1,153 @@ +import ApexCharts from "apexcharts"; +import type { ApexOptions } from "apexcharts"; +import * as bootstrap from "bootstrap"; + +const HALF_YEAR = 182 * 24 * 60 * 60 * 1000 +const INFINITY = Date.parse("3000-01-01T00:00:00Z") +const fmt = (ms: number): string => { + let d = new Date(ms); + d.setSeconds(0, 0) + // let str = d.toISOString().slice(0, -5) + // return `${str}Z` + // "de" because ISO is unreadable in this context (too information dense) and "en" is just cursed + let str = d.toLocaleString("de") + return `${str}` +}; + +function demandElementById(id: string): T { + const el = document.getElementById(id) + if (el === null) { + throw new Error(`Element #${id} not found`) + } + return el as T +} +const options_: ApexOptions = { + chart: { + height: "200px", + type: 'rangeBar', + events: { + // see https://apexcharts.com/docs/options/chart/events/ + click: (_event, _chartContext, w) => { + console.log(w); + let { seriesIndex, dataPointIndex } = w; + let data = w.config.series[seriesIndex].data[dataPointIndex] as Data; + + // console.log(data); + const ID = "group-detail-modal" + demandElementById("group-detail-title").innerHTML = `Edit membership #${data.id.toString()}`; + + let { url_edit: urlEdit, url_end: urlEnd } = data.orig_data + // console.log(actions) + let elTerm = demandElementById("group-detail-terminate") + let elEd = demandElementById("group-detail-edit") + elEd.href = urlEdit + console.log(urlEnd) + + if (urlEnd !== null) { + elTerm.ariaHidden = "false" + elTerm.classList.remove("d-none") + elTerm.href = urlEnd + } else { + elTerm.ariaHidden = "true" + elTerm.classList.add("d-none") + } + const modal = new bootstrap.Modal(`#${ID}`, {}); + console.log(modal) + modal.show(); + }, + }, + }, + plotOptions: { + bar: { + horizontal: true, + rangeBarGroupRows: true, + } + }, + xaxis: { + type: 'datetime', + max: new Date().getTime() + HALF_YEAR, + tooltip: {enabled: true}, + }, + annotations: { + xaxis: [ + // We could add lots of lines here (tasks, membership start, …). + { x: new Date().getTime(), label: { text: "today" }, }, + ] + }, + stroke: { + show: true, + curve: 'straight', + lineCap: 'butt', + colors: undefined, + width: 2, // this is important: otherwise we might not see e.g. small blockages + dashArray: 0, + }, + tooltip: { + custom: ({ctx, series, seriesIndex, dataPointIndex, w}) => { + let data = w.config.series[seriesIndex].data[dataPointIndex] as Data + let [since, until] = data.y + let range_desc = data.ends_unbounded + ? `since ${fmt(since)}` + : `from ${fmt(since)} +
to ${fmt(until)}` + return ` +
+
+
${data.x}
+

${range_desc}

+ +
+ ` + } + }, +}; + +document.addEventListener('DOMContentLoaded', () => { + const id = "memberships-timeline"; + const el = document.getElementById(id); + if (el === null) { + console.error(`no element with id ${id} found`); + return; + } + fetch(el.dataset.url!) + .then(resp => resp.json()) + .catch(console.error) + .then(j => { + const data = parseResponse(j); + console.log(data) + let chart = new ApexCharts( + el, + { + ...options_, + series: [ + {data}, + ], + } + ) + chart.render() + }) +}) + +type Data = { + x: string + y: [number, number] + begins_unbounded: boolean + ends_unbounded: boolean + id: number, + orig_data: any, +} +function parseResponse(j: any): Data[] { + // schema for `j`: see `user_show_groups_json` + return j.items?.map(mem => ({ + x: mem.group_name, + y: [ + new Date(mem.begins_at.timestamp * 1000).getTime(), + mem.ends_at.timestamp ? new Date(mem.ends_at.timestamp * 1000) : INFINITY, + ], + begins_unbounded: mem.begins_at.timestamp == null, + ends_unbounded: mem.ends_at.timestamp == null, + id: mem.id, + orig_data: mem, + } as Data)) +} + diff --git a/web/templates/user/_user_show_groups.html b/web/templates/user/_user_show_groups.html index 73919f2f6..af0fb6ff4 100644 --- a/web/templates/user/_user_show_groups.html +++ b/web/templates/user/_user_show_groups.html @@ -23,16 +23,46 @@

Resultierende Properties


-
-
+
+
+
+
{{ membership_table_active.render("active-memberships") }}
{{ membership_table_all.render("memberships")}}
+ + +
diff --git a/web/templates/user/user_show.html b/web/templates/user/user_show.html index 8a3fccc16..e920aecb3 100644 --- a/web/templates/user/user_show.html +++ b/web/templates/user/user_show.html @@ -60,4 +60,5 @@ {% block page_script %} {{ resources.link_script_file('tab-anchor.js' | require) }} {{ resources.link_script_file('user-suite.js' | require) }} + {{ resources.link_script_file('memberships-chart.js' | require) }} {% endblock %} From 2430e998367bc9b0a3fd292119218b6d8bc5be7b Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 7 Jun 2024 17:26:36 +0200 Subject: [PATCH 2/2] user_show: Move tab definitions to jinja template --- web/blueprints/user/__init__.py | 50 ------------------------------ web/templates/user/user_show.html | 51 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 50 deletions(-) diff --git a/web/blueprints/user/__init__.py b/web/blueprints/user/__init__.py index 04dcd057f..60f78e31e 100644 --- a/web/blueprints/user/__init__.py +++ b/web/blueprints/user/__init__.py @@ -381,56 +381,6 @@ def user_show(user_id: int) -> ResponseReturnValue: tenancy_table=TenancyTable( data_url=url_for(".tenancies_json", user_id=user.id) ), - tabs=[ - { - 'id': 'hosts', - 'icon': 'fa-laptop', - 'name': 'Hosts', - 'badge': len(user.hosts) - }, - { - 'id': 'tasks', - 'icon': 'fa-clipboard-check', - 'name': 'Tasks', - 'badge': len(user.tasks), - 'badge_color': '#d9534f' if len(user.tasks) > 0 else None - }, - { - 'id': 'logs', - 'icon': 'fa-list-ul', - 'name': 'Logs', - 'badge': len(user.log_entries) - }, - { - 'id': 'traffic', - 'icon': 'fa-chart-area', - 'name': 'Traffic', - }, - { - 'id': 'finance', - 'icon': 'fa-euro-sign', - 'name': 'Finanzen', - }, - { - 'id': 'groups', - 'icon': 'fa-users-cog', - 'name': 'Gruppen', - 'badge': len(user.active_memberships()) - }, - { - 'id': 'room_history', - 'icon': 'fa-history', - 'name': 'Wohnorte', - 'badge': len(user.room_history_entries) - }, - { - 'id': 'tenancies', - 'icon': 'fa-file-signature', - 'name': 'Mietverträge', - 'badge': len(user.tenancies), - 'disabled': len(user.tenancies) == 0, - }, - ] ) diff --git a/web/templates/user/user_show.html b/web/templates/user/user_show.html index e920aecb3..dfa9a5c35 100644 --- a/web/templates/user/user_show.html +++ b/web/templates/user/user_show.html @@ -9,6 +9,57 @@ {% import "macros/forms.html" as forms %} {% import "macros/resources.html" as resources %} +{% set tabs = [ + { + 'id': 'hosts', + 'icon': 'fa-laptop', + 'name': 'Hosts', + 'badge': user.hosts | length + }, + { + 'id': 'tasks', + 'icon': 'fa-clipboard-check', + 'name': 'Tasks', + 'badge': user.tasks | length, + 'badge_color': '#d9534f' if user.tasks | length > 0 else None + }, + { + 'id': 'logs', + 'icon': 'fa-list-ul', + 'name': 'Logs', + 'badge': user.log_entries | length + }, + { + 'id': 'traffic', + 'icon': 'fa-chart-area', + 'name': 'Traffic', + }, + { + 'id': 'finance', + 'icon': 'fa-euro-sign', + 'name': 'Finanzen', + }, + { + 'id': 'groups', + 'icon': 'fa-users-cog', + 'name': 'Gruppen', + 'badge': user.active_memberships() | length + }, + { + 'id': 'room_history', + 'icon': 'fa-history', + 'name': 'Wohnorte', + 'badge': user.room_history_entries | length + }, + { + 'id': 'tenancies', + 'icon': 'fa-file-signature', + 'name': 'Mietverträge', + 'badge': user.tenancies | length, + 'disabled': user.tenancies | length == 0, + }, +] +%} {% block content %} {# Stammdaten #}