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>
135 lines
6.9 KiB
XML
135 lines
6.9 KiB
XML
<?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>
|