Compare commits
10 Commits
db50c3f15c
...
9a24691c37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a24691c37 | ||
|
|
8c8043a49a | ||
|
|
d05d80c4df | ||
|
|
b94ee06d3a | ||
|
|
f6886c1b20 | ||
|
|
4449582d76 | ||
|
|
7e860b65c5 | ||
|
|
91e91fe1b8 | ||
|
|
e05448b939 | ||
|
|
7ac5880f20 |
217
HANDOFF.md
Normal file
217
HANDOFF.md
Normal file
@ -0,0 +1,217 @@
|
||||
# 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 0–7 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.
|
||||
@ -84,7 +84,8 @@ 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.
|
||||
phase-by-phase build plan, and `HANDOFF.md` for exactly where the build
|
||||
currently stands, how to resume it, and known gotchas.
|
||||
|
||||
## CI
|
||||
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@ -23,9 +23,16 @@ entitled per membership tier:
|
||||
'currency': 'USD',
|
||||
'depends': [
|
||||
'contacts',
|
||||
'website',
|
||||
'portal',
|
||||
'community_membership',
|
||||
],
|
||||
'data': [],
|
||||
'data': [
|
||||
'security/benefits_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/benefit_views.xml',
|
||||
'views/benefits_templates.xml',
|
||||
],
|
||||
'demo': [],
|
||||
'images': ['static/description/banner.png'],
|
||||
'application': False,
|
||||
|
||||
1
addons/community_benefits/controllers/__init__.py
Normal file
1
addons/community_benefits/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
31
addons/community_benefits/controllers/main.py
Normal file
31
addons/community_benefits/controllers/main.py
Normal file
@ -0,0 +1,31 @@
|
||||
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})
|
||||
3
addons/community_benefits/models/__init__.py
Normal file
3
addons/community_benefits/models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import benefit_partner
|
||||
from . import benefit
|
||||
from . import benefit_redemption
|
||||
45
addons/community_benefits/models/benefit.py
Normal file
45
addons/community_benefits/models/benefit.py
Normal file
@ -0,0 +1,45 @@
|
||||
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),
|
||||
]
|
||||
25
addons/community_benefits/models/benefit_partner.py
Normal file
25
addons/community_benefits/models/benefit_partner.py
Normal file
@ -0,0 +1,25 @@
|
||||
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')
|
||||
28
addons/community_benefits/models/benefit_redemption.py
Normal file
28
addons/community_benefits/models/benefit_redemption.py
Normal file
@ -0,0 +1,28 @@
|
||||
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."
|
||||
)
|
||||
19
addons/community_benefits/security/benefits_security.xml
Normal file
19
addons/community_benefits/security/benefits_security.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?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>
|
||||
@ -1 +1,4 @@
|
||||
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
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
from . import test_benefits
|
||||
66
addons/community_benefits/tests/test_benefits.py
Normal file
66
addons/community_benefits/tests/test_benefits.py
Normal file
@ -0,0 +1,66 @@
|
||||
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,
|
||||
})
|
||||
114
addons/community_benefits/views/benefit_views.xml
Normal file
114
addons/community_benefits/views/benefit_views.xml
Normal file
@ -0,0 +1,114 @@
|
||||
<?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>
|
||||
53
addons/community_benefits/views/benefits_templates.xml
Normal file
53
addons/community_benefits/views/benefits_templates.xml
Normal file
@ -0,0 +1,53 @@
|
||||
<?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>
|
||||
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@ -25,7 +25,15 @@ Soft-detects Community OS Membership; runs standalone without it.
|
||||
'website',
|
||||
'portal',
|
||||
],
|
||||
'data': [],
|
||||
'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',
|
||||
],
|
||||
'demo': [],
|
||||
'images': ['static/description/banner.png'],
|
||||
'application': False,
|
||||
|
||||
1
addons/community_classifieds/controllers/__init__.py
Normal file
1
addons/community_classifieds/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
109
addons/community_classifieds/controllers/main.py
Normal file
109
addons/community_classifieds/controllers/main.py
Normal file
@ -0,0 +1,109 @@
|
||||
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')
|
||||
23
addons/community_classifieds/data/ir_cron.xml
Normal file
23
addons/community_classifieds/data/ir_cron.xml
Normal file
@ -0,0 +1,23 @@
|
||||
<?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>
|
||||
35
addons/community_classifieds/data/mail_templates.xml
Normal file
35
addons/community_classifieds/data/mail_templates.xml
Normal file
@ -0,0 +1,35 @@
|
||||
<?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>
|
||||
2
addons/community_classifieds/models/__init__.py
Normal file
2
addons/community_classifieds/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import community_classified
|
||||
from . import res_config_settings
|
||||
133
addons/community_classifieds/models/community_classified.py
Normal file
133
addons/community_classifieds/models/community_classified.py
Normal file
@ -0,0 +1,133 @@
|
||||
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)
|
||||
12
addons/community_classifieds/models/res_config_settings.py
Normal file
12
addons/community_classifieds/models/res_config_settings.py
Normal file
@ -0,0 +1,12 @@
|
||||
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.",
|
||||
)
|
||||
@ -0,0 +1,19 @@
|
||||
<?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>
|
||||
@ -1 +1,3 @@
|
||||
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
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
from . import test_classifieds
|
||||
79
addons/community_classifieds/tests/test_classifieds.py
Normal file
79
addons/community_classifieds/tests/test_classifieds.py
Normal file
@ -0,0 +1,79 @@
|
||||
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)
|
||||
85
addons/community_classifieds/views/classified_views.xml
Normal file
85
addons/community_classifieds/views/classified_views.xml
Normal file
@ -0,0 +1,85 @@
|
||||
<?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>
|
||||
134
addons/community_classifieds/views/classifieds_templates.xml
Normal file
134
addons/community_classifieds/views/classifieds_templates.xml
Normal file
@ -0,0 +1,134 @@
|
||||
<?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>
|
||||
@ -0,0 +1,21 @@
|
||||
<?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>
|
||||
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@ -27,7 +27,15 @@ Sellable to any Canadian organization accepting Interac e-Transfer.
|
||||
'payment',
|
||||
'account',
|
||||
],
|
||||
'data': [],
|
||||
'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',
|
||||
],
|
||||
'demo': [],
|
||||
'images': ['static/description/banner.png'],
|
||||
'application': False,
|
||||
|
||||
3
addons/community_interac/const.py
Normal file
3
addons/community_interac/const.py
Normal file
@ -0,0 +1,3 @@
|
||||
DEFAULT_PAYMENT_METHOD_CODES = {
|
||||
'interac',
|
||||
}
|
||||
1
addons/community_interac/controllers/__init__.py
Normal file
1
addons/community_interac/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
15
addons/community_interac/controllers/main.py
Normal file
15
addons/community_interac/controllers/main.py
Normal file
@ -0,0 +1,15 @@
|
||||
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')
|
||||
14
addons/community_interac/data/ir_cron.xml
Normal file
14
addons/community_interac/data/ir_cron.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?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>
|
||||
44
addons/community_interac/data/mail_templates.xml
Normal file
44
addons/community_interac/data/mail_templates.xml
Normal file
@ -0,0 +1,44 @@
|
||||
<?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>
|
||||
13
addons/community_interac/data/payment_method_data.xml
Normal file
13
addons/community_interac/data/payment_method_data.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<?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>
|
||||
15
addons/community_interac/data/payment_provider_data.xml
Normal file
15
addons/community_interac/data/payment_provider_data.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?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>
|
||||
2
addons/community_interac/models/__init__.py
Normal file
2
addons/community_interac/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import payment_provider
|
||||
from . import payment_transaction
|
||||
27
addons/community_interac/models/payment_provider.py
Normal file
27
addons/community_interac/models/payment_provider.py
Normal file
@ -0,0 +1,27 @@
|
||||
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
|
||||
61
addons/community_interac/models/payment_transaction.py
Normal file
61
addons/community_interac/models/payment_transaction.py
Normal file
@ -0,0 +1,61 @@
|
||||
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
|
||||
19
addons/community_interac/security/interac_security.xml
Normal file
19
addons/community_interac/security/interac_security.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?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>
|
||||
@ -0,0 +1 @@
|
||||
from . import test_interac
|
||||
69
addons/community_interac/tests/test_interac.py
Normal file
69
addons/community_interac/tests/test_interac.py
Normal file
@ -0,0 +1,69 @@
|
||||
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')
|
||||
@ -0,0 +1,8 @@
|
||||
<?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>
|
||||
32
addons/community_interac/views/payment_transaction_views.xml
Normal file
32
addons/community_interac/views/payment_transaction_views.xml
Normal file
@ -0,0 +1,32 @@
|
||||
<?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>
|
||||
@ -3,6 +3,15 @@ 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):
|
||||
@ -12,16 +21,22 @@ class MembershipPortal(CustomerPortal):
|
||||
values['membership_count'] = 1 if partner.membership_state != 'none' else 0
|
||||
return values
|
||||
|
||||
@http.route(['/my/membership'], type='http', auth='user', website=True)
|
||||
@http.route(['/my/membership'], type='http', auth='public', 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='user', website=True)
|
||||
@http.route(['/my/membership/renew'], type='http', auth='public', 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),
|
||||
@ -34,8 +49,11 @@ class MembershipPortal(CustomerPortal):
|
||||
return request.redirect('/my/membership')
|
||||
return request.redirect(f'/my/invoices/{invoice.id}')
|
||||
|
||||
@http.route(['/my/membership/card'], type='http', auth='user', website=True)
|
||||
@http.route(['/my/membership/card'], type='http', auth='public', 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,
|
||||
|
||||
@ -0,0 +1 @@
|
||||
from . import controllers
|
||||
@ -24,8 +24,11 @@ a missing module:
|
||||
'currency': 'USD',
|
||||
'depends': [
|
||||
'portal',
|
||||
'website',
|
||||
],
|
||||
'data': [
|
||||
'views/portal_dashboard_templates.xml',
|
||||
],
|
||||
'data': [],
|
||||
'demo': [],
|
||||
'images': ['static/description/banner.png'],
|
||||
'application': False,
|
||||
|
||||
1
addons/community_portal/controllers/__init__.py
Normal file
1
addons/community_portal/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import portal
|
||||
59
addons/community_portal/controllers/portal.py
Normal file
59
addons/community_portal/controllers/portal.py
Normal file
@ -0,0 +1,59 @@
|
||||
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})
|
||||
@ -0,0 +1 @@
|
||||
from . import test_portal_dashboard
|
||||
34
addons/community_portal/tests/test_portal_dashboard.py
Normal file
34
addons/community_portal/tests/test_portal_dashboard.py
Normal file
@ -0,0 +1,34 @@
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
MODULE_TO_CARD_TITLE = {
|
||||
'community_membership': 'Membership',
|
||||
'event_qr_ticketing': 'My Events',
|
||||
'community_school': 'School',
|
||||
'community_benefits': 'My Benefits',
|
||||
'community_classifieds': 'My Classifieds',
|
||||
}
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPortalDashboard(HttpCase):
|
||||
|
||||
def test_portal_home_renders_without_error(self):
|
||||
self.authenticate('admin', 'admin')
|
||||
response = self.url_open('/my')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_dashboard_only_shows_cards_for_installed_modules(self):
|
||||
"""The dashboard must never crash on a missing module, and must not show
|
||||
a card for a module that isn't installed."""
|
||||
installed = set(self.env['ir.module.module'].search([
|
||||
('name', 'in', list(MODULE_TO_CARD_TITLE)), ('state', '=', 'installed'),
|
||||
]).mapped('name'))
|
||||
|
||||
self.authenticate('admin', 'admin')
|
||||
response = self.url_open('/my')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.content.decode()
|
||||
|
||||
for module_name, card_title in MODULE_TO_CARD_TITLE.items():
|
||||
if module_name not in installed:
|
||||
self.assertNotIn(card_title, content, f"{card_title} card should be hidden when {module_name} is not installed")
|
||||
72
addons/community_portal/views/portal_dashboard_templates.xml
Normal file
72
addons/community_portal/views/portal_dashboard_templates.xml
Normal file
@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="portal_my_home_community" name="Portal my home: Community OS cards" inherit_id="portal.portal_my_home" customize_show="True" priority="10">
|
||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="before">
|
||||
<t t-set="portal_client_category_enable" t-value="True"/>
|
||||
</xpath>
|
||||
<div id="portal_client_category" position="inside">
|
||||
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'community_membership'), ('state', '=', 'installed')])"
|
||||
t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">Membership</t>
|
||||
<t t-set="url" t-value="'/my/membership'"/>
|
||||
<t t-set="text">View your membership status, renew, or download your card</t>
|
||||
<t t-set="placeholder_count" t-value="'membership_count'"/>
|
||||
</t>
|
||||
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'event_qr_ticketing'), ('state', '=', 'installed')])"
|
||||
t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">My Events</t>
|
||||
<t t-set="url" t-value="'/event'"/>
|
||||
<t t-set="text">See events you have registered for and download your tickets</t>
|
||||
<t t-set="placeholder_count" t-value="'event_registration_count'"/>
|
||||
</t>
|
||||
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'community_school'), ('state', '=', 'installed')])"
|
||||
t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">School</t>
|
||||
<t t-set="url" t-value="'/my/school'"/>
|
||||
<t t-set="text">See your children's classes, schedule, and attendance</t>
|
||||
<t t-set="placeholder_count" t-value="'school_enrollment_count'"/>
|
||||
</t>
|
||||
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'community_benefits'), ('state', '=', 'installed')])"
|
||||
t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">My Benefits</t>
|
||||
<t t-set="url" t-value="'/my/benefits'"/>
|
||||
<t t-set="text">Benefits your membership tier entitles you to</t>
|
||||
<t t-set="placeholder_count" t-value="'benefits_count'"/>
|
||||
</t>
|
||||
<t t-if="env['ir.module.module'].sudo().search_count([('name', '=', 'community_classifieds'), ('state', '=', 'installed')])"
|
||||
t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">My Classifieds</t>
|
||||
<t t-set="url" t-value="'/classifieds/my'"/>
|
||||
<t t-set="text">Manage the listings you have posted</t>
|
||||
<t t-set="placeholder_count" t-value="'classifieds_count'"/>
|
||||
</t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="portal_my_school" name="My Children's Classes">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_doc_table">
|
||||
<h3>My Children</h3>
|
||||
<t t-if="not students">
|
||||
<p class="alert alert-info">No children are registered under your account.</p>
|
||||
</t>
|
||||
<t t-foreach="students" t-as="student">
|
||||
<h5 t-out="student.partner_id.name"/>
|
||||
<table class="table mb-4">
|
||||
<thead><tr><th>Class</th><th>Term</th><th>Status</th><th>Attendance</th></tr></thead>
|
||||
<tbody>
|
||||
<t t-foreach="student.enrollment_ids" t-as="enrollment">
|
||||
<tr>
|
||||
<td t-out="enrollment.class_id.name"/>
|
||||
<td t-out="enrollment.term_id.name"/>
|
||||
<td t-out="dict(enrollment._fields['state'].selection).get(enrollment.state)"/>
|
||||
<td><t t-out="round(enrollment.attendance_rate, 1)"/>%</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@ -29,8 +29,23 @@ with no code change.
|
||||
'website_slides',
|
||||
'account',
|
||||
],
|
||||
'data': [],
|
||||
'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',
|
||||
],
|
||||
'demo': [],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'community_school/static/src/js/school_attendance.js',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/banner.png'],
|
||||
'application': True,
|
||||
'installable': True,
|
||||
|
||||
2
addons/community_school/controllers/__init__.py
Normal file
2
addons/community_school/controllers/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import attendance
|
||||
from . import registration
|
||||
84
addons/community_school/controllers/attendance.py
Normal file
84
addons/community_school/controllers/attendance.py
Normal file
@ -0,0 +1,84 @@
|
||||
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}
|
||||
86
addons/community_school/controllers/registration.py
Normal file
86
addons/community_school/controllers/registration.py
Normal file
@ -0,0 +1,86 @@
|
||||
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',
|
||||
})
|
||||
14
addons/community_school/data/ir_cron.xml
Normal file
14
addons/community_school/data/ir_cron.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?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>
|
||||
43
addons/community_school/data/mail_templates.xml
Normal file
43
addons/community_school/data/mail_templates.xml
Normal file
@ -0,0 +1,43 @@
|
||||
<?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>
|
||||
8
addons/community_school/models/__init__.py
Normal file
8
addons/community_school/models/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
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
|
||||
12
addons/community_school/models/res_config_settings.py
Normal file
12
addons/community_school/models/res_config_settings.py
Normal file
@ -0,0 +1,12 @@
|
||||
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.",
|
||||
)
|
||||
7
addons/community_school/models/res_partner.py
Normal file
7
addons/community_school/models/res_partner.py
Normal file
@ -0,0 +1,7 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
is_teacher = fields.Boolean(string='Teacher')
|
||||
31
addons/community_school/models/school_attendance.py
Normal file
31
addons/community_school/models/school_attendance.py
Normal file
@ -0,0 +1,31 @@
|
||||
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)
|
||||
83
addons/community_school/models/school_class.py
Normal file
83
addons/community_school/models/school_class.py
Normal file
@ -0,0 +1,83 @@
|
||||
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
|
||||
130
addons/community_school/models/school_enrollment.py
Normal file
130
addons/community_school/models/school_enrollment.py
Normal file
@ -0,0 +1,130 @@
|
||||
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)
|
||||
16
addons/community_school/models/school_level.py
Normal file
16
addons/community_school/models/school_level.py
Normal file
@ -0,0 +1,16 @@
|
||||
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.')
|
||||
32
addons/community_school/models/school_student.py
Normal file
32
addons/community_school/models/school_student.py
Normal file
@ -0,0 +1,32 @@
|
||||
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
|
||||
17
addons/community_school/models/school_term.py
Normal file
17
addons/community_school/models/school_term.py
Normal file
@ -0,0 +1,17 @@
|
||||
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,
|
||||
)
|
||||
@ -1 +1,17 @@
|
||||
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
|
||||
|
||||
|
26
addons/community_school/security/school_security.xml
Normal file
26
addons/community_school/security/school_security.xml
Normal file
@ -0,0 +1,26 @@
|
||||
<?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>
|
||||
40
addons/community_school/static/src/js/school_attendance.js
Normal file
40
addons/community_school/static/src/js/school_attendance.js
Normal file
@ -0,0 +1,40 @@
|
||||
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);
|
||||
@ -0,0 +1,3 @@
|
||||
from . import test_school
|
||||
from . import test_school_attendance
|
||||
from . import test_school_registration
|
||||
79
addons/community_school/tests/test_school.py
Normal file
79
addons/community_school/tests/test_school.py
Normal file
@ -0,0 +1,79 @@
|
||||
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)
|
||||
55
addons/community_school/tests/test_school_attendance.py
Normal file
55
addons/community_school/tests/test_school_attendance.py
Normal file
@ -0,0 +1,55 @@
|
||||
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',
|
||||
})
|
||||
84
addons/community_school/tests/test_school_registration.py
Normal file
84
addons/community_school/tests/test_school_registration.py
Normal file
@ -0,0 +1,84 @@
|
||||
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")
|
||||
@ -0,0 +1,63 @@
|
||||
<?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>
|
||||
101
addons/community_school/views/registration_templates.xml
Normal file
101
addons/community_school/views/registration_templates.xml
Normal file
@ -0,0 +1,101 @@
|
||||
<?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>
|
||||
21
addons/community_school/views/res_config_settings_views.xml
Normal file
21
addons/community_school/views/res_config_settings_views.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?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>
|
||||
35
addons/community_school/views/school_menus.xml
Normal file
35
addons/community_school/views/school_menus.xml
Normal file
@ -0,0 +1,35 @@
|
||||
<?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>
|
||||
231
addons/community_school/views/school_views.xml
Normal file
231
addons/community_school/views/school_views.xml
Normal file
@ -0,0 +1,231 @@
|
||||
<?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>
|
||||
@ -0,0 +1 @@
|
||||
from . import models
|
||||
@ -25,7 +25,10 @@ layout that reads these tokens.
|
||||
'depends': [
|
||||
'website',
|
||||
],
|
||||
'data': [],
|
||||
'data': [
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/theme_templates.xml',
|
||||
],
|
||||
'demo': [],
|
||||
'images': ['static/description/banner.png'],
|
||||
'application': False,
|
||||
|
||||
1
addons/community_theme_base/models/__init__.py
Normal file
1
addons/community_theme_base/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import res_config_settings
|
||||
54
addons/community_theme_base/models/res_config_settings.py
Normal file
54
addons/community_theme_base/models/res_config_settings.py
Normal file
@ -0,0 +1,54 @@
|
||||
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 '')
|
||||
@ -0,0 +1,2 @@
|
||||
from . import test_theme
|
||||
from . import test_theme_http
|
||||
23
addons/community_theme_base/tests/test_theme.py
Normal file
23
addons/community_theme_base/tests/test_theme.py
Normal file
@ -0,0 +1,23 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestTheme(TransactionCase):
|
||||
|
||||
def test_default_colors_are_neutral(self):
|
||||
primary = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'community_theme_base.primary_color', '#2C3E50'
|
||||
)
|
||||
self.assertEqual(primary, '#2C3E50')
|
||||
html = self.env['ir.qweb']._render('community_theme_base.theme_css_vars')
|
||||
self.assertIn('#2C3E50', html, "Unconfigured deployments should render the neutral default")
|
||||
|
||||
def test_custom_color_reflected_in_css_vars_render(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('community_theme_base.primary_color', '#123456')
|
||||
html = self.env['ir.qweb']._render('community_theme_base.theme_css_vars')
|
||||
self.assertIn('#123456', html)
|
||||
|
||||
def test_report_basic_layout_includes_theme_css_vars(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('community_theme_base.primary_color', '#FEDCBA')
|
||||
html = self.env['ir.qweb']._render('web.basic_layout', {'doc': self.env.company})
|
||||
self.assertIn('#FEDCBA', html)
|
||||
11
addons/community_theme_base/tests/test_theme_http.py
Normal file
11
addons/community_theme_base/tests/test_theme_http.py
Normal file
@ -0,0 +1,11 @@
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestThemeHttp(HttpCase):
|
||||
|
||||
def test_theme_css_vars_present_on_public_page(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('community_theme_base.primary_color', '#654321')
|
||||
response = self.url_open('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'#654321', response.content)
|
||||
@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_theme" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.theme</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Theme" string="Theme" name="community_theme_base"
|
||||
groups="base.group_system">
|
||||
<block title="Brand Colours" id="theme_colors_settings">
|
||||
<setting id="theme_primary_color_setting" string="Primary Colour">
|
||||
<field name="theme_primary_color"/>
|
||||
</setting>
|
||||
<setting id="theme_secondary_color_setting" string="Secondary Colour">
|
||||
<field name="theme_secondary_color"/>
|
||||
</setting>
|
||||
<setting id="theme_accent_color_setting" string="Accent Colour">
|
||||
<field name="theme_accent_color"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Brand Assets" id="theme_assets_settings">
|
||||
<setting id="theme_logo_setting" string="Logo">
|
||||
<field name="theme_logo" widget="image"/>
|
||||
</setting>
|
||||
<setting id="theme_heading_font_setting" string="Heading Font">
|
||||
<field name="theme_heading_font"/>
|
||||
</setting>
|
||||
<setting id="theme_body_font_setting" string="Body Font">
|
||||
<field name="theme_body_font"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
41
addons/community_theme_base/views/theme_templates.xml
Normal file
41
addons/community_theme_base/views/theme_templates.xml
Normal file
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="theme_css_vars" name="Community OS Theme CSS Variables">
|
||||
<t t-set="theme_primary" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.primary_color', '#2C3E50')"/>
|
||||
<t t-set="theme_secondary" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.secondary_color', '#7F8C8D')"/>
|
||||
<t t-set="theme_accent" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.accent_color', '#3498DB')"/>
|
||||
<t t-set="theme_heading_font" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.heading_font', 'Inter, sans-serif')"/>
|
||||
<t t-set="theme_body_font" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.body_font', 'Inter, sans-serif')"/>
|
||||
<style>
|
||||
:root {
|
||||
--community-primary: <t t-out="theme_primary"/>;
|
||||
--community-secondary: <t t-out="theme_secondary"/>;
|
||||
--community-accent: <t t-out="theme_accent"/>;
|
||||
--community-heading-font: <t t-out="theme_heading_font"/>;
|
||||
--community-body-font: <t t-out="theme_body_font"/>;
|
||||
}
|
||||
body { font-family: var(--community-body-font); }
|
||||
h1, h2, h3, h4, h5, h6 { font-family: var(--community-heading-font); }
|
||||
.btn-primary, .bg-primary { background-color: var(--community-primary) !important; border-color: var(--community-primary) !important; }
|
||||
a { color: var(--community-primary); }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<template id="website_layout_theme_vars" inherit_id="website.layout">
|
||||
<xpath expr="//head" position="inside">
|
||||
<t t-call="community_theme_base.theme_css_vars"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="report_basic_layout_theme_vars" inherit_id="web.basic_layout">
|
||||
<xpath expr="//t[@t-out='0']" position="before">
|
||||
<t t-call="community_theme_base.theme_css_vars"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="theme_logo_snippet" name="Community OS Theme Logo">
|
||||
<t t-set="theme_logo" t-value="env['ir.config_parameter'].sudo().get_param('community_theme_base.logo')"/>
|
||||
<img t-if="theme_logo" t-att-src="image_data_uri(theme_logo)" alt="Logo"/>
|
||||
<img t-elif="env.company.logo" t-att-src="image_data_uri(env.company.logo)" alt="Logo"/>
|
||||
</template>
|
||||
</odoo>
|
||||
@ -8,10 +8,22 @@ 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='user', website=True)
|
||||
@http.route(['/event/checkin', '/event/checkin/<int:event_id>'], type='http', auth='public', 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]
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
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()
|
||||
@ -34,9 +34,19 @@ 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',
|
||||
}
|
||||
|
||||
23
addons/tncsc_deployment/data/ir_cron.xml
Normal file
23
addons/tncsc_deployment/data/ir_cron.xml
Normal file
@ -0,0 +1,23 @@
|
||||
<?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>
|
||||
59
addons/tncsc_deployment/data/mail_template_overrides.xml
Normal file
59
addons/tncsc_deployment/data/mail_template_overrides.xml
Normal file
@ -0,0 +1,59 @@
|
||||
<?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>
|
||||
59
addons/tncsc_deployment/data/membership_data.xml
Normal file
59
addons/tncsc_deployment/data/membership_data.xml
Normal file
@ -0,0 +1,59 @@
|
||||
<?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
Loading…
x
Reference in New Issue
Block a user