Compare commits

..

No commits in common. "9a24691c37d570178fa1a03d5036fd2991bd636d" and "db50c3f15c53d20ff08a9d77a3b9b03fa6ec51c1" have entirely different histories.

114 changed files with 13 additions and 4779 deletions

View File

@ -1,217 +0,0 @@
# Handoff / Resume Notes
Read this first if you're picking this project back up — whether that's
Claude Code in a future session or a human developer. The phase-by-phase
build plan is in `CommunityOS_Implementation_Plan_for_Claude_Code.md`; this
file is the "where things actually stand" companion to it.
## Status at a glance
| Phase | What it is | Status |
|---|---|---|
| 0 | Repo scaffold, Docker dev stack, CI + brand-leak guardrails | ✅ Done, committed |
| 1 | `community_membership` | ✅ Done, committed |
| 2 | `event_qr_ticketing` | ✅ Done, committed |
| 3 | `community_school` | ✅ Done, committed |
| 4 | `community_classifieds` + `community_benefits` | ✅ Done, committed |
| 5 | `community_interac` (Interac e-Transfer payment provider) | ✅ Done, committed |
| 6 | `community_theme_base` + `community_portal` | ✅ Done, committed |
| 7 | `tncsc_deployment` (client config layer) | ✅ Done, committed |
| 8 | TNCSC data migration scripts | ✅ Written and verified live, **not committed** — see below |
| 9 | Packaging for resale + production go-live | ⬜ Not started |
Every phase 07 commit was verified against a **live** Odoo 19 + Postgres 16
container before being committed — not just written and assumed to work.
Run `git log --oneline` for the full commit list; each commit message
documents what was built and how it was verified, including several real
Odoo 19 API changes that don't match older docs/tutorials (see "Gotchas"
below for the index).
**No git remote is configured.** This repo only exists on this machine
right now. Before handing off to a team, push it somewhere (GitHub/GitLab/
etc.) — see "Handing off to a team" below.
## Resuming the dev environment
```bash
cd deploy
docker compose up -d
```
- Odoo: http://localhost:8069, database `communityos_dev`, login `admin` /
password `admin`.
- This dev database has been used for **all** testing across every phase —
it has real posted accounting entries in it (from Interac transaction
confirmations, membership invoices, etc.). That's *why* Phase 7's
`tncsc_deployment` module can't be installed on it directly (Odoo
correctly refuses to change a company's currency once journal entries
exist) — it was instead verified on throwaway fresh databases
(`tncsc_freshN`, all dropped after verification). A real TNCSC production
database should be a genuinely fresh install, not this dev one.
- **After any module code change**, the *running* `deploy-odoo-1` container
has a stale in-memory registry until you either upgrade the module (`-u`)
and restart the container, or just restart it:
```bash
docker compose restart odoo
```
This bit repeatedly during development — don't skip it when testing.
## Phase 8 — exactly where it was left off
All three migration scripts exist and have been verified against a **live**
Odoo instance, but **the work is not committed yet** (see "To pick this back
up" below for why).
- `scripts/_migration_common.py` — shared JSON-RPC helpers (auth, execute_kw,
CSV normalization, a `MigrationReport` class) for the migration scripts.
- `scripts/migrate_members.py` — idempotent upsert by email, dry-run flag,
tier lookup by code, report CSV.
- `scripts/migrate_students.py` — idempotent upsert of parent partner (by
email, reusing a partner created by `migrate_members.py` if one exists)
+ child partner + `community.school.student`, matched by (parent, student
name) since students don't have their own email in the source data.
Enrolls into the matching class in the current open term (matched by
`community.school.level` code) when one exists; otherwise still
creates/updates the student record and reports why no enrollment
happened.
- `scripts/migrate_opening_balances.py` — posts one `account.move` in the
Miscellaneous Operations journal, dated the last fiscal year-end
(computed from `res.company.fiscalyear_last_day/month`, or `--date`
override). Refuses to post anything if the CSV doesn't balance
(`sum(debit) != sum(credit)`) or references an unknown account code —
checked before any Odoo write happens. Idempotent via a fixed `ref`
("Opening Balances Import"): re-running finds the existing posted entry
and does nothing.
### How each was tested
Followed the same pattern as `scripts/migrate_classroom.py` (Phase 3):
dry-run first, then a real run, then a second real run to prove
idempotency — against a **fresh** database, not `communityos_dev`. See
"Why a fresh DB, not communityos_dev" below for why.
1. `docker compose up -d`, then created a throwaway DB with both product
modules and the client layer: `odoo -d tncsc_migration_test -i
community_membership,tncsc_deployment --stop-after-init` (installing
`tncsc_deployment` alone pulls in `community_school` and everything else
as dependencies). Restarted the container afterward.
2. `migrate_members.py`: synthetic CSV at
`data/raw/members_export_sample.csv` (gitignored, left in place as a
fixture) with 4 valid rows + 2 deliberately-bad rows (missing email,
unknown tier code). Dry-run matched real-run output; second real run
reported `updated` not `created` for all 4; confirmed via RPC that
exactly one `res.partner` per email existed after both runs and
`membership_state` was `active`.
3. `migrate_students.py`: needed a `community.school.term` (state=open),
`community.school.level` (code=`BEG`), and `community.school.class`
seeded first (the fresh DB has none) — also had to grant the `admin`
user the School Coordinator group, since a fresh install doesn't put
admin in it. Synthetic CSV at `data/raw/students_export_sample.csv`
with 3 valid rows (2 matching the open term's class, 1 with no
`level_code`) + 1 deliberately-bad row (missing parent). Confirmed via
RPC after 2 runs: exactly 3 `community.school.student` records, exactly
2 `community.school.enrollment` records (no duplicates), parent
partners correctly reused from step 2 instead of duplicated.
4. `migrate_opening_balances.py`: synthetic CSV at
`data/raw/opening_balances_sample.csv` using real account codes from
the fresh DB's TNCSC chart of accounts (111100 Cash, 112110 Trade AR,
322000 Retained Earnings as the balancing line). Verified the
out-of-balance guard refuses to post (tested with a deliberately
mismatched debit/credit CSV). Real run posted one balanced, posted
`account.move`; second run detected the existing entry and did nothing.
Confirmed via RPC: exactly one move, three correctly-valued lines, AR
line correctly linked to the migrated Ravi Kumar partner.
Real TNCSC member/student/trial-balance data doesn't exist yet — all three
scripts were intentionally designed against a *documented, assumed* CSV
schema (see each script's docstring) since the real export formats aren't
known yet. Confirm the assumed column names match the real exports before
relying on them, or adjust the scripts to match.
### Why a fresh DB, not `communityos_dev`
`communityos_dev` doesn't have `tncsc_deployment` installed (see the top of
this file — it can't be, since it already has posted journal entries and
Odoo refuses to change a company's currency once those exist). Testing the
Phase 8 scripts there would exercise generic `community_membership`/
`community_school` behavior, not the actual TNCSC-configured target
(TNCSC's real chart of accounts, company currency, groups). Since Phase 8's
entire purpose is rehearsing the real TNCSC data load, a fresh DB with
`tncsc_deployment` installed is the more representative target — and it
avoids adding more synthetic test data to the one long-lived dev DB used
across every other phase. `tncsc_migration_test` was **not** dropped after
this session (unlike the Phase 7 `tncsc_freshN` throwaways) — it's left in
place in case the next session wants to keep testing against it; drop it
before it's mistaken for anything resembling real data.
### To pick this back up
The scripts work and are proven idempotent, but haven't been committed —
do that first (a `feat: Phase 8 migration scripts` commit, following the
same "verified against a live container before committing" convention as
every other phase), then either wait for real TNCSC export samples to
validate the assumed CSV schemas against, or move on to Phase 9.
## Phase 9 — not started
Two sub-parts with very different risk profiles:
- **9-A (productize)**: static description pages, READMEs, CHANGELOGs,
release tagging for the product modules. Safe to do autonomously.
- **9-B (go-live)**: SSL, DNS cutover, firewall rules, backups on a **real
production server**. This needs a human in the loop with actual server
access/credentials — don't let an agent run this unattended.
## Gotchas discovered this build (Odoo 19 API drift + environment quirks)
This Odoo 19 build (`19.0-20260817`, essentially a nightly) diverges from
older tutorials/docs in several places that cost real debugging time. Each
is documented in detail in the commit message where it was found — this is
just the index so you know to search `git log` for it instead of
re-discovering it:
- `res.groups.category_id` → replaced by `privilege_id`
`res.groups.privilege.category_id` (Session 1-A commit).
- Settings pages use a new `<app>/<block>/<setting>` structure inherited
from `base.res_config_settings_view_form`, not the old raw-div layout
(Session 1-A commit).
- `ir.cron` dropped `numbercall` entirely (Session 1-B commit).
- `event.event` has no `state` field anymore (replaced by a
`stage_id`/`event.stage` kanban system) (Phase 2 commit).
- Route `type='json'` should be `type='jsonrpc'` (`'json'` still works but
is deprecated) (Phase 2 commit).
- Search-view `<group>` elements for "Group By" sections no longer accept a
`string` attribute, only `name` (Phase 3 / Phase 4 commits).
- `res.groups.users` → renamed to `user_ids` (Phase 4 commit).
- `res.config.settings` fields backed by `config_parameter=` only support
boolean/integer/float/char/selection/many2one/datetime — **not**
`Binary`. Using it on a Binary field breaks *every* Settings tab, not
just the one you're adding, since they share one model (`fix:` commit
after Phase 6).
- Anonymous visitors hitting an `auth='user'` + `website=True` route in
this specific build crash with a raw 500 instead of a clean redirect to
`/web/login` (`Request._serve_db`'s `finally: self.env = None` runs
before the website error handler tries to build the redirect). Worked
around by switching those routes to `auth='public'` with a manual
`if request.env.user._is_public(): return request.redirect(...)` check
(`fix:` commit after Phase 6 — this is the fix to reach for if you add
*new* member-only pages later, don't reintroduce `auth='user'` on a
`website=True` route without it).
- Setting `res.company.country_id` on a brand-new company auto-schedules
this build's own chart-template installer via a precommit hook, which
races an explicit `try_loading()` call made in the same install
transaction and silently reverts it afterwards. `ir.cron` code execution
is also sandboxed and forbids direct attribute assignment (`STORE_ATTR`)
— use `.write(...)`. Both fully explained in the Phase 7 commit message,
fix pattern is in `addons/tncsc_deployment/models/res_company.py`.
## Handing off to a team
1. **Push this repo to a real remote** (GitHub/GitLab/etc.) — right now it
only exists locally.
2. Point them at `CommunityOS_Implementation_Plan_for_Claude_Code.md` (the
plan) and this file (current status).
3. They'll need Docker Desktop (or equivalent) to run `deploy/docker-compose.yml`
locally — no other local dependencies.
4. If they're using Claude Code to continue: just point it at this repo and
this file. It doesn't need conversation history — everything load-bearing
is either in a commit message or in this document.

View File

@ -84,8 +84,7 @@ mode reload is enabled in `deploy/odoo.conf`).
package, and a `static/description/index.html` listing page.
See `CommunityOS_Implementation_Plan_for_Claude_Code.md` for the full,
phase-by-phase build plan, and `HANDOFF.md` for exactly where the build
currently stands, how to resume it, and known gotchas.
phase-by-phase build plan.
## CI

View File

@ -1,2 +0,0 @@
from . import models
from . import controllers

View File

@ -23,16 +23,9 @@ entitled per membership tier:
'currency': 'USD',
'depends': [
'contacts',
'website',
'portal',
'community_membership',
],
'data': [
'security/benefits_security.xml',
'security/ir.model.access.csv',
'views/benefit_views.xml',
'views/benefits_templates.xml',
],
'data': [],
'demo': [],
'images': ['static/description/banner.png'],
'application': False,

View File

@ -1 +0,0 @@
from . import main

View File

@ -1,31 +0,0 @@
from odoo import http
from odoo.http import request
def _redirect_to_login_if_public(path):
"""Manual login-required redirect - see community_classifieds for why this
is needed instead of auth='user' (a bug in this Odoo version's own
SessionExpiredException -> login-redirect handling)."""
if request.env.user._is_public():
return request.redirect(f'/web/login?redirect={path}')
return None
class BenefitsController(http.Controller):
@http.route(['/benefits'], type='http', auth='public', website=True, sitemap=True)
def benefits_directory(self, **kwargs):
centres = request.env['community.benefit.partner'].sudo().search([('active', '=', True)])
return request.render('community_benefits.benefits_directory_page', {'centres': centres})
@http.route(['/my/benefits'], type='http', auth='public', website=True)
def my_benefits(self, **kwargs):
redirect = _redirect_to_login_if_public('/my/benefits')
if redirect:
return redirect
partner = request.env.user.partner_id
benefits = request.env['community.benefit'].sudo().search(
request.env['community.benefit']._entitled_domain_for_partner(partner)
)
benefits = benefits.filtered(lambda benefit: benefit.is_entitled(partner))
return request.render('community_benefits.portal_my_benefits', {'benefits': benefits})

View File

@ -1,3 +0,0 @@
from . import benefit_partner
from . import benefit
from . import benefit_redemption

View File

@ -1,45 +0,0 @@
from odoo import fields, models
class CommunityBenefit(models.Model):
_name = 'community.benefit'
_description = 'Membership Benefit'
_order = 'name'
name = fields.Char(required=True)
benefit_partner_id = fields.Many2one('community.benefit.partner', string='Benefit Centre', required=True)
description = fields.Html()
tier_ids = fields.Many2many('community.membership.tier', string='Eligible Tiers')
discount_type = fields.Selection(
[('percent', 'Percentage'), ('amount', 'Fixed Amount'), ('perk', 'Perk (non-monetary)')],
default='percent', required=True,
)
value = fields.Float(help="Percentage or fixed amount, depending on Discount Type. Ignored for 'Perk'.")
valid_from = fields.Date()
valid_to = fields.Date()
active = fields.Boolean(default=True)
def _is_valid_today(self):
self.ensure_one()
today = fields.Date.context_today(self)
if self.valid_from and today < self.valid_from:
return False
if self.valid_to and today > self.valid_to:
return False
return True
def is_entitled(self, partner):
"""Whether the given res.partner is entitled to this benefit right now."""
self.ensure_one()
if not self.active or not self._is_valid_today():
return False
if partner.membership_state not in ('active', 'renewal_due'):
return False
return partner.membership_tier_id in self.tier_ids
@staticmethod
def _entitled_domain_for_partner(partner):
return [
('tier_ids', 'in', [partner.membership_tier_id.id]),
('active', '=', True),
]

View File

@ -1,25 +0,0 @@
from odoo import fields, models
class CommunityBenefitPartner(models.Model):
_name = 'community.benefit.partner'
_description = 'Benefit Centre'
_order = 'name'
name = fields.Char(related='partner_id.name', store=True, readonly=False)
partner_id = fields.Many2one('res.partner', required=True)
category = fields.Selection(
[
('retail', 'Retail'),
('food', 'Food & Dining'),
('services', 'Services'),
('health', 'Health & Wellness'),
('other', 'Other'),
],
default='other', required=True,
)
description = fields.Html()
logo = fields.Binary(attachment=True)
locations = fields.Text(help="Free-text addresses / areas served.")
active = fields.Boolean(default=True)
benefit_ids = fields.One2many('community.benefit', 'benefit_partner_id')

View File

@ -1,28 +0,0 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class CommunityBenefitRedemption(models.Model):
_name = 'community.benefit.redemption'
_description = 'Benefit Redemption'
_order = 'date desc'
member_id = fields.Many2one('res.partner', string='Member', required=True)
benefit_id = fields.Many2one('community.benefit', required=True)
date = fields.Datetime(default=fields.Datetime.now, required=True)
verified_by = fields.Many2one('res.users', default=lambda self: self.env.user)
notes = fields.Text()
@api.constrains('member_id', 'benefit_id')
def _check_member_entitled(self):
for redemption in self:
member = redemption.member_id
if member.membership_state not in ('active', 'renewal_due'):
raise ValidationError(
f"{member.name} does not have an active membership - reusing the same check as the "
f"membership QR verification page. Redemption refused."
)
if not redemption.benefit_id.is_entitled(member):
raise ValidationError(
f"{member.name}'s membership tier is not entitled to this benefit."
)

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_benefits" model="ir.module.category">
<field name="name">Benefits</field>
<field name="sequence">23</field>
</record>
<record id="privilege_benefits" model="res.groups.privilege">
<field name="name">Benefits</field>
<field name="category_id" ref="module_category_benefits"/>
</record>
<record id="group_benefits_manager" model="res.groups">
<field name="name">Benefits Manager</field>
<field name="privilege_id" ref="privilege_benefits"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can manage benefit centres, benefits, and log redemptions.</field>
</record>
</odoo>

View File

@ -1,4 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_community_benefit_partner_manager,community.benefit.partner manager,model_community_benefit_partner,group_benefits_manager,1,1,1,1
access_community_benefit_manager,community.benefit manager,model_community_benefit,group_benefits_manager,1,1,1,1
access_community_benefit_redemption_manager,community.benefit.redemption manager,model_community_benefit_redemption,group_benefits_manager,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
access_community_benefit_partner_manager community.benefit.partner manager model_community_benefit_partner group_benefits_manager 1 1 1 1
access_community_benefit_manager community.benefit manager model_community_benefit group_benefits_manager 1 1 1 1
access_community_benefit_redemption_manager community.benefit.redemption manager model_community_benefit_redemption group_benefits_manager 1 1 1 1

View File

@ -1 +0,0 @@
from . import test_benefits

View File

@ -1,66 +0,0 @@
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestBenefits(TransactionCase):
def setUp(self):
super().setUp()
self.tier = self.env['community.membership.tier'].create({
'name': 'Individual', 'code': 'BEN-IND', 'price': 50.0, 'period': 'annual',
})
self.other_tier = self.env['community.membership.tier'].create({
'name': 'Student', 'code': 'BEN-STU', 'price': 20.0, 'period': 'annual',
})
self.member = self.env['res.partner'].create({
'name': 'Benefit Member', 'membership_tier_id': self.tier.id,
})
self.member.action_activate_membership()
vendor_partner = self.env['res.partner'].create({'name': 'Local Cafe'})
self.centre = self.env['community.benefit.partner'].create({
'partner_id': vendor_partner.id, 'category': 'food',
})
self.benefit = self.env['community.benefit'].create({
'name': '10% off coffee',
'benefit_partner_id': self.centre.id,
'tier_ids': [(6, 0, [self.tier.id])],
'discount_type': 'percent',
'value': 10.0,
})
def test_entitlement_resolves_by_tier(self):
self.assertTrue(self.benefit.is_entitled(self.member))
other_member = self.env['res.partner'].create({
'name': 'Other Member', 'membership_tier_id': self.other_tier.id,
})
other_member.action_activate_membership()
self.assertFalse(self.benefit.is_entitled(other_member), "Wrong tier should not be entitled")
def test_redemption_logs_for_active_member(self):
redemption = self.env['community.benefit.redemption'].create({
'member_id': self.member.id, 'benefit_id': self.benefit.id,
})
self.assertTrue(redemption)
def test_redemption_blocked_for_inactive_member(self):
inactive_member = self.env['res.partner'].create({
'name': 'Inactive Member', 'membership_tier_id': self.tier.id,
})
# Never activated - membership_state stays 'none'.
with self.assertRaises(ValidationError):
self.env['community.benefit.redemption'].create({
'member_id': inactive_member.id, 'benefit_id': self.benefit.id,
})
def test_redemption_blocked_for_wrong_tier(self):
other_member = self.env['res.partner'].create({
'name': 'Wrong Tier Member', 'membership_tier_id': self.other_tier.id,
})
other_member.action_activate_membership()
with self.assertRaises(ValidationError):
self.env['community.benefit.redemption'].create({
'member_id': other_member.id, 'benefit_id': self.benefit.id,
})

View File

@ -1,114 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_benefit_partner_list" model="ir.ui.view">
<field name="name">community.benefit.partner.list</field>
<field name="model">community.benefit.partner</field>
<field name="arch" type="xml">
<list string="Benefit Centres">
<field name="name"/>
<field name="category"/>
<field name="active" column_invisible="1"/>
</list>
</field>
</record>
<record id="view_benefit_partner_form" model="ir.ui.view">
<field name="name">community.benefit.partner.form</field>
<field name="model">community.benefit.partner</field>
<field name="arch" type="xml">
<form string="Benefit Centre">
<sheet>
<div class="oe_title"><h1><field name="partner_id"/></h1></div>
<group>
<field name="category"/>
<field name="locations"/>
<field name="active"/>
</group>
<group string="Description"><field name="description" nolabel="1"/></group>
<notebook>
<page string="Benefits">
<field name="benefit_ids">
<list><field name="name"/><field name="discount_type"/><field name="value"/></list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_benefit_partner" model="ir.actions.act_window">
<field name="name">Benefit Centres</field>
<field name="res_model">community.benefit.partner</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_benefit_list" model="ir.ui.view">
<field name="name">community.benefit.list</field>
<field name="model">community.benefit</field>
<field name="arch" type="xml">
<list string="Benefits">
<field name="name"/>
<field name="benefit_partner_id"/>
<field name="discount_type"/>
<field name="value"/>
<field name="active" column_invisible="1"/>
</list>
</field>
</record>
<record id="view_benefit_form" model="ir.ui.view">
<field name="name">community.benefit.form</field>
<field name="model">community.benefit</field>
<field name="arch" type="xml">
<form string="Benefit">
<sheet>
<div class="oe_title"><h1><field name="name"/></h1></div>
<group>
<group>
<field name="benefit_partner_id"/>
<field name="tier_ids" widget="many2many_tags"/>
<field name="active"/>
</group>
<group>
<field name="discount_type"/>
<field name="value" invisible="discount_type == 'perk'"/>
<field name="valid_from"/>
<field name="valid_to"/>
</group>
</group>
<group string="Description"><field name="description" nolabel="1"/></group>
</sheet>
</form>
</field>
</record>
<record id="action_benefit" model="ir.actions.act_window">
<field name="name">Benefits</field>
<field name="res_model">community.benefit</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_benefit_redemption_list" model="ir.ui.view">
<field name="name">community.benefit.redemption.list</field>
<field name="model">community.benefit.redemption</field>
<field name="arch" type="xml">
<list string="Redemptions">
<field name="date"/>
<field name="member_id"/>
<field name="benefit_id"/>
<field name="verified_by"/>
</list>
</field>
</record>
<record id="action_benefit_redemption" model="ir.actions.act_window">
<field name="name">Redemptions</field>
<field name="res_model">community.benefit.redemption</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_benefits_root" name="Benefits" sequence="28"
groups="community_benefits.group_benefits_manager"/>
<menuitem id="menu_benefits_redemptions" name="Redemptions"
parent="menu_benefits_root" action="action_benefit_redemption" sequence="10"/>
<menuitem id="menu_benefits_list" name="Benefits"
parent="menu_benefits_root" action="action_benefit" sequence="20"/>
<menuitem id="menu_benefit_partners" name="Benefit Centres"
parent="menu_benefits_root" action="action_benefit_partner" sequence="30"/>
</odoo>

View File

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="benefits_directory_page" name="Benefit Centres Directory">
<t t-call="website.layout">
<div class="container" style="margin-top: 24px; margin-bottom: 60px;">
<h2>Participating Benefit Centres</h2>
<div class="row">
<t t-foreach="centres" t-as="centre">
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title" t-out="centre.name"/>
<p class="text-muted" t-out="dict(centre._fields['category'].selection).get(centre.category)"/>
<p t-out="centre.locations or ''"/>
</div>
</div>
</div>
</t>
<t t-if="not centres">
<p class="text-muted">No participating benefit centres yet.</p>
</t>
</div>
</div>
</t>
</template>
<template id="portal_my_benefits" name="My Benefits">
<t t-call="portal.portal_layout">
<div class="o_portal_my_doc_table">
<h3>My Benefits</h3>
<t t-if="not benefits">
<p class="alert alert-info">No benefits available for your current membership tier.</p>
</t>
<table class="table" t-if="benefits">
<thead><tr><th>Benefit</th><th>Centre</th><th>Value</th></tr></thead>
<tbody>
<t t-foreach="benefits" t-as="benefit">
<tr>
<td t-out="benefit.name"/>
<td t-out="benefit.benefit_partner_id.name"/>
<td>
<t t-if="benefit.discount_type == 'percent'"><t t-out="benefit.value"/>%</t>
<t t-elif="benefit.discount_type == 'amount'"><t t-out="benefit.value"/></t>
<t t-else="">Perk</t>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</template>
</odoo>

View File

@ -1,2 +0,0 @@
from . import models
from . import controllers

View File

@ -25,15 +25,7 @@ Soft-detects Community OS Membership; runs standalone without it.
'website',
'portal',
],
'data': [
'security/classifieds_security.xml',
'security/ir.model.access.csv',
'data/mail_templates.xml',
'data/ir_cron.xml',
'views/classified_views.xml',
'views/res_config_settings_views.xml',
'views/classifieds_templates.xml',
],
'data': [],
'demo': [],
'images': ['static/description/banner.png'],
'application': False,

View File

@ -1 +0,0 @@
from . import main

View File

@ -1,109 +0,0 @@
import base64
from odoo import http
from odoo.http import request
MAX_IMAGES = 3
def _is_module_installed(env, module_name):
return bool(env['ir.module.module'].sudo().search_count(
[('name', '=', module_name), ('state', '=', 'installed')]
))
def _has_active_membership(partner):
"""Soft-check: only meaningful if community_membership is installed."""
if 'membership_state' not in partner._fields:
return True
return partner.membership_state in ('active', 'renewal_due')
def _redirect_to_login_if_public(path):
"""Manual login-required redirect.
Routes here use auth='public' (not 'user') and check this explicitly,
because Odoo's own auth='user' + website=True error handling has a bug
in this version: SessionExpiredException triggers a login redirect via
self.env['ir.http']._redirect(...), but self.env has already been reset
to None by the finally block in Request._serve_db by the time the error
handler runs, causing a 500 instead of a redirect. Checking auth
ourselves and issuing a plain redirect avoids that code path entirely.
"""
if request.env.user._is_public():
return request.redirect(f'/web/login?redirect={path}')
return None
class ClassifiedsController(http.Controller):
@http.route(['/classifieds'], type='http', auth='public', website=True, sitemap=True)
def classifieds_list(self, category=None, **kwargs):
domain = [('state', '=', 'published')]
if category:
domain.append(('category', '=', category))
listings = request.env['community.classified'].sudo().search(domain)
return request.render('community_classifieds.classifieds_list_page', {
'listings': listings,
'category': category,
})
@http.route(['/classifieds/<int:classified_id>'], type='http', auth='public', website=True, sitemap=False)
def classifieds_detail(self, classified_id, **kwargs):
listing = request.env['community.classified'].sudo().browse(classified_id)
if not listing.exists() or listing.state != 'published':
return request.not_found()
listing._increment_view_count()
return request.render('community_classifieds.classifieds_detail_page', {'listing': listing})
@http.route(['/classifieds/new'], type='http', auth='public', website=True)
def classifieds_new(self, **kwargs):
redirect = _redirect_to_login_if_public('/classifieds/new')
if redirect:
return redirect
partner = request.env.user.partner_id
if _is_module_installed(request.env, 'community_membership') and not _has_active_membership(partner):
return request.render('community_classifieds.classifieds_membership_required', {})
if request.httprequest.method == 'POST':
image_ids = []
for field_name in ('image1', 'image2', 'image3'):
upload = kwargs.get(field_name)
if upload and getattr(upload, 'filename', None):
image_ids.append((0, 0, {'image': base64.b64encode(upload.read())}))
listing = request.env['community.classified'].sudo().create({
'title': kwargs.get('title', '').strip(),
'category': kwargs.get('category', 'other'),
'description': kwargs.get('description', ''),
'contact_method': kwargs.get('contact_method', 'email'),
'contact_email': kwargs.get('contact_email', '').strip(),
'contact_phone': kwargs.get('contact_phone', '').strip(),
'poster_partner_id': partner.id,
'image_ids': image_ids[:MAX_IMAGES],
})
return request.redirect(f'/classifieds/my?posted={listing.id}')
return request.render('community_classifieds.classifieds_new_page', {})
@http.route(['/classifieds/my'], type='http', auth='public', website=True)
def classifieds_my(self, **kwargs):
redirect = _redirect_to_login_if_public('/classifieds/my')
if redirect:
return redirect
partner = request.env.user.partner_id
listings = request.env['community.classified'].sudo().search([('poster_partner_id', '=', partner.id)])
return request.render('community_classifieds.classifieds_my_page', {'listings': listings})
@http.route(['/classifieds/<int:classified_id>/renew'], type='http', auth='public', website=True)
def classifieds_renew(self, classified_id, **kwargs):
redirect = _redirect_to_login_if_public(f'/classifieds/{classified_id}/renew')
if redirect:
return redirect
partner = request.env.user.partner_id
listing = request.env['community.classified'].sudo().search([
('id', '=', classified_id), ('poster_partner_id', '=', partner.id),
], limit=1)
if listing:
listing.action_renew()
return request.redirect('/classifieds/my')

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_classifieds_expire" model="ir.cron">
<field name="name">Classifieds: Expire Listings</field>
<field name="model_id" ref="model_community_classified"/>
<field name="state">code</field>
<field name="code">model._cron_expire_listings()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<record id="ir_cron_classifieds_expiry_warning" model="ir.cron">
<field name="name">Classifieds: Send Expiry Warnings</field>
<field name="model_id" ref="model_community_classified"/>
<field name="state">code</field>
<field name="code">model._cron_send_expiry_warnings()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_new_submission" model="mail.template">
<field name="name">Classifieds: New Submission</field>
<field name="model_id" ref="model_community_classified"/>
<field name="subject">New classified pending review: {{ object.title }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p>A new classified listing is pending review:</p>
<p><strong t-out="object.title"/></p>
<p>Posted by: <t t-out="object.poster_partner_id.name or ''"/></p>
</div>
</field>
</record>
<record id="mail_template_expiry_warning" model="mail.template">
<field name="name">Classifieds: Expiry Warning</field>
<field name="model_id" ref="model_community_classified"/>
<field name="subject">Your classified "{{ object.title }}" expires soon</field>
<field name="partner_to">{{ object.poster_partner_id.id }}</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.poster_partner_id.name or ''">Member</t>,</p>
<p>
Your classified listing "<t t-out="object.title"/>" expires on
<t t-out="format_date(object.expiry_date)"/>. Renew it from your portal to keep it visible.
</p>
</div>
</field>
</record>
</data>
</odoo>

View File

@ -1,2 +0,0 @@
from . import community_classified
from . import res_config_settings

View File

@ -1,133 +0,0 @@
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.exceptions import ValidationError
DEFAULT_EXPIRY_DAYS = 30
DEFAULT_WARNING_DAYS_BEFORE_EXPIRY = 7
MAX_IMAGES = 3
class CommunityClassified(models.Model):
_name = 'community.classified'
_description = 'Classified Listing'
_order = 'post_date desc'
title = fields.Char(required=True)
category = fields.Selection(
[
('for_sale', 'For Sale'),
('housing', 'Housing'),
('services', 'Services'),
('jobs', 'Jobs'),
('other', 'Other'),
],
default='other', required=True,
)
description = fields.Html()
image_ids = fields.One2many('community.classified.image', 'classified_id')
contact_method = fields.Selection(
[('email', 'Email'), ('phone', 'Phone'), ('both', 'Both')], default='email', required=True,
)
contact_email = fields.Char()
contact_phone = fields.Char()
poster_partner_id = fields.Many2one('res.partner', string='Posted By', readonly=True)
post_date = fields.Datetime(default=fields.Datetime.now, readonly=True)
expiry_date = fields.Date(readonly=True)
state = fields.Selection(
[
('pending_review', 'Pending Review'),
('published', 'Published'),
('expired', 'Expired'),
('rejected', 'Rejected'),
],
default='pending_review', required=True,
)
admin_notes = fields.Text()
view_count = fields.Integer(default=0, readonly=True)
@api.constrains('image_ids')
def _check_max_images(self):
for record in self:
if len(record.image_ids) > MAX_IMAGES:
raise ValidationError(f"A classified may have at most {MAX_IMAGES} images.")
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('expiry_date'):
vals['expiry_date'] = self._compute_default_expiry_date()
records = super().create(vals_list)
records._notify_moderators_new_submission()
return records
@api.model
def _get_expiry_days(self):
return int(self.env['ir.config_parameter'].sudo().get_param(
'community_classifieds.expiry_days', DEFAULT_EXPIRY_DAYS
))
@api.model
def _compute_default_expiry_date(self):
today = fields.Date.context_today(self)
return today + relativedelta(days=self._get_expiry_days())
def action_publish(self):
for record in self:
record.write({'state': 'published', 'expiry_date': record._compute_default_expiry_date()})
return True
def action_reject(self):
self.write({'state': 'rejected'})
return True
def action_renew(self):
for record in self:
record.write({
'state': 'published',
'expiry_date': record._compute_default_expiry_date(),
})
return True
def _increment_view_count(self):
self.sudo().write({'view_count': self.view_count + 1})
def _notify_moderators_new_submission(self):
template = self.env.ref('community_classifieds.mail_template_new_submission', raise_if_not_found=False)
if not template:
return
moderators = self.env.ref('community_classifieds.group_classifieds_moderator').user_ids
for record in self:
for moderator in moderators:
if moderator.partner_id:
template.send_mail(record.id, force_send=False, email_values={
'recipient_ids': [(4, moderator.partner_id.id)],
})
@api.model
def _cron_expire_listings(self):
today = fields.Date.context_today(self)
expired = self.search([('state', '=', 'published'), ('expiry_date', '<', today)])
expired.write({'state': 'expired'})
return True
@api.model
def _cron_send_expiry_warnings(self):
today = fields.Date.context_today(self)
warning_date = today + relativedelta(days=DEFAULT_WARNING_DAYS_BEFORE_EXPIRY)
soon_to_expire = self.search([('state', '=', 'published'), ('expiry_date', '=', warning_date)])
template = self.env.ref('community_classifieds.mail_template_expiry_warning', raise_if_not_found=False)
if template:
for record in soon_to_expire:
template.send_mail(record.id, force_send=False)
return True
class CommunityClassifiedImage(models.Model):
_name = 'community.classified.image'
_description = 'Classified Listing Image'
_order = 'sequence, id'
classified_id = fields.Many2one('community.classified', required=True, ondelete='cascade')
sequence = fields.Integer(default=10)
image = fields.Binary(required=True, attachment=True)

View File

@ -1,12 +0,0 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
classifieds_expiry_days = fields.Integer(
string='Listing Duration (days)',
config_parameter='community_classifieds.expiry_days',
default=30,
help="Number of days a published classified listing stays active before it expires.",
)

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_classifieds" model="ir.module.category">
<field name="name">Classifieds</field>
<field name="sequence">22</field>
</record>
<record id="privilege_classifieds" model="res.groups.privilege">
<field name="name">Classifieds</field>
<field name="category_id" ref="module_category_classifieds"/>
</record>
<record id="group_classifieds_moderator" model="res.groups">
<field name="name">Classifieds Moderator</field>
<field name="privilege_id" ref="privilege_classifieds"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can review, publish, and reject classified listings.</field>
</record>
</odoo>

View File

@ -1,3 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_community_classified_moderator,community.classified moderator,model_community_classified,group_classifieds_moderator,1,1,1,1
access_community_classified_image_moderator,community.classified.image moderator,model_community_classified_image,group_classifieds_moderator,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
access_community_classified_moderator community.classified moderator model_community_classified group_classifieds_moderator 1 1 1 1
access_community_classified_image_moderator community.classified.image moderator model_community_classified_image group_classifieds_moderator 1 1 1 1

View File

@ -1 +0,0 @@
from . import test_classifieds

View File

@ -1,79 +0,0 @@
from datetime import timedelta
from odoo import fields
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestClassifieds(TransactionCase):
def setUp(self):
super().setUp()
self.poster = self.env['res.partner'].create({'name': 'Classified Poster'})
def test_new_listing_is_pending_review(self):
listing = self.env['community.classified'].create({
'title': 'Old Bicycle', 'poster_partner_id': self.poster.id,
})
self.assertEqual(listing.state, 'pending_review')
self.assertTrue(listing.expiry_date)
def test_publish_makes_listing_visible(self):
listing = self.env['community.classified'].create({
'title': 'Piano Lessons', 'poster_partner_id': self.poster.id,
})
listing.action_publish()
self.assertEqual(listing.state, 'published')
visible = self.env['community.classified'].search([('state', '=', 'published')])
self.assertIn(listing, visible)
def test_reject_listing(self):
listing = self.env['community.classified'].create({
'title': 'Spam Listing', 'poster_partner_id': self.poster.id,
})
listing.action_reject()
self.assertEqual(listing.state, 'rejected')
def test_expiry_cron_archives_past_due_listings(self):
listing = self.env['community.classified'].create({
'title': 'Expiring Soon', 'poster_partner_id': self.poster.id,
})
listing.action_publish()
listing.expiry_date = fields.Date.today() - timedelta(days=1)
self.env['community.classified']._cron_expire_listings()
self.assertEqual(listing.state, 'expired')
def test_renew_resets_expiry_and_republishes(self):
listing = self.env['community.classified'].create({
'title': 'Renew Me', 'poster_partner_id': self.poster.id,
})
listing.action_publish()
listing.expiry_date = fields.Date.today() - timedelta(days=1)
self.env['community.classified']._cron_expire_listings()
self.assertEqual(listing.state, 'expired')
listing.action_renew()
self.assertEqual(listing.state, 'published')
self.assertGreater(listing.expiry_date, fields.Date.today())
def test_max_three_images(self):
listing = self.env['community.classified'].create({
'title': 'Many Photos', 'poster_partner_id': self.poster.id,
})
tiny_png = b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
with self.assertRaises(ValidationError):
listing.write({
'image_ids': [(0, 0, {'image': tiny_png}) for _ in range(4)],
})
def test_configurable_expiry_days(self):
self.env['ir.config_parameter'].sudo().set_param('community_classifieds.expiry_days', '5')
listing = self.env['community.classified'].create({
'title': 'Short Lived', 'poster_partner_id': self.poster.id,
})
expected = fields.Date.today() + timedelta(days=5)
self.assertEqual(listing.expiry_date, expected)

View File

@ -1,85 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_classified_list" model="ir.ui.view">
<field name="name">community.classified.list</field>
<field name="model">community.classified</field>
<field name="arch" type="xml">
<list string="Classifieds">
<field name="title"/>
<field name="category"/>
<field name="poster_partner_id"/>
<field name="post_date"/>
<field name="expiry_date"/>
<field name="state" decoration-warning="state == 'pending_review'"/>
</list>
</field>
</record>
<record id="view_classified_form" model="ir.ui.view">
<field name="name">community.classified.form</field>
<field name="model">community.classified</field>
<field name="arch" type="xml">
<form string="Classified">
<header>
<button name="action_publish" type="object" string="Publish" class="btn-primary"
invisible="state == 'published'"/>
<button name="action_reject" type="object" string="Reject"
invisible="state == 'rejected'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title"><h1><field name="title"/></h1></div>
<group>
<group>
<field name="category"/>
<field name="poster_partner_id"/>
<field name="contact_method"/>
<field name="contact_email"/>
<field name="contact_phone"/>
</group>
<group>
<field name="post_date"/>
<field name="expiry_date"/>
<field name="view_count"/>
</group>
</group>
<group string="Description">
<field name="description" nolabel="1"/>
</group>
<group string="Moderation Notes">
<field name="admin_notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_classified_search" model="ir.ui.view">
<field name="name">community.classified.search</field>
<field name="model">community.classified</field>
<field name="arch" type="xml">
<search string="Classifieds">
<field name="title"/>
<filter string="Pending Review" name="pending_review" domain="[('state', '=', 'pending_review')]"/>
<filter string="Published" name="published" domain="[('state', '=', 'published')]"/>
<group name="group_by">
<filter string="Category" name="group_by_category" context="{'group_by': 'category'}"/>
<filter string="Status" name="group_by_state" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_classified_moderation" model="ir.actions.act_window">
<field name="name">Classifieds Moderation</field>
<field name="res_model">community.classified</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_classified_search"/>
<field name="context">{'search_default_pending_review': 1}</field>
</record>
<menuitem id="menu_classifieds_root" name="Classifieds" sequence="27"
groups="community_classifieds.group_classifieds_moderator"/>
<menuitem id="menu_classifieds_moderation" name="Moderation"
parent="menu_classifieds_root" action="action_classified_moderation" sequence="10"/>
</odoo>

View File

@ -1,134 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="classifieds_list_page" name="Classifieds">
<t t-call="website.layout">
<div class="container" style="margin-top: 24px; margin-bottom: 60px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Classifieds</h2>
<a href="/classifieds/new" class="btn btn-primary">Post a Listing</a>
</div>
<div class="row">
<t t-foreach="listings" t-as="listing">
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<a t-attf-href="/classifieds/#{listing.id}" t-out="listing.title"/>
</h5>
<p class="card-text text-muted" t-out="dict(listing._fields['category'].selection).get(listing.category)"/>
</div>
</div>
</div>
</t>
<t t-if="not listings">
<p class="text-muted">No listings yet.</p>
</t>
</div>
</div>
</t>
</template>
<template id="classifieds_detail_page" name="Classified Detail">
<t t-call="website.layout">
<div class="container" style="max-width: 640px; margin-top: 24px; margin-bottom: 60px;">
<h2 t-out="listing.title"/>
<p class="text-muted" t-out="dict(listing._fields['category'].selection).get(listing.category)"/>
<div t-out="listing.description"/>
<t t-foreach="listing.image_ids" t-as="img">
<img t-attf-src="/web/image/community.classified.image/#{img.id}/image" style="max-width: 100%; margin-bottom: 8px;" alt="Listing image"/>
</t>
<hr/>
<p t-if="listing.contact_method in ('email', 'both') and listing.contact_email">
Email: <span t-out="listing.contact_email"/>
</p>
<p t-if="listing.contact_method in ('phone', 'both') and listing.contact_phone">
Phone: <span t-out="listing.contact_phone"/>
</p>
</div>
</t>
</template>
<template id="classifieds_new_page" name="Post a Classified">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;">
<h2>Post a Listing</h2>
<form method="POST" enctype="multipart/form-data" t-attf-action="/classifieds/new">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label">Title</label>
<input type="text" class="form-control" name="title" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<select class="form-select" name="category">
<option value="for_sale">For Sale</option>
<option value="housing">Housing</option>
<option value="services">Services</option>
<option value="jobs">Jobs</option>
<option value="other" selected="selected">Other</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="4"/>
</div>
<div class="mb-3">
<label class="form-label">Contact Method</label>
<select class="form-select" name="contact_method">
<option value="email" selected="selected">Email</option>
<option value="phone">Phone</option>
<option value="both">Both</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Contact Email</label>
<input type="email" class="form-control" name="contact_email"/>
</div>
<div class="mb-3">
<label class="form-label">Contact Phone</label>
<input type="text" class="form-control" name="contact_phone"/>
</div>
<div class="mb-3">
<label class="form-label">Images (up to 3)</label>
<input type="file" class="form-control mb-1" name="image1" accept="image/*"/>
<input type="file" class="form-control mb-1" name="image2" accept="image/*"/>
<input type="file" class="form-control" name="image3" accept="image/*"/>
</div>
<button type="submit" class="btn btn-primary">Submit for Review</button>
</form>
</div>
</t>
</template>
<template id="classifieds_my_page" name="My Classifieds">
<t t-call="portal.portal_layout">
<div class="o_portal_my_doc_table">
<h3>My Classifieds</h3>
<table class="table">
<thead><tr><th>Title</th><th>Status</th><th>Expiry</th><th/></tr></thead>
<tbody>
<t t-foreach="listings" t-as="listing">
<tr>
<td t-out="listing.title"/>
<td t-out="dict(listing._fields['state'].selection).get(listing.state)"/>
<td t-out="listing.expiry_date or ''"/>
<td>
<a t-if="listing.state in ('published', 'expired')"
t-attf-href="/classifieds/#{listing.id}/renew" class="btn btn-sm btn-secondary">Renew</a>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</template>
<template id="classifieds_membership_required" name="Classifieds: Membership Required">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 60px;">
<p class="alert alert-warning">You need an active membership to post a classified listing.</p>
</div>
</t>
</template>
</odoo>

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_classifieds" model="ir.ui.view">
<field name="name">res.config.settings.view.form.classifieds</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="Classifieds" string="Classifieds" name="community_classifieds"
groups="community_classifieds.group_classifieds_moderator">
<block title="Classifieds" id="classifieds_settings">
<setting id="classifieds_expiry_days_setting" string="Listing Duration"
help="Days a published listing stays active before it expires">
<field name="classifieds_expiry_days"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@ -1,2 +0,0 @@
from . import models
from . import controllers

View File

@ -27,15 +27,7 @@ Sellable to any Canadian organization accepting Interac e-Transfer.
'payment',
'account',
],
'data': [
'security/interac_security.xml',
'views/payment_interac_templates.xml',
'views/payment_transaction_views.xml',
'data/payment_method_data.xml',
'data/payment_provider_data.xml',
'data/mail_templates.xml',
'data/ir_cron.xml',
],
'data': [],
'demo': [],
'images': ['static/description/banner.png'],
'application': False,

View File

@ -1,3 +0,0 @@
DEFAULT_PAYMENT_METHOD_CODES = {
'interac',
}

View File

@ -1 +0,0 @@
from . import main

View File

@ -1,15 +0,0 @@
from odoo.http import Controller, request, route
from odoo.addons.payment.logging import get_payment_logger
_logger = get_payment_logger(__name__)
class InteracController(Controller):
_process_url = '/payment/interac/process'
@route(_process_url, type='http', auth='public', methods=['POST'], csrf=False)
def interac_process_transaction(self, **post):
_logger.info("Handling Interac processing with reference %s", post.get('reference'))
request.env['payment.transaction'].sudo()._process('interac', post)
return request.redirect('/payment/status')

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_interac_auto_cancel" model="ir.cron">
<field name="name">Interac: Auto-cancel Unconfirmed Payments</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="state">code</field>
<field name="code">model._cron_auto_cancel_interac()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_interac_instructions" model="mail.template">
<field name="name">Interac: Payment Instructions</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="subject">{{ object.company_id.name }}: Interac e-Transfer instructions</field>
<field name="partner_to">{{ object.partner_id.id }}</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.partner_id.name or ''">Customer</t>,</p>
<p>To complete your order with <t t-out="object.company_id.name or ''"/>, please send an Interac e-Transfer:</p>
<ul>
<li><strong>Send to:</strong> <t t-out="object.provider_id.interac_recipient_email or ''"/></li>
<li><strong>Amount:</strong> <t t-out="format_amount(object.amount, object.currency_id)"/></li>
<li><strong>Reference code (use as the e-transfer message/security question if possible):</strong>
<t t-out="object.reference"/></li>
<li><strong>Please send within:</strong> <t t-out="object.provider_id.interac_deadline_hours"/> hours,
or the order will be automatically cancelled.</li>
</ul>
</div>
</field>
</record>
<record id="mail_template_interac_cancelled" model="mail.template">
<field name="name">Interac: Payment Cancelled</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="subject">{{ object.company_id.name }}: Interac e-Transfer window expired</field>
<field name="partner_to">{{ object.partner_id.id }}</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.partner_id.name or ''">Customer</t>,</p>
<p>
We did not receive confirmation of your Interac e-Transfer for order reference
<t t-out="object.reference"/> within the payment window, so it has been cancelled.
Please place your order again if you would still like to proceed.
</p>
</div>
</field>
</record>
</data>
</odoo>

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment_method_interac" model="payment.method">
<field name="name">Interac e-Transfer</field>
<field name="code">interac</field>
<field name="sequence">1001</field>
<field name="active">False</field>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_manual_capture">none</field>
<field name="support_refund">none</field>
</record>
</odoo>

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment_provider_interac" model="payment.provider">
<field name="name">Interac e-Transfer</field>
<field name="code">interac</field>
<field name="state">disabled</field>
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="pending_msg" type="html">
<p>Your order will be confirmed once we receive your Interac e-Transfer. Please
check your email for payment instructions, including the recipient address, amount,
and reference code to use.</p>
</field>
<field name="payment_method_ids" eval="[Command.set([ref('community_interac.payment_method_interac')])]"/>
</record>
</odoo>

View File

@ -1,2 +0,0 @@
from . import payment_provider
from . import payment_transaction

View File

@ -1,27 +0,0 @@
from odoo import fields, models
from odoo.addons.community_interac import const
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
code = fields.Selection(
selection_add=[('interac', "Interac e-Transfer")], ondelete={'interac': 'set default'},
)
interac_recipient_email = fields.Char(
string='Recipient e-Transfer Email',
help="The e-transfer address customers should send payment to. Never hardcode this - it is "
"per-deployment configuration, set here or by the deployment layer.",
required_if_provider='interac',
)
interac_deadline_hours = fields.Integer(
string='Payment Deadline (hours)', default=48,
help="Pending Interac transactions not confirmed within this many hours are auto-cancelled.",
)
def _get_default_payment_method_codes(self):
self.ensure_one()
if self.code != 'interac':
return super()._get_default_payment_method_codes()
return const.DEFAULT_PAYMENT_METHOD_CODES

View File

@ -1,61 +0,0 @@
from odoo import fields, models
from odoo.addons.payment.logging import get_payment_logger
from odoo.addons.community_interac.controllers.main import InteracController
_logger = get_payment_logger(__name__)
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
def _get_specific_rendering_values(self, processing_values):
if self.provider_code != 'interac':
return super()._get_specific_rendering_values(processing_values)
return {
'api_url': InteracController._process_url,
'reference': self.reference,
}
def _extract_amount_data(self, payment_data):
"""Override of `payment` to skip amount validation - there is no external gateway response."""
if self.provider_code != 'interac':
return super()._extract_amount_data(payment_data)
return None
def _apply_updates(self, payment_data):
if self.provider_code != 'interac':
return super()._apply_updates(payment_data)
_logger.info("Interac transaction %s selected by customer: set as pending.", self.reference)
self._set_pending()
self._send_interac_instructions_email()
def _send_interac_instructions_email(self):
self.ensure_one()
template = self.env.ref('community_interac.mail_template_interac_instructions', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=False)
def _send_interac_cancel_email(self):
self.ensure_one()
template = self.env.ref('community_interac.mail_template_interac_cancelled', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=False)
def action_confirm_interac_payment(self):
"""One-click admin/treasurer confirmation that the e-transfer was received."""
for tx in self:
if tx.provider_code == 'interac' and tx.state == 'pending':
tx._set_done()
return True
def _cron_auto_cancel_interac(self):
pending_interac = self.search([('provider_code', '=', 'interac'), ('state', '=', 'pending')])
for tx in pending_interac:
deadline_hours = tx.provider_id.interac_deadline_hours or 48
elapsed_hours = (fields.Datetime.now() - tx.last_state_change).total_seconds() / 3600.0
if elapsed_hours >= deadline_hours:
tx._set_canceled(state_message="Auto-cancelled: Interac payment not confirmed within the deadline.")
tx._send_interac_cancel_email()
return True

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_interac" model="ir.module.category">
<field name="name">Interac Payments</field>
<field name="sequence">24</field>
</record>
<record id="privilege_interac" model="res.groups.privilege">
<field name="name">Interac Payments</field>
<field name="category_id" ref="module_category_interac"/>
</record>
<record id="group_interac_verifier" model="res.groups">
<field name="name">Interac Payment Verifier</field>
<field name="privilege_id" ref="privilege_interac"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can view pending Interac e-Transfers and confirm receipt.</field>
</record>
</odoo>

View File

@ -1 +0,0 @@
from . import test_interac

View File

@ -1,69 +0,0 @@
from datetime import timedelta
from odoo import fields
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestInterac(TransactionCase):
def setUp(self):
super().setUp()
self.provider = self.env.ref('community_interac.payment_provider_interac')
self.provider.write({
'interac_recipient_email': 'payments@example.org',
'state': 'test',
'interac_deadline_hours': 48,
})
self.method = self.env.ref('community_interac.payment_method_interac')
self.partner = self.env['res.partner'].create({'name': 'Interac Payer', 'email': 'payer@example.com'})
def _create_transaction(self):
return self.env['payment.transaction'].create({
'provider_id': self.provider.id,
'payment_method_id': self.method.id,
'amount': 100.0,
'currency_id': self.env.company.currency_id.id,
'partner_id': self.partner.id,
'reference': self.env['payment.transaction']._compute_reference('interac'),
})
def test_select_sets_pending_and_sends_instructions(self):
tx = self._create_transaction()
mail_count_before = self.env['mail.mail'].search_count([])
tx._apply_updates({})
self.assertEqual(tx.state, 'pending')
mail_count_after = self.env['mail.mail'].search_count([])
self.assertGreater(mail_count_after, mail_count_before)
def test_confirm_sets_done(self):
tx = self._create_transaction()
tx._apply_updates({})
self.assertEqual(tx.state, 'pending')
tx.action_confirm_interac_payment()
self.assertEqual(tx.state, 'done')
def test_auto_cancel_after_deadline(self):
tx = self._create_transaction()
tx._apply_updates({})
tx.last_state_change = fields.Datetime.now() - timedelta(hours=49)
mail_count_before = self.env['mail.mail'].search_count([])
self.env['payment.transaction']._cron_auto_cancel_interac()
self.assertEqual(tx.state, 'cancel')
mail_count_after = self.env['mail.mail'].search_count([])
self.assertGreater(mail_count_after, mail_count_before)
def test_not_yet_due_is_not_cancelled(self):
tx = self._create_transaction()
tx._apply_updates({})
tx.last_state_change = fields.Datetime.now() - timedelta(hours=1)
self.env['payment.transaction']._cron_auto_cancel_interac()
self.assertEqual(tx.state, 'pending')

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="redirect_form">
<form t-att-action="api_url" method="post">
<input type="hidden" name="reference" t-att-value="reference"/>
</form>
</template>
</odoo>

View File

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_payment_transaction_list_interac" model="ir.ui.view">
<field name="name">payment.transaction.list.interac.pending</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<list string="Pending Interac Payments">
<field name="reference"/>
<field name="partner_id"/>
<field name="amount" widget="monetary"/>
<field name="currency_id" column_invisible="1"/>
<field name="create_date"/>
<field name="last_state_change"/>
<button name="action_confirm_interac_payment" type="object"
string="Payment Received" class="btn-primary"/>
</list>
</field>
</record>
<record id="action_payment_transaction_interac_pending" model="ir.actions.act_window">
<field name="name">Pending Interac Payments</field>
<field name="res_model">payment.transaction</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_payment_transaction_list_interac"/>
<field name="domain">[('provider_code', '=', 'interac'), ('state', '=', 'pending')]</field>
</record>
<menuitem id="menu_interac_root" name="Interac Payments" sequence="29"
groups="community_interac.group_interac_verifier"/>
<menuitem id="menu_interac_pending" name="Pending Payments"
parent="menu_interac_root" action="action_payment_transaction_interac_pending" sequence="10"/>
</odoo>

View File

@ -3,15 +3,6 @@ from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.http import request
def _redirect_to_login_if_public(path):
"""Manual login-required redirect - see community_classifieds for why this
is needed instead of auth='user' (a bug in this Odoo version's own
SessionExpiredException -> login-redirect handling)."""
if request.env.user._is_public():
return request.redirect(f'/web/login?redirect={path}')
return None
class MembershipPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
@ -21,22 +12,16 @@ class MembershipPortal(CustomerPortal):
values['membership_count'] = 1 if partner.membership_state != 'none' else 0
return values
@http.route(['/my/membership'], type='http', auth='public', website=True)
@http.route(['/my/membership'], type='http', auth='user', website=True)
def portal_my_membership(self, **kwargs):
redirect = _redirect_to_login_if_public('/my/membership')
if redirect:
return redirect
partner = request.env.user.partner_id
return request.render('community_membership.portal_my_membership', {
'partner': partner,
'page_name': 'membership',
})
@http.route(['/my/membership/renew'], type='http', auth='public', website=True)
@http.route(['/my/membership/renew'], type='http', auth='user', website=True)
def portal_membership_renew(self, **kwargs):
redirect = _redirect_to_login_if_public('/my/membership/renew')
if redirect:
return redirect
partner = request.env.user.partner_id
invoice = request.env['account.move'].sudo().search([
('partner_id', '=', partner.id),
@ -49,11 +34,8 @@ class MembershipPortal(CustomerPortal):
return request.redirect('/my/membership')
return request.redirect(f'/my/invoices/{invoice.id}')
@http.route(['/my/membership/card'], type='http', auth='public', website=True)
@http.route(['/my/membership/card'], type='http', auth='user', website=True)
def portal_membership_card(self, **kwargs):
redirect = _redirect_to_login_if_public('/my/membership/card')
if redirect:
return redirect
partner = request.env.user.partner_id
pdf_content, _report_type = request.env['ir.actions.report'].sudo()._render_qweb_pdf(
'community_membership.action_report_membership_card', res_ids=partner.ids,

View File

@ -1 +0,0 @@
from . import controllers

View File

@ -24,11 +24,8 @@ a missing module:
'currency': 'USD',
'depends': [
'portal',
'website',
],
'data': [
'views/portal_dashboard_templates.xml',
],
'data': [],
'demo': [],
'images': ['static/description/banner.png'],
'application': False,

View File

@ -1 +0,0 @@
from . import portal

View File

@ -1,59 +0,0 @@
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='public', website=True)
def portal_my_school(self, **kwargs):
if request.env.user._is_public():
return request.redirect('/web/login?redirect=/my/school')
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})

View File

@ -1 +0,0 @@
from . import test_portal_dashboard

View File

@ -1,34 +0,0 @@
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")

View File

@ -1,72 +0,0 @@
<?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>

View File

@ -1,2 +0,0 @@
from . import models
from . import controllers

View File

@ -29,23 +29,8 @@ with no code change.
'website_slides',
'account',
],
'data': [
'security/school_security.xml',
'security/ir.model.access.csv',
'data/mail_templates.xml',
'data/ir_cron.xml',
'views/school_views.xml',
'views/school_menus.xml',
'views/res_config_settings_views.xml',
'views/portal_attendance_templates.xml',
'views/registration_templates.xml',
],
'data': [],
'demo': [],
'assets': {
'web.assets_frontend': [
'community_school/static/src/js/school_attendance.js',
],
},
'images': ['static/description/banner.png'],
'application': True,
'installable': True,

View File

@ -1,2 +0,0 @@
from . import attendance
from . import registration

View File

@ -1,84 +0,0 @@
from odoo import fields, http
from odoo.exceptions import AccessDenied
from odoo.http import request
def _redirect_to_login_if_public(path):
"""Manual login-required redirect - see community_classifieds for why this
is needed instead of auth='user' (a bug in this Odoo version's own
SessionExpiredException -> login-redirect handling)."""
if request.env.user._is_public():
return request.redirect(f'/web/login?redirect={path}')
return None
class SchoolAttendanceController(http.Controller):
def _get_teacher_classes(self):
partner = request.env.user.partner_id
return request.env['community.school.class'].sudo().search([('teacher_id', '=', partner.id)])
@http.route(['/school/attendance'], type='http', auth='public', website=True)
def attendance_home(self, class_id=None, date=None, **kwargs):
redirect = _redirect_to_login_if_public('/school/attendance')
if redirect:
return redirect
classes = self._get_teacher_classes()
if not classes:
return request.render('community_school.portal_no_classes', {})
selected_class = classes.filtered(lambda c: c.id == int(class_id)) if class_id else classes[:1]
if not selected_class:
selected_class = classes[:1]
selected_date = date or fields.Date.context_today(request.env.user).isoformat()
Enrollment = request.env['community.school.enrollment'].sudo()
enrollments = Enrollment.search([
('class_id', '=', selected_class.id), ('state', '=', 'enrolled'),
])
existing_by_enrollment = {
attendance.enrollment_id.id: attendance
for attendance in request.env['community.school.attendance'].sudo().search([
('class_id', '=', selected_class.id), ('date', '=', selected_date),
])
}
roster = [
{'enrollment': enrollment, 'attendance': existing_by_enrollment.get(enrollment.id)}
for enrollment in enrollments
]
return request.render('community_school.portal_attendance', {
'classes': classes,
'selected_class': selected_class,
'selected_date': selected_date,
'roster': roster,
})
@http.route(['/school/attendance/save'], type='jsonrpc', auth='user', website=True)
def attendance_save(self, class_id=None, date=None, lines=None, **kwargs):
classes = self._get_teacher_classes()
klass = classes.filtered(lambda c: c.id == int(class_id))
if not klass:
raise AccessDenied()
Attendance = request.env['community.school.attendance'].sudo()
Enrollment = request.env['community.school.enrollment'].sudo()
saved = 0
for line in (lines or []):
enrollment = Enrollment.browse(int(line.get('enrollment_id', 0)))
if not enrollment.exists() or enrollment.class_id.id != klass.id:
continue
vals = {'state': line.get('state', 'present'), 'notes': line.get('notes') or False}
record = Attendance.search([
('enrollment_id', '=', enrollment.id), ('date', '=', date),
], limit=1)
if record:
record.write(vals)
else:
vals.update({'enrollment_id': enrollment.id, 'date': date})
record = Attendance.create(vals)
saved += 1
if record.state == 'absent':
record._send_absence_notice()
return {'status': 'ok', 'saved': saved}

View File

@ -1,86 +0,0 @@
from odoo import http
from odoo.http import request
SESSION_PARENT_KEY = 'school_reg_parent'
SESSION_STUDENT_KEY = 'school_reg_student'
class SchoolRegistrationController(http.Controller):
@http.route(['/school/register'], type='http', auth='public', website=True)
def register_parent(self, **kwargs):
if http.request.httprequest.method == 'POST':
request.session[SESSION_PARENT_KEY] = {
'name': kwargs.get('name', '').strip(),
'email': kwargs.get('email', '').strip(),
'phone': kwargs.get('phone', '').strip(),
}
return request.redirect('/school/register/student')
return request.render('community_school.registration_step_parent', {})
@http.route(['/school/register/student'], type='http', auth='public', website=True)
def register_student(self, **kwargs):
if not request.session.get(SESSION_PARENT_KEY):
return request.redirect('/school/register')
if request.httprequest.method == 'POST':
request.session[SESSION_STUDENT_KEY] = {
'name': kwargs.get('name', '').strip(),
'date_of_birth': kwargs.get('date_of_birth') or False,
'health_notes': kwargs.get('health_notes', '').strip(),
'emergency_contact_name': kwargs.get('emergency_contact_name', '').strip(),
'emergency_contact_phone': kwargs.get('emergency_contact_phone', '').strip(),
}
return request.redirect('/school/register/class')
return request.render('community_school.registration_step_student', {})
@http.route(['/school/register/class'], type='http', auth='public', website=True)
def register_class(self, **kwargs):
if not request.session.get(SESSION_PARENT_KEY) or not request.session.get(SESSION_STUDENT_KEY):
return request.redirect('/school/register')
if request.httprequest.method == 'POST':
return self._finalize_registration(int(kwargs.get('class_id', 0)))
classes = request.env['community.school.class'].sudo().search([('state', '=', 'open')])
return request.render('community_school.registration_step_class', {'classes': classes})
def _finalize_registration(self, class_id):
Partner = request.env['res.partner'].sudo()
parent_data = request.session[SESSION_PARENT_KEY]
student_data = request.session[SESSION_STUDENT_KEY]
parent = Partner.search([('email', '=', parent_data['email'])], limit=1) if parent_data['email'] else Partner
if not parent:
parent = Partner.create({
'name': parent_data['name'],
'email': parent_data['email'],
'phone': parent_data['phone'],
})
child_partner = Partner.create({'name': student_data['name']})
student = request.env['community.school.student'].sudo().create({
'partner_id': child_partner.id,
'parent_partner_id': parent.id,
'date_of_birth': student_data['date_of_birth'],
'health_notes': student_data['health_notes'],
'emergency_contact_name': student_data['emergency_contact_name'],
'emergency_contact_phone': student_data['emergency_contact_phone'],
})
klass = request.env['community.school.class'].sudo().browse(class_id)
state = 'enrolled' if klass.enrolled_count < klass.max_students else 'waitlist'
enrollment = request.env['community.school.enrollment'].sudo().create({
'student_id': student.id,
'class_id': klass.id,
'state': state,
})
if state == 'enrolled' and klass.fee:
enrollment._create_fee_invoice()
request.session.pop(SESSION_PARENT_KEY, None)
request.session.pop(SESSION_STUDENT_KEY, None)
return request.render('community_school.registration_done', {
'enrollment': enrollment,
'waitlisted': state == 'waitlist',
})

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_school_promote_waitlist" model="ir.cron">
<field name="name">School: Promote Waitlist</field>
<field name="model_id" ref="model_community_school_enrollment"/>
<field name="state">code</field>
<field name="code">model._cron_promote_waitlist()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_absence_notice" model="mail.template">
<field name="name">School: Absence Notice</field>
<field name="model_id" ref="model_community_school_attendance"/>
<field name="subject">Absence notice: {{ object.enrollment_id.student_id.partner_id.name }}</field>
<field name="partner_to">{{ object.enrollment_id.student_id.parent_partner_id.id }}</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.enrollment_id.student_id.parent_partner_id.name or ''">Parent</t>,</p>
<p>
This is to let you know that
<t t-out="object.enrollment_id.student_id.partner_id.name or ''"/>
was marked absent from
<t t-out="object.class_id.name or ''"/>
on <t t-out="format_date(object.date)"/>.
</p>
<p t-if="object.notes">Notes: <t t-out="object.notes"/></p>
</div>
</field>
</record>
<record id="mail_template_seat_available" model="mail.template">
<field name="name">School: Waitlist Seat Available</field>
<field name="model_id" ref="model_community_school_enrollment"/>
<field name="subject">A seat opened up in {{ object.class_id.name }}</field>
<field name="partner_to">{{ object.student_id.parent_partner_id.id }}</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.student_id.parent_partner_id.name or ''">Parent</t>,</p>
<p>
Good news - a seat has opened up in
<t t-out="object.class_id.name or ''"/>
and <t t-out="object.student_id.partner_id.name or ''"/> has been moved from the
waitlist to enrolled.
</p>
</div>
</field>
</record>
</data>
</odoo>

View File

@ -1,8 +0,0 @@
from . import res_partner
from . import school_term
from . import school_level
from . import school_class
from . import school_student
from . import school_enrollment
from . import school_attendance
from . import res_config_settings

View File

@ -1,12 +0,0 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
school_at_risk_attendance_threshold = fields.Integer(
string='At-Risk Attendance Threshold (%)',
config_parameter='community_school.at_risk_attendance_threshold',
default=70,
help="Students whose attendance rate falls below this percentage are flagged at-risk.",
)

View File

@ -1,7 +0,0 @@
from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
is_teacher = fields.Boolean(string='Teacher')

View File

@ -1,31 +0,0 @@
from odoo import fields, models
class CommunitySchoolAttendance(models.Model):
_name = 'community.school.attendance'
_description = 'School Attendance'
_order = 'date desc, id desc'
enrollment_id = fields.Many2one('community.school.enrollment', required=True, ondelete='cascade')
class_id = fields.Many2one(related='enrollment_id.class_id', store=True)
date = fields.Date(required=True, default=fields.Date.context_today)
state = fields.Selection(
[
('present', 'Present'),
('absent', 'Absent'),
('late', 'Late'),
('excused', 'Excused'),
],
default='present', required=True,
)
notes = fields.Char()
_enrollment_date_uniq = models.Constraint(
'unique(enrollment_id, date)', 'Attendance for this student on this date is already recorded.',
)
def _send_absence_notice(self):
self.ensure_one()
template = self.env.ref('community_school.mail_template_absence_notice', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=False)

View File

@ -1,83 +0,0 @@
from odoo import api, fields, models
WEEKDAYS = [
('mon', 'Monday'), ('tue', 'Tuesday'), ('wed', 'Wednesday'), ('thu', 'Thursday'),
('fri', 'Friday'), ('sat', 'Saturday'), ('sun', 'Sunday'),
]
class CommunitySchoolClass(models.Model):
_name = 'community.school.class'
_description = 'School Class'
_order = 'id desc'
name = fields.Char(compute='_compute_name', store=True)
level_id = fields.Many2one('community.school.level', required=True)
term_id = fields.Many2one('community.school.term', required=True)
teacher_id = fields.Many2one('res.partner', domain=[('is_teacher', '=', True)])
max_students = fields.Integer(default=20)
fee = fields.Monetary(currency_field='currency_id')
currency_id = fields.Many2one('res.currency', default=lambda self: self.env.company.currency_id)
fee_product_id = fields.Many2one('product.product', readonly=True, copy=False)
enrolled_count = fields.Integer(compute='_compute_enrolled_count', store=True)
weekday = fields.Selection(WEEKDAYS)
start_time = fields.Float()
end_time = fields.Float()
location = fields.Char()
slide_channel_id = fields.Many2one('slide.channel', readonly=True, copy=False)
enrollment_ids = fields.One2many('community.school.enrollment', 'class_id')
state = fields.Selection(
[('draft', 'Draft'), ('open', 'Open'), ('closed', 'Closed')],
default='draft', required=True,
)
@api.depends('level_id.name', 'term_id.name')
def _compute_name(self):
for record in self:
record.name = f"{record.level_id.name or '?'} - {record.term_id.name or '?'}"
@api.depends('enrollment_ids.state')
def _compute_enrolled_count(self):
if not self:
return
counts = dict(self.env['community.school.enrollment']._read_group(
[('class_id', 'in', self.ids), ('state', '=', 'enrolled')],
groupby=['class_id'], aggregates=['__count'],
))
for record in self:
record.enrolled_count = counts.get(record, 0)
def _get_or_create_slide_channel(self):
self.ensure_one()
if self.slide_channel_id:
return self.slide_channel_id
channel = self.env['slide.channel'].create({
'name': self.name,
'channel_type': 'training',
'visibility': 'members',
'enroll': 'invite',
})
self.slide_channel_id = channel.id
return channel
def _get_or_create_fee_product(self):
self.ensure_one()
if self.fee_product_id:
return self.fee_product_id
product = self.env['product.product'].create({
'name': f"School Fee - {self.name}",
'list_price': self.fee,
'type': 'service',
'sale_ok': True,
'purchase_ok': False,
'invoice_policy': 'order',
})
self.fee_product_id = product.id
return product
@api.model_create_multi
def create(self, vals_list):
classes = super().create(vals_list)
for record in classes:
record._get_or_create_slide_channel()
return classes

View File

@ -1,130 +0,0 @@
from odoo import api, fields, models
class CommunitySchoolEnrollment(models.Model):
_name = 'community.school.enrollment'
_description = 'School Enrollment'
_order = 'id desc'
student_id = fields.Many2one('community.school.student', required=True, ondelete='cascade')
class_id = fields.Many2one('community.school.class', required=True, ondelete='cascade')
term_id = fields.Many2one(related='class_id.term_id', store=True)
enrollment_date = fields.Date(default=fields.Date.context_today)
state = fields.Selection(
[
('waitlist', 'Waitlisted'),
('enrolled', 'Enrolled'),
('completed', 'Completed'),
('withdrawn', 'Withdrawn'),
],
default='enrolled', required=True,
)
payment_state = fields.Selection(
[('not_invoiced', 'Not Invoiced'), ('invoiced', 'Invoiced'), ('paid', 'Paid')],
default='not_invoiced', required=True,
)
invoice_id = fields.Many2one('account.move', readonly=True, copy=False)
attendance_ids = fields.One2many('community.school.attendance', 'enrollment_id')
attendance_rate = fields.Float(compute='_compute_attendance_rate')
is_at_risk = fields.Boolean(compute='_compute_is_at_risk')
@api.depends('attendance_ids.state')
def _compute_attendance_rate(self):
for enrollment in self:
records = enrollment.attendance_ids
total = len(records)
if not total:
enrollment.attendance_rate = 0.0
continue
present = len(records.filtered(lambda a: a.state in ('present', 'late')))
enrollment.attendance_rate = (present / total) * 100.0
@api.depends('attendance_rate', 'attendance_ids')
def _compute_is_at_risk(self):
threshold = int(self.env['ir.config_parameter'].sudo().get_param(
'community_school.at_risk_attendance_threshold', 70
))
for enrollment in self:
enrollment.is_at_risk = bool(enrollment.attendance_ids) and enrollment.attendance_rate < threshold
def action_enroll_from_waitlist(self):
for enrollment in self:
if enrollment.state == 'waitlist' and enrollment.class_id.enrolled_count < enrollment.class_id.max_students:
enrollment.state = 'enrolled'
return True
@api.model_create_multi
def create(self, vals_list):
enrollments = super().create(vals_list)
for enrollment in enrollments:
if enrollment.state == 'enrolled':
enrollment._lms_enrol()
return enrollments
def write(self, vals):
res = super().write(vals)
if 'state' in vals:
for enrollment in self:
if enrollment.state == 'enrolled':
enrollment._lms_enrol()
elif enrollment.state == 'withdrawn':
enrollment._lms_unenrol()
return res
def _lms_enrol(self):
self.ensure_one()
channel = self.class_id.slide_channel_id
if channel and self.student_id.partner_id:
channel.sudo()._action_add_members(self.student_id.partner_id, member_status='joined')
def _lms_unenrol(self):
self.ensure_one()
channel = self.class_id.slide_channel_id
if not channel:
return
membership = self.env['slide.channel.partner'].sudo().search([
('channel_id', '=', channel.id),
('partner_id', '=', self.student_id.partner_id.id),
])
membership.write({'active': False})
def _create_fee_invoice(self):
self.ensure_one()
product = self.class_id.fee_product_id or self.class_id._get_or_create_fee_product()
if not self.class_id.fee:
return self.env['account.move']
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.student_id.parent_partner_id.id,
'invoice_origin': 'School Registration',
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'quantity': 1,
'price_unit': self.class_id.fee,
})],
})
self.write({'invoice_id': invoice.id, 'payment_state': 'invoiced'})
return invoice
@api.model
def _cron_promote_waitlist(self):
"""Hourly: promote the earliest waitlisted enrollment(s) into any newly-freed seats."""
classes = self.env['community.school.class'].search([])
for klass in classes:
free_seats = klass.max_students - klass.enrolled_count
if free_seats <= 0:
continue
waitlisted = self.search([
('class_id', '=', klass.id), ('state', '=', 'waitlist'),
], order='enrollment_date asc, id asc', limit=free_seats)
for enrollment in waitlisted:
enrollment.write({'state': 'enrolled'})
enrollment._send_seat_available_notice()
return True
def _send_seat_available_notice(self):
self.ensure_one()
template = self.env.ref('community_school.mail_template_seat_available', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=False)

View File

@ -1,16 +0,0 @@
from odoo import fields, models
class CommunitySchoolLevel(models.Model):
_name = 'community.school.level'
_description = 'School Level'
_order = 'sequence, id'
name = fields.Char(required=True, translate=True, help="e.g. 'Beginner' or 'Grade 3' - defined by the admin.")
code = fields.Char(required=True)
sequence = fields.Integer(default=10)
min_age = fields.Integer()
max_age = fields.Integer()
active = fields.Boolean(default=True)
_code_uniq = models.Constraint('unique(code)', 'A level with this code already exists.')

View File

@ -1,32 +0,0 @@
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
class CommunitySchoolStudent(models.Model):
_name = 'community.school.student'
_description = 'School Student'
_order = 'id desc'
partner_id = fields.Many2one('res.partner', string='Student', required=True)
parent_partner_id = fields.Many2one('res.partner', string='Parent/Guardian', required=True)
date_of_birth = fields.Date()
age = fields.Integer(compute='_compute_age')
proficiency = fields.Selection(
[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')],
default='beginner',
)
grade_ref = fields.Char(string='Grade', help="Free-text grade reference, e.g. 'Grade 3'.")
health_notes = fields.Text()
emergency_contact_name = fields.Char()
emergency_contact_phone = fields.Char()
enrollment_ids = fields.One2many('community.school.enrollment', 'student_id')
@api.depends('date_of_birth')
def _compute_age(self):
today = fields.Date.context_today(self)
for student in self:
if student.date_of_birth:
student.age = relativedelta(today, student.date_of_birth).years
else:
student.age = 0

View File

@ -1,17 +0,0 @@
from odoo import fields, models
class CommunitySchoolTerm(models.Model):
_name = 'community.school.term'
_description = 'School Term'
_order = 'start_date desc'
name = fields.Char(required=True)
start_date = fields.Date(required=True)
end_date = fields.Date(required=True)
registration_open = fields.Boolean(default=False)
registration_deadline = fields.Date()
state = fields.Selection(
[('draft', 'Draft'), ('open', 'Open'), ('closed', 'Closed')],
default='draft', required=True,
)

View File

@ -1,17 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_community_school_term_coordinator,community.school.term coordinator,model_community_school_term,group_school_coordinator,1,1,1,1
access_community_school_term_teacher,community.school.term teacher read,model_community_school_term,group_school_teacher,1,0,0,0
access_community_school_level_coordinator,community.school.level coordinator,model_community_school_level,group_school_coordinator,1,1,1,1
access_community_school_level_teacher,community.school.level teacher read,model_community_school_level,group_school_teacher,1,0,0,0
access_community_school_class_coordinator,community.school.class coordinator,model_community_school_class,group_school_coordinator,1,1,1,1
access_community_school_class_teacher,community.school.class teacher read,model_community_school_class,group_school_teacher,1,0,0,0
access_community_school_student_coordinator,community.school.student coordinator,model_community_school_student,group_school_coordinator,1,1,1,1
access_community_school_student_teacher,community.school.student teacher read,model_community_school_student,group_school_teacher,1,0,0,0
access_community_school_enrollment_coordinator,community.school.enrollment coordinator,model_community_school_enrollment,group_school_coordinator,1,1,1,1
access_community_school_enrollment_teacher,community.school.enrollment teacher read,model_community_school_enrollment,group_school_teacher,1,0,0,0
access_community_school_attendance_coordinator,community.school.attendance coordinator,model_community_school_attendance,group_school_coordinator,1,1,1,1
access_community_school_attendance_teacher,community.school.attendance teacher,model_community_school_attendance,group_school_teacher,1,1,1,0
access_community_school_student_portal,community.school.student portal read,model_community_school_student,base.group_portal,1,0,0,0
access_community_school_enrollment_portal,community.school.enrollment portal read,model_community_school_enrollment,base.group_portal,1,0,0,0
access_community_school_attendance_portal,community.school.attendance portal read,model_community_school_attendance,base.group_portal,1,0,0,0
access_community_school_class_portal,community.school.class portal read,model_community_school_class,base.group_portal,1,0,0,0

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
access_community_school_term_coordinator community.school.term coordinator model_community_school_term group_school_coordinator 1 1 1 1
access_community_school_term_teacher community.school.term teacher read model_community_school_term group_school_teacher 1 0 0 0
access_community_school_level_coordinator community.school.level coordinator model_community_school_level group_school_coordinator 1 1 1 1
access_community_school_level_teacher community.school.level teacher read model_community_school_level group_school_teacher 1 0 0 0
access_community_school_class_coordinator community.school.class coordinator model_community_school_class group_school_coordinator 1 1 1 1
access_community_school_class_teacher community.school.class teacher read model_community_school_class group_school_teacher 1 0 0 0
access_community_school_student_coordinator community.school.student coordinator model_community_school_student group_school_coordinator 1 1 1 1
access_community_school_student_teacher community.school.student teacher read model_community_school_student group_school_teacher 1 0 0 0
access_community_school_enrollment_coordinator community.school.enrollment coordinator model_community_school_enrollment group_school_coordinator 1 1 1 1
access_community_school_enrollment_teacher community.school.enrollment teacher read model_community_school_enrollment group_school_teacher 1 0 0 0
access_community_school_attendance_coordinator community.school.attendance coordinator model_community_school_attendance group_school_coordinator 1 1 1 1
access_community_school_attendance_teacher community.school.attendance teacher model_community_school_attendance group_school_teacher 1 1 1 0
access_community_school_student_portal community.school.student portal read model_community_school_student base.group_portal 1 0 0 0
access_community_school_enrollment_portal community.school.enrollment portal read model_community_school_enrollment base.group_portal 1 0 0 0
access_community_school_attendance_portal community.school.attendance portal read model_community_school_attendance base.group_portal 1 0 0 0
access_community_school_class_portal community.school.class portal read model_community_school_class base.group_portal 1 0 0 0

View File

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_school" model="ir.module.category">
<field name="name">School</field>
<field name="sequence">21</field>
</record>
<record id="privilege_school" model="res.groups.privilege">
<field name="name">School</field>
<field name="category_id" ref="module_category_school"/>
</record>
<record id="group_school_coordinator" model="res.groups">
<field name="name">School Coordinator</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can manage terms, levels, classes, students and enrollment.</field>
</record>
<record id="group_school_teacher" model="res.groups">
<field name="name">Teacher</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can take attendance for their own classes.</field>
</record>
</odoo>

View File

@ -1,40 +0,0 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
export class SchoolAttendance extends Interaction {
static selector = ".o_school_attendance";
dynamicContent = {
".o_attendance_save_btn": { "t-on-click": this.onSave },
".o_attendance_class_select": { "t-on-change": this.onReload },
".o_attendance_date_input": { "t-on-change": this.onReload },
};
setup() {
this.resultEl = this.el.querySelector(".o_attendance_result");
}
onReload() {
const classId = this.el.querySelector(".o_attendance_class_select").value;
const date = this.el.querySelector(".o_attendance_date_input").value;
window.location.href = `/school/attendance?class_id=${classId}&date=${date}`;
}
async onSave() {
const classId = this.el.dataset.classId;
const date = this.el.dataset.date;
const lines = [...this.el.querySelectorAll("tbody tr")].map((row) => ({
enrollment_id: row.dataset.enrollmentId,
state: row.querySelector(".o_attendance_state_select").value,
notes: row.querySelector(".o_attendance_notes_input").value,
}));
const result = await this.waitFor(
rpc("/school/attendance/save", { class_id: classId, date, lines })
);
this.resultEl.className = "alert alert-success mt-2";
this.resultEl.textContent = `Saved ${result.saved} record(s).`;
}
}
registry.category("public.interactions").add("community_school.school_attendance", SchoolAttendance);

View File

@ -1,3 +0,0 @@
from . import test_school
from . import test_school_attendance
from . import test_school_registration

View File

@ -1,79 +0,0 @@
from dateutil.relativedelta import relativedelta
from odoo import fields
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSchool(TransactionCase):
def setUp(self):
super().setUp()
self.term = self.env['community.school.term'].create({
'name': 'Fall 2030',
'start_date': '2030-09-01',
'end_date': '2030-12-15',
})
self.level = self.env['community.school.level'].create({
'name': 'Beginner',
'code': 'BEG',
'min_age': 5,
'max_age': 8,
})
self.klass = self.env['community.school.class'].create({
'level_id': self.level.id,
'term_id': self.term.id,
'max_students': 2,
})
self.parent = self.env['res.partner'].create({'name': 'Parent One'})
self.child = self.env['res.partner'].create({'name': 'Child One'})
self.student = self.env['community.school.student'].create({
'partner_id': self.child.id,
'parent_partner_id': self.parent.id,
'date_of_birth': '2023-01-01',
})
def test_class_auto_creates_slide_channel(self):
self.assertTrue(self.klass.slide_channel_id, "Creating a class should auto-create a slide channel")
def test_class_name_computed(self):
self.assertEqual(self.klass.name, f"{self.level.name} - {self.term.name}")
def test_enrollment_updates_enrolled_count(self):
self.assertEqual(self.klass.enrolled_count, 0)
self.env['community.school.enrollment'].create({
'student_id': self.student.id,
'class_id': self.klass.id,
})
self.klass.invalidate_recordset()
self.assertEqual(self.klass.enrolled_count, 1)
def test_withdrawn_enrollment_does_not_count(self):
enrollment = self.env['community.school.enrollment'].create({
'student_id': self.student.id,
'class_id': self.klass.id,
})
enrollment.state = 'withdrawn'
self.klass.invalidate_recordset()
self.assertEqual(self.klass.enrolled_count, 0)
def test_attendance_rate_computed(self):
enrollment = self.env['community.school.enrollment'].create({
'student_id': self.student.id,
'class_id': self.klass.id,
})
self.env['community.school.attendance'].create({
'enrollment_id': enrollment.id, 'date': '2030-09-08', 'state': 'present',
})
self.env['community.school.attendance'].create({
'enrollment_id': enrollment.id, 'date': '2030-09-15', 'state': 'absent',
})
self.env['community.school.attendance'].create({
'enrollment_id': enrollment.id, 'date': '2030-09-22', 'state': 'present',
})
self.assertAlmostEqual(enrollment.attendance_rate, (2 / 3) * 100)
def test_age_computed(self):
dob = fields.Date.today() - relativedelta(years=5, days=1)
self.student.date_of_birth = dob
self.assertEqual(self.student.age, 5)

View File

@ -1,55 +0,0 @@
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSchoolAttendance(TransactionCase):
def setUp(self):
super().setUp()
self.term = self.env['community.school.term'].create({
'name': 'Fall 2030', 'start_date': '2030-09-01', 'end_date': '2030-12-15',
})
self.level = self.env['community.school.level'].create({'name': 'Beginner', 'code': 'ATT-BEG'})
self.klass = self.env['community.school.class'].create({
'level_id': self.level.id, 'term_id': self.term.id,
})
self.parent = self.env['res.partner'].create({'name': 'Attendance Parent', 'email': 'parent@example.com'})
self.child = self.env['res.partner'].create({'name': 'Attendance Child'})
self.student = self.env['community.school.student'].create({
'partner_id': self.child.id, 'parent_partner_id': self.parent.id,
})
self.enrollment = self.env['community.school.enrollment'].create({
'student_id': self.student.id, 'class_id': self.klass.id,
})
def test_absence_queues_mail_to_parent(self):
mail_count_before = self.env['mail.mail'].search_count([])
attendance = self.env['community.school.attendance'].create({
'enrollment_id': self.enrollment.id, 'date': '2030-09-08', 'state': 'absent',
})
attendance._send_absence_notice()
mail_count_after = self.env['mail.mail'].search_count([])
self.assertGreater(mail_count_after, mail_count_before)
def test_present_attendance_does_not_require_notice(self):
attendance = self.env['community.school.attendance'].create({
'enrollment_id': self.enrollment.id, 'date': '2030-09-08', 'state': 'present',
})
self.assertEqual(attendance.state, 'present')
def test_at_risk_flag(self):
self.env['ir.config_parameter'].sudo().set_param('community_school.at_risk_attendance_threshold', '70')
for i, state in enumerate(['absent', 'absent', 'present']):
self.env['community.school.attendance'].create({
'enrollment_id': self.enrollment.id, 'date': f'2030-09-0{i + 1}', 'state': state,
})
self.assertTrue(self.enrollment.is_at_risk, "1/3 present should be below a 70% threshold")
def test_unique_attendance_per_enrollment_and_date(self):
self.env['community.school.attendance'].create({
'enrollment_id': self.enrollment.id, 'date': '2030-09-08', 'state': 'present',
})
with self.assertRaises(Exception):
self.env['community.school.attendance'].create({
'enrollment_id': self.enrollment.id, 'date': '2030-09-08', 'state': 'absent',
})

View File

@ -1,84 +0,0 @@
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSchoolRegistration(TransactionCase):
def setUp(self):
super().setUp()
self.term = self.env['community.school.term'].create({
'name': 'Reg Term', 'start_date': '2030-09-01', 'end_date': '2030-12-15',
})
self.level = self.env['community.school.level'].create({'name': 'Beginner', 'code': 'REG-BEG'})
self.klass = self.env['community.school.class'].create({
'level_id': self.level.id, 'term_id': self.term.id, 'max_students': 1, 'fee': 100.0,
})
def _make_student(self, name):
parent = self.env['res.partner'].create({'name': f'{name} Parent', 'email': f'{name.lower()}@example.com'})
child = self.env['res.partner'].create({'name': name})
return self.env['community.school.student'].create({
'partner_id': child.id, 'parent_partner_id': parent.id,
})
def test_enrollment_creates_invoice_when_class_has_fee(self):
student = self._make_student('Fee Student')
enrollment = self.env['community.school.enrollment'].create({
'student_id': student.id, 'class_id': self.klass.id,
})
invoice = enrollment._create_fee_invoice()
self.assertTrue(invoice)
self.assertEqual(invoice.state, 'draft')
self.assertEqual(enrollment.payment_state, 'invoiced')
def test_full_class_waitlists_second_student(self):
student1 = self._make_student('First Student')
student2 = self._make_student('Second Student')
enrollment1 = self.env['community.school.enrollment'].create({
'student_id': student1.id, 'class_id': self.klass.id,
})
self.assertEqual(enrollment1.state, 'enrolled')
# max_students=1, so a manual second enrollment past capacity should be
# created as waitlist by the controller logic; simulate that here.
state = 'enrolled' if self.klass.enrolled_count < self.klass.max_students else 'waitlist'
enrollment2 = self.env['community.school.enrollment'].create({
'student_id': student2.id, 'class_id': self.klass.id, 'state': state,
})
self.assertEqual(enrollment2.state, 'waitlist')
def test_waitlist_promoted_when_seat_frees(self):
student1 = self._make_student('Withdraw Student')
student2 = self._make_student('Waitlist Student')
enrollment1 = self.env['community.school.enrollment'].create({
'student_id': student1.id, 'class_id': self.klass.id,
})
enrollment2 = self.env['community.school.enrollment'].create({
'student_id': student2.id, 'class_id': self.klass.id, 'state': 'waitlist',
})
enrollment1.write({'state': 'withdrawn'})
self.klass.invalidate_recordset()
self.assertEqual(self.klass.enrolled_count, 0)
mail_count_before = self.env['mail.mail'].search_count([])
self.env['community.school.enrollment']._cron_promote_waitlist()
self.assertEqual(enrollment2.state, 'enrolled')
mail_count_after = self.env['mail.mail'].search_count([])
self.assertGreater(mail_count_after, mail_count_before)
def test_lms_enrol_and_unenrol(self):
student = self._make_student('LMS Student')
enrollment = self.env['community.school.enrollment'].create({
'student_id': student.id, 'class_id': self.klass.id,
})
channel = self.klass.slide_channel_id
membership = self.env['slide.channel.partner'].sudo().search([
('channel_id', '=', channel.id), ('partner_id', '=', student.partner_id.id),
])
self.assertTrue(membership, "Enrolling should add the student to the class's slide channel")
enrollment.write({'state': 'withdrawn'})
membership.invalidate_recordset()
self.assertFalse(membership.active, "Withdrawing should deactivate the LMS membership")

View File

@ -1,63 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_no_classes" name="School Attendance: No Classes">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 60px;">
<p class="alert alert-info">You are not assigned as a teacher to any class.</p>
</div>
</t>
</template>
<template id="portal_attendance" name="School Attendance">
<t t-call="website.layout">
<div class="container o_school_attendance" style="max-width: 640px; margin-top: 24px; margin-bottom: 60px;"
t-att-data-class-id="selected_class.id" t-att-data-date="selected_date">
<h2>Attendance</h2>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Class</label>
<select class="form-select o_attendance_class_select">
<t t-foreach="classes" t-as="klass">
<option t-att-value="klass.id" t-att-selected="'selected' if klass.id == selected_class.id else None" t-out="klass.name"/>
</t>
</select>
</div>
<div class="col-6">
<label class="form-label">Date</label>
<input type="date" class="form-control o_attendance_date_input" t-att-value="selected_date"/>
</div>
</div>
<table class="table">
<thead>
<tr><th>Student</th><th>Status</th><th>Notes</th></tr>
</thead>
<tbody>
<t t-foreach="roster" t-as="row">
<tr t-att-data-enrollment-id="row['enrollment'].id">
<td t-out="row['enrollment'].student_id.partner_id.name"/>
<td>
<select class="form-select form-select-sm o_attendance_state_select">
<t t-set="current_state" t-value="row['attendance'].state if row['attendance'] else 'present'"/>
<option value="present" t-att-selected="'selected' if current_state == 'present' else None">Present</option>
<option value="absent" t-att-selected="'selected' if current_state == 'absent' else None">Absent</option>
<option value="late" t-att-selected="'selected' if current_state == 'late' else None">Late</option>
<option value="excused" t-att-selected="'selected' if current_state == 'excused' else None">Excused</option>
</select>
</td>
<td>
<input type="text" class="form-control form-control-sm o_attendance_notes_input"
t-att-value="row['attendance'].notes if row['attendance'] else ''"/>
</td>
</tr>
</t>
</tbody>
</table>
<button class="btn btn-primary o_attendance_save_btn" type="button">Save Attendance</button>
<div class="o_attendance_result mt-2" role="status"></div>
</div>
</t>
</template>
</odoo>

View File

@ -1,101 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="registration_step_parent" name="School Registration: Parent">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;">
<h2>Register - Step 1 of 3: Parent/Guardian</h2>
<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</label>
<input type="text" class="form-control" name="name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email" required="required"/>
</div>
<div class="mb-3">
<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>
</t>
</template>
<template id="registration_step_student" name="School Registration: Student">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;">
<h2>Register - Step 2 of 3: Student</h2>
<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</label>
<input type="text" class="form-control" name="name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control" name="date_of_birth"/>
</div>
<div class="mb-3">
<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>
</t>
</template>
<template id="registration_step_class" name="School Registration: Class">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;">
<h2>Register - Step 3 of 3: Class</h2>
<form method="POST" t-attf-action="/school/register/class">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label">Class</label>
<select class="form-select" name="class_id" required="required">
<t t-foreach="classes" t-as="klass">
<option t-att-value="klass.id">
<t t-out="klass.name"/> (<t t-out="klass.enrolled_count"/>/<t t-out="klass.max_students"/> seats)
<t t-if="klass.fee"> - <t t-out="klass.fee"/></t>
</option>
</t>
</select>
</div>
<button type="submit" class="btn btn-primary">Complete Registration</button>
</form>
</div>
</t>
</template>
<template id="registration_done" name="School Registration: Done">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 60px; margin-bottom: 60px;">
<t t-if="waitlisted">
<div class="alert alert-warning">
<h2>You're on the Waitlist</h2>
<p>The class is currently full. We'll notify you as soon as a seat opens up.</p>
</div>
</t>
<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>
</t>
</template>
</odoo>

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_school" model="ir.ui.view">
<field name="name">res.config.settings.view.form.school</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="School" string="School" name="community_school"
groups="community_school.group_school_coordinator">
<block title="School" id="school_settings">
<setting id="school_at_risk_threshold_setting" string="At-Risk Attendance Threshold"
help="Students below this attendance percentage are flagged at-risk">
<field name="school_at_risk_attendance_threshold"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_school_root" name="School" sequence="26"
groups="community_school.group_school_coordinator,community_school.group_school_teacher"/>
<menuitem id="menu_school_students" name="Students"
parent="menu_school_root" action="action_school_student" sequence="10"
groups="community_school.group_school_coordinator"/>
<menuitem id="menu_school_classes" name="Classes"
parent="menu_school_root" action="action_school_class" sequence="20"
groups="community_school.group_school_coordinator"/>
<menuitem id="menu_school_enrollments" name="Enrollments"
parent="menu_school_root" action="action_school_enrollment" sequence="30"
groups="community_school.group_school_coordinator"/>
<menuitem id="menu_school_at_risk" name="At-Risk Students"
parent="menu_school_root" action="action_school_enrollment_at_risk" sequence="35"
groups="community_school.group_school_coordinator"/>
<menuitem id="menu_school_attendance_report" name="Attendance Report"
parent="menu_school_root" action="action_school_attendance_report" sequence="40"
groups="community_school.group_school_coordinator"/>
<menuitem id="menu_school_configuration" name="Configuration"
parent="menu_school_root" sequence="90"
groups="community_school.group_school_coordinator"/>
<menuitem id="menu_school_terms" name="Terms"
parent="menu_school_configuration" action="action_school_term" sequence="10"/>
<menuitem id="menu_school_levels" name="Levels"
parent="menu_school_configuration" action="action_school_level" sequence="20"/>
</odoo>

View File

@ -1,231 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Term -->
<record id="view_school_term_list" model="ir.ui.view">
<field name="name">community.school.term.list</field>
<field name="model">community.school.term</field>
<field name="arch" type="xml">
<list string="School Terms">
<field name="name"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="registration_open"/>
<field name="state"/>
</list>
</field>
</record>
<record id="view_school_term_form" model="ir.ui.view">
<field name="name">community.school.term.form</field>
<field name="model">community.school.term</field>
<field name="arch" type="xml">
<form string="School Term">
<sheet>
<div class="oe_title"><h1><field name="name"/></h1></div>
<group>
<group>
<field name="start_date"/>
<field name="end_date"/>
<field name="state"/>
</group>
<group>
<field name="registration_open"/>
<field name="registration_deadline"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_school_term" model="ir.actions.act_window">
<field name="name">Terms</field>
<field name="res_model">community.school.term</field>
<field name="view_mode">list,form</field>
</record>
<!-- Level -->
<record id="view_school_level_list" model="ir.ui.view">
<field name="name">community.school.level.list</field>
<field name="model">community.school.level</field>
<field name="arch" type="xml">
<list string="School Levels" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="min_age"/>
<field name="max_age"/>
</list>
</field>
</record>
<record id="action_school_level" model="ir.actions.act_window">
<field name="name">Levels</field>
<field name="res_model">community.school.level</field>
<field name="view_mode">list</field>
</record>
<!-- Class -->
<record id="view_school_class_list" model="ir.ui.view">
<field name="name">community.school.class.list</field>
<field name="model">community.school.class</field>
<field name="arch" type="xml">
<list string="School Classes">
<field name="name"/>
<field name="teacher_id"/>
<field name="weekday"/>
<field name="enrolled_count"/>
<field name="max_students"/>
<field name="state"/>
</list>
</field>
</record>
<record id="view_school_class_form" model="ir.ui.view">
<field name="name">community.school.class.form</field>
<field name="model">community.school.class</field>
<field name="arch" type="xml">
<form string="School Class">
<sheet>
<div class="oe_title"><h1><field name="name" readonly="1"/></h1></div>
<group>
<group>
<field name="level_id"/>
<field name="term_id"/>
<field name="teacher_id"/>
<field name="state"/>
</group>
<group>
<field name="weekday"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="location"/>
<field name="max_students"/>
<field name="enrolled_count"/>
<field name="slide_channel_id" readonly="1"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_school_class" model="ir.actions.act_window">
<field name="name">Classes</field>
<field name="res_model">community.school.class</field>
<field name="view_mode">list,form</field>
</record>
<!-- Student -->
<record id="view_school_student_list" model="ir.ui.view">
<field name="name">community.school.student.list</field>
<field name="model">community.school.student</field>
<field name="arch" type="xml">
<list string="Students">
<field name="partner_id"/>
<field name="parent_partner_id"/>
<field name="age"/>
<field name="proficiency"/>
</list>
</field>
</record>
<record id="view_school_student_form" model="ir.ui.view">
<field name="name">community.school.student.form</field>
<field name="model">community.school.student</field>
<field name="arch" type="xml">
<form string="Student">
<sheet>
<div class="oe_title"><h1><field name="partner_id"/></h1></div>
<group>
<group>
<field name="parent_partner_id"/>
<field name="date_of_birth"/>
<field name="age" readonly="1"/>
<field name="proficiency"/>
<field name="grade_ref"/>
</group>
<group>
<field name="emergency_contact_name"/>
<field name="emergency_contact_phone"/>
</group>
</group>
<group string="Health Notes">
<field name="health_notes" nolabel="1"/>
</group>
<notebook>
<page string="Enrollments" name="enrollments">
<field name="enrollment_ids">
<list>
<field name="class_id"/>
<field name="term_id"/>
<field name="state"/>
<field name="payment_state"/>
<field name="attendance_rate" widget="percentage"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_school_student" model="ir.actions.act_window">
<field name="name">Students</field>
<field name="res_model">community.school.student</field>
<field name="view_mode">list,form</field>
</record>
<!-- Enrollment -->
<record id="view_school_enrollment_list" model="ir.ui.view">
<field name="name">community.school.enrollment.list</field>
<field name="model">community.school.enrollment</field>
<field name="arch" type="xml">
<list string="Enrollments">
<field name="student_id"/>
<field name="class_id"/>
<field name="term_id"/>
<field name="state"/>
<field name="payment_state"/>
<field name="attendance_rate" widget="percentage"/>
</list>
</field>
</record>
<record id="action_school_enrollment" model="ir.actions.act_window">
<field name="name">Enrollments</field>
<field name="res_model">community.school.enrollment</field>
<field name="view_mode">list,form</field>
</record>
<record id="action_school_enrollment_at_risk" model="ir.actions.act_window">
<field name="name">At-Risk Students</field>
<field name="res_model">community.school.enrollment</field>
<field name="view_mode">list,form</field>
<field name="domain">[('is_at_risk', '=', True)]</field>
</record>
<!-- Attendance pivot report -->
<record id="view_school_attendance_pivot" model="ir.ui.view">
<field name="name">community.school.attendance.pivot</field>
<field name="model">community.school.attendance</field>
<field name="arch" type="xml">
<pivot string="Attendance">
<field name="class_id" type="row"/>
<field name="date" type="col" interval="week"/>
<field name="state" type="row"/>
</pivot>
</field>
</record>
<record id="view_school_attendance_list" model="ir.ui.view">
<field name="name">community.school.attendance.list</field>
<field name="model">community.school.attendance</field>
<field name="arch" type="xml">
<list string="Attendance">
<field name="date"/>
<field name="class_id"/>
<field name="enrollment_id"/>
<field name="state"/>
<field name="notes"/>
</list>
</field>
</record>
<record id="action_school_attendance_report" model="ir.actions.act_window">
<field name="name">Attendance Report</field>
<field name="res_model">community.school.attendance</field>
<field name="view_mode">pivot,list</field>
</record>
</odoo>

View File

@ -1 +0,0 @@
from . import models

View File

@ -25,10 +25,7 @@ layout that reads these tokens.
'depends': [
'website',
],
'data': [
'views/res_config_settings_views.xml',
'views/theme_templates.xml',
],
'data': [],
'demo': [],
'images': ['static/description/banner.png'],
'application': False,

View File

@ -1 +0,0 @@
from . import res_config_settings

View File

@ -1,54 +0,0 @@
from odoo import api, fields, models
DEFAULT_PRIMARY_COLOR = '#2C3E50'
DEFAULT_SECONDARY_COLOR = '#7F8C8D'
DEFAULT_ACCENT_COLOR = '#3498DB'
DEFAULT_FONT = 'Inter, sans-serif'
LOGO_PARAM = 'community_theme_base.logo'
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,
)
# Binary fields cannot use config_parameter= directly (Odoo's settings
# framework only supports boolean/integer/float/char/selection/many2one/
# datetime that way), so this one is persisted manually via get_values/
# set_values, base64-encoded into an ir.config_parameter like the others.
theme_logo = fields.Binary(
string='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,
)
@api.model
def get_values(self):
res = super().get_values()
res['theme_logo'] = self.env['ir.config_parameter'].sudo().get_param(LOGO_PARAM) or False
return res
def set_values(self):
super().set_values()
value = self.theme_logo
if isinstance(value, bytes):
value = value.decode()
self.env['ir.config_parameter'].sudo().set_param(LOGO_PARAM, value or '')

View File

@ -1,2 +0,0 @@
from . import test_theme
from . import test_theme_http

View File

@ -1,23 +0,0 @@
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)

View File

@ -1,11 +0,0 @@
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)

View File

@ -1,37 +0,0 @@
<?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>

View File

@ -1,41 +0,0 @@
<?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>

View File

@ -8,22 +8,10 @@ def _require_registration_desk():
raise AccessDenied()
def _redirect_to_login_if_public(path):
"""Manual login-required redirect - see community_classifieds for why this
is needed instead of auth='user' (a bug in this Odoo version's own
SessionExpiredException -> login-redirect handling)."""
if request.env.user._is_public():
return request.redirect(f'/web/login?redirect={path}')
return None
class EventCheckinController(http.Controller):
@http.route(['/event/checkin', '/event/checkin/<int:event_id>'], type='http', auth='public', website=True)
@http.route(['/event/checkin', '/event/checkin/<int:event_id>'], type='http', auth='user', website=True)
def checkin_page(self, event_id=None, **kwargs):
redirect = _redirect_to_login_if_public('/event/checkin')
if redirect:
return redirect
_require_registration_desk()
events = request.env['event.event'].search([('date_end', '>=', fields.Datetime.now())])
event = request.env['event.event'].browse(event_id) if event_id else events[:1]

View File

@ -1,11 +0,0 @@
from . import models
def post_init_hook(env):
"""Best-effort immediate attempt at the accounting bootstrap. The daily
cron in data/ir_cron.xml (calling the same idempotent method from a
separate transaction) is what guarantees eventual correctness - see
models/res_company.py for why a single call from within this hook's own
transaction isn't reliable on this Odoo build.
"""
env.company._tncsc_setup_accounting()

View File

@ -34,19 +34,9 @@ that applies to the community_* product modules.
'community_benefits',
'community_interac',
'community_portal',
'l10n_ca',
],
'data': [
'security/tncsc_roles.xml',
'data/res_company_data.xml',
'data/theme_data.xml',
'data/membership_data.xml',
'data/mail_template_overrides.xml',
'data/ir_cron.xml',
'views/website_pages.xml',
],
'data': [],
'demo': [],
'application': False,
'installable': True,
'post_init_hook': 'post_init_hook',
}

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Safety net for a not-fully-isolated Odoo bootstrap quirk on fresh
databases: this Odoo build's own chart-template auto-install can
race with post_init_hook's explicit try_loading() call within
the same install transaction and silently replace its result
afterwards (see models/res_company.py for the full explanation).
A call from this cron's own separate transaction is immune to
that race. _tncsc_setup_accounting() is idempotent - it does
nothing once already correct - so running it daily forever is
harmless self-healing, not just a one-time fix. -->
<record id="ir_cron_tncsc_setup_accounting" model="ir.cron">
<field name="name">TNCSC: Verify Accounting Setup</field>
<field name="model_id" ref="base.model_res_company"/>
<field name="state">code</field>
<field name="code">model.search([])._tncsc_setup_accounting()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Bilingual (EN/Tamil) overrides of the community_membership templates,
demonstrating the pattern: TNCSC voice, org name, both languages.
The Tamil text below is a best-effort placeholder draft only - it
has NOT been reviewed by a native Tamil speaker and must be
corrected before this goes live. -->
<record id="community_membership.mail_template_membership_renewal_due" model="mail.template">
<field name="subject">TNCSC membership renewal due | 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>
Your Tamil Nadu Cultural Society of Canada membership
<t t-if="object.membership_member_id">(<t t-out="object.membership_member_id"/>)</t>
expires on <t t-out="format_date(object.membership_expiry)"/>.
</p>
<p>Please renew soon to keep your membership active and avoid a lapse in benefits.</p>
<hr/>
<p><strong>தமிழ்</strong></p>
<p>அன்புள்ள <t t-out="object.name or ''">உறுப்பினர்</t>,</p>
<p>
கனடா தமிழ்நாடு பண்பாட்டு சங்கத்தில் உங்கள் உறுப்பினர் காலம்
<t t-out="format_date(object.membership_expiry)"/> அன்று
காலாவதியாகிறது.
</p>
<p>உங்கள் உறுப்பினர் தகுதியைத் தொடர தயவுசெய்து விரைவில் புதுப்பிக்கவும்.</p>
</div>
</field>
</record>
<record id="community_membership.mail_template_membership_expired" model="mail.template">
<field name="subject">Your TNCSC membership has expired | 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>
Your Tamil Nadu Cultural Society of Canada membership
<t t-if="object.membership_member_id">(<t t-out="object.membership_member_id"/>)</t>
expired on <t t-out="format_date(object.membership_expiry)"/>.
</p>
<p>A renewal invoice has been prepared for you. Please get in touch to reactivate your membership.</p>
<hr/>
<p><strong>தமிழ்</strong></p>
<p>அன்புள்ள <t t-out="object.name or ''">உறுப்பினர்</t>,</p>
<p>
கனடா தமிழ்நாடு பண்பாட்டு சங்கத்தில் உங்கள் உறுப்பினர் தகுதி
<t t-out="format_date(object.membership_expiry)"/> அன்று
காலாவதியானது.
</p>
<p>உங்களுக்கான புதுப்பித்தல் விலைப்பட்டியல் தயாரிக்கப்பட்டுள்ளது. உறுப்பினர் தகுதியை மீண்டும்
பெற எங்களைத் தொடர்பு கொள்ளவும்.</p>
</div>
</field>
</record>
</odoo>

View File

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Membership settings -->
<record id="config_parameter_membership_org_name" model="ir.config_parameter">
<field name="key">community_membership.org_name</field>
<field name="value">Tamil Nadu Cultural Society of Canada</field>
</record>
<record id="config_parameter_membership_id_format" model="ir.config_parameter">
<field name="key">community_membership.member_id_format</field>
<field name="value">TNCSC-{year}-{seq}</field>
</record>
<record id="config_parameter_membership_renewal_offsets" model="ir.config_parameter">
<field name="key">community_membership.renewal_offsets</field>
<field name="value">30,14,7</field>
</record>
<!-- Membership tiers (CAD) - figures from the implementation plan;
update via Settings once TNCSC confirms final pricing. -->
<record id="membership_tier_individual" model="community.membership.tier">
<field name="name">Individual</field>
<field name="code">IND</field>
<field name="price">50.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="sequence">10</field>
</record>
<record id="membership_tier_family" model="community.membership.tier">
<field name="name">Family</field>
<field name="code">FAM</field>
<field name="price">80.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="sequence">20</field>
</record>
<record id="membership_tier_student" model="community.membership.tier">
<field name="name">Student</field>
<field name="code">STU</field>
<field name="price">20.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="sequence">30</field>
</record>
<record id="membership_tier_senior" model="community.membership.tier">
<field name="name">Senior</field>
<field name="code">SEN</field>
<field name="price">30.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">annual</field>
<field name="sequence">40</field>
</record>
<record id="membership_tier_life" model="community.membership.tier">
<field name="name">Life</field>
<field name="code">LIFE</field>
<field name="price">500.0</field>
<field name="currency_id" ref="base.CAD"/>
<field name="period">one_time</field>
<field name="sequence">50</field>
</record>
</odoo>

Some files were not shown because too many files have changed in this diff Show More