From 91e91fe1b868df02e3f50af12466b1d4295357d3 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 17 Aug 2026 21:51:38 -0400 Subject: [PATCH] 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 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 --- addons/community_benefits/__init__.py | 2 + addons/community_benefits/__manifest__.py | 9 +- .../controllers/__init__.py | 1 + addons/community_benefits/controllers/main.py | 19 +++ addons/community_benefits/models/__init__.py | 3 + addons/community_benefits/models/benefit.py | 45 ++++++ .../models/benefit_partner.py | 25 ++++ .../models/benefit_redemption.py | 28 ++++ .../security/benefits_security.xml | 19 +++ .../security/ir.model.access.csv | 3 + addons/community_benefits/tests/__init__.py | 1 + .../community_benefits/tests/test_benefits.py | 66 +++++++++ .../views/benefit_views.xml | 114 +++++++++++++++ .../views/benefits_templates.xml | 53 +++++++ addons/community_classifieds/__init__.py | 2 + addons/community_classifieds/__manifest__.py | 10 +- .../controllers/__init__.py | 1 + .../community_classifieds/controllers/main.py | 84 +++++++++++ addons/community_classifieds/data/ir_cron.xml | 23 +++ .../data/mail_templates.xml | 35 +++++ .../community_classifieds/models/__init__.py | 2 + .../models/community_classified.py | 133 +++++++++++++++++ .../models/res_config_settings.py | 12 ++ .../security/classifieds_security.xml | 19 +++ .../security/ir.model.access.csv | 2 + .../community_classifieds/tests/__init__.py | 1 + .../tests/test_classifieds.py | 79 +++++++++++ .../views/classified_views.xml | 85 +++++++++++ .../views/classifieds_templates.xml | 134 ++++++++++++++++++ .../views/res_config_settings_views.xml | 21 +++ 30 files changed, 1029 insertions(+), 2 deletions(-) create mode 100644 addons/community_benefits/controllers/__init__.py create mode 100644 addons/community_benefits/controllers/main.py create mode 100644 addons/community_benefits/models/__init__.py create mode 100644 addons/community_benefits/models/benefit.py create mode 100644 addons/community_benefits/models/benefit_partner.py create mode 100644 addons/community_benefits/models/benefit_redemption.py create mode 100644 addons/community_benefits/security/benefits_security.xml create mode 100644 addons/community_benefits/tests/test_benefits.py create mode 100644 addons/community_benefits/views/benefit_views.xml create mode 100644 addons/community_benefits/views/benefits_templates.xml create mode 100644 addons/community_classifieds/controllers/__init__.py create mode 100644 addons/community_classifieds/controllers/main.py create mode 100644 addons/community_classifieds/data/ir_cron.xml create mode 100644 addons/community_classifieds/data/mail_templates.xml create mode 100644 addons/community_classifieds/models/__init__.py create mode 100644 addons/community_classifieds/models/community_classified.py create mode 100644 addons/community_classifieds/models/res_config_settings.py create mode 100644 addons/community_classifieds/security/classifieds_security.xml create mode 100644 addons/community_classifieds/tests/test_classifieds.py create mode 100644 addons/community_classifieds/views/classified_views.xml create mode 100644 addons/community_classifieds/views/classifieds_templates.xml create mode 100644 addons/community_classifieds/views/res_config_settings_views.xml diff --git a/addons/community_benefits/__init__.py b/addons/community_benefits/__init__.py index e69de29..f7209b1 100644 --- a/addons/community_benefits/__init__.py +++ b/addons/community_benefits/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/community_benefits/__manifest__.py b/addons/community_benefits/__manifest__.py index bdfb588..35fbbdc 100644 --- a/addons/community_benefits/__manifest__.py +++ b/addons/community_benefits/__manifest__.py @@ -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, diff --git a/addons/community_benefits/controllers/__init__.py b/addons/community_benefits/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/community_benefits/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/community_benefits/controllers/main.py b/addons/community_benefits/controllers/main.py new file mode 100644 index 0000000..e32ac9c --- /dev/null +++ b/addons/community_benefits/controllers/main.py @@ -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}) diff --git a/addons/community_benefits/models/__init__.py b/addons/community_benefits/models/__init__.py new file mode 100644 index 0000000..c1698bc --- /dev/null +++ b/addons/community_benefits/models/__init__.py @@ -0,0 +1,3 @@ +from . import benefit_partner +from . import benefit +from . import benefit_redemption diff --git a/addons/community_benefits/models/benefit.py b/addons/community_benefits/models/benefit.py new file mode 100644 index 0000000..529b76b --- /dev/null +++ b/addons/community_benefits/models/benefit.py @@ -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), + ] diff --git a/addons/community_benefits/models/benefit_partner.py b/addons/community_benefits/models/benefit_partner.py new file mode 100644 index 0000000..f44b606 --- /dev/null +++ b/addons/community_benefits/models/benefit_partner.py @@ -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') diff --git a/addons/community_benefits/models/benefit_redemption.py b/addons/community_benefits/models/benefit_redemption.py new file mode 100644 index 0000000..ecb0ebf --- /dev/null +++ b/addons/community_benefits/models/benefit_redemption.py @@ -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." + ) diff --git a/addons/community_benefits/security/benefits_security.xml b/addons/community_benefits/security/benefits_security.xml new file mode 100644 index 0000000..ab40443 --- /dev/null +++ b/addons/community_benefits/security/benefits_security.xml @@ -0,0 +1,19 @@ + + + + Benefits + 23 + + + + Benefits + + + + + Benefits Manager + + + Can manage benefit centres, benefits, and log redemptions. + + diff --git a/addons/community_benefits/security/ir.model.access.csv b/addons/community_benefits/security/ir.model.access.csv index 97dd8b9..272091e 100644 --- a/addons/community_benefits/security/ir.model.access.csv +++ b/addons/community_benefits/security/ir.model.access.csv @@ -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 diff --git a/addons/community_benefits/tests/__init__.py b/addons/community_benefits/tests/__init__.py index e69de29..c7814cf 100644 --- a/addons/community_benefits/tests/__init__.py +++ b/addons/community_benefits/tests/__init__.py @@ -0,0 +1 @@ +from . import test_benefits diff --git a/addons/community_benefits/tests/test_benefits.py b/addons/community_benefits/tests/test_benefits.py new file mode 100644 index 0000000..031105b --- /dev/null +++ b/addons/community_benefits/tests/test_benefits.py @@ -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, + }) diff --git a/addons/community_benefits/views/benefit_views.xml b/addons/community_benefits/views/benefit_views.xml new file mode 100644 index 0000000..4d82fa0 --- /dev/null +++ b/addons/community_benefits/views/benefit_views.xml @@ -0,0 +1,114 @@ + + + + community.benefit.partner.list + community.benefit.partner + + + + + + + + + + community.benefit.partner.form + community.benefit.partner + +
+ +

+ + + + + + + + + + + + + +
+
+
+
+ + Benefit Centres + community.benefit.partner + list,form + + + + community.benefit.list + community.benefit + + + + + + + + + + + + community.benefit.form + community.benefit + +
+ +

+ + + + + + + + + + + + + + +
+
+
+
+ + Benefits + community.benefit + list,form + + + + community.benefit.redemption.list + community.benefit.redemption + + + + + + + + + + + Redemptions + community.benefit.redemption + list,form + + + + + + +
diff --git a/addons/community_benefits/views/benefits_templates.xml b/addons/community_benefits/views/benefits_templates.xml new file mode 100644 index 0000000..c6aa8ac --- /dev/null +++ b/addons/community_benefits/views/benefits_templates.xml @@ -0,0 +1,53 @@ + + + + + + diff --git a/addons/community_classifieds/__init__.py b/addons/community_classifieds/__init__.py index e69de29..f7209b1 100644 --- a/addons/community_classifieds/__init__.py +++ b/addons/community_classifieds/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/community_classifieds/__manifest__.py b/addons/community_classifieds/__manifest__.py index c3bc7df..4d7b7d6 100644 --- a/addons/community_classifieds/__manifest__.py +++ b/addons/community_classifieds/__manifest__.py @@ -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, diff --git a/addons/community_classifieds/controllers/__init__.py b/addons/community_classifieds/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/community_classifieds/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/community_classifieds/controllers/main.py b/addons/community_classifieds/controllers/main.py new file mode 100644 index 0000000..f800beb --- /dev/null +++ b/addons/community_classifieds/controllers/main.py @@ -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/'], 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//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') diff --git a/addons/community_classifieds/data/ir_cron.xml b/addons/community_classifieds/data/ir_cron.xml new file mode 100644 index 0000000..29caf8c --- /dev/null +++ b/addons/community_classifieds/data/ir_cron.xml @@ -0,0 +1,23 @@ + + + + + Classifieds: Expire Listings + + code + model._cron_expire_listings() + 1 + days + + + + Classifieds: Send Expiry Warnings + + code + model._cron_send_expiry_warnings() + 1 + days + + + + diff --git a/addons/community_classifieds/data/mail_templates.xml b/addons/community_classifieds/data/mail_templates.xml new file mode 100644 index 0000000..844f973 --- /dev/null +++ b/addons/community_classifieds/data/mail_templates.xml @@ -0,0 +1,35 @@ + + + + + Classifieds: New Submission + + New classified pending review: {{ object.title }} + + +
+

A new classified listing is pending review:

+

+

Posted by:

+
+
+
+ + + Classifieds: Expiry Warning + + Your classified "{{ object.title }}" expires soon + {{ object.poster_partner_id.id }} + + +
+

Dear Member,

+

+ Your classified listing "" expires on + . Renew it from your portal to keep it visible. +

+
+
+
+
+
diff --git a/addons/community_classifieds/models/__init__.py b/addons/community_classifieds/models/__init__.py new file mode 100644 index 0000000..3595379 --- /dev/null +++ b/addons/community_classifieds/models/__init__.py @@ -0,0 +1,2 @@ +from . import community_classified +from . import res_config_settings diff --git a/addons/community_classifieds/models/community_classified.py b/addons/community_classifieds/models/community_classified.py new file mode 100644 index 0000000..80538c4 --- /dev/null +++ b/addons/community_classifieds/models/community_classified.py @@ -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) diff --git a/addons/community_classifieds/models/res_config_settings.py b/addons/community_classifieds/models/res_config_settings.py new file mode 100644 index 0000000..cac083b --- /dev/null +++ b/addons/community_classifieds/models/res_config_settings.py @@ -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.", + ) diff --git a/addons/community_classifieds/security/classifieds_security.xml b/addons/community_classifieds/security/classifieds_security.xml new file mode 100644 index 0000000..af52897 --- /dev/null +++ b/addons/community_classifieds/security/classifieds_security.xml @@ -0,0 +1,19 @@ + + + + Classifieds + 22 + + + + Classifieds + + + + + Classifieds Moderator + + + Can review, publish, and reject classified listings. + + diff --git a/addons/community_classifieds/security/ir.model.access.csv b/addons/community_classifieds/security/ir.model.access.csv index 97dd8b9..c7dbd30 100644 --- a/addons/community_classifieds/security/ir.model.access.csv +++ b/addons/community_classifieds/security/ir.model.access.csv @@ -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 diff --git a/addons/community_classifieds/tests/__init__.py b/addons/community_classifieds/tests/__init__.py index e69de29..0e73d68 100644 --- a/addons/community_classifieds/tests/__init__.py +++ b/addons/community_classifieds/tests/__init__.py @@ -0,0 +1 @@ +from . import test_classifieds diff --git a/addons/community_classifieds/tests/test_classifieds.py b/addons/community_classifieds/tests/test_classifieds.py new file mode 100644 index 0000000..00f845d --- /dev/null +++ b/addons/community_classifieds/tests/test_classifieds.py @@ -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) diff --git a/addons/community_classifieds/views/classified_views.xml b/addons/community_classifieds/views/classified_views.xml new file mode 100644 index 0000000..b1c19c5 --- /dev/null +++ b/addons/community_classifieds/views/classified_views.xml @@ -0,0 +1,85 @@ + + + + community.classified.list + community.classified + + + + + + + + + + + + + + community.classified.form + community.classified + +
+
+
+ +

+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + community.classified.search + community.classified + + + + + + + + + + + + + + + Classifieds Moderation + community.classified + list,form + + {'search_default_pending_review': 1} + + + + +
diff --git a/addons/community_classifieds/views/classifieds_templates.xml b/addons/community_classifieds/views/classifieds_templates.xml new file mode 100644 index 0000000..207b8f3 --- /dev/null +++ b/addons/community_classifieds/views/classifieds_templates.xml @@ -0,0 +1,134 @@ + + + + + + +