From 6b0616c326c1c51ce0c151b2b7fa1c19f812614a Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Fri, 7 Jun 2024 22:57:17 +1000 Subject: [PATCH] added stats page and ENABLE_STATS_PAGE/STATS_MAX_DAYS config options --- memberportal/api_general/views.py | 1 + memberportal/api_metrics/metrics.py | 27 ++++- memberportal/api_metrics/views.py | 44 ++++++- .../membermatters/constance_config.py | 9 ++ src-frontend/package-lock.json | 113 ++++++++++++++++++ src-frontend/package.json | 2 + src-frontend/quasar.config.js | 2 +- src-frontend/src/boot/apexcharts.ts | 6 + .../src/components/MembersOnsiteCard.vue | 8 +- src-frontend/src/components/MetricsGraph.vue | 75 ++++++++++++ src-frontend/src/i18n/en-AU/index.ts | 14 +++ src-frontend/src/icons/index.ts | 8 ++ src-frontend/src/pages/Stats.vue | 79 ++++++++++++ src-frontend/src/pages/pageAndRouteConfig.ts | 19 +++ 14 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 src-frontend/src/boot/apexcharts.ts create mode 100644 src-frontend/src/components/MetricsGraph.vue create mode 100644 src-frontend/src/pages/Stats.vue diff --git a/memberportal/api_general/views.py b/memberportal/api_general/views.py index 39cbccbc..049fba18 100644 --- a/memberportal/api_general/views.py +++ b/memberportal/api_general/views.py @@ -60,6 +60,7 @@ def get(self, request): "senderId": config.SMS_SENDER_ID, "footer": config.SMS_FOOTER, }, + "enableStatsPage": config.ENABLE_STATS_PAGE, } keys = {"stripePublishableKey": config.STRIPE_PUBLISHABLE_KEY} diff --git a/memberportal/api_metrics/metrics.py b/memberportal/api_metrics/metrics.py index e266811f..b759ff6c 100644 --- a/memberportal/api_metrics/metrics.py +++ b/memberportal/api_metrics/metrics.py @@ -57,7 +57,10 @@ def calculate_member_count(): profile_states.append({"state": state["state"], "total": state["count"]}) Metric.objects.create( - name=Metric.MetricName.MEMBER_COUNT_TOTAL, data=profile_states + name=Metric.MetricName.MEMBER_COUNT_TOTAL, + data=( + profile_states if len(profile_states) else [{"state": "active", "total": 0}] + ), ).full_clean() @@ -74,7 +77,10 @@ def calculate_member_count_6_months(): profile_states.append({"state": state["state"], "total": state["count"]}) Metric.objects.create( - name=Metric.MetricName.MEMBER_COUNT_6_MONTHS, data=profile_states + name=Metric.MetricName.MEMBER_COUNT_6_MONTHS, + data=( + profile_states if len(profile_states) else [{"state": "active", "total": 0}] + ), ).full_clean() @@ -91,7 +97,10 @@ def calculate_member_count_12_months(): profile_states.append({"state": state["state"], "total": state["count"]}) Metric.objects.create( - name=Metric.MetricName.MEMBER_COUNT_12_MONTHS, data=profile_states + name=Metric.MetricName.MEMBER_COUNT_12_MONTHS, + data=( + profile_states if len(profile_states) else [{"state": "active", "total": 0}] + ), ).full_clean() @@ -109,7 +118,11 @@ def calculate_subscription_count(): ) Metric.objects.create( name=Metric.MetricName.SUBSCRIPTION_COUNT_TOTAL, - data=subscription_states_data, + data=( + subscription_states_data + if len(subscription_states_data) + else [{"state": "inactive", "total": 0}] + ), ).full_clean() @@ -139,5 +152,9 @@ def calculate_memberbucks_transactions(): ) Metric.objects.create( name=Metric.MetricName.MEMBERBUCKS_TRANSACTIONS_TOTAL, - data=transaction_data, + data=( + transaction_data + if len(transaction_data) + else [{"type": "stripe", "total": 0.0}] + ), ).full_clean() diff --git a/memberportal/api_metrics/views.py b/memberportal/api_metrics/views.py index 57e81468..d3765e81 100644 --- a/memberportal/api_metrics/views.py +++ b/memberportal/api_metrics/views.py @@ -1,7 +1,11 @@ +from django.db.models import Max +from django.utils import timezone + import api_metrics.metrics from api_metrics.models import Metric from api_general.models import SiteSession +from constance import config from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import permissions @@ -16,13 +20,45 @@ class Statistics(APIView): """ def get(self, request): + statistics = {} + + # On site members + on_site = {"members": [], "count": 0} members = SiteSession.objects.filter(signout_date=None).order_by("-signin_date") - member_list = [] + on_site["count"] = members.count() for member in members: - member_list.append(member.user.profile.get_full_name()) - - statistics = {"onSite": {"members": member_list, "count": members.count()}} + on_site["members"].append(member.user.profile.get_full_name()) + + statistics["on_site"] = on_site + + for metric_name in Metric.MetricName.values: + # Don't return any data from the API if the stats page isn't enabled + if config.ENABLE_STATS_PAGE or request.user.is_admin: + metric_data = [] + one_year_before_today = timezone.now() - timezone.timedelta( + days=config.STATS_MAX_DAYS + ) + last_stat_per_day = ( + Metric.objects.filter( + name=metric_name, creation_date__gte=one_year_before_today + ) + .extra(select={"the_date": "date(creation_date)"}) + .values_list("the_date") + .order_by("-the_date") + .annotate(max_date=Max("creation_date")) + ) + max_dates = [item[1] for item in last_stat_per_day] + metrics = Metric.objects.filter( + name=metric_name, creation_date__in=max_dates + ).order_by("creation_date") + for metric in metrics: + metric_data.append( + {"date": metric.creation_date, "data": metric.data} + ) + statistics[metric_name] = metric_data + else: + statistics[metric_name] = [] return Response(statistics) diff --git a/memberportal/membermatters/constance_config.py b/memberportal/membermatters/constance_config.py index ec22c9ac..814edb47 100644 --- a/memberportal/membermatters/constance_config.py +++ b/memberportal/membermatters/constance_config.py @@ -334,6 +334,14 @@ 3600, "The interval in seconds to calculate and store application level metrics data like member count and door swipes.", ), + "ENABLE_STATS_PAGE": ( + True, + "Enable the stats page that shows member counts and other metrics.", + ), + "STATS_MAX_DAYS": ( + 365, + "The maximum number of days to show on the stats page.", + ), } CONSTANCE_CONFIG_FIELDSETS = OrderedDict( @@ -366,6 +374,7 @@ "ENABLE_DOOR_BUMP_API", ), ), + ("Stats Settings", ("ENABLE_STATS_PAGE", "STATS_MAX_DAYS")), ( "Sentry Error Reporting", ( diff --git a/src-frontend/package-lock.json b/src-frontend/package-lock.json index 72139bd9..bfd098d1 100644 --- a/src-frontend/package-lock.json +++ b/src-frontend/package-lock.json @@ -22,6 +22,7 @@ "@stripe/stripe-js": "^1.29.0", "address": "^1.1.2", "animate.css": "^4.1.1", + "apexcharts": "^3.49.1", "axios": "^0.26.1", "core-js": "^3.22.2", "crypto-js": "^4.1.1", @@ -42,6 +43,7 @@ "vue-i18n": "^9.2.0-beta.35", "vue-router": "^4.0.14", "vue2-transitions": "^0.3.0", + "vue3-apexcharts": "^1.5.3", "vuex": "^4.0.2" }, "devDependencies": { @@ -6258,6 +6260,11 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6474,6 +6481,20 @@ "node": ">= 8" } }, + "node_modules/apexcharts": { + "version": "3.49.1", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.49.1.tgz", + "integrity": "sha512-MqGtlq/KQuO8j0BBsUJYlRG8VBctKwYdwuBtajHgHTmSgUU3Oai+8oYN/rKCXwXzrUlYA+GiMgotAIbXY2BCGw==", + "dependencies": { + "@yr/monotone-cubic-spline": "^1.0.3", + "svg.draggable.js": "^2.2.2", + "svg.easing.js": "^2.0.0", + "svg.filter.js": "^2.0.2", + "svg.pathmorphing.js": "^0.1.3", + "svg.resize.js": "^1.4.3", + "svg.select.js": "^3.0.1" + } + }, "node_modules/arch": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", @@ -20582,6 +20603,89 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg.draggable.js": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz", + "integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==", + "dependencies": { + "svg.js": "^2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.easing.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz", + "integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==", + "dependencies": { + "svg.js": ">=2.3.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.filter.js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz", + "integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==", + "dependencies": { + "svg.js": "^2.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz", + "integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==" + }, + "node_modules/svg.pathmorphing.js": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz", + "integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==", + "dependencies": { + "svg.js": "^2.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.resize.js": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz", + "integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==", + "dependencies": { + "svg.js": "^2.6.5", + "svg.select.js": "^2.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.resize.js/node_modules/svg.select.js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz", + "integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==", + "dependencies": { + "svg.js": "^2.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.select.js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz", + "integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==", + "dependencies": { + "svg.js": "^2.6.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/svgo": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", @@ -21967,6 +22071,15 @@ "resolved": "https://registry.npmjs.org/vue2-transitions/-/vue2-transitions-0.3.0.tgz", "integrity": "sha512-m1ad8K8kufqiEhj5gXHkkqOioI5sW0FaMbRiO0Tv2WFfGbO2eIKrfkFiO3HPQtMJboimaLCN4p/zL81clLbG4w==" }, + "node_modules/vue3-apexcharts": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.5.3.tgz", + "integrity": "sha512-yaHTPoj0iVKAtEVg8wEwIwwvf0VG+lPYNufCf3txRzYQOqdKPoZaZ9P3Dj3X+2A1XY9O1kcTk9HVqvLo+rppvQ==", + "peerDependencies": { + "apexcharts": "> 3.0.0", + "vue": "> 3.0.0" + } + }, "node_modules/vuex": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz", diff --git a/src-frontend/package.json b/src-frontend/package.json index c0f97977..4d047d51 100644 --- a/src-frontend/package.json +++ b/src-frontend/package.json @@ -36,6 +36,7 @@ "@stripe/stripe-js": "^1.29.0", "address": "^1.1.2", "animate.css": "^4.1.1", + "apexcharts": "^3.49.1", "axios": "^0.26.1", "core-js": "^3.22.2", "crypto-js": "^4.1.1", @@ -56,6 +57,7 @@ "vue-i18n": "^9.2.0-beta.35", "vue-router": "^4.0.14", "vue2-transitions": "^0.3.0", + "vue3-apexcharts": "^1.5.3", "vuex": "^4.0.2" }, "devDependencies": { diff --git a/src-frontend/quasar.config.js b/src-frontend/quasar.config.js index 41ffc6a0..56502e98 100644 --- a/src-frontend/quasar.config.js +++ b/src-frontend/quasar.config.js @@ -30,7 +30,7 @@ module.exports = configure(async function (ctx) { // app boot file (/src/boot) // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli-vite/boot-files - boot: ['sentry', 'i18n', 'axios', 'routeGuards', 'capacitor'], + boot: ['sentry', 'i18n', 'axios', 'routeGuards', 'capacitor', 'apexcharts'], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css css: ['app.scss'], diff --git a/src-frontend/src/boot/apexcharts.ts b/src-frontend/src/boot/apexcharts.ts new file mode 100644 index 00000000..2667236f --- /dev/null +++ b/src-frontend/src/boot/apexcharts.ts @@ -0,0 +1,6 @@ +import { boot } from 'quasar/wrappers'; +import VueApexCharts from 'vue3-apexcharts'; + +export default boot(({ app }) => { + app.use(VueApexCharts); +}); diff --git a/src-frontend/src/components/MembersOnsiteCard.vue b/src-frontend/src/components/MembersOnsiteCard.vue index c801e212..ec532b72 100644 --- a/src-frontend/src/components/MembersOnsiteCard.vue +++ b/src-frontend/src/components/MembersOnsiteCard.vue @@ -11,7 +11,7 @@ {{ $t('statistics.memberCount') }} - {{ onsiteCount }} {{ $t('statistics.onSite') }} + {{ onsiteCount }} {{ $t('statistics.on_site') }} @@ -63,11 +63,11 @@ export default { return icons; }, onsiteCount() { - return this.statistics?.onSite ? this.statistics?.onSite?.count : 0; + return this.statistics?.on_site ? this.statistics?.on_site?.count : 0; }, onsiteMembers() { - return this.statistics?.onSite?.members - ? this.statistics?.onSite?.members + return this.statistics?.on_site?.members + ? this.statistics?.on_site?.members : []; }, }, diff --git a/src-frontend/src/components/MetricsGraph.vue b/src-frontend/src/components/MetricsGraph.vue new file mode 100644 index 00000000..be9ee04c --- /dev/null +++ b/src-frontend/src/components/MetricsGraph.vue @@ -0,0 +1,75 @@ + + + diff --git a/src-frontend/src/i18n/en-AU/index.ts b/src-frontend/src/i18n/en-AU/index.ts index f0959a5c..ec1ad1e8 100644 --- a/src-frontend/src/i18n/en-AU/index.ts +++ b/src-frontend/src/i18n/en-AU/index.ts @@ -32,6 +32,7 @@ export default { reportIssue: 'Report Issue', proxy: 'Proxy Votes', recentSwipes: 'Recent Swipes', + stats: 'Stats & Metrics', lastSeen: 'Last Seen', membership: 'Membership', billing: 'Billing Method', @@ -104,6 +105,19 @@ export default { 'groups. It was originally created by Jaimyn Mayer but is now used by several spaces.', linkText: 'on GitHub', }, + stats: { + title: 'Stats and Metrics', + internalStatsDescription: + 'This page lists some stats and metrics collected by the member portal.', + disabled: + 'This feature is currently disabled. Metrics data may not be available or up to date.', + member_count_total: 'Member Count', + member_count_6_months_total: 'Member Count (>6 Mths)', + member_count_12_months_total: 'Member Count (>12 Mths)', + subscription_count_total: 'Subscription States', + memberbucks_balance_total: 'Spacebucks In Circulation', + memberbucks_transactions_total: 'Spacebucks Transaction Volume', + }, button: { submit: 'Submit', send: 'Send', diff --git a/src-frontend/src/icons/index.ts b/src-frontend/src/icons/index.ts index 0a4a1035..0acbb70a 100644 --- a/src-frontend/src/icons/index.ts +++ b/src-frontend/src/icons/index.ts @@ -26,6 +26,7 @@ export default { groupMembers: 'mdi-account-multiple', recentSwipes: 'mdi-history', lastSeen: 'mdi-account-clock', + stats: 'mdi-chart-line', membership: 'mdi-account', profile: 'mdi-account', checkAccess: 'mdi-account-lock', @@ -73,4 +74,11 @@ export default { interlock: 'mdi-tools', lock: 'mdi-lock-outline', unlock: 'mdi-lock-open-variant-outline', + + member_count_total: 'mdi-account-multiple', + member_count_6_months_total: 'mdi-account-multiple', + member_count_12_months_total: 'mdi-account-multiple', + subscription_count_total: 'mdi-receipt', + memberbucks_balance_total: 'mdi-wallet', + memberbucks_transactions_total: 'mdi-wallet', }; diff --git a/src-frontend/src/pages/Stats.vue b/src-frontend/src/pages/Stats.vue new file mode 100644 index 00000000..df9c19fe --- /dev/null +++ b/src-frontend/src/pages/Stats.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src-frontend/src/pages/pageAndRouteConfig.ts b/src-frontend/src/pages/pageAndRouteConfig.ts index 65ccbd15..575e5d92 100644 --- a/src-frontend/src/pages/pageAndRouteConfig.ts +++ b/src-frontend/src/pages/pageAndRouteConfig.ts @@ -132,6 +132,15 @@ const PageAndRouteConfig: PageAndRouteConfigType[] = [ admin: true, component: () => import('pages/Kiosks.vue'), }, + { + icon: icons.stats, + to: '/tools/stats/', + name: 'stats', + loggedIn: true, + memberOnly: true, + admin: true, + component: () => import('pages/Stats.vue'), + }, ], }, { @@ -177,6 +186,16 @@ const PageAndRouteConfig: PageAndRouteConfigType[] = [ memberOnly: true, component: () => import('pages/LastSeen.vue'), }, + { + icon: icons.stats, + to: '/tools/stats/', + name: 'stats', + loggedIn: true, + kiosk: true, + memberOnly: true, + featureEnabledFlag: 'enableStatsPage', + component: () => import('pages/Stats.vue'), + }, ], }, {