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 <noreply@anthropic.com>
This commit is contained in:
parent
7e860b65c5
commit
4449582d76
@ -0,0 +1 @@
|
|||||||
|
from . import controllers
|
||||||
@ -24,8 +24,11 @@ a missing module:
|
|||||||
'currency': 'USD',
|
'currency': 'USD',
|
||||||
'depends': [
|
'depends': [
|
||||||
'portal',
|
'portal',
|
||||||
|
'website',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'views/portal_dashboard_templates.xml',
|
||||||
],
|
],
|
||||||
'data': [],
|
|
||||||
'demo': [],
|
'demo': [],
|
||||||
'images': ['static/description/banner.png'],
|
'images': ['static/description/banner.png'],
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
1
addons/community_portal/controllers/__init__.py
Normal file
1
addons/community_portal/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import portal
|
||||||
57
addons/community_portal/controllers/portal.py
Normal file
57
addons/community_portal/controllers/portal.py
Normal file
@ -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})
|
||||||
@ -0,0 +1 @@
|
|||||||
|
from . import test_portal_dashboard
|
||||||
34
addons/community_portal/tests/test_portal_dashboard.py
Normal file
34
addons/community_portal/tests/test_portal_dashboard.py
Normal file
@ -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")
|
||||||
72
addons/community_portal/views/portal_dashboard_templates.xml
Normal file
72
addons/community_portal/views/portal_dashboard_templates.xml
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="portal_my_home_community" name="Portal my home: Community OS cards" inherit_id="portal.portal_my_home" customize_show="True" priority="10">
|
||||||
|
<xpath expr="//div[hasclass('o_portal_docs')]" position="before">
|
||||||
|
<t t-set="portal_client_category_enable" t-value="True"/>
|
||||||
|
</xpath>
|
||||||
|
<div id="portal_client_category" position="inside">
|
||||||
|
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'community_membership'), ('state', '=', 'installed')])"
|
||||||
|
t-call="portal.portal_docs_entry">
|
||||||
|
<t t-set="title">Membership</t>
|
||||||
|
<t t-set="url" t-value="'/my/membership'"/>
|
||||||
|
<t t-set="text">View your membership status, renew, or download your card</t>
|
||||||
|
<t t-set="placeholder_count" t-value="'membership_count'"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'event_qr_ticketing'), ('state', '=', 'installed')])"
|
||||||
|
t-call="portal.portal_docs_entry">
|
||||||
|
<t t-set="title">My Events</t>
|
||||||
|
<t t-set="url" t-value="'/event'"/>
|
||||||
|
<t t-set="text">See events you have registered for and download your tickets</t>
|
||||||
|
<t t-set="placeholder_count" t-value="'event_registration_count'"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'community_school'), ('state', '=', 'installed')])"
|
||||||
|
t-call="portal.portal_docs_entry">
|
||||||
|
<t t-set="title">School</t>
|
||||||
|
<t t-set="url" t-value="'/my/school'"/>
|
||||||
|
<t t-set="text">See your children's classes, schedule, and attendance</t>
|
||||||
|
<t t-set="placeholder_count" t-value="'school_enrollment_count'"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'community_benefits'), ('state', '=', 'installed')])"
|
||||||
|
t-call="portal.portal_docs_entry">
|
||||||
|
<t t-set="title">My Benefits</t>
|
||||||
|
<t t-set="url" t-value="'/my/benefits'"/>
|
||||||
|
<t t-set="text">Benefits your membership tier entitles you to</t>
|
||||||
|
<t t-set="placeholder_count" t-value="'benefits_count'"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'community_classifieds'), ('state', '=', 'installed')])"
|
||||||
|
t-call="portal.portal_docs_entry">
|
||||||
|
<t t-set="title">My Classifieds</t>
|
||||||
|
<t t-set="url" t-value="'/classifieds/my'"/>
|
||||||
|
<t t-set="text">Manage the listings you have posted</t>
|
||||||
|
<t t-set="placeholder_count" t-value="'classifieds_count'"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="portal_my_school" name="My Children's Classes">
|
||||||
|
<t t-call="portal.portal_layout">
|
||||||
|
<div class="o_portal_my_doc_table">
|
||||||
|
<h3>My Children</h3>
|
||||||
|
<t t-if="not students">
|
||||||
|
<p class="alert alert-info">No children are registered under your account.</p>
|
||||||
|
</t>
|
||||||
|
<t t-foreach="students" t-as="student">
|
||||||
|
<h5 t-out="student.partner_id.name"/>
|
||||||
|
<table class="table mb-4">
|
||||||
|
<thead><tr><th>Class</th><th>Term</th><th>Status</th><th>Attendance</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="student.enrollment_ids" t-as="enrollment">
|
||||||
|
<tr>
|
||||||
|
<td t-out="enrollment.class_id.name"/>
|
||||||
|
<td t-out="enrollment.term_id.name"/>
|
||||||
|
<td t-out="dict(enrollment._fields['state'].selection).get(enrollment.state)"/>
|
||||||
|
<td><t t-out="round(enrollment.attendance_rate, 1)"/>%</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
@ -25,7 +25,10 @@ layout that reads these tokens.
|
|||||||
'depends': [
|
'depends': [
|
||||||
'website',
|
'website',
|
||||||
],
|
],
|
||||||
'data': [],
|
'data': [
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
'views/theme_templates.xml',
|
||||||
|
],
|
||||||
'demo': [],
|
'demo': [],
|
||||||
'images': ['static/description/banner.png'],
|
'images': ['static/description/banner.png'],
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
1
addons/community_theme_base/models/__init__.py
Normal file
1
addons/community_theme_base/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import res_config_settings
|
||||||
36
addons/community_theme_base/models/res_config_settings.py
Normal file
36
addons/community_theme_base/models/res_config_settings.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
from . import test_theme
|
||||||
|
from . import test_theme_http
|
||||||
23
addons/community_theme_base/tests/test_theme.py
Normal file
23
addons/community_theme_base/tests/test_theme.py
Normal file
@ -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)
|
||||||
11
addons/community_theme_base/tests/test_theme_http.py
Normal file
11
addons/community_theme_base/tests/test_theme_http.py
Normal file
@ -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)
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="res_config_settings_view_form_theme" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.theme</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="Theme" string="Theme" name="community_theme_base"
|
||||||
|
groups="base.group_system">
|
||||||
|
<block title="Brand Colours" id="theme_colors_settings">
|
||||||
|
<setting id="theme_primary_color_setting" string="Primary Colour">
|
||||||
|
<field name="theme_primary_color"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="theme_secondary_color_setting" string="Secondary Colour">
|
||||||
|
<field name="theme_secondary_color"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="theme_accent_color_setting" string="Accent Colour">
|
||||||
|
<field name="theme_accent_color"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
<block title="Brand Assets" id="theme_assets_settings">
|
||||||
|
<setting id="theme_logo_setting" string="Logo">
|
||||||
|
<field name="theme_logo" widget="image"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="theme_heading_font_setting" string="Heading Font">
|
||||||
|
<field name="theme_heading_font"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="theme_body_font_setting" string="Body Font">
|
||||||
|
<field name="theme_body_font"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
41
addons/community_theme_base/views/theme_templates.xml
Normal file
41
addons/community_theme_base/views/theme_templates.xml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="theme_css_vars" name="Community OS Theme CSS Variables">
|
||||||
|
<t t-set="theme_primary" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.primary_color', '#2C3E50')"/>
|
||||||
|
<t t-set="theme_secondary" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.secondary_color', '#7F8C8D')"/>
|
||||||
|
<t t-set="theme_accent" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.accent_color', '#3498DB')"/>
|
||||||
|
<t t-set="theme_heading_font" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.heading_font', 'Inter, sans-serif')"/>
|
||||||
|
<t t-set="theme_body_font" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.body_font', 'Inter, sans-serif')"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--community-primary: <t t-out="theme_primary"/>;
|
||||||
|
--community-secondary: <t t-out="theme_secondary"/>;
|
||||||
|
--community-accent: <t t-out="theme_accent"/>;
|
||||||
|
--community-heading-font: <t t-out="theme_heading_font"/>;
|
||||||
|
--community-body-font: <t t-out="theme_body_font"/>;
|
||||||
|
}
|
||||||
|
body { font-family: var(--community-body-font); }
|
||||||
|
h1, h2, h3, h4, h5, h6 { font-family: var(--community-heading-font); }
|
||||||
|
.btn-primary, .bg-primary { background-color: var(--community-primary) !important; border-color: var(--community-primary) !important; }
|
||||||
|
a { color: var(--community-primary); }
|
||||||
|
</style>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="website_layout_theme_vars" inherit_id="website.layout">
|
||||||
|
<xpath expr="//head" position="inside">
|
||||||
|
<t t-call="community_theme_base.theme_css_vars"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="report_basic_layout_theme_vars" inherit_id="web.basic_layout">
|
||||||
|
<xpath expr="//t[@t-out='0']" position="before">
|
||||||
|
<t t-call="community_theme_base.theme_css_vars"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="theme_logo_snippet" name="Community OS Theme Logo">
|
||||||
|
<t t-set="theme_logo" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.logo')"/>
|
||||||
|
<img t-if="theme_logo" t-att-src="image_data_uri(theme_logo)" alt="Logo"/>
|
||||||
|
<img t-elif="env.company.logo" t-att-src="image_data_uri(env.company.logo)" alt="Logo"/>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
Loading…
x
Reference in New Issue
Block a user