add membership_expiry_reminder

This commit is contained in:
Luis 2026-05-04 12:51:13 +02:00
parent de0520d5a8
commit 9376d03d9d
8 changed files with 256 additions and 0 deletions

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import models

View file

@ -0,0 +1,17 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Membership Expiry Reminder",
"summary": "Sends an automated email reminder to members approaching their "
"membership expiry date, with a link to renew online.",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "Custom",
"depends": ["membership", "website_sale"],
"data": [
"data/mail_template.xml",
"data/ir_cron.xml",
"views/res_config_settings_views.xml",
],
"installable": True,
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_membership_expiry_reminder" model="ir.cron">
<field name="name">Membership: Send Expiry Reminder Emails</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="state">code</field>
<field name="code">model._cron_send_membership_expiry_reminders()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="user_id" ref="base.user_root"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="email_template_membership_expiry" model="mail.template">
<field name="name">Membership: Renewal Reminder</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="subject">Reminder: your membership expires soon</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.id }}</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin:0px;padding:0px;font-family:Arial,Helvetica,sans-serif;">
<table style="width:100%;max-width:600px;margin:auto;border-collapse:collapse;">
<tr>
<td style="padding:24px 24px 8px;background:#f8f8f8;border-bottom:2px solid #e0e0e0;">
<h2 style="margin:0;color:#333;">
Dear <t t-out="object.name or ''">Member Name</t>,
</h2>
</td>
</tr>
<tr>
<td style="padding:24px;">
<p style="color:#555;font-size:15px;line-height:1.6;">
This is a friendly reminder that your membership with
<strong t-out="object.company_id.name or ''">Your Organisation</strong>
is due to expire on
<strong t-out="format_date(object.membership_stop) or ''">expiry date</strong>.
</p>
<p style="color:#555;font-size:15px;line-height:1.6;">
To continue enjoying your membership benefits without interruption,
please renew your membership before that date.
</p>
<t t-set="last_line" t-value="object.member_lines and object.member_lines[0] or False"/>
<t t-if="last_line and last_line.membership_id and last_line.membership_id.product_tmpl_id.website_url">
<t t-set="product_url" t-value="object.get_base_url() + last_line.membership_id.product_tmpl_id.website_url"/>
<p style="text-align:center;margin:32px 0;">
<a t-att-href="product_url"
style="background:#875A7B;color:#fff;padding:14px 32px;border-radius:4px;
text-decoration:none;font-size:16px;font-weight:bold;display:inline-block;">
Renew my membership
</a>
</p>
<p style="color:#888;font-size:13px;text-align:center;">
Or copy this link into your browser:<br/>
<a t-att-href="product_url" style="color:#875A7B;">
<t t-out="product_url"/>
</a>
</p>
</t>
<p style="color:#555;font-size:15px;line-height:1.6;margin-top:24px;">
If you have any questions, please do not hesitate to contact us.
</p>
<p style="color:#555;font-size:15px;">
Kind regards,<br/>
<strong t-out="object.company_id.name or ''">Your Organisation</strong>
</p>
</td>
</tr>
<tr>
<td style="padding:16px 24px;background:#f8f8f8;border-top:1px solid #e0e0e0;
color:#aaa;font-size:12px;text-align:center;">
<t t-out="object.company_id.name or ''">Your Organisation</t>
<t t-if="object.company_id.street">
&#x2022; <t t-out="object.company_id.street"/>
</t>
<t t-if="object.company_id.email">
&#x2022; <t t-out="object.company_id.email"/>
</t>
</td>
</tr>
</table>
</div>
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import res_config_settings, res_partner

View file

@ -0,0 +1,15 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
membership_expiry_reminder_days = fields.Integer(
string="Days before expiry to send reminder",
default=30,
config_parameter="membership_expiry_reminder.days_before_expiry",
help="Number of days before the membership end date to send the renewal "
"reminder email. Set to 0 to disable automatic reminders.",
)

View file

@ -0,0 +1,88 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from datetime import timedelta
from odoo import fields, models
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = "res.partner"
membership_expiry_reminder_sent = fields.Boolean(
string="Expiry Reminder Sent",
default=False,
copy=False,
help="Checked when the membership renewal reminder email has been sent "
"for the current membership period. Reset automatically when the "
"membership is renewed.",
)
def _cron_send_membership_expiry_reminders(self):
"""Daily cron: find members whose membership expires within the next N days
and who have not yet received a reminder. This catches up on any members
that were missed on previous runs."""
days = int(
self.env["ir.config_parameter"]
.sudo()
.get_param("membership_expiry_reminder.days_before_expiry", 30)
)
if not days:
_logger.info(
"membership_expiry_reminder: disabled (days_before_expiry = 0)"
)
return
template = self.env.ref(
"membership_expiry_reminder.email_template_membership_expiry",
raise_if_not_found=False,
)
if not template:
_logger.warning(
"membership_expiry_reminder: email template not found, skipping."
)
return
today = fields.Date.today()
deadline = today + timedelta(days=days)
partners = self.search(
[
("membership_state", "=", "paid"),
("membership_stop", ">=", today),
("membership_stop", "<=", deadline),
("membership_expiry_reminder_sent", "=", False),
("email", "!=", False),
]
)
_logger.info(
"membership_expiry_reminder: sending reminders to %d partner(s) "
"(expiry between today and %s, i.e. within %d days)",
len(partners),
deadline,
days,
)
for partner in partners:
try:
template.send_mail(partner.id, force_send=True)
partner.membership_expiry_reminder_sent = True
except Exception:
_logger.exception(
"membership_expiry_reminder: failed to send email to partner %d (%s)",
partner.id,
partner.email,
)
def write(self, vals):
# Reset the reminder flag when the membership is renewed
# (i.e., when member_lines changes, which updates membership_stop)
result = super().write(vals)
if "membership_stop" in vals:
self.filtered("membership_expiry_reminder_sent").write(
{"membership_expiry_reminder_sent": False}
)
return result

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="res_config_settings_view_form_membership_expiry" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.membership.expiry.reminder</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="90"/>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Members" id="membership_expiry_reminder"
string="Members" name="membership">
<block title="Membership Renewal Reminders"
id="membership_expiry_reminder_block">
<setting
string="Expiry reminder"
help="An email is sent automatically to active members before their membership expires. Set to 0 to disable.">
<div class="text-muted content-group mt16">
<span>Send reminder </span>
<field name="membership_expiry_reminder_days"
class="text-center"
style="width: 10%; min-width: 4rem;"/>
<span> days before expiry</span>
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</data>
</odoo>