diff --git a/child_compassion/models/compassion_hold.py b/child_compassion/models/compassion_hold.py
index 79b27c82c..efbb1d702 100644
--- a/child_compassion/models/compassion_hold.py
+++ b/child_compassion/models/compassion_hold.py
@@ -486,24 +486,28 @@ def postpone_no_money_hold(self, additional_text=None):
}
hold.write(hold_vals)
- body = (
- "The no money hold for child {local_id} was expiring on "
- "{old_expiration} and was extended to {new_expiration} "
- "({extension_description} extension).{additional_text}"
- )
- hold.message_post(
- body=_(body.format(**values)),
- subject=_("No money hold extension"),
- subtype_xmlid="mail.mt_comment",
- )
-
- else:
- body = _(
- "The no money hold for child {local_id} is expiring on "
- "{old_expiration} and will not be extended since "
- "no sponsorship exists for this child."
- )
- hold.message_post(body=body.format(**values))
+ # ----------------------------------------------------
+ # NOTIFICATION (Only for NO_MONEY_HOLD)
+ # ----------------------------------------------------
+ if hold.type == HoldType.NO_MONEY_HOLD.value:
+ if hold.child_id.sponsor_id:
+ body = (
+ "The no money hold for child {local_id} was expiring on "
+ "{old_expiration} and was extended to {new_expiration} "
+ "({extension_description} extension).{additional_text}"
+ )
+ hold.message_post(
+ body=_(body.format(**values)),
+ subject=_("No money hold extension"),
+ subtype_xmlid="mail.mt_comment",
+ )
+ else:
+ body = _(
+ "The no money hold for child {local_id} is expiring on "
+ "{old_expiration} and will not be extended since "
+ "no sponsorship exists for this child."
+ )
+ hold.message_post(body=body.format(**values))
##########################################################################
# Mapping METHOD #
diff --git a/partner_communication/models/communication_snippet.py b/partner_communication/models/communication_snippet.py
index 119109745..e4a2bd322 100644
--- a/partner_communication/models/communication_snippet.py
+++ b/partner_communication/models/communication_snippet.py
@@ -1,12 +1,26 @@
from odoo import fields, models
+class CommunicationSnippetCategory(models.Model):
+ _name = "communication.snippet.category"
+ _description = "Communication Snippet Category"
+
+ name = fields.Char(string="Category Name", required=True)
+
+
class CommunicationSnippet(models.Model):
_name = "communication.snippet"
_description = "Communication Snippet"
name = fields.Char(required=True, index=True)
snippet_text = fields.Html(required=True, translate=True)
+ description = fields.Text(string="Description")
+
+ category_id = fields.Many2one(
+ "communication.snippet.category",
+ string="Category",
+ help="Category of the communication snippet",
+ )
def action_edit_snippet(self):
self.ensure_one()
diff --git a/partner_communication/security/ir.model.access.csv b/partner_communication/security/ir.model.access.csv
index ac4dfc9f3..57d0a57bc 100644
--- a/partner_communication/security/ir.model.access.csv
+++ b/partner_communication/security/ir.model.access.csv
@@ -11,3 +11,4 @@ access_partner_communication_generate_wizard,access_partner_communication_genera
access_partner_communication_download_print_wizard,access_partner_communication_download_print_wizard,model_partner_communication_download_print_job_wizard,base.group_user,1,0,1,0
access_partner_communication_default_config,access_partner_communication_default_config,model_partner_communication_default_config,base.group_user,1,1,1,1
access_communication_snippets,Full access on communication_snippets,model_communication_snippet,base.group_user,1,1,1,1
+access_comm_snippet_category_user,communication.snippet.category,model_communication_snippet_category,base.group_user,1,1,1,1
diff --git a/partner_communication/views/communication_snippet_view.xml b/partner_communication/views/communication_snippet_view.xml
index df1276fb9..e96ed7650 100644
--- a/partner_communication/views/communication_snippet_view.xml
+++ b/partner_communication/views/communication_snippet_view.xml
@@ -8,6 +8,14 @@
+
+
@@ -20,6 +28,8 @@
+
+
self.MAX_PAGE_COUNT:
+ msg = _("Oops your letter has %d pages. The limit is %d.") % (
+ n_pages,
+ self.MAX_PAGE_COUNT,
+ )
+
+ raise UserError(msg)
with Image(blob=pdf, resolution=96) as pdf_image:
preview = base64.b64encode(pdf_image.make_blob(format="jpeg"))
- except PolicyError as error:
- _logger.error(
- "ImageMagick policy error. Please add following line to "
- "/etc/Image-Magick-/policy.xml: "
- ' ',
- )
- raise UserError(
- _(
- "Please allow ImageMagick to write PDF files."
- " Ask an IT admin for help."
+
+ return self.isolated_write(
+ {
+ "state": "preview",
+ "generation_status": "done",
+ "generation_error_message": False,
+ "preview_image": preview,
+ "preview_pdf": base64.b64encode(pdf),
+ }
)
- ) from error
- except TypeError as error:
- raise UserError(
+
+ except (PolicyError, TypeError, UserError, Exception) as error:
+ error_message = (
_(
+ "Unfortunately the server cannot generate PDF documents "
+ "at the moment. Our IT team is informed and will fix this issue "
+ "as soon as possible."
+ )
+ if isinstance(error, PolicyError)
+ else str(error)
+ if isinstance(error, UserError)
+ else _(
"There was an error while generating the PDF of the letter. "
"Please check FPDF logs for more information."
)
- ) from error
-
- pdf_image = base64.b64encode(pdf)
-
- return self.write(
- {
- "state": "preview",
- "preview_image": preview,
- "preview_pdf": pdf_image,
- }
- )
+ )
+ _logger.error("Unable to generate PDF", exc_info=True)
+ if self.env.context.get("raise_error"):
+ raise UserError(error_message) from error
+ self.env.cr.rollback()
+ self.isolated_write(
+ {
+ "generation_status": "failed",
+ "generation_error_message": error_message,
+ }
+ )
def edit(self):
"""Generate a picture for preview."""
@@ -184,6 +213,7 @@ def generate_letters(self):
Launch S2B Creation job
:return: True
"""
+ self.generation_status = "finalizing"
self.with_delay(
identity_key="s2b_generator." + str(self.ids)
).generate_letters_job()
@@ -217,7 +247,7 @@ def generate_letters_job(self):
"res_model": letters._name,
},
)
- for atchmt in self.image_ids
+ for atchmt in self.image_ids.sorted(reverse=True)
]
letters += letters.create(vals)
@@ -227,11 +257,26 @@ def generate_letters_job(self):
# If the operation succeeds, notify the user
message = "Letters have been successfully generated."
self.env.user.notify_success(message=message)
- return self.write({"state": "done", "date": fields.Datetime.now()})
+
+ return self.isolated_write(
+ {
+ "state": "done",
+ "date": fields.Datetime.now(),
+ "generation_status": "done",
+ "generation_error_message": False,
+ }
+ )
except Exception as error:
# If the operation fails, notify the user with the error message
+ self.env.cr.rollback()
error_message = str(error)
+ self.isolated_write(
+ {
+ "generation_status": "failed",
+ "generation_error_message": error_message,
+ }
+ )
_logger.error(error_message, exc_info=True)
self.env.user.notify_danger(message=error_message)
@@ -257,8 +302,8 @@ def _get_text(self, sponsorship):
keywords = {
"%child%": child.preferred_name,
"%age%": str(child.age),
- "%firstname%": sponsor.firstname or sponsor.name,
- "%lastname%": sponsor.firstname and sponsor.lastname or "",
+ "%firstname%": sponsor.preferred_name or sponsor.firstname or sponsor.name,
+ "%lastname%": sponsor.lastname or "",
}
text = self.body
for keyword, replacement in list(keywords.items()):
@@ -285,7 +330,19 @@ def _get_pdf(self, sponsorship):
sponsorship.display_name,
(header, ""), # Headers (front/back)
{"Original": [text]}, # Text
- self.mapped("image_ids.datas"), # Images
+ self.mapped("image_ids").sorted(reverse=True).mapped("datas"), # Images
+ s2b_generator=self,
),
text,
)
+
+ def isolated_write(self, vals):
+ """Use a separate transaction to update the letter_generator."""
+ if len(self) != 1:
+ return False
+
+ with self.env.registry.cursor() as new_cr:
+ new_env = self.env(cr=new_cr)
+ new_s2b_generator = new_env[self._name].browse(self.id)
+ new_s2b_generator.write(vals)
+ new_cr.commit()
diff --git a/sbc_compassion/models/correspondence_template.py b/sbc_compassion/models/correspondence_template.py
index 7e09255cd..167ceb58a 100644
--- a/sbc_compassion/models/correspondence_template.py
+++ b/sbc_compassion/models/correspondence_template.py
@@ -112,13 +112,24 @@ def _compute_template_image(self):
##########################################################################
# PUBLIC METHODS #
##########################################################################
- def generate_pdf(self, pdf_name, header, text, image_data, background_list=None):
+ # ruff: noqa: C901 (Yes this function is complex... it will be removed in v17)
+ def generate_pdf(
+ self,
+ pdf_name,
+ header,
+ text,
+ image_data,
+ background_list=None,
+ s2b_generator=None,
+ ):
"""
Generate a pdf file
This function is nearly as generic as it should be to be implemented
directly to generate PDF for any template, text and image
We save every text to a temp txt file to avoid having to escape all
characters that could potentially be problematic
+ :param s2b_generator: a s2b.generator record to update
+ the generation status
:param pdf_name: path and name of the pdf file to write on
:param header: tuple of text for the headers to display
(first value is for front pages, second for back pages)
@@ -130,129 +141,170 @@ def generate_pdf(self, pdf_name, header, text, image_data, background_list=None)
the template.
"""
self.ensure_one()
+ if s2b_generator is None:
+ s2b_generator = self.env["correspondence.s2b.generator"]
- # Images stored on disk for FPDF processing. We keep them in these lists
- # to make sure we properly remove the files at the end of the process.
- temp_img = []
-
- if background_list is None:
- background_list = []
- overflow_template = False
-
- pages = self.mapped("page_ids") - self.additional_page_id
- template_list, header_data, image_boxes = self._generate_template_list(
- pages, header, background_list, temp_img
- )
- image_list = []
-
- if background_list:
- # An original document is provided. We want
- # to complete the PDF document with the remaining pages
- # and provide an overflow template in case the text is longer
- # and we should add pages for additional translation.
- if len(background_list) > len(pages):
- for i in range(len(pages), len(background_list)):
+ # Keep file objects alive to prevent auto-deletion
+ temp_files = []
+ std_err_file = None
+ pdf_file = None
+
+ try:
+ if background_list is None:
+ background_list = []
+ overflow_template = False
+
+ pages = self.mapped("page_ids") - self.additional_page_id
+ template_list, header_data, image_boxes = self._generate_template_list(
+ pages, header, background_list, temp_files
+ )
+ image_list = []
+
+ s2b_generator.isolated_write({"generation_status": "apply_template"})
+
+ if background_list:
+ # An original document is provided. We want
+ # to complete the PDF document with the remaining pages
+ # and provide an overflow template in case the text is longer
+ # and we should add pages for additional translation.
+ if len(background_list) > len(pages):
+ for i in range(len(pages), len(background_list)):
+ bf = tempfile.NamedTemporaryFile(prefix="img_", suffix=".jpg")
+ bf.write(base64.b64decode(background_list[i]))
+ bf.flush()
+ temp_files.append(bf)
+ template_list.append([bf.name, [], [], []])
+ additional_page = self.env.ref("sbc_compassion.b2s_additional_page")
+ bf_name = False
+ if additional_page.background:
bf = tempfile.NamedTemporaryFile(prefix="img_", suffix=".jpg")
- bf.write(base64.b64decode(background_list[i]))
+ bf.write(base64.b64decode(additional_page.background))
bf.flush()
- temp_img.append(bf)
- template_list.append([bf.name, [], [], []])
- additional_page = self.env.ref("sbc_compassion.b2s_additional_page")
- bf_name = False
- if additional_page.background:
- bf = tempfile.NamedTemporaryFile(prefix="img_", suffix=".jpg")
- bf.write(base64.b64decode(additional_page.background))
- bf.flush()
- bf_name = bf.name
- temp_img.append(bf)
- text_list = []
- for text_box in additional_page.text_box_ids:
- text_list.append(text_box.get_json_repr())
- overflow_template = [bf_name, [], text_list, image_boxes]
- elif self.additional_page_id:
- # We are generating a new PDF (S2B case). We provide
- # an overflow template using the template
- add_background = tempfile.NamedTemporaryFile(prefix="img_", suffix=".jpg")
- add_background.write(base64.b64decode(self.additional_page_id.background))
- add_background.flush()
- temp_img.append(add_background)
+ bf_name = bf.name
+ temp_files.append(bf)
+ text_list = []
+ for text_box in additional_page.text_box_ids:
+ text_list.append(text_box.get_json_repr())
+ overflow_template = [bf_name, [], text_list, image_boxes]
+ elif self.additional_page_id:
+ # We are generating a new PDF (S2B case). We provide
+ # an overflow template using the template
+ add_background = tempfile.NamedTemporaryFile(
+ prefix="img_", suffix=".jpg"
+ )
+ add_background.write(
+ base64.b64decode(self.additional_page_id.background)
+ )
+ add_background.flush()
+ temp_files.append(add_background)
+ text_list = []
+ for text_box in self.additional_page_id.text_box_ids:
+ text_list.append(text_box.get_json_repr())
+ overflow_template = [add_background.name, header_data, text_list, []]
+
+ s2b_generator.isolated_write({"generation_status": "apply_text"})
+
text_list = []
- for text_box in self.additional_page_id.text_box_ids:
- text_list.append(text_box.get_json_repr())
- overflow_template = [add_background.name, header_data, text_list, []]
+ for t_type, t_boxes in list(text.items()):
+ for txt in t_boxes:
+ txt_file = tempfile.NamedTemporaryFile(
+ "w", prefix=t_type + "_", suffix=".txt", encoding="utf-8"
+ )
+ txt_file.write(txt)
+ txt_file.flush()
+ temp_files.append(txt_file)
+ text_list.append([txt_file.name, t_type])
- text_list = []
- for t_type, t_boxes in list(text.items()):
- for txt in t_boxes:
- txt_file = tempfile.NamedTemporaryFile(
- "w", prefix=t_type + "_", suffix=".txt", encoding="utf-8"
- )
- txt_file.write(txt)
- txt_file.flush()
- temp_img.append(txt_file)
- text_list.append([txt_file.name, t_type])
-
- for image in image_data:
- ifile = tempfile.NamedTemporaryFile(prefix="img_", suffix=".jpg")
- ifile.write(base64.b64decode(image))
- ifile.flush()
- image_list.append(ifile.name)
- temp_img.append(ifile)
-
- generated_json = {
- "images": image_list,
- "templates": template_list,
- "texts": text_list,
- # The output should at least contain 2 pages
- "original_size": max(2, len(background_list)),
- "overflow_template": overflow_template,
- "lang": self.env.lang,
- "prevent_overflow": self.type == "b2s",
- }
-
- json_val = json.dumps(generated_json).replace(" ", "")
-
- std_err_file_path = self.path_to("stderr.txt")
- std_err_file = open(std_err_file_path, "w", encoding="utf-8")
-
- php_command_args = ["php", self.path_to("pdf.php"), pdf_name, json_val]
- if config.get("php_debug"):
- # Allow php debugging with Xend
- os.environ["XDEBUG_CONFIG"] = "PHPSTORM"
- php_command_args.extend(
- [
- "-dxdebug.remote_enable=1",
- "-dxdebug.remote_mode=req",
- "-dxdebug.remote_port=9000",
- "-dxdebug.remote_host=127.0.0.1",
- ]
- )
- proc = subprocess.Popen(php_command_args, stderr=std_err_file)
- proc.communicate()
-
- if proc.returncode != 0:
- with open(std_err_file_path, "r", encoding="utf-8") as stderr:
- _logger.error(
- "FPDF returned nonzero exit code %d. stderr:\n%s",
- proc.returncode,
- stderr.read(),
+ s2b_generator.isolated_write({"generation_status": "apply_images"})
+
+ for image in image_data:
+ ifile = tempfile.NamedTemporaryFile(prefix="img_", suffix=".jpg")
+ ifile.write(base64.b64decode(image))
+ ifile.flush()
+ image_list.append(ifile.name)
+ temp_files.append(ifile)
+
+ generated_json = {
+ "images": image_list,
+ "templates": template_list,
+ "texts": text_list,
+ # The output should at least contain 2 pages
+ "original_size": max(2, len(background_list)),
+ "overflow_template": overflow_template,
+ "lang": self.env.lang,
+ "prevent_overflow": self.type == "b2s",
+ }
+
+ json_val = json.dumps(generated_json).replace(" ", "")
+
+ std_err_file_path = self.path_to("stderr.txt")
+ std_err_file = open(std_err_file_path, "w", encoding="utf-8")
+
+ s2b_generator.isolated_write({"generation_status": "generate_pdf"})
+
+ php_command_args = ["php", self.path_to("pdf.php"), pdf_name, json_val]
+ if config.get("php_debug"):
+ # Allow php debugging with Xend
+ os.environ["XDEBUG_CONFIG"] = "PHPSTORM"
+ php_command_args.extend(
+ [
+ "-dxdebug.remote_enable=1",
+ "-dxdebug.remote_mode=req",
+ "-dxdebug.remote_port=9000",
+ "-dxdebug.remote_host=127.0.0.1",
+ ]
)
+ proc = subprocess.Popen(php_command_args, stderr=std_err_file)
+ proc.communicate()
- # Clean temp files
- for img in temp_img:
- img.close()
- std_err_file.close()
+ if proc.returncode != 0:
+ with open(std_err_file_path, "r", encoding="utf-8") as stderr:
+ _logger.error(
+ "FPDF returned nonzero exit code %d. stderr:\n%s",
+ proc.returncode,
+ stderr.read(),
+ )
- # Read and return output
- try:
+ # Read and return output
pdf_file = open(pdf_name, "rb")
res = pdf_file.read()
pdf_file.close()
- os.remove(pdf_file.name)
- except FileNotFoundError:
- _logger.error("Cannot read PDF made by FPDF.")
- res = False
- return res
+ pdf_file = None
+ os.remove(pdf_name)
+
+ return res
+
+ except Exception:
+ _logger.error("Cannot read PDF made by FPDF.", exc_info=True)
+ return False
+
+ finally:
+ # Clean up all temporary files (they will be auto-deleted when closed)
+ for temp_file in temp_files:
+ try:
+ temp_file.close()
+ except Exception as e:
+ _logger.warning("Failed to close temp file: %s", str(e))
+
+ # Clean up stderr file
+ if std_err_file:
+ try:
+ std_err_file.close()
+ except Exception:
+ pass
+ try:
+ std_err_file_path = self.path_to("stderr.txt")
+ if os.path.exists(std_err_file_path):
+ os.remove(std_err_file_path)
+ except Exception as e:
+ _logger.warning("Failed to clean up stderr file: %s", str(e))
+
+ # Clean up PDF file if still open
+ if pdf_file:
+ try:
+ pdf_file.close()
+ except Exception:
+ pass
# path of the FPDF folder
_absolute_path = os.path.join(
diff --git a/sbc_compassion/views/correspondence_s2b_generator_view.xml b/sbc_compassion/views/correspondence_s2b_generator_view.xml
index 57985cf03..649a0fb16 100644
--- a/sbc_compassion/views/correspondence_s2b_generator_view.xml
+++ b/sbc_compassion/views/correspondence_s2b_generator_view.xml
@@ -25,6 +25,7 @@
icon="fa-search-plus"
class="oe_stat_button"
states="draft"
+ context="{'raise_error': True}"
/>