Compare commits

...

10 Commits

Author SHA1 Message Date
metatroncubeswdev
9a24691c37 feat: Phase 8 TNCSC data migration scripts (members, students, opening balances)
Some checks failed
CI / Brand-leak check (push) Has been cancelled
CI / flake8 / pylint-odoo / manifest completeness (push) Has been cancelled
CI / Install all modules with --test-enable (push) Has been cancelled
Adds migrate_students.py (parent+child partner upsert, current-term
enrollment by school level code) and migrate_opening_balances.py (one
posted, balance-checked journal entry dated the last fiscal year-end).
All three Phase 8 scripts (incl. the existing migrate_members.py) verified
against a live Odoo 19 instance: fresh tncsc_migration_test DB with
tncsc_deployment installed, dry-run + real run + idempotency re-run each,
confirmed via RPC no duplicate records were created. See HANDOFF.md for
the full verification log and why a fresh DB was used instead of
communityos_dev.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 01:47:34 -04:00
metatroncubeswdev
8c8043a49a docs: add HANDOFF.md, commit Phase 8 WIP
HANDOFF.md is the "where things actually stand" companion to the plan
document: a phase-by-phase status table, how to spin the dev environment
back up (including the "restart the container after any module change or
you'll hit a stale registry" gotcha that bit repeatedly this session), an
index of every real Odoo 19 API-drift gotcha discovered during the build
(pointing at the commit that documents each in full rather than
duplicating it), and instructions for pushing this repo to a remote before
handing it to a team (none is configured yet - this repo only exists
locally).

Also commits the two Phase 8 files that existed only as uncommitted local
changes (scripts/_migration_common.py, a first pass at
scripts/migrate_members.py) so they survive a git clone rather than being
fragile local-only WIP. Neither is wired into anything or tested yet -
migrate_members.py has no live run against Odoo, and migrate_students.py /
migrate_opening_balances.py don't exist yet. Paused here at the user's
request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 07:29:29 -04:00
metatroncubeswdev
d05d80c4df feat(tncsc_deployment): TNCSC client configuration (Phase 7)
Data-only deployment layer seeding TNCSC's branding (navy/orange/electric-
blue from the plan), 5 membership tiers in CAD (Individual $50, Family $80,
Student $20, Senior $30, Life $500 - the plan's own example figures;
update via Settings once TNCSC confirms real pricing), the
'TNCSC-{year}-{seq}' member-ID format, a Canadian (Ontario/HST) chart of
accounts via l10n_ca plus non-profit-specific accounts (Membership Dues/
Event/Sponsorship/School Fees/Donations Revenue, Deferred Event Revenue
liability, a Stripe clearing account) and a Donations journal, bilingual
EN/Tamil overrides of two membership email templates (Tamil text is a
best-effort draft only, explicitly flagged as needing native-speaker
review before go-live), five placeholder website pages (Home/About/Tamil
School/Sponsors/Contact), and TNCSC-named role groups (Board Admin,
Treasurer, Events Officer, School Coordinator, Teacher, Classifieds
Moderator) that imply the existing generic product-layer groups rather
than defining new permission logic.

Chasing the chart-of-accounts setup down to a genuinely working state
took real digging: setting company.country_id on a brand-new company
auto-schedules this Odoo build's own chart-template installer via a
precommit hook (res.company.install_l10n_modules), which races an
explicit try_loading('ca_2023', ...) call made in the same install
transaction and silently replaces its result afterwards (reverting
currency to USD, chart_template to 'generic_coa', and deleting the custom
accounts) - confirmed via raw SQL checks that the correct state exists
right up until the post_init_hook transaction commits, and is gone by the
time the install process exits. Since the precommit auto-trigger only
ever fires once per company (guarded by chart_template being unset), a
second call from a separate transaction is immune to the race. The fix:
the actual setup logic lives in an idempotent res.company._tncsc_setup_
accounting() method (models/res_company.py - a narrow, documented
exception to "no models" in the deployment layer, since cramming this
into a sandboxed ir.cron code string wasn't practical), called as a
best-effort from post_init_hook and guaranteed by a daily safety-net cron
que runs in its own transaction. Also hit two smaller, separate bugs on
the way: ir.cron code execution forbids direct attribute assignment
(STORE_ATTR) in its sandbox, and Html config_parameter fields must not be
wrapped in CDATA in data XML.

Verified end-to-end on a genuinely fresh database (not the long-lived dev
DB, which already has posted entries and correctly refuses a currency
change): installed tncsc_deployment alone, pulling in all 8 product
modules plus l10n_ca as dependencies, confirmed the post-install state via
raw SQL, manually triggered the safety-net cron and confirmed it reached
the fully-correct state (CAD, ca_2023, 355 accounts including all 7
custom ones, the Donations journal, tier products linked to the dues
account), confirmed the cron is idempotent on a second run, and ran the
full test suite (9/9 passing) with the same deterministic setup called
from the test transaction directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:53:17 -04:00
metatroncubeswdev
b94ee06d3a fix: anonymous access to auth='user' pages crashed with 500 instead of redirecting to login
Reported: clicking "Post a Listing" on /classifieds while logged out threw
a raw 500 instead of prompting login.

Root cause is upstream, in this Odoo 19 build's own http.py: when
auth='user' raises SessionExpiredException for an anonymous visitor,
Request._serve_db's `finally: self.env = None` clears the request env
before the exception reaches the website error handler, which then tries
to build the login redirect via self.env['ir.http']._redirect(...) and
crashes with TypeError: 'NoneType' object is not subscriptable. This
isn't specific to any one route - it reproduces on every auth='user' +
website=True page hit anonymously, including stock Odoo's own /my (traced
this back to the true cause rather than continuing to treat it as an
unrelated environment quirk, since it now has a real reported symptom).

Since core can't be patched here, worked around it at the route level
across all 9 affected pages (classifieds new/my/renew, membership
my/renew/card, benefits my, school attendance, portal my/school, event
checkin): switched from auth='user' to auth='public' and added an
explicit `if request.env.user._is_public(): return request.redirect(...)`
check at the top of each handler, before Odoo's own auth layer ever gets
a chance to raise. The jsonrpc AJAX endpoints (attendance save, checkin
scan/dashboard) were left on auth='user' since they return a JSON error
rather than attempting an HTML redirect, so they don't hit this path.

Verified against a live Odoo 19 + Postgres 16 container: reproduced the
original crash pre-fix, then confirmed all 9 previously-broken routes now
303-redirect to /web/login?redirect=<path> when hit anonymously, that the
login page carries the redirect target, that logged-in access is
unaffected (200), and that the separate "logged in but lacking a required
group" case (event check-in without Registration Desk) still degrades
gracefully to a clean 403 rather than a crash. Full regression: 48/48
tests pass across the six touched modules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:06:30 -04:00
metatroncubeswdev
f6886c1b20 fix(community_theme_base): Binary fields can't use config_parameter= directly
Odoo's res.config.settings framework only supports auto-persisting fields
of type boolean/integer/float/char/selection/many2one/datetime via
config_parameter=. theme_logo (Binary) doesn't qualify, and because it was
injected into the *shared* res.config.settings form, it broke every
Settings tab across the whole install (including unrelated ones like
Website/eLearning) - any settings load triggered default_get, which
scans all fields on the model and raised. Caught via live testing in the
browser, not by the automated suite, since the failure only surfaces on
create()/default_get() of a resx.config.settings record, which the
existing tests didn't exercise for other apps' tabs.

Fixed by removing config_parameter from the field and persisting it
manually through get_values()/set_values() overrides, base64-encoded into
the same ir.config_parameter key the QWeb template already reads from -
so the storage format is unchanged, just how it gets there.

Audited every other config_parameter= field across all modules (grep) to
confirm none of the others have the same problem - all are Char/Integer/
Boolean, which are on the whitelist.

Verified against the live container: settings load and save cleanly for
every module's tab, the logo round-trips through upload -> config
parameter -> template read, and a full test run across all 9 modules
(56 tests) plus a sweep of every custom button (call_button, matching
the exact browser click path) and every public/portal page passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 22:22:23 -04:00
metatroncubeswdev
4449582d76 feat(community_theme_base, community_portal): presentation layer (Phase 6)
community_theme_base: brand tokens (primary/secondary/accent colour, logo,
heading/body font) exposed via res.config.settings, backed by
ir.config_parameter with a neutral default palette. A theme_css_vars QWeb
template renders them as CSS custom properties and is injected into both
website.layout (xpath into //head) and web.basic_layout (used by every
PDF report, including community_membership's card and event_qr_ticketing's
ticket) - so any deployment rebrands with data only. Confirmed env['model']
is accessible bare inside arbitrary QWeb templates (checked core usage in
web/report_templates.xml and website_templates.xml first) before relying
on it for the injection.

community_portal: extends portal.portal_my_home with cards for Membership,
Events, School, Benefits, and Classifieds, each gated by an inline
ir.module.module installed-check so a card is fully absent (not just
zero-count) when its module isn't installed - this is the piece that
finally wires up community_membership's membership_count counter, which
Session 1-C had already implemented but nothing was rendering yet. Adds
counters for the other four product modules (none of which had their own
portal home counter) and a new /my/school page listing a parent's
children's enrollments and attendance.

Verified against a live Odoo 19 + Postgres 16 container, automated (6
tests) and manually per the Phase 6 gate exactly: set a custom primary
colour via config parameter and confirmed it appears both on a real public
page (HttpCase hitting '/') and the live /my dashboard; then uninstalled
community_classifieds via button_immediate_uninstall and confirmed /my
still returned 200 with the "My Classifieds" card gone and no error,
before reinstalling it to restore the dev environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 22:07:42 -04:00
metatroncubeswdev
7e860b65c5 feat(community_interac): Interac e-Transfer payment provider (Phase 5)
The plan flags this as the highest-risk module since the payment provider
API is strict and version-sensitive. Before writing any code, read Odoo
19's own payment_custom module (its wire-transfer provider) end to end as
a reference, since it's the closest first-party analog to a manual/
offline payment flow - this avoided the trial-and-error that hit the
other modules and got the core logic right on the first install attempt.

payment.provider gains code='interac' (via selection_add, same pattern
payment_custom uses for 'custom') plus interac_recipient_email
(required_if_provider='interac' - Odoo only enforces this when the
provider's state is enabled/test, so the module ships a disabled,
unconfigured provider record and the deployment layer configures + enables
it, keeping client specifics out of product code) and a configurable
interac_deadline_hours.

Flow: selecting Interac at checkout calls _apply_updates, which sets the
transaction 'pending' and emails instructions (recipient address, amount,
reference, deadline) via a mail.template - no dynamic per-transaction data
needs to live in the static provider-level pending_msg field, since the
reference/amount are already shown on Odoo's generic payment status page.
A "Pending Interac Payments" admin list (Interac Payment Verifier group)
has a one-click "Payment Received" button calling action_confirm_interac_
payment (-> _set_done, which triggers Odoo's normal order/invoice
reconciliation - no need to reimplement that). An hourly cron cancels
unconfirmed pending transactions past the deadline and emails a
cancellation notice.

Verified against a live Odoo 19 + Postgres 16 container: 4/4 automated
tests pass, plus a full manual live run of both cycles the plan's gate
asks for - drove a transaction through the actual /payment/interac/process
controller to pending (confirmed the instructions email), used the
treasurer action to confirm it to 'done', and separately backdated a
second transaction's last_state_change and triggered the auto-cancel cron
via ir.cron's method_direct_trigger, confirming both the state change to
'cancel' and the cancellation email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:59:15 -04:00
metatroncubeswdev
91e91fe1b8 feat(community_classifieds, community_benefits): Phase 4
community_classifieds: a member-gated classifieds board. community.classified
(title/category/description/up-to-3-images via a child .image model/contact
info/state) with a public board at /classifieds, detail pages that track
view_count, portal posting at /classifieds/new (soft-detects
community_membership - blocks non-active-members only if that module is
installed, otherwise anyone logged in can post), a "my listings" portal
page with self-service renew, and an admin moderation queue
(publish/reject). Daily crons expire past-due listings and send a 7-day
expiry warning; a new-submission notice goes out to the Classifieds
Moderator group. Listing duration is configurable (Settings), never
hardcoded.

community_benefits: community.benefit.partner (benefit centres) offer
community.benefit entitlements scoped to specific community_membership
tiers via tier_ids. community.benefit.redemption logs a redemption but
its create() is guarded by a constraint that re-checks the same
active-membership condition the membership QR verification page uses
(membership_state in active/renewal_due) plus tier entitlement, refusing
the redemption otherwise. Public directory at /benefits, portal page at
/my/benefits listing only benefits the member's tier actually entitles
them to.

Two more real Odoo 19 API changes hit here: search-view <group> elements
for "Group By" sections no longer accept a `string` attribute (must use
`name` only - same fix as community_membership's partner search view,
applied here to a fresh module), and res.groups.users was renamed to
user_ids.

Verified against a live Odoo 19 + Postgres 16 container: 7+4 automated
tests pass, plus a full manual live run covering the Phase 4 gate exactly -
confirmed a non-member is blocked from /classifieds/new, activated a real
membership, posted a classified (pending_review), published it via the
moderator action, confirmed it appears on the public board and detail
page with view_count incrementing, entitled a tier to a benefit, logged a
redemption for the active member, confirmed it shows on /my/benefits, and
confirmed redemption creation is refused for a non-member.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:51:38 -04:00
metatroncubeswdev
e05448b939 feat(community_school): multi-step registration, waitlist, LMS glue (Session 3-C)
Adds the multi-step website registration at /school/register (parent ->
student -> class), carrying state across steps in the request session
(auth='public' - a family with no account yet can register). Finalizing
creates/finds the parent partner by email, creates the child partner +
student, and creates the enrollment as 'enrolled' or 'waitlist' depending
on whether the chosen class.fee_product_id/max_students still has room -
generating a draft fee invoice when the class has a fee (a new
class.fee/fee_product_id, following the same auto-created-product pattern
as community_membership's tiers).

LMS glue: enrollment create/write now auto-enrols the student's partner
into the class's slide.channel via _action_add_members() when state
becomes 'enrolled', and deactivates the slide.channel.partner membership
on withdrawal. An hourly cron (_cron_promote_waitlist) fills freed seats
from the waitlist in enrollment-date order and emails the parent.

Adds scripts/migrate_classroom.py: a standalone, dependency-free (stdlib
only) JSON-RPC script that reads a title,url CSV and creates slide.slide
records in a target channel - YouTube links become published video slides
(source_type='external' lets Odoo's own compute fields resolve youtube_id
automatically), everything else becomes an unpublished document slide
flagged for manual re-upload/review, since the script can't read Google
Drive content itself. Idempotent by (channel_id, title); --dry-run and
--force supported.

Verified against a live Odoo 19 + Postgres 16 container: 14/14 automated
tests pass, plus full manual live runs - walked all three registration
steps over real HTTP (with CSRF tokens) and confirmed the resulting
enrollment and draft invoice; ran the migration script twice against a
real channel and confirmed the second run skipped both already-created
slides, with the YouTube slide's youtube_id correctly auto-derived.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:42:51 -04:00
metatroncubeswdev
7ac5880f20 feat(community_school): core models + teacher attendance portal (Sessions 3-A, 3-B)
Session 3-A - core models: community.school.term, .level (admin-defined,
so the same module fits a Grade 1-12 school or a Beginner-Advanced
language school with no code change), .class (auto-creates a linked
slide.channel for LMS glue), .student, .enrollment, and .attendance, plus
is_teacher on res.partner. Class enrolled_count and enrollment
attendance_rate are stored/non-stored computes driven by the
enrollment/attendance one2many chains.

Session 3-B - teacher attendance + at-risk reporting: a portal page at
/school/attendance (auth='user', scoped to classes where teacher_id
matches the logged-in user's partner - works whether the teacher is an
internal or portal user) with a roster and batch save, built as a v19
Interaction (same pattern as event_qr_ticketing's check-in page). Marking
a student absent queues a configurable notice to the parent partner.
Enrollment.is_at_risk flags students below a configurable attendance
threshold (School settings page, same <app>/<block>/<setting> pattern as
community_membership), plus an admin pivot attendance report and an
At-Risk Students list.

Verified against a live Odoo 19 + Postgres 16 container: 14/14 automated
tests pass (class/slide-channel creation, enrolled_count tracking incl.
withdrawal, attendance_rate and at-risk computation, unique
enrollment+date constraint, absence email queuing), plus a full manual
live run - created a term/level/class/student/enrollment via JSON-RPC,
loaded the actual /school/attendance page as the teacher, POSTed a batch
save marking the student absent, and confirmed both the attendance record
and the queued "Absence notice" mail.mail record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:37:56 -04:00
114 changed files with 4779 additions and 13 deletions

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

View File

@ -84,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

View File

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

View File

@ -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,

View File

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

View 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})

View File

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

View 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),
]

View 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')

View 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."
)

View 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>

View File

@ -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

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

View File

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

View 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,
})

View 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>

View 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>

View File

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

View File

@ -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,

View File

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

View 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')

View 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>

View 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>

View File

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

View 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)

View 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.",
)

View File

@ -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>

View File

@ -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

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

View File

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

View 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)

View 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>

View 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>

View File

@ -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>

View File

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

View File

@ -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,

View File

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

View File

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

View 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')

View 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>

View 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>

View 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>

View 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>

View File

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

View 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

View 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

View 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>

View File

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

View 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')

View File

@ -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>

View 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>

View File

@ -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,

View File

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

View File

@ -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,

View File

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

View 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})

View File

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

View 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")

View 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>

View File

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

View File

@ -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,

View File

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

View 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}

View 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',
})

View 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>

View 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>

View 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

View 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.",
)

View File

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

View 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)

View 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

View 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)

View 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.')

View 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

View 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,
)

View File

@ -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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_community_school_term_coordinator community.school.term coordinator model_community_school_term group_school_coordinator 1 1 1 1
3 access_community_school_term_teacher community.school.term teacher read model_community_school_term group_school_teacher 1 0 0 0
4 access_community_school_level_coordinator community.school.level coordinator model_community_school_level group_school_coordinator 1 1 1 1
5 access_community_school_level_teacher community.school.level teacher read model_community_school_level group_school_teacher 1 0 0 0
6 access_community_school_class_coordinator community.school.class coordinator model_community_school_class group_school_coordinator 1 1 1 1
7 access_community_school_class_teacher community.school.class teacher read model_community_school_class group_school_teacher 1 0 0 0
8 access_community_school_student_coordinator community.school.student coordinator model_community_school_student group_school_coordinator 1 1 1 1
9 access_community_school_student_teacher community.school.student teacher read model_community_school_student group_school_teacher 1 0 0 0
10 access_community_school_enrollment_coordinator community.school.enrollment coordinator model_community_school_enrollment group_school_coordinator 1 1 1 1
11 access_community_school_enrollment_teacher community.school.enrollment teacher read model_community_school_enrollment group_school_teacher 1 0 0 0
12 access_community_school_attendance_coordinator community.school.attendance coordinator model_community_school_attendance group_school_coordinator 1 1 1 1
13 access_community_school_attendance_teacher community.school.attendance teacher model_community_school_attendance group_school_teacher 1 1 1 0
14 access_community_school_student_portal community.school.student portal read model_community_school_student base.group_portal 1 0 0 0
15 access_community_school_enrollment_portal community.school.enrollment portal read model_community_school_enrollment base.group_portal 1 0 0 0
16 access_community_school_attendance_portal community.school.attendance portal read model_community_school_attendance base.group_portal 1 0 0 0
17 access_community_school_class_portal community.school.class portal read model_community_school_class base.group_portal 1 0 0 0

View 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>

View 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);

View File

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

View 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)

View 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',
})

View 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")

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View File

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

View File

@ -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,

View File

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

View 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 '')

View File

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

View 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)

View 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)

View File

@ -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>

View 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>

View File

@ -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]

View File

@ -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()

View File

@ -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',
}

View 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>

View 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>

View 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