TNCSC_Odoo/addons/community_classifieds/models/community_classified.py
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

134 lines
4.8 KiB
Python

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)