diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py
index 2a8cbcd..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
@@ -157,6 +151,7 @@ THIRD_PARTY_APPS = [
'django_countries',
'django_select2',
'rest_framework',
+ 'post_office',
]
FIRST_PARTY_APPS = [
@@ -199,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
@@ -216,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/
@@ -234,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',
},
]
@@ -264,7 +261,6 @@ LOGIN_URL = reverse_lazy('login')
LOGIN_REDIRECT_URL = reverse_lazy('index')
-
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
@@ -278,7 +274,6 @@ USE_L10N = True
USE_TZ = True
-
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
@@ -286,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
@@ -332,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 = {
@@ -346,7 +340,6 @@ CONSTANCE_CONFIG_FIELDSETS = {
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
-
# Bootstrap settings
# See https://django-bootstrap4.readthedocs.io/en/latest/settings.html
@@ -354,37 +347,42 @@ 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
+CUSTOMISATION_NAME = None
TEMPLATE_NAME_INDEX = 'index.html'
+TEMPLATE_WELCOME_EMAIL_NAME = 'welcome-email'
try:
- from custom.settings import (
- CUSTOMISATION_NAME,
- TEMPLATE_NAME_INDEX
- )
+ 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')
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/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 3eae75a..6590932 100644
--- a/people/apps.py
+++ b/people/apps.py
@@ -1,5 +1,69 @@
+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__) # pylint: disable=invalid-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, 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)
+
+ 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/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 10efe56..f7910f9 100644
--- a/people/models/person.py
+++ b/people/models/person.py
@@ -1,13 +1,20 @@
+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 _
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
+logger = logging.getLogger(__name__) # pylint: disable=invalid-name
+
__all__ = [
'User',
'Organisation',
@@ -22,30 +29,54 @@ 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?
"""
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,
+ })
+
+ logger.info('Sending welcome mail to user \'%s\'', self.username)
+
+ 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):
"""
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
@@ -55,23 +86,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
@@ -88,21 +116,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
@@ -115,7 +144,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')
@@ -131,8 +161,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)
@@ -141,34 +172,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})
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)
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/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
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 }}