| 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 |