From 32ecb3f7baddf7ff76b54dd91871e9ecc7a01326 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Mon, 11 Nov 2024 19:42:11 +0530 Subject: [PATCH 1/4] Added Social Registry Dashboard Module Signed-off-by: Manoj Kumar --- README.md | 1 + g2p_social_registry_dashboard/README.md | 3 + g2p_social_registry_dashboard/__init__.py | 122 ++++++++++++++++++ g2p_social_registry_dashboard/__manifest__.py | 28 ++++ .../data/cron_job.xml | 17 +++ .../models/__init__.py | 2 + g2p_social_registry_dashboard/models/cron.py | 8 ++ .../models/registrant.py | 52 ++++++++ g2p_social_registry_dashboard/pyproject.toml | 3 + .../static/description/icon.png | Bin 0 -> 3985 bytes .../static/src/js/dashboard.js | 100 ++++++++++++++ .../static/src/scss/dashboard.scss | 15 +++ .../static/src/xml/dashboard.xml | 70 ++++++++++ g2p_social_registry_dashboard/views/menu.xml | 20 +++ 14 files changed, 441 insertions(+) create mode 100644 g2p_social_registry_dashboard/README.md create mode 100644 g2p_social_registry_dashboard/__init__.py create mode 100644 g2p_social_registry_dashboard/__manifest__.py create mode 100644 g2p_social_registry_dashboard/data/cron_job.xml create mode 100644 g2p_social_registry_dashboard/models/__init__.py create mode 100644 g2p_social_registry_dashboard/models/cron.py create mode 100644 g2p_social_registry_dashboard/models/registrant.py create mode 100644 g2p_social_registry_dashboard/pyproject.toml create mode 100644 g2p_social_registry_dashboard/static/description/icon.png create mode 100644 g2p_social_registry_dashboard/static/src/js/dashboard.js create mode 100644 g2p_social_registry_dashboard/static/src/scss/dashboard.scss create mode 100644 g2p_social_registry_dashboard/static/src/xml/dashboard.xml create mode 100644 g2p_social_registry_dashboard/views/menu.xml diff --git a/README.md b/README.md index 2829e8a..e4b6845 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_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..7f7bb21 --- /dev/null +++ b/g2p_social_registry_dashboard/__init__.py @@ -0,0 +1,122 @@ +# Part of OpenG2P Registry. See LICENSE file for full copyright and licensing details. +from . import models + +from odoo import _ +from odoo.exceptions import MissingError + + +def init_materialized_view(env): + """ + Initializes the res_partner_dashboard_data materialized view. + """ + + cr = env.cr + cr.execute( + """ + SELECT + matviewname + FROM + pg_matviews + WHERE + matviewname = 'res_partner_dashboard_data'; + """ + ) + check = cr.fetchone() + + if check: + return {} + + query = """ + CREATE MATERIALIZED VIEW res_partner_dashboard_data AS + SELECT + company_id, + jsonb_build_object( + 'individual_count', COUNT(id) + FILTER (WHERE is_registrant = True AND is_group = False), + 'group_count', COUNT(id) + FILTER (WHERE is_registrant = True AND is_group = True) + ) AS total_registrant, + jsonb_build_object( + 'male_count', COUNT(id) + FILTER ( + WHERE is_registrant = True + AND is_group = False + AND gender = 'Male' + ), + 'female_count', COUNT(id) + FILTER ( + WHERE is_registrant = True + AND is_group = False + AND gender = 'Female' + ) + ) AS gender_spec, + jsonb_build_object( + 'below_18', COUNT(id) + FILTER ( + WHERE is_registrant = True + AND is_group = False + AND EXTRACT(YEAR FROM AGE(birthdate)) < 18 + ), + '18_to_30', COUNT(id) + FILTER ( + WHERE is_registrant = True + AND is_group = False + AND EXTRACT(YEAR FROM AGE(birthdate)) BETWEEN 18 AND 30 + ), + '31_to_40', COUNT(id) + FILTER ( + WHERE is_registrant = True + AND is_group = False + AND EXTRACT(YEAR FROM AGE(birthdate)) BETWEEN 31 AND 40 + ), + '41_to_50', COUNT(id) + FILTER ( + WHERE is_registrant = True + AND is_group = False + AND EXTRACT(YEAR FROM AGE(birthdate)) BETWEEN 41 AND 50 + ), + 'above_50', COUNT(id) + FILTER ( + WHERE is_registrant = True + AND is_group = False + AND EXTRACT(YEAR FROM AGE(birthdate)) > 50 + ) + ) AS age_distribution + FROM + res_partner + GROUP BY + company_id; + """ + + try: + cr.execute(query) + except Exception as exc: + raise MissingError( + _( + "Failed to create the materialized view 'res_partner_dashboard_data'.\n" + "Please create it manually by running the required SQL query with proper permissions." + ) + ) from exc + + +def drop_materialized_view(env): + """ + Drop the res_partner_dashboard_data materialized view. + """ + + cr = env.cr + try: + cr.execute( + """ + DROP MATERIALIZED VIEW IF EXISTS + res_partner_dashboard_data; + """ + ) + + except Exception as exc: + raise Exception( + _( + "Failed to drop the materialized view 'res_partner_dashboard_data'.\n" + "Please 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..6b33ec7 --- /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": [ + "https://cdn.jsdelivr.net/npm/chart.js", + "g2p_social_registry_dashboard/static/src/js/dashboard.js", + "g2p_social_registry_dashboard/static/src/xml/dashboard.xml", + "g2p_social_registry_dashboard/static/src/scss/dashboard.scss", + ], + }, + "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..17f3444 --- /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 + minutes + -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..ab88a8c --- /dev/null +++ b/g2p_social_registry_dashboard/models/__init__.py @@ -0,0 +1,2 @@ +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..63cfe23 --- /dev/null +++ b/g2p_social_registry_dashboard/models/cron.py @@ -0,0 +1,8 @@ +from odoo import models + + +class DashboardCron(models.Model): + _inherit = "ir.cron" + + def _refresh_dashboard_materialized_view(self): + self.env.cr.execute("REFRESH MATERIALIZED VIEW res_partner_dashboard_data") diff --git a/g2p_social_registry_dashboard/models/registrant.py b/g2p_social_registry_dashboard/models/registrant.py new file mode 100644 index 0000000..224cc3e --- /dev/null +++ b/g2p_social_registry_dashboard/models/registrant.py @@ -0,0 +1,52 @@ +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 + + # Fetch data from the materialized view + query = """ + SELECT total_registrant, gender_spec, age_distribution + FROM res_partner_dashboard_data + WHERE company_id = %s + """ + self.env.cr.execute(query, (company_id,)) + result = self.env.cr.fetchone() + + if not result: + return { + "total_individuals": 0, + "total_groups": 0, + "gender_distribution": {"male": 0, "female": 0}, + "age_distribution": { + "below_18": 0, + "18_to_30": 0, + "31_to_40": 0, + "41_to_50": 0, + "above_50": 0, + }, + } + + total_registrant, gender_spec, age_distribution = result + + # Return formatted data + return { + "total_individuals": total_registrant.get("individual_count", 0), + "total_groups": total_registrant.get("group_count", 0), + "gender_distribution": { + "male": gender_spec.get("male_count", 0), + "female": gender_spec.get("female_count", 0), + }, + "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 0000000000000000000000000000000000000000..5ecb429ea9ceb3863d46ea852dc9c9f78d4e901b GIT binary patch literal 3985 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 literal 0 HcmV?d00001 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..fc92e23 --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/js/dashboard.js @@ -0,0 +1,100 @@ +/** @odoo-module **/ +import {Component} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +const actionRegistry = registry.category("actions"); + +class SRDashboard extends Component { + setup() { + super.setup(); + this.orm = useService("orm"); + this._fetch_data(); + } + + _fetch_data() { + // Add loading spinners to tiles + $("#my_individuals").html('
'); + $("#my_groups").html('
'); + $("#gender_pie_chart_container").html('
'); + $("#age_bar_chart_container").html('
'); + + this.orm.call("res.partner", "get_dashboard_data", [], {}).then((result) => { + // Update tiles with fetched data + $("#my_individuals").html("" + result.total_individuals + ""); + $("#my_groups").html("" + result.total_groups + ""); + $("#gender_pie_chart_container").html( + '' + ); + this.renderGenderPieChart(result.gender_distribution); + + // Update age distribution chart + $("#age_bar_chart_container").html( + '' + ); + this.renderAgeBarChart(result.age_distribution); + }); + } + + renderGenderPieChart(data) { + const ctx = document.getElementById("gender_pie_chart").getContext("2d"); + new Chart(ctx, { + type: "pie", + data: { + labels: ["Male", "Female"], + datasets: [ + { + data: [data.male, data.female], + backgroundColor: ["#4e73df", "#1cc88a"], + }, + ], + }, + options: { + responsive: true, + plugins: { + legend: { + position: "top", + }, + }, + }, + }); + } + + renderAgeBarChart(data) { + const ctx = document.getElementById("age_bar_chart").getContext("2d"); + const labels = Object.keys(data); + const values = Object.values(data); + + new Chart(ctx, { + type: "bar", + data: { + labels: labels, + datasets: [ + { + label: "Age Distribution", + data: values, + backgroundColor: "#4e73df", + borderColor: "#4e73df", + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + scales: { + y: { + beginAtZero: true, + }, + }, + plugins: { + legend: { + position: "top", + }, + }, + }, + }); + } +} + +SRDashboard.template = "g2p_social_registry_dashboard.dashboard_template"; +actionRegistry.add("sr_dashboard_tag", SRDashboard); diff --git a/g2p_social_registry_dashboard/static/src/scss/dashboard.scss b/g2p_social_registry_dashboard/static/src/scss/dashboard.scss new file mode 100644 index 0000000..93d23dc --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/scss/dashboard.scss @@ -0,0 +1,15 @@ +.spinner-border { + width: 2rem; + height: 2rem; + vertical-align: middle; + color: $primary; // Use Odoo's primary theme color +} + +#my_individuals, +#my_groups, +#gender_pie_chart_container { + display: flex; + justify-content: center; + align-items: center; + min-height: 2rem; // Ensures consistent spacing even before data loads +} 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..07ad90b --- /dev/null +++ b/g2p_social_registry_dashboard/static/src/xml/dashboard.xml @@ -0,0 +1,70 @@ + + + +
+ +
+
+

SR Dashboard

+
+
+ + +
+ + +
+
+
+
Groups
+
+ +
+
+
+
+ + + +
+
+
+
Individuals
+
+ +
+
+
+
+ + +
+
+
+
Gender Distribution
+
+ +
+
+
+
+ + +
+
+
+
Age Distribution
+
+ +
+
+
+
+ +
+
+
+
diff --git a/g2p_social_registry_dashboard/views/menu.xml b/g2p_social_registry_dashboard/views/menu.xml new file mode 100644 index 0000000..71213b5 --- /dev/null +++ b/g2p_social_registry_dashboard/views/menu.xml @@ -0,0 +1,20 @@ + + + + + + Dashboard + sr_dashboard_tag + + + + + From 6a7e246c4ac578f1f7410a9ef50b08a1e3cdea39 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Wed, 13 Nov 2024 16:07:34 +0530 Subject: [PATCH 2/4] Dashboard: Added chart and KPI components. Created materialized view --- g2p_social_registry_dashboard/__init__.py | 234 +++++++++++------- g2p_social_registry_dashboard/__manifest__.py | 4 +- .../data/cron_job.xml | 2 +- .../models/__init__.py | 2 + g2p_social_registry_dashboard/models/cron.py | 43 +++- .../models/registrant.py | 33 +-- .../static/src/components/chart/chart.js | 50 ++++ .../static/src/components/chart/chart.xml | 12 + .../static/src/components/kpi/kpi.js | 13 + .../static/src/components/kpi/kpi.xml | 22 ++ .../static/src/js/dashboard.js | 113 +++------ .../static/src/scss/dashboard.scss | 15 -- .../static/src/xml/dashboard.xml | 103 ++++---- g2p_social_registry_dashboard/views/menu.xml | 2 +- 14 files changed, 372 insertions(+), 276 deletions(-) create mode 100644 g2p_social_registry_dashboard/static/src/components/chart/chart.js create mode 100644 g2p_social_registry_dashboard/static/src/components/chart/chart.xml create mode 100644 g2p_social_registry_dashboard/static/src/components/kpi/kpi.js create mode 100644 g2p_social_registry_dashboard/static/src/components/kpi/kpi.xml delete mode 100644 g2p_social_registry_dashboard/static/src/scss/dashboard.scss diff --git a/g2p_social_registry_dashboard/__init__.py b/g2p_social_registry_dashboard/__init__.py index 7f7bb21..572a7b5 100644 --- a/g2p_social_registry_dashboard/__init__.py +++ b/g2p_social_registry_dashboard/__init__.py @@ -1,122 +1,170 @@ -# Part of OpenG2P Registry. See LICENSE file for full copyright and licensing details. +# 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 the res_partner_dashboard_data materialized view. + Initializes or refreshes the materialized views for the res_partner_dashboard_data. """ - cr = env.cr - cr.execute( - """ - SELECT - matviewname - FROM - pg_matviews - WHERE - matviewname = 'res_partner_dashboard_data'; - """ - ) - check = cr.fetchone() - - if check: - return {} - - query = """ - CREATE MATERIALIZED VIEW res_partner_dashboard_data AS - SELECT - company_id, - jsonb_build_object( - 'individual_count', COUNT(id) - FILTER (WHERE is_registrant = True AND is_group = False), - 'group_count', COUNT(id) - FILTER (WHERE is_registrant = True AND is_group = True) - ) AS total_registrant, - jsonb_build_object( - 'male_count', COUNT(id) - FILTER ( - WHERE is_registrant = True - AND is_group = False - AND gender = 'Male' - ), - 'female_count', COUNT(id) - FILTER ( - WHERE is_registrant = True - AND is_group = False - AND gender = 'Female' - ) - ) AS gender_spec, - jsonb_build_object( - 'below_18', COUNT(id) - FILTER ( - WHERE is_registrant = True - AND is_group = False - AND EXTRACT(YEAR FROM AGE(birthdate)) < 18 - ), - '18_to_30', COUNT(id) - FILTER ( - WHERE is_registrant = True - AND is_group = False - AND EXTRACT(YEAR FROM AGE(birthdate)) BETWEEN 18 AND 30 - ), - '31_to_40', COUNT(id) - FILTER ( - WHERE is_registrant = True - AND is_group = False - AND EXTRACT(YEAR FROM AGE(birthdate)) BETWEEN 31 AND 40 - ), - '41_to_50', COUNT(id) - FILTER ( - WHERE is_registrant = True - AND is_group = False - AND EXTRACT(YEAR FROM AGE(birthdate)) BETWEEN 41 AND 50 - ), - 'above_50', COUNT(id) - FILTER ( - WHERE is_registrant = True - AND is_group = False - AND EXTRACT(YEAR FROM AGE(birthdate)) > 50 - ) - ) AS age_distribution - FROM - res_partner - GROUP BY - company_id; - """ + + matviews_to_check = [ + "g2p_gender_count_view", + "g2p_age_distribution_view", + "g2p_total_registrants_view", + "g2p_sr_dashboard_data", + ] try: - cr.execute(query) + 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 view 'res_partner_dashboard_data'.\n" - "Please create it manually by running the required SQL query with proper permissions." + "Failed to create the materialized views." + "Please check the logs for details or Manually create it." ) ) from exc def drop_materialized_view(env): """ - Drop the res_partner_dashboard_data materialized view. + Drop all the materialized views related to the dashboard. """ - cr = env.cr + + matviews_to_drop = [ + "g2p_gender_count_view", + "g2p_age_distribution_view", + "g2p_total_registrants_view", + "g2p_sr_dashboard_data", + ] + try: - cr.execute( - """ - DROP MATERIALIZED VIEW IF EXISTS - res_partner_dashboard_data; - """ - ) + for matview in matviews_to_drop: + cr.execute(f"DROP MATERIALIZED VIEW IF EXISTS {matview};") # pylint: disable=sql-injection + _logger.info("Dropped materialized view: %s", matview) except Exception as exc: - raise Exception( + _logger.error("Error while dropping materialized views: %s", str(exc)) + raise MissingError( _( - "Failed to drop the materialized view 'res_partner_dashboard_data'.\n" - "Please manually delete the view." + "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 index 6b33ec7..3c10c99 100644 --- a/g2p_social_registry_dashboard/__manifest__.py +++ b/g2p_social_registry_dashboard/__manifest__.py @@ -12,10 +12,10 @@ "data": ["data/cron_job.xml", "views/menu.xml"], "assets": { "web.assets_backend": [ - "https://cdn.jsdelivr.net/npm/chart.js", + "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", - "g2p_social_registry_dashboard/static/src/scss/dashboard.scss", ], }, "demo": [], diff --git a/g2p_social_registry_dashboard/data/cron_job.xml b/g2p_social_registry_dashboard/data/cron_job.xml index 17f3444..0ab7fad 100644 --- a/g2p_social_registry_dashboard/data/cron_job.xml +++ b/g2p_social_registry_dashboard/data/cron_job.xml @@ -9,7 +9,7 @@ Part of OpenG2P. See LICENSE file for full copyright and licensing details. code model._refresh_dashboard_materialized_view() 10 - minutes + hours -1 diff --git a/g2p_social_registry_dashboard/models/__init__.py b/g2p_social_registry_dashboard/models/__init__.py index ab88a8c..84d1d85 100644 --- a/g2p_social_registry_dashboard/models/__init__.py +++ b/g2p_social_registry_dashboard/models/__init__.py @@ -1,2 +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 index 63cfe23..7c1445a 100644 --- a/g2p_social_registry_dashboard/models/cron.py +++ b/g2p_social_registry_dashboard/models/cron.py @@ -1,8 +1,47 @@ -from odoo import models +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): - self.env.cr.execute("REFRESH MATERIALIZED VIEW res_partner_dashboard_data") + """ + 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 index 224cc3e..0a4b607 100644 --- a/g2p_social_registry_dashboard/models/registrant.py +++ b/g2p_social_registry_dashboard/models/registrant.py @@ -1,3 +1,5 @@ +# Part of OpenG2P. See LICENSE file for full copyright and licensing details. + from odoo import api, models @@ -9,39 +11,20 @@ def get_dashboard_data(self): """Fetch data from materialized view and prepare it for charts.""" company_id = self.env.company.id - # Fetch data from the materialized view query = """ - SELECT total_registrant, gender_spec, age_distribution - FROM res_partner_dashboard_data + 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() - if not result: - return { - "total_individuals": 0, - "total_groups": 0, - "gender_distribution": {"male": 0, "female": 0}, - "age_distribution": { - "below_18": 0, - "18_to_30": 0, - "31_to_40": 0, - "41_to_50": 0, - "above_50": 0, - }, - } - - total_registrant, gender_spec, age_distribution = result + total_registrants, gender_spec, age_distribution = result - # Return formatted data return { - "total_individuals": total_registrant.get("individual_count", 0), - "total_groups": total_registrant.get("group_count", 0), - "gender_distribution": { - "male": gender_spec.get("male_count", 0), - "female": gender_spec.get("female_count", 0), - }, + "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), 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..201b9a4 --- /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}, + size: {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 index fc92e23..7d81161 100644 --- a/g2p_social_registry_dashboard/static/src/js/dashboard.js +++ b/g2p_social_registry_dashboard/static/src/js/dashboard.js @@ -1,100 +1,49 @@ /** @odoo-module **/ -import {Component} from "@odoo/owl"; +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"; -const actionRegistry = registry.category("actions"); - class SRDashboard extends Component { setup() { super.setup(); this.orm = useService("orm"); - this._fetch_data(); - } - - _fetch_data() { - // Add loading spinners to tiles - $("#my_individuals").html('
'); - $("#my_groups").html('
'); - $("#gender_pie_chart_container").html('
'); - $("#age_bar_chart_container").html('
'); + 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.orm.call("res.partner", "get_dashboard_data", [], {}).then((result) => { - // Update tiles with fetched data - $("#my_individuals").html("" + result.total_individuals + ""); - $("#my_groups").html("" + result.total_groups + ""); - $("#gender_pie_chart_container").html( - '' - ); - this.renderGenderPieChart(result.gender_distribution); + this.dataLoaded = useState({flag: false}); - // Update age distribution chart - $("#age_bar_chart_container").html( - '' - ); - this.renderAgeBarChart(result.age_distribution); - }); + this.fetchData(); } - renderGenderPieChart(data) { - const ctx = document.getElementById("gender_pie_chart").getContext("2d"); - new Chart(ctx, { - type: "pie", - data: { - labels: ["Male", "Female"], - datasets: [ - { - data: [data.male, data.female], - backgroundColor: ["#4e73df", "#1cc88a"], - }, - ], - }, - options: { - responsive: true, - plugins: { - legend: { - position: "top", - }, - }, - }, - }); - } + async fetchData() { + try { + const data = await this.orm.call("res.partner", "get_dashboard_data", []); - renderAgeBarChart(data) { - const ctx = document.getElementById("age_bar_chart").getContext("2d"); - const labels = Object.keys(data); - const values = Object.values(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); - new Chart(ctx, { - type: "bar", - data: { - labels: labels, - datasets: [ - { - label: "Age Distribution", - data: values, - backgroundColor: "#4e73df", - borderColor: "#4e73df", - borderWidth: 1, - }, - ], - }, - options: { - responsive: true, - scales: { - y: { - beginAtZero: true, - }, - }, - plugins: { - legend: { - position: "top", - }, - }, - }, - }); + 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"; -actionRegistry.add("sr_dashboard_tag", SRDashboard); +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/scss/dashboard.scss b/g2p_social_registry_dashboard/static/src/scss/dashboard.scss deleted file mode 100644 index 93d23dc..0000000 --- a/g2p_social_registry_dashboard/static/src/scss/dashboard.scss +++ /dev/null @@ -1,15 +0,0 @@ -.spinner-border { - width: 2rem; - height: 2rem; - vertical-align: middle; - color: $primary; // Use Odoo's primary theme color -} - -#my_individuals, -#my_groups, -#gender_pie_chart_container { - display: flex; - justify-content: center; - align-items: center; - min-height: 2rem; // Ensures consistent spacing even before data loads -} diff --git a/g2p_social_registry_dashboard/static/src/xml/dashboard.xml b/g2p_social_registry_dashboard/static/src/xml/dashboard.xml index 07ad90b..09f7851 100644 --- a/g2p_social_registry_dashboard/static/src/xml/dashboard.xml +++ b/g2p_social_registry_dashboard/static/src/xml/dashboard.xml @@ -1,69 +1,62 @@ + -
- -
-
-

SR Dashboard

-
+
+
+

- -
- - -
-
-
-
Groups
-
- -
-
-
-
- - - -
-
-
-
Individuals
-
- -
-
+ +
+ +
+ +
+ +
-
- -
-
-
-
Gender Distribution
-
- -
-
+ +
+ + +
- -
-
-
-
Age Distribution
-
- -
-
-
+ +
+ + +
-
diff --git a/g2p_social_registry_dashboard/views/menu.xml b/g2p_social_registry_dashboard/views/menu.xml index 71213b5..db56c79 100644 --- a/g2p_social_registry_dashboard/views/menu.xml +++ b/g2p_social_registry_dashboard/views/menu.xml @@ -6,7 +6,7 @@ Dashboard - sr_dashboard_tag + g2p_social_registry_dashboard.sr_dashboard_tag Date: Wed, 13 Nov 2024 17:00:43 +0530 Subject: [PATCH 3/4] Dashboard: Fixed materialized view drop issue --- g2p_social_registry_dashboard/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/g2p_social_registry_dashboard/__init__.py b/g2p_social_registry_dashboard/__init__.py index 572a7b5..6c4fc1f 100644 --- a/g2p_social_registry_dashboard/__init__.py +++ b/g2p_social_registry_dashboard/__init__.py @@ -149,15 +149,15 @@ def drop_materialized_view(env): cr = env.cr matviews_to_drop = [ + "g2p_sr_dashboard_data", "g2p_gender_count_view", "g2p_age_distribution_view", "g2p_total_registrants_view", - "g2p_sr_dashboard_data", ] try: for matview in matviews_to_drop: - cr.execute(f"DROP MATERIALIZED VIEW IF EXISTS {matview};") # pylint: disable=sql-injection + 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: From f71757db9bda18d223a3c9acca66ed6a67b21f22 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Wed, 4 Dec 2024 12:27:00 +0530 Subject: [PATCH 4/4] Dashboard: Added icon_class in KPI Component props Signed-off-by: Manoj Kumar --- g2p_social_registry_dashboard/static/src/components/kpi/kpi.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js index 201b9a4..bfe8281 100644 --- a/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js +++ b/g2p_social_registry_dashboard/static/src/components/kpi/kpi.js @@ -9,5 +9,5 @@ KpiComponent.template = "g2p_social_registry_dashboard.KpiTemplate"; KpiComponent.props = { title: {type: String, optional: true}, data: {type: [String, Number], optional: true}, - size: {type: String, optional: true}, + icon_class: {type: String, optional: true}, };