Compare commits
No commits in common. "b0998f81a7e9ffab6da1f92f8d84640a34f63f2f" and "35e62e8c61409340929a3e2b4f1e82f4016e6dd5" have entirely different histories.
b0998f81a7
...
35e62e8c61
@ -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
|
||||||
|
|||||||
@ -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': """
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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())
|
|
||||||
Loading…
x
Reference in New Issue
Block a user