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>
This commit is contained in:
metatroncubeswdev 2026-08-17 21:51:38 -04:00
parent e05448b939
commit 91e91fe1b8
30 changed files with 1029 additions and 2 deletions

View File

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

View File

@ -23,9 +23,16 @@ entitled per membership tier:
'currency': 'USD', 'currency': 'USD',
'depends': [ 'depends': [
'contacts', 'contacts',
'website',
'portal',
'community_membership', 'community_membership',
], ],
'data': [], 'data': [
'security/benefits_security.xml',
'security/ir.model.access.csv',
'views/benefit_views.xml',
'views/benefits_templates.xml',
],
'demo': [], 'demo': [],
'images': ['static/description/banner.png'], 'images': ['static/description/banner.png'],
'application': False, 'application': False,

View File

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

View File

@ -0,0 +1,19 @@
from odoo import http
from odoo.http import request
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='user', website=True)
def my_benefits(self, **kwargs):
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 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', 'website',
'portal', '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': [], 'demo': [],
'images': ['static/description/banner.png'], 'images': ['static/description/banner.png'],
'application': False, 'application': False,

View File

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

View File

@ -0,0 +1,84 @@
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')
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='user', website=True)
def classifieds_new(self, **kwargs):
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='user', website=True)
def classifieds_my(self, **kwargs):
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='user', website=True)
def classifieds_renew(self, classified_id, **kwargs):
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 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>