Merge branch 'dev'

This commit is contained in:
James Graham
2020-06-02 09:48:11 +01:00
11 changed files with 247 additions and 97 deletions

View File

@@ -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':
config('DATABASE_URL',
default='sqlite:///' + str(BASE_DIR.joinpath('db.sqlite3')),
cast=dj_database_url.parse
)
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,17 +347,19 @@ 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'
@@ -372,19 +367,22 @@ else:
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_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')

View File

@@ -0,0 +1 @@
default_app_config = 'people.apps.PeopleConfig'

View File

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

View File

@@ -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')

View File

@@ -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": "<h1>{{ settings.PROJECT_LONG_NAME }}</h1>\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
}
}
]

View File

@@ -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'),
),
]

View File

@@ -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,19 +29,44 @@ 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
@@ -44,8 +76,7 @@ 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,12 +86,10 @@ 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
@@ -70,8 +99,7 @@ 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,18 +116,19 @@ 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',
relationship_targets = models.ManyToManyField(
'self',
related_name='relationship_sources',
through='Relationship',
through_fields=('source', 'target'),
symmetrical=False)
@@ -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,7 +161,8 @@ 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)
@@ -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})

View File

@@ -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,29 +50,39 @@ 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

View File

@@ -16,5 +16,3 @@
vars:
ansible_python_interpreter: python2
db_user: 'breccia'
db_pass: 'breccia'

View File

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

View File

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