Compare commits

..

No commits in common. "b0998f81a7e9ffab6da1f92f8d84640a34f63f2f" and "35e62e8c61409340929a3e2b4f1e82f4016e6dd5" have entirely different histories.

5 changed files with 1 additions and 429 deletions

View File

@ -1,14 +1,6 @@
Changelog Changelog
========= =========
19.0.1.0.1 (2026-08-24)
~~~~~~~~~~~~~~~~~~~~~~~~
* Every Odoo Administrator now automatically gets the Classifieds
Moderator group, instead of needing it granted by hand. Without this,
a standalone install (no deployment layer seeding roles) left the
installing admin unable to see the Classifieds app or Moderation queue
at all.
19.0.1.0.0 (2026-08-17) 19.0.1.0.0 (2026-08-17)
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
* Initial release: public classifieds board, member-gated posting * Initial release: public classifieds board, member-gated posting

View File

@ -1,6 +1,6 @@
{ {
'name': 'Community OS - Classifieds', 'name': 'Community OS - Classifieds',
'version': '19.0.1.0.1', 'version': '19.0.1.0.0',
'category': 'Website/Website', 'category': 'Website/Website',
'summary': 'Member-gated classifieds board with moderation queue and auto-expiry', 'summary': 'Member-gated classifieds board with moderation queue and auto-expiry',
'description': """ 'description': """

View File

@ -16,19 +16,4 @@
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can review, publish, and reject classified listings.</field> <field name="comment">Can review, publish, and reject classified listings.</field>
</record> </record>
<!-- This module is meant to run standalone (see soft-detection of
community_membership in controllers/main.py) - it can't assume a
deployment layer exists to grant this group to anyone. Without
this, a fresh install leaves the installing Administrator unable
to see the Classifieds app or Moderation queue at all, since
group_classifieds_moderator isn't implied by anything the admin
already has - a deployment layer can hit the same gap by seeding
its own client role groups and forgetting to grant this one too.
Every Odoo Administrator (Settings > Users & Companies > Users >
Administrator access) gets moderator rights automatically
instead, so no deployment-layer wiring is required. -->
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_classifieds_moderator'))]"/>
</record>
</odoo> </odoo>

View File

@ -1,167 +0,0 @@
# `community_classifieds` — Module Handover
A technical guide for taking this module out of the CommunityOS monorepo
and installing it in a different Odoo project. For how the module *works*
day-to-day, see the CommunityOS build plan or `README.rst` inside the
module itself — this doc is specifically about the move.
## What it is
A member-gated classifieds board: public listing board, a posting form
for logged-in users, an admin moderation queue (publish/reject), and
automatic expiry with warning emails. Odoo 19 Community Edition, licensed
`LGPL-3`.
## Dependencies
**Hard** (declared in `__manifest__.py`, both stock Odoo Community — no
Enterprise, nothing else from this suite):
```python
'depends': ['website', 'portal'],
```
**Soft — one runtime check, nowhere else**: `controllers/main.py` calls
`_is_module_installed(env, 'community_membership')` before gating who can
post. If `community_membership` is present *and* installed, posting
requires an active membership; if it's absent, that check is simply
skipped and posting only requires being logged in. This isn't in
`depends`, so Odoo has no idea the two modules are related — verified by
grepping the whole module for any other cross-reference to
`community_membership`, `community_theme_base`, `community_portal`, or
any other `community_*`/`event_qr_ticketing` module: there are none.
Every template it calls into (`website.layout`, `portal.portal_layout`)
comes from its own declared dependencies.
**Net effect**: copy the folder, install it in a project with just
`website` and `portal`, and it works exactly like it does here, minus
the optional membership-gating.
## Moving it
1. Copy `addons/community_classifieds/` into the new project's addons
path. That's the whole module — no other file in this repo is
required by it.
2. **No client data to strip.** This is a product-layer module in the
original repo's own architecture — it's already been through that
repo's brand-leak CI check (`scripts/check_brand_leak.py`), which
greps every `community_*` module for client names/emails/colours and
fails the build if it finds any. There's nothing TNCSC-specific
anywhere in it.
3. **Consider renaming the technical prefix** before distributing it
publicly or installing it alongside another copy of the same module —
`community_classifieds` could collide with another vendor's module of
the same name. If you rename the folder, note that the Python model
name (`community.classified`) is hardcoded in `models/community_classified.py`
and referenced by XML id throughout `views/`, `security/`, and
`data/` — a folder rename alone is enough (Odoo resolves everything
by folder/technical name automatically), but a *model* rename would
need a project-wide find-replace across every file in the module.
4. Install normally: `-i community_classifieds` (or via Apps once the
addons path is registered).
## Post-install: access & permissions
As of `19.0.1.0.1`, **every Odoo Administrator automatically gets
moderator access** — nothing to configure. This wasn't always true: a
standalone install used to leave the installing admin unable to see the
Classifieds app or Moderation queue at all, because the
`Classifieds Moderator` group wasn't implied by anything an admin already
had. Fixed in `security/classifieds_security.xml` by making
`base.group_system` (Odoo's built-in Administrator group) imply
`group_classifieds_moderator`:
```xml
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_classifieds_moderator'))]"/>
</record>
```
Verified on a from-scratch database with *only* `community_classifieds`
installed (`community_membership` and any deployment layer both
`uninstalled`): `admin.has_group('community_classifieds.group_classifieds_moderator')`
returns `True` and "Classifieds" appears in the app switcher immediately.
**To give a non-admin staff member moderator access**: Settings → Users
& Companies → Users → open their record → check **Classifieds Moderator**
under the Classifieds privilege group.
## Configuration
Settings → General Settings → **Classifieds** tab:
| Setting | Field / param | Default |
|---|---|---|
| Listing Duration (days) | `community_classifieds.expiry_days` | 30 |
One thing *not* exposed as a setting: the expiry-warning email fires
exactly **7 days** before expiry — `DEFAULT_WARNING_DAYS_BEFORE_EXPIRY`
in `models/community_classified.py`, a hardcoded constant. Change the
constant if a different lead time is needed; it's not wired to a
`res.config.settings` field.
## Data model
**`community.classified`** — the listing itself:
| Field | Type | Notes |
|---|---|---|
| `title` | Char | required |
| `category` | Selection | for_sale / housing / services / jobs / other |
| `description` | Html | |
| `image_ids` | One2many → `community.classified.image` | max 3, enforced by a `@api.constrains` |
| `contact_method` / `contact_email` / `contact_phone` | | |
| `poster_partner_id` | Many2one res.partner | readonly, set by the controller on create |
| `post_date` | Datetime | readonly, defaults to now |
| `expiry_date` | Date | readonly, computed on create/publish/renew |
| `state` | Selection | pending_review / published / expired / rejected |
| `admin_notes` | Text | |
| `view_count` | Integer | incremented via `sudo()` on each public detail-page view |
**`community.classified.image`** — `classified_id` (required, cascade
delete), `sequence`, `image` (Binary, `attachment=True`).
## Public routes
| Route | Auth | Notes |
|---|---|---|
| `GET /classifieds` | public | list, `?category=` filter, only `state=published` |
| `GET /classifieds/<id>` | public | detail page, 404s unless published |
| `GET`/`POST /classifieds/new` | portal login required | membership check if applicable (see Dependencies) |
| `GET /classifieds/my` | portal login required | poster's own listings, any state |
| `GET /classifieds/<id>/renew` | portal login required | only works if the requesting partner is the poster |
Login-required routes use `auth='public'` plus a manual redirect check
(`_redirect_to_login_if_public`), not `auth='user'` — a documented
workaround for an Odoo 19 bug where `auth='user'` + `website=True` throws
a raw 500 instead of redirecting to login in some builds. Worth knowing
before "simplifying" that pattern back to `auth='user'`.
## Background jobs
Two daily `ir.cron` entries (`data/ir_cron.xml`):
- **Expire Listings** — flips `published` past `expiry_date` to `expired`.
- **Send Expiry Warnings** — emails the poster when `expiry_date` is
exactly 7 days out.
## Email templates
`data/mail_templates.xml`: one to moderators on every new submission
(`mail_template_new_submission`), one to the poster on approaching expiry
(`mail_template_expiry_warning`). Both are looked up via
`env.ref(..., raise_if_not_found=False)` — if you strip the data file,
the module still runs, it just silently skips sending.
## Known gaps to fix before shipping this elsewhere
- **`static/description/banner.png` is referenced in the manifest's
`images` key but the file doesn't exist.** The App Store / Apps-list
listing will show a broken image until a real banner asset is added.
- **`demo/` is empty** (just a `.gitkeep`) — no demo data ships with the
module.
- Manifest `price`/`currency` are placeholder zero values (`0.00 USD`) —
set real pricing before listing it for sale.
## Tests
`tests/test_classifieds.py` covers: new listing starts pending review,
publish makes it visible, reject, the expiry cron, renew resets expiry,
the 3-image max constraint, and the configurable expiry-days setting.
Run with `--test-enable --test-tags /community_classifieds` on install.

View File

@ -1,238 +0,0 @@
#!/usr/bin/env python3
"""Migrate TNCSC's real WordPress member export (users.csv) into Community
OS Membership.
This is deliberately separate from migrate_members.py, which expects a
clean, documented schema. users.csv is a real WordPress/WooCommerce export
with messy real-world data: duplicate "Country"/"State" columns (WordPress
exports the real profile ones AND a pair of empty leftover checkout-form
columns under the same header names - this script reads by column
*position*, not name, to get the real ones), free-text membership levels
that don't map 1:1 to our tier codes, ~229/493 rows with no legacy
membership ID at all, and several sensitive columns (date of birth,
citizenship/immigration-eligibility Yes/No answers) that have no field in
this system and are deliberately never read here.
Behaviour, confirmed with TNCSC before writing this:
- Rows whose email is on the @abbuzz.com test/spam domain are skipped
entirely (not real members).
- "Level" maps to a tier code: Annual/Family -> FAM, Long-Term
Membership -> LIFE, Student Annual Membership -> STU, Senior Annual
Membership -> SEN. Blank Level, or a comma-separated multi-value Level
(ambiguous - can't tell which one is current), is imported as a
contact with NO tier set and flagged 'needs_review' in the report -
never auto-guessed.
- The legacy "TNCSC Membership ID" column (where present) is never used
as the new member ID - the system generates its own via the
configured Member ID Format. Instead it's preserved as an internal
note (chatter message) on the partner, alongside the original raw
Level and Join Date, so it's still there for reference/lookup.
- Existing members are matched by email (case-insensitive) and updated;
re-running the same file converges rather than duplicating.
- State/province is intentionally NOT set - the source data for it is
frequently garbage in this export (e.g. a city name duplicated into
the state column, or blank), and guessing wrong is worse than leaving
it blank for staff to fill in. Country is resolved by name lookup.
Usage:
python scripts/migrate_wp_members.py \\
--url https://your-odoo-host --db tncsc_site \\
--username admin --password admin \\
--csv users.csv \\
--report data/raw/wp_members_migration_report.csv \\
[--dry-run]
"""
import argparse
import csv
import sys
from pathlib import Path
from _migration_common import MigrationReport, authenticate, execute_kw, normalize_email, normalize_phone
EXPECTED_HEADER_PREFIX = ['Username', 'Email', 'First Name', 'Last Name']
JUNK_EMAIL_DOMAINS = {'abbuzz.com'}
# Column indices, verified against the real export - see module docstring
# for why these are read positionally rather than by (duplicated) name.
COL_EMAIL = 1
COL_FIRST_NAME = 2
COL_LAST_NAME = 3
COL_PHONE = 6
COL_ADDRESS1 = 7
COL_ADDRESS2 = 8
COL_ZIP = 9
COL_CITY = 10
COL_STATE = 11
COL_COUNTRY = 12
COL_LEGACY_MEMBER_ID = 27
COL_LEVEL = 28
COL_JOIN_DATE = 30
LEVEL_TO_TIER_CODE = {
'Annual/Family': 'FAM',
'Long-Term Membership': 'LIFE',
'Student Annual Membership': 'STU',
'Senior Annual Membership': 'SEN',
}
def read_rows(csv_path):
with open(csv_path, newline='', encoding='utf-8-sig') as handle:
reader = csv.reader(handle)
header = next(reader)
if header[:4] != EXPECTED_HEADER_PREFIX:
raise RuntimeError(
f"CSV header doesn't match the expected users.csv shape "
f"(first 4 columns were {header[:4]!r}) - this script reads "
f"columns by position, so a differently-shaped export would "
f"silently misimport. Update the COL_* indices first."
)
for row in reader:
yield row
def parse_join_date(value):
"""'2018-09-24 16:34:25' -> '2018-09-24'. Returns None if unparseable."""
value = (value or '').strip()
if not value:
return None
date_part = value.split(' ')[0]
parts = date_part.split('-')
if len(parts) == 3 and all(p.isdigit() for p in parts):
return date_part
return None
def resolve_tier(level_raw, tier_by_code):
"""Returns (tier_id_or_None, needs_review_reason_or_None)."""
level = (level_raw or '').strip()
if not level:
return None, None
if ',' in level:
return None, f"ambiguous multi-value Level '{level}' - pick manually"
code = LEVEL_TO_TIER_CODE.get(level)
if not code:
return None, f"unrecognized Level '{level}' - pick manually"
tier_id = tier_by_code.get(code)
if not tier_id:
return None, f"tier code '{code}' (from Level '{level}') not found in this database"
return tier_id, None
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--url', required=True)
parser.add_argument('--db', required=True)
parser.add_argument('--username', required=True)
parser.add_argument('--password', required=True)
parser.add_argument('--csv', required=True, type=Path)
parser.add_argument('--report', required=True, type=Path)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
if not args.csv.exists():
print(f"CSV file not found: {args.csv}", file=sys.stderr)
return 1
uid = authenticate(args.url, args.db, args.username, args.password)
report = MigrationReport()
def rpc(model, method, call_args, kwargs=None):
return execute_kw(args.url, args.db, uid, args.password, model, method, call_args, kwargs)
tier_by_code = {}
for tier in rpc('community.membership.tier', 'search_read', [[]], {'fields': ['code']}):
tier_by_code[tier['code']] = tier['id']
countries_by_name = {}
for country in rpc('res.country', 'search_read', [[]], {'fields': ['name']}):
countries_by_name[country['name'].strip().lower()] = country['id']
seen_emails = set()
for row_number, row in enumerate(read_rows(args.csv), start=2):
email = normalize_email(row[COL_EMAIL])
key = email or f'row {row_number}'
if not email:
report.add(key, 'failed', 'missing email')
continue
if email.rsplit('@', 1)[-1] in JUNK_EMAIL_DOMAINS:
report.add(key, 'skipped', 'test/spam domain')
continue
if email in seen_emails:
report.add(key, 'failed', 'duplicate email in source file')
continue
seen_emails.add(email)
first_name = (row[COL_FIRST_NAME] or '').strip()
last_name = (row[COL_LAST_NAME] or '').strip()
name = f'{first_name} {last_name}'.strip()
if not name:
report.add(key, 'failed', 'missing first and last name')
continue
tier_id, tier_review_reason = resolve_tier(row[COL_LEVEL], tier_by_code)
vals = {
'name': name,
'email': email,
'phone': normalize_phone(row[COL_PHONE]),
'street': (row[COL_ADDRESS1] or '').strip() or False,
'street2': (row[COL_ADDRESS2] or '').strip() or False,
'zip': (row[COL_ZIP] or '').strip() or False,
'city': (row[COL_CITY] or '').strip() or False,
}
country_name = (row[COL_COUNTRY] or '').strip().lower()
if country_name in countries_by_name:
vals['country_id'] = countries_by_name[country_name]
if tier_id:
vals['membership_tier_id'] = tier_id
join_date = parse_join_date(row[COL_JOIN_DATE])
if join_date:
vals['membership_start'] = join_date
existing = rpc('res.partner', 'search', [[('email', '=', email)]], {'limit': 1})
if args.dry_run:
action = 'would_update' if existing else 'would_create'
report.add(key, 'needs_review' if tier_review_reason else action, tier_review_reason or '')
continue
if existing:
rpc('res.partner', 'write', [existing, vals])
partner_id = existing[0]
action = 'updated'
else:
partner_id = rpc('res.partner', 'create', [vals])
action = 'created'
legacy_id = (row[COL_LEGACY_MEMBER_ID] or '').strip()
raw_level = (row[COL_LEVEL] or '').strip()
if legacy_id or raw_level:
note_lines = ['Imported from TNCSC WordPress export (users.csv):']
if legacy_id:
note_lines.append(f'- Legacy TNCSC Membership ID: {legacy_id}')
if raw_level:
note_lines.append(f'- Original WordPress "Level": {raw_level}')
rpc('res.partner', 'message_post', [[partner_id]], {
'body': '<br/>'.join(note_lines),
})
if tier_id:
partner_state = rpc('res.partner', 'read', [[partner_id], ['membership_state']])[0]['membership_state']
if partner_state not in ('active', 'renewal_due'):
rpc('res.partner', 'action_activate_membership', [[partner_id]])
report.add(key, 'needs_review' if tier_review_reason else action, tier_review_reason or '')
report.write(args.report)
summary = report.summary()
print(f"Done. {summary}")
print(f"Report written to {args.report}")
return 1 if summary.get('failed') else 0
if __name__ == '__main__':
sys.exit(main())