From 567322c0af0cb61a87b4a4f2cbf8e8ab286fe8dc Mon Sep 17 00:00:00 2001 From: James Graham Date: Tue, 28 Apr 2020 15:17:58 +0100 Subject: [PATCH 1/6] feat: Send welcome email from template --- breccia_mapper/settings.py | 6 ++- people/__init__.py | 1 + people/apps.py | 58 ++++++++++++++++++++++++++++ people/fixtures/email_templates.json | 16 ++++++++ people/models/person.py | 18 +++++++++ requirements.txt | 2 + 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 people/fixtures/email_templates.json diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index 2a8cbcd..ba9e2a2 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -157,6 +157,7 @@ THIRD_PARTY_APPS = [ 'django_countries', 'django_select2', 'rest_framework', + 'post_office', ] FIRST_PARTY_APPS = [ @@ -378,12 +379,15 @@ else: # Import customisation app settings if present +CUSTOMISATION_NAME = None TEMPLATE_NAME_INDEX = 'index.html' +TEMPLATE_WELCOME_EMAIL_NAME = 'welcome-email' try: from custom.settings import ( CUSTOMISATION_NAME, - TEMPLATE_NAME_INDEX + TEMPLATE_NAME_INDEX, + TEMPLATE_WELCOME_EMAIL_NAME ) logger.info("Loaded customisation app: %s", CUSTOMISATION_NAME) diff --git a/people/__init__.py b/people/__init__.py index e69de29..a6e61af 100644 --- a/people/__init__.py +++ b/people/__init__.py @@ -0,0 +1 @@ +default_app_config = 'people.apps.PeopleConfig' diff --git a/people/apps.py b/people/apps.py index 3eae75a..f549ed6 100644 --- a/people/apps.py +++ b/people/apps.py @@ -1,5 +1,63 @@ +import logging + from django.apps import AppConfig +from django.conf import settings +from django.core import serializers +from django.db.models.signals import post_save + +logger = logging.getLogger(__name__) + + +def load_welcome_template_fixture(fixture_path) -> bool: + """Load welcome email template from a JSON fixture.""" + try: + with open(fixture_path) as f: + for deserialized in serializers.deserialize('json', f): + if deserialized.object.name == settings.TEMPLATE_WELCOME_EMAIL_NAME: + deserialized.save() + logger.warning('Welcome email template \'%s\' loaded', deserialized.object.name) + return True + + return False + + except FileNotFoundError: + logger.warning('Email template fixture not found.') + return False + + +def send_welcome_email(sender, instance, **kwargs): + from post_office import models + + try: + instance.send_welcome_email() + + except models.EmailTemplate.DoesNotExist: + logger.warning('Welcome email template \'%s\' not found - attempting to load from fixtures', + settings.TEMPLATE_WELCOME_EMAIL_NAME) + + is_loaded = False + if settings.CUSTOMISATION_NAME: + # Customisation app present - try here first + is_loaded |= load_welcome_template_fixture( + settings.BASE_DIR.joinpath('custom', 'fixtures', 'email_templates.json') + ) + + # |= operator shortcuts - only try here if we don't already have it + is_loaded |= load_welcome_template_fixture( + settings.BASE_DIR.joinpath('people', 'fixtures', 'email_templates.json') + ) + + if is_loaded: + instance.send_welcome_email() + + else: + logger.error('Welcome email template \'%s\' not found', settings.TEMPLATE_WELCOME_EMAIL_NAME) class PeopleConfig(AppConfig): name = 'people' + + def ready(self) -> None: + # Activate signal handlers + post_save.connect(send_welcome_email, + sender='people.user') diff --git a/people/fixtures/email_templates.json b/people/fixtures/email_templates.json new file mode 100644 index 0000000..78a8fd2 --- /dev/null +++ b/people/fixtures/email_templates.json @@ -0,0 +1,16 @@ +[ + { + "model": "post_office.emailtemplate", + "fields": { + "name": "welcome-email", + "description": "Default welcome email template", + "created": "2020-04-27T12:13:30.448Z", + "last_updated": "2020-04-27T14:45:27.152Z", + "subject": "Welcome to {{settings.PROJECT_LONG_NAME}}", + "content": "Dear {{ user.get_full_name }},\r\n\r\nWelcome to {{ settings.PROJECT_LONG_NAME }}.\r\n\r\nThanks,\r\n\r\nThe {{ settings.PROJECT_LONG_NAME }} team", + "html_content": "

{{ settings.PROJECT_LONG_NAME }}

\r\n\r\nDear {{ user.get_full_name }},\r\n\r\nWelcome to {{ settings.PROJECT_LONG_NAME }}.\r\n\r\nThanks,\r\n\r\nThe {{ settings.PROJECT_LONG_NAME }} team", + "language": "", + "default_template": null + } + } +] diff --git a/people/models/person.py b/people/models/person.py index 10efe56..e049081 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -5,6 +5,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField +from django_settings_export import settings_export +from post_office import mail from backports.db.models.enums import TextChoices @@ -28,6 +30,22 @@ class User(AbstractUser): """ return hasattr(self, 'person') + def send_welcome_email(self): + """Send a welcome email to a new user.""" + # Get exported data from settings.py first + context = settings_export(None) + context.update({ + 'user': self, + }) + + mail.send( + [self.email], + sender=settings.DEFAULT_FROM_EMAIL, + template=settings.TEMPLATE_WELCOME_EMAIL_NAME, + context=context, + priority='now' # Send immediately - don't add to queue + ) + class Organisation(models.Model): """ diff --git a/requirements.txt b/requirements.txt index 1c63c7e..2877175 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,11 +9,13 @@ django-countries==5.5 django-dbbackup==3.2.0 django-filter==2.2.0 django-picklefield==2.1.1 +django-post-office==3.4.0 django-select2==7.2.0 django-settings-export==1.2.1 djangorestframework==3.11.0 dodgy==0.2.1 isort==4.3.21 +jsonfield==3.1.0 lazy-object-proxy==1.4.3 mccabe==0.6.1 mysqlclient==1.4.6 From 82fbdd2ca19476567d887f1edeffd94d5940de94 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 27 May 2020 11:26:52 +0100 Subject: [PATCH 2/6] deploy: Add allowed hosts to Ansible variables Move DB credentials to inventory file --- playbook.yml | 2 -- roles/webserver/templates/settings.j2 | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/playbook.yml b/playbook.yml index 00f763d..815ee4d 100644 --- a/playbook.yml +++ b/playbook.yml @@ -16,5 +16,3 @@ vars: ansible_python_interpreter: python2 - db_user: 'breccia' - db_pass: 'breccia' diff --git a/roles/webserver/templates/settings.j2 b/roles/webserver/templates/settings.j2 index 4f45b14..f99d88e 100644 --- a/roles/webserver/templates/settings.j2 +++ b/roles/webserver/templates/settings.j2 @@ -3,9 +3,14 @@ SECRET_KEY={{ secret_key }} DEBUG={{ "True" if deploy_mode > 1 else "False" }} -ALLOWED_HOSTS={{ inventory_hostname }},localhost,127.0.0.1 DATABASE_URL=mysql://{{ db_user }}:{{ db_pass }}@localhost:3306/{{ db_name }} +{% if allowed_hosts is defined %} +ALLOWED_HOSTS={% for h in allowed_hosts %}{{ h }},{% endfor %} +{% else %} +ALLOWED_HOSTS={{ inventory_hostname }},localhost,127.0.0.1 +{% endif %} + PROJECT_SHORT_NAME={{ display_short_name }} PROJECT_LONG_NAME={{ display_long_name }} From c364db4f1634db57b184cafca9413d66d4bcd161 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 27 May 2020 14:42:28 +0100 Subject: [PATCH 3/6] fix: Add DEFAULT_FROM_EMAIL --- breccia_mapper/settings.py | 80 ++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 43 deletions(-) diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index ba9e2a2..e536c0f 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -38,7 +38,7 @@ The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABAS - DATABASE_URL default: sqlite://db.sqlite3 URL to database - uses format described at https://github.com/jacobian/dj-database-url - + - DBBACKUP_STORAGE_LOCATION default: .dbbackup Directory where database backups should be stored @@ -106,7 +106,6 @@ from django.urls import reverse_lazy from decouple import config, Csv import dj_database_url - # Settings exported to templates # https://github.com/jakubroztocil/django-settings-export @@ -116,15 +115,12 @@ SETTINGS_EXPORT = [ 'PROJECT_SHORT_NAME', ] - PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name') PROJECT_SHORT_NAME = config('PROJECT_SHORT_NAME', default='shortname') - # Build paths inside the project like this: BASE_DIR.joinpath(...) BASE_DIR = pathlib.Path(__file__).parent.parent - # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = config('SECRET_KEY') @@ -134,9 +130,7 @@ DEBUG = config('DEBUG', default=False, cast=bool) ALLOWED_HOSTS = config( 'ALLOWED_HOSTS', default='*' if DEBUG else '127.0.0.1,localhost,localhost.localdomain', - cast=Csv() -) - + cast=Csv()) # Application definition @@ -200,16 +194,14 @@ TEMPLATES = [ WSGI_APPLICATION = 'breccia_mapper.wsgi.application' - # Database # https://docs.djangoproject.com/en/2.2/ref/settings/#databases DATABASES = { - 'default': config( - 'DATABASE_URL', - default='sqlite:///' + str(BASE_DIR.joinpath('db.sqlite3')), - cast=dj_database_url.parse - ) + 'default': + config('DATABASE_URL', + default='sqlite:///' + str(BASE_DIR.joinpath('db.sqlite3')), + cast=dj_database_url.parse) } # Django DBBackup @@ -217,10 +209,11 @@ DATABASES = { DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage' DBBACKUP_STORAGE_OPTIONS = { - 'location': config('DBBACKUP_STORAGE_LOCATION', default=BASE_DIR.joinpath('.dbbackup')), + 'location': + config('DBBACKUP_STORAGE_LOCATION', + default=BASE_DIR.joinpath('.dbbackup')), } - # Django REST Framework # https://www.django-rest-framework.org/ @@ -235,22 +228,25 @@ REST_FRAMEWORK = { ], } - # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + 'NAME': + 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'NAME': + 'django.contrib.auth.password_validation.MinimumLengthValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + 'NAME': + 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + 'NAME': + 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] @@ -265,7 +261,6 @@ LOGIN_URL = reverse_lazy('login') LOGIN_REDIRECT_URL = reverse_lazy('index') - # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ @@ -279,7 +274,6 @@ USE_L10N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ @@ -287,10 +281,7 @@ STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR.joinpath('static') -STATICFILES_DIRS = [ - BASE_DIR.joinpath('breccia_mapper', 'static') -] - +STATICFILES_DIRS = [BASE_DIR.joinpath('breccia_mapper', 'static')] # Logging - NB the logger name is empty to capture all output @@ -333,12 +324,14 @@ LOGGING_CONFIG = None logging.config.dictConfig(LOGGING) logger = logging.getLogger(__name__) - # Admin panel variables CONSTANCE_CONFIG = collections.OrderedDict([ - ('NOTICE_TEXT', ('', 'Text to be displayed in a notice banner at the top of every page.')), - ('NOTICE_CLASS', ('alert-warning', 'CSS class to use for background of notice banner.')), + ('NOTICE_TEXT', + ('', + 'Text to be displayed in a notice banner at the top of every page.')), + ('NOTICE_CLASS', ('alert-warning', + 'CSS class to use for background of notice banner.')), ]) CONSTANCE_CONFIG_FIELDSETS = { @@ -347,7 +340,6 @@ CONSTANCE_CONFIG_FIELDSETS = { CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' - # Bootstrap settings # See https://django-bootstrap4.readthedocs.io/en/latest/settings.html @@ -355,27 +347,32 @@ BOOTSTRAP4 = { 'include_jquery': 'full', } - # Email backend settings # See https://docs.djangoproject.com/en/3.0/topics/email EMAIL_HOST = config('EMAIL_HOST', default=None) -DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default=None) +DEFAULT_FROM_EMAIL = config( + 'DEFAULT_FROM_EMAIL', + default=f'{PROJECT_SHORT_NAME}@localhost.localdomain') SERVER_EMAIL = DEFAULT_FROM_EMAIL if EMAIL_HOST is None: EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' - EMAIL_FILE_PATH = config('EMAIL_FILE_PATH', default=str(BASE_DIR.joinpath('mail.log'))) + EMAIL_FILE_PATH = config('EMAIL_FILE_PATH', + default=str(BASE_DIR.joinpath('mail.log'))) else: EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST_USER = config('EMAIL_HOST_USER', default=None) EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default=None) - - EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int) - EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=(EMAIL_PORT == 587), cast=bool) - EMAIL_USE_SSL = config('EMAIL_USE_SSL', default=(EMAIL_PORT == 465), cast=bool) + EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int) + EMAIL_USE_TLS = config('EMAIL_USE_TLS', + default=(EMAIL_PORT == 587), + cast=bool) + EMAIL_USE_SSL = config('EMAIL_USE_SSL', + default=(EMAIL_PORT == 465), + cast=bool) # Import customisation app settings if present @@ -384,11 +381,8 @@ TEMPLATE_NAME_INDEX = 'index.html' TEMPLATE_WELCOME_EMAIL_NAME = 'welcome-email' try: - from custom.settings import ( - CUSTOMISATION_NAME, - TEMPLATE_NAME_INDEX, - TEMPLATE_WELCOME_EMAIL_NAME - ) + from custom.settings import (CUSTOMISATION_NAME, TEMPLATE_NAME_INDEX, + TEMPLATE_WELCOME_EMAIL_NAME) logger.info("Loaded customisation app: %s", CUSTOMISATION_NAME) INSTALLED_APPS.append('custom') From 8bc82b2a15e5465c2ec890b0e215cfde9a426fd3 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 27 May 2020 14:47:10 +0100 Subject: [PATCH 4/6] temp fix: Temporarily disable welcome emails --- people/models/person.py | 75 ++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/people/models/person.py b/people/models/person.py index e049081..e4ef9fb 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -37,7 +37,9 @@ class User(AbstractUser): context.update({ 'user': self, }) - + + return False + mail.send( [self.email], sender=settings.DEFAULT_FROM_EMAIL, @@ -51,19 +53,17 @@ class Organisation(models.Model): """ Organisation to which a :class:`Person` belongs. """ - name = models.CharField(max_length=255, - blank=False, null=False) - + name = models.CharField(max_length=255, blank=False, null=False) + def __str__(self) -> str: return self.name - - + + class Role(models.Model): """ Role which a :class:`Person` holds within the project. """ - name = models.CharField(max_length=255, - blank=False, null=False) + name = models.CharField(max_length=255, blank=False, null=False) def __str__(self) -> str: return self.name @@ -73,23 +73,20 @@ class Discipline(models.Model): """ Discipline within which a :class:`Person` works. """ - name = models.CharField(max_length=255, - blank=False, null=False) + name = models.CharField(max_length=255, blank=False, null=False) #: Short code using system such as JACS 3 - code = models.CharField(max_length=15, - blank=True, null=False) + code = models.CharField(max_length=15, blank=True, null=False) def __str__(self) -> str: return self.name - - + + class Theme(models.Model): """ Project theme within which a :class:`Person` works. """ - name = models.CharField(max_length=255, - blank=False, null=False) + name = models.CharField(max_length=255, blank=False, null=False) def __str__(self) -> str: return self.name @@ -106,21 +103,22 @@ class Person(models.Model): user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='person', on_delete=models.CASCADE, - blank=True, null=True) + blank=True, + null=True) #: Name of the person - name = models.CharField(max_length=255, - blank=False, null=False) + name = models.CharField(max_length=255, blank=False, null=False) #: Is this person a member of the core project team? - core_member = models.BooleanField(default=False, - blank=False, null=False) + core_member = models.BooleanField(default=False, blank=False, null=False) #: People with whom this person has relationship - via intermediate :class:`Relationship` model - relationship_targets = models.ManyToManyField('self', related_name='relationship_sources', - through='Relationship', - through_fields=('source', 'target'), - symmetrical=False) + relationship_targets = models.ManyToManyField( + 'self', + related_name='relationship_sources', + through='Relationship', + through_fields=('source', 'target'), + symmetrical=False) ############################################################### # Data collected for analysis of community makeup and structure @@ -133,7 +131,8 @@ class Person(models.Model): gender = models.CharField(max_length=1, choices=GenderChoices.choices, - blank=True, null=False) + blank=True, + null=False) class AgeGroupChoices(TextChoices): LTE_25 = '<=25', _('25 or under') @@ -149,8 +148,9 @@ class Person(models.Model): age_group = models.CharField(max_length=5, choices=AgeGroupChoices.choices, - blank=True, null=False) - + blank=True, + null=False) + nationality = CountryField(blank=True, null=True) country_of_residence = CountryField(blank=True, null=True) @@ -159,34 +159,33 @@ class Person(models.Model): organisation = models.ForeignKey(Organisation, on_delete=models.PROTECT, related_name='members', - blank=True, null=True) + blank=True, + null=True) #: Job title this person holds within their organisation - job_title = models.CharField(max_length=255, - blank=True, null=False) + job_title = models.CharField(max_length=255, blank=True, null=False) #: Discipline within which this person works discipline = models.ForeignKey(Discipline, on_delete=models.PROTECT, related_name='people', - blank=True, null=True) + blank=True, + null=True) #: Role this person holds within the project role = models.ForeignKey(Role, on_delete=models.PROTECT, related_name='holders', - blank=True, null=True) + blank=True, + null=True) #: Project themes within this person works - themes = models.ManyToManyField(Theme, - related_name='people', - blank=True) + themes = models.ManyToManyField(Theme, related_name='people', blank=True) @property def relationships(self): return self.relationships_as_source.all().union( - self.relationships_as_target.all() - ) + self.relationships_as_target.all()) def get_absolute_url(self): return reverse('people:person.detail', kwargs={'pk': self.pk}) From c6eda514caa7809c1d6a841afbccaa2d6edf1843 Mon Sep 17 00:00:00 2001 From: James Graham Date: Thu, 28 May 2020 13:51:00 +0100 Subject: [PATCH 5/6] fix: Fix time filtering of relationships on MySQL Resolves #31 --- people/views/network.py | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/people/views/network.py b/people/views/network.py index a17ab9e..58addd2 100644 --- a/people/views/network.py +++ b/people/views/network.py @@ -2,15 +2,18 @@ Views for displaying networks of :class:`People` and :class:`Relationship`s. """ +import logging + from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Q from django.forms import ValidationError from django.utils import timezone from django.views.generic import FormView - from people import forms, models, serializers +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + class NetworkView(LoginRequiredMixin, FormView): """ @@ -47,35 +50,45 @@ class NetworkView(LoginRequiredMixin, FormView): if not at_date: at_date = timezone.now().date() + # Filter on timestamp__date doesn't seem to work on MySQL + # To compare datetimes we need at_date to be midnight at + # the *end* of the day in question - so add one day here + at_date += timezone.timedelta(days=1) + relationship_answerset_set = models.RelationshipAnswerSet.objects.filter( - Q(replaced_timestamp__date__gte=at_date) | Q(replaced_timestamp__isnull=True), - timestamp__date__lte=at_date - ) + Q(replaced_timestamp__gte=at_date) + | Q(replaced_timestamp__isnull=True), + timestamp__lte=at_date) + + logger.info('Found %d relationship answer sets for %s', + relationship_answerset_set.count(), at_date) # Filter answers to relationship questions for field, values in form.cleaned_data.items(): if field.startswith('question_') and values: relationship_answerset_set = relationship_answerset_set.filter( - question_answers__in=values - ) + question_answers__in=values) + + logger.info('Found %d relationship answer sets matching filters', + relationship_answerset_set.count()) context['person_set'] = serializers.PersonSerializer( - models.Person.objects.all(), - many=True - ).data + models.Person.objects.all(), many=True).data context['relationship_set'] = serializers.RelationshipSerializer( models.Relationship.objects.filter( - pk__in=relationship_answerset_set.values_list('relationship', flat=True) - ), - many=True - ).data + pk__in=relationship_answerset_set.values_list('relationship', + flat=True)), + many=True).data + + logger.info('Found %d distinct relationships matching filters', + len(context['relationship_set'])) return context def form_valid(self, form): try: return self.render_to_response(self.get_context_data()) - + except ValidationError: return self.form_invalid(form) From 0cceb604dde71afb0a639187b6e749700881f35b Mon Sep 17 00:00:00 2001 From: James Graham Date: Thu, 28 May 2020 16:24:44 +0100 Subject: [PATCH 6/6] fix: Fix welcome email bugs Send only when new user is created Require email address when user created Resolves #32 --- people/admin.py | 7 +++- people/apps.py | 34 ++++++++++++-------- people/migrations/0018_require_user_email.py | 18 +++++++++++ people/models/person.py | 29 ++++++++++++----- 4 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 people/migrations/0018_require_user_email.py diff --git a/people/admin.py b/people/admin.py index ae3c9cc..a6f4ae9 100644 --- a/people/admin.py +++ b/people/admin.py @@ -8,7 +8,12 @@ from django.contrib.auth.admin import UserAdmin from . import models -admin.site.register(models.User, UserAdmin) +@admin.register(models.User) +class CustomUserAdmin(UserAdmin): + """Add email address field to new user form.""" + add_fieldsets = UserAdmin.add_fieldsets + ( + ('Details', {'fields': ('email', )}), + ) # yapf: disable @admin.register(models.Organisation) diff --git a/people/apps.py b/people/apps.py index f549ed6..6590932 100644 --- a/people/apps.py +++ b/people/apps.py @@ -5,7 +5,7 @@ from django.conf import settings from django.core import serializers from django.db.models.signals import post_save -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) # pylint: disable=invalid-name def load_welcome_template_fixture(fixture_path) -> bool: @@ -15,7 +15,8 @@ def load_welcome_template_fixture(fixture_path) -> bool: for deserialized in serializers.deserialize('json', f): if deserialized.object.name == settings.TEMPLATE_WELCOME_EMAIL_NAME: deserialized.save() - logger.warning('Welcome email template \'%s\' loaded', deserialized.object.name) + logger.warning('Welcome email template \'%s\' loaded', + deserialized.object.name) return True return False @@ -25,39 +26,44 @@ def load_welcome_template_fixture(fixture_path) -> bool: return False -def send_welcome_email(sender, instance, **kwargs): +def send_welcome_email(sender, instance, created, **kwargs): from post_office import models + if not created: + # If user already exists, don't send welcome message + return + try: instance.send_welcome_email() except models.EmailTemplate.DoesNotExist: - logger.warning('Welcome email template \'%s\' not found - attempting to load from fixtures', - settings.TEMPLATE_WELCOME_EMAIL_NAME) - + logger.warning( + 'Welcome email template \'%s\' not found - attempting to load from fixtures', + settings.TEMPLATE_WELCOME_EMAIL_NAME) + is_loaded = False if settings.CUSTOMISATION_NAME: # Customisation app present - try here first is_loaded |= load_welcome_template_fixture( - settings.BASE_DIR.joinpath('custom', 'fixtures', 'email_templates.json') - ) + settings.BASE_DIR.joinpath('custom', 'fixtures', + 'email_templates.json')) # |= operator shortcuts - only try here if we don't already have it is_loaded |= load_welcome_template_fixture( - settings.BASE_DIR.joinpath('people', 'fixtures', 'email_templates.json') - ) + settings.BASE_DIR.joinpath('people', 'fixtures', + 'email_templates.json')) if is_loaded: instance.send_welcome_email() else: - logger.error('Welcome email template \'%s\' not found', settings.TEMPLATE_WELCOME_EMAIL_NAME) + logger.error('Welcome email template \'%s\' not found', + settings.TEMPLATE_WELCOME_EMAIL_NAME) class PeopleConfig(AppConfig): name = 'people' - + def ready(self) -> None: # Activate signal handlers - post_save.connect(send_welcome_email, - sender='people.user') + post_save.connect(send_welcome_email, sender='people.user') diff --git a/people/migrations/0018_require_user_email.py b/people/migrations/0018_require_user_email.py new file mode 100644 index 0000000..81ab328 --- /dev/null +++ b/people/migrations/0018_require_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.10 on 2020-05-28 15:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0017_answerset_replaced_timestamp'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, verbose_name='email address'), + ), + ] diff --git a/people/models/person.py b/people/models/person.py index e4ef9fb..f7910f9 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -1,5 +1,8 @@ +import logging + from django.conf import settings from django.contrib.auth.models import AbstractUser +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -10,6 +13,8 @@ from post_office import mail from backports.db.models.enums import TextChoices +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + __all__ = [ 'User', 'Organisation', @@ -24,6 +29,8 @@ class User(AbstractUser): """ Custom user model in case we need to make changes later. """ + email = models.EmailField(_('email address'), blank=False, null=False) + def has_person(self) -> bool: """ Does this user have a linked :class:`Person` record? @@ -38,15 +45,21 @@ class User(AbstractUser): 'user': self, }) - return False + logger.info('Sending welcome mail to user \'%s\'', self.username) - mail.send( - [self.email], - sender=settings.DEFAULT_FROM_EMAIL, - template=settings.TEMPLATE_WELCOME_EMAIL_NAME, - context=context, - priority='now' # Send immediately - don't add to queue - ) + try: + mail.send( + [self.email], + sender=settings.DEFAULT_FROM_EMAIL, + template=settings.TEMPLATE_WELCOME_EMAIL_NAME, + context=context, + priority='now' # Send immediately - don't add to queue + ) + + except ValidationError: + logger.error( + 'Sending welcome mail failed, invalid email for user \'%s\'', + self.username) class Organisation(models.Model):