Merge pull request #69 from Southampton-RSG/dev

State as of demo 2021-02-02
This commit is contained in:
James Graham
2021-02-02 09:05:27 +00:00
committed by GitHub
26 changed files with 409 additions and 239 deletions

15
breccia_mapper/forms.py Normal file
View File

@@ -0,0 +1,15 @@
from django import forms
from django.contrib.auth import get_user_model
User = get_user_model() # pylint: disable=invalid-name
class ConsentForm(forms.ModelForm):
"""Form used to collect user consent for data collection / processing."""
class Meta:
model = User
fields = ['consent_given']
labels = {
'consent_given':
'I have read and understood this information and consent to my data being used in this way',
}

View File

@@ -157,6 +157,7 @@ THIRD_PARTY_APPS = [
'django_select2',
'rest_framework',
'post_office',
'bootstrap_datepicker_plus',
]
FIRST_PARTY_APPS = [
@@ -327,7 +328,7 @@ LOGGING = {
LOGGING_CONFIG = None
logging.config.dictConfig(LOGGING)
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
# Admin panel variables
@@ -337,10 +338,17 @@ CONSTANCE_CONFIG = collections.OrderedDict([
'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.')),
('CONSENT_TEXT',
('This is template consent text and should have been replaced. Please contact an admin.',
'Text to be displayed to ask for consent for data collection.'))
])
CONSTANCE_CONFIG_FIELDSETS = {
'Notice Banner': ('NOTICE_TEXT', 'NOTICE_CLASS'),
'Notice Banner': (
'NOTICE_TEXT',
'NOTICE_CLASS',
),
'Data Collection': ('CONSENT_TEXT', ),
}
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
@@ -379,12 +387,10 @@ else:
default=(EMAIL_PORT == 465),
cast=bool)
# Upstream API keys
GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None)
# Import customisation app settings if present
try:

View File

@@ -156,6 +156,15 @@
</div>
{% endif %}
{% if request.user.is_authenticated and not request.user.consent_given %}
<div class="alert alert-warning rounded-0" role="alert">
<p class="text-center mb-0">
You have not yet given consent for your data to be collected and processed.
Please read and accept the <a href="{% url 'consent' %}">consent text</a>.
</p>
</div>
{% endif %}
{% block before_content %}{% endblock %}
<main class="container">

View File

@@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% block content %}
<h2>Consent</h2>
<p>
{{ config.CONSENT_TEXT|linebreaks }}
</p>
<form class="form"
method="POST">
{% csrf_token %}
{% load bootstrap4 %}
{% bootstrap_form form %}
{% buttons %}
<button class="btn btn-success" type="submit">Submit</button>
{% endbuttons %}
</form>
{% endblock %}

View File

@@ -63,7 +63,7 @@
{% endblock %}
{% block content %}
<div class="row align-items-center">
<div class="row align-items-center" style="min-height: 400px;">
<div class="col-sm-8">
<h2 class="pb-2">About {{ settings.PROJECT_LONG_NAME }}</h2>

View File

@@ -32,6 +32,10 @@ urlpatterns = [
views.IndexView.as_view(),
name='index'),
path('consent',
views.ConsentTextView.as_view(),
name='consent'),
path('',
include('export.urls')),

View File

@@ -1,13 +1,31 @@
"""
Views belonging to the core of the project.
"""Views belonging to the core of the project.
These views don't represent any of the models in the apps.
"""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import TemplateView
from django.views.generic.edit import UpdateView
from . import forms
User = get_user_model() # pylint: disable=invalid-name
class IndexView(TemplateView):
# Template set in Django settings file - may be customised by a customisation app
template_name = settings.TEMPLATE_NAME_INDEX
class ConsentTextView(LoginRequiredMixin, UpdateView):
"""View with consent text and form for users to indicate consent."""
model = User
form_class = forms.ConsentForm
template_name = 'consent.html'
success_url = reverse_lazy('index')
def get_object(self, *args, **kwargs) -> User:
return self.request.user

View File

@@ -3,25 +3,13 @@
import typing
from django import forms
from django.forms.widgets import SelectDateWidget
from django.utils import timezone
from bootstrap_datepicker_plus import DatePickerInput
from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget
from . import models
def get_date_year_range() -> typing.Iterable[int]:
"""
Get sensible year range for SelectDateWidgets in the past.
By default these widgets show 10 years in the future.
"""
num_years_display = 60
this_year = timezone.datetime.now().year
return range(this_year, this_year - num_years_display, -1)
class OrganisationForm(forms.ModelForm):
"""Form for creating / updating an instance of :class:`Organisation`."""
class Meta:
@@ -46,9 +34,10 @@ class RelationshipForm(forms.Form):
class DynamicAnswerSetBase(forms.Form):
field_class = forms.ModelChoiceField
field_widget = None
field_required = True
question_model = None
field_widget: typing.Optional[typing.Type[forms.Widget]] = None
question_model: typing.Type[models.Question]
answer_model: typing.Type[models.QuestionChoice]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -72,6 +61,11 @@ class DynamicAnswerSetBase(forms.Form):
initial=initial.get(field_name, None))
self.fields[field_name] = field
if question.allow_free_text:
free_field = forms.CharField(label=f'{question} free text',
required=False)
self.fields[f'{field_name}_free'] = free_field
class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
"""Form for variable person attributes.
@@ -94,6 +88,7 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
widgets = {
'nationality': Select2Widget(),
'country_of_residence': Select2Widget(),
'organisation_started_date': DatePickerInput(format='%Y-%m-%d'),
'themes': Select2MultipleWidget(),
'latitude': forms.HiddenInput,
'longitude': forms.HiddenInput,
@@ -104,6 +99,7 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
}
question_model = models.PersonQuestion
answer_model = models.PersonQuestionChoice
def save(self, commit=True) -> models.PersonAnswerSet:
# Save Relationship model
@@ -116,6 +112,13 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
# Save answers to relationship questions
for key, value in self.cleaned_data.items():
if key.startswith('question_') and value:
if key.endswith('_free'):
# Create new answer from free text
value, _ = self.answer_model.objects.get_or_create(
text=value,
question=self.question_model.objects.get(
pk=key.split('_')[1]))
try:
self.instance.question_answers.add(value)
@@ -139,6 +142,7 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
]
question_model = models.RelationshipQuestion
answer_model = models.RelationshipQuestionChoice
def save(self, commit=True) -> models.RelationshipAnswerSet:
# Save Relationship model
@@ -148,6 +152,13 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
# Save answers to relationship questions
for key, value in self.cleaned_data.items():
if key.startswith('question_') and value:
if key.endswith('_free'):
# Create new answer from free text
value, _ = self.answer_model.objects.get_or_create(
text=value,
question=self.question_model.objects.get(
pk=key.split('_')[1]))
try:
self.instance.question_answers.add(value)
@@ -166,6 +177,7 @@ class NetworkFilterForm(DynamicAnswerSetBase):
field_widget = Select2MultipleWidget
field_required = False
question_model = models.RelationshipQuestion
answer_model = models.RelationshipQuestionChoice
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -173,5 +185,5 @@ class NetworkFilterForm(DynamicAnswerSetBase):
# Add date field to select relationships at a particular point in time
self.fields['date'] = forms.DateField(
required=False,
widget=SelectDateWidget(years=get_date_year_range()),
widget=DatePickerInput(format='%Y-%m-%d'),
help_text='Show relationships as they were on this date')

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2021-01-20 11:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('people', '0029_organisation_location_fields'),
]
operations = [
migrations.AddField(
model_name='user',
name='consent_given',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2.10 on 2021-01-20 13:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('people', '0030_user_consent_given'),
]
operations = [
migrations.AddField(
model_name='personquestion',
name='allow_free_text',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='relationshipquestion',
name='allow_free_text',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,2 +1,3 @@
from .person import *
from .question import *
from .relationship import *

View File

@@ -32,6 +32,9 @@ class User(AbstractUser):
"""
email = models.EmailField(_('email address'), blank=False, null=False)
#: Have they given consent to collect and store their data?
consent_given = models.BooleanField(default=False)
def has_person(self) -> bool:
"""
Does this user have a linked :class:`Person` record?

View File

@@ -4,6 +4,11 @@ import typing
from django.db import models
from django.utils.text import slugify
__all__ = [
'Question',
'QuestionChoice',
]
class Question(models.Model):
"""Questions from which a survey form can be created."""
@@ -27,6 +32,11 @@ class Question(models.Model):
blank=False,
null=False)
#: Should people be able to add their own answers?
allow_free_text = models.BooleanField(default=False,
blank=False,
null=False)
#: Position of this question in the list
order = models.SmallIntegerField(default=0, blank=False, null=False)

View File

@@ -1,5 +1,4 @@
let marker = null;
let search_markers = []
/**
@@ -7,9 +6,9 @@ let search_markers = []
* @param {Event} event - Click event from a Google Map.
*/
function selectLocation(event) {
if (marker === null) {
if (selected_marker === null) {
// Generate a new marker
marker = new google.maps.Marker({
selected_marker = new google.maps.Marker({
position: event.latLng,
map: map,
icon: {
@@ -24,10 +23,10 @@ function selectLocation(event) {
},
});
} else {
marker.setPosition(event.latLng);
selected_marker.setPosition(event.latLng);
}
const pos = marker.getPosition();
const pos = selected_marker.getPosition();
document.getElementById('id_latitude').value = pos.lat();
document.getElementById('id_longitude').value = pos.lng();
}

View File

@@ -11,6 +11,7 @@ const marker_edge_alpha = 1.0;
const marker_edge_width = 1.0;
let map = null;
let selected_marker = null;
let selected_marker_info = null;
function createMarker(map, marker_data) {
@@ -75,6 +76,9 @@ function initMap() {
try {
const marker = createMarker(map, marker_data);
bounds.extend(marker.position);
if (markers_data.length === 1) {
selected_marker = marker;
}
} catch (exc) {
// Just skip and move on to next

View File

@@ -1,163 +0,0 @@
{% extends 'base.html' %}
{% block extra_head %}
{{ map_markers|json_script:'map-markers' }}
{% load staticfiles %}
<script src="{% static 'js/map.js' %}"></script>
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
type="text/javascript"></script>
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'people:person.list' %}">People</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
</ol>
</nav>
<h1>{{ person.name }}</h1>
<hr>
{% if person.user == request.user or request.user.is_superuser %}
{% if person.user != request.user and request.user.is_superuser %}
<div class="alert alert-warning">
<strong>NB:</strong> You are able to see the details of this person because you are an admin.
Regular users are not able to see this information for people other than themselves.
</div>
{% endif %}
{% include 'people/person/includes/answer_set.html' %}
<a class="btn btn-success"
href="{% url 'people:person.update' pk=person.pk %}">Update</a>
{% if person.user == request.user %}
<a class="btn btn-info"
href="{% url 'password_change' %}?next={{ person.get_absolute_url }}">Change Password</a>
{% endif %}
{% endif %}
<hr>
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
<div class="row">
<div class="col-md-6">
<h2>Relationships As Source</h2>
<table class="table table-borderless">
<thead>
<tr>
<th>Contact Name</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{% for relationship in person.relationships_as_source.all %}
<tr>
<td>{{ relationship.target }}</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:person.detail' pk=relationship.target.pk %}">Profile</a>
</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:relationship.detail' pk=relationship.pk %}">Relationship Detail</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2">No known relationships</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="btn btn-success btn-block"
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">New Relationship
</a>
</div>
<div class="col-md-6">
<h2>Relationships As Target</h2>
<table class="table table-borderless">
<thead>
<tr>
<th>Contact Name</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{% for relationship in person.relationships_as_target.all %}
<tr>
<td>{{ relationship.source }}</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:person.detail' pk=relationship.source.pk %}">Profile</a>
</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:relationship.detail' pk=relationship.pk %}">Relationship Detail</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2">No known relationships</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<hr>
<h2>Activities</h2>
<table class="table table-borderless">
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{% for activity in person.activities.all %}
<tr>
<td>{{ activity }}</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'activities:activity.detail' pk=activity.pk %}">Details</a>
</td>
</tr>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block extra_script %}
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block extra_head %}
{{ map_markers|json_script:'map-markers' }}
{% load staticfiles %}
<script src="{% static 'js/map.js' %}"></script>
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
type="text/javascript"></script>
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'people:person.list' %}">People</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
</ol>
</nav>
<h1>{{ person.name }}</h1>
<hr>
{% if person.user != request.user and request.user.is_superuser %}
<div class="alert alert-warning">
<strong>NB:</strong> You are able to see the details of this person because you are an admin.
Regular users are not able to see this information for people other than themselves.
</div>
{% endif %}
{% include 'people/person/includes/answer_set_full.html' %}
<a class="btn btn-success"
href="{% url 'people:person.update' pk=person.pk %}">Update</a>
{% if person.user == request.user %}
<a class="btn btn-info"
href="{% url 'password_change' %}?next={{ person.get_absolute_url }}">Change Password</a>
{% endif %}
<hr>
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
{% include 'people/person/includes/relationships_full.html' %}
<hr>
{% include 'people/person/includes/activities_full.html' %}
<hr>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends 'base.html' %}
{% block extra_head %}
{{ map_markers|json_script:'map-markers' }}
{% load staticfiles %}
<script src="{% static 'js/map.js' %}"></script>
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
type="text/javascript"></script>
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'people:person.list' %}">People</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
</ol>
</nav>
<h1>{{ person.name }}</h1>
<a class="btn btn-success"
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">New Relationship
</a>
<hr>
{% include 'people/person/includes/answer_set_partial.html' %}
<hr>
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
{% endblock %}

View File

@@ -0,0 +1,26 @@
<h2>Activities</h2>
<table class="table table-borderless">
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{% for activity in person.activities.all %}
<tr>
<td>{{ activity }}</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'activities:activity.detail' pk=activity.pk %}">Details</a>
</td>
</tr>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,32 @@
<table class="table table-borderless">
<thead>
<tr>
<th>Question</th>
<th>Answer</th>
</tr>
</thead>
<tbody>
{% if answer_set.country_of_residence %}
<tr><td>Country of Residence</td><td>{{ answer_set.country_of_residence.name }}</td></tr>
{% endif %}
{% if answer_set.organisation %}
<tr><td>Organisation</td><td>{{ answer_set.organisation }}</td></tr>
{% endif %}
{% for answer in answer_set.question_answers.all %}
<tr>
<td>{{ answer.question }}</td>
<td>{{ answer }}</td>
</tr>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>Last updated: {{ answer_set.timestamp }}</p>

View File

@@ -0,0 +1,36 @@
<h2>People I've Answered Questions About</h2>
<table class="table table-borderless">
<thead>
<tr>
<th>Contact Name</th>
<th></th>
</tr>
</thead>
<tbody>
{% for relationship in person.relationships_as_source.all %}
<tr>
<td>{{ relationship.target }}</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:person.detail' pk=relationship.target.pk %}">Profile</a>
<a class="btn btn-sm btn-info"
href="{% url 'people:relationship.detail' pk=relationship.pk %}">Relationship Detail</a>
<a class="btn btn-sm btn-success"
href="{% url 'people:relationship.update' relationship_pk=relationship.pk %}">Update</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2">No known relationships</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="btn btn-success"
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">New Relationship
</a>

View File

@@ -28,6 +28,19 @@
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:person.detail' pk=person.pk %}">Profile</a>
{% if person.pk in existing_relationships %}
<a class="btn btn-sm btn-warning"
style="width: 10rem"
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Update Relationship
</a>
{% else %}
<a class="btn btn-sm btn-success"
style="width: 10rem"
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">New Relationship
</a>
{% endif %}
</td>
</tr>

View File

@@ -30,19 +30,33 @@ class PersonCreateView(LoginRequiredMixin, CreateView):
class PersonListView(LoginRequiredMixin, ListView):
"""
View displaying a list of :class:`Person` objects - searchable.
"""
"""View displaying a list of :class:`Person` objects - searchable."""
model = models.Person
template_name = 'people/person/list.html'
def get_context_data(self,
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
context = super().get_context_data(**kwargs)
class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView):
context['existing_relationships'] = set(
self.request.user.person.relationship_targets.values_list(
'pk', flat=True))
return context
class ProfileView(LoginRequiredMixin, DetailView):
"""
View displaying the profile of a :class:`Person` - who may be a user.
"""
model = models.Person
template_name = 'people/person/detail.html'
def get_template_names(self) -> typing.List[str]:
"""Return template depending on level of access."""
if (self.object.user == self.request.user) or self.request.user.is_superuser:
return ['people/person/detail_full.html']
return ['people/person/detail_partial.html']
def get_object(self, queryset=None) -> models.Person:
"""

View File

@@ -1,12 +1,11 @@
"""
Views for displaying or manipulating instances of :class:`Relationship`.
"""
"""Views for displaying or manipulating instances of :class:`Relationship`."""
from django.db import IntegrityError
from django.forms import ValidationError
import typing
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.utils import timezone
from django.views.generic import CreateView, DetailView, FormView
from django.views.generic import CreateView, DetailView, RedirectView
from people import forms, models, permissions
@@ -20,46 +19,18 @@ class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView):
related_person_field = 'source'
class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, FormView):
class RelationshipCreateView(LoginRequiredMixin, RedirectView):
"""View for creating a :class:`Relationship`.
Redirects to a form containing the :class:`RelationshipQuestion`s.
"""
View for creating a :class:`Relationship`.
def get_redirect_url(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Optional[str]:
target = models.Person.objects.get(pk=self.kwargs.get('person_pk'))
relationship, _ = models.Relationship.objects.get_or_create(
source=self.request.user.person, target=target)
Displays / processes a form containing the :class:`RelationshipQuestion`s.
"""
model = models.Relationship
template_name = 'people/relationship/create.html'
form_class = forms.RelationshipForm
def get_person(self) -> models.Person:
return models.Person.objects.get(pk=self.kwargs.get('person_pk'))
def get_test_person(self) -> models.Person:
return self.get_person()
def form_valid(self, form):
try:
self.object = models.Relationship.objects.create(
source=self.get_person(), target=form.cleaned_data['target'])
except IntegrityError:
form.add_error(
None,
ValidationError('This relationship already exists',
code='already-exists'))
return self.form_invalid(form)
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['person'] = self.get_person()
return context
def get_success_url(self):
return reverse('people:relationship.update',
kwargs={'relationship_pk': self.object.pk})
kwargs={'relationship_pk': relationship.pk})
class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):

View File

@@ -4,6 +4,7 @@ dj-database-url==0.5.0
Django==2.2.10
django-appconf==1.0.3
django-bootstrap4==1.1.1
django-bootstrap-datepicker-plus==3.0.5
django-constance==2.6.0
django-countries==5.5
django-dbbackup==3.2.0