From da81fb2c8dc7bf404a2021a35e20e9ce54599cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Thu, 3 May 2018 16:59:46 +0200 Subject: [PATCH 01/13] Restore-mail-notifier WIP --- setup.py | 5 + tracim/command/user.py | 2 +- tracim/config.py | 218 +++---- tracim/lib/core/content.py | 9 +- tracim/lib/core/notifications.py | 34 +- tracim/lib/core/user.py | 42 +- tracim/lib/mail_notifier/daemon.py | 61 ++ tracim/lib/mail_notifier/notifier.py | 556 ++++++++++++++++++ tracim/lib/mail_notifier/sender.py | 114 ++++ tracim/lib/mail_notifier/utils.py | 33 ++ tracim/lib/utils/utils.py | 25 + tracim/models/data.py | 18 +- tracim/templates/mail/__init__.py | 0 .../mail/content_update_body_html.mak | 72 +++ .../mail/content_update_body_text.mak | 32 + .../mail/created_account_body_html.mak | 89 +++ .../mail/created_account_body_text.mak | 26 + tracim/tests/library/test_content_api.py | 90 +-- tracim/tests/library/test_notification.py | 6 +- tracim/tests/library/test_user_api.py | 13 +- tracim/tests/library/test_workspace.py | 4 +- tracim/views/core_api/session_controller.py | 107 ++++ 22 files changed, 1367 insertions(+), 189 deletions(-) create mode 100644 tracim/lib/mail_notifier/daemon.py create mode 100644 tracim/lib/mail_notifier/notifier.py create mode 100644 tracim/lib/mail_notifier/sender.py create mode 100644 tracim/lib/mail_notifier/utils.py create mode 100644 tracim/templates/mail/__init__.py create mode 100644 tracim/templates/mail/content_update_body_html.mak create mode 100644 tracim/templates/mail/content_update_body_text.mak create mode 100644 tracim/templates/mail/created_account_body_html.mak create mode 100644 tracim/templates/mail/created_account_body_text.mak diff --git a/setup.py b/setup.py index 3f8df60..0eb6842 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,11 @@ # others 'filedepot', 'babel', + # mail-notifier + 'mako', + 'lxml', + 'redis', + 'rq', ] tests_require = [ diff --git a/tracim/command/user.py b/tracim/command/user.py index 4687945..717ab7f 100644 --- a/tracim/command/user.py +++ b/tracim/command/user.py @@ -115,7 +115,7 @@ def _create_user(self, login: str, password: str, **kwargs) -> User: password = '' try: - user = self._user_api.create_user(email=login) + user = self._user_api.create_minimal_user(email=login) user.password = password self._user_api.save(user) # TODO - G.M - 04-04-2018 - [Caldav] Check this code diff --git a/tracim/config.py b/tracim/config.py index 82ce5a3..451cf6d 100644 --- a/tracim/config.py +++ b/tracim/config.py @@ -4,6 +4,8 @@ from tracim.lib.utils.logger import logger from depot.manager import DepotManager +from tracim.models.data import ActionDescription, ContentType + class CFG(object): """Object used for easy access to config file parameters.""" @@ -131,87 +133,87 @@ def __init__(self, settings): # TODO - G.M - 27-03-2018 - [Email] Restore email config ### # EMAIL related stuff (notification, reply) - ### - # - # self.EMAIL_NOTIFICATION_NOTIFIED_EVENTS = [ - # # ActionDescription.COMMENT, - # # ActionDescription.CREATION, - # # ActionDescription.EDITION, - # # ActionDescription.REVISION, - # # ActionDescription.STATUS_UPDATE - # ] - # - # self.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS = [ - # # ContentType.Page, - # # ContentType.Thread, - # # ContentType.File, - # # ContentType.Comment, - # # ContentType.Folder -- Folder is skipped - # ] - # if settings.get('email.notification.from'): - # raise Exception( - # 'email.notification.from configuration is deprecated. ' - # 'Use instead email.notification.from.email and ' - # 'email.notification.from.default_label.' - # ) - # - # self.EMAIL_NOTIFICATION_FROM_EMAIL = settings.get( - # 'email.notification.from.email', - # ) - # self.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL = settings.get( - # 'email.notification.from.default_label' - # ) - # self.EMAIL_NOTIFICATION_REPLY_TO_EMAIL = settings.get( - # 'email.notification.reply_to.email', - # ) - # self.EMAIL_NOTIFICATION_REFERENCES_EMAIL = settings.get( - # 'email.notification.references.email' - # ) - # self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = settings.get( - # 'email.notification.content_update.template.html', - # ) - # self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT = settings.get( - # 'email.notification.content_update.template.text', - # ) - # self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML = settings.get( - # 'email.notification.created_account.template.html', - # './tracim/templates/mail/created_account_body_html.mak', - # ) - # self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT = settings.get( - # 'email.notification.created_account.template.text', - # './tracim/templates/mail/created_account_body_text.mak', - # ) - # self.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT = settings.get( - # 'email.notification.content_update.subject', - # ) - # self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT = settings.get( - # 'email.notification.created_account.subject', - # '[{website_title}] Created account', - # ) - # self.EMAIL_NOTIFICATION_PROCESSING_MODE = settings.get( - # 'email.notification.processing_mode', - # ) - # + ## + + self.EMAIL_NOTIFICATION_NOTIFIED_EVENTS = [ + ActionDescription.COMMENT, + ActionDescription.CREATION, + ActionDescription.EDITION, + ActionDescription.REVISION, + ActionDescription.STATUS_UPDATE + ] + + self.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS = [ + ContentType.Page, + ContentType.Thread, + ContentType.File, + ContentType.Comment, + # ContentType.Folder -- Folder is skipped + ] + if settings.get('email.notification.from'): + raise Exception( + 'email.notification.from configuration is deprecated. ' + 'Use instead email.notification.from.email and ' + 'email.notification.from.default_label.' + ) + + self.EMAIL_NOTIFICATION_FROM_EMAIL = settings.get( + 'email.notification.from.email', + ) + self.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL = settings.get( + 'email.notification.from.default_label' + ) + self.EMAIL_NOTIFICATION_REPLY_TO_EMAIL = settings.get( + 'email.notification.reply_to.email', + ) + self.EMAIL_NOTIFICATION_REFERENCES_EMAIL = settings.get( + 'email.notification.references.email' + ) + self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = settings.get( + 'email.notification.content_update.template.html', + ) + self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT = settings.get( + 'email.notification.content_update.template.text', + ) + self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML = settings.get( + 'email.notification.created_account.template.html', + './tracim/templates/mail/created_account_body_html.mak', + ) + self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT = settings.get( + 'email.notification.created_account.template.text', + './tracim/templates/mail/created_account_body_text.mak', + ) + self.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT = settings.get( + 'email.notification.content_update.subject', + ) + self.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT = settings.get( + 'email.notification.created_account.subject', + '[{website_title}] Created account', + ) + self.EMAIL_NOTIFICATION_PROCESSING_MODE = settings.get( + 'email.notification.processing_mode', + ) + self.EMAIL_NOTIFICATION_ACTIVATED = asbool(settings.get( 'email.notification.activated', )) - # self.EMAIL_NOTIFICATION_SMTP_SERVER = settings.get( - # 'email.notification.smtp.server', - # ) - # self.EMAIL_NOTIFICATION_SMTP_PORT = settings.get( - # 'email.notification.smtp.port', - # ) - # self.EMAIL_NOTIFICATION_SMTP_USER = settings.get( - # 'email.notification.smtp.user', - # ) - # self.EMAIL_NOTIFICATION_SMTP_PASSWORD = settings.get( - # 'email.notification.smtp.password', - # ) - # self.EMAIL_NOTIFICATION_LOG_FILE_PATH = settings.get( - # 'email.notification.log_file_path', - # None, - # ) - # + self.EMAIL_NOTIFICATION_SMTP_SERVER = settings.get( + 'email.notification.smtp.server', + ) + self.EMAIL_NOTIFICATION_SMTP_PORT = settings.get( + 'email.notification.smtp.port', + ) + self.EMAIL_NOTIFICATION_SMTP_USER = settings.get( + 'email.notification.smtp.user', + ) + self.EMAIL_NOTIFICATION_SMTP_PASSWORD = settings.get( + 'email.notification.smtp.password', + ) + self.EMAIL_NOTIFICATION_LOG_FILE_PATH = settings.get( + 'email.notification.log_file_path', + None, + ) + # self.EMAIL_REPLY_ACTIVATED = asbool(settings.get( # 'email.reply.activated', # False, @@ -267,36 +269,36 @@ def __init__(self, settings): # mandatory_msg.format('email.reply.lockfile_path') # ) # - # self.EMAIL_PROCESSING_MODE = settings.get( - # 'email.processing_mode', - # 'sync', - # ).upper() - # - # if self.EMAIL_PROCESSING_MODE not in ( - # self.CST.ASYNC, - # self.CST.SYNC, - # ): - # raise Exception( - # 'email.processing_mode ' - # 'can ''be "{}" or "{}", not "{}"'.format( - # self.CST.ASYNC, - # self.CST.SYNC, - # self.EMAIL_PROCESSING_MODE, - # ) - # ) - # - # self.EMAIL_SENDER_REDIS_HOST = settings.get( - # 'email.async.redis.host', - # 'localhost', - # ) - # self.EMAIL_SENDER_REDIS_PORT = int(settings.get( - # 'email.async.redis.port', - # 6379, - # )) - # self.EMAIL_SENDER_REDIS_DB = int(settings.get( - # 'email.async.redis.db', - # 0, - # )) + self.EMAIL_PROCESSING_MODE = settings.get( + 'email.processing_mode', + 'sync', + ).upper() + + if self.EMAIL_PROCESSING_MODE not in ( + self.CST.ASYNC, + self.CST.SYNC, + ): + raise Exception( + 'email.processing_mode ' + 'can ''be "{}" or "{}", not "{}"'.format( + self.CST.ASYNC, + self.CST.SYNC, + self.EMAIL_PROCESSING_MODE, + ) + ) + + self.EMAIL_SENDER_REDIS_HOST = settings.get( + 'email.async.redis.host', + 'localhost', + ) + self.EMAIL_SENDER_REDIS_PORT = int(settings.get( + 'email.async.redis.port', + 6379, + )) + self.EMAIL_SENDER_REDIS_DB = int(settings.get( + 'email.async.redis.db', + 0, + )) ### # WSGIDAV (Webdav server) diff --git a/tracim/lib/core/content.py b/tracim/lib/core/content.py index e2a20f7..b86c00f 100644 --- a/tracim/lib/core/content.py +++ b/tracim/lib/core/content.py @@ -376,7 +376,7 @@ def get_child_folders(self, parent: Content=None, workspace: Workspace=None, fil return result - def create(self, content_type: str, workspace: Workspace, parent: Content=None, label:str ='', do_save=False, is_temporary: bool=False) -> Content: + def create(self, content_type: str, workspace: Workspace, parent: Content=None, label:str ='', do_save=False, is_temporary: bool=False, do_notify=False) -> Content: assert content_type in ContentType.allowed_types() if content_type == ContentType.Folder and not label: @@ -399,7 +399,7 @@ def create(self, content_type: str, workspace: Workspace, parent: Content=None, if do_save: self._session.add(content) - self.save(content, ActionDescription.CREATION) + self.save(content, ActionDescription.CREATION, do_notify=do_notify) return content @@ -1127,8 +1127,9 @@ def do_notify(self, content: Content): :return: """ NotifierFactory.create( - self._config, - self._user + config=self._config, + current_user=self._user, + session=self._session, ).notify_content_update(content) def get_keywords(self, search_string, search_string_separators=None) -> [str]: diff --git a/tracim/lib/core/notifications.py b/tracim/lib/core/notifications.py index 9ad502a..44d0546 100644 --- a/tracim/lib/core/notifications.py +++ b/tracim/lib/core/notifications.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from sqlalchemy.orm import Session +from tracim import CFG from tracim.lib.utils.logger import logger from tracim.models.auth import User from tracim.models.data import Content @@ -9,7 +11,11 @@ class INotifier(object): """ Interface for Notifier instances """ - def __init__(self, config, current_user: User=None): + def __init__(self, + config: CFG, + session: Session, + current_user: User=None, + ) -> None: pass def notify_content_update(self, content: Content): @@ -19,17 +25,28 @@ def notify_content_update(self, content: Content): class NotifierFactory(object): @classmethod - def create(cls, config, current_user: User=None) -> INotifier: + def create(cls, config, session, current_user: User=None) -> INotifier: if not config.EMAIL_NOTIFICATION_ACTIVATED: - return DummyNotifier(config, current_user) - return EmailNotifier(config, current_user) + return DummyNotifier(config, session, current_user) + from tracim.lib.mail_notifier.notifier import EmailNotifier + return EmailNotifier(config, session, current_user) class DummyNotifier(INotifier): send_count = 0 - def __init__(self, config, current_user: User=None): - INotifier.__init__(config, current_user) + def __init__( + self, + config: CFG, + session: Session, + current_user: User=None + ) -> None: + INotifier.__init__( + self, + config, + session, + current_user, + ) logger.info(self, 'Instantiating Dummy Notifier') def notify_content_update(self, content: Content): @@ -38,8 +55,3 @@ def notify_content_update(self, content: Content): self, 'Fake notifier, do not send notification for update of content {}'.format(content.content_id) # nopep8 ) - - -class EmailNotifier(INotifier): - # TODO - G.M [emailNotif] move and restore Email Notifier in another file. - pass diff --git a/tracim/lib/core/user.py b/tracim/lib/core/user.py index 0f5f32e..8c1e6e9 100644 --- a/tracim/lib/core/user.py +++ b/tracim/lib/core/user.py @@ -4,6 +4,7 @@ import transaction import typing as typing +from tracim.lib.mail_notifier.notifier import get_email_manager from tracim.models.auth import User @@ -34,8 +35,9 @@ def update( user: User, name: str=None, email: str=None, - do_save=True, + password: str=None, timezone: str='', + do_save=True, ): if name is not None: user.display_name = name @@ -43,6 +45,9 @@ def update( if email is not None: user.email = email + if password is not None: + user.password = password + user.timezone = timezone if do_save: @@ -56,7 +61,40 @@ def user_with_email_exists(self, email: str): except: return False - def create_user(self, email=None, groups=[], save_now=False) -> User: + def create_user( + self, + email: str = None, + password: str = None, + name: str = None, + timezone: str = '', + groups=[], + do_save: bool=True, + do_notify: bool=True, + ) -> User: + new_user = self.create_minimal_user(email, groups, save_now=False) + self.update( + user=new_user, + name=name, + email=email, + password=password, + timezone=timezone, + do_save=do_save, + ) + if do_notify: + email_manager = get_email_manager(self._config, self._session) + email_manager.notify_created_account( + new_user, + password=password + ) + return new_user + + def create_minimal_user( + self, + email=None, + groups=[], + save_now=False + ) -> User: + """Previous create_user method""" user = User() if email: diff --git a/tracim/lib/mail_notifier/daemon.py b/tracim/lib/mail_notifier/daemon.py new file mode 100644 index 0000000..f9ea7ab --- /dev/null +++ b/tracim/lib/mail_notifier/daemon.py @@ -0,0 +1,61 @@ +from sqlalchemy.orm import collections + +from tracim.lib.utils.logger import logger +from tracim.lib.utils.utils import get_rq_queue +from tracim.lib.utils.utils import get_redis_connection +from rq.dummy import do_nothing +from rq.worker import StopRequested +from rq import Connection as RQConnection +from rq import Worker as BaseRQWorker + + +class FakeDaemon(object): + """ + Temporary class for transition between tracim 1 and tracim 2 + """ + def __init__(self, config, *args, **kwargs): + pass + + +class MailSenderDaemon(FakeDaemon): + # NOTE: use *args and **kwargs because parent __init__ use strange + # * parameter + def __init__(self, config, *args, **kwargs): + super().__init__(*args, **kwargs) + self.config = config + self.worker = None # type: RQWorker + + def append_thread_callback(self, callback: collections.Callable) -> None: + logger.warning('MailSenderDaemon not implement append_thread_callback') + pass + + def stop(self) -> None: + # When _stop_requested at False, tracim.lib.daemons.RQWorker + # will raise StopRequested exception in worker thread after receive a + # job. + self.worker._stop_requested = True + redis_connection = get_redis_connection(self.config) + queue = get_rq_queue(redis_connection, 'mail_sender') + queue.enqueue(do_nothing) + + def run(self) -> None: + + with RQConnection(get_redis_connection(self.config)): + self.worker = RQWorker(['mail_sender']) + self.worker.work() + + +class RQWorker(BaseRQWorker): + def _install_signal_handlers(self): + # RQ Worker is designed to work in main thread + # So we have to disable these signals (we implement server stop in + # MailSenderDaemon.stop method). + pass + + def dequeue_job_and_maintain_ttl(self, timeout): + # RQ Worker is designed to work in main thread, so we add behaviour + # here: if _stop_requested has been set to True, raise the standard way + # StopRequested exception to stop worker. + if self._stop_requested: + raise StopRequested() + return super().dequeue_job_and_maintain_ttl(timeout) \ No newline at end of file diff --git a/tracim/lib/mail_notifier/notifier.py b/tracim/lib/mail_notifier/notifier.py new file mode 100644 index 0000000..774005c --- /dev/null +++ b/tracim/lib/mail_notifier/notifier.py @@ -0,0 +1,556 @@ +# -*- coding: utf-8 -*- +import datetime +import typing + +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formataddr + +from lxml.html.diff import htmldiff +from mako.template import Template +from sqlalchemy.orm import Session + +from tracim import CFG +from tracim.lib.core.notifications import INotifier +from tracim.lib.mail_notifier.sender import EmailSender +from tracim.lib.mail_notifier.utils import SmtpConfiguration, EST +from tracim.lib.mail_notifier.sender import send_email_through +from tracim.lib.core.workspace import WorkspaceApi +from tracim.lib.utils.logger import logger +from tracim.models import User +from tracim.models.auth import User +from tracim.models.data import ActionDescription +from tracim.models.data import Content +from tracim.models.data import ContentType +from tracim.models.data import UserRoleInWorkspace +from tracim.lib.utils.translation import fake_translator as l_, \ + fake_translator as _ + + +class EmailNotifier(INotifier): + """ + EmailNotifier, this class will decide how to notify by mail + in order to let a EmailManager create email + """ + + def __init__( + self, + config: CFG, + session: Session, + current_user: User=None + ): + """ + :param current_user: the user that has triggered the notification + :return: + """ + INotifier.__init__(self, config, session, current_user) + logger.info(self, 'Instantiating Email Notifier') + + self._user = current_user + self.session = session + self.config = config + self._smtp_config = SmtpConfiguration( + self.config.EMAIL_NOTIFICATION_SMTP_SERVER, + self.config.EMAIL_NOTIFICATION_SMTP_PORT, + self.config.EMAIL_NOTIFICATION_SMTP_USER, + self.config.EMAIL_NOTIFICATION_SMTP_PASSWORD + ) + + def notify_content_update(self, content: Content): + + if content.get_last_action().id not \ + in self.config.EMAIL_NOTIFICATION_NOTIFIED_EVENTS: + logger.info( + self, + 'Skip email notification for update of content {}' + 'by user {} (the action is {})'.format( + content.content_id, + # below: 0 means "no user" + self._user.user_id if self._user else 0, + content.get_last_action().id + ) + ) + return + + logger.info(self, + 'About to email-notify update' + 'of content {} by user {}'.format( + content.content_id, + # Below: 0 means "no user" + self._user.user_id if self._user else 0 + ) + ) + + if content.type not \ + in self.config.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS: + logger.info( + self, + 'Skip email notification for update of content {}' + 'by user {} (the content type is {})'.format( + content.type, + # below: 0 means "no user" + self._user.user_id if self._user else 0, + content.get_last_action().id + ) + ) + return + + logger.info(self, + 'About to email-notify update' + 'of content {} by user {}'.format( + content.content_id, + # Below: 0 means "no user" + self._user.user_id if self._user else 0 + ) + ) + + #### + # + # INFO - D.A. - 2014-11-05 - Emails are sent through asynchronous jobs. + # For that reason, we do not give SQLAlchemy objects but ids only + # (SQLA objects are related to a given thread/session) + # + try: + if self.config.EMAIL_NOTIFICATION_PROCESSING_MODE.lower() == self.config.CST.ASYNC.lower(): + logger.info(self, 'Sending email in ASYNC mode') + # TODO - D.A - 2014-11-06 + # This feature must be implemented in order to be able to scale to large communities + raise NotImplementedError('Sending emails through ASYNC mode is not working yet') + else: + logger.info(self, 'Sending email in SYNC mode') + EmailManager( + self._smtp_config, + self.config, + self.session, + ).notify_content_update(self._user.user_id, content.content_id) + except TypeError as e: + logger.error(self, 'Exception catched during email notification: {}'.format(e.__str__())) + + +class EmailManager(object): + """ + Compared to Notifier, this class is independant from the HTTP request thread + This class will build Email and send it for both created account and content + update + """ + + def __init__( + self, + smtp_config: SmtpConfiguration, + config: CFG, + session: Session + ) -> None: + self._smtp_config = smtp_config + self.config = config + self.session = session + # FIXME - G.M - We need to have a session for the emailNotifier + + # if not self.session: + # engine = get_engine(settings) + # session_factory = get_session_factory(engine) + # app_config = CFG(settings) + + def _get_sender(self, user: User=None) -> str: + """ + Return sender string like "Bob Dylan + (via Tracim) " + :param user: user to extract display name + :return: sender string + """ + + email_template = self.config.EMAIL_NOTIFICATION_FROM_EMAIL + mail_sender_name = self.config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL # nopep8 + if user: + mail_sender_name = '{name} via Tracim'.format(name=user.display_name) + email_address = email_template.replace('{user_id}', str(user.user_id)) + # INFO - D.A. - 2017-08-04 + # We use email_template.replace() instead of .format() because this + # method is more robust to errors in config file. + # + # For example, if the email is info+{userid}@tracim.fr + # email.format(user_id='bob') will raise an exception + # email.replace('{user_id}', 'bob') will just ignore {userid} + else: + email_address = email_template.replace('{user_id}', '0') + + return formataddr((mail_sender_name, email_address)) + + # Content Notification + + @staticmethod + def log_notification( + config: CFG, + action: str, + recipient: typing.Optional[str], + subject: typing.Optional[str], + ) -> None: + """Log notification metadata.""" + log_path = config.EMAIL_NOTIFICATION_LOG_FILE_PATH + if log_path: + # TODO - A.P - 2017-09-06 - file logging inefficiency + # Updating a document with 100 users to notify will leads to open + # and close the file 100 times. + with open(log_path, 'a') as log_file: + print( + datetime.datetime.now(), + action, + recipient, + subject, + sep='|', + file=log_file, + ) + + def notify_content_update( + self, + event_actor_id: int, + event_content_id: int + ) -> None: + """ + Look for all users to be notified about the new content and send them an + individual email + :param event_actor_id: id of the user that has triggered the event + :param event_content_id: related content_id + :return: + """ + # FIXME - D.A. - 2014-11-05 + # Dirty import. It's here in order to avoid circular import + from tracim.lib.core.content import ContentApi + from tracim.lib.core.user import UserApi + user = UserApi( + None, + config=self.config, + session=self.session, + ).get_one(event_actor_id) + logger.debug(self, 'Content: {}'.format(event_content_id)) + content_api = ContentApi( + current_user=user, + session=self.session, + config=self.config, + ) + content = ContentApi( + session=self.session, + current_user=user, # TODO - use a system user instead of the user that has triggered the event + config=self.config, + show_archived=True, + show_deleted=True, + ).get_one(event_content_id, ContentType.Any) + main_content = content.parent if content.type == ContentType.Comment else content + notifiable_roles = WorkspaceApi( + current_user=user, + session=self.session, + ).get_notifiable_roles(content.workspace) + + if len(notifiable_roles) <= 0: + logger.info(self, 'Skipping notification as nobody subscribed to in workspace {}'.format(content.workspace.label)) + return + + + logger.info(self, 'Sending asynchronous emails to {} user(s)'.format(len(notifiable_roles))) + # INFO - D.A. - 2014-11-06 + # The following email sender will send emails in the async task queue + # This allow to build all mails through current thread but really send them (including SMTP connection) + # In the other thread. + # + # This way, the webserver will return sooner (actually before notification emails are sent + async_email_sender = EmailSender( + self.config, + self._smtp_config, + self.config.EMAIL_NOTIFICATION_ACTIVATED + ) + for role in notifiable_roles: + logger.info(self, 'Sending email to {}'.format(role.user.email)) + to_addr = formataddr((role.user.display_name, role.user.email)) + # + # INFO - G.M - 2017-11-15 - set content_id in header to permit reply + # references can have multiple values, but only one in this case. + replyto_addr = self.config.EMAIL_NOTIFICATION_REPLY_TO_EMAIL.replace( # nopep8 + '{content_id}',str(content.content_id) + ) + + reference_addr = self.config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace( #nopep8 + '{content_id}',str(content.content_id) + ) + # + # INFO - D.A. - 2014-11-06 + # We do not use .format() here because the subject defined in the .ini file + # may not include all required labels. In order to avoid partial format() (which result in an exception) + # we do use replace and force the use of .__str__() in order to process LazyString objects + # + subject = self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT + subject = subject.replace(EST.WEBSITE_TITLE, self.config.WEBSITE_TITLE.__str__()) + subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__()) + subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__()) + subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__()) + reply_to_label = l_('{username} & all members of {workspace}').format( + username=user.display_name, + workspace=main_content.workspace.label) + + message = MIMEMultipart('alternative') + message['Subject'] = subject + message['From'] = self._get_sender(user) + message['To'] = to_addr + message['Reply-to'] = formataddr((reply_to_label, replyto_addr)) + # INFO - G.M - 2017-11-15 + # References can theorically have label, but in pratice, references + # contains only message_id from parents post in thread. + # To link this email to a content we create a virtual parent + # in reference who contain the content_id. + message['References'] = formataddr(('',reference_addr)) + body_text = self._build_email_body_for_content(self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user) + body_html = self._build_email_body_for_content(self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML, role, content, user) + + part1 = MIMEText(body_text, 'plain', 'utf-8') + part2 = MIMEText(body_html, 'html', 'utf-8') + # Attach parts into message container. + # According to RFC 2046, the last part of a multipart message, in this case + # the HTML message, is best and preferred. + message.attach(part1) + message.attach(part2) + + self.log_notification( + action='CREATED', + recipient=message['To'], + subject=message['Subject'], + config=self.config, + ) + + send_email_through( + self.config, + async_email_sender.send_mail, + message + ) + + def notify_created_account( + self, + user: User, + password: str, + ) -> None: + """ + Send created account email to given user. + + :param password: choosed password + :param user: user to notify + """ + # TODO BS 20160712: Cyclic import + logger.debug(self, 'user: {}'.format(user.user_id)) + logger.info(self, 'Sending asynchronous email to 1 user ({0})'.format( + user.email, + )) + + async_email_sender = EmailSender( + self.config, + self._smtp_config, + self.config.EMAIL_NOTIFICATION_ACTIVATED + ) + + subject = \ + self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT \ + .replace( + EST.WEBSITE_TITLE, + self.config.WEBSITE_TITLE.__str__() + ) + message = MIMEMultipart('alternative') + message['Subject'] = subject + message['From'] = self._get_sender() + message['To'] = formataddr((user.get_display_name(), user.email)) + + text_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT # nopep8 + html_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML # nopep8 + + body_text = self._render_template( + mako_template_filepath=text_template_file_path, + context={ + 'user': user, + 'password': password, + 'login_url': self.config.WEBSITE_BASE_URL, + } + ) + + body_html = self._render_template( + mako_template_filepath=html_template_file_path, + context={ + 'user': user, + 'password': password, + 'login_url': self.config.WEBSITE_BASE_URL, + } + ) + + part1 = MIMEText(body_text, 'plain', 'utf-8') + part2 = MIMEText(body_html, 'html', 'utf-8') + + # Attach parts into message container. + # According to RFC 2046, the last part of a multipart message, + # in this case the HTML message, is best and preferred. + message.attach(part1) + message.attach(part2) + + send_email_through( + config=self.config, + sendmail_callable=async_email_sender.send_mail, + message=message + ) + + def _render_template( + self, + mako_template_filepath: str, + context: dict + ) -> str: + """ + Render mako template with all needed current variables. + + :param mako_template_filepath: file path of mako template + :param context: dict with template context + :return: template rendered string + """ + + template = Template(filename=mako_template_filepath) + return template.render( + _=_, + config=self.config, + **context + ) + + def _build_email_body_for_content( + self, + mako_template_filepath: str, + role: UserRoleInWorkspace, + content: Content, + actor: User + ) -> str: + """ + Build an email body and return it as a string + :param mako_template_filepath: the absolute path to the mako template to be used for email body building + :param role: the role related to user to whom the email must be sent. The role is required (and not the user only) in order to show in the mail why the user receive the notification + :param content: the content item related to the notification + :param actor: the user at the origin of the action / notification (for example the one who wrote a comment + :param config: the global configuration + :return: the built email body as string. In case of multipart email, this method must be called one time for text and one time for html + """ + logger.debug(self, 'Building email content from MAKO template {}'.format(mako_template_filepath)) + + main_title = content.label + content_intro = '' + content_text = '' + call_to_action_text = '' + + action = content.get_last_action().id + if ActionDescription.COMMENT == action: + content_intro = l_('{} added a comment:').format(actor.display_name) + content_text = content.description + call_to_action_text = l_('Answer') + + elif ActionDescription.CREATION == action: + + # Default values (if not overriden) + content_text = content.description + call_to_action_text = l_('View online') + + if ContentType.Thread == content.type: + call_to_action_text = l_('Answer') + content_intro = l_('{} started a thread entitled:').format(actor.display_name) + content_text = '

{}

'.format(content.label) + \ + content.get_last_comment_from(actor).description + + elif ContentType.File == content.type: + content_intro = l_('{} added a file entitled:').format(actor.display_name) + if content.description: + content_text = content.description + else: + content_text = '{}'.format(content.label) + + elif ContentType.Page == content.type: + content_intro = l_('{} added a page entitled:').format(actor.display_name) + content_text = '{}'.format(content.label) + + elif ActionDescription.REVISION == action: + content_text = content.description + call_to_action_text = l_('View online') + + if ContentType.File == content.type: + content_intro = l_('{} uploaded a new revision.').format(actor.display_name) + content_text = '' + + elif ContentType.Page == content.type: + content_intro = l_('{} updated this page.').format(actor.display_name) + previous_revision = content.get_previous_revision() + title_diff = '' + if previous_revision.label != content.label: + title_diff = htmldiff(previous_revision.label, content.label) + content_text = str(l_('

Here is an overview of the changes:

'))+ \ + title_diff + \ + htmldiff(previous_revision.description, content.description) + + elif ContentType.Thread == content.type: + content_intro = l_('{} updated the thread description.').format(actor.display_name) + previous_revision = content.get_previous_revision() + title_diff = '' + if previous_revision.label != content.label: + title_diff = htmldiff(previous_revision.label, content.label) + content_text = str(l_('

Here is an overview of the changes:

'))+ \ + title_diff + \ + htmldiff(previous_revision.description, content.description) + + elif ActionDescription.EDITION == action: + call_to_action_text = l_('View online') + + if ContentType.File == content.type: + content_intro = l_('{} updated the file description.').format(actor.display_name) + content_text = '

{}

'.format(content.get_label()) + \ + content.description + + elif ActionDescription.STATUS_UPDATE == action: + call_to_action_text = l_('View online') + intro_user_msg = l_( + '{} ' + 'updated the following status:' + ) + content_intro = intro_user_msg.format(actor.display_name) + intro_body_msg = '

{}: {}

' + content_text = intro_body_msg.format( + content.get_label(), + content.get_status().label, + ) + + if '' == content_intro and content_text == '': + # Skip notification, but it's not normal + logger.error( + self, 'A notification is being sent but no content. ' + 'Here are some debug informations: [content_id: {cid}]' + '[action: {act}][author: {actor}]'.format( + cid=content.content_id, act=action, actor=actor + ) + ) + raise ValueError('Unexpected empty notification') + + user = role.user + workspace = role.workspace + body_content = self._render_template( + mako_template_filepath=mako_template_filepath, + context={ + 'user': role.user, + 'workspace': role.workspace, + 'main_title': main_title, + 'status': content.get_status().label, + 'role': role.role_as_label(), + 'content_intro': content_intro, + 'content_text': content_text, + 'call_to_action_text': call_to_action_text, + } + ) + return body_content + + +def get_email_manager(config: CFG, session: Session): + """ + :return: EmailManager instance + """ + #  TODO: Find a way to import properly without cyclic import + + smtp_config = SmtpConfiguration( + config.EMAIL_NOTIFICATION_SMTP_SERVER, + config.EMAIL_NOTIFICATION_SMTP_PORT, + config.EMAIL_NOTIFICATION_SMTP_USER, + config.EMAIL_NOTIFICATION_SMTP_PASSWORD + ) + + return EmailManager(config=config, smtp_config=smtp_config, session=session) diff --git a/tracim/lib/mail_notifier/sender.py b/tracim/lib/mail_notifier/sender.py new file mode 100644 index 0000000..1332f13 --- /dev/null +++ b/tracim/lib/mail_notifier/sender.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +import smtplib +import typing +from email.message import Message +from email.mime.multipart import MIMEMultipart + +from tracim.config import CFG +from tracim.lib.utils.logger import logger +from tracim.lib.utils.utils import get_rq_queue +from tracim.lib.utils.utils import get_redis_connection +from tracim.lib.mail_notifier.utils import SmtpConfiguration + +def send_email_through( + config: CFG, + sendmail_callable: typing.Callable[[Message], None], + message: Message, +) -> None: + """ + Send mail encapsulation to send it in async or sync mode. + + TODO BS 20170126: A global mail/sender management should be a good + thing. Actually, this method is an fast solution. + :param config: system configuration + :param sendmail_callable: A callable who get message on first parameter + :param message: The message who have to be sent + """ + + if config.EMAIL_PROCESSING_MODE == config.CST.SYNC: + sendmail_callable(message) + elif config.EMAIL_PROCESSING_MODE == config.CST.ASYNC: + redis_connection = get_redis_connection(config) + queue = get_rq_queue(redis_connection, 'mail_sender') + queue.enqueue(sendmail_callable, message) + else: + raise NotImplementedError( + 'Mail sender processing mode {} is not implemented'.format( + config.EMAIL_PROCESSING_MODE, + ) + ) + + +class EmailSender(object): + """ + Independent email sender class. + + To allow its use in any thread, as an asyncjob_perform() call for + example, it has no dependencies on SQLAlchemy nor tg HTTP request. + """ + + def __init__( + self, + config: CFG, + smtp_config: SmtpConfiguration, + really_send_messages + ) -> None: + self._smtp_config = smtp_config + self.config = config + self._smtp_connection = None + self._is_active = really_send_messages + + def connect(self): + if not self._smtp_connection: + log = 'Connecting from SMTP server {}' + logger.info(self, log.format(self._smtp_config.server)) + self._smtp_connection = smtplib.SMTP( + self._smtp_config.server, + self._smtp_config.port + ) + self._smtp_connection.ehlo() + + if self._smtp_config.login: + try: + starttls_result = self._smtp_connection.starttls() + log = 'SMTP start TLS result: {}' + logger.debug(self, log.format(starttls_result)) + except Exception as e: + log = 'SMTP start TLS error: {}' + logger.debug(self, log.format(e.__str__())) + + if self._smtp_config.login: + try: + login_res = self._smtp_connection.login( + self._smtp_config.login, + self._smtp_config.password + ) + log = 'SMTP login result: {}' + logger.debug(self, log.format(login_res)) + except Exception as e: + log = 'SMTP login error: {}' + logger.debug(self, log.format(e.__str__())) + logger.info(self, 'Connection OK') + + def disconnect(self): + if self._smtp_connection: + log = 'Disconnecting from SMTP server {}' + logger.info(self, log.format(self._smtp_config.server)) + self._smtp_connection.quit() + logger.info(self, 'Connection closed.') + + def send_mail(self, message: MIMEMultipart): + if not self._is_active: + log = 'Not sending email to {} (service disabled)' + logger.info(self, log.format(message['To'])) + else: + self.connect() # Actually, this connects to SMTP only if required + logger.info(self, 'Sending email to {}'.format(message['To'])) + self._smtp_connection.send_message(message) + from tracim.lib.mail_notifier.notifier import EmailManager + EmailManager.log_notification( + action=' SENT', + recipient=message['To'], + subject=message['Subject'], + config=self.config, + ) diff --git a/tracim/lib/mail_notifier/utils.py b/tracim/lib/mail_notifier/utils.py new file mode 100644 index 0000000..5b7b190 --- /dev/null +++ b/tracim/lib/mail_notifier/utils.py @@ -0,0 +1,33 @@ +class SmtpConfiguration(object): + """Container class for SMTP configuration used in Tracim.""" + + def __init__(self, server: str, port: int, login: str, password: str): + self.server = server + self.port = port + self.login = login + self.password = password + + +class EST(object): + """ + EST = Email Subject Tags - this is a convenient class - no business logic + here + This class is intended to agregate all dynamic content that may be included + in email subjects + """ + + WEBSITE_TITLE = '{website_title}' + WORKSPACE_LABEL = '{workspace_label}' + CONTENT_LABEL = '{content_label}' + CONTENT_STATUS_LABEL = '{content_status_label}' + + @classmethod + def all(cls): + return [ + cls.CONTENT_LABEL, + cls.CONTENT_STATUS_LABEL, + cls.WEBSITE_TITLE, + cls.WORKSPACE_LABEL + ] + + diff --git a/tracim/lib/utils/utils.py b/tracim/lib/utils/utils.py index 6a14939..25d3507 100644 --- a/tracim/lib/utils/utils.py +++ b/tracim/lib/utils/utils.py @@ -1,10 +1,35 @@ # -*- coding: utf-8 -*- import datetime +from redis import Redis +from rq import Queue + +from tracim.config import CFG DEFAULT_WEBDAV_CONFIG_FILE = "wsgidav.conf" DEFAULT_TRACIM_CONFIG_FILE = "development.ini" +def get_redis_connection(config: CFG) -> Redis: + """ + :param config: current app_config + :return: redis connection + """ + return Redis( + host=config.EMAIL_SENDER_REDIS_HOST, + port=config.EMAIL_SENDER_REDIS_PORT, + db=config.EMAIL_SENDER_REDIS_DB, + ) + + +def get_rq_queue(redis_connection: Redis, queue_name: str ='default') -> Queue: + """ + :param queue_name: name of queue + :return: wanted queue + """ + + return Queue(name=queue_name, connection=redis_connection) + + def cmp_to_key(mycmp): """ List sort related function diff --git a/tracim/models/data.py b/tracim/models/data.py index 0fef9e7..12dd4f5 100644 --- a/tracim/models/data.py +++ b/tracim/models/data.py @@ -131,12 +131,12 @@ class UserRoleInWorkspace(DeclarativeBase): WORKSPACE_MANAGER = 8 # TODO - G.M - 10-04-2018 - [Cleanup] Drop this - # LABEL = dict() - # LABEL[0] = l_('N/A') - # LABEL[1] = l_('Reader') - # LABEL[2] = l_('Contributor') - # LABEL[4] = l_('Content Manager') - # LABEL[8] = l_('Workspace Manager') + LABEL = dict() + LABEL[0] = l_('N/A') + LABEL[1] = l_('Reader') + LABEL[2] = l_('Contributor') + LABEL[4] = l_('Content Manager') + LABEL[8] = l_('Workspace Manager') # # STYLE = dict() # STYLE[0] = '' @@ -161,8 +161,9 @@ class UserRoleInWorkspace(DeclarativeBase): # def style(self): # return UserRoleInWorkspace.STYLE[self.role] # - # def role_as_label(self): - # return UserRoleInWorkspace.LABEL[self.role] + + def role_as_label(self): + return UserRoleInWorkspace.LABEL[self.role] @classmethod def get_all_role_values(self): @@ -318,6 +319,7 @@ def __init__(self, # type='' ): self.id = id + self.label = self.id # TODO - G.M - 10-04-2018 - [Cleanup] Drop this # self.icon = ContentStatus._ICONS[id] # self.css = ContentStatus._CSS[id] diff --git a/tracim/templates/mail/__init__.py b/tracim/templates/mail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tracim/templates/mail/content_update_body_html.mak b/tracim/templates/mail/content_update_body_html.mak new file mode 100644 index 0000000..513e8b8 --- /dev/null +++ b/tracim/templates/mail/content_update_body_html.mak @@ -0,0 +1,72 @@ +## -*- coding: utf-8 -*- + + + + + + + + + + + +
+ FIXME restore logo + + + ${main_title} + —  + ${status|n} + FIXME restore icon + +
+ +

${content_intro|n}

+
+
${content_text|n}
+
+
+
+ + + + diff --git a/tracim/templates/mail/content_update_body_text.mak b/tracim/templates/mail/content_update_body_text.mak new file mode 100644 index 0000000..1946151 --- /dev/null +++ b/tracim/templates/mail/content_update_body_text.mak @@ -0,0 +1,32 @@ +## -*- coding: utf-8 -*- + +Dear ${user.display_name}, + +This email is intended to be read as HTML content. +Please configure your email client to get the best of Tracim notifications. + +We understand that Email was originally intended to carry raw text only. +And you probably understand on your own that we are a decades after email +was created ;) + +Hope you'll switch your mail client configuration and enjoy Tracim :) + + +-------------------------------------------------------------------------------- + + +You receive this email because you are registered on /${config.WEBSITE_TITLE}/ +and you are /${role}/ in the workspace /${workspace.label}/ + +---- + +This email was automatically sent by *Tracim*, +a collaborative software developped by Algoo. + +FIXME ADDR +**Algoo SAS** +9 rue du rocher de Lorzier +38430 Moirans +France +http://algoo.fr + diff --git a/tracim/templates/mail/created_account_body_html.mak b/tracim/templates/mail/created_account_body_html.mak new file mode 100644 index 0000000..0966f20 --- /dev/null +++ b/tracim/templates/mail/created_account_body_html.mak @@ -0,0 +1,89 @@ +## -*- coding: utf-8 -*- + + + + + + + + + + + +
+ FIXME restore logo + + + + ${config.WEBSITE_TITLE}: ${_('Created account')} + + +
+ +
+
+ ${_('An administrator just create account for you on {website_title}'.format( + website_title=config.WEBSITE_TITLE + ))} + +
    +
  • + ${_('Login')}: ${user.email} +
  • +
  • + ${_('Password')}: ${password} +
  • +
+ +
+
+ + ${_('To go to {website_title}, please click on following link'.format( + website_title=config.WEBSITE_TITLE + ))} + + + ${login_url} + +
+
+ + + + diff --git a/tracim/templates/mail/created_account_body_text.mak b/tracim/templates/mail/created_account_body_text.mak new file mode 100644 index 0000000..dd5c71a --- /dev/null +++ b/tracim/templates/mail/created_account_body_text.mak @@ -0,0 +1,26 @@ +## -*- coding: utf-8 -*- + +${_('An administrator just create account for you on {website_title}'.format( + website_title=config.WEBSITE_TITLE +))} + +* ${_('Login')}: ${user.email} +* ${_('Password')}: ${password} + +${_('To go to {website_title}, please click on following link'.format( + website_title=config.WEBSITE_TITLE +))} + +${login_url} + +-------------------------------------------------------------------------------- + +This email was sent by *Tracim*, +a collaborative software developped by Algoo. + +FIXME ADDR +**Algoo SAS** +9 rue du rocher de Lorzier +38430 Moirans +France +http://algoo.fr diff --git a/tracim/tests/library/test_content_api.py b/tracim/tests/library/test_content_api.py index 5b35b3c..95db7a8 100644 --- a/tracim/tests/library/test_content_api.py +++ b/tracim/tests/library/test_content_api.py @@ -112,8 +112,8 @@ def test_delete(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace = WorkspaceApi( current_user=user, session=self.session @@ -189,8 +189,8 @@ def test_archive(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace_api = WorkspaceApi(current_user=user, session=self.session) workspace = workspace_api.create_workspace( 'test workspace', @@ -274,7 +274,7 @@ def test_get_all_with_filter(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user( + user = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True @@ -331,8 +331,8 @@ def test_get_all_with_parent_id(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace = WorkspaceApi( current_user=user, session=self.session @@ -400,8 +400,8 @@ def test_set_status_unknown_status(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace = WorkspaceApi( current_user=user, @@ -438,8 +438,8 @@ def test_set_status_ok(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace = WorkspaceApi( current_user=user, @@ -480,8 +480,8 @@ def test_create_comment_ok(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace = WorkspaceApi( current_user=user, @@ -522,12 +522,12 @@ def test_unit_copy_file_different_label_different_parent_ok(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user( + user = uapi.create_minimal_user( email='user1@user', groups=groups, save_now=True ) - user2 = uapi.create_user( + user2 = uapi.create_minimal_user( email='user2@user', groups=groups, save_now=True @@ -634,12 +634,12 @@ def test_unit_copy_file__same_label_different_parent_ok(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user( + user = uapi.create_minimal_user( email='user1@user', groups=groups, save_now=True ) - user2 = uapi.create_user( + user2 = uapi.create_minimal_user( email='user2@user', groups=groups, save_now=True @@ -744,12 +744,12 @@ def test_unit_copy_file_different_label_same_parent_ok(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user( + user = uapi.create_minimal_user( email='user1@user', groups=groups, save_now=True, ) - user2 = uapi.create_user( + user2 = uapi.create_minimal_user( email='user2@user', groups=groups, save_now=True @@ -843,10 +843,10 @@ def test_mark_read__workspace(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user_a = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) - user_b = uapi.create_user(email='this.is@another.user', - groups=groups, save_now=True) + user_a = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) + user_b = uapi.create_minimal_user(email='this.is@another.user', + groups=groups, save_now=True) wapi = WorkspaceApi( current_user=user_a, @@ -946,12 +946,12 @@ def test_mark_read(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user_a = uapi.create_user( + user_a = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True ) - user_b = uapi.create_user( + user_b = uapi.create_minimal_user( email='this.is@another.user', groups=groups, save_now=True @@ -1009,12 +1009,12 @@ def test_mark_read__all(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user_a = uapi.create_user( + user_a = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True ) - user_b = uapi.create_user( + user_b = uapi.create_minimal_user( email='this.is@another.user', groups=groups, save_now=True @@ -1102,7 +1102,7 @@ def test_update(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user1 = uapi.create_user( + user1 = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True @@ -1116,7 +1116,7 @@ def test_update(self): wid = workspace.workspace_id - user2 = uapi.create_user() + user2 = uapi.create_minimal_user() user2.email = 'this.is@another.user' uapi.save(user2) @@ -1223,7 +1223,7 @@ def test_update_no_change(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user1 = uapi.create_user( + user1 = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True, @@ -1237,7 +1237,7 @@ def test_update_no_change(self): save_now=True ) - user2 = uapi.create_user() + user2 = uapi.create_minimal_user() user2.email = 'this.is@another.user' uapi.save(user2) @@ -1301,7 +1301,7 @@ def test_update_file_data(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user1 = uapi.create_user( + user1 = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True @@ -1314,7 +1314,7 @@ def test_update_file_data(self): ) wid = workspace.workspace_id - user2 = uapi.create_user() + user2 = uapi.create_minimal_user() user2.email = 'this.is@another.user' uapi.save(user2) @@ -1415,7 +1415,7 @@ def test_update_no_change(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user1 = uapi.create_user( + user1 = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True, @@ -1427,7 +1427,7 @@ def test_update_no_change(self): save_now=True ) - user2 = uapi.create_user() + user2 = uapi.create_minimal_user() user2.email = 'this.is@another.user' uapi.save(user2) @@ -1494,7 +1494,7 @@ def test_archive_unarchive(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user1 = uapi.create_user( + user1 = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True @@ -1508,7 +1508,7 @@ def test_archive_unarchive(self): ) wid = workspace.workspace_id - user2 = uapi.create_user() + user2 = uapi.create_minimal_user() user2.email = 'this.is@another.user' uapi.save(user2) @@ -1643,7 +1643,7 @@ def test_delete_undelete(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user1 = uapi.create_user( + user1 = uapi.create_minimal_user( email='this.is@user', groups=groups, save_now=True @@ -1657,7 +1657,7 @@ def test_delete_undelete(self): ) wid = workspace.workspace_id - user2 = uapi.create_user() + user2 = uapi.create_minimal_user() user2.email = 'this.is@another.user' uapi.save(user2) @@ -1793,8 +1793,8 @@ def test_search_in_label(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace = WorkspaceApi( current_user=user, @@ -1848,8 +1848,8 @@ def test_search_in_description(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace = WorkspaceApi( current_user=user, @@ -1899,8 +1899,8 @@ def test_search_in_label_or_description(self): group_api.get_one(Group.TIM_MANAGER), group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user(email='this.is@user', - groups=groups, save_now=True) + user = uapi.create_minimal_user(email='this.is@user', + groups=groups, save_now=True) workspace = WorkspaceApi( current_user=user, diff --git a/tracim/tests/library/test_notification.py b/tracim/tests/library/test_notification.py index 147c171..76f5e13 100644 --- a/tracim/tests/library/test_notification.py +++ b/tracim/tests/library/test_notification.py @@ -4,18 +4,20 @@ from tracim.lib.core.notifications import DummyNotifier -from tracim.lib.core.notifications import EmailNotifier + from tracim.lib.core.notifications import NotifierFactory +from tracim.lib.mail_notifier.notifier import EmailNotifier from tracim.models.auth import User from tracim.models.data import Content from tracim.tests import DefaultTest from tracim.tests import eq_ + class TestDummyNotifier(DefaultTest): def test_dummy_notifier__notify_content_update(self): c = Content() - notifier = DummyNotifier(self.app_config) + notifier = DummyNotifier(self.app_config, self.session) notifier.notify_content_update(c) # INFO - D.A. - 2014-12-09 - # Old notification_content_update raised an exception diff --git a/tracim/tests/library/test_user_api.py b/tracim/tests/library/test_user_api.py index 805e57b..e19918c 100644 --- a/tracim/tests/library/test_user_api.py +++ b/tracim/tests/library/test_user_api.py @@ -17,7 +17,7 @@ def test_create_and_update_user(self): session=self.session, config=self.config, ) - u = api.create_user() + u = api.create_minimal_user() api.update(u, 'bob', 'bob@bob', True) nu = api.get_one_by_email('bob@bob') @@ -31,7 +31,7 @@ def test_user_with_email_exists(self): session=self.session, config=self.config, ) - u = api.create_user() + u = api.create_minimal_user() api.update(u, 'bibi', 'bibi@bibi', True) transaction.commit() @@ -44,7 +44,8 @@ def test_get_one_by_email(self): session=self.session, config=self.config, ) - u = api.create_user() + u = api.create_minimal_user() + self.session.flush() api.update(u, 'bibi', 'bibi@bibi', True) uid = u.user_id transaction.commit() @@ -67,8 +68,8 @@ def test_get_all(self): session=self.session, config=self.config, ) - # u1 = api.create_user(True) - # u2 = api.create_user(True) + # u1 = api.create_minimal_user(True) + # u2 = api.create_minimal_user(True) # users = api.get_all() # ok_(2==len(users)) @@ -79,7 +80,7 @@ def test_get_one(self): session=self.session, config=self.config, ) - u = api.create_user() + u = api.create_minimal_user() api.update(u, 'titi', 'titi@titi', True) one = api.get_one(u.user_id) eq_(u.user_id, one.user_id) diff --git a/tracim/tests/library/test_workspace.py b/tracim/tests/library/test_workspace.py index be401f7..418538a 100644 --- a/tracim/tests/library/test_workspace.py +++ b/tracim/tests/library/test_workspace.py @@ -52,7 +52,7 @@ def test_get_notifiable_roles(self): current_user=admin, config=self.config ) - u = uapi.create_user(email='u.u@u.u', save_now=True) + u = uapi.create_minimal_user(email='u.u@u.u', save_now=True) eq_([], wapi.get_notifiable_roles(workspace=w)) rapi = RoleApi( session=self.session, @@ -88,7 +88,7 @@ def test_unit__get_all_manageable(self): session=self.session, current_user=None, ) - u = uapi.create_user('u.s@e.r', [gapi.get_one(Group.TIM_USER)], True) + u = uapi.create_minimal_user('u.s@e.r', [gapi.get_one(Group.TIM_USER)], True) wapi = WorkspaceApi( session=self.session, current_user=u diff --git a/tracim/views/core_api/session_controller.py b/tracim/views/core_api/session_controller.py index 2819d87..bd1cfb5 100644 --- a/tracim/views/core_api/session_controller.py +++ b/tracim/views/core_api/session_controller.py @@ -1,6 +1,14 @@ # coding=utf-8 from pyramid.config import Configurator from sqlalchemy.orm.exc import NoResultFound + +from tracim.lib.core.content import ContentApi +from tracim.lib.core.group import GroupApi +from tracim.lib.core.userworkspace import RoleApi +from tracim.lib.core.workspace import WorkspaceApi +from tracim.models import Group +from tracim.models.data import ContentType + try: # Python 3.5+ from http import HTTPStatus except ImportError: @@ -86,6 +94,99 @@ def whoami(self, context, request: TracimRequest, hapic_data=None): config=app_config, ) + @hapic.with_api_doc() + @hapic.output_body( + UserSchema(), + ) + def create_user(self, context, request: TracimRequest, hapic_data=None): + """ + Return current logged in user or 401 + """ + app_config = request.registry.settings['CFG'] + uapi = UserApi( + None, + session=request.dbsession, + config=app_config, + ) + group_api = GroupApi(current_user=None, session=request.dbsession) + groups = [group_api.get_one(Group.TIM_USER), + group_api.get_one(Group.TIM_MANAGER), + group_api.get_one(Group.TIM_ADMIN)] + user = uapi.create_user( + email='dev.tracim.testuser@algoo.fr', + password='toto', + name='toto', + groups=groups, + timezone="lapin", + do_save=True, + do_notify=True, + ) + wapi = WorkspaceApi( + current_user=user, + session=request.dbsession, + ) + workspace = wapi.get_one_by_label('w1') + rapi = RoleApi( + session=request.dbsession, + current_user=user, + ) + rapi.create_one( + user=user, + workspace=workspace, + role_level=8, + with_notif=True, + flush=True, + ) + return UserInContext( + user=user, + dbsession=request.dbsession, + config=app_config, + ) + + @hapic.with_api_doc() + @hapic.handle_exception( + NotAuthentificated, + http_code=HTTPStatus.UNAUTHORIZED + ) + @hapic.output_body( + NoContentSchema() + ) + def add_content(self, context, request: TracimRequest, hapic_data=None): + """ + Return current logged in user or 401 + """ + app_config = request.registry.settings['CFG'] + uapi = UserApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + workspace = WorkspaceApi( + current_user=request.current_user, + session=request.dbsession + ).get_one_by_label('w1') + api = ContentApi( + current_user=request.current_user, + session=request.dbsession, + config=app_config, + ) + item = api.create( + ContentType.Folder, + workspace, + None, + 'parent', + do_save=True, + ) + item2 = api.create( + ContentType.File, + workspace, + item, + 'file1', + do_save=True, + do_notify=True, + ) + return + def bind(self, configurator: Configurator): # Login @@ -99,3 +200,9 @@ def bind(self, configurator: Configurator): # Whoami configurator.add_route('whoami', '/sessions/whoami', request_method='GET') # nopep8 configurator.add_view(self.whoami, route_name='whoami',) + + configurator.add_route('create_user_test', '/create_user', request_method='POST') # nopep8 + configurator.add_view(self.create_user, route_name='create_user_test',) + + configurator.add_route('add_content', '/add_content', request_method='POST') # nopep8 + configurator.add_view(self.add_content, route_name='add_content',) \ No newline at end of file From be510d78c1460ebc9c6d1a01ac0b269e395c1cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Wed, 9 May 2018 09:14:14 +0200 Subject: [PATCH 02/13] update template address --- tracim/templates/mail/content_update_body_html.mak | 2 +- tracim/templates/mail/content_update_body_text.mak | 3 +-- tracim/templates/mail/created_account_body_html.mak | 3 +-- tracim/templates/mail/created_account_body_text.mak | 3 +-- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/tracim/templates/mail/content_update_body_html.mak b/tracim/templates/mail/content_update_body_html.mak index 513e8b8..7c4f3c8 100644 --- a/tracim/templates/mail/content_update_body_html.mak +++ b/tracim/templates/mail/content_update_body_html.mak @@ -65,7 +65,7 @@

${_('This email was automatically sent by Tracim, a collaborative software developped by Algoo.')}
- Algoo SAS — 9 rue du rocher de Lorzier, 38430 Moirans, France — www.algoo.fr + Algoo SAS — 340 Rue de l'Eygala, 38430 Moirans, France — www.algoo.fr

diff --git a/tracim/templates/mail/content_update_body_text.mak b/tracim/templates/mail/content_update_body_text.mak index 1946151..92d9e8d 100644 --- a/tracim/templates/mail/content_update_body_text.mak +++ b/tracim/templates/mail/content_update_body_text.mak @@ -23,9 +23,8 @@ and you are /${role}/ in the workspace /${workspace.label}/ This email was automatically sent by *Tracim*, a collaborative software developped by Algoo. -FIXME ADDR **Algoo SAS** -9 rue du rocher de Lorzier +340 Rue de l'Eygala 38430 Moirans France http://algoo.fr diff --git a/tracim/templates/mail/created_account_body_html.mak b/tracim/templates/mail/created_account_body_html.mak index 0966f20..4e72e59 100644 --- a/tracim/templates/mail/created_account_body_html.mak +++ b/tracim/templates/mail/created_account_body_html.mak @@ -81,8 +81,7 @@ diff --git a/tracim/templates/mail/created_account_body_text.mak b/tracim/templates/mail/created_account_body_text.mak index dd5c71a..9d2e0ab 100644 --- a/tracim/templates/mail/created_account_body_text.mak +++ b/tracim/templates/mail/created_account_body_text.mak @@ -18,9 +18,8 @@ ${login_url} This email was sent by *Tracim*, a collaborative software developped by Algoo. -FIXME ADDR **Algoo SAS** -9 rue du rocher de Lorzier +340 Rue de l'Eygala 38430 Moirans France http://algoo.fr From f7b7969cff0ddae80cf1147a21017e344dcfdd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Wed, 9 May 2018 09:19:28 +0200 Subject: [PATCH 03/13] FIXME as Comments --- tracim/templates/mail/content_update_body_html.mak | 10 ++++++---- tracim/templates/mail/created_account_body_html.mak | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tracim/templates/mail/content_update_body_html.mak b/tracim/templates/mail/content_update_body_html.mak index 7c4f3c8..06526b5 100644 --- a/tracim/templates/mail/content_update_body_html.mak +++ b/tracim/templates/mail/content_update_body_html.mak @@ -38,14 +38,14 @@ @@ -54,13 +54,15 @@

${content_intro|n}

${content_text|n}
-
+ +
- FIXME restore logo + ${main_title} —  ${status|n} - FIXME restore icon +
- FIXME restore logo + From 37d33febe1d63ae172c7e1762a4dae7e95961f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Wed, 9 May 2018 10:04:39 +0200 Subject: [PATCH 04/13] fix user creation with command and email notification --- tracim/command/user.py | 27 +++++++++++++++++++++------ tracim/exceptions.py | 4 ++++ tracim/lib/core/content.py | 2 +- tracim/lib/core/user.py | 19 +++++++++++++------ 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/tracim/command/user.py b/tracim/command/user.py index 717ab7f..88bc6e2 100644 --- a/tracim/command/user.py +++ b/tracim/command/user.py @@ -12,6 +12,7 @@ #from tracim.lib.daemons import RadicaleDaemon #from tracim.lib.email import get_email_manager from tracim.exceptions import AlreadyExistError +from tracim.exceptions import NotificationNotSend from tracim.exceptions import CommandAbortedError from tracim.lib.core.group import GroupApi from tracim.lib.core.user import UserApi @@ -106,7 +107,13 @@ def _remove_user_from_named_group( group.users.remove(user) self._session.flush() - def _create_user(self, login: str, password: str, **kwargs) -> User: + def _create_user( + self, + login: str, + password: str, + do_notify: bool, + **kwargs + ) -> User: if not password: if self._password_required(): raise CommandAbortedError( @@ -115,18 +122,23 @@ def _create_user(self, login: str, password: str, **kwargs) -> User: password = '' try: - user = self._user_api.create_minimal_user(email=login) - user.password = password - self._user_api.save(user) + user = self._user_api.create_user( + email=login, + password=password, + do_save=True, + do_notify=do_notify, + ) # TODO - G.M - 04-04-2018 - [Caldav] Check this code # # We need to enable radicale if it not already done # daemons = DaemonsManager() # daemons.run('radicale', RadicaleDaemon) - self._user_api.execute_created_user_actions(user) except IntegrityError: self._session.rollback() raise AlreadyExistError() + except NotificationNotSend as exception: + self._session.rollback() + raise exception return user @@ -166,10 +178,13 @@ def _proceed_user(self, parsed_args: argparse.Namespace) -> User: try: user = self._create_user( login=parsed_args.login, - password=parsed_args.password + password=parsed_args.password, + do_notify=parsed_args.send_email, ) except AlreadyExistError: raise CommandAbortedError("Error: User already exist (use `user update` command instead)") + except NotificationNotSend: + raise CommandAbortedError("Error: Cannot send email notification, user not created.") # TODO - G.M - 04-04-2018 - [Email] Check this code # if parsed_args.send_email: # email_manager = get_email_manager() diff --git a/tracim/exceptions.py b/tracim/exceptions.py index 1725fe2..6b12053 100644 --- a/tracim/exceptions.py +++ b/tracim/exceptions.py @@ -87,3 +87,7 @@ class DigestAuthNotImplemented(Exception): class LoginFailed(TracimException): pass + + +class NotificationNotSend(TracimException): + pass diff --git a/tracim/lib/core/content.py b/tracim/lib/core/content.py index b86c00f..ec769e7 100644 --- a/tracim/lib/core/content.py +++ b/tracim/lib/core/content.py @@ -376,7 +376,7 @@ def get_child_folders(self, parent: Content=None, workspace: Workspace=None, fil return result - def create(self, content_type: str, workspace: Workspace, parent: Content=None, label:str ='', do_save=False, is_temporary: bool=False, do_notify=False) -> Content: + def create(self, content_type: str, workspace: Workspace, parent: Content=None, label:str ='', do_save=False, is_temporary: bool=False, do_notify=True) -> Content: assert content_type in ContentType.allowed_types() if content_type == ContentType.Folder and not label: diff --git a/tracim/lib/core/user.py b/tracim/lib/core/user.py index 8c1e6e9..71ed3ad 100644 --- a/tracim/lib/core/user.py +++ b/tracim/lib/core/user.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- import threading +from smtplib import SMTPException import transaction import typing as typing +from tracim.exceptions import NotificationNotSend from tracim.lib.mail_notifier.notifier import get_email_manager from tracim.models.auth import User @@ -78,14 +80,19 @@ def create_user( email=email, password=password, timezone=timezone, - do_save=do_save, + do_save=False, ) if do_notify: - email_manager = get_email_manager(self._config, self._session) - email_manager.notify_created_account( - new_user, - password=password - ) + try: + email_manager = get_email_manager(self._config, self._session) + email_manager.notify_created_account( + new_user, + password=password + ) + except SMTPException as e: + raise NotificationNotSend() + if do_save: + self.save(new_user) return new_user def create_minimal_user( From 682682f5f1ffbb591a9a12be5bd407e6ca610648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Wed, 9 May 2018 11:25:32 +0200 Subject: [PATCH 05/13] fix tests + force email in create_minimal_user method --- tracim/lib/core/user.py | 5 ++- tracim/tests/library/test_content_api.py | 18 ++++------ tracim/tests/library/test_user_api.py | 46 ++++++++++++++---------- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/tracim/lib/core/user.py b/tracim/lib/core/user.py index 71ed3ad..017ff75 100644 --- a/tracim/lib/core/user.py +++ b/tracim/lib/core/user.py @@ -97,15 +97,14 @@ def create_user( def create_minimal_user( self, - email=None, + email, groups=[], save_now=False ) -> User: """Previous create_user method""" user = User() - if email: - user.email = email + user.email = email for group in groups: user.groups.append(group) diff --git a/tracim/tests/library/test_content_api.py b/tracim/tests/library/test_content_api.py index 95db7a8..cffa497 100644 --- a/tracim/tests/library/test_content_api.py +++ b/tracim/tests/library/test_content_api.py @@ -1116,8 +1116,7 @@ def test_update(self): wid = workspace.workspace_id - user2 = uapi.create_minimal_user() - user2.email = 'this.is@another.user' + user2 = uapi.create_minimal_user('this.is@another.user') uapi.save(user2) RoleApi( @@ -1237,8 +1236,7 @@ def test_update_no_change(self): save_now=True ) - user2 = uapi.create_minimal_user() - user2.email = 'this.is@another.user' + user2 = uapi.create_minimal_user('this.is@another.user') uapi.save(user2) RoleApi( @@ -1314,8 +1312,7 @@ def test_update_file_data(self): ) wid = workspace.workspace_id - user2 = uapi.create_minimal_user() - user2.email = 'this.is@another.user' + user2 = uapi.create_minimal_user('this.is@another.user') uapi.save(user2) RoleApi( @@ -1427,8 +1424,7 @@ def test_update_no_change(self): save_now=True ) - user2 = uapi.create_minimal_user() - user2.email = 'this.is@another.user' + user2 = uapi.create_minimal_user('this.is@another.user') uapi.save(user2) RoleApi( @@ -1508,8 +1504,7 @@ def test_archive_unarchive(self): ) wid = workspace.workspace_id - user2 = uapi.create_minimal_user() - user2.email = 'this.is@another.user' + user2 = uapi.create_minimal_user('this.is@another.user') uapi.save(user2) RoleApi( @@ -1657,8 +1652,7 @@ def test_delete_undelete(self): ) wid = workspace.workspace_id - user2 = uapi.create_minimal_user() - user2.email = 'this.is@another.user' + user2 = uapi.create_minimal_user('this.is@another.user') uapi.save(user2) RoleApi( diff --git a/tracim/tests/library/test_user_api.py b/tracim/tests/library/test_user_api.py index e19918c..68589eb 100644 --- a/tracim/tests/library/test_user_api.py +++ b/tracim/tests/library/test_user_api.py @@ -5,34 +5,45 @@ import transaction from tracim.lib.core.user import UserApi +from tracim.models import User from tracim.tests import DefaultTest from tracim.tests import eq_ class TestUserApi(DefaultTest): - def test_create_and_update_user(self): + def test_unit__create_minimal_user__ok__nominal_case(self): api = UserApi( current_user=None, session=self.session, config=self.config, ) - u = api.create_minimal_user() - api.update(u, 'bob', 'bob@bob', True) + u = api.create_minimal_user('bob@bob') + assert u.email == 'bob@bob' + assert u.display_name is None + def test_unit__create_minimal_user_and_update__ok__nominal_case(self): + api = UserApi( + current_user=None, + session=self.session, + config=self.config, + ) + u = api.create_minimal_user('bob@bob') + api.update(u, 'bob', 'bob@bob', 'pass', do_save=True) nu = api.get_one_by_email('bob@bob') - assert nu != None - eq_('bob@bob', nu.email) - eq_('bob', nu.display_name) + assert nu is not None + assert nu.email == 'bob@bob' + assert nu.display_name == 'bob' + assert nu.validate_password('pass') - def test_user_with_email_exists(self): + def test_unit__user_with_email_exists__ok__nominal_case(self): api = UserApi( current_user=None, session=self.session, config=self.config, ) - u = api.create_minimal_user() - api.update(u, 'bibi', 'bibi@bibi', True) + u = api.create_minimal_user('bibi@bibi') + api.update(u, 'bibi', 'bibi@bibi', 'pass', do_save=True) transaction.commit() eq_(True, api.user_with_email_exists('bibi@bibi')) @@ -44,9 +55,9 @@ def test_get_one_by_email(self): session=self.session, config=self.config, ) - u = api.create_minimal_user() + u = api.create_minimal_user('bibi@bibi') self.session.flush() - api.update(u, 'bibi', 'bibi@bibi', True) + api.update(u, 'bibi', 'bibi@bibi', 'pass', do_save=True) uid = u.user_id transaction.commit() @@ -62,17 +73,16 @@ def test_get_one_by_email_exception(self): api.get_one_by_email('unknown') def test_get_all(self): - # TODO - G.M - 29-03-2018 Check why this method is not enabled api = UserApi( current_user=None, session=self.session, config=self.config, ) - # u1 = api.create_minimal_user(True) - # u2 = api.create_minimal_user(True) + u1 = api.create_minimal_user('bibi@bibi') - # users = api.get_all() - # ok_(2==len(users)) + users = api.get_all() + # u1 + Admin user from BaseFixture + assert 2 == len(users) def test_get_one(self): api = UserApi( @@ -80,7 +90,7 @@ def test_get_one(self): session=self.session, config=self.config, ) - u = api.create_minimal_user() - api.update(u, 'titi', 'titi@titi', True) + u = api.create_minimal_user('titi@titi') + api.update(u, 'titi', 'titi@titi', 'pass', do_save=True) one = api.get_one(u.user_id) eq_(u.user_id, one.user_id) From 60171c6620a5c68a0c7a8e6dedd36b9f65bf503a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Wed, 9 May 2018 11:41:49 +0200 Subject: [PATCH 06/13] renaming of tests in user_api --- tracim/tests/library/test_user_api.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tracim/tests/library/test_user_api.py b/tracim/tests/library/test_user_api.py index 68589eb..767c71f 100644 --- a/tracim/tests/library/test_user_api.py +++ b/tracim/tests/library/test_user_api.py @@ -5,7 +5,6 @@ import transaction from tracim.lib.core.user import UserApi -from tracim.models import User from tracim.tests import DefaultTest from tracim.tests import eq_ @@ -49,7 +48,7 @@ def test_unit__user_with_email_exists__ok__nominal_case(self): eq_(True, api.user_with_email_exists('bibi@bibi')) eq_(False, api.user_with_email_exists('unknown')) - def test_get_one_by_email(self): + def test_unit__get_one_by_email__ok__nominal_case(self): api = UserApi( current_user=None, session=self.session, @@ -63,7 +62,7 @@ def test_get_one_by_email(self): eq_(uid, api.get_one_by_email('bibi@bibi').user_id) - def test_get_one_by_email_exception(self): + def test_unit__get_one_by_email__ok__user_not_found(self): api = UserApi( current_user=None, session=self.session, @@ -72,7 +71,7 @@ def test_get_one_by_email_exception(self): with pytest.raises(NoResultFound): api.get_one_by_email('unknown') - def test_get_all(self): + def test_unit__get_all__ok__nominal_case(self): api = UserApi( current_user=None, session=self.session, @@ -84,7 +83,7 @@ def test_get_all(self): # u1 + Admin user from BaseFixture assert 2 == len(users) - def test_get_one(self): + def test_unit__get_one__ok__nominal_case(self): api = UserApi( current_user=None, session=self.session, From f11e0731a1f0c93d4ee602c8b8e42476786481c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Wed, 9 May 2018 11:48:31 +0200 Subject: [PATCH 07/13] add small test for new create_user method + email required --- tracim/lib/core/user.py | 2 +- tracim/tests/library/test_user_api.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tracim/lib/core/user.py b/tracim/lib/core/user.py index 017ff75..5c8df43 100644 --- a/tracim/lib/core/user.py +++ b/tracim/lib/core/user.py @@ -65,7 +65,7 @@ def user_with_email_exists(self, email: str): def create_user( self, - email: str = None, + email, password: str = None, name: str = None, timezone: str = '', diff --git a/tracim/tests/library/test_user_api.py b/tracim/tests/library/test_user_api.py index 767c71f..1bc9411 100644 --- a/tracim/tests/library/test_user_api.py +++ b/tracim/tests/library/test_user_api.py @@ -35,6 +35,26 @@ def test_unit__create_minimal_user_and_update__ok__nominal_case(self): assert nu.display_name == 'bob' assert nu.validate_password('pass') + def test__unit__create__user__ok_nominal_case(self): + api = UserApi( + current_user=None, + session=self.session, + config=self.config, + ) + u = api.create_user( + email='bob@bob', + password='pass', + name='bob', + timezone='+2', + do_save=True, + do_notify=False, + ) + assert u is not None + assert u.email == "bob@bob" + assert u.validate_password('pass') + assert u.display_name == 'bob' + assert u.timezone == '+2' + def test_unit__user_with_email_exists__ok__nominal_case(self): api = UserApi( current_user=None, From 81504d53ba717c14e157d02783fc9d523a2922d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Wed, 9 May 2018 16:41:41 +0200 Subject: [PATCH 08/13] better test for email --- .travis.yml | 6 + setup.py | 1 + tests_configs.ini | 31 ++++ tracim/fixtures/content.py | 11 ++ tracim/tests/__init__.py | 31 +++- .../functional/test_mail_notification.py | 170 ++++++++++++++++++ 6 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 tests_configs.ini create mode 100644 tracim/tests/functional/test_mail_notification.py diff --git a/.travis.yml b/.travis.yml index dbd5c95..ca063d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,12 @@ python: - "3.5" - "3.6" +services: + - docker + +before_install: + - docker pull mailhog/mailhog + - docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog install: - pip install --upgrade pip setuptools - pip install -e ".[testing]" diff --git a/setup.py b/setup.py index 0eb6842..db80dca 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ 'pytest-cov', 'pep8', 'mypy', + 'requests' ] mysql_require = [ diff --git a/tests_configs.ini b/tests_configs.ini new file mode 100644 index 0000000..6fb5ff8 --- /dev/null +++ b/tests_configs.ini @@ -0,0 +1,31 @@ +[base_test] +sqlalchemy.url = sqlite:///:memory: +depot_storage_name = test +depot_storage_dir = /tmp/test/depot +user.auth_token.validity = 604800 +preview_cache_dir = /tmp/test/preview_cache + +[mail_test] +sqlalchemy.url = sqlite:///:memory: +depot_storage_name = test +depot_storage_dir = /tmp/test/depot +user.auth_token.validity = 604800 +preview_cache_dir = /tmp/test/preview_cache +email.notification.activated = true +email.notification.from.email = test_user_from+{user_id}@localhost +email.notification.from.default_label = Tracim Notifications +email.notification.reply_to.email = test_user_reply+{content_id}@localhost +email.notification.references.email = test_user_refs+{content_id}@localhost +email.notification.content_update.template.html = %(here)s/tracim/templates/mail/content_update_body_html.mak +email.notification.content_update.template.text = %(here)s/tracim/templates/mail/content_update_body_text.mak +email.notification.created_account.template.html = %(here)s/tracim/templates/mail/created_account_body_html.mak +email.notification.created_account.template.text = %(here)s/tracim/templates/mail/created_account_body_text.mak +# Note: items between { and } are variable names. Do not remove / rename them +email.notification.content_update.subject = [{website_title}] [{workspace_label}] {content_label} ({content_status_label}) +email.notification.created_account.subject = [{website_title}] Created account +# processing_mode may be sync or async +email.notification.processing_mode = sync +email.notification.smtp.server = 127.0.0.1 +email.notification.smtp.port = 1025 +email.notification.smtp.user = test_user +email.notification.smtp.password = just_a_password \ No newline at end of file diff --git a/tracim/fixtures/content.py b/tracim/fixtures/content.py index 181bbb3..7bb458b 100644 --- a/tracim/fixtures/content.py +++ b/tracim/fixtures/content.py @@ -58,12 +58,14 @@ def insert(self): workspace=w1, label='w1f1', do_save=True, + do_notify=False, ) w1f2 = content_api.create( content_type=ContentType.Folder, workspace=w1, label='w1f2', do_save=True, + do_notify=False, ) w2f1 = content_api.create( @@ -71,12 +73,14 @@ def insert(self): workspace=w2, label='w2f1', do_save=True, + do_notify=False, ) w2f2 = content_api.create( content_type=ContentType.Folder, workspace=w2, label='w2f2', do_save=True, + do_notify=False, ) w3f1 = content_api.create( @@ -84,6 +88,7 @@ def insert(self): workspace=w3, label='w3f3', do_save=True, + do_notify=False, ) # Pages, threads, .. @@ -93,6 +98,7 @@ def insert(self): parent=w1f1, label='w1f1p1', do_save=True, + do_notify=False, ) w1f1t1 = content_api.create( content_type=ContentType.Thread, @@ -100,6 +106,7 @@ def insert(self): parent=w1f1, label='w1f1t1', do_save=False, + do_notify=False, ) w1f1t1.description = 'w1f1t1 description' self._session.add(w1f1t1) @@ -109,6 +116,7 @@ def insert(self): parent=w1f1, label='w1f1d1', do_save=False, + do_notify=False, ) w1f1d1_txt.file_extension = '.txt' w1f1d1_txt.depot_file = FileIntent( @@ -123,6 +131,7 @@ def insert(self): parent=w1f1, label='w1f1d2', do_save=False, + do_notify=False, ) w1f1d2_html.file_extension = '.html' w1f1d2_html.depot_file = FileIntent( @@ -137,6 +146,7 @@ def insert(self): label='w1f1f1', parent=w1f1, do_save=True, + do_notify=False, ) w2f1p1 = content_api.create( @@ -145,5 +155,6 @@ def insert(self): parent=w2f1, label='w2f1p1', do_save=True, + do_notify=False, ) self._session.flush() diff --git a/tracim/tests/__init__.py b/tracim/tests/__init__.py index c7d6d0a..5694b52 100644 --- a/tracim/tests/__init__.py +++ b/tracim/tests/__init__.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- import unittest + +import plaster +import requests import transaction from depot.manager import DepotManager from pyramid import testing @@ -92,16 +95,16 @@ class BaseTest(unittest.TestCase): Pyramid default test. """ + config_uri = 'tests_configs.ini' + config_section = 'base_test' + def setUp(self): logger.debug(self, 'Setup Test...') - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:', - 'user.auth_token.validity': '604800', - 'depot_storage_dir': '/tmp/test/depot', - 'depot_storage_name': 'test', - 'preview_cache_dir': '/tmp/test/preview_cache', - - }) + self.settings = plaster.get_settings( + self.config_uri, + self.config_section + ) + self.config = testing.setUp(settings = self.settings) self.config.include('tracim.models') DepotManager._clear() DepotManager.configure( @@ -225,3 +228,15 @@ def _create_thread_and_test(self, owner=user ) return thread + + +class MailHogTest(DefaultTest): + """ + Theses test need a working mailhog + """ + + config_section = 'mail_test' + + def tearDown(self): + logger.debug(self, 'Cleanup MailHog list...') + requests.delete('http://127.0.0.1:8025/api/v1/messages') diff --git a/tracim/tests/functional/test_mail_notification.py b/tracim/tests/functional/test_mail_notification.py new file mode 100644 index 0000000..bd10e30 --- /dev/null +++ b/tracim/tests/functional/test_mail_notification.py @@ -0,0 +1,170 @@ +# coding=utf-8 +# INFO - G.M - 09-06-2018 - Those test need a working MailHog + +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import requests +import transaction + +from tracim.fixtures.users_and_groups import Base as BaseFixture +from tracim.fixtures.content import Content as ContentFixture +from tracim.models.data import ContentType + +from tracim.lib.core.content import ContentApi +from tracim.lib.core.group import GroupApi +from tracim.lib.core.user import UserApi +from tracim.lib.core.userworkspace import RoleApi +from tracim.lib.core.workspace import WorkspaceApi +from tracim.lib.mail_notifier.notifier import EmailManager +from tracim.lib.mail_notifier.sender import EmailSender +from tracim.lib.mail_notifier.utils import SmtpConfiguration +from tracim.models import Group +from tracim.tests import MailHogTest + + +class TestEmailSender(MailHogTest): + + def test__func__connect_disconnect__ok__nominal_case(self): + smtp_config = SmtpConfiguration( + self.app_config.EMAIL_NOTIFICATION_SMTP_SERVER, + self.app_config.EMAIL_NOTIFICATION_SMTP_PORT, + self.app_config.EMAIL_NOTIFICATION_SMTP_USER, + self.app_config.EMAIL_NOTIFICATION_SMTP_PASSWORD + ) + sender = EmailSender( + self.app_config, + smtp_config, + True, + ) + sender.connect() + sender.disconnect() + + def test__func__send_email__ok__nominal_case(self): + smtp_config = SmtpConfiguration( + self.app_config.EMAIL_NOTIFICATION_SMTP_SERVER, + self.app_config.EMAIL_NOTIFICATION_SMTP_PORT, + self.app_config.EMAIL_NOTIFICATION_SMTP_USER, + self.app_config.EMAIL_NOTIFICATION_SMTP_PASSWORD + ) + sender = EmailSender( + self.app_config, + smtp_config, + True, + ) + + # Create test_mail + msg = MIMEMultipart() + msg['Subject'] = 'test__func__send_email__ok__nominal_case' + msg['From'] = 'test_send_mail@localhost' + msg['To'] = 'receiver_test_send_mail@localhost' + text = "test__func__send_email__ok__nominal_case" + html = """\ + + + +

test__func__send_email__ok__nominal_case

+ + + """.replace(' ', '').replace('\n', '') + part1 = MIMEText(text, 'plain') + part2 = MIMEText(html, 'html') + msg.attach(part1) + msg.attach(part2) + + sender.send_mail(msg) + sender.disconnect() + + # check mail received + response = requests.get('http://127.0.0.1:8025/api/v1/messages') + response = response.json() + headers = response[0]['Content']['Headers'] + assert headers['From'][0] == 'test_send_mail@localhost' + assert headers['To'][0] == 'receiver_test_send_mail@localhost' + assert headers['Subject'][0] == 'test__func__send_email__ok__nominal_case' + assert response[0]['MIME']['Parts'][0]['Body'] == text + assert response[0]['MIME']['Parts'][1]['Body'] == html + + +class testUserNotification(MailHogTest): + + def test_func__create_user_with_mail_notification__ok__nominal_case(self): + api = UserApi( + current_user=None, + session=self.session, + config=self.app_config, + ) + u = api.create_user( + email='bob@bob', + password='pass', + name='bob', + timezone='+2', + do_save=True, + do_notify=True, + ) + assert u is not None + assert u.email == "bob@bob" + assert u.validate_password('pass') + assert u.display_name == 'bob' + assert u.timezone == '+2' + + # check mail received + response = requests.get('http://127.0.0.1:8025/api/v1/messages') + response = response.json() + headers = response[0]['Content']['Headers'] + assert headers['From'][0] == 'Tracim Notifications ' + assert headers['To'][0] == 'bob ' + assert headers['Subject'][0] == '[TRACIM] Created account' + + +class testContentNotification(MailHogTest): + + fixtures = [BaseFixture, ContentFixture] + + def test_func__create_new_content_with_notification__ok__nominal_case(self): + uapi = UserApi( + current_user=None, + session=self.session, + config=self.app_config, + ) + current_user = uapi.get_one_by_email('admin@admin.admin') + # Create new user with notification enabled on w1 workspace + wapi = WorkspaceApi( + current_user=current_user, + session=self.session, + ) + workspace = wapi.get_one_by_label('w1') + user = uapi.get_one_by_email('bob@fsf.local') + wapi.enable_notifications(user, workspace) + + api = ContentApi( + current_user=user, + session=self.session, + config=self.app_config, + ) + item = api.create( + ContentType.Folder, + workspace, + None, + 'parent', + do_save=True, + do_notify=False, + ) + item2 = api.create( + ContentType.File, + workspace, + item, + 'file1', + do_save=True, + do_notify=True, + ) + + # check mail received + response = requests.get('http://127.0.0.1:8025/api/v1/messages') + response = response.json() + headers = response[0]['Content']['Headers'] + assert headers['From'][0] == '"Bob i. via Tracim" ' + assert headers['To'][0] == 'Global manager ' + assert headers['Subject'][0] == '[TRACIM] [w1] file1 (open)' + assert headers['References'][0] == 'test_user_refs+13@localhost' + assert headers['Reply-to'][0] == '"Bob i. & all members of w1" ' \ No newline at end of file From 8ccc7b1d5bb11a7fac59303c8167e6b49de4d68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Wed, 9 May 2018 16:56:15 +0200 Subject: [PATCH 09/13] add comment about mailhog dependency for somes tests --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cbdf62..840680c 100644 --- a/README.md +++ b/README.md @@ -89,12 +89,23 @@ run wsgidav server: tracimcli webdav start -### Run Tests and others checks ### +### Run Tests ### + +Before running some functional test related to email, you need a local working *MailHog* +see here : https://github.com/mailhog/MailHog + +You can run it this way with docker : + + docker pull mailhog/mailhog + docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog + Run your project's tests: pytest +### Lints and others checks ### + Run mypy checks: mypy --ignore-missing-imports --disallow-untyped-defs tracim From 2933b47895dfd025df2211aef9eb8ae002f0649a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Mon, 14 May 2018 09:42:24 +0200 Subject: [PATCH 10/13] remove temporary test view for email --- tracim/views/core_api/session_controller.py | 99 --------------------- 1 file changed, 99 deletions(-) diff --git a/tracim/views/core_api/session_controller.py b/tracim/views/core_api/session_controller.py index bd1cfb5..bd02f98 100644 --- a/tracim/views/core_api/session_controller.py +++ b/tracim/views/core_api/session_controller.py @@ -94,99 +94,6 @@ def whoami(self, context, request: TracimRequest, hapic_data=None): config=app_config, ) - @hapic.with_api_doc() - @hapic.output_body( - UserSchema(), - ) - def create_user(self, context, request: TracimRequest, hapic_data=None): - """ - Return current logged in user or 401 - """ - app_config = request.registry.settings['CFG'] - uapi = UserApi( - None, - session=request.dbsession, - config=app_config, - ) - group_api = GroupApi(current_user=None, session=request.dbsession) - groups = [group_api.get_one(Group.TIM_USER), - group_api.get_one(Group.TIM_MANAGER), - group_api.get_one(Group.TIM_ADMIN)] - user = uapi.create_user( - email='dev.tracim.testuser@algoo.fr', - password='toto', - name='toto', - groups=groups, - timezone="lapin", - do_save=True, - do_notify=True, - ) - wapi = WorkspaceApi( - current_user=user, - session=request.dbsession, - ) - workspace = wapi.get_one_by_label('w1') - rapi = RoleApi( - session=request.dbsession, - current_user=user, - ) - rapi.create_one( - user=user, - workspace=workspace, - role_level=8, - with_notif=True, - flush=True, - ) - return UserInContext( - user=user, - dbsession=request.dbsession, - config=app_config, - ) - - @hapic.with_api_doc() - @hapic.handle_exception( - NotAuthentificated, - http_code=HTTPStatus.UNAUTHORIZED - ) - @hapic.output_body( - NoContentSchema() - ) - def add_content(self, context, request: TracimRequest, hapic_data=None): - """ - Return current logged in user or 401 - """ - app_config = request.registry.settings['CFG'] - uapi = UserApi( - current_user=request.current_user, - session=request.dbsession, - config=app_config, - ) - workspace = WorkspaceApi( - current_user=request.current_user, - session=request.dbsession - ).get_one_by_label('w1') - api = ContentApi( - current_user=request.current_user, - session=request.dbsession, - config=app_config, - ) - item = api.create( - ContentType.Folder, - workspace, - None, - 'parent', - do_save=True, - ) - item2 = api.create( - ContentType.File, - workspace, - item, - 'file1', - do_save=True, - do_notify=True, - ) - return - def bind(self, configurator: Configurator): # Login @@ -200,9 +107,3 @@ def bind(self, configurator: Configurator): # Whoami configurator.add_route('whoami', '/sessions/whoami', request_method='GET') # nopep8 configurator.add_view(self.whoami, route_name='whoami',) - - configurator.add_route('create_user_test', '/create_user', request_method='POST') # nopep8 - configurator.add_view(self.create_user, route_name='create_user_test',) - - configurator.add_route('add_content', '/add_content', request_method='POST') # nopep8 - configurator.add_view(self.add_content, route_name='add_content',) \ No newline at end of file From 968163b1e84db5644bc75fbbcf1a31872a0005ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Mon, 14 May 2018 14:29:04 +0200 Subject: [PATCH 11/13] add test for async rq mode --- tests_configs.ini | 26 ++++ .../functional/test_mail_notification.py | 119 ++++++++++++++++-- 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/tests_configs.ini b/tests_configs.ini index 6fb5ff8..4c2b2ce 100644 --- a/tests_configs.ini +++ b/tests_configs.ini @@ -28,4 +28,30 @@ email.notification.processing_mode = sync email.notification.smtp.server = 127.0.0.1 email.notification.smtp.port = 1025 email.notification.smtp.user = test_user +email.notification.smtp.password = just_a_password + +[mail_test_async] +sqlalchemy.url = sqlite:///:memory: +depot_storage_name = test +depot_storage_dir = /tmp/test/depot +user.auth_token.validity = 604800 +preview_cache_dir = /tmp/test/preview_cache +email.notification.activated = true +email.notification.from.email = test_user_from+{user_id}@localhost +email.notification.from.default_label = Tracim Notifications +email.notification.reply_to.email = test_user_reply+{content_id}@localhost +email.notification.references.email = test_user_refs+{content_id}@localhost +email.notification.content_update.template.html = %(here)s/tracim/templates/mail/content_update_body_html.mak +email.notification.content_update.template.text = %(here)s/tracim/templates/mail/content_update_body_text.mak +email.notification.created_account.template.html = %(here)s/tracim/templates/mail/created_account_body_html.mak +email.notification.created_account.template.text = %(here)s/tracim/templates/mail/created_account_body_text.mak +# Note: items between { and } are variable names. Do not remove / rename them +email.notification.content_update.subject = [{website_title}] [{workspace_label}] {content_label} ({content_status_label}) +email.notification.created_account.subject = [{website_title}] Created account +# processing_mode may be sync or async +email.notification.processing_mode = sync +email.processing_mode = async +email.notification.smtp.server = 127.0.0.1 +email.notification.smtp.port = 1025 +email.notification.smtp.user = test_user email.notification.smtp.password = just_a_password \ No newline at end of file diff --git a/tracim/tests/functional/test_mail_notification.py b/tracim/tests/functional/test_mail_notification.py index bd10e30..6f82cae 100644 --- a/tracim/tests/functional/test_mail_notification.py +++ b/tracim/tests/functional/test_mail_notification.py @@ -5,21 +5,19 @@ from email.mime.text import MIMEText import requests -import transaction +from rq import SimpleWorker from tracim.fixtures.users_and_groups import Base as BaseFixture from tracim.fixtures.content import Content as ContentFixture +from tracim.lib.utils.utils import get_redis_connection +from tracim.lib.utils.utils import get_rq_queue from tracim.models.data import ContentType from tracim.lib.core.content import ContentApi -from tracim.lib.core.group import GroupApi from tracim.lib.core.user import UserApi -from tracim.lib.core.userworkspace import RoleApi from tracim.lib.core.workspace import WorkspaceApi -from tracim.lib.mail_notifier.notifier import EmailManager from tracim.lib.mail_notifier.sender import EmailSender from tracim.lib.mail_notifier.utils import SmtpConfiguration -from tracim.models import Group from tracim.tests import MailHogTest @@ -81,12 +79,14 @@ def test__func__send_email__ok__nominal_case(self): headers = response[0]['Content']['Headers'] assert headers['From'][0] == 'test_send_mail@localhost' assert headers['To'][0] == 'receiver_test_send_mail@localhost' - assert headers['Subject'][0] == 'test__func__send_email__ok__nominal_case' + assert headers['Subject'][0] == 'test__func__send_email__ok__nominal_case' # nopep8 assert response[0]['MIME']['Parts'][0]['Body'] == text assert response[0]['MIME']['Parts'][1]['Body'] == html -class testUserNotification(MailHogTest): +class TestNotificationsSync(MailHogTest): + + fixtures = [BaseFixture, ContentFixture] def test_func__create_user_with_mail_notification__ok__nominal_case(self): api = UserApi( @@ -112,14 +112,100 @@ def test_func__create_user_with_mail_notification__ok__nominal_case(self): response = requests.get('http://127.0.0.1:8025/api/v1/messages') response = response.json() headers = response[0]['Content']['Headers'] - assert headers['From'][0] == 'Tracim Notifications ' + assert headers['From'][0] == 'Tracim Notifications ' # nopep8 assert headers['To'][0] == 'bob ' assert headers['Subject'][0] == '[TRACIM] Created account' + def test_func__create_new_content_with_notification__ok__nominal_case(self): + uapi = UserApi( + current_user=None, + session=self.session, + config=self.app_config, + ) + current_user = uapi.get_one_by_email('admin@admin.admin') + # Create new user with notification enabled on w1 workspace + wapi = WorkspaceApi( + current_user=current_user, + session=self.session, + ) + workspace = wapi.get_one_by_label('w1') + user = uapi.get_one_by_email('bob@fsf.local') + wapi.enable_notifications(user, workspace) -class testContentNotification(MailHogTest): + api = ContentApi( + current_user=user, + session=self.session, + config=self.app_config, + ) + item = api.create( + ContentType.Folder, + workspace, + None, + 'parent', + do_save=True, + do_notify=False, + ) + item2 = api.create( + ContentType.File, + workspace, + item, + 'file1', + do_save=True, + do_notify=True, + ) + # check mail received + response = requests.get('http://127.0.0.1:8025/api/v1/messages') + response = response.json() + headers = response[0]['Content']['Headers'] + assert headers['From'][0] == '"Bob i. via Tracim" ' # nopep8 + assert headers['To'][0] == 'Global manager ' + assert headers['Subject'][0] == '[TRACIM] [w1] file1 (open)' + assert headers['References'][0] == 'test_user_refs+13@localhost' + assert headers['Reply-to'][0] == '"Bob i. & all members of w1" ' # nopep8 + + +class TestNotificationsAsync(MailHogTest): fixtures = [BaseFixture, ContentFixture] + config_section = 'mail_test_async' + + def test_func__create_user_with_mail_notification__ok__nominal_case(self): + api = UserApi( + current_user=None, + session=self.session, + config=self.app_config, + ) + u = api.create_user( + email='bob@bob', + password='pass', + name='bob', + timezone='+2', + do_save=True, + do_notify=True, + ) + assert u is not None + assert u.email == "bob@bob" + assert u.validate_password('pass') + assert u.display_name == 'bob' + assert u.timezone == '+2' + + # Send mail async from redis queue + redis = get_redis_connection( + self.app_config + ) + queue = get_rq_queue( + redis, + 'mail_sender', + ) + worker = SimpleWorker([queue], connection=queue.connection) + worker.work(burst=True) + # check mail received + response = requests.get('http://127.0.0.1:8025/api/v1/messages') + response = response.json() + headers = response[0]['Content']['Headers'] + assert headers['From'][0] == 'Tracim Notifications ' # nopep8 + assert headers['To'][0] == 'bob ' + assert headers['Subject'][0] == '[TRACIM] Created account' def test_func__create_new_content_with_notification__ok__nominal_case(self): uapi = UserApi( @@ -158,13 +244,22 @@ def test_func__create_new_content_with_notification__ok__nominal_case(self): do_save=True, do_notify=True, ) - + # Send mail async from redis queue + redis = get_redis_connection( + self.app_config + ) + queue = get_rq_queue( + redis, + 'mail_sender', + ) + worker = SimpleWorker([queue], connection=queue.connection) + worker.work(burst=True) # check mail received response = requests.get('http://127.0.0.1:8025/api/v1/messages') response = response.json() headers = response[0]['Content']['Headers'] - assert headers['From'][0] == '"Bob i. via Tracim" ' + assert headers['From'][0] == '"Bob i. via Tracim" ' # nopep8 assert headers['To'][0] == 'Global manager ' assert headers['Subject'][0] == '[TRACIM] [w1] file1 (open)' assert headers['References'][0] == 'test_user_refs+13@localhost' - assert headers['Reply-to'][0] == '"Bob i. & all members of w1" ' \ No newline at end of file + assert headers['Reply-to'][0] == '"Bob i. & all members of w1" ' # nopep8 From 078bb357796646061766087050b97ad494f0bc1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Tue, 15 May 2018 15:35:51 +0200 Subject: [PATCH 12/13] rq need redis-server --- .travis.yml | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index ca063d6..602e547 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ python: services: - docker + - redis-server before_install: - docker pull mailhog/mailhog diff --git a/README.md b/README.md index 840680c..c4b82c3 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ on Debian Stretch (9) with sudo: sudo apt update sudo apt install git sudo apt install python3 python3-venv python3-dev python3-pip + sudo apt install redis-server ### Get the source ### From d3378ccff1ea43f898764cc172ffa21bfbc60dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gu=C3=A9na=C3=ABl=20Muller?= Date: Mon, 11 Jun 2018 14:45:01 +0200 Subject: [PATCH 13/13] fix template to allow easily to fix url bug directly from python code --- tracim/lib/mail_notifier/notifier.py | 54 ++++++++++++------- .../mail/content_update_body_html.mak | 11 ++-- .../mail/content_update_body_text.mak | 2 +- .../mail/created_account_body_html.mak | 2 +- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/tracim/lib/mail_notifier/notifier.py b/tracim/lib/mail_notifier/notifier.py index 774005c..c8fc9d3 100644 --- a/tracim/lib/mail_notifier/notifier.py +++ b/tracim/lib/mail_notifier/notifier.py @@ -357,22 +357,22 @@ def notify_created_account( text_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT # nopep8 html_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML # nopep8 + context = { + 'user': user, + 'password': password, + # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for logo_url # nopep8 + 'logo_url': '', + # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for login_url # nopep8 + 'login_url': self.config.WEBSITE_BASE_URL, + } body_text = self._render_template( mako_template_filepath=text_template_file_path, - context={ - 'user': user, - 'password': password, - 'login_url': self.config.WEBSITE_BASE_URL, - } + context=context ) body_html = self._render_template( mako_template_filepath=html_template_file_path, - context={ - 'user': user, - 'password': password, - 'login_url': self.config.WEBSITE_BASE_URL, - } + context=context, ) part1 = MIMEText(body_text, 'plain', 'utf-8') @@ -433,6 +433,15 @@ def _build_email_body_for_content( content_text = '' call_to_action_text = '' + # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for call_to_action_url # nopep8 + call_to_action_url ='' + # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for status_icon_url # nopep8 + status_icon_url = '' + # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for workspace_url # nopep8 + workspace_url = '' + # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for logo_url # nopep8 + logo_url = '' + action = content.get_last_action().id if ActionDescription.COMMENT == action: content_intro = l_('{} added a comment:').format(actor.display_name) @@ -522,20 +531,25 @@ def _build_email_body_for_content( ) raise ValueError('Unexpected empty notification') + context = { + 'user': role.user, + 'workspace': role.workspace, + 'workspace_url': workspace_url, + 'main_title': main_title, + 'status_label': content.get_status().label, + 'status_icon_url': status_icon_url, + 'role_label': role.role_as_label(), + 'content_intro': content_intro, + 'content_text': content_text, + 'call_to_action_text': call_to_action_text, + 'call_to_action_url': call_to_action_url, + 'logo_url': logo_url, + } user = role.user workspace = role.workspace body_content = self._render_template( mako_template_filepath=mako_template_filepath, - context={ - 'user': role.user, - 'workspace': role.workspace, - 'main_title': main_title, - 'status': content.get_status().label, - 'role': role.role_as_label(), - 'content_intro': content_intro, - 'content_text': content_text, - 'call_to_action_text': call_to_action_text, - } + context=context, ) return body_content diff --git a/tracim/templates/mail/content_update_body_html.mak b/tracim/templates/mail/content_update_body_html.mak index 06526b5..b2c4c37 100644 --- a/tracim/templates/mail/content_update_body_html.mak +++ b/tracim/templates/mail/content_update_body_html.mak @@ -38,14 +38,14 @@ @@ -54,15 +54,14 @@

${content_intro|n}

${content_text|n}
-
- + logo ${main_title} —  - ${status|n} - + ${status_label|n} + status_icon
- + logo