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>
134 lines
4.8 KiB
Python
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)
|