diff --git a/breccia_mapper/forms.py b/breccia_mapper/forms.py new file mode 100644 index 0000000..a307c7d --- /dev/null +++ b/breccia_mapper/forms.py @@ -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', + } diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index 395c659..75b92c4 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -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: diff --git a/breccia_mapper/templates/base.html b/breccia_mapper/templates/base.html index faf19a4..ed255c7 100644 --- a/breccia_mapper/templates/base.html +++ b/breccia_mapper/templates/base.html @@ -156,6 +156,15 @@ {% endif %} + {% if request.user.is_authenticated and not request.user.consent_given %} + + {% endif %} + {% block before_content %}{% endblock %}
diff --git a/breccia_mapper/templates/consent.html b/breccia_mapper/templates/consent.html new file mode 100644 index 0000000..6506f2a --- /dev/null +++ b/breccia_mapper/templates/consent.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} + +{% block content %} +

Consent

+ +

+ {{ config.CONSENT_TEXT|linebreaks }} +

+ +
+ {% csrf_token %} + + {% load bootstrap4 %} + {% bootstrap_form form %} + + {% buttons %} + + {% endbuttons %} +
+{% endblock %} diff --git a/breccia_mapper/templates/index.html b/breccia_mapper/templates/index.html index 8f6c0c4..2044dde 100644 --- a/breccia_mapper/templates/index.html +++ b/breccia_mapper/templates/index.html @@ -63,7 +63,7 @@ {% endblock %} {% block content %} -
+

About {{ settings.PROJECT_LONG_NAME }}

diff --git a/breccia_mapper/urls.py b/breccia_mapper/urls.py index 0aade11..4db8da3 100644 --- a/breccia_mapper/urls.py +++ b/breccia_mapper/urls.py @@ -32,6 +32,10 @@ urlpatterns = [ views.IndexView.as_view(), name='index'), + path('consent', + views.ConsentTextView.as_view(), + name='consent'), + path('', include('export.urls')), diff --git a/breccia_mapper/views.py b/breccia_mapper/views.py index ff539f6..44c23f2 100644 --- a/breccia_mapper/views.py +++ b/breccia_mapper/views.py @@ -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 diff --git a/people/forms.py b/people/forms.py index 607214f..2adc9d6 100644 --- a/people/forms.py +++ b/people/forms.py @@ -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') diff --git a/people/migrations/0030_user_consent_given.py b/people/migrations/0030_user_consent_given.py new file mode 100644 index 0000000..e51adfa --- /dev/null +++ b/people/migrations/0030_user_consent_given.py @@ -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), + ), + ] diff --git a/people/migrations/0031_question_allow_free_text.py b/people/migrations/0031_question_allow_free_text.py new file mode 100644 index 0000000..a1dfaff --- /dev/null +++ b/people/migrations/0031_question_allow_free_text.py @@ -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), + ), + ] diff --git a/people/models/__init__.py b/people/models/__init__.py index e8bc10e..f3a1059 100644 --- a/people/models/__init__.py +++ b/people/models/__init__.py @@ -1,2 +1,3 @@ from .person import * +from .question import * from .relationship import * diff --git a/people/models/person.py b/people/models/person.py index 670f0db..914c53d 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -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? diff --git a/people/models/question.py b/people/models/question.py index 7379fb3..7948640 100644 --- a/people/models/question.py +++ b/people/models/question.py @@ -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) diff --git a/people/static/js/location_picker.js b/people/static/js/location_picker.js index fc15d4d..188644b 100644 --- a/people/static/js/location_picker.js +++ b/people/static/js/location_picker.js @@ -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(); } diff --git a/people/static/js/map.js b/people/static/js/map.js index 5945c99..905601e 100644 --- a/people/static/js/map.js +++ b/people/static/js/map.js @@ -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 diff --git a/people/templates/people/person/detail.html b/people/templates/people/person/detail.html deleted file mode 100644 index b071b83..0000000 --- a/people/templates/people/person/detail.html +++ /dev/null @@ -1,163 +0,0 @@ -{% extends 'base.html' %} - -{% block extra_head %} - {{ map_markers|json_script:'map-markers' }} - - {% load staticfiles %} - - - -{% endblock %} - -{% block content %} - - -

{{ person.name }}

- -
- - {% if person.user == request.user or request.user.is_superuser %} - {% if person.user != request.user and request.user.is_superuser %} -
- NB: 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. -
- {% endif %} - - - {% include 'people/person/includes/answer_set.html' %} - - Update - - {% if person.user == request.user %} - Change Password - {% endif %} - {% endif %} - -
- -
- -
- -
-
-

Relationships As Source

- - - - - - - - - - - - {% for relationship in person.relationships_as_source.all %} - - - - - - - {% empty %} - - - - - {% endfor %} - -
Contact Name
{{ relationship.target }} - Profile - - Relationship Detail -
No known relationships
- - New Relationship - -
- -
-

Relationships As Target

- - - - - - - - - - - - {% for relationship in person.relationships_as_target.all %} - - - - - - - {% empty %} - - - - - {% endfor %} - -
Contact Name
{{ relationship.source }} - Profile - - Relationship Detail -
No known relationships
-
-
- - -
- -

Activities

- - - - - - - - - - {% for activity in person.activities.all %} - - - - - - {% empty %} - - - - {% endfor %} - -
Name
{{ activity }} - Details -
No records
-{% endblock %} - -{% block extra_script %} -{% endblock %} diff --git a/people/templates/people/person/detail_full.html b/people/templates/people/person/detail_full.html new file mode 100644 index 0000000..6d288a8 --- /dev/null +++ b/people/templates/people/person/detail_full.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} + +{% block extra_head %} + {{ map_markers|json_script:'map-markers' }} + + {% load staticfiles %} + + + +{% endblock %} + +{% block content %} + + +

{{ person.name }}

+ +
+ + {% if person.user != request.user and request.user.is_superuser %} +
+ NB: 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. +
+ {% endif %} + + + {% include 'people/person/includes/answer_set_full.html' %} + + Update + + {% if person.user == request.user %} + Change Password + {% endif %} + +
+ +
+ +
+ + {% include 'people/person/includes/relationships_full.html' %} + +
+ + {% include 'people/person/includes/activities_full.html' %} + +
+ +{% endblock %} diff --git a/people/templates/people/person/detail_partial.html b/people/templates/people/person/detail_partial.html new file mode 100644 index 0000000..124213d --- /dev/null +++ b/people/templates/people/person/detail_partial.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} + +{% block extra_head %} + {{ map_markers|json_script:'map-markers' }} + + {% load staticfiles %} + + + +{% endblock %} + +{% block content %} + + +

{{ person.name }}

+ + New Relationship + + +
+ + {% include 'people/person/includes/answer_set_partial.html' %} + +
+ +
+ +
+{% endblock %} diff --git a/people/templates/people/person/includes/activities_full.html b/people/templates/people/person/includes/activities_full.html new file mode 100644 index 0000000..0f2b811 --- /dev/null +++ b/people/templates/people/person/includes/activities_full.html @@ -0,0 +1,26 @@ +

Activities

+ + + + + + + + + + {% for activity in person.activities.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
Name
{{ activity }} + Details +
No records
diff --git a/people/templates/people/person/includes/answer_set.html b/people/templates/people/person/includes/answer_set_full.html similarity index 100% rename from people/templates/people/person/includes/answer_set.html rename to people/templates/people/person/includes/answer_set_full.html diff --git a/people/templates/people/person/includes/answer_set_partial.html b/people/templates/people/person/includes/answer_set_partial.html new file mode 100644 index 0000000..ccd6dd8 --- /dev/null +++ b/people/templates/people/person/includes/answer_set_partial.html @@ -0,0 +1,32 @@ + + + + + + + + + + {% if answer_set.country_of_residence %} + + {% endif %} + + {% if answer_set.organisation %} + + {% endif %} + + {% for answer in answer_set.question_answers.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
QuestionAnswer
Country of Residence{{ answer_set.country_of_residence.name }}
Organisation{{ answer_set.organisation }}
{{ answer.question }}{{ answer }}
No records
+ +

Last updated: {{ answer_set.timestamp }}

diff --git a/people/templates/people/person/includes/relationships_full.html b/people/templates/people/person/includes/relationships_full.html new file mode 100644 index 0000000..6f5350d --- /dev/null +++ b/people/templates/people/person/includes/relationships_full.html @@ -0,0 +1,36 @@ +

People I've Answered Questions About

+ + + + + + + + + + + {% for relationship in person.relationships_as_source.all %} + + + + + + {% empty %} + + + + + {% endfor %} + +
Contact Name
{{ relationship.target }} + Profile + Relationship Detail + Update +
No known relationships
+ +New Relationship + diff --git a/people/templates/people/person/list.html b/people/templates/people/person/list.html index c2bd1ec..bca5628 100644 --- a/people/templates/people/person/list.html +++ b/people/templates/people/person/list.html @@ -28,6 +28,19 @@ Profile + + {% if person.pk in existing_relationships %} + Update Relationship + + + {% else %} + New Relationship + + {% endif %} diff --git a/people/views/person.py b/people/views/person.py index 73f2e3b..506bcc8 100644 --- a/people/views/person.py +++ b/people/views/person.py @@ -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: """ diff --git a/people/views/relationship.py b/people/views/relationship.py index 3ea46d9..f11f147 100644 --- a/people/views/relationship.py +++ b/people/views/relationship.py @@ -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): diff --git a/requirements.txt b/requirements.txt index 2877175..3f1e283 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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