diff --git a/README.md b/README.md index 8b02383..f36c242 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ addon | version | maintainers | summary [g2p_registry_g2p_connect_rest_api](g2p_registry_g2p_connect_rest_api/) | 17.0.0.0.0 | | OpenG2P Registry: G2P Connect REST API [g2p_registry_id_deduplication](g2p_registry_id_deduplication/) | 17.0.0.0.0 | | OpenG2P Registry ID Deduplication [g2p_social_registry](g2p_social_registry/) | 17.0.0.0.0 | | OpenG2P Social Registry +[g2p_social_registry_dashboard](g2p_social_registry_dashboard/) | 17.0.0.0.0 | | OpenG2P Social Registry: Dashboard [g2p_social_registry_model](g2p_social_registry_model/) | 17.0.0.0.0 | | G2P Social Registry: Demo [g2p_social_registry_proxy_means_test](g2p_social_registry_proxy_means_test/) | 17.0.0.0.0 | | G2P Social Registry: PMT [g2p_social_registry_theme](g2p_social_registry_theme/) | 17.0.0.0.0 | | OpenG2P Social Registry: Theme diff --git a/g2p_social_registry_dashboard/README.md b/g2p_social_registry_dashboard/README.md new file mode 100644 index 0000000..9aefd9d --- /dev/null +++ b/g2p_social_registry_dashboard/README.md @@ -0,0 +1,3 @@ +# OpenG2P Social Registry Dashboard + +Refer to https://docs.openg2p.org. diff --git a/g2p_social_registry_dashboard/__init__.py b/g2p_social_registry_dashboard/__init__.py new file mode 100644 index 0000000..6c4fc1f --- /dev/null +++ b/g2p_social_registry_dashboard/__init__.py @@ -0,0 +1,170 @@ +# Part of OpenG2P. See LICENSE file for full copyright and licensing details. + +from . import models + +from odoo import _ +from odoo.exceptions import MissingError +import logging + +_logger = logging.getLogger(__name__) + + +def init_materialized_view(env): + """ + Initializes or refreshes the materialized views for the res_partner_dashboard_data. + """ + cr = env.cr + + matviews_to_check = [ + "g2p_gender_count_view", + "g2p_age_distribution_view", + "g2p_total_registrants_view", + "g2p_sr_dashboard_data", + ] + + try: + cr.execute( + """ + SELECT matviewname + FROM pg_matviews + WHERE matviewname IN %s; + """, + (tuple(matviews_to_check),), + ) + + existing_views = set([row[0] for row in cr.fetchall()]) + + if "g2p_gender_count_view" not in existing_views: + gender_query = """ + CREATE MATERIALIZED VIEW g2p_gender_count_view AS + SELECT + rp.company_id, + gt.code AS gender, + COUNT(rp.id) AS gender_count + FROM + res_partner rp + LEFT JOIN + gender_type gt ON rp.gender = gt.value + WHERE + rp.is_registrant = True + AND rp.active = True + AND rp.is_group = False + GROUP BY + rp.company_id, gt.code; + """ + cr.execute(gender_query) + _logger.info("Created materialized view: g2p_gender_count_view") + + if "g2p_age_distribution_view" not in existing_views: + age_distribution_query = """ + CREATE MATERIALIZED VIEW g2p_age_distribution_view AS + SELECT + rp.company_id, + jsonb_build_object( + 'below_18', COUNT(rp.id) FILTER ( + WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) < 18 + ), + '18_to_30', COUNT(rp.id) FILTER ( + WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) BETWEEN 18 AND 30 + ), + '31_to_40', COUNT(rp.id) FILTER ( + WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) BETWEEN 31 AND 40 + ), + '41_to_50', COUNT(rp.id) FILTER ( + WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) BETWEEN 41 AND 50 + ), + 'above_50', COUNT(rp.id) FILTER ( + WHERE EXTRACT(YEAR FROM AGE(rp.birthdate)) > 50 + ) + ) AS age_distribution + FROM + res_partner rp + WHERE + rp.is_registrant = True + AND rp.active = True + AND rp.is_group = False + GROUP BY + rp.company_id; + """ + cr.execute(age_distribution_query) + _logger.info("Created materialized view: g2p_age_distribution_view") + + if "g2p_total_registrants_view" not in existing_views: + total_registrants_query = """ + CREATE MATERIALIZED VIEW g2p_total_registrants_view AS + SELECT + rp.company_id, + jsonb_build_object( + 'total_individuals', COUNT(rp.id) FILTER (WHERE rp.is_group = False), + 'total_groups', COUNT(rp.id) FILTER (WHERE rp.is_group = True) + ) AS total_registrants + FROM + res_partner rp + WHERE + rp.is_registrant = True + AND rp.active = True + GROUP BY + rp.company_id; + """ + cr.execute(total_registrants_query) + _logger.info("Created materialized view: g2p_total_registrants_view") + + if "g2p_sr_dashboard_data" not in existing_views: + dashboard_query = """ + CREATE MATERIALIZED VIEW g2p_sr_dashboard_data AS + SELECT + trv.company_id, + trv.total_registrants, + COALESCE( + jsonb_object_agg(gc.gender, gc.gender_count) FILTER (WHERE gc.gender IS NOT NULL), + '{}' + ) AS gender_spec, + adv.age_distribution + FROM + g2p_total_registrants_view trv + LEFT JOIN + g2p_gender_count_view gc ON trv.company_id = gc.company_id + LEFT JOIN + g2p_age_distribution_view adv ON trv.company_id = adv.company_id + GROUP BY + trv.company_id, trv.total_registrants, adv.age_distribution; + """ + cr.execute(dashboard_query) + _logger.info("Created materialized view: g2p_sr_dashboard_data") + + except Exception as exc: + _logger.error("Error while creating materialized views: %s", str(exc)) + raise MissingError( + _( + "Failed to create the materialized views." + "Please check the logs for details or Manually create it." + ) + ) from exc + + +def drop_materialized_view(env): + """ + Drop all the materialized views related to the dashboard. + """ + cr = env.cr + + matviews_to_drop = [ + "g2p_sr_dashboard_data", + "g2p_gender_count_view", + "g2p_age_distribution_view", + "g2p_total_registrants_view", + ] + + try: + for matview in matviews_to_drop: + cr.execute(f"DROP MATERIALIZED VIEW IF EXISTS {matview} CASCADE;") # pylint: disable=sql-injection + _logger.info("Dropped materialized view: %s", matview) + + except Exception as exc: + _logger.error("Error while dropping materialized views: %s", str(exc)) + raise MissingError( + _( + "Failed to drop the materialized views." + "Please check the logs for details or manually delete the view." + ) + ) from exc diff --git a/g2p_social_registry_dashboard/__manifest__.py b/g2p_social_registry_dashboard/__manifest__.py new file mode 100644 index 0000000..3c10c99 --- /dev/null +++ b/g2p_social_registry_dashboard/__manifest__.py @@ -0,0 +1,28 @@ +# Part of OpenG2P Registry. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenG2P Social Registry: Dashboard", + "category": "G2P", + "version": "17.0.0.0.0", + "sequence": 1, + "author": "OpenG2P", + "website": "https://openg2p.org", + "license": "LGPL-3", + "depends": ["g2p_social_registry"], + "external_dependencies": {}, + "data": ["data/cron_job.xml", "views/menu.xml"], + "assets": { + "web.assets_backend": [ + "g2p_social_registry_dashboard/static/src/components/chart/**/*", + "g2p_social_registry_dashboard/static/src/components/kpi/**/*", + "g2p_social_registry_dashboard/static/src/js/dashboard.js", + "g2p_social_registry_dashboard/static/src/xml/dashboard.xml", + ], + }, + "demo": [], + "images": [], + "application": True, + "installable": True, + "auto_install": False, + "post_init_hook": "init_materialized_view", + "uninstall_hook": "drop_materialized_view", +} diff --git a/g2p_social_registry_dashboard/data/cron_job.xml b/g2p_social_registry_dashboard/data/cron_job.xml new file mode 100644 index 0000000..0ab7fad --- /dev/null +++ b/g2p_social_registry_dashboard/data/cron_job.xml @@ -0,0 +1,17 @@ + + + + + Refresh Materialized View Cron Job + + code + model._refresh_dashboard_materialized_view() + 10 + hours + -1 + + + + diff --git a/g2p_social_registry_dashboard/models/__init__.py b/g2p_social_registry_dashboard/models/__init__.py new file mode 100644 index 0000000..84d1d85 --- /dev/null +++ b/g2p_social_registry_dashboard/models/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenG2P. See LICENSE file for full copyright and licensing details. + +from . import cron +from . import registrant diff --git a/g2p_social_registry_dashboard/models/cron.py b/g2p_social_registry_dashboard/models/cron.py new file mode 100644 index 0000000..7c1445a --- /dev/null +++ b/g2p_social_registry_dashboard/models/cron.py @@ -0,0 +1,47 @@ +import logging + +from odoo import _, models +from odoo.exceptions import MissingError + +_logger = logging.getLogger(__name__) + + +class DashboardCron(models.Model): + _inherit = "ir.cron" + + def _refresh_dashboard_materialized_view(self): + """ + Refreshes all the materialized views related to the Dashboard. + """ + cr = self.env.cr + matviews_to_refresh = [ + "g2p_gender_count_view", + "g2p_age_distribution_view", + "g2p_total_registrants_view", + "g2p_sr_dashboard_data", + ] + + for matview in matviews_to_refresh: + try: + cr.execute( + """ + SELECT matviewname + FROM pg_matviews + WHERE matviewname = %s; + """, + (matview,), + ) + + if not cr.fetchall(): + raise MissingError( + _("Materialized view '%s' does not exist. Please create it first.") % matview + ) + + cr.execute(f"REFRESH MATERIALIZED VIEW {matview}") # pylint: disable=sql-injection + + except Exception as exc: + _logger.error("Error refreshing materialized view '%s': %s", matview, str(exc)) + raise MissingError( + _("Failed to refresh materialized view '%s'. Please check the logs for details.") + % matview + ) from exc diff --git a/g2p_social_registry_dashboard/models/registrant.py b/g2p_social_registry_dashboard/models/registrant.py new file mode 100644 index 0000000..0a4b607 --- /dev/null +++ b/g2p_social_registry_dashboard/models/registrant.py @@ -0,0 +1,35 @@ +# Part of OpenG2P. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ResPartnerDashboard(models.Model): + _inherit = "res.partner" + + @api.model + def get_dashboard_data(self): + """Fetch data from materialized view and prepare it for charts.""" + company_id = self.env.company.id + + query = """ + SELECT total_registrants, gender_spec, age_distribution + FROM g2p_sr_dashboard_data + WHERE company_id = %s + """ + self.env.cr.execute(query, (company_id,)) + result = self.env.cr.fetchone() + + total_registrants, gender_spec, age_distribution = result + + return { + "total_individuals": total_registrants.get("total_individuals", 0), + "total_groups": total_registrants.get("total_groups", 0), + "gender_distribution": gender_spec, + "age_distribution": { + "Below 18": age_distribution.get("below_18", 0), + "18 to 30": age_distribution.get("18_to_30", 0), + "31 to 40": age_distribution.get("31_to_40", 0), + "41 to 50": age_distribution.get("41_to_50", 0), + "Above 50": age_distribution.get("above_50", 0), + }, + } diff --git a/g2p_social_registry_dashboard/pyproject.toml b/g2p_social_registry_dashboard/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/g2p_social_registry_dashboard/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/g2p_social_registry_dashboard/static/description/icon.png b/g2p_social_registry_dashboard/static/description/icon.png new file mode 100644 index 0000000..5ecb429 Binary files /dev/null and b/g2p_social_registry_dashboard/static/description/icon.png differ diff --git a/g2p_social_registry_dashboard/static/src/components/chart/chart.js b/g2p_social_registry_dashboard/static/src/components/chart/chart.js new file mode 100644 index 0000000..bffafc7 --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/components/chart/chart.js @@ -0,0 +1,50 @@ +/** @odoo-module */ +/* global Chart */ + +import {Component, onMounted, onWillStart, useRef} from "@odoo/owl"; +import {loadJS} from "@web/core/assets"; + +export class ChartComponent extends Component { + setup() { + this.canvasRef = useRef("canvas"); + + onWillStart(async () => { + await loadJS("https://cdn.jsdelivr.net/npm/chart.js"); + }); + + onMounted(() => this.renderChart()); + } + + renderChart() { + const ctx = this.canvasRef.el.getContext("2d"); + + // eslint-disable-next-line no-new + new Chart(ctx, { + type: this.props.type, + data: { + labels: this.props.labels, + datasets: [ + { + label: this.props.data_label, + data: this.props.data, + backgroundColor: this.props.backgroundColor, + hoverOffset: 2, + }, + ], + }, + options: {...this.props.options}, + }); + } +} + +ChartComponent.template = "g2p_social_registry_dashboard.ChartTemplate"; + +ChartComponent.props = { + type: {type: String, optional: true}, + labels: {type: Array, optional: true}, + data_label: {type: String, optional: true}, + data: {type: Array, optional: true}, + backgroundColor: {type: Array, optional: true}, + options: {type: Object, optional: true}, + size: {type: String, optional: true}, +}; diff --git a/g2p_social_registry_dashboard/static/src/components/chart/chart.xml b/g2p_social_registry_dashboard/static/src/components/chart/chart.xml new file mode 100644 index 0000000..4b82f5a --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/components/chart/chart.xml @@ -0,0 +1,12 @@ + + +
+

+ +

+
+
diff --git a/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js new file mode 100644 index 0000000..bfe8281 --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js @@ -0,0 +1,13 @@ +/** @odoo-module */ + +import {Component} from "@odoo/owl"; + +export class KpiComponent extends Component {} + +KpiComponent.template = "g2p_social_registry_dashboard.KpiTemplate"; + +KpiComponent.props = { + title: {type: String, optional: true}, + data: {type: [String, Number], optional: true}, + icon_class: {type: String, optional: true}, +}; diff --git a/g2p_social_registry_dashboard/static/src/components/kpi/kpi.xml b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.xml new file mode 100644 index 0000000..b725b76 --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.xml @@ -0,0 +1,22 @@ + + + +
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
diff --git a/g2p_social_registry_dashboard/static/src/js/dashboard.js b/g2p_social_registry_dashboard/static/src/js/dashboard.js new file mode 100644 index 0000000..7d81161 --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/js/dashboard.js @@ -0,0 +1,49 @@ +/** @odoo-module **/ +import {Component, useState} from "@odoo/owl"; +import {ChartComponent} from "../components/chart/chart"; +import {KpiComponent} from "../components/kpi/kpi"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +class SRDashboard extends Component { + setup() { + super.setup(); + this.orm = useService("orm"); + this.dashboard_title = "SR Dashboard"; + + this.dashboard_data = useState({ + total_groups: 0, + total_individuals: 0, + gender_distribution_keys: [], + gender_distribution_values: [], + age_distribution_keys: [], + age_distribution_values: [], + }); + + this.dataLoaded = useState({flag: false}); + + this.fetchData(); + } + + async fetchData() { + try { + const data = await this.orm.call("res.partner", "get_dashboard_data", []); + + data.gender_distribution_keys = Object.keys(data.gender_distribution); + data.gender_distribution_values = Object.values(data.gender_distribution); + data.age_distribution_keys = Object.keys(data.age_distribution); + data.age_distribution_values = Object.values(data.age_distribution); + + Object.assign(this.dashboard_data, data); + + this.dataLoaded.flag = true; + } catch (error) { + console.error("Error fetching dashboard data:", error); + } + } +} + +SRDashboard.template = "g2p_social_registry_dashboard.dashboard_template"; +SRDashboard.components = {ChartComponent, KpiComponent}; + +registry.category("actions").add("g2p_social_registry_dashboard.sr_dashboard_tag", SRDashboard); diff --git a/g2p_social_registry_dashboard/static/src/xml/dashboard.xml b/g2p_social_registry_dashboard/static/src/xml/dashboard.xml new file mode 100644 index 0000000..09f7851 --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/xml/dashboard.xml @@ -0,0 +1,63 @@ + + + + +
+
+

+
+ + +
+ +
+ +
+ + +
+ + +
+ + + +
+
+ + +
+ + + +
+
+
+
+
diff --git a/g2p_social_registry_dashboard/views/menu.xml b/g2p_social_registry_dashboard/views/menu.xml new file mode 100644 index 0000000..db56c79 --- /dev/null +++ b/g2p_social_registry_dashboard/views/menu.xml @@ -0,0 +1,20 @@ + + + + + + Dashboard + g2p_social_registry_dashboard.sr_dashboard_tag + + + + +