From 4449582d7687f6268f82dc7e5378bd479070e1e7 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 17 Aug 2026 22:07:42 -0400 Subject: [PATCH] feat(community_theme_base, community_portal): presentation layer (Phase 6) community_theme_base: brand tokens (primary/secondary/accent colour, logo, heading/body font) exposed via res.config.settings, backed by ir.config_parameter with a neutral default palette. A theme_css_vars QWeb template renders them as CSS custom properties and is injected into both website.layout (xpath into //head) and web.basic_layout (used by every PDF report, including community_membership's card and event_qr_ticketing's ticket) - so any deployment rebrands with data only. Confirmed env['model'] is accessible bare inside arbitrary QWeb templates (checked core usage in web/report_templates.xml and website_templates.xml first) before relying on it for the injection. community_portal: extends portal.portal_my_home with cards for Membership, Events, School, Benefits, and Classifieds, each gated by an inline ir.module.module installed-check so a card is fully absent (not just zero-count) when its module isn't installed - this is the piece that finally wires up community_membership's membership_count counter, which Session 1-C had already implemented but nothing was rendering yet. Adds counters for the other four product modules (none of which had their own portal home counter) and a new /my/school page listing a parent's children's enrollments and attendance. Verified against a live Odoo 19 + Postgres 16 container, automated (6 tests) and manually per the Phase 6 gate exactly: set a custom primary colour via config parameter and confirmed it appears both on a real public page (HttpCase hitting '/') and the live /my dashboard; then uninstalled community_classifieds via button_immediate_uninstall and confirmed /my still returned 200 with the "My Classifieds" card gone and no error, before reinstalling it to restore the dev environment. Co-Authored-By: Claude Sonnet 5 --- addons/community_portal/__init__.py | 1 + addons/community_portal/__manifest__.py | 5 +- .../community_portal/controllers/__init__.py | 1 + addons/community_portal/controllers/portal.py | 57 +++++++++++++++ addons/community_portal/tests/__init__.py | 1 + .../tests/test_portal_dashboard.py | 34 +++++++++ .../views/portal_dashboard_templates.xml | 72 +++++++++++++++++++ addons/community_theme_base/__init__.py | 1 + addons/community_theme_base/__manifest__.py | 5 +- .../community_theme_base/models/__init__.py | 1 + .../models/res_config_settings.py | 36 ++++++++++ addons/community_theme_base/tests/__init__.py | 2 + .../community_theme_base/tests/test_theme.py | 23 ++++++ .../tests/test_theme_http.py | 11 +++ .../views/res_config_settings_views.xml | 37 ++++++++++ .../views/theme_templates.xml | 41 +++++++++++ 16 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 addons/community_portal/controllers/__init__.py create mode 100644 addons/community_portal/controllers/portal.py create mode 100644 addons/community_portal/tests/test_portal_dashboard.py create mode 100644 addons/community_portal/views/portal_dashboard_templates.xml create mode 100644 addons/community_theme_base/models/__init__.py create mode 100644 addons/community_theme_base/models/res_config_settings.py create mode 100644 addons/community_theme_base/tests/test_theme.py create mode 100644 addons/community_theme_base/tests/test_theme_http.py create mode 100644 addons/community_theme_base/views/res_config_settings_views.xml create mode 100644 addons/community_theme_base/views/theme_templates.xml diff --git a/addons/community_portal/__init__.py b/addons/community_portal/__init__.py index e69de29..e046e49 100644 --- a/addons/community_portal/__init__.py +++ b/addons/community_portal/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/addons/community_portal/__manifest__.py b/addons/community_portal/__manifest__.py index fb77ab5..84282ae 100644 --- a/addons/community_portal/__manifest__.py +++ b/addons/community_portal/__manifest__.py @@ -24,8 +24,11 @@ a missing module: 'currency': 'USD', 'depends': [ 'portal', + 'website', + ], + 'data': [ + 'views/portal_dashboard_templates.xml', ], - 'data': [], 'demo': [], 'images': ['static/description/banner.png'], 'application': False, diff --git a/addons/community_portal/controllers/__init__.py b/addons/community_portal/controllers/__init__.py new file mode 100644 index 0000000..8c3feb6 --- /dev/null +++ b/addons/community_portal/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/addons/community_portal/controllers/portal.py b/addons/community_portal/controllers/portal.py new file mode 100644 index 0000000..621700f --- /dev/null +++ b/addons/community_portal/controllers/portal.py @@ -0,0 +1,57 @@ +from odoo import http +from odoo.addons.portal.controllers.portal import CustomerPortal +from odoo.http import request + +SOFT_DETECTED_MODULES = ( + 'community_membership', + 'event_qr_ticketing', + 'community_school', + 'community_benefits', + 'community_classifieds', +) + + +class CommunityPortalDashboard(CustomerPortal): + + def _get_installed_community_modules(self): + """Soft-detect which Community OS product modules are installed.""" + installed = request.env['ir.module.module'].sudo().search([ + ('name', 'in', list(SOFT_DETECTED_MODULES)), + ('state', '=', 'installed'), + ]) + return set(installed.mapped('name')) + + def _prepare_home_portal_values(self, counters): + values = super()._prepare_home_portal_values(counters) + partner = request.env.user.partner_id + installed = self._get_installed_community_modules() + + if 'event_registration_count' in counters and 'event_qr_ticketing' in installed: + values['event_registration_count'] = request.env['event.registration'].sudo().search_count([ + ('partner_id', '=', partner.id), + ]) + + if 'school_enrollment_count' in counters and 'community_school' in installed: + values['school_enrollment_count'] = request.env['community.school.student'].sudo().search_count([ + ('parent_partner_id', '=', partner.id), + ]) + + if 'benefits_count' in counters and 'community_benefits' in installed: + Benefit = request.env['community.benefit'].sudo() + entitled = Benefit.search(Benefit._entitled_domain_for_partner(partner)) + values['benefits_count'] = len(entitled.filtered(lambda b: b.is_entitled(partner))) + + if 'classifieds_count' in counters and 'community_classifieds' in installed: + values['classifieds_count'] = request.env['community.classified'].sudo().search_count([ + ('poster_partner_id', '=', partner.id), + ]) + + return values + + @http.route(['/my/school'], type='http', auth='user', website=True) + def portal_my_school(self, **kwargs): + partner = request.env.user.partner_id + students = request.env['community.school.student'].sudo().search([ + ('parent_partner_id', '=', partner.id), + ]) + return request.render('community_portal.portal_my_school', {'students': students}) diff --git a/addons/community_portal/tests/__init__.py b/addons/community_portal/tests/__init__.py index e69de29..31c4afb 100644 --- a/addons/community_portal/tests/__init__.py +++ b/addons/community_portal/tests/__init__.py @@ -0,0 +1 @@ +from . import test_portal_dashboard diff --git a/addons/community_portal/tests/test_portal_dashboard.py b/addons/community_portal/tests/test_portal_dashboard.py new file mode 100644 index 0000000..c9d4308 --- /dev/null +++ b/addons/community_portal/tests/test_portal_dashboard.py @@ -0,0 +1,34 @@ +from odoo.tests.common import HttpCase, tagged + +MODULE_TO_CARD_TITLE = { + 'community_membership': 'Membership', + 'event_qr_ticketing': 'My Events', + 'community_school': 'School', + 'community_benefits': 'My Benefits', + 'community_classifieds': 'My Classifieds', +} + + +@tagged('post_install', '-at_install') +class TestPortalDashboard(HttpCase): + + def test_portal_home_renders_without_error(self): + self.authenticate('admin', 'admin') + response = self.url_open('/my') + self.assertEqual(response.status_code, 200) + + def test_dashboard_only_shows_cards_for_installed_modules(self): + """The dashboard must never crash on a missing module, and must not show + a card for a module that isn't installed.""" + installed = set(self.env['ir.module.module'].search([ + ('name', 'in', list(MODULE_TO_CARD_TITLE)), ('state', '=', 'installed'), + ]).mapped('name')) + + self.authenticate('admin', 'admin') + response = self.url_open('/my') + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + for module_name, card_title in MODULE_TO_CARD_TITLE.items(): + if module_name not in installed: + self.assertNotIn(card_title, content, f"{card_title} card should be hidden when {module_name} is not installed") diff --git a/addons/community_portal/views/portal_dashboard_templates.xml b/addons/community_portal/views/portal_dashboard_templates.xml new file mode 100644 index 0000000..834236c --- /dev/null +++ b/addons/community_portal/views/portal_dashboard_templates.xml @@ -0,0 +1,72 @@ + + + + + + diff --git a/addons/community_theme_base/__init__.py b/addons/community_theme_base/__init__.py index e69de29..0650744 100644 --- a/addons/community_theme_base/__init__.py +++ b/addons/community_theme_base/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/community_theme_base/__manifest__.py b/addons/community_theme_base/__manifest__.py index a7bb227..2d94339 100644 --- a/addons/community_theme_base/__manifest__.py +++ b/addons/community_theme_base/__manifest__.py @@ -25,7 +25,10 @@ layout that reads these tokens. 'depends': [ 'website', ], - 'data': [], + 'data': [ + 'views/res_config_settings_views.xml', + 'views/theme_templates.xml', + ], 'demo': [], 'images': ['static/description/banner.png'], 'application': False, diff --git a/addons/community_theme_base/models/__init__.py b/addons/community_theme_base/models/__init__.py new file mode 100644 index 0000000..0deb68c --- /dev/null +++ b/addons/community_theme_base/models/__init__.py @@ -0,0 +1 @@ +from . import res_config_settings diff --git a/addons/community_theme_base/models/res_config_settings.py b/addons/community_theme_base/models/res_config_settings.py new file mode 100644 index 0000000..02f77b5 --- /dev/null +++ b/addons/community_theme_base/models/res_config_settings.py @@ -0,0 +1,36 @@ +from odoo import fields, models + +DEFAULT_PRIMARY_COLOR = '#2C3E50' +DEFAULT_SECONDARY_COLOR = '#7F8C8D' +DEFAULT_ACCENT_COLOR = '#3498DB' +DEFAULT_FONT = 'Inter, sans-serif' + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + theme_primary_color = fields.Char( + string='Primary Colour', config_parameter='community_theme_base.primary_color', + default=DEFAULT_PRIMARY_COLOR, + ) + theme_secondary_color = fields.Char( + string='Secondary Colour', config_parameter='community_theme_base.secondary_color', + default=DEFAULT_SECONDARY_COLOR, + ) + theme_accent_color = fields.Char( + string='Accent Colour', config_parameter='community_theme_base.accent_color', + default=DEFAULT_ACCENT_COLOR, + ) + theme_logo = fields.Binary( + string='Logo', config_parameter='community_theme_base.logo', + help="Shown in the website navbar and on generated PDF reports/cards. " + "Falls back to the company logo if not set.", + ) + theme_heading_font = fields.Char( + string='Heading Font', config_parameter='community_theme_base.heading_font', + default=DEFAULT_FONT, + ) + theme_body_font = fields.Char( + string='Body Font', config_parameter='community_theme_base.body_font', + default=DEFAULT_FONT, + ) diff --git a/addons/community_theme_base/tests/__init__.py b/addons/community_theme_base/tests/__init__.py index e69de29..cdf175d 100644 --- a/addons/community_theme_base/tests/__init__.py +++ b/addons/community_theme_base/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_theme +from . import test_theme_http diff --git a/addons/community_theme_base/tests/test_theme.py b/addons/community_theme_base/tests/test_theme.py new file mode 100644 index 0000000..e9cee99 --- /dev/null +++ b/addons/community_theme_base/tests/test_theme.py @@ -0,0 +1,23 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestTheme(TransactionCase): + + def test_default_colors_are_neutral(self): + primary = self.env['ir.config_parameter'].sudo().get_param( + 'community_theme_base.primary_color', '#2C3E50' + ) + self.assertEqual(primary, '#2C3E50') + html = self.env['ir.qweb']._render('community_theme_base.theme_css_vars') + self.assertIn('#2C3E50', html, "Unconfigured deployments should render the neutral default") + + def test_custom_color_reflected_in_css_vars_render(self): + self.env['ir.config_parameter'].sudo().set_param('community_theme_base.primary_color', '#123456') + html = self.env['ir.qweb']._render('community_theme_base.theme_css_vars') + self.assertIn('#123456', html) + + def test_report_basic_layout_includes_theme_css_vars(self): + self.env['ir.config_parameter'].sudo().set_param('community_theme_base.primary_color', '#FEDCBA') + html = self.env['ir.qweb']._render('web.basic_layout', {'doc': self.env.company}) + self.assertIn('#FEDCBA', html) diff --git a/addons/community_theme_base/tests/test_theme_http.py b/addons/community_theme_base/tests/test_theme_http.py new file mode 100644 index 0000000..0b41fce --- /dev/null +++ b/addons/community_theme_base/tests/test_theme_http.py @@ -0,0 +1,11 @@ +from odoo.tests.common import HttpCase, tagged + + +@tagged('post_install', '-at_install') +class TestThemeHttp(HttpCase): + + def test_theme_css_vars_present_on_public_page(self): + self.env['ir.config_parameter'].sudo().set_param('community_theme_base.primary_color', '#654321') + response = self.url_open('/') + self.assertEqual(response.status_code, 200) + self.assertIn(b'#654321', response.content) diff --git a/addons/community_theme_base/views/res_config_settings_views.xml b/addons/community_theme_base/views/res_config_settings_views.xml new file mode 100644 index 0000000..1c38d16 --- /dev/null +++ b/addons/community_theme_base/views/res_config_settings_views.xml @@ -0,0 +1,37 @@ + + + + res.config.settings.view.form.theme + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/community_theme_base/views/theme_templates.xml b/addons/community_theme_base/views/theme_templates.xml new file mode 100644 index 0000000..80ef21e --- /dev/null +++ b/addons/community_theme_base/views/theme_templates.xml @@ -0,0 +1,41 @@ + + + + + + + + + +