privacy_consent: Avoid race condition with mail queue cron
Before this patch, all privacy consent emails were forced to be sent under a context with `mark_consent_sent=True` to track the state change and trigger the attached server action.
When using the automatic mode, if a separate cron job started running the mail queue, it could happen that mails were being sent from that another worker, losing the context key and, as such, being sent without token and without being marked as sent nor executing the attached server action (if any).
To avoid this problem, now the context dependency is removed. After all, the `mail.mail` object is only created when sending in `mass_mail` model, so other kind of notifications or messages are not affected. When a mail of type `mass_mail` is sent, we can assume that it is asking for consent and we can move draft consent requests to sent.
6 years ago privacy_consent: Separate automated emails send process
Before https://github.com/OCA/data-protection/pull/29 there was a race condition where an email could be sent while the same transaction that created the `privacy.consent` record still wasn't committed, producing a 404 error if the user clicked on "Accept" or "Reject" before all mails were sent.
To avoid that, a raw `cr.commit()` was issued, but this produced another situation where the user had to wait until the full email queue is cleared to get his page loaded. It wasn't an error, but a long queue meant several minutes waiting, and it's ulikely that an average human is so patient.
So, here's the final fix (I hope!). The main problem was that I was looking in the wrong place to send the email. It turns out that the `self.post_message_with_template()` method is absolutely helpless in the case at hand, where these criteria must be met:
* E-mail must be enqueued, no matter if there are less or more than 50 consents to send.
* The template must be processed per record.
* In an ideal world, a `cr.commit()` must be issued after each sent mail.
The metod that was being used:
* Didn't allow to use `auto_commit` mode.
* Only allowed to render the template per record if called with `composition_mode="mass_mail"`.
* Only allowed to enqueue emails if called with `composition_mode="mass_post"`.
Obviously, I cannot set 2 different values for `composition_mode`, so a different strategy had to be used.
I discovered that the `mail.template` model has a helpful method called `send_mail()` that, by default:
* Renders the template per record
* Enqueues the email
* The email queue is cleared in `auto_commit=True` mode.
So, from now on, problems are gone:
* The user click, or the cron run, will just generate the missing `privacy.consent` records and enqueue mails for them.
* The mail queue manager will send them later, in `auto_commit` mode.
* After sending the e-mail, this module will set the `privacy.consent` record as `sent`.
* Thanks to *not* sending the email, the process the user faces when he hits the "generate" button is faster.
* Instructions in the README and text in the "generate" button are updated to reflect this new behavior.
* Thanks to the `auto_commit` feature, if Odoo is rebooted in the middle of a mail queue clearance, the records that were sent remain properly marked as sent, and the missing mails will be sent after the next boot.
* No hardcoded commits.
* No locked transactions.
* BTW I discovered that 2 different emails were created when creating a new consent. I started using `mail_create_nolog=True` to avoid that problem and only log a single creation message.
Note to self: never use again `post_message_with_template()`.
6 years ago |
|
# Copyright 2018 Tecnativa - Jairo Llopis # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import hashlib import hmac
from odoo import api, fields, models
class PrivacyConsent(models.Model): _name = 'privacy.consent' _description = "Consent of data processing" _inherit = "mail.thread" _rec_name = "partner_id" _sql_constraints = [ ("unique_partner_activity", "UNIQUE(partner_id, activity_id)", "Duplicated partner in this data processing activity"), ]
active = fields.Boolean( default=True, index=True, ) accepted = fields.Boolean( track_visibility="onchange", help="Indicates current acceptance status, which can come from " "subject's last answer, or from the default specified in the " "related data processing activity.", ) last_metadata = fields.Text( readonly=True, track_visibility="onchange", help="Metadata from the last acceptance or rejection by the subject", ) partner_id = fields.Many2one( "res.partner", "Subject", required=True, readonly=True, track_visibility="onchange", help="Subject asked for consent.", ) activity_id = fields.Many2one( "privacy.activity", "Activity", readonly=True, required=True, track_visibility="onchange", ) state = fields.Selection( selection=[ ("draft", "Draft"), ("sent", "Awaiting response"), ("answered", "Answered"), ], default="draft", readonly=True, required=True, track_visibility="onchange", )
def _track_subtype(self, init_values): """Return specific subtypes.""" if self.env.context.get("subject_answering"): return "privacy_consent.mt_consent_acceptance_changed" if "activity_id" in init_values or "partner_id" in init_values: return "privacy_consent.mt_consent_consent_new" if "state" in init_values: return "privacy_consent.mt_consent_state_changed" return super(PrivacyConsent, self)._track_subtype(init_values)
def _token(self): """Secret token to publicly authenticate this record.""" secret = self.env["ir.config_parameter"].sudo().get_param( "database.secret") params = "{}-{}-{}-{}".format( self.env.cr.dbname, self.id, self.partner_id.id, self.activity_id.id, ) return hmac.new( secret.encode('utf-8'), params.encode('utf-8'), hashlib.sha512, ).hexdigest()
def _url(self, accept): """Tokenized URL to let subject decide consent.
:param bool accept: Indicates if you want the acceptance URL, or the rejection one. """
return "/privacy/consent/{}/{}/{}?db={}".format( "accept" if accept else "reject", self.id, self._token(), self.env.cr.dbname, )
def _send_consent_notification(self): """Send email notification to subject.""" for one in self.with_context(tpl_force_default_to=True, mail_notify_user_signature=False, mail_auto_subscribe_no_notify=True): one.activity_id.consent_template_id.send_mail(one.id)
def _run_action(self): """Execute server action defined in data processing activity.""" for one in self: # Always skip draft consents if one.state == "draft": continue action = one.activity_id.server_action_id.with_context( active_id=one.id, active_ids=one.ids, active_model=one._name, ) action.run()
@api.model_create_multi def create(self, vals_list): """Run server action on create.""" super_ = super(PrivacyConsent, self.with_context(mail_create_nolog=True)) results = super_.create(vals_list) # Sync the default acceptance status results.sudo()._run_action() return results
def write(self, vals): """Run server action on update.""" result = super().write(vals) self._run_action() return result
def message_get_suggested_recipients(self): result = super() \ .message_get_suggested_recipients() reason = self._fields["partner_id"].string for one in self: one._message_add_suggested_recipient( result, partner=one.partner_id, reason=reason, ) return result
def action_manual_ask(self): """Let user manually ask for consent.""" return { "context": { "default_composition_mode": "mass_mail", "default_model": self._name, "default_res_id": self.id, "default_template_id": self.activity_id.consent_template_id.id, "default_use_template": True, "tpl_force_default_to": True, }, "force_email": True, "res_model": "mail.compose.message", "target": "new", "type": "ir.actions.act_window", "view_mode": "form", }
def action_auto_ask(self): """Automatically ask for consent.""" templated = self.filtered("activity_id.consent_template_id") automated = templated.filtered( lambda one: one.activity_id.consent_required == "auto") automated._send_consent_notification()
def action_answer(self, answer, metadata=False): """Process answer.
:param bool answer: Did the subject accept?
:param str metadata: Metadata from last user acceptance or rejection request. """
self.write({ "state": "answered", "accepted": answer, "last_metadata": metadata, })
|