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:
parent
e05448b939
commit
91e91fe1b8
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@ -23,9 +23,16 @@ entitled per membership tier:
|
||||
'currency': 'USD',
|
||||
'depends': [
|
||||
'contacts',
|
||||
'website',
|
||||
'portal',
|
||||
'community_membership',
|
||||
],
|
||||
'data': [],
|
||||
'data': [
|
||||
'security/benefits_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/benefit_views.xml',
|
||||
'views/benefits_templates.xml',
|
||||
],
|
||||
'demo': [],
|
||||
'images': ['static/description/banner.png'],
|
||||
'application': False,
|
||||
|
||||
1
addons/community_benefits/controllers/__init__.py
Normal file
1
addons/community_benefits/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
19
addons/community_benefits/controllers/main.py
Normal file
19
addons/community_benefits/controllers/main.py
Normal 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})
|
||||
3
addons/community_benefits/models/__init__.py
Normal file
3
addons/community_benefits/models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import benefit_partner
|
||||
from . import benefit
|
||||
from . import benefit_redemption
|
||||
45
addons/community_benefits/models/benefit.py
Normal file
45
addons/community_benefits/models/benefit.py
Normal 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),
|
||||
]
|
||||
25
addons/community_benefits/models/benefit_partner.py
Normal file
25
addons/community_benefits/models/benefit_partner.py
Normal 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')
|
||||
28
addons/community_benefits/models/benefit_redemption.py
Normal file
28
addons/community_benefits/models/benefit_redemption.py
Normal 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."
|
||||
)
|
||||
19
addons/community_benefits/security/benefits_security.xml
Normal file
19
addons/community_benefits/security/benefits_security.xml
Normal 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>
|
||||
@ -1 +1,4 @@
|
||||
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
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
from . import test_benefits
|
||||
66
addons/community_benefits/tests/test_benefits.py
Normal file
66
addons/community_benefits/tests/test_benefits.py
Normal 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,
|
||||
})
|
||||
114
addons/community_benefits/views/benefit_views.xml
Normal file
114
addons/community_benefits/views/benefit_views.xml
Normal 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>
|
||||
53
addons/community_benefits/views/benefits_templates.xml
Normal file
53
addons/community_benefits/views/benefits_templates.xml
Normal 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>
|
||||
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@ -25,7 +25,15 @@ Soft-detects Community OS Membership; runs standalone without it.
|
||||
'website',
|
||||
'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': [],
|
||||
'images': ['static/description/banner.png'],
|
||||
'application': False,
|
||||
|
||||
1
addons/community_classifieds/controllers/__init__.py
Normal file
1
addons/community_classifieds/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
84
addons/community_classifieds/controllers/main.py
Normal file
84
addons/community_classifieds/controllers/main.py
Normal 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')
|
||||
23
addons/community_classifieds/data/ir_cron.xml
Normal file
23
addons/community_classifieds/data/ir_cron.xml
Normal 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>
|
||||
35
addons/community_classifieds/data/mail_templates.xml
Normal file
35
addons/community_classifieds/data/mail_templates.xml
Normal 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>
|
||||
2
addons/community_classifieds/models/__init__.py
Normal file
2
addons/community_classifieds/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import community_classified
|
||||
from . import res_config_settings
|
||||
133
addons/community_classifieds/models/community_classified.py
Normal file
133
addons/community_classifieds/models/community_classified.py
Normal 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)
|
||||
12
addons/community_classifieds/models/res_config_settings.py
Normal file
12
addons/community_classifieds/models/res_config_settings.py
Normal 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.",
|
||||
)
|
||||
@ -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>
|
||||
@ -1 +1,3 @@
|
||||
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
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
from . import test_classifieds
|
||||
79
addons/community_classifieds/tests/test_classifieds.py
Normal file
79
addons/community_classifieds/tests/test_classifieds.py
Normal 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)
|
||||
85
addons/community_classifieds/views/classified_views.xml
Normal file
85
addons/community_classifieds/views/classified_views.xml
Normal 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>
|
||||
134
addons/community_classifieds/views/classifieds_templates.xml
Normal file
134
addons/community_classifieds/views/classifieds_templates.xml
Normal 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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user