| Total Complexity | 45 |
| Total Lines | 727 |
| Duplicated Lines | 18.57 % |
| Changes | 0 | ||
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like notifier often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
| 1 | # -*- coding: utf-8 -*- |
||
| 2 | import datetime |
||
| 3 | import typing |
||
| 4 | from email.mime.multipart import MIMEMultipart |
||
| 5 | from email.mime.text import MIMEText |
||
| 6 | from email.utils import formataddr |
||
| 7 | from smtplib import SMTPRecipientsRefused |
||
| 8 | |||
| 9 | from lxml.html.diff import htmldiff |
||
| 10 | from mako.template import Template |
||
| 11 | from sqlalchemy.orm import Session |
||
| 12 | |||
| 13 | from tracim_backend.app_models.contents import content_type_list |
||
| 14 | from tracim_backend.config import CFG |
||
| 15 | from tracim_backend.exceptions import EmptyNotificationError |
||
| 16 | from tracim_backend.lib.core.notifications import INotifier |
||
| 17 | from tracim_backend.lib.core.workspace import WorkspaceApi |
||
| 18 | from tracim_backend.lib.mail_notifier.sender import EmailSender |
||
| 19 | from tracim_backend.lib.mail_notifier.sender import send_email_through |
||
| 20 | from tracim_backend.lib.mail_notifier.utils import EST |
||
| 21 | from tracim_backend.lib.mail_notifier.utils import SmtpConfiguration |
||
| 22 | from tracim_backend.lib.utils.logger import logger |
||
| 23 | from tracim_backend.lib.utils.translation import Translator |
||
| 24 | from tracim_backend.lib.utils.utils import get_email_logo_frontend_url |
||
| 25 | from tracim_backend.lib.utils.utils import get_login_frontend_url |
||
| 26 | from tracim_backend.lib.utils.utils import get_reset_password_frontend_url |
||
| 27 | from tracim_backend.models.auth import User |
||
| 28 | from tracim_backend.models.context_models import ContentInContext |
||
| 29 | from tracim_backend.models.context_models import WorkspaceInContext |
||
| 30 | from tracim_backend.models.data import ActionDescription |
||
| 31 | from tracim_backend.models.data import Content |
||
| 32 | from tracim_backend.models.data import UserRoleInWorkspace |
||
| 33 | |||
| 34 | |||
| 35 | class EmailNotifier(INotifier): |
||
| 36 | """ |
||
| 37 | EmailNotifier, this class will decide how to notify by mail |
||
| 38 | in order to let a EmailManager create email |
||
| 39 | """ |
||
| 40 | |||
| 41 | def __init__( |
||
| 42 | self, |
||
| 43 | config: CFG, |
||
| 44 | session: Session, |
||
| 45 | current_user: User=None |
||
| 46 | ): |
||
| 47 | """ |
||
| 48 | :param current_user: the user that has triggered the notification |
||
| 49 | :return: |
||
| 50 | """ |
||
| 51 | INotifier.__init__(self, config, session, current_user) |
||
| 52 | logger.info(self, 'Instantiating Email Notifier') |
||
| 53 | |||
| 54 | self._user = current_user |
||
| 55 | self.session = session |
||
| 56 | self.config = config |
||
| 57 | self._smtp_config = SmtpConfiguration( |
||
| 58 | self.config.EMAIL_NOTIFICATION_SMTP_SERVER, |
||
| 59 | self.config.EMAIL_NOTIFICATION_SMTP_PORT, |
||
| 60 | self.config.EMAIL_NOTIFICATION_SMTP_USER, |
||
| 61 | self.config.EMAIL_NOTIFICATION_SMTP_PASSWORD |
||
| 62 | ) |
||
| 63 | |||
| 64 | def notify_content_update(self, content: Content): |
||
| 65 | |||
| 66 | if content.get_last_action().id not \ |
||
| 67 | in self.config.EMAIL_NOTIFICATION_NOTIFIED_EVENTS: |
||
| 68 | logger.info( |
||
| 69 | self, |
||
| 70 | 'Skip email notification for update of content {}' |
||
| 71 | 'by user {} (the action is {})'.format( |
||
| 72 | content.content_id, |
||
| 73 | # below: 0 means "no user" |
||
| 74 | self._user.user_id if self._user else 0, |
||
| 75 | content.get_last_action().id |
||
| 76 | ) |
||
| 77 | ) |
||
| 78 | return |
||
| 79 | |||
| 80 | logger.info(self, |
||
| 81 | 'About to email-notify update' |
||
| 82 | 'of content {} by user {}'.format( |
||
| 83 | content.content_id, |
||
| 84 | # Below: 0 means "no user" |
||
| 85 | self._user.user_id if self._user else 0 |
||
| 86 | ) |
||
| 87 | ) |
||
| 88 | |||
| 89 | if content.type not \ |
||
| 90 | in self.config.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS: |
||
| 91 | logger.info( |
||
| 92 | self, |
||
| 93 | 'Skip email notification for update of content {}' |
||
| 94 | 'by user {} (the content type is {})'.format( |
||
| 95 | content.type, |
||
| 96 | # below: 0 means "no user" |
||
| 97 | self._user.user_id if self._user else 0, |
||
| 98 | content.get_last_action().id |
||
| 99 | ) |
||
| 100 | ) |
||
| 101 | return |
||
| 102 | |||
| 103 | logger.info(self, |
||
| 104 | 'About to email-notify update' |
||
| 105 | 'of content {} by user {}'.format( |
||
| 106 | content.content_id, |
||
| 107 | # Below: 0 means "no user" |
||
| 108 | self._user.user_id if self._user else 0 |
||
| 109 | ) |
||
| 110 | ) |
||
| 111 | |||
| 112 | #### |
||
| 113 | # |
||
| 114 | # INFO - D.A. - 2014-11-05 - Emails are sent through asynchronous jobs. |
||
| 115 | # For that reason, we do not give SQLAlchemy objects but ids only |
||
| 116 | # (SQLA objects are related to a given thread/session) |
||
| 117 | # |
||
| 118 | try: |
||
| 119 | if self.config.EMAIL_NOTIFICATION_PROCESSING_MODE.lower() == self.config.CST.ASYNC.lower(): |
||
| 120 | logger.info(self, 'Sending email in ASYNC mode') |
||
| 121 | # TODO - D.A - 2014-11-06 |
||
| 122 | # This feature must be implemented in order to be able to scale to large communities |
||
| 123 | raise NotImplementedError('Sending emails through ASYNC mode is not working yet') |
||
| 124 | else: |
||
| 125 | logger.info(self, 'Sending email in SYNC mode') |
||
| 126 | EmailManager( |
||
| 127 | self._smtp_config, |
||
| 128 | self.config, |
||
| 129 | self.session, |
||
| 130 | ).notify_content_update(self._user.user_id, content.content_id) |
||
| 131 | except Exception as e: |
||
| 132 | # TODO - G.M - 2018-08-27 - Do Better catching for exception here |
||
| 133 | logger.error(self, 'Exception catched during email notification: {}'.format(e.__str__())) |
||
| 134 | logger.exception(self, e) |
||
| 135 | |||
| 136 | |||
| 137 | class EmailManager(object): |
||
| 138 | """ |
||
| 139 | Compared to Notifier, this class is independant from the HTTP request thread |
||
| 140 | This class will build Email and send it for both created account and content |
||
| 141 | update |
||
| 142 | """ |
||
| 143 | |||
| 144 | def __init__( |
||
| 145 | self, |
||
| 146 | smtp_config: SmtpConfiguration, |
||
| 147 | config: CFG, |
||
| 148 | session: Session |
||
| 149 | ) -> None: |
||
| 150 | self._smtp_config = smtp_config |
||
| 151 | self.config = config |
||
| 152 | self.session = session |
||
| 153 | # FIXME - G.M - We need to have a session for the emailNotifier |
||
| 154 | |||
| 155 | # if not self.session: |
||
| 156 | # engine = get_engine(settings) |
||
| 157 | # session_factory = get_session_factory(engine) |
||
| 158 | # app_config = CFG(settings) |
||
| 159 | |||
| 160 | def _get_sender(self, user: User=None) -> str: |
||
| 161 | """ |
||
| 162 | Return sender string like "Bob Dylan |
||
| 163 | (via Tracim) <[email protected]>" |
||
| 164 | :param user: user to extract display name |
||
| 165 | :return: sender string |
||
| 166 | """ |
||
| 167 | |||
| 168 | email_template = self.config.EMAIL_NOTIFICATION_FROM_EMAIL |
||
| 169 | mail_sender_name = self.config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL # nopep8 |
||
| 170 | if user: |
||
| 171 | mail_sender_name = '{name} via Tracim'.format(name=user.display_name) |
||
| 172 | email_address = email_template.replace('{user_id}', str(user.user_id)) |
||
| 173 | # INFO - D.A. - 2017-08-04 |
||
| 174 | # We use email_template.replace() instead of .format() because this |
||
| 175 | # method is more robust to errors in config file. |
||
| 176 | # |
||
| 177 | # For example, if the email is info+{userid}@tracim.fr |
||
| 178 | # email.format(user_id='bob') will raise an exception |
||
| 179 | # email.replace('{user_id}', 'bob') will just ignore {userid} |
||
| 180 | else: |
||
| 181 | email_address = email_template.replace('{user_id}', '0') |
||
| 182 | |||
| 183 | return formataddr((mail_sender_name, email_address)) |
||
| 184 | |||
| 185 | # Content Notification |
||
| 186 | |||
| 187 | @staticmethod |
||
| 188 | def log_notification( |
||
| 189 | config: CFG, |
||
| 190 | action: str, |
||
| 191 | recipient: typing.Optional[str], |
||
| 192 | subject: typing.Optional[str], |
||
| 193 | ) -> None: |
||
| 194 | """Log notification metadata.""" |
||
| 195 | log_path = config.EMAIL_NOTIFICATION_LOG_FILE_PATH |
||
| 196 | if log_path: |
||
| 197 | # TODO - A.P - 2017-09-06 - file logging inefficiency |
||
| 198 | # Updating a document with 100 users to notify will leads to open |
||
| 199 | # and close the file 100 times. |
||
| 200 | with open(log_path, 'a') as log_file: |
||
| 201 | print( |
||
| 202 | datetime.datetime.now(), |
||
| 203 | action, |
||
| 204 | recipient, |
||
| 205 | subject, |
||
| 206 | sep='|', |
||
| 207 | file=log_file, |
||
| 208 | ) |
||
| 209 | |||
| 210 | def notify_content_update( |
||
| 211 | self, |
||
| 212 | event_actor_id: int, |
||
| 213 | event_content_id: int |
||
| 214 | ) -> None: |
||
| 215 | """ |
||
| 216 | Look for all users to be notified about the new content and send them an |
||
| 217 | individual email |
||
| 218 | :param event_actor_id: id of the user that has triggered the event |
||
| 219 | :param event_content_id: related content_id |
||
| 220 | :return: |
||
| 221 | """ |
||
| 222 | # FIXME - D.A. - 2014-11-05 |
||
| 223 | # Dirty import. It's here in order to avoid circular import |
||
| 224 | from tracim_backend.lib.core.content import ContentApi |
||
| 225 | from tracim_backend.lib.core.user import UserApi |
||
| 226 | user = UserApi( |
||
| 227 | None, |
||
| 228 | config=self.config, |
||
| 229 | session=self.session, |
||
| 230 | ).get_one(event_actor_id) |
||
| 231 | logger.debug(self, 'Content: {}'.format(event_content_id)) |
||
| 232 | content_api = ContentApi( |
||
| 233 | current_user=user, |
||
| 234 | session=self.session, |
||
| 235 | config=self.config, |
||
| 236 | ) |
||
| 237 | content = ContentApi( |
||
| 238 | session=self.session, |
||
| 239 | current_user=user, # nopep8 TODO - use a system user instead of the user that has triggered the event |
||
| 240 | config=self.config, |
||
| 241 | show_archived=True, |
||
| 242 | show_deleted=True, |
||
| 243 | ).get_one(event_content_id, content_type_list.Any_SLUG) |
||
| 244 | workspace_api = WorkspaceApi( |
||
| 245 | session=self.session, |
||
| 246 | current_user=user, |
||
| 247 | config=self.config, |
||
| 248 | ) |
||
| 249 | workpace_in_context = workspace_api.get_workspace_with_context(workspace_api.get_one(content.workspace_id)) # nopep8 |
||
| 250 | main_content = content.parent if content.type == content_type_list.Comment.slug else content # nopep8 |
||
| 251 | notifiable_roles = WorkspaceApi( |
||
| 252 | current_user=user, |
||
| 253 | session=self.session, |
||
| 254 | config=self.config, |
||
| 255 | ).get_notifiable_roles(content.workspace) |
||
| 256 | |||
| 257 | if len(notifiable_roles) <= 0: |
||
| 258 | logger.info(self, 'Skipping notification as nobody subscribed to in workspace {}'.format(content.workspace.label)) |
||
| 259 | return |
||
| 260 | |||
| 261 | |||
| 262 | logger.info(self, 'Sending asynchronous emails to {} user(s)'.format(len(notifiable_roles))) |
||
| 263 | # INFO - D.A. - 2014-11-06 |
||
| 264 | # The following email sender will send emails in the async task queue |
||
| 265 | # This allow to build all mails through current thread but really send them (including SMTP connection) |
||
| 266 | # In the other thread. |
||
| 267 | # |
||
| 268 | # This way, the webserver will return sooner (actually before notification emails are sent |
||
| 269 | async_email_sender = EmailSender( |
||
| 270 | self.config, |
||
| 271 | self._smtp_config, |
||
| 272 | self.config.EMAIL_NOTIFICATION_ACTIVATED |
||
| 273 | ) |
||
| 274 | for role in notifiable_roles: |
||
| 275 | logger.info(self, 'Sending email to {}'.format(role.user.email)) |
||
| 276 | translator = Translator(app_config=self.config, default_lang=role.user.lang) # nopep8 |
||
| 277 | _ = translator.get_translation |
||
| 278 | to_addr = formataddr((role.user.display_name, role.user.email)) |
||
| 279 | # INFO - G.M - 2017-11-15 - set content_id in header to permit reply |
||
| 280 | # references can have multiple values, but only one in this case. |
||
| 281 | replyto_addr = self.config.EMAIL_NOTIFICATION_REPLY_TO_EMAIL.replace( # nopep8 |
||
| 282 | '{content_id}', str(main_content.content_id) |
||
| 283 | ) |
||
| 284 | |||
| 285 | reference_addr = self.config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace( #nopep8 |
||
| 286 | '{content_id}',str(main_content.content_id) |
||
| 287 | ) |
||
| 288 | # |
||
| 289 | # INFO - D.A. - 2014-11-06 |
||
| 290 | # We do not use .format() here because the subject defined in the .ini file |
||
| 291 | # may not include all required labels. In order to avoid partial format() (which result in an exception) |
||
| 292 | # we do use replace and force the use of .__str__() in order to process LazyString objects |
||
| 293 | # |
||
| 294 | subject = self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT |
||
| 295 | subject = subject.replace(EST.WEBSITE_TITLE, self.config.WEBSITE_TITLE.__str__()) |
||
| 296 | subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__()) |
||
| 297 | subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__()) |
||
| 298 | subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__()) |
||
| 299 | reply_to_label = _('{username} & all members of {workspace}').format( # nopep8 |
||
| 300 | username=user.display_name, |
||
| 301 | workspace=main_content.workspace.label) |
||
| 302 | |||
| 303 | message = MIMEMultipart('alternative') |
||
| 304 | message['Subject'] = subject |
||
| 305 | message['From'] = self._get_sender(user) |
||
| 306 | message['To'] = to_addr |
||
| 307 | message['Reply-to'] = formataddr((reply_to_label, replyto_addr)) |
||
| 308 | # INFO - G.M - 2017-11-15 |
||
| 309 | # References can theorically have label, but in pratice, references |
||
| 310 | # contains only message_id from parents post in thread. |
||
| 311 | # To link this email to a content we create a virtual parent |
||
| 312 | # in reference who contain the content_id. |
||
| 313 | message['References'] = formataddr(('', reference_addr)) |
||
| 314 | content_in_context = content_api.get_content_in_context(content) |
||
| 315 | parent_in_context = None |
||
| 316 | if content.parent_id: |
||
| 317 | parent_in_context = content_api.get_content_in_context(content.parent) # nopep8 |
||
| 318 | body_text = self._build_email_body_for_content( |
||
| 319 | self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, |
||
| 320 | role, |
||
| 321 | content_in_context, |
||
| 322 | parent_in_context, |
||
| 323 | workpace_in_context, |
||
| 324 | user, |
||
| 325 | translator, |
||
| 326 | ) |
||
| 327 | body_html = self._build_email_body_for_content( |
||
| 328 | self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML, |
||
| 329 | role, |
||
| 330 | content_in_context, |
||
| 331 | parent_in_context, |
||
| 332 | workpace_in_context, |
||
| 333 | user, |
||
| 334 | translator, |
||
| 335 | ) |
||
| 336 | |||
| 337 | part1 = MIMEText(body_text, 'plain', 'utf-8') |
||
| 338 | part2 = MIMEText(body_html, 'html', 'utf-8') |
||
| 339 | # Attach parts into message container. |
||
| 340 | # According to RFC 2046, the last part of a multipart message, in this case |
||
| 341 | # the HTML message, is best and preferred. |
||
| 342 | message.attach(part1) |
||
| 343 | message.attach(part2) |
||
| 344 | |||
| 345 | self.log_notification( |
||
| 346 | action='CREATED', |
||
| 347 | recipient=message['To'], |
||
| 348 | subject=message['Subject'], |
||
| 349 | config=self.config, |
||
| 350 | ) |
||
| 351 | |||
| 352 | send_email_through( |
||
| 353 | self.config, |
||
| 354 | async_email_sender.send_mail, |
||
| 355 | message |
||
| 356 | ) |
||
| 357 | |||
| 358 | View Code Duplication | def notify_created_account( |
|
|
|
|||
| 359 | self, |
||
| 360 | user: User, |
||
| 361 | password: str, |
||
| 362 | ) -> None: |
||
| 363 | """ |
||
| 364 | Send created account email to given user. |
||
| 365 | |||
| 366 | :param password: choosed password |
||
| 367 | :param user: user to notify |
||
| 368 | """ |
||
| 369 | # TODO BS 20160712: Cyclic import |
||
| 370 | logger.debug(self, 'user: {}'.format(user.user_id)) |
||
| 371 | logger.info(self, 'Sending asynchronous email to 1 user ({0})'.format( |
||
| 372 | user.email, |
||
| 373 | )) |
||
| 374 | |||
| 375 | async_email_sender = EmailSender( |
||
| 376 | self.config, |
||
| 377 | self._smtp_config, |
||
| 378 | self.config.EMAIL_NOTIFICATION_ACTIVATED |
||
| 379 | ) |
||
| 380 | |||
| 381 | subject = \ |
||
| 382 | self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT \ |
||
| 383 | .replace( |
||
| 384 | EST.WEBSITE_TITLE, |
||
| 385 | str(self.config.WEBSITE_TITLE) |
||
| 386 | ) |
||
| 387 | message = MIMEMultipart('alternative') |
||
| 388 | message['Subject'] = subject |
||
| 389 | message['From'] = self._get_sender() |
||
| 390 | message['To'] = formataddr((user.get_display_name(), user.email)) |
||
| 391 | |||
| 392 | text_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT # nopep8 |
||
| 393 | html_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML # nopep8 |
||
| 394 | |||
| 395 | context = { |
||
| 396 | 'user': user, |
||
| 397 | 'password': password, |
||
| 398 | 'logo_url': get_email_logo_frontend_url(self.config), |
||
| 399 | 'login_url': get_login_frontend_url(self.config), |
||
| 400 | } |
||
| 401 | translator = Translator(self.config, default_lang=user.lang) |
||
| 402 | body_text = self._render_template( |
||
| 403 | mako_template_filepath=text_template_file_path, |
||
| 404 | context=context, |
||
| 405 | translator=translator |
||
| 406 | ) |
||
| 407 | |||
| 408 | body_html = self._render_template( |
||
| 409 | mako_template_filepath=html_template_file_path, |
||
| 410 | context=context, |
||
| 411 | translator=translator |
||
| 412 | ) |
||
| 413 | |||
| 414 | part1 = MIMEText(body_text, 'plain', 'utf-8') |
||
| 415 | part2 = MIMEText(body_html, 'html', 'utf-8') |
||
| 416 | |||
| 417 | # Attach parts into message container. |
||
| 418 | # According to RFC 2046, the last part of a multipart message, |
||
| 419 | # in this case the HTML message, is best and preferred. |
||
| 420 | message.attach(part1) |
||
| 421 | message.attach(part2) |
||
| 422 | |||
| 423 | send_email_through( |
||
| 424 | config=self.config, |
||
| 425 | sendmail_callable=async_email_sender.send_mail, |
||
| 426 | message=message |
||
| 427 | ) |
||
| 428 | |||
| 429 | View Code Duplication | def notify_reset_password( |
|
| 430 | self, |
||
| 431 | user: User, |
||
| 432 | reset_password_token: str, |
||
| 433 | ) -> None: |
||
| 434 | """ |
||
| 435 | Reset password link for user |
||
| 436 | :param user: user to notify |
||
| 437 | :param reset_password_token: token for resetting password |
||
| 438 | """ |
||
| 439 | logger.debug(self, 'user: {}'.format(user.user_id)) |
||
| 440 | logger.info(self, 'Sending asynchronous email to 1 user ({0})'.format( |
||
| 441 | user.email, |
||
| 442 | )) |
||
| 443 | translator = Translator(self.config, default_lang=user.lang) |
||
| 444 | async_email_sender = EmailSender( |
||
| 445 | self.config, |
||
| 446 | self._smtp_config, |
||
| 447 | self.config.EMAIL_NOTIFICATION_ACTIVATED |
||
| 448 | ) |
||
| 449 | subject = self.config.EMAIL_NOTIFICATION_RESET_PASSWORD_SUBJECT.replace( |
||
| 450 | EST.WEBSITE_TITLE, |
||
| 451 | str(self.config.WEBSITE_TITLE) |
||
| 452 | ) |
||
| 453 | message = MIMEMultipart('alternative') |
||
| 454 | message['Subject'] = subject |
||
| 455 | message['From'] = self._get_sender() |
||
| 456 | message['To'] = formataddr((user.get_display_name(), user.email)) |
||
| 457 | |||
| 458 | text_template_file_path = self.config.EMAIL_NOTIFICATION_RESET_PASSWORD_TEMPLATE_TEXT # nopep8 |
||
| 459 | html_template_file_path = self.config.EMAIL_NOTIFICATION_RESET_PASSWORD_TEMPLATE_HTML # nopep8 |
||
| 460 | # TODO - G.M - 2018-08-17 - Generate token |
||
| 461 | context = { |
||
| 462 | 'user': user, |
||
| 463 | 'logo_url': get_email_logo_frontend_url(self.config), |
||
| 464 | 'reset_password_url': get_reset_password_frontend_url( |
||
| 465 | self.config, |
||
| 466 | token=reset_password_token, |
||
| 467 | email=user.email, |
||
| 468 | ), |
||
| 469 | } |
||
| 470 | body_text = self._render_template( |
||
| 471 | mako_template_filepath=text_template_file_path, |
||
| 472 | context=context, |
||
| 473 | translator=translator, |
||
| 474 | ) |
||
| 475 | |||
| 476 | body_html = self._render_template( |
||
| 477 | mako_template_filepath=html_template_file_path, |
||
| 478 | context=context, |
||
| 479 | translator=translator, |
||
| 480 | ) |
||
| 481 | |||
| 482 | part1 = MIMEText(body_text, 'plain', 'utf-8') |
||
| 483 | part2 = MIMEText(body_html, 'html', 'utf-8') |
||
| 484 | |||
| 485 | # Attach parts into message container. |
||
| 486 | # According to RFC 2046, the last part of a multipart message, |
||
| 487 | # in this case the HTML message, is best and preferred. |
||
| 488 | message.attach(part1) |
||
| 489 | message.attach(part2) |
||
| 490 | |||
| 491 | send_email_through( |
||
| 492 | config=self.config, |
||
| 493 | sendmail_callable=async_email_sender.send_mail, |
||
| 494 | message=message |
||
| 495 | ) |
||
| 496 | |||
| 497 | def _render_template( |
||
| 498 | self, |
||
| 499 | mako_template_filepath: str, |
||
| 500 | context: dict, |
||
| 501 | translator: Translator |
||
| 502 | ) -> str: |
||
| 503 | """ |
||
| 504 | Render mako template with all needed current variables. |
||
| 505 | |||
| 506 | :param mako_template_filepath: file path of mako template |
||
| 507 | :param context: dict with template context |
||
| 508 | :return: template rendered string |
||
| 509 | """ |
||
| 510 | |||
| 511 | template = Template(filename=mako_template_filepath) |
||
| 512 | return template.render( |
||
| 513 | _=translator.get_translation, |
||
| 514 | config=self.config, |
||
| 515 | **context |
||
| 516 | ) |
||
| 517 | |||
| 518 | def _build_context_for_content_update( |
||
| 519 | self, |
||
| 520 | role: UserRoleInWorkspace, |
||
| 521 | content_in_context: ContentInContext, |
||
| 522 | parent_in_context: typing.Optional[ContentInContext], |
||
| 523 | workspace_in_context: WorkspaceInContext, |
||
| 524 | actor: User, |
||
| 525 | translator: Translator |
||
| 526 | ): |
||
| 527 | |||
| 528 | _ = translator.get_translation |
||
| 529 | content = content_in_context.content |
||
| 530 | action = content.get_last_action().id |
||
| 531 | |||
| 532 | # default values |
||
| 533 | user = role.user |
||
| 534 | workspace = role.workspace |
||
| 535 | workspace_url = workspace_in_context.frontend_url |
||
| 536 | main_title = content.label |
||
| 537 | status_label = content.get_status().label |
||
| 538 | # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for status_icon_url # nopep8 |
||
| 539 | status_icon_url = '' |
||
| 540 | role_label = role.role_as_label() |
||
| 541 | content_intro = '<span id="content-intro-username">{}</span> did something.'.format(actor.display_name) # nopep8 |
||
| 542 | content_text = content.description |
||
| 543 | call_to_action_text = 'See more' |
||
| 544 | call_to_action_url = content_in_context.frontend_url |
||
| 545 | logo_url = get_email_logo_frontend_url(self.config) |
||
| 546 | |||
| 547 | if ActionDescription.CREATION == action: |
||
| 548 | call_to_action_text = _('View online') |
||
| 549 | content_intro = _('<span id="content-intro-username">{}</span> create a content:').format(actor.display_name) # nopep8 |
||
| 550 | |||
| 551 | if content_type_list.Thread.slug == content.type: |
||
| 552 | if content.get_last_comment_from(actor): |
||
| 553 | content_text = content.get_last_comment_from(actor).description # nopep8 |
||
| 554 | |||
| 555 | call_to_action_text = _('Answer') |
||
| 556 | content_intro = _('<span id="content-intro-username">{}</span> started a thread entitled:').format(actor.display_name) |
||
| 557 | content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + content_text # nopep8 |
||
| 558 | |||
| 559 | elif content_type_list.File.slug == content.type: |
||
| 560 | content_intro = _('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name) |
||
| 561 | if content.description: |
||
| 562 | content_text = content.description |
||
| 563 | else: |
||
| 564 | content_text = '<span id="content-body-only-title">{}</span>'.format(content.label) |
||
| 565 | |||
| 566 | elif content_type_list.Page.slug == content.type: |
||
| 567 | content_intro = _('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name) |
||
| 568 | content_text = '<span id="content-body-only-title">{}</span>'.format(content.label) |
||
| 569 | |||
| 570 | elif ActionDescription.REVISION == action: |
||
| 571 | content_text = content.description |
||
| 572 | call_to_action_text = _('View online') |
||
| 573 | |||
| 574 | if content_type_list.File.slug == content.type: |
||
| 575 | content_intro = _('<span id="content-intro-username">{}</span> uploaded a new revision.').format(actor.display_name) |
||
| 576 | content_text = content.description |
||
| 577 | |||
| 578 | elif ActionDescription.EDITION == action: |
||
| 579 | call_to_action_text = _('View online') |
||
| 580 | |||
| 581 | if content_type_list.File.slug == content.type: |
||
| 582 | content_intro = _('<span id="content-intro-username">{}</span> updated the file description.').format(actor.display_name) |
||
| 583 | content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + content.description # nopep8 |
||
| 584 | |||
| 585 | elif content_type_list.Thread.slug == content.type: |
||
| 586 | content_intro = _('<span id="content-intro-username">{}</span> updated the thread description.').format(actor.display_name) |
||
| 587 | previous_revision = content.get_previous_revision() |
||
| 588 | title_diff = '' |
||
| 589 | if previous_revision.label != content.label: |
||
| 590 | title_diff = htmldiff(previous_revision.label, content.label) |
||
| 591 | content_text = str('<p id="content-body-intro">{}</p> {text} {title_diff} {content_diff}').format( |
||
| 592 | text=_('Here is an overview of the changes:'), |
||
| 593 | title_diff=title_diff, |
||
| 594 | content_diff=htmldiff(previous_revision.description, content.description) |
||
| 595 | ) |
||
| 596 | elif content_type_list.Page.slug == content.type: |
||
| 597 | content_intro = _('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name) |
||
| 598 | previous_revision = content.get_previous_revision() |
||
| 599 | title_diff = '' |
||
| 600 | if previous_revision.label != content.label: |
||
| 601 | title_diff = htmldiff(previous_revision.label, content.label) # nopep8 |
||
| 602 | content_text = str('<p id="content-body-intro">{}</p> {text}</p> {title_diff} {content_diff}').format( # nopep8 |
||
| 603 | actor.display_name, |
||
| 604 | text=_('Here is an overview of the changes:'), |
||
| 605 | title_diff=title_diff, |
||
| 606 | content_diff=htmldiff(previous_revision.description, content.description) |
||
| 607 | ) |
||
| 608 | |||
| 609 | elif ActionDescription.STATUS_UPDATE == action: |
||
| 610 | intro_user_msg = _( |
||
| 611 | '<span id="content-intro-username">{}</span> ' |
||
| 612 | 'updated the following status:' |
||
| 613 | ) |
||
| 614 | intro_body_msg = '<p id="content-body-intro">{}: {}</p>' |
||
| 615 | |||
| 616 | call_to_action_text = _('View online') |
||
| 617 | content_intro = intro_user_msg.format(actor.display_name) |
||
| 618 | content_text = intro_body_msg.format( |
||
| 619 | content.get_label(), |
||
| 620 | content.get_status().label, |
||
| 621 | ) |
||
| 622 | |||
| 623 | elif ActionDescription.COMMENT == action: |
||
| 624 | call_to_action_text = _('Answer') |
||
| 625 | main_title = parent_in_context.label |
||
| 626 | content_intro = _('<span id="content-intro-username">{}</span> added a comment:').format(actor.display_name) # nopep8 |
||
| 627 | call_to_action_url = parent_in_context.frontend_url |
||
| 628 | |||
| 629 | if not content_intro and not content_text: |
||
| 630 | # Skip notification, but it's not normal |
||
| 631 | logger.error( |
||
| 632 | self, |
||
| 633 | 'A notification is being sent but no content. ' |
||
| 634 | 'Here are some debug informations: [content_id: {cid}]' |
||
| 635 | '[action: {act}][author: {actor}]'.format( |
||
| 636 | cid=content.content_id, |
||
| 637 | act=action, |
||
| 638 | actor=actor |
||
| 639 | ) |
||
| 640 | ) |
||
| 641 | raise EmptyNotificationError('Unexpected empty notification') |
||
| 642 | |||
| 643 | # FIXME: remove/readapt assert to debug easily broken case |
||
| 644 | assert user |
||
| 645 | assert workspace |
||
| 646 | assert main_title |
||
| 647 | assert status_label |
||
| 648 | # assert status_icon_url |
||
| 649 | assert role_label |
||
| 650 | assert content_intro |
||
| 651 | assert content_text or content_text == content.description |
||
| 652 | assert call_to_action_text |
||
| 653 | assert call_to_action_url |
||
| 654 | assert logo_url |
||
| 655 | |||
| 656 | return { |
||
| 657 | 'user': role.user, |
||
| 658 | 'workspace': role.workspace, |
||
| 659 | 'workspace_url': workspace_url, |
||
| 660 | 'main_title': main_title, |
||
| 661 | 'status_label': status_label, |
||
| 662 | 'status_icon_url': status_icon_url, |
||
| 663 | 'role_label': role_label, |
||
| 664 | 'content_intro': content_intro, |
||
| 665 | 'content_text': content_text, |
||
| 666 | 'call_to_action_text': call_to_action_text, |
||
| 667 | 'call_to_action_url': call_to_action_url, |
||
| 668 | 'logo_url': logo_url, |
||
| 669 | } |
||
| 670 | |||
| 671 | def _build_email_body_for_content( |
||
| 672 | self, |
||
| 673 | mako_template_filepath: str, |
||
| 674 | role: UserRoleInWorkspace, |
||
| 675 | content_in_context: ContentInContext, |
||
| 676 | parent_in_context: typing.Optional[ContentInContext], |
||
| 677 | workspace_in_context: WorkspaceInContext, |
||
| 678 | actor: User, |
||
| 679 | translator: Translator |
||
| 680 | ) -> str: |
||
| 681 | """ |
||
| 682 | Build an email body and return it as a string |
||
| 683 | :param mako_template_filepath: the absolute path to the mako template |
||
| 684 | to be used for email body building |
||
| 685 | :param role: the role related to user to whom the email must be sent. |
||
| 686 | The role is required (and not the user only) in order to show in the |
||
| 687 | mail why the user receive the notification |
||
| 688 | :param content_in_context: the content item related to the notification |
||
| 689 | :param parent_in_context: parent of the content item related to the |
||
| 690 | notification |
||
| 691 | :param actor: the user at the origin of the action / notification |
||
| 692 | (for example the one who wrote a comment |
||
| 693 | :return: the built email body as string. In case of multipart email, |
||
| 694 | this method must be called one time for text and one time for html |
||
| 695 | """ |
||
| 696 | logger.debug(self, 'Building email content from MAKO template {}'.format(mako_template_filepath)) # nopep8 |
||
| 697 | context = self._build_context_for_content_update( |
||
| 698 | role=role, |
||
| 699 | content_in_context=content_in_context, |
||
| 700 | parent_in_context=parent_in_context, |
||
| 701 | workspace_in_context=workspace_in_context, |
||
| 702 | actor=actor, |
||
| 703 | translator=translator |
||
| 704 | ) |
||
| 705 | body_content = self._render_template( |
||
| 706 | mako_template_filepath=mako_template_filepath, |
||
| 707 | context=context, |
||
| 708 | translator=translator, |
||
| 709 | ) |
||
| 710 | return body_content |
||
| 711 | |||
| 712 | |||
| 713 | def get_email_manager(config: CFG, session: Session): |
||
| 714 | """ |
||
| 715 | :return: EmailManager instance |
||
| 716 | """ |
||
| 717 | # TODO: Find a way to import properly without cyclic import |
||
| 718 | |||
| 719 | smtp_config = SmtpConfiguration( |
||
| 720 | config.EMAIL_NOTIFICATION_SMTP_SERVER, |
||
| 721 | config.EMAIL_NOTIFICATION_SMTP_PORT, |
||
| 722 | config.EMAIL_NOTIFICATION_SMTP_USER, |
||
| 723 | config.EMAIL_NOTIFICATION_SMTP_PASSWORD |
||
| 724 | ) |
||
| 725 | |||
| 726 | return EmailManager(config=config, smtp_config=smtp_config, session=session) |
||
| 727 |