add TNCSC deployment module, community add-ons, role guides, and website navigation overrides
Some checks failed
CI / Brand-leak check (push) Has been cancelled
CI / flake8 / pylint-odoo / manifest completeness (push) Has been cancelled
CI / Install all modules with --test-enable (push) Has been cancelled

This commit is contained in:
Alaguraj0361 2026-09-28 19:43:48 +05:30
parent 522e2cfba9
commit 0f462ad21a
43 changed files with 2405 additions and 566 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -65,5 +65,79 @@
</field> </field>
</record> </record>
<record id="mail_template_membership_application_received" model="mail.template">
<field name="name">Membership: Application Received</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="subject">Your {{ object.membership_org_name or 'organization' }} membership application has been received</field>
<field name="partner_to">{{ object.id }}</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p>Dear <t t-out="object.name or ''">Applicant</t>,</p>
<p>
Thank you for applying for membership with <t t-out="object.membership_org_name or 'our organization'"/>!
</p>
<p>
Selected Plan: <strong><t t-out="object.membership_tier_id.name or 'Membership'"/></strong>
</p>
<p>
Your application is currently under review by the Board of Directors. You will be notified as soon as a decision is made.
</p>
<p>Thank you for your interest and support!</p>
</div>
</field>
</record>
<record id="mail_template_membership_approved" model="mail.template">
<field name="name">Membership: Application Approved</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="subject">Welcome! Your {{ object.membership_org_name or 'organization' }} membership has been approved</field>
<field name="partner_to">{{ object.id }}</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p>Dear <t t-out="object.name or ''">Member</t>,</p>
<p>
We are pleased to inform you that your membership application for
<strong><t t-out="object.membership_tier_id.name or 'Membership'"/></strong>
with <t t-out="object.membership_org_name or 'our organization'"/> has been approved by the Board of Directors!
</p>
<t t-if="object.membership_member_id">
<p>Your Member ID: <strong><t t-out="object.membership_member_id"/></strong></p>
</t>
<p>You can access your member portal and download your digital membership card by logging in at our website.</p>
<p>Welcome to our community!</p>
</div>
</field>
</record>
<record id="mail_template_membership_rejected" model="mail.template">
<field name="name">Membership: Application Rejected</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="subject">Update on your {{ object.membership_org_name or 'organization' }} membership application</field>
<field name="partner_to">{{ object.id }}</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p>Dear <t t-out="object.name or ''">Applicant</t>,</p>
<p>
Thank you for your interest in joining <t t-out="object.membership_org_name or 'our organization'"/>.
</p>
<p>
After careful review, the Board of Directors was unable to approve your application at this time.
</p>
<p>
If any fees were paid during application submission, a full refund will be processed promptly.
</p>
<p>
If you have questions or believe this was in error, please contact our support team.
</p>
</div>
</field>
</record>
</data> </data>
</odoo> </odoo>

View File

@ -88,14 +88,16 @@ class CommunityMembershipTier(models.Model):
def _membership_product_values(self): def _membership_product_values(self):
self.ensure_one() self.ensure_one()
return { vals = {
'name': self.name, 'name': self.name,
'list_price': self.price, 'list_price': self.price,
'type': 'service', 'type': 'service',
'sale_ok': True, 'sale_ok': True,
'purchase_ok': False, 'purchase_ok': False,
'invoice_policy': 'order',
} }
if 'invoice_policy' in self.env['product.product']._fields:
vals['invoice_policy'] = 'order'
return vals
def _create_membership_product(self): def _create_membership_product(self):
self.ensure_one() self.ensure_one()

View File

@ -128,10 +128,31 @@ class ResPartner(models.Model):
def action_approve_membership(self): def action_approve_membership(self):
self.write({'membership_approval_state': 'approved'}) self.write({'membership_approval_state': 'approved'})
template = self.env.ref('community_membership.mail_template_membership_approved', raise_if_not_found=False)
for partner in self:
if partner.membership_state in ('none', 'invoiced') and partner.membership_tier_id:
partner.action_activate_membership()
if template and partner.email:
template.send_mail(partner.id, force_send=True)
if hasattr(partner, 'message_post'):
partner.message_post(body=_("Membership application approved by the Board of Directors."))
return True return True
def action_reject_membership(self): def action_reject_membership(self):
self.write({'membership_approval_state': 'rejected'}) self.write({'membership_approval_state': 'rejected'})
template = self.env.ref('community_membership.mail_template_membership_rejected', raise_if_not_found=False)
for partner in self:
draft_invoices = self.env['account.move'].sudo().search([
('partner_id', '=', partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'draft'),
])
if draft_invoices:
draft_invoices.button_cancel()
if template and partner.email:
template.send_mail(partner.id, force_send=True)
if hasattr(partner, 'message_post'):
partner.message_post(body=_("Membership application rejected by the Board of Directors."))
return True return True
def _compute_membership_org_name(self): def _compute_membership_org_name(self):

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -14,7 +14,7 @@ class TestPortalDashboard(HttpCase):
def test_portal_home_renders_without_error(self): def test_portal_home_renders_without_error(self):
self.authenticate('admin', 'admin') self.authenticate('admin', 'admin')
response = self.url_open('/my') response = self.url_open('/my', timeout=30)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_dashboard_only_shows_cards_for_installed_modules(self): def test_dashboard_only_shows_cards_for_installed_modules(self):
@ -25,7 +25,7 @@ class TestPortalDashboard(HttpCase):
]).mapped('name')) ]).mapped('name'))
self.authenticate('admin', 'admin') self.authenticate('admin', 'admin')
response = self.url_open('/my') response = self.url_open('/my', timeout=30)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
content = response.content.decode() content = response.content.decode()

View File

@ -64,14 +64,16 @@ class CommunitySchoolClass(models.Model):
self.ensure_one() self.ensure_one()
if self.fee_product_id: if self.fee_product_id:
return self.fee_product_id return self.fee_product_id
product = self.env['product.product'].create({ vals = {
'name': f"School Fee - {self.name}", 'name': f"School Fee - {self.name}",
'list_price': self.fee, 'list_price': self.fee,
'type': 'service', 'type': 'service',
'sale_ok': True, 'sale_ok': True,
'purchase_ok': False, 'purchase_ok': False,
'invoice_policy': 'order', }
}) if 'invoice_policy' in self.env['product.product']._fields:
vals['invoice_policy'] = 'order'
product = self.env['product.product'].create(vals)
self.fee_product_id = product.id self.fee_product_id = product.id
return product return product

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,100 +1,539 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<!-- Shared Stepper Template -->
<template id="school_registration_stepper" name="School Registration Stepper">
<div class="school-stepper mb-4">
<div t-attf-class="step-item #{'active' if step == 1 else ('completed' if step &gt; 1 else '')}">
<div class="step-circle">
<t t-if="step &gt; 1"><i class="fa fa-check"></i></t>
<t t-else="">1</t>
</div>
<div class="step-label">Parent / Guardian</div>
</div>
<div t-attf-class="step-item #{'active' if step == 2 else ('completed' if step &gt; 2 else '')}">
<div class="step-circle">
<t t-if="step &gt; 2"><i class="fa fa-check"></i></t>
<t t-else="">2</t>
</div>
<div class="step-label">Student Details</div>
</div>
<div t-attf-class="step-item #{'active' if step == 3 else ('completed' if step &gt; 3 else '')}">
<div class="step-circle">
<t t-if="step &gt; 3"><i class="fa fa-check"></i></t>
<t t-else="">3</t>
</div>
<div class="step-label">Class Selection</div>
</div>
</div>
</template>
<!-- Shared Styles -->
<template id="school_registration_styles" name="School Registration Styles">
<style>
.school-reg-wrap {
background-color: #f7f8fc;
min-height: 85vh;
padding-bottom: 80px;
font-family: var(--community-font, 'Outfit', 'Inter', sans-serif);
}
.school-reg-hero {
background: linear-gradient(135deg, #120735 0%, #1e1048 50%, #2a165f 100%);
color: #ffffff;
padding: 50px 20px 65px 20px;
text-align: center;
position: relative;
}
.school-reg-hero::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #e5a93c, #f3c669, #e5a93c);
}
.school-reg-card {
background: #ffffff;
border-radius: 18px;
border: 1px solid #e2e8f0;
box-shadow: 0 15px 40px rgba(18, 7, 53, 0.08);
padding: 42px 48px 48px 48px;
margin-top: -35px;
margin-bottom: 60px;
position: relative;
z-index: 10;
max-width: 640px;
margin-left: auto;
margin-right: auto;
}
@media (max-width: 576px) {
.school-reg-card {
padding: 28px 20px 32px 20px;
margin-top: -25px;
}
.btn-school-next, .btn-school-back {
width: 100%;
justify-content: center;
}
}
/* Stepper */
.school-stepper {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
margin-bottom: 32px;
}
.school-stepper::before {
content: "";
position: absolute;
top: 21px;
left: 40px;
right: 40px;
height: 3px;
background: #e2e8f0;
z-index: 1;
}
.step-item {
position: relative;
z-index: 2;
text-align: center;
flex: 1;
}
.step-circle {
width: 42px;
height: 42px;
border-radius: 50%;
background: #ffffff;
border: 3px solid #cbd5e1;
color: #64748b;
font-weight: 700;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 8px auto;
transition: all 0.3s ease;
}
.step-item.active .step-circle {
border-color: #e5a93c;
background: #120735;
color: #e5a93c;
box-shadow: 0 0 0 4px rgba(229, 169, 60, 0.2);
}
.step-item.completed .step-circle {
border-color: #10b981;
background: #10b981;
color: #ffffff;
}
.step-label {
font-size: 0.8rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.step-item.active .step-label {
color: #120735;
font-weight: 700;
}
.school-section-title {
font-size: 1.25rem;
font-weight: 700;
color: #120735;
border-bottom: 2px solid #edf2f7;
padding-bottom: 12px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.form-label {
font-weight: 600;
color: #1e293b;
font-size: 0.95rem;
margin-bottom: 6px;
}
.input-group-text {
background-color: #f8fafc;
border-color: #cbd5e1;
color: #64748b;
padding-left: 14px;
padding-right: 14px;
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.form-control, .form-select {
border-color: #cbd5e1;
padding: 11px 14px;
border-radius: 8px;
font-size: 0.95rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
border-color: #e5a93c;
box-shadow: 0 0 0 3px rgba(229, 169, 60, 0.2);
}
.btn-school-next {
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e5a93c 0%, #d19b33 100%);
color: #120735;
font-weight: 700;
font-size: 1.05rem;
padding: 13px 38px;
border-radius: 30px;
border: none;
box-shadow: 0 6px 18px rgba(229, 169, 60, 0.35);
transition: all 0.25s ease;
cursor: pointer;
}
.btn-school-next:hover {
background: linear-gradient(135deg, #f3c669 0%, #e5a93c 100%);
transform: translateY(-2px);
box-shadow: 0 10px 22px rgba(229, 169, 60, 0.45);
color: #120735;
}
.btn-school-back {
display: inline-flex;
align-items: center;
border-radius: 30px;
padding: 12px 26px;
font-weight: 600;
color: #475569;
border: 1px solid #cbd5e1;
background: #ffffff;
transition: all 0.2s ease;
text-decoration: none;
}
.btn-school-back:hover {
background: #f1f5f9;
color: #0f172a;
}
.class-card {
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 14px;
cursor: pointer;
transition: all 0.2s ease;
background: #ffffff;
}
.class-card:hover {
border-color: #e5a93c;
background-color: #fefcf7;
}
</style>
</template>
<!-- STEP 1: PARENT / GUARDIAN -->
<template id="registration_step_parent" name="School Registration: Parent"> <template id="registration_step_parent" name="School Registration: Parent">
<t t-call="website.layout"> <t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;"> <t t-call="community_school.school_registration_styles"/>
<h2>Register - Step 1 of 3: Parent/Guardian</h2> <div id="wrap" class="school-reg-wrap">
<form method="POST" t-attf-action="/school/register"> <!-- Hero Header -->
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <div class="school-reg-hero">
<div class="mb-3"> <div class="container">
<label class="form-label">Full Name</label> <span class="badge px-3 py-2 rounded-pill fw-bold mb-3" style="background-color: #e5a93c; color: #120735;">
<input type="text" class="form-control" name="name" required="required"/> LANGUAGE SCHOOL ADMISSIONS
</span>
<h1 class="display-5 fw-bold text-white mb-2">School Student Registration</h1>
<p class="fs-5 mb-0" style="color: #c9c3ec;">Step 1 of 3: Parent or Guardian Information</p>
</div> </div>
<div class="mb-3"> </div>
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email" required="required"/> <!-- Registration Card -->
<div class="container">
<div class="school-reg-card">
<t t-call="community_school.school_registration_stepper">
<t t-set="step" t-value="1"/>
</t>
<div class="school-section-title">
<i class="fa fa-user-circle text-warning"></i>
<span>Parent / Guardian Information</span>
</div>
<form method="POST" t-attf-action="/school/register">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label">Full Name <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-user"></i></span>
<input type="text" class="form-control" name="name" required="required" placeholder="e.g. Anand Natarajan"/>
</div>
</div>
<div class="mb-3">
<label class="form-label">Email Address <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-envelope"></i></span>
<input type="email" class="form-control" name="email" required="required" placeholder="e.g. anand@example.com"/>
</div>
<small class="text-muted">Enrollment notices, invoices, and class reminders will be sent to this email.</small>
</div>
<div class="mb-4">
<label class="form-label">Phone Number</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-phone"></i></span>
<input type="tel" class="form-control" name="phone" placeholder="e.g. +1 416-555-0199"/>
</div>
</div>
<div class="d-flex justify-content-end align-items-center pt-3 pb-2">
<button type="submit" class="btn btn-school-next">
Continue to Student Info <i class="fa fa-arrow-right ms-2"></i>
</button>
</div>
</form>
</div> </div>
<div class="mb-3"> </div>
<label class="form-label">Phone</label>
<input type="text" class="form-control" name="phone"/>
</div>
<button type="submit" class="btn btn-primary">Continue</button>
</form>
</div> </div>
</t> </t>
</template> </template>
<!-- STEP 2: STUDENT -->
<template id="registration_step_student" name="School Registration: Student"> <template id="registration_step_student" name="School Registration: Student">
<t t-call="website.layout"> <t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;"> <t t-call="community_school.school_registration_styles"/>
<h2>Register - Step 2 of 3: Student</h2> <div id="wrap" class="school-reg-wrap">
<form method="POST" t-attf-action="/school/register/student"> <!-- Hero Header -->
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <div class="school-reg-hero">
<div class="mb-3"> <div class="container">
<label class="form-label">Student Full Name</label> <span class="badge px-3 py-2 rounded-pill fw-bold mb-3" style="background-color: #e5a93c; color: #120735;">
<input type="text" class="form-control" name="name" required="required"/> LANGUAGE SCHOOL ADMISSIONS
</span>
<h1 class="display-5 fw-bold text-white mb-2">School Student Registration</h1>
<p class="fs-5 mb-0" style="color: #c9c3ec;">Step 2 of 3: Student Details &amp; Emergency Contact</p>
</div> </div>
<div class="mb-3"> </div>
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control" name="date_of_birth"/> <!-- Registration Card -->
<div class="container">
<div class="school-reg-card">
<t t-call="community_school.school_registration_stepper">
<t t-set="step" t-value="2"/>
</t>
<div class="school-section-title">
<i class="fa fa-graduation-cap text-warning"></i>
<span>Student Information</span>
</div>
<form method="POST" t-attf-action="/school/register/student">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label">Student Full Name <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-id-card"></i></span>
<input type="text" class="form-control" name="name" required="required" placeholder="e.g. Kavin Anand"/>
</div>
</div>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-calendar"></i></span>
<input type="date" class="form-control" name="date_of_birth"/>
</div>
<small class="text-muted">Used to match the student to their appropriate age or grade level.</small>
</div>
<div class="mb-3">
<label class="form-label">Health &amp; Medical Notes</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-medkit"></i></span>
<textarea class="form-control" name="health_notes" rows="2" placeholder="Allergies, medical conditions, or special requirements (optional)"/>
</div>
</div>
<div class="school-section-title mt-4">
<i class="fa fa-shield text-warning"></i>
<span>Emergency Contact</span>
</div>
<div class="mb-3">
<label class="form-label">Emergency Contact Name</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-user-plus"></i></span>
<input type="text" class="form-control" name="emergency_contact_name" placeholder="Contact person if parent is unreachable"/>
</div>
</div>
<div class="mb-4">
<label class="form-label">Emergency Contact Phone</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-phone-square"></i></span>
<input type="tel" class="form-control" name="emergency_contact_phone" placeholder="e.g. +1 416-555-0188"/>
</div>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 pt-3 pb-1">
<a href="/school/register" class="btn btn-school-back">
<i class="fa fa-arrow-left me-1"></i> Back
</a>
<button type="submit" class="btn btn-school-next">
Continue to Class Selection <i class="fa fa-arrow-right ms-2"></i>
</button>
</div>
</form>
</div> </div>
<div class="mb-3"> </div>
<label class="form-label">Health Notes</label>
<textarea class="form-control" name="health_notes"/>
</div>
<div class="mb-3">
<label class="form-label">Emergency Contact Name</label>
<input type="text" class="form-control" name="emergency_contact_name"/>
</div>
<div class="mb-3">
<label class="form-label">Emergency Contact Phone</label>
<input type="text" class="form-control" name="emergency_contact_phone"/>
</div>
<button type="submit" class="btn btn-primary">Continue</button>
</form>
</div> </div>
</t> </t>
</template> </template>
<!-- STEP 3: CLASS SELECTION -->
<template id="registration_step_class" name="School Registration: Class"> <template id="registration_step_class" name="School Registration: Class">
<t t-call="website.layout"> <t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;"> <t t-call="community_school.school_registration_styles"/>
<h2>Register - Step 3 of 3: Class</h2> <div id="wrap" class="school-reg-wrap">
<form method="POST" t-attf-action="/school/register/class"> <!-- Hero Header -->
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <div class="school-reg-hero">
<div class="mb-3"> <div class="container">
<label class="form-label">Class</label> <span class="badge px-3 py-2 rounded-pill fw-bold mb-3" style="background-color: #e5a93c; color: #120735;">
<select class="form-select" name="class_id" required="required"> LANGUAGE SCHOOL ADMISSIONS
<t t-foreach="classes" t-as="klass"> </span>
<option t-att-value="klass.id"> <h1 class="display-5 fw-bold text-white mb-2">School Student Registration</h1>
<t t-out="klass.name"/> (<t t-out="klass.enrolled_count"/>/<t t-out="klass.max_students"/> seats) <p class="fs-5 mb-0" style="color: #c9c3ec;">Step 3 of 3: Select Class &amp; Complete Registration</p>
<t t-if="klass.fee"> - <t t-out="klass.fee"/></t>
</option>
</t>
</select>
</div> </div>
<button type="submit" class="btn btn-primary">Complete Registration</button> </div>
</form>
<!-- Registration Card -->
<div class="container">
<div class="school-reg-card">
<t t-call="community_school.school_registration_stepper">
<t t-set="step" t-value="3"/>
</t>
<div class="school-section-title">
<i class="fa fa-book text-warning"></i>
<span>Select Class</span>
</div>
<form method="POST" t-attf-action="/school/register/class">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-4">
<label class="form-label d-block mb-3">Available Classes <span class="text-danger">*</span></label>
<t t-foreach="classes" t-as="klass">
<div class="class-card">
<div class="form-check d-flex justify-content-between align-items-center">
<div>
<input class="form-check-input me-2" type="radio" name="class_id"
t-att-value="klass.id" t-att-id="'class_' + str(klass.id)" required="required"/>
<label class="form-check-label fw-bold text-dark" t-att-for="'class_' + str(klass.id)">
<t t-out="klass.name"/>
</label>
<div class="small text-muted ps-4">
Seats: <t t-out="klass.enrolled_count"/> / <t t-out="klass.max_students"/>
<t t-if="klass.enrolled_count &gt;= klass.max_students">
<span class="badge bg-warning text-dark ms-2">Waitlist</span>
</t>
<t t-else="">
<span class="badge bg-success ms-2">Available</span>
</t>
</div>
</div>
<div class="text-end">
<t t-if="klass.fee">
<span class="fw-bold text-primary fs-5">$<t t-out="'%g' % klass.fee"/></span>
</t>
<t t-else="">
<span class="badge bg-light text-secondary border">Free</span>
</t>
</div>
</div>
</div>
</t>
</div>
<div class="p-3 bg-light rounded-3 border mb-4">
<small class="text-muted d-block">
<i class="fa fa-info-circle text-primary me-1"></i>
Upon submission, an enrollment record will be created and an invoice/receipt will be generated. You will be able to view classroom details in your Member Portal.
</small>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 pt-3 pb-1">
<a href="/school/register/student" class="btn btn-school-back">
<i class="fa fa-arrow-left me-1"></i> Back
</a>
<button type="submit" class="btn btn-school-next">
Complete Registration <i class="fa fa-check ms-2"></i>
</button>
</div>
</form>
</div>
</div>
</div> </div>
</t> </t>
</template> </template>
<!-- STEP 4: DONE / CONFIRMATION -->
<template id="registration_done" name="School Registration: Done"> <template id="registration_done" name="School Registration: Done">
<t t-call="website.layout"> <t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 60px; margin-bottom: 60px;"> <t t-call="community_school.school_registration_styles"/>
<t t-if="waitlisted"> <div id="wrap" class="school-reg-wrap py-5">
<div class="alert alert-warning"> <div class="container py-4">
<h2>You're on the Waitlist</h2> <div class="row justify-content-center">
<p>The class is currently full. We'll notify you as soon as a seat opens up.</p> <div class="col-md-8 col-lg-7">
<div class="card border-0 shadow-sm rounded-4 p-4 p-md-5 text-center bg-white">
<t t-if="waitlisted">
<div class="mb-4">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle"
style="width: 80px; height: 80px; background-color: #fff8e1; color: #f59e0b; font-size: 2.5rem;">
<i class="fa fa-clock-o"></i>
</span>
</div>
<h2 class="fw-bold mb-2" style="color: #120735;">Placed on Waitlist</h2>
<p class="text-secondary mb-4">
The selected class is currently full. We have placed
<b class="text-dark" t-out="enrollment.student_id.partner_id.name"/> on the waitlist for
<b class="text-primary" t-out="enrollment.class_id.name"/>.
You will be notified immediately when a seat opens up.
</p>
</t>
<t t-else="">
<div class="mb-4">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle"
style="width: 80px; height: 80px; background-color: #e8f5e9; color: #2e7d32; font-size: 2.5rem;">
<i class="fa fa-check"></i>
</span>
</div>
<h2 class="fw-bold mb-2" style="color: #120735;">Registration Complete!</h2>
<p class="text-secondary mb-4">
Congratulations! <b class="text-dark" t-out="enrollment.student_id.partner_id.name"/> is successfully enrolled in
<b class="text-primary" t-out="enrollment.class_id.name"/>.
</p>
</t>
<div class="p-3 bg-light rounded-3 border text-start mb-4">
<h6 class="fw-bold mb-2 text-dark"><i class="fa fa-info-circle text-primary me-2"></i>What's Next:</h6>
<ul class="text-muted small mb-0 ps-3">
<li class="mb-1"><b>Confirmation Receipt:</b> An enrollment confirmation has been sent to your email.</li>
<li class="mb-1"><b>Classroom Access:</b> Log in to your portal anytime to view class schedule, instructors, and student attendance.</li>
<li><b>School Portal:</b> Access your parent dashboard at <a href="/my/school">/my/school</a>.</li>
</ul>
</div>
<div class="d-flex flex-wrap gap-3 justify-content-center">
<a href="/" class="btn btn-outline-secondary px-4 py-2 rounded-pill">
<i class="fa fa-home me-2"></i>Return to Home
</a>
<a href="/my/school" class="btn px-4 py-2 rounded-pill fw-bold text-white" style="background-color: #120735;">
<i class="fa fa-graduation-cap me-2"></i>View Student Portal
</a>
</div>
</div>
</div>
</div> </div>
</t> </div>
<t t-else="">
<div class="alert alert-success">
<h2>Registration Complete</h2>
<p t-out="enrollment.student_id.partner_id.name"/> is enrolled in
<span t-out="enrollment.class_id.name"/>.
</div>
</t>
</div> </div>
</t> </t>
</template> </template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -12,6 +12,8 @@ def post_init_hook(env):
env.company._tncsc_setup_accounting() env.company._tncsc_setup_accounting()
_remove_stock_homepage_page(env) _remove_stock_homepage_page(env)
_fix_website_menus(env) _fix_website_menus(env)
env['ir.config_parameter'].sudo().set_param('web.base.url', 'https://tncsc.odoo.thedomainnest.com')
env['ir.config_parameter'].sudo().set_param('web.base.url.freeze', 'True')
def _remove_stock_homepage_page(env): def _remove_stock_homepage_page(env):

View File

@ -1,6 +1,6 @@
{ {
'name': 'TNCSC Deployment', 'name': 'TNCSC Deployment',
'version': '19.0.1.2.0', 'version': '19.0.1.3.0',
'category': 'Hidden', 'category': 'Hidden',
'summary': 'Tamil Nadu Cultural Society of Canada - client configuration for the Community OS suite', 'summary': 'Tamil Nadu Cultural Society of Canada - client configuration for the Community OS suite',
'description': """ 'description': """

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import main from . import main
from . import dashboard from . import dashboard
from . import membership

View File

@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
import base64
import json
import logging
from datetime import date
from odoo import fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
ELIGIBILITY_KEYS = (
'tncsc_born_tamil_nadu',
'tncsc_pio_oci',
'tncsc_tamil_speaker_resident',
'tncsc_family_tamil_indian',
'tncsc_other_citizenship',
)
MONTH_OPTIONS = [
('1', 'January'), ('2', 'February'), ('3', 'March'), ('4', 'April'),
('5', 'May'), ('6', 'June'), ('7', 'July'), ('8', 'August'),
('9', 'September'), ('10', 'October'), ('11', 'November'), ('12', 'December'),
]
class TNCSCMembershipController(http.Controller):
@http.route('/membership/check_username', type='jsonrpc', auth='public', methods=['POST'], website=True)
def check_username(self, username=None, **kwargs):
if not username:
return {'available': False, 'message': 'Username is required.'}
username = username.strip()
if len(username) < 3:
return {'available': False, 'message': 'Username must be at least 3 characters.'}
count = request.env['res.users'].sudo().search_count([('login', '=', username)])
if count > 0:
return {'available': False, 'message': 'Username is already taken.'}
return {'available': True, 'message': 'Username is available.'}
@http.route('/membership/check_email', type='jsonrpc', auth='public', methods=['POST'], website=True)
def check_email(self, email=None, **kwargs):
if not email:
return {'available': False, 'message': 'Email is required.'}
email = email.strip()
if '@' not in email or '.' not in email:
return {'available': False, 'message': 'Please enter a valid email address.'}
user_count = request.env['res.users'].sudo().search_count([('email', '=', email)])
partner_count = request.env['res.partner'].sudo().search_count([
('email', '=', email),
('membership_tier_id', '!=', False),
])
if user_count > 0 or partner_count > 0:
return {'available': False, 'message': 'An account with this email already exists.'}
return {'available': True, 'message': 'Email is available.'}
def _get_form_context(self, selected_code=None, errors=None, form_data=None):
Tier = request.env['community.membership.tier'].sudo()
tiers = Tier.search([('active', '=', True)], order='sequence, id')
selected_tier = None
if selected_code:
selected_tier = tiers.filtered(lambda t: t.code == selected_code)
if not selected_tier and selected_code.isdigit():
selected_tier = tiers.filtered(lambda t: t.id == int(selected_code))
if selected_tier:
selected_tier = selected_tier[0]
if not selected_tier and tiers:
selected_tier = tiers[0]
countries = request.env['res.country'].sudo().search([], order='name asc')
ca_country = request.env.ref('base.ca', raise_if_not_found=False)
if not ca_country:
ca_country = request.env['res.country'].sudo().search([('code', '=', 'CA')], limit=1)
current_year = fields.Date.today().year
birth_years = list(range(current_year - 14, current_year - 100, -1))
return {
'tiers': tiers,
'selected_tier': selected_tier,
'countries': countries,
'default_country_id': ca_country.id if ca_country else False,
'birth_months': MONTH_OPTIONS,
'birth_years': birth_years,
'errors': errors or [],
'form_data': form_data or {},
}
@http.route(['/membership/join'], type='http', auth='public', methods=['GET', 'POST'], website=True, sitemap=True)
def membership_join(self, **post):
if request.httprequest.method == 'GET':
tier_code = post.get('tier') or 'FAM'
values = self._get_form_context(selected_code=tier_code)
return request.render('tncsc_deployment.page_membership_join', values)
# Handle POST submission
errors = []
tier_id = post.get('tier_id')
username = (post.get('username') or '').strip()
email = (post.get('email') or '').strip().lower()
password = post.get('password') or ''
confirm_password = post.get('confirm_password') or ''
first_name = (post.get('first_name') or '').strip()
last_name = (post.get('last_name') or '').strip()
phone = (post.get('phone') or '').strip()
street = (post.get('street') or '').strip()
city = (post.get('city') or '').strip()
zip_code = (post.get('zip') or '').strip()
country_id = post.get('country_id')
birth_month = post.get('birth_month') or ''
birth_year = post.get('birth_year') or ''
terms_accepted = bool(post.get('membership_terms_accepted'))
coupon_code = (post.get('coupon_code') or '').strip()
tier = None
if tier_id:
tier = request.env['community.membership.tier'].sudo().browse(int(tier_id))
if not tier.exists():
tier = None
if not tier:
errors.append('Please select a valid membership plan.')
if not username or len(username) < 3:
errors.append('Username must be at least 3 characters long.')
elif request.env['res.users'].sudo().search_count([('login', '=', username)]) > 0:
errors.append(f'The username "{username}" is already taken. Please choose another username.')
if not email or '@' not in email:
errors.append('Please provide a valid email address.')
else:
existing_user = request.env['res.users'].sudo().search_count([('email', '=', email)])
existing_partner = request.env['res.partner'].sudo().search_count([
('email', '=', email),
('membership_tier_id', '!=', False),
])
if existing_user > 0 or existing_partner > 0:
errors.append(f'An account or membership application with email "{email}" already exists.')
if not password or len(password) < 6:
errors.append('Password must be at least 6 characters long.')
elif password != confirm_password:
errors.append('Passwords do not match.')
if not first_name:
errors.append('First Name is required.')
if not last_name:
errors.append('Last Name is required.')
if not terms_accepted:
errors.append('You must accept the TNCSC Constitution and Membership Terms to proceed.')
# Minimum age validation if tier requires it
today = date.today()
calculated_age = None
if birth_year and birth_year.isdigit():
b_year = int(birth_year)
b_month = int(birth_month) if birth_month and birth_month.isdigit() else 1
calculated_age = today.year - b_year
if today.month < b_month:
calculated_age -= 1
if tier and tier.min_age and calculated_age < tier.min_age:
errors.append(
f'The {tier.name} plan requires a minimum age of {tier.min_age} years '
f'(calculated age: {calculated_age}).'
)
# File uploads
avatar_file = request.httprequest.files.get('avatar_file')
proof_file = request.httprequest.files.get('proof_file')
if tier and tier.requires_proof and not proof_file:
# Alert requirement if mandatory
pass
if errors:
values = self._get_form_context(
selected_code=tier.code if tier else post.get('tier_id'),
errors=errors,
form_data=post,
)
return request.render('tncsc_deployment.page_membership_join', values)
# Create Member Partner
partner_vals = {
'first_name': first_name,
'last_name': last_name,
'name': f"{first_name} {last_name}".strip(),
'email': email,
'phone': phone,
'street': street,
'city': city,
'zip': zip_code,
'country_id': int(country_id) if country_id and country_id.isdigit() else False,
'birth_month': str(birth_month) if birth_month else False,
'birth_year': int(birth_year) if birth_year and birth_year.isdigit() else False,
'membership_tier_id': tier.id,
'membership_approval_state': 'pending',
'membership_terms_accepted': True,
'company_id': request.env.company.id,
}
# TNCSC Eligibility Answers
for k in ELIGIBILITY_KEYS:
ans = post.get(k)
if ans in ('yes', 'no'):
partner_vals[k] = ans
if avatar_file and avatar_file.filename:
try:
partner_vals['image_1920'] = base64.b64encode(avatar_file.read())
except Exception as e:
_logger.warning("Failed to read uploaded avatar: %s", e)
if proof_file and proof_file.filename:
try:
partner_vals['membership_proof_document'] = base64.b64encode(proof_file.read())
partner_vals['membership_proof_filename'] = proof_file.filename
except Exception as e:
_logger.warning("Failed to read proof document: %s", e)
partner = request.env['res.partner'].sudo().create(partner_vals)
# Create Portal User
try:
portal_group = request.env.ref('base.group_portal')
user_vals = {
'name': partner.name,
'login': username,
'email': email,
'partner_id': partner.id,
'groups_id': [(6, 0, [portal_group.id])],
}
user = request.env['res.users'].sudo().create(user_vals)
user.sudo().password = password
except Exception as e:
_logger.exception("Failed to create portal user for new member: %s", e)
# Process Family Members if applicable
if tier.allows_family_members:
fam_first_names = request.httprequest.form.getlist('family_first_name[]')
fam_last_names = request.httprequest.form.getlist('family_last_name[]')
fam_emails = request.httprequest.form.getlist('family_email[]')
fam_phones = request.httprequest.form.getlist('family_phone[]')
for idx in range(len(fam_first_names)):
f_first = (fam_first_names[idx] or '').strip()
f_last = (fam_last_names[idx] or '').strip()
if f_first or f_last:
f_email = (fam_emails[idx] or '').strip() if idx < len(fam_emails) else ''
f_phone = (fam_phones[idx] or '').strip() if idx < len(fam_phones) else ''
request.env['res.partner'].sudo().create({
'first_name': f_first,
'last_name': f_last,
'name': f"{f_first} {f_last}".strip(),
'email': f_email or False,
'phone': f_phone or False,
'family_head_id': partner.id,
'company_id': request.env.company.id,
})
# Post signup note on partner chatter
coupon_note = f"<br/><b>Coupon Applied:</b> {coupon_code}" if coupon_code else ""
partner.sudo().message_post(
body=(
f"<b>Online Membership Application Submitted</b><br/>"
f"<b>Tier:</b> {tier.name} ({tier.code}) - ${tier.price}<br/>"
f"<b>Username:</b> {username}<br/>"
f"<b>Status:</b> Pending Board Approval"
f"{coupon_note}"
),
subtype_xmlid='mail.mt_note',
)
# Send application received confirmation email
try:
template = request.env.ref('community_membership.mail_template_membership_application_received', raise_if_not_found=False)
if template:
template.sudo().send_mail(partner.id, force_send=True)
except Exception as e:
_logger.warning("Failed to send application confirmation email: %s", e)
return request.render('tncsc_deployment.page_membership_thank_you', {
'partner': partner,
'tier': tier,
'username': username,
})
@http.route('/membership/thank-you', type='http', auth='public', website=True)
def membership_thank_you(self, **kw):
return request.render('tncsc_deployment.page_membership_thank_you', {})

View File

@ -53,6 +53,63 @@
</p> </p>
<p>உங்களுக்கான புதுப்பித்தல் விலைப்பட்டியல் தயாரிக்கப்பட்டுள்ளது. உறுப்பினர் தகுதியை மீண்டும் <p>உங்களுக்கான புதுப்பித்தல் விலைப்பட்டியல் தயாரிக்கப்பட்டுள்ளது. உறுப்பினர் தகுதியை மீண்டும்
பெற எங்களைத் தொடர்பு கொள்ளவும்.</p> பெற எங்களைத் தொடர்பு கொள்ளவும்.</p>
</div>
</field>
</record>
<record id="community_membership.mail_template_membership_approved" model="mail.template">
<field name="subject">TNCSC Membership Approved! Welcome | TNCSC உறுப்பினர் சேர்க்கை அங்கீகரிக்கப்பட்டது</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p><strong>English</strong></p>
<p>Dear <t t-out="object.name or ''">Member</t>,</p>
<p>
We are thrilled to welcome you to the Tamil Nadu Cultural Society of Canada!
Your membership application for
<strong><t t-out="object.membership_tier_id.name or 'Membership'"/></strong>
has been approved by the Board of Directors.
</p>
<t t-if="object.membership_member_id">
<p>Your Member ID: <strong><t t-out="object.membership_member_id"/></strong></p>
</t>
<p>You can access your member portal, download your digital membership card, and access community events through our website.</p>
<hr/>
<p><strong>தமிழ்</strong></p>
<p>அன்புள்ள <t t-out="object.name or ''">உறுப்பினர்</t>,</p>
<p>
கனடா தமிழ்நாடு பண்பாட்டு சங்கத்திற்கு உங்களை அன்போடு வரவேற்கிறோம்!
உங்கள் <strong><t t-out="object.membership_tier_id.name or 'உறுப்பினர்'"/></strong> விண்ணப்பம்
இயக்குனர் குழுவால் அங்கீகரிக்கப்பட்டுள்ளது.
</p>
<t t-if="object.membership_member_id">
<p>உங்கள் உறுப்பினர் எண்: <strong><t t-out="object.membership_member_id"/></strong></p>
</t>
<p>நமது இணையதளம் மூலம் உறுப்பினர் போர்ட்டலை அணுகி உங்கள் டிஜிட்டல் அட்டை மற்றும் நிகழ்வு விவரங்களைப் பெறலாம்.</p>
</div>
</field>
</record>
<record id="community_membership.mail_template_membership_rejected" model="mail.template">
<field name="subject">TNCSC Membership Application Update | TNCSC உறுப்பினர் விண்ணப்ப தகவல்</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p><strong>English</strong></p>
<p>Dear <t t-out="object.name or ''">Applicant</t>,</p>
<p>
Thank you for your application to join the Tamil Nadu Cultural Society of Canada.
After careful review, the Board of Directors was unable to approve your application at this time.
</p>
<p>If any registration fees were collected, a full refund will be processed promptly.</p>
<p>If you have any questions or require clarification, please reach out to us at support@tncsc.ca.</p>
<hr/>
<p><strong>தமிழ்</strong></p>
<p>அன்புள்ள <t t-out="object.name or ''">விண்ணப்பதாரர்</t>,</p>
<p>
கனடா தமிழ்நாடு பண்பாட்டு சங்கத்தில் இணைய விண்ணப்பித்தமைக்கு நன்றி.
விண்ணப்ப பரிசீலனைக்குப் பிறகு, தற்போதைக்கு தங்கள் விண்ணப்பத்தை ஏற்க இயலவில்லை என்பதைத் தெரிவித்துக் கொள்கிறோம்.
</p>
<p>கட்டணம் ஏதேனும் செலுத்தப்பட்டிருந்தால், அது முழுமையாக திருப்பித் தரப்படும்.</p>
<p>தொடர்புக்கு: support@tncsc.ca</p>
</div> </div>
</field> </field>
</record> </record>

View File

@ -0,0 +1,16 @@
"""Reload the seeded website pages from views/website_pages.xml.
That file is noupdate="1" so edits made in the website editor survive
upgrades, but it also means upgrades never deliver template fixes or new pages
(here: /membership/join, /membership/thank-you, dynamic event links, and captcha fixes).
Loading it once in 'init' mode rewrites the existing page views.
Runs before the module's data is loaded.
"""
from odoo import SUPERUSER_ID, api
from odoo.tools import convert_file
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
convert_file(env, 'tncsc_deployment', 'views/website_pages.xml', {}, mode='init', noupdate=True)

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -5,28 +5,32 @@
display: none !important; display: none !important;
} }
.tncsc-hero-nav .tncsc-member-login { .tncsc-member-login {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 8px 12px; padding: 7px 16px;
border: 1px solid #D89C43; border: 1px solid #d19b33 !important;
border-radius: 6px; border-radius: 20px !important;
background: #FFFCF7; background: linear-gradient(135deg, #e5a93c 0%, #d19b33 100%) !important;
color: #87530D; color: #120735 !important;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 700 !important;
text-decoration: none; text-decoration: none !important;
white-space: nowrap; white-space: nowrap;
box-shadow: 0 2px 8px rgba(229, 169, 60, 0.3);
transition: all 0.2s ease;
&:hover { &:hover {
background: #FFF0D6; background: linear-gradient(135deg, #f3c669 0%, #e5a93c 100%) !important;
color: #643D09; color: #120735 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 169, 60, 0.45);
} }
&:focus-visible { &:focus-visible {
outline: 3px solid #D89C43; outline: 3px solid #e5a93c;
outline-offset: 3px; outline-offset: 3px;
} }
} }

View File

@ -106,3 +106,46 @@ class TestTncscDeployment(TransactionCase):
self.env.ref('community_interac.group_interac_verifier'), self.env.ref('community_interac.group_interac_verifier'),
treasurer.implied_ids, treasurer.implied_ids,
) )
def test_membership_join_pages_published(self):
urls = ('/membership/join', '/membership/thank-you')
for url in urls:
page = self.env['website.page'].search([('url', '=', url)], limit=1)
self.assertTrue(page, f"Expected a website.page for {url}")
self.assertTrue(page.is_published)
def test_membership_approval_workflow_end_to_end(self):
tier = self.env['community.membership.tier'].search([('code', '=', 'FAM')], limit=1)
partner = self.env['res.partner'].create({
'first_name': 'Meena',
'last_name': 'Sundaram',
'email': 'meena.test@example.com',
'membership_tier_id': tier.id,
'membership_approval_state': 'pending',
})
self.assertEqual(partner.membership_approval_state, 'pending')
self.assertEqual(partner.membership_state, 'none')
# Approve
partner.action_approve_membership()
self.assertEqual(partner.membership_approval_state, 'approved')
self.assertEqual(partner.membership_state, 'active')
self.assertTrue(partner.membership_start)
self.assertTrue(partner.membership_expiry)
self.assertTrue(partner.membership_member_id)
# Reject workflow on another partner
partner_rej = self.env['res.partner'].create({
'first_name': 'Raj',
'last_name': 'Kumar',
'email': 'raj.test@example.com',
'membership_tier_id': tier.id,
'membership_approval_state': 'pending',
})
partner_rej.action_reject_membership()
self.assertEqual(partner_rej.membership_approval_state, 'rejected')
self.assertEqual(partner_rej.membership_state, 'none')
def test_base_url_configured(self):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
self.assertEqual(base_url, 'https://tncsc.odoo.thedomainnest.com')

View File

@ -10,9 +10,12 @@
'/membership': 'Membership | TNCSC', '/membership': 'Membership | TNCSC',
'/membership/benefits': 'Membership Benefits | TNCSC', '/membership/benefits': 'Membership Benefits | TNCSC',
'/membership/registration': 'Membership Registration | TNCSC', '/membership/registration': 'Membership Registration | TNCSC',
'/membership/join': 'Join Membership | TNCSC',
'/event': 'Events | TNCSC', '/event': 'Events | TNCSC',
'/product/tncsc-summer-picnic-2026': 'Summer Picnic 2026 | TNCSC',
'/tamil-school': 'Tamil School | TNCSC', '/tamil-school': 'Tamil School | TNCSC',
'/school/register': 'Tamil School Registration | TNCSC',
'/school/register/student': 'Tamil School Registration - Student | TNCSC',
'/school/register/class': 'Tamil School Registration - Class | TNCSC',
'/sponsors': 'Sponsors | TNCSC', '/sponsors': 'Sponsors | TNCSC',
'/contact': 'Contact Us | TNCSC', '/contact': 'Contact Us | TNCSC',
'/my': 'Member Portal | TNCSC', '/my': 'Member Portal | TNCSC',

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<template id="website_member_login_button" name="TNCSC Member Login Button"> <template id="website_member_login_button" name="TNCSC Member Login Button">
<a href="/web/login?redirect=/my" class="tncsc-member-login"> <a href="/web/login?redirect=/my" class="btn btn-warning btn-sm rounded-pill px-3 fw-bold text-dark tncsc-member-login" style="background: linear-gradient(135deg, #e5a93c 0%, #d19b33 100%) !important; color: #120735 !important; font-weight: 700 !important; border: 1px solid #d19b33 !important; text-decoration: none !important; display: inline-flex; align-items: center; gap: 6px; box-shadow: 0 2px 8px rgba(229, 169, 60, 0.3);">
<i class="fa fa-user" aria-hidden="true"/> Member Login <i class="fa fa-user" aria-hidden="true"/> Member Login
</a> </a>
</template> </template>
@ -97,15 +97,12 @@
</xpath> </xpath>
</template> </template>
<template id="view_page_product_summer_picnic_custom_header_only" <template id="view_page_membership_join_custom_header_only"
inherit_id="tncsc_deployment.view_page_product_summer_picnic" inherit_id="tncsc_deployment.view_page_membership_join"
name="Use TNCSC navigation without the default website header"> name="Use TNCSC navigation without the default website header">
<xpath expr="//t[@t-call='website.layout']" position="inside"> <xpath expr="//t[@t-call='website.layout']" position="inside">
<t t-set="no_header" t-value="True"/> <t t-set="no_header" t-value="True"/>
</xpath> </xpath>
<xpath expr="//div[hasclass('tncsc-hero-nav')]//a[@href='/contact']" position="after">
<t t-call="tncsc_deployment.website_member_login_button"/>
</xpath>
</template> </template>
<template id="view_page_tamil_school_custom_header_only" <template id="view_page_tamil_school_custom_header_only"
@ -129,6 +126,67 @@
<t t-call="tncsc_deployment.website_member_login_button"/> <t t-call="tncsc_deployment.website_member_login_button"/>
</xpath> </xpath>
</template> </template>
<!-- School Registration Navigation & Branding -->
<template id="tncsc_school_reg_header_nav" name="TNCSC School Registration Navigation">
<div class="tncsc-top-header" style="background-color: #120735; padding: 14px 20px; text-align: center;">
<h1 style="font-size: 1.55rem; font-weight: 700; color: #ffffff; margin: 0; line-height: 1.2;">தமிழ்நாடு கலாச்சார சங்கம்</h1>
<p style="font-size: 1.1rem; font-weight: 400; color: #B8B2E6; margin: 2px 0 0 0;">Tamil Nadu Cultural Society of Canada</p>
</div>
<nav class="navbar navbar-expand-lg navbar-dark" style="background-color: #1c0d48; border-bottom: 2px solid #e5a93c; padding: 8px 24px;">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<img src="/tncsc_deployment/static/src/img/brand/logo.png" alt="TNCSC Logo" style="height: 38px; width: auto;"/>
<span class="fw-bold text-white small d-none d-sm-inline">TNCSC</span>
</a>
<div class="d-flex align-items-center gap-2 ms-auto">
<a href="/tamil-school" class="btn btn-outline-light btn-sm rounded-pill px-3">
<i class="fa fa-graduation-cap me-1"></i> Tamil School
</a>
<a href="/membership/registration" class="btn btn-outline-warning btn-sm rounded-pill px-3">
<i class="fa fa-id-card me-1"></i> Membership
</a>
<t t-call="tncsc_deployment.website_member_login_button"/>
</div>
</div>
</nav>
</template>
<template id="view_school_reg_step_parent_header" inherit_id="community_school.registration_step_parent" name="TNCSC School Reg Header Parent">
<xpath expr="//t[@t-call='website.layout']" position="inside">
<t t-set="no_header" t-value="True"/>
</xpath>
<xpath expr="//div[@id='wrap']" position="before">
<t t-call="tncsc_deployment.tncsc_school_reg_header_nav"/>
</xpath>
</template>
<template id="view_school_reg_step_student_header" inherit_id="community_school.registration_step_student" name="TNCSC School Reg Header Student">
<xpath expr="//t[@t-call='website.layout']" position="inside">
<t t-set="no_header" t-value="True"/>
</xpath>
<xpath expr="//div[@id='wrap']" position="before">
<t t-call="tncsc_deployment.tncsc_school_reg_header_nav"/>
</xpath>
</template>
<template id="view_school_reg_step_class_header" inherit_id="community_school.registration_step_class" name="TNCSC School Reg Header Class">
<xpath expr="//t[@t-call='website.layout']" position="inside">
<t t-set="no_header" t-value="True"/>
</xpath>
<xpath expr="//div[@id='wrap']" position="before">
<t t-call="tncsc_deployment.tncsc_school_reg_header_nav"/>
</xpath>
</template>
<template id="view_school_reg_done_header" inherit_id="community_school.registration_done" name="TNCSC School Reg Header Done">
<xpath expr="//t[@t-call='website.layout']" position="inside">
<t t-set="no_header" t-value="True"/>
</xpath>
<xpath expr="//div[@id='wrap']" position="before">
<t t-call="tncsc_deployment.tncsc_school_reg_header_nav"/>
</xpath>
</template>
<!-- Odoo's stock footer (shown on built-in pages such as event <!-- Odoo's stock footer (shown on built-in pages such as event
registration, login and school registration) ships demo content: registration, login and school registration) ships demo content:
dead "#" links, info@yourcompany.example.com, a 555 phone number and dead "#" links, info@yourcompany.example.com, a 555 phone number and
@ -136,47 +194,51 @@
website.footer_custom (16) makes this replace it even where the website.footer_custom (16) makes this replace it even where the
website editor has made a website-specific copy of that view. --> website editor has made a website-specific copy of that view. -->
<template id="tncsc_footer" inherit_id="website.layout" name="TNCSC Footer" priority="90"> <template id="tncsc_footer" inherit_id="website.layout" name="TNCSC Footer" priority="90">
<xpath expr="//footer[@id='bottom']" position="attributes">
<attribute name="style">background-color: #120735 !important; color: #ffffff;</attribute>
<attribute name="class" remove="bg-light" separator=" "/>
</xpath>
<xpath expr="//div[@id='footer']" position="replace"> <xpath expr="//div[@id='footer']" position="replace">
<div id="footer" class="oe_structure oe_structure_solo text-break" t-if="not no_footer"> <div id="footer" class="oe_structure oe_structure_solo text-break" t-if="not no_footer" style="background-color: #120735; color: #ffffff;">
<section class="pt40 pb16"> <section class="pt48 pb32" style="background-color: #120735; color: #cbd5e1;">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-lg-3 pt24 pb24"> <div class="col-lg-3 pt16 pb16">
<h5>Useful Links</h5> <h5 style="color: #e5a93c; font-weight: 700; font-size: 1.15rem; margin-bottom: 18px; letter-spacing: 0.5px;">Useful Links</h5>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a href="/">Home</a></li> <li class="mb-2"><a href="/" style="color: #cbd5e1; text-decoration: none; transition: color 0.2s ease;">Home</a></li>
<li><a href="/about">About Us</a></li> <li class="mb-2"><a href="/about" style="color: #cbd5e1; text-decoration: none; transition: color 0.2s ease;">About Us</a></li>
<li><a href="/event">Events</a></li> <li class="mb-2"><a href="/event" style="color: #cbd5e1; text-decoration: none; transition: color 0.2s ease;">Events</a></li>
<li><a href="/membership/registration">Membership</a></li> <li class="mb-2"><a href="/membership/registration" style="color: #cbd5e1; text-decoration: none; transition: color 0.2s ease;">Membership</a></li>
<li><a href="/tamil-school">Tamil Class</a></li> <li class="mb-2"><a href="/tamil-school" style="color: #cbd5e1; text-decoration: none; transition: color 0.2s ease;">Tamil Class</a></li>
<li><a href="/contact">Contact</a></li> <li class="mb-2"><a href="/contact" style="color: #cbd5e1; text-decoration: none; transition: color 0.2s ease;">Contact</a></li>
</ul> </ul>
</div> </div>
<div class="col-lg-5 pt24 pb24"> <div class="col-lg-5 pt16 pb16">
<h5>About TNCSC</h5> <h5 style="color: #e5a93c; font-weight: 700; font-size: 1.15rem; margin-bottom: 18px; letter-spacing: 0.5px;">About TNCSC</h5>
<p>A not-for-profit Ontario corporation serving Tamils of Indian origin in Canada <p style="color: #cbd5e1; font-size: 0.95rem; line-height: 1.7;">A not-for-profit Ontario corporation serving Tamils of Indian origin in Canada
since 1997, preserving and promoting our language, heritage and culture.</p> since 1997, preserving and promoting our language, heritage and culture.</p>
</div> </div>
<div class="col-lg-3 offset-lg-1 pt24 pb24"> <div class="col-lg-3 offset-lg-1 pt16 pb16">
<h5>Connect with us</h5> <h5 style="color: #e5a93c; font-weight: 700; font-size: 1.15rem; margin-bottom: 18px; letter-spacing: 0.5px;">Connect with us</h5>
<ul class="list-unstyled"> <ul class="list-unstyled mb-4">
<li><i class="fa fa-comment fa-fw me-2"/><a href="/contact">Contact us</a></li> <li class="mb-2"><i class="fa fa-comment fa-fw me-2" style="color: #e5a93c;"/><a href="/contact" style="color: #cbd5e1; text-decoration: none;">Contact us</a></li>
<li t-if="res_company.email"> <li t-if="res_company.email" class="mb-2">
<i class="fa fa-envelope fa-fw me-2"/><a t-attf-href="mailto:#{res_company.email}" t-out="res_company.email"/> <i class="fa fa-envelope fa-fw me-2" style="color: #e5a93c;"/><a t-attf-href="mailto:#{res_company.email}" t-out="res_company.email" style="color: #cbd5e1; text-decoration: none;"/>
</li> </li>
<li t-if="res_company.phone"> <li t-if="res_company.phone" class="mb-2">
<i class="fa fa-phone fa-fw me-2"/><a t-attf-href="tel:#{res_company.phone}" t-out="res_company.phone"/> <i class="fa fa-phone fa-fw me-2" style="color: #e5a93c;"/><a t-attf-href="tel:#{res_company.phone}" t-out="res_company.phone" style="color: #cbd5e1; text-decoration: none;"/>
</li> </li>
</ul> </ul>
<div class="s_social_media text-start"> <div class="s_social_media text-start d-flex gap-3">
<a t-if="website.social_facebook" href="/website/social/facebook" target="_blank" rel="noopener" aria-label="Facebook"> <a t-if="website.social_facebook" href="/website/social/facebook" target="_blank" rel="noopener" aria-label="Facebook" style="color: #e5a93c; font-size: 1.3rem;">
<i class="fa fa-facebook rounded-circle shadow-sm"/> <i class="fa fa-facebook"/>
</a> </a>
<a t-if="website.social_youtube" href="/website/social/youtube" target="_blank" rel="noopener" aria-label="YouTube"> <a t-if="website.social_youtube" href="/website/social/youtube" target="_blank" rel="noopener" aria-label="YouTube" style="color: #e5a93c; font-size: 1.3rem;">
<i class="fa fa-youtube-play rounded-circle shadow-sm"/> <i class="fa fa-youtube-play"/>
</a> </a>
<a t-if="website.social_instagram" href="/website/social/instagram" target="_blank" rel="noopener" aria-label="Instagram"> <a t-if="website.social_instagram" href="/website/social/instagram" target="_blank" rel="noopener" aria-label="Instagram" style="color: #e5a93c; font-size: 1.3rem;">
<i class="fa fa-instagram rounded-circle shadow-sm"/> <i class="fa fa-instagram"/>
</a> </a>
</div> </div>
</div> </div>
@ -185,6 +247,20 @@
</section> </section>
</div> </div>
</xpath> </xpath>
<xpath expr="//div[hasclass('o_footer_copyright')]" position="replace">
<div class="o_footer_copyright" style="background-color: #0c0424 !important; border-top: 1px solid rgba(255, 255, 255, 0.08); padding: 18px 0; color: #94a3b8;">
<div class="container">
<div class="row align-items-center row-gap-2">
<div class="col-md-8 text-center text-md-start small">
<span>Copyright &amp;copy; 2026 Tamil Nadu Cultural Society of Canada. All rights reserved.</span>
</div>
<div class="col-md-4 text-center text-md-end small">
<span style="color: #e5a93c; font-weight: 600;">Preserving Tamil Culture Since 1997</span>
</div>
</div>
</div>
</div>
</xpath>
</template> </template>
<!-- Odoo's stock header (on built-in pages such as login, event and <!-- Odoo's stock header (on built-in pages such as login, event and

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ db_user = odoo
db_password = odoo db_password = odoo
addons_path = /mnt/extra-addons addons_path = /mnt/extra-addons
dev_mode = reload,qweb,werkzeug,xml dev_mode = reload,qweb,werkzeug,xml
db_name = TNCSC
list_db = True list_db = True
proxy_mode = False proxy_mode = False
logfile = logfile =

View File

@ -0,0 +1,67 @@
# TNCSC Community OS — Administrator Guide
## Overview
This guide is for TNCSC System Administrators, Board Members, and Membership Managers managing the Tamil Nadu Cultural Society of Canada's digital platform on Odoo 19.
---
## 1. Membership Management
### Reviewing Applications
1. Navigate to **Community OS > Members**.
2. Filter by **Pending Approval** in the search bar.
3. Open a member record to inspect their application:
- **Personal Details**: First Name, Last Name, Phone, Email, Address.
- **Eligibility Questions**: Check answers to the 5 TNCSC Constitution eligibility questions (Tamil origin, PIO/OCI status, citizenship history).
- **Age & Birth Info**: Check computed age (worked out from birth month and year).
- **Uploaded Proof**: If a Senior or Student tier was requested, review the attached `Proof of Eligibility` document in the chatter or membership tab.
- **Family Members**: For Annual/Family memberships, view child records linked under `Family Members`.
### Approving or Rejecting
- Click **Approve**:
- Automatically transitions `Board Approval` to **Approved**.
- Sets membership state to **Active** and sets the start and expiry dates based on the tier.
- Automatically dispatches the bilingual **TNCSC Membership Approved** email containing their Member ID and portal access information.
- Logs approval details in the chatter.
- Click **Reject**:
- Sets `Board Approval` to **Rejected**.
- Cancels any draft membership dues invoice.
- Sends the **TNCSC Membership Rejected** notification email with instructions on refund/appeals.
- Logs the rejection in chatter.
---
## 2. Event Management & Ticketing
1. Navigate to **Community OS > Events**.
2. Create or open an event (e.g. Tamil New Year, Picnic, Pongal Celebration).
3. **Hero & Website Display**:
- Ensure `Website Published` is checked.
- The public website dynamically links to the earliest upcoming event for "Book Now" and lists all upcoming events at `/event`.
4. **QR Code Attendance**:
- Attendees receive a QR code badge/ticket.
- Volunteers can scan attendees at the entrance using any mobile device camera or Odoo barcode scanner to validate registration.
---
## 3. Website Administration & CMS
1. **Pages**:
- `/` — Homepage featuring upcoming events, announcements, and quick links.
- `/about` & `/about/board-of-directors` — Organization mission, history, and committee members.
- `/membership/registration` — Plan comparison and details.
- `/membership/join` — Public interactive membership signup application with real-time validation.
- `/event` — Dynamic events catalog.
- `/tamil-school` — Tamil class information and enrollment.
- `/contact` — Contact details with spam-protected inquiry form.
2. **Editing Content**:
- Log in as an administrator and click the **Edit** button in the website top bar to make visual changes.
---
## 4. System Security & User Roles
- **Portal Users**: Regular members, students, and parents. Can only view their own membership card, profile, events, and family school records.
- **Membership Managers**: Access to sensitive applicant data (DOB, proof documents, eligibility answers, board approval buttons).
- **School Teachers**: Access to classroom rosters, attendance sheets, and student grades.
- **Treasurers / Accountants**: Access to invoices, bank journals, Interac e-Transfer reconciliations, and financial reports.

View File

@ -0,0 +1,49 @@
# TNCSC Community Portal — Member & Parent Guide
## Overview
This guide is for TNCSC members, parents of Tamil School students, and event attendees using the self-service web portal.
---
## 1. Accessing the Member Portal
1. Visit `https://tncsc.odoo.thedomainnest.com/my` (or click **Member Login** in the top navigation).
2. Enter your registered **Username / Email** and **Password** chosen during registration.
3. Once logged in, your personal dashboard displays:
- Your Active Membership Tier & Expiry Date
- Digital Membership Card with Scannable QR Code
- Event Registrations & QR Tickets
- Children Enrolled in Tamil School
- Invoices & Payment History
---
## 2. Digital Membership Card
1. On your portal home page (`/my`), click **View Membership Card**.
2. Your digital card includes:
- Your full name and Member ID (e.g. `TNCSC-2026-0042`)
- Membership tier (e.g. Annual/Family, Life Term, Senior)
- QR code for entry verification at community events
- Expiration date (or *Lifetime*)
3. You can screenshot or save the card to your smartphone for instant check-in.
---
## 3. Tamil School Student Portal
1. Navigate to **My Account > Tamil School** (`/my/school`).
2. View each enrolled child:
- Class Level & Instructor
- Attendance Records & Progress
- Schedule & Classroom location
3. Update emergency contacts or submit absence notes directly.
---
## 4. Event Registration & Tickets
1. Browse upcoming cultural festivals and picnics at `/event`.
2. Click **Register** on an event to claim tickets for yourself and family members.
3. Access your tickets anytime under **My Account > Event Tickets**.
4. Show the ticket QR code at the event entrance for expedited check-in.

View File

@ -0,0 +1,41 @@
# TNCSC Tamil School — Teacher & Instructor Guide
## Overview
This guide is for Tamil language and cultural instructors conducting classes at the TNCSC Tamil School.
---
## 1. Accessing Your Classes
1. Log in to the TNCSC platform at `https://tncsc.odoo.thedomainnest.com` (or local portal).
2. Navigate to **Community OS > Tamil School > Classes**.
3. Open your assigned class (e.g. *Kindergarten Tamil*, *Level 1 Tamil*, *Advanced Tamil Literature*).
---
## 2. Managing Class Rosters & Students
1. In the class form, review the **Enrolled Students** list.
2. Each student card displays:
- Student Name & Tamil Name
- Age / Date of Birth
- Parent / Guardian Contact (Email & Phone)
- Emergency Contact details
---
## 3. Daily Attendance
1. Under the class view, select **Take Attendance** for the current session date.
2. Mark each student as:
- **Present**
- **Absent**
- **Excused / Late**
3. Save the attendance record. Parents can see their child's attendance history in real time via their member portal.
---
## 4. Class Announcements & Syllabus
1. Post homework, lesson plans, and reading assignments directly in the class chatter.
2. Parents and students will receive email notifications with class updates.

View File

@ -0,0 +1,49 @@
# TNCSC Community OS — Treasurer & Finance Guide
## Overview
This guide is for the TNCSC Treasurer and finance team responsible for managing membership dues, event ticketing revenue, Tamil school tuition fees, donations, and Canadian non-profit compliance.
---
## 1. Membership Dues & Invoicing
### Automated Invoicing
- When an applicant submits a membership registration or is approved, an invoice is generated based on their chosen tier:
- **Annual/Family (FAM)**: $30 CAD / 1 year
- **Long-Term (LONG)**: $250 CAD / 10 years
- **Life Term (LIFE)**: $500 CAD / Lifetime
- **Senior Annual (SEN)**: $15 CAD / 1 year
- **Senior Life (SENLIFE)**: $250 CAD / Lifetime
- **Student Annual (STU)**: $10 CAD / 1 year
### Reviewing Dues Invoices
1. Navigate to **Accounting > Customers > Invoices**.
2. Filter by status: `Draft`, `Posted`, or `Paid`.
3. Invoices are tagged to the member partner and default to the Canadian Chart of Accounts revenue account for Membership Dues.
---
## 2. Interac e-Transfer Reconciliations
TNCSC accepts e-Transfers through `community_interac`.
1. Members send Interac payments to `finance@tncsc.ca` (or configured company email).
2. When the bank statement or notification is received:
- Match the transaction reference or applicant name in **Accounting > Bank and Cash**.
- Reconcile the payment against the open customer invoice.
- Once marked Paid, the membership state automatically advances to `Active`.
---
## 3. Event Revenue & Ticketing
1. Event registrations (such as picnics or banquets) automatically generate sales records and invoices if fee-based.
2. Invoices can be reviewed under **Accounting > Invoices** filtered by Event tag.
3. Refunds for canceled events or rejected applicants can be processed via **Credit Note** directly on the invoice.
---
## 4. Financial Reporting & Non-Profit Filings
1. **Balance Sheet**: **Accounting > Reporting > Balance Sheet**.
2. **Profit and Loss (Income Statement)**: **Accounting > Reporting > Profit and Loss**.
3. **General Ledger**: Exportable for Canadian CRA T3010 / T2 non-profit returns and annual general meeting (AGM) presentations.

View File

@ -10,8 +10,8 @@ import re
import sys import sys
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent if Path(__file__).name != '<stdin>' else Path('/mnt/extra-addons').parent
ADDONS_DIR = REPO_ROOT / "addons" ADDONS_DIR = Path('/mnt/extra-addons') if Path('/mnt/extra-addons').exists() else (REPO_ROOT / "addons")
BANNED_WORDS = [ BANNED_WORDS = [
"tncsc", "tncsc",

View File

@ -9,8 +9,8 @@ import ast
import sys import sys
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent if Path(__file__).name != '<stdin>' else Path('/mnt/extra-addons').parent
ADDONS_DIR = REPO_ROOT / "addons" ADDONS_DIR = Path('/mnt/extra-addons') if Path('/mnt/extra-addons').exists() else (REPO_ROOT / "addons")
REQUIRED_KEYS = ["name", "version", "author", "website", "license", "category", "summary", "depends"] REQUIRED_KEYS = ["name", "version", "author", "website", "license", "category", "summary", "depends"]
PRODUCT_ONLY_KEYS = ["price", "currency", "images"] PRODUCT_ONLY_KEYS = ["price", "currency", "images"]

View File

@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Generate professional icon.png and banner.png assets for Community OS modules."""
import os
from PIL import Image, ImageDraw, ImageFont
MODULES = [
{
'dir': 'community_membership',
'title': 'Community Membership',
'subtitle': 'Tiers, Terms, Expiry, Family Plans & Digital Cards',
'color_primary': (18, 7, 53), # Deep Navy
'color_secondary': (229, 169, 60), # Gold
'badge': 'MEMBERSHIP',
},
{
'dir': 'community_school',
'title': 'Community School',
'subtitle': 'Classrooms, Tamil School Rosters, Attendance & Teachers',
'color_primary': (15, 60, 45), # Deep Emerald
'color_secondary': (229, 169, 60), # Gold
'badge': 'TAMIL SCHOOL',
},
{
'dir': 'event_qr_ticketing',
'title': 'Event QR Ticketing',
'subtitle': 'Fast QR Code Check-in, Attendee Badges & Scanner',
'color_primary': (40, 15, 80), # Deep Violet
'color_secondary': (243, 198, 105),# Light Gold
'badge': 'EVENTS & QR',
},
{
'dir': 'community_portal',
'title': 'Community Portal',
'subtitle': 'Self-Service Member Portal, Digital Cards & School Roster',
'color_primary': (20, 30, 75), # Deep Indigo
'color_secondary': (70, 190, 200), # Teal
'badge': 'PORTAL',
},
{
'dir': 'community_benefits',
'title': 'Community Benefits',
'subtitle': 'Member Discounts, Partner Perks & Entitlement Verification',
'color_primary': (25, 45, 100), # Royal Blue
'color_secondary': (229, 169, 60), # Gold
'badge': 'BENEFITS',
},
{
'dir': 'community_classifieds',
'title': 'Community Classifieds',
'subtitle': 'Community Marketplace, Volunteer Listings & Housing',
'color_primary': (20, 70, 70), # Dark Teal
'color_secondary': (255, 140, 60), # Tangerine
'badge': 'CLASSIFIEDS',
},
{
'dir': 'community_interac',
'title': 'Community Interac',
'subtitle': 'Canadian Interac e-Transfer Direct Payment Provider',
'color_primary': (80, 50, 10), # Dark Bronze
'color_secondary': (255, 190, 40), # Amber
'badge': 'INTERAC CAD',
},
{
'dir': 'community_theme_base',
'title': 'Community Theme Base',
'subtitle': 'Unified Theme Tokens, Typography, Colors & Branding',
'color_primary': (35, 25, 60), # Deep Slate Purple
'color_secondary': (180, 140, 230),# Lavender
'badge': 'THEME BASE',
},
{
'dir': 'tncsc_deployment',
'title': 'TNCSC Deployment',
'subtitle': 'Tamil Nadu Cultural Society of Canada Production Platform',
'color_primary': (18, 7, 53), # TNCSC Navy
'color_secondary': (229, 169, 60), # TNCSC Gold
'badge': 'TNCSC CANADA',
},
]
def make_gradient(width, height, color1, color2):
base = Image.new('RGB', (width, height), color1)
top = Image.new('RGB', (width, height), color2)
mask = Image.new('L', (width, height))
mask_data = []
for y in range(height):
for x in range(width):
mask_data.append(int(255 * (x / width * 0.5 + y / height * 0.5)))
mask.putdata(mask_data)
base.paste(top, (0, 0), mask)
return base
def generate_icon(mod, target_dir):
width, height = 128, 128
c1 = mod['color_primary']
c2 = tuple(min(255, c + 35) for c in c1)
im = make_gradient(width, height, c1, c2)
draw = ImageDraw.Draw(im)
# Gold accent border
gold = mod['color_secondary']
draw.rounded_rectangle([4, 4, width - 4, height - 4], radius=24, outline=gold, width=3)
# Center monogram/symbol
words = mod['badge'].split()
monogram = "".join(w[0] for w in words[:3])
# Inner badge pill
draw.rounded_rectangle([20, 46, width - 20, 82], radius=8, fill=gold)
draw.text((width // 2, 64), monogram, fill=c1, anchor="mm")
# Bottom bar
draw.line([30, 100, width - 30, 100], fill=gold, width=2)
os.makedirs(target_dir, exist_ok=True)
icon_path = os.path.join(target_dir, 'icon.png')
im.save(icon_path, 'PNG')
print(f"Generated {icon_path}")
def generate_banner(mod, target_dir):
width, height = 800, 400
c1 = mod['color_primary']
c2 = tuple(min(255, c + 25) for c in c1)
im = make_gradient(width, height, c1, c2)
draw = ImageDraw.Draw(im)
gold = mod['color_secondary']
# Elegant decorative borders
draw.rectangle([0, 0, width, 8], fill=gold)
draw.rectangle([0, height - 8, width, height], fill=gold)
# Subtle geometric grid / card background
draw.rounded_rectangle([40, 40, width - 40, height - 40], radius=16, outline=gold, width=2)
draw.rounded_rectangle([48, 48, width - 48, height - 48], radius=12, outline=(60, 60, 80), width=1)
# Badge Pill at top
badge_text = f"★ {mod['badge']} MODULE ★"
draw.rounded_rectangle([280, 70, 520, 102], radius=16, fill=gold)
draw.text((400, 86), badge_text, fill=c1, anchor="mm")
# Main Title
draw.text((400, 170), mod['title'], fill=(255, 255, 255), anchor="mm")
# Gold separator line
draw.line([250, 220, 550, 220], fill=gold, width=3)
# Subtitle
draw.text((400, 260), mod['subtitle'], fill=(210, 215, 230), anchor="mm")
# Footer attribution
draw.text((400, 330), "Community OS • Odoo 19 Edition", fill=gold, anchor="mm")
os.makedirs(target_dir, exist_ok=True)
banner_path = os.path.join(target_dir, 'banner.png')
im.save(banner_path, 'PNG')
print(f"Generated {banner_path}")
def main():
if os.path.exists('/mnt/extra-addons'):
root = '/mnt/extra-addons'
else:
root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'addons'))
for mod in MODULES:
desc_dir = os.path.join(root, mod['dir'], 'static', 'description')
generate_icon(mod, desc_dir)
generate_banner(mod, desc_dir)
if __name__ == '__main__':
main()