From 6dc4bd770f0e46c5b7d58aa64b5260f613168475 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 20 Jan 2021 11:53:08 +0000 Subject: [PATCH 001/101] feat: add consent form for data processing --- breccia_mapper/forms.py | 15 +++++++++++++ breccia_mapper/settings.py | 13 ++++++++---- breccia_mapper/templates/base.html | 9 ++++++++ breccia_mapper/templates/consent.html | 21 +++++++++++++++++++ breccia_mapper/urls.py | 4 ++++ breccia_mapper/views.py | 22 ++++++++++++++++++-- people/migrations/0030_user_consent_given.py | 18 ++++++++++++++++ people/models/person.py | 3 +++ 8 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 breccia_mapper/forms.py create mode 100644 breccia_mapper/templates/consent.html create mode 100644 people/migrations/0030_user_consent_given.py 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..91b5966 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -327,7 +327,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 +337,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 +386,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/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/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/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? From 94b2ee9d707d623e23a94ae0707fe0521bd877f2 Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 22 Jan 2021 17:15:18 +0000 Subject: [PATCH 002/101] feat: allow free form text answers see #52 --- people/forms.py | 27 +++++++++++++++++-- .../0031_question_allow_free_text.py | 23 ++++++++++++++++ people/models/__init__.py | 1 + people/models/question.py | 10 +++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 people/migrations/0031_question_allow_free_text.py diff --git a/people/forms.py b/people/forms.py index 607214f..16e50f2 100644 --- a/people/forms.py +++ b/people/forms.py @@ -46,9 +46,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 +73,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. @@ -104,6 +110,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 +123,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 +153,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 +163,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 +188,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) 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/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) From 9b98ce73f0a757ea46d9ae1d28dd1d89528244dd Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 29 Jan 2021 14:02:33 +0000 Subject: [PATCH 003/101] fix: remove 'as target' relationships from profile Resolves #64 --- people/templates/people/person/detail.html | 106 +++++++-------------- 1 file changed, 33 insertions(+), 73 deletions(-) diff --git a/people/templates/people/person/detail.html b/people/templates/people/person/detail.html index b071b83..a9ee268 100644 --- a/people/templates/people/person/detail.html +++ b/people/templates/people/person/detail.html @@ -50,84 +50,44 @@
-
-
-

Relationships As Source

+

People I've Answered Questions About

- - - - - - - - +
Contact Name
+ + + + + + - - {% for relationship in person.relationships_as_source.all %} - - - - - + + {% for relationship in person.relationships_as_source.all %} + + + + - {% empty %} - - - + {% empty %} + + + - {% endfor %} - -
Contact Name
{{ relationship.target }} - Profile - - Relationship Detail -
{{ relationship.target }} + Profile + Relationship Detail + {% if person.user == request.user or request.user.is_superuser %} + Update + {% endif %} +
No known relationships
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
-
-
+ {% endfor %} + + + New Relationship +
From 4d4d7ab70bf7a80459272928a37150288638bf7f Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 29 Jan 2021 14:35:15 +0000 Subject: [PATCH 004/101] feat: show basic profiles to non-admins Resolves #61 --- breccia_mapper/templates/index.html | 2 +- people/templates/people/person/detail.html | 123 ------------------ .../templates/people/person/detail_full.html | 59 +++++++++ .../people/person/detail_partial.html | 35 +++++ .../person/includes/activities_full.html | 26 ++++ .../{answer_set.html => answer_set_full.html} | 0 .../person/includes/answer_set_partial.html | 32 +++++ .../person/includes/relationships_full.html | 36 +++++ people/views/person.py | 10 +- 9 files changed, 197 insertions(+), 126 deletions(-) delete mode 100644 people/templates/people/person/detail.html create mode 100644 people/templates/people/person/detail_full.html create mode 100644 people/templates/people/person/detail_partial.html create mode 100644 people/templates/people/person/includes/activities_full.html rename people/templates/people/person/includes/{answer_set.html => answer_set_full.html} (100%) create mode 100644 people/templates/people/person/includes/answer_set_partial.html create mode 100644 people/templates/people/person/includes/relationships_full.html 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/people/templates/people/person/detail.html b/people/templates/people/person/detail.html deleted file mode 100644 index a9ee268..0000000 --- a/people/templates/people/person/detail.html +++ /dev/null @@ -1,123 +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 %} - -
- -
- -
- -

People I've Answered Questions About

- - - - - - - - - - - {% for relationship in person.relationships_as_source.all %} - - - - - - {% empty %} - - - - - {% endfor %} - -
Contact Name
{{ relationship.target }} - Profile - Relationship Detail - {% if person.user == request.user or request.user.is_superuser %} - Update - {% endif %} -
No known relationships
- - New Relationship - - -
- -

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..f2f05fa --- /dev/null +++ b/people/templates/people/person/detail_partial.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} + +{% block extra_head %} + {{ map_markers|json_script:'map-markers' }} + + {% load staticfiles %} + + + +{% endblock %} + +{% block content %} + + +

{{ person.name }}

+ +
+ + + {% 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/views/person.py b/people/views/person.py index 73f2e3b..86f394f 100644 --- a/people/views/person.py +++ b/people/views/person.py @@ -37,12 +37,18 @@ class PersonListView(LoginRequiredMixin, ListView): template_name = 'people/person/list.html' -class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView): +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: """ From 4bbe4eac3a235267046e124bc673dd0e8a75c6fa Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 1 Feb 2021 09:47:53 +0000 Subject: [PATCH 005/101] refactor: simplify relationship create workflow --- .../people/person/detail_partial.html | 5 +- people/views/relationship.py | 57 +++++-------------- 2 files changed, 18 insertions(+), 44 deletions(-) diff --git a/people/templates/people/person/detail_partial.html b/people/templates/people/person/detail_partial.html index f2f05fa..124213d 100644 --- a/people/templates/people/person/detail_partial.html +++ b/people/templates/people/person/detail_partial.html @@ -22,8 +22,11 @@

{{ person.name }}

-
+ New Relationship + +
{% include 'people/person/includes/answer_set_partial.html' %} 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): From afae0fd943490303466b8d99d18b1fd803ea9718 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 1 Feb 2021 15:47:51 +0000 Subject: [PATCH 006/101] feat: add relationship buttons on person list Resolves #63 --- people/templates/people/person/list.html | 13 +++++++++++++ people/views/person.py | 16 ++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) 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 86f394f..506bcc8 100644 --- a/people/views/person.py +++ b/people/views/person.py @@ -30,12 +30,20 @@ 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) + + context['existing_relationships'] = set( + self.request.user.person.relationship_targets.values_list( + 'pk', flat=True)) + + return context + class ProfileView(LoginRequiredMixin, DetailView): """ @@ -45,7 +53,7 @@ class ProfileView(LoginRequiredMixin, DetailView): 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: + 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'] From 25a28755c68261f0df5639a0b14078293ac48805 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 1 Feb 2021 16:50:12 +0000 Subject: [PATCH 007/101] feat: use proper datepicker widgets Resolves #58 --- breccia_mapper/settings.py | 1 + people/forms.py | 17 +++-------------- requirements.txt | 1 + 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index 91b5966..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 = [ diff --git a/people/forms.py b/people/forms.py index 16e50f2..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: @@ -100,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, @@ -196,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/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 From f4bd9a0cef052385f73e542d7a870fcfa3df9443 Mon Sep 17 00:00:00 2001 From: James Graham Date: Tue, 2 Feb 2021 09:00:10 +0000 Subject: [PATCH 008/101] fix: move existing marker in map picker widget Resolves #49 --- people/static/js/location_picker.js | 9 ++++----- people/static/js/map.js | 4 ++++ 2 files changed, 8 insertions(+), 5 deletions(-) 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 From 9164ea8a0542e5afb53a8dfcb8d6106934269a1f Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 8 Feb 2021 14:40:37 +0000 Subject: [PATCH 009/101] feat: use django-hijack to switch user accounts Resolves #74 --- breccia_mapper/settings.py | 11 ++++++++++- breccia_mapper/templates/base.html | 7 +++++++ breccia_mapper/urls.py | 3 +++ people/templates/people/person/detail_full.html | 7 +++++++ requirements.txt | 2 ++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index 75b92c4..9e55f4f 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -158,6 +158,8 @@ THIRD_PARTY_APPS = [ 'rest_framework', 'post_office', 'bootstrap_datepicker_plus', + 'hijack', + 'compat', ] FIRST_PARTY_APPS = [ @@ -265,7 +267,7 @@ AUTH_USER_MODEL = 'people.User' LOGIN_URL = reverse_lazy('login') -LOGIN_REDIRECT_URL = reverse_lazy('index') +LOGIN_REDIRECT_URL = reverse_lazy('people:person.profile') # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ @@ -353,6 +355,13 @@ CONSTANCE_CONFIG_FIELDSETS = { CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' + +# Django Hijack settings +# See https://django-hijack.readthedocs.io/en/stable/ + +HIJACK_USE_BOOTSTRAP = True + + # Bootstrap settings # See https://django-bootstrap4.readthedocs.io/en/latest/settings.html diff --git a/breccia_mapper/templates/base.html b/breccia_mapper/templates/base.html index ed255c7..182f665 100644 --- a/breccia_mapper/templates/base.html +++ b/breccia_mapper/templates/base.html @@ -27,6 +27,10 @@ {% load staticfiles %} + + {% if 'javascript_in_head'|bootstrap_setting %} {% if 'include_jquery'|bootstrap_setting %} {# jQuery JavaScript if it is in head #} @@ -144,6 +148,9 @@
{% endif %} + {% load hijack_tags %} + {% hijack_notification %} + {% if request.user.is_authenticated and not request.user.has_person %} + + {% if relationship %} + + {% endif %}

diff --git a/people/templates/people/person/detail_partial.html b/people/templates/people/person/detail_partial.html index ec9c04c..ff0b785 100644 --- a/people/templates/people/person/detail_partial.html +++ b/people/templates/people/person/detail_partial.html @@ -37,6 +37,14 @@ {% endif %}
+ + {% if relationship %} + + {% endif %}
diff --git a/people/templates/people/person/includes/relationships_full.html b/people/templates/people/person/includes/relationships_full.html index 336e177..cc67d8b 100644 --- a/people/templates/people/person/includes/relationships_full.html +++ b/people/templates/people/person/includes/relationships_full.html @@ -13,7 +13,15 @@ {% for relationship in person.relationships_as_source.all %} - {{ relationship.target }} + + {% if relationship.is_current %} + {{ relationship.target }} + {% else %} + + {{ relationship.target }} + + {% endif %} + Profile diff --git a/people/urls.py b/people/urls.py index dcd49b6..e38621b 100644 --- a/people/urls.py +++ b/people/urls.py @@ -60,6 +60,10 @@ urlpatterns = [ views.relationship.RelationshipUpdateView.as_view(), name='relationship.update'), + path('relationships//end', + views.relationship.RelationshipEndView.as_view(), + name='relationship.end'), + ################################ # OrganisationRelationship views path('organisations//relationships/create', diff --git a/people/views/person.py b/people/views/person.py index 498abf9..8735f73 100644 --- a/people/views/person.py +++ b/people/views/person.py @@ -44,8 +44,10 @@ class PersonListView(LoginRequiredMixin, ListView): existing_relationships = set() try: existing_relationships = set( - self.request.user.person.relationship_targets.values_list( - 'pk', flat=True)) + self.request.user.person.relationships_as_source.filter( + answer_sets__replaced_timestamp__isnull=True + ).values_list('target_id', flat=True) + ) except ObjectDoesNotExist: # No linked Person yet @@ -132,9 +134,12 @@ class ProfileView(LoginRequiredMixin, DetailView): context['relationship'] = None try: - context['relationship'] = models.Relationship.objects.get( + relationship = models.Relationship.objects.get( source=self.request.user.person, target=self.object) + if relationship.is_current: + context['relationship'] = relationship + except models.Relationship.DoesNotExist: pass diff --git a/people/views/relationship.py b/people/views/relationship.py index 692b9c4..3e638ec 100644 --- a/people/views/relationship.py +++ b/people/views/relationship.py @@ -6,6 +6,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse from django.utils import timezone from django.views.generic import DetailView, RedirectView, UpdateView +from django.views.generic.detail import SingleObjectMixin from people import forms, models, permissions @@ -91,6 +92,30 @@ class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView): return self.object.get_absolute_url() +class RelationshipEndView(permissions.UserIsLinkedPersonMixin, + SingleObjectMixin, RedirectView): + """View for marking a relationship as ended. + + Sets `replaced_timestamp` on all answer sets where this is currently null. + """ + model = models.Relationship + + def get_test_person(self) -> models.Person: + """Get the person instance which should be used for access control checks.""" + return self.get_object().source + + def get_redirect_url(self, *args, **kwargs): + """Mark any previous answer sets as replaced.""" + now_date = timezone.now().date() + relationship = self.get_object() + + relationship.answer_sets.filter( + replaced_timestamp__isnull=True).update( + replaced_timestamp=now_date) + + return relationship.target.get_absolute_url() + + class OrganisationRelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView): """View displaying details of an :class:`OrganisationRelationship`.""" From 74fffb0cac6426b4f0342deade7744f8a4b6003e Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 19 Mar 2021 12:41:36 +0000 Subject: [PATCH 060/101] refactor: views to handled ended relationships Add ending of organisation relationships --- people/models/relationship.py | 22 ++++-- .../organisation-relationship/detail.html | 68 +++++++++++++------ .../templates/people/organisation/detail.html | 8 +++ .../person/includes/relationships_full.html | 10 ++- .../templates/people/relationship/detail.html | 68 +++++++++++++------ people/urls.py | 4 ++ people/views/organisation.py | 24 +++++-- people/views/relationship.py | 8 +++ 8 files changed, 156 insertions(+), 56 deletions(-) diff --git a/people/models/relationship.py b/people/models/relationship.py index 5123764..ea68d0e 100644 --- a/people/models/relationship.py +++ b/people/models/relationship.py @@ -61,9 +61,14 @@ class Relationship(models.Model): @property def current_answers(self) -> typing.Optional['RelationshipAnswerSet']: - answer_set = self.answer_sets.latest() - if answer_set.is_current: - return answer_set + try: + answer_set = self.answer_sets.latest() + if answer_set.is_current: + return answer_set + + except RelationshipAnswerSet.DoesNotExist: + # No AnswerSet created yet + pass return None @@ -145,9 +150,14 @@ class OrganisationRelationship(models.Model): @property def current_answers(self) -> typing.Optional['OrganisationRelationshipAnswerSet']: - answer_set = self.answer_sets.latest() - if answer_set.is_current: - return answer_set + try: + answer_set = self.answer_sets.latest() + if answer_set.is_current: + return answer_set + + except OrganisationRelationshipAnswerSet.DoesNotExist: + # No AnswerSet created yet + pass return None diff --git a/people/templates/people/organisation-relationship/detail.html b/people/templates/people/organisation-relationship/detail.html index d4ea1ec..0d10870 100644 --- a/people/templates/people/organisation-relationship/detail.html +++ b/people/templates/people/organisation-relationship/detail.html @@ -17,6 +17,24 @@
+
+ + + {% if relationship.is_current %} + + {% endif %} +
+ +
+

Source

@@ -39,34 +57,40 @@
- Update - {% with relationship.current_answers as answer_set %} - - - - - - - + {% if answer_set is None %} +
+ This relationship has ended. You can start it again by updating it. +
- - {% for answer in answer_set.question_answers.all %} + {% else %} +
QuestionAnswer
+ - - + + + - {% empty %} - - - - {% endfor %} - -
{{ answer.question }}{{ answer }}QuestionAnswer
No records
+ + {% for answer in answer_set.question_answers.all %} + + {{ answer.question }} + {{ answer }} + - Last updated: {{ answer_set.timestamp }} + {% empty %} + + No records + + {% endfor %} + + + +

+ Last updated: {{ answer_set.timestamp }} +

+ {% endif %} {% endwith %} {% endblock %} diff --git a/people/templates/people/organisation/detail.html b/people/templates/people/organisation/detail.html index 807f769..1dd9065 100644 --- a/people/templates/people/organisation/detail.html +++ b/people/templates/people/organisation/detail.html @@ -42,6 +42,14 @@ {% endif %}
+ + {% if relationship %} + + {% endif %}

diff --git a/people/templates/people/person/includes/relationships_full.html b/people/templates/people/person/includes/relationships_full.html index cc67d8b..e1a1aa3 100644 --- a/people/templates/people/person/includes/relationships_full.html +++ b/people/templates/people/person/includes/relationships_full.html @@ -56,7 +56,15 @@ {% for relationship in person.organisation_relationships_as_source.all %} - {{ relationship.target }} + + {% if relationship.is_current %} + {{ relationship.target }} + {% else %} + + {{ relationship.target }} + + {% endif %} + Profile diff --git a/people/templates/people/relationship/detail.html b/people/templates/people/relationship/detail.html index 71db9d0..70ff1a0 100644 --- a/people/templates/people/relationship/detail.html +++ b/people/templates/people/relationship/detail.html @@ -17,6 +17,24 @@
+
+ + + {% if relationship.is_current %} + + {% endif %} +
+ +
+

Source

@@ -45,34 +63,40 @@
- Update - {% with relationship.current_answers as answer_set %} - - - - - - - + {% if answer_set is None %} +
+ This relationship has ended. You can start it again by updating it. +
- - {% for answer in answer_set.question_answers.all %} + {% else %} +
QuestionAnswer
+ - - + + + - {% empty %} - - - - {% endfor %} - -
{{ answer.question }}{{ answer }}QuestionAnswer
No records
+ + {% for answer in answer_set.question_answers.all %} + + {{ answer.question }} + {{ answer }} + - Last updated: {{ answer_set.timestamp }} + {% empty %} + + No records + + {% endfor %} + + + +

+ Last updated: {{ answer_set.timestamp }} +

+ {% endif %} {% endwith %} {% endblock %} diff --git a/people/urls.py b/people/urls.py index e38621b..25e526f 100644 --- a/people/urls.py +++ b/people/urls.py @@ -78,6 +78,10 @@ urlpatterns = [ views.relationship.OrganisationRelationshipUpdateView.as_view(), name='organisation.relationship.update'), + path('organisation-relationships//end', + views.relationship.OrganisationRelationshipEndView.as_view(), + name='organisation.relationship.end'), + ############ # Data views path('map', diff --git a/people/views/organisation.py b/people/views/organisation.py index db1b46a..9a2df8e 100644 --- a/people/views/organisation.py +++ b/people/views/organisation.py @@ -2,6 +2,7 @@ import typing from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from django.views.generic import CreateView, DetailView, ListView, UpdateView @@ -93,9 +94,19 @@ class OrganisationListView(LoginRequiredMixin, ListView): context['orgs_by_country'] = self.sort_organisation_countries( orgs_by_country) - context['existing_relationships'] = set( - self.request.user.person.organisation_relationship_targets. - values_list('pk', flat=True)) + existing_relationships = set() + try: + existing_relationships = set( + self.request.user.person.organisation_relationships_as_source.filter( + answer_sets__replaced_timestamp__isnull=True + ).values_list('target_id', flat=True) + ) + + except ObjectDoesNotExist: + # No linked Person yet + pass + + context['existing_relationships'] = existing_relationships return context @@ -144,8 +155,11 @@ class OrganisationDetailView(LoginRequiredMixin, DetailView): context['relationship'] = None try: - context['relationship'] = models.OrganisationRelationship.objects.get( - source=self.request.user.person, target=self.object) # yapf: disable + relationship = models.OrganisationRelationship.objects.get( + source=self.request.user.person, target=self.object) + + if relationship.is_current: + context['relationship'] = relationship except models.OrganisationRelationship.DoesNotExist: pass diff --git a/people/views/relationship.py b/people/views/relationship.py index 3e638ec..fb6a5ee 100644 --- a/people/views/relationship.py +++ b/people/views/relationship.py @@ -116,6 +116,14 @@ class RelationshipEndView(permissions.UserIsLinkedPersonMixin, return relationship.target.get_absolute_url() +class OrganisationRelationshipEndView(RelationshipEndView): + """View for marking an organisation relationship as ended. + + Sets `replaced_timestamp` on all answer sets where this is currently null. + """ + model = models.OrganisationRelationship + + class OrganisationRelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView): """View displaying details of an :class:`OrganisationRelationship`.""" From 81598ea624afd2881a18245cc3d786f3438c7f43 Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 19 Mar 2021 15:36:09 +0000 Subject: [PATCH 061/101] refactor: allow admin config of static questions Text and visibility set in admin panel are now respected everywhere --- people/forms.py | 5 +- .../0051_refactor_hardcoded_questions.py | 81 +++++++++++++++++++ people/models/organisation.py | 3 + people/models/person.py | 2 + people/models/question.py | 59 +++++++++++--- people/models/relationship.py | 8 +- .../{person => }/includes/answer_set.html | 0 .../organisation-relationship/detail.html | 27 +------ .../templates/people/organisation/detail.html | 44 +--------- .../templates/people/person/detail_full.html | 2 +- .../people/person/detail_partial.html | 2 +- .../templates/people/relationship/detail.html | 27 +------ people/views/organisation.py | 37 +++------ people/views/person.py | 36 ++------- people/views/relationship.py | 20 ++++- 15 files changed, 184 insertions(+), 169 deletions(-) create mode 100644 people/migrations/0051_refactor_hardcoded_questions.py rename people/templates/people/{person => }/includes/answer_set.html (100%) diff --git a/people/forms.py b/people/forms.py index 0423431..f315a3f 100644 --- a/people/forms.py +++ b/people/forms.py @@ -52,8 +52,9 @@ class DynamicAnswerSetBase(forms.Form): continue # Placeholder question for sorting hardcoded questions - if question.is_hardcoded and (question.text in self.Meta.fields): - field_order.append(question.text) + if question.is_hardcoded and (question.hardcoded_field + in self.Meta.fields): + field_order.append(question.hardcoded_field) continue field_class = self.field_class diff --git a/people/migrations/0051_refactor_hardcoded_questions.py b/people/migrations/0051_refactor_hardcoded_questions.py new file mode 100644 index 0000000..7ccafb1 --- /dev/null +++ b/people/migrations/0051_refactor_hardcoded_questions.py @@ -0,0 +1,81 @@ +# Generated by Django 2.2.10 on 2021-03-19 14:37 + +from django.db import migrations, models +from django.db.models import F + + +def forward(apps, schema_editor): + """Move `text` field to `hardcoded_field`.""" + models = [ + 'OrganisationQuestion', + 'OrganisationRelationshipQuestion', + 'PersonQuestion', + 'RelationshipQuestion', + ] + models = map(lambda m: apps.get_model('people', m), models) + + for model in models: + model.objects.filter(is_hardcoded=True).update( + hardcoded_field=F('text')) + + +def backward(apps, schema_editor): + """Move `hardcoded_field` to `text` field.""" + models = [ + 'OrganisationQuestion', + 'OrganisationRelationshipQuestion', + 'PersonQuestion', + 'RelationshipQuestion', + ] + models = map(lambda m: apps.get_model('people', m), models) + + for model in models: + model.objects.exclude(hardcoded_field='').update( + text=F('hardcoded_field'), is_hardcoded=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0050_relationship_remove_timestamps'), + ] + + operations = [ + migrations.AddField( + model_name='organisationquestion', + name='hardcoded_field', + field=models.CharField(blank=True, help_text='Which hardcoded field does this question represent?', max_length=255), + ), + migrations.AddField( + model_name='organisationrelationshipquestion', + name='hardcoded_field', + field=models.CharField(blank=True, help_text='Which hardcoded field does this question represent?', max_length=255), + ), + migrations.AddField( + model_name='personquestion', + name='hardcoded_field', + field=models.CharField(blank=True, help_text='Which hardcoded field does this question represent?', max_length=255), + ), + migrations.AddField( + model_name='relationshipquestion', + name='hardcoded_field', + field=models.CharField(blank=True, help_text='Which hardcoded field does this question represent?', max_length=255), + ), + migrations.RunPython(forward, backward), + migrations.RemoveField( + model_name='organisationquestion', + name='is_hardcoded', + ), + migrations.RemoveField( + model_name='organisationrelationshipquestion', + name='is_hardcoded', + ), + migrations.RemoveField( + model_name='personquestion', + name='is_hardcoded', + ), + migrations.RemoveField( + model_name='relationshipquestion', + name='is_hardcoded', + ), + ] diff --git a/people/models/organisation.py b/people/models/organisation.py index d43fc1b..3f9c76c 100644 --- a/people/models/organisation.py +++ b/people/models/organisation.py @@ -61,6 +61,9 @@ class Organisation(models.Model): class OrganisationAnswerSet(AnswerSet): """The answers to the organisation questions at a particular point in time.""" + + question_model = OrganisationQuestion + #: Organisation to which this answer set belongs organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE, diff --git a/people/models/person.py b/people/models/person.py index b1bf9c7..fab2333 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -132,6 +132,8 @@ class Person(models.Model): class PersonAnswerSet(AnswerSet): """The answers to the person questions at a particular point in time.""" + question_model = PersonQuestion + #: Person to which this answer set belongs person = models.ForeignKey(Person, on_delete=models.CASCADE, diff --git a/people/models/question.py b/people/models/question.py index 4a5fe1a..e7a50d6 100644 --- a/people/models/question.py +++ b/people/models/question.py @@ -1,4 +1,5 @@ """Base models for configurable questions and response sets.""" +import abc import typing from django.db import models @@ -53,12 +54,16 @@ class Question(models.Model): blank=False, null=False) - #: Is this question hardcoded in an AnswerSet? - is_hardcoded = models.BooleanField( - help_text='Only the order field has any effect for a hardcoded question.', - default=False, - blank=False, - null=False) + @property + def is_hardcoded(self) -> bool: + return bool(self.hardcoded_field) + + hardcoded_field = models.CharField( + help_text='Which hardcoded field does this question represent?', + max_length=255, + blank=True, + null=False + ) #: Should people be able to add their own answers? allow_free_text = models.BooleanField(default=False, @@ -126,6 +131,12 @@ class AnswerSet(models.Model): ] get_latest_by = 'timestamp' + @classmethod + @abc.abstractproperty + def question_model(cls) -> models.Model: + """Model representing questions to be answered in this AnswerSet.""" + raise NotImplementedError + #: Entity to which this answer set belongs #: This foreign key must be added to each concrete subclass # person = models.ForeignKey(Person, @@ -134,9 +145,14 @@ class AnswerSet(models.Model): # blank=False, # null=False) - #: Answers to :class:`Question`s - #: This many to many relation must be added to each concrete subclass - # question_answers = models.ManyToManyField(QuestionChoice) + @abc.abstractproperty + def question_answers(self) -> models.QuerySet: + """Answers to :class:`Question`s. + + This many to many relation must be added to each concrete subclass + question_answers = models.ManyToManyField(QuestionChoice) + """ + raise NotImplementedError #: When were these answers collected? timestamp = models.DateTimeField(auto_now_add=True, editable=False) @@ -150,6 +166,31 @@ class AnswerSet(models.Model): def is_current(self) -> bool: return self.replaced_timestamp is None + def build_question_answers(self, show_all: bool = False) -> typing.Dict[str, str]: + """Collect answers to dynamic questions and join with commas.""" + questions = self.question_model.objects.all() + if not show_all: + questions = questions.filter(answer_is_public=True) + + question_answers = {} + try: + for question in questions: + if question.hardcoded_field: + question_answers[question.text] = getattr( + self, question.hardcoded_field) + + else: + answers = self.question_answers.filter( + question=question) + question_answers[question.text] = ', '.join( + map(str, answers)) + + except AttributeError: + # No AnswerSet yet + pass + + return question_answers + def as_dict(self, answers: typing.Optional[typing.Dict[str, typing.Any]] = None): """Get the answers from this set as a dictionary for use in Form.initial.""" if answers is None: diff --git a/people/models/relationship.py b/people/models/relationship.py index ea68d0e..3fd703d 100644 --- a/people/models/relationship.py +++ b/people/models/relationship.py @@ -36,9 +36,7 @@ class RelationshipQuestionChoice(QuestionChoice): class Relationship(models.Model): - """ - A directional relationship between two people allowing linked questions. - """ + """A directional relationship between two people allowing linked questions.""" class Meta: constraints = [ models.UniqueConstraint(fields=['source', 'target'], @@ -95,6 +93,8 @@ class Relationship(models.Model): class RelationshipAnswerSet(AnswerSet): """The answers to the relationship questions at a particular point in time.""" + question_model = RelationshipQuestion + #: Relationship to which this answer set belongs relationship = models.ForeignKey(Relationship, on_delete=models.CASCADE, @@ -176,6 +176,8 @@ class OrganisationRelationship(models.Model): class OrganisationRelationshipAnswerSet(AnswerSet): """The answers to the organisation relationship questions at a particular point in time.""" + question_model = OrganisationRelationshipQuestion + #: OrganisationRelationship to which this answer set belongs relationship = models.ForeignKey(OrganisationRelationship, on_delete=models.CASCADE, diff --git a/people/templates/people/person/includes/answer_set.html b/people/templates/people/includes/answer_set.html similarity index 100% rename from people/templates/people/person/includes/answer_set.html rename to people/templates/people/includes/answer_set.html diff --git a/people/templates/people/organisation-relationship/detail.html b/people/templates/people/organisation-relationship/detail.html index 0d10870..7fb3374 100644 --- a/people/templates/people/organisation-relationship/detail.html +++ b/people/templates/people/organisation-relationship/detail.html @@ -64,32 +64,7 @@
{% else %} - - - - - - - - - - {% for answer in answer_set.question_answers.all %} - - - - - - {% empty %} - - - - {% endfor %} - -
QuestionAnswer
{{ answer.question }}{{ answer }}
No records
- -

- Last updated: {{ answer_set.timestamp }} -

+ {% include 'people/includes/answer_set.html' %} {% endif %} {% endwith %} diff --git a/people/templates/people/organisation/detail.html b/people/templates/people/organisation/detail.html index 1dd9065..599ec56 100644 --- a/people/templates/people/organisation/detail.html +++ b/people/templates/people/organisation/detail.html @@ -54,49 +54,7 @@
- - - - - - - - - - {% if answer_set.website %} - - {% endif %} - - {% if answer_set.countries %} - - {% endif %} - - {% if answer_set.hq_country %} - - {% endif %} - - {% for question, answers in question_answers.items %} - - - - - {% endfor %} - - {% if answer_set is None %} - - - - {% endif %} - -
QuestionAnswer
Website - {{ answer_set.website }} -
Countries - {% for country in answer_set.countries %} - {{ country.name }}{% if not forloop.last %},{% endif %} - {% endfor %} -
HQ Country{{ answer_set.hq_country.name }}
{{ question }}{{ answers }}
No answers
- -

Last updated: {{ answer_set.timestamp }}

+ {% include 'people/includes/answer_set.html' %}
diff --git a/people/templates/people/person/detail_full.html b/people/templates/people/person/detail_full.html index 494e610..f65f92a 100644 --- a/people/templates/people/person/detail_full.html +++ b/people/templates/people/person/detail_full.html @@ -57,7 +57,7 @@ {% endif %} - {% include 'people/person/includes/answer_set.html' %} + {% include 'people/includes/answer_set.html' %} Update diff --git a/people/templates/people/person/detail_partial.html b/people/templates/people/person/detail_partial.html index ff0b785..3d5e146 100644 --- a/people/templates/people/person/detail_partial.html +++ b/people/templates/people/person/detail_partial.html @@ -49,7 +49,7 @@
- {% include 'people/person/includes/answer_set.html' %} + {% include 'people/includes/answer_set.html' %}
diff --git a/people/templates/people/relationship/detail.html b/people/templates/people/relationship/detail.html index 70ff1a0..2d4c0ee 100644 --- a/people/templates/people/relationship/detail.html +++ b/people/templates/people/relationship/detail.html @@ -70,32 +70,7 @@
{% else %} - - - - - - - - - - {% for answer in answer_set.question_answers.all %} - - - - - - {% empty %} - - - - {% endfor %} - -
QuestionAnswer
{{ answer.question }}{{ answer }}
No records
- -

- Last updated: {{ answer_set.timestamp }} -

+ {% include 'people/includes/answer_set.html' %} {% endif %} {% endwith %} diff --git a/people/views/organisation.py b/people/views/organisation.py index 9a2df8e..b678b32 100644 --- a/people/views/organisation.py +++ b/people/views/organisation.py @@ -117,42 +117,25 @@ class OrganisationDetailView(LoginRequiredMixin, DetailView): context_object_name = 'organisation' template_name = 'people/organisation/detail.html' - def build_question_answers( - self, - answer_set: models.OrganisationAnswerSet) -> typing.Dict[str, str]: - """Collect answers to dynamic questions and join with commas.""" - show_all = self.request.user.is_superuser - questions = models.OrganisationQuestion.objects.filter( - is_hardcoded=False) - if not show_all: - questions = questions.filter(answer_is_public=True) - - question_answers = {} - try: - for question in questions: - answers = answer_set.question_answers.filter(question=question) - question_answers[str(question)] = ', '.join(map(str, answers)) - - except AttributeError: - # No AnswerSet yet - pass - - return question_answers - def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: """Add map marker to context.""" context = super().get_context_data(**kwargs) - answerset = self.object.current_answers - context['answer_set'] = answerset - context['question_answers'] = self.build_question_answers(answerset) + answer_set = self.object.current_answers + context['answer_set'] = answer_set context['map_markers'] = [{ 'name': self.object.name, - 'lat': getattr(answerset, 'latitude', None), - 'lng': getattr(answerset, 'longitude', None), + 'lat': getattr(answer_set, 'latitude', None), + 'lng': getattr(answer_set, 'longitude', None), }] + context['question_answers'] = {} + if answer_set is not None: + show_all = self.request.user.is_superuser + context['question_answers'] = answer_set.build_question_answers( + show_all) + context['relationship'] = None try: relationship = models.OrganisationRelationship.objects.get( diff --git a/people/views/person.py b/people/views/person.py index 8735f73..5271319 100644 --- a/people/views/person.py +++ b/people/views/person.py @@ -94,34 +94,6 @@ class ProfileView(LoginRequiredMixin, DetailView): # pk was not provided in URL return self.request.user.person - def build_question_answers( - self, answer_set: models.PersonAnswerSet) -> typing.Dict[str, str]: - """Collect answers to dynamic questions and join with commas.""" - show_all = ((self.object.user == self.request.user) - or self.request.user.is_superuser) - questions = models.PersonQuestion.objects.all() - if not show_all: - questions = questions.filter(answer_is_public=True) - - question_answers = {} - try: - for question in questions: - if question.is_hardcoded: - question_answers[str(question)] = getattr( - answer_set, question.text) - - else: - answers = answer_set.question_answers.filter( - question=question) - question_answers[str(question)] = ', '.join( - map(str, answers)) - - except AttributeError: - # No AnswerSet yet - pass - - return question_answers - def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: """Add current :class:`PersonAnswerSet` to context.""" @@ -129,9 +101,15 @@ class ProfileView(LoginRequiredMixin, DetailView): answer_set = self.object.current_answers context['answer_set'] = answer_set - context['question_answers'] = self.build_question_answers(answer_set) context['map_markers'] = [get_map_data(self.object)] + context['question_answers'] = {} + if answer_set is not None: + show_all = ((self.object.user == self.request.user) + or self.request.user.is_superuser) + context['question_answers'] = answer_set.build_question_answers( + show_all) + context['relationship'] = None try: relationship = models.Relationship.objects.get( diff --git a/people/views/relationship.py b/people/views/relationship.py index fb6a5ee..66589c6 100644 --- a/people/views/relationship.py +++ b/people/views/relationship.py @@ -19,6 +19,23 @@ class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView): template_name = 'people/relationship/detail.html' related_person_field = 'source' + def get_context_data(self, + **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + """Add current :class:`RelationshipAnswerSet` to context.""" + context = super().get_context_data(**kwargs) + + answer_set = self.object.current_answers + context['answer_set'] = answer_set + + context['question_answers'] = {} + if answer_set is not None: + show_all = ((self.object.source == self.request.user) + or self.request.user.is_superuser) + context['question_answers'] = answer_set.build_question_answers( + show_all) + + return context + class RelationshipCreateView(LoginRequiredMixin, RedirectView): """View for creating a :class:`Relationship`. @@ -124,8 +141,7 @@ class OrganisationRelationshipEndView(RelationshipEndView): model = models.OrganisationRelationship -class OrganisationRelationshipDetailView(permissions.UserIsLinkedPersonMixin, - DetailView): +class OrganisationRelationshipDetailView(RelationshipDetailView): """View displaying details of an :class:`OrganisationRelationship`.""" model = models.OrganisationRelationship template_name = 'people/organisation-relationship/detail.html' From 27b16c2212f3e4275523eb1ffddc59835828a0c4 Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 19 Mar 2021 15:57:32 +0000 Subject: [PATCH 062/101] feat: allow multiple nationalities Resolves #108 --- people/forms.py | 2 +- .../0052_allow_multiple_nationalities.py | 20 +++++++++++++++++++ people/models/person.py | 2 +- people/models/question.py | 7 +++++-- 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 people/migrations/0052_allow_multiple_nationalities.py diff --git a/people/forms.py b/people/forms.py index f315a3f..ae0a0e9 100644 --- a/people/forms.py +++ b/people/forms.py @@ -171,7 +171,7 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase): 'longitude', ] widgets = { - 'nationality': Select2Widget(), + 'nationality': Select2MultipleWidget(), 'country_of_residence': Select2Widget(), 'organisation_started_date': DatePickerInput(format='%Y-%m-%d'), 'project_started_date': DatePickerInput(format='%Y-%m-%d'), diff --git a/people/migrations/0052_allow_multiple_nationalities.py b/people/migrations/0052_allow_multiple_nationalities.py new file mode 100644 index 0000000..e242bd5 --- /dev/null +++ b/people/migrations/0052_allow_multiple_nationalities.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.10 on 2021-03-19 15:39 + +from django.db import migrations +import django_countries.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0051_refactor_hardcoded_questions'), + ] + + operations = [ + migrations.AlterField( + model_name='personanswerset', + name='nationality', + field=django_countries.fields.CountryField(blank=True, default=[], max_length=746, multiple=True), + preserve_default=False, + ), + ] diff --git a/people/models/person.py b/people/models/person.py index fab2333..08c76a8 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -147,7 +147,7 @@ class PersonAnswerSet(AnswerSet): ################## # Static questions - nationality = CountryField(blank=True, null=True) + nationality = CountryField(multiple=True, blank=True) country_of_residence = CountryField(blank=True, null=True) diff --git a/people/models/question.py b/people/models/question.py index e7a50d6..b143db3 100644 --- a/people/models/question.py +++ b/people/models/question.py @@ -176,8 +176,11 @@ class AnswerSet(models.Model): try: for question in questions: if question.hardcoded_field: - question_answers[question.text] = getattr( - self, question.hardcoded_field) + answer = getattr(self, question.hardcoded_field) + if isinstance(answer, list): + answer = ', '.join(map(str, answer)) + + question_answers[question.text] = answer else: answers = self.question_answers.filter( From 9f067249de749455d38f68e59fc033f3b4b574fb Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 21 Mar 2021 11:59:29 +0000 Subject: [PATCH 063/101] fix: show free text for 'other' in multi-select Resolves #104 Should resolve #101 See #75 --- people/templates/people/person/update.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/people/templates/people/person/update.html b/people/templates/people/person/update.html index 0ed58a9..1049a8b 100644 --- a/people/templates/people/person/update.html +++ b/people/templates/people/person/update.html @@ -62,7 +62,14 @@ } function setFreeTextState(select, freeTextField) { - if (select.selectedOptions[0].text.toLowerCase().startsWith('other')) { + var other_selected = false; + for (var i = 0; i < select.selectedOptions.length; i++) { + if (select.selectedOptions[i].text.toLowerCase().startsWith('other')) { + other_selected = true; + } + } + + if (other_selected) { freeTextField.show(); } else { freeTextField.hide(); From 2664ff6e8375b8b1139a4add72d7692b0842dbcc Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 26 Mar 2021 11:36:19 +0000 Subject: [PATCH 064/101] refactor: allow question prefix on dynamic forms Setup to support multiple network filters See #54 --- people/forms.py | 29 ++++++++++++++++++++++++----- people/views/network.py | 6 +++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/people/forms.py b/people/forms.py index ae0a0e9..61cf2ee 100644 --- a/people/forms.py +++ b/people/forms.py @@ -41,6 +41,7 @@ class DynamicAnswerSetBase(forms.Form): field_widget: typing.Optional[typing.Type[forms.Widget]] = None question_model: typing.Type[models.Question] answer_model: typing.Type[models.QuestionChoice] + question_prefix: str = '' def __init__(self, *args, as_filters: bool = False, **kwargs): super().__init__(*args, **kwargs) @@ -64,7 +65,7 @@ class DynamicAnswerSetBase(forms.Form): field_class = forms.ModelMultipleChoiceField field_widget = Select2MultipleWidget - field_name = f'question_{question.pk}' + field_name = f'{self.question_prefix}question_{question.pk}' # If being used as a filter - do we have alternate text? field_label = question.text @@ -310,15 +311,33 @@ class OrganisationRelationshipAnswerSetForm(forms.ModelForm, return self.instance -class NetworkFilterForm(DynamicAnswerSetBase): - """ - Form to provide filtering on the network view. - """ +class NetworkRelationshipFilterForm(DynamicAnswerSetBase): + """Form to provide filtering on the network view.""" field_class = forms.ModelMultipleChoiceField field_widget = Select2MultipleWidget field_required = False question_model = models.RelationshipQuestion answer_model = models.RelationshipQuestionChoice + question_prefix = 'relationship_' + + def __init__(self, *args, **kwargs): + super().__init__(*args, as_filters=True, **kwargs) + + # Add date field to select relationships at a particular point in time + self.fields['date'] = forms.DateField( + required=False, + widget=DatePickerInput(format='%Y-%m-%d'), + help_text='Show relationships as they were on this date') + + +class NetworkPersonFilterForm(DynamicAnswerSetBase): + """Form to provide filtering on the network view.""" + field_class = forms.ModelMultipleChoiceField + field_widget = Select2MultipleWidget + field_required = False + question_model = models.PersonQuestion + answer_model = models.PersonQuestionChoice + question_prefix = 'person_' def __init__(self, *args, **kwargs): super().__init__(*args, as_filters=True, **kwargs) diff --git a/people/views/network.py b/people/views/network.py index d2a483e..2cd0aec 100644 --- a/people/views/network.py +++ b/people/views/network.py @@ -20,7 +20,7 @@ class NetworkView(LoginRequiredMixin, FormView): View to display relationship network. """ template_name = 'people/network.html' - form_class = forms.NetworkFilterForm + form_class = forms.NetworkRelationshipFilterForm def get_form_kwargs(self): """ @@ -42,7 +42,7 @@ class NetworkView(LoginRequiredMixin, FormView): Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context. """ context = super().get_context_data(**kwargs) - form: forms.NetworkFilterForm = context['form'] + form: forms.NetworkRelationshipFilterForm = context['form'] if not form.is_valid(): return context @@ -65,7 +65,7 @@ class NetworkView(LoginRequiredMixin, FormView): # Filter answers to relationship questions for field, values in form.cleaned_data.items(): - if field.startswith('question_') and values: + if field.startswith(f'{form.question_prefix}question_') and values: relationship_answerset_set = relationship_answerset_set.filter( question_answers__in=values) From 78056c7752f910a1748b4512efeffcc2ee5733b4 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 19 Apr 2021 09:48:39 +0100 Subject: [PATCH 065/101] fix: add back name to person table export --- export/serializers/people.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/export/serializers/people.py b/export/serializers/people.py index 8791775..ea00ef2 100644 --- a/export/serializers/people.py +++ b/export/serializers/people.py @@ -10,8 +10,7 @@ class PersonSerializer(base.FlattenedModelSerializer): model = models.Person fields = [ 'id', - # Name is excluded from exports - # See https://github.com/Southampton-RSG/breccia-mapper/issues/35 + 'name', ] From 9f5253834f47914fca93f06dbeff85bb6b377a4e Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 19 Apr 2021 12:09:38 +0100 Subject: [PATCH 066/101] fix: hide 'become user' button on own profile Resolves #89 --- people/templates/people/person/detail_full.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/people/templates/people/person/detail_full.html b/people/templates/people/person/detail_full.html index f65f92a..bbc7945 100644 --- a/people/templates/people/person/detail_full.html +++ b/people/templates/people/person/detail_full.html @@ -68,7 +68,7 @@ href="{% url 'password_change' %}?next={{ person.get_absolute_url }}">Change Password {% endif %} - {% if request.user.is_superuser and person.user %} + {% if request.user.is_superuser and person.user and person.user != request.user %}
{% csrf_token %} From 78e35b70e088333a0d3180ee23e19ba9521eaede Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 19 Apr 2021 12:49:27 +0100 Subject: [PATCH 067/101] feat: add button to save image of network Resolves #84 --- people/templates/people/network.html | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/people/templates/people/network.html b/people/templates/people/network.html index 411740d..40f664e 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -37,6 +37,8 @@ {% endbuttons %} + +
@@ -61,14 +63,28 @@ integrity="sha256-rI7zH7xDqO306nxvXUw9gqkeBpvvmddDdlXJjJM7rEM=" crossorigin="anonymous"> + + - + {% load staticfiles %} + {% endblock %} \ No newline at end of file From 20812dfc4049d321aaab85e061f478296a27a1ad Mon Sep 17 00:00:00 2001 From: James Graham Date: Thu, 22 Apr 2021 21:16:39 +0100 Subject: [PATCH 069/101] feat: add person and org filters to network Resolves #54 --- people/forms.py | 26 ++++- people/static/js/network.js | 21 ++-- people/templates/people/network.html | 34 +++++-- people/views/network.py | 144 +++++++++++++++++++-------- 4 files changed, 162 insertions(+), 63 deletions(-) diff --git a/people/forms.py b/people/forms.py index 61cf2ee..3d128c4 100644 --- a/people/forms.py +++ b/people/forms.py @@ -53,8 +53,9 @@ class DynamicAnswerSetBase(forms.Form): continue # Placeholder question for sorting hardcoded questions - if question.is_hardcoded and (question.hardcoded_field - in self.Meta.fields): + if (question.is_hardcoded + and (as_filters or + (question.hardcoded_field in self.Meta.fields))): field_order.append(question.hardcoded_field) continue @@ -83,7 +84,7 @@ class DynamicAnswerSetBase(forms.Form): self.fields[field_name] = field field_order.append(field_name) - if question.allow_free_text: + if question.allow_free_text and not as_filters: free_field = forms.CharField(label=f'{question} free text', required=False) self.fields[f'{field_name}_free'] = free_field @@ -347,3 +348,22 @@ class NetworkPersonFilterForm(DynamicAnswerSetBase): required=False, widget=DatePickerInput(format='%Y-%m-%d'), help_text='Show relationships as they were on this date') + + +class NetworkOrganisationFilterForm(DynamicAnswerSetBase): + """Form to provide filtering on the network view.""" + field_class = forms.ModelMultipleChoiceField + field_widget = Select2MultipleWidget + field_required = False + question_model = models.OrganisationQuestion + answer_model = models.OrganisationQuestionChoice + question_prefix = 'organisation_' + + def __init__(self, *args, **kwargs): + super().__init__(*args, as_filters=True, **kwargs) + + # Add date field to select relationships at a particular point in time + self.fields['date'] = forms.DateField( + required=False, + widget=DatePickerInput(format='%Y-%m-%d'), + help_text='Show relationships as they were on this date') diff --git a/people/static/js/network.js b/people/static/js/network.js index 1ab6b44..ea1a243 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -88,14 +88,19 @@ function get_network() { var relationship_set = JSON.parse(document.getElementById('relationship-set-data').textContent); for (var relationship of relationship_set) { - cy.add({ - group: 'edges', - data: { - id: 'relationship-' + relationship.pk.toString(), - source: 'person-' + relationship.source.pk.toString(), - target: 'person-' + relationship.target.pk.toString() - } - }) + try { + cy.add({ + group: 'edges', + data: { + id: 'relationship-' + relationship.pk.toString(), + source: 'person-' + relationship.source.pk.toString(), + target: 'person-' + relationship.target.pk.toString() + } + }) + } catch { + // Exception thrown if a node in the relationship does not exist + // This is probably because it's been filtered out + } } // Optimise graph layout diff --git a/people/templates/people/network.html b/people/templates/people/network.html index 9aeced5..60061c5 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -14,22 +14,40 @@
{% csrf_token %} + {% load bootstrap4 %}
-
+

Filter Relationships

- {% load bootstrap4 %} - {% bootstrap_form form exclude='date' %} - -
- {% bootstrap_field form.date %} - + {% bootstrap_form relationship_form exclude='date' %}
-
+

Filter People

+ {% bootstrap_form person_form exclude='date' %}
+
+

Filter Organisations

+ {% bootstrap_form organisation_form exclude='date' %} +
+
+ +
+
+
+ {% bootstrap_field relationship_form.date %} +
+ +
+
+ {% bootstrap_field person_form.date %} +
+ +
+
+ {% bootstrap_field organisation_form.date %} +
{% buttons %} diff --git a/people/views/network.py b/people/views/network.py index 2cd0aec..7f2037d 100644 --- a/people/views/network.py +++ b/people/views/network.py @@ -8,32 +8,89 @@ 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 django.views.generic import TemplateView from people import forms, models, serializers logger = logging.getLogger(__name__) # pylint: disable=invalid-name -class NetworkView(LoginRequiredMixin, FormView): - """ - View to display relationship network. - """ +def filter_relationships(form, at_date): + relationship_answerset_set = models.RelationshipAnswerSet.objects.filter( + Q(replaced_timestamp__gte=at_date) + | Q(replaced_timestamp__isnull=True), + timestamp__lte=at_date) + + # Filter answers to relationship questions + for field, values in form.cleaned_data.items(): + if field.startswith(f'{form.question_prefix}question_') and values: + relationship_answerset_set = relationship_answerset_set.filter( + question_answers__in=values) + + return models.Relationship.objects.filter( + pk__in=relationship_answerset_set.values_list('relationship', + flat=True)) + + +def filter_people(form, at_date): + answerset_set = models.PersonAnswerSet.objects.filter( + Q(replaced_timestamp__gte=at_date) + | Q(replaced_timestamp__isnull=True), + timestamp__lte=at_date) + + # Filter answers to questions + for field, values in form.cleaned_data.items(): + if field.startswith(f'{form.question_prefix}question_') and values: + answerset_set = answerset_set.filter(question_answers__in=values) + + return models.Person.objects.filter( + pk__in=answerset_set.values_list('person', flat=True)) + + +def filter_organisations(form, at_date): + answerset_set = models.OrganisationAnswerSet.objects.filter( + Q(replaced_timestamp__gte=at_date) + | Q(replaced_timestamp__isnull=True), + timestamp__lte=at_date) + + # Filter answers to questions + for field, values in form.cleaned_data.items(): + if field.startswith(f'{form.question_prefix}question_') and values: + answerset_set = answerset_set.filter(question_answers__in=values) + + return models.Organisation.objects.filter( + pk__in=answerset_set.values_list('organisation', flat=True)) + + +class NetworkView(LoginRequiredMixin, TemplateView): + """View to display relationship network.""" template_name = 'people/network.html' - form_class = forms.NetworkRelationshipFilterForm + + def post(self, request, *args, **kwargs): + forms = self.get_forms() + if all(map(lambda f: f.is_valid(), forms.values())): + return self.forms_valid(forms) + + return self.forms_invalid(forms) + + def get_forms(self): + form_kwargs = self.get_form_kwargs() + + return { + 'relationship': forms.NetworkRelationshipFilterForm(**form_kwargs), + 'person': forms.NetworkPersonFilterForm(**form_kwargs), + 'organisation': forms.NetworkOrganisationFilterForm(**form_kwargs), + } def get_form_kwargs(self): - """ - Add GET params to form data. - """ - kwargs = super().get_form_kwargs() + """Add GET params to form data.""" + kwargs = {} if self.request.method == 'GET': - if 'data' in kwargs: - kwargs['data'].update(self.request.GET) + kwargs['data'] = self.request.GET - else: - kwargs['data'] = self.request.GET + if self.request.method in ('POST', 'PUT'): + kwargs['data'] = self.request.POST return kwargs @@ -42,46 +99,42 @@ class NetworkView(LoginRequiredMixin, FormView): Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context. """ context = super().get_context_data(**kwargs) - form: forms.NetworkRelationshipFilterForm = context['form'] - if not form.is_valid(): + + forms = self.get_forms() + context['relationship_form'] = forms['relationship'] + context['person_form'] = forms['person'] + context['organisation_form'] = forms['organisation'] + + if not all(map(lambda f: f.is_valid(), forms.values())): return context - at_date = form.cleaned_data['date'] - if not at_date: - at_date = timezone.now().date() + relationship_at_date = forms['relationship'].cleaned_data['date'] + if not relationship_at_date: + relationship_at_date = timezone.now().date() + + person_at_date = forms['person'].cleaned_data['date'] + if not person_at_date: + person_at_date = timezone.now().date() + + organisation_at_date = forms['organisation'].cleaned_data['date'] + if not organisation_at_date: + organisation_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__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(f'{form.question_prefix}question_') and values: - relationship_answerset_set = relationship_answerset_set.filter( - question_answers__in=values) - - logger.info('Found %d relationship answer sets matching filters', - relationship_answerset_set.count()) + relationship_at_date += timezone.timedelta(days=1) context['person_set'] = serializers.PersonSerializer( - models.Person.objects.all(), many=True).data + filter_people(forms['person'], person_at_date), + many=True).data context['organisation_set'] = serializers.OrganisationSerializer( - models.Organisation.objects.all(), many=True).data + filter_organisations(forms['organisation'], organisation_at_date), + many=True).data context['relationship_set'] = serializers.RelationshipSerializer( - models.Relationship.objects.filter( - pk__in=relationship_answerset_set.values_list('relationship', - flat=True)), + filter_relationships(forms['relationship'], relationship_at_date), many=True).data logger.info('Found %d distinct relationships matching filters', @@ -89,9 +142,12 @@ class NetworkView(LoginRequiredMixin, FormView): return context - def form_valid(self, form): + def forms_valid(self, forms): try: return self.render_to_response(self.get_context_data()) except ValidationError: - return self.form_invalid(form) + return self.forms_invalid(forms) + + def forms_invalid(self, forms): + return self.render_to_response(self.get_context_data()) From cc25a154acff0f4f74819704c869c0f12b948307 Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 25 Apr 2021 11:25:15 +0100 Subject: [PATCH 070/101] refactor: reduce duplication in network filters --- people/views/network.py | 101 +++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 57 deletions(-) diff --git a/people/views/network.py b/people/views/network.py index 7f2037d..cf81d61 100644 --- a/people/views/network.py +++ b/people/views/network.py @@ -3,6 +3,7 @@ Views for displaying networks of :class:`People` and :class:`Relationship`s. """ import logging +import typing from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Q @@ -15,51 +16,37 @@ from people import forms, models, serializers logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def filter_relationships(form, at_date): - relationship_answerset_set = models.RelationshipAnswerSet.objects.filter( - Q(replaced_timestamp__gte=at_date) - | Q(replaced_timestamp__isnull=True), - timestamp__lte=at_date) +def filter_by_form_answers(model: typing.Type, answerset_model: typing.Type, + relationship_key: str): + """Build a filter to select based on form responses.""" + def inner(form, at_date): + answerset_set = answerset_model.objects.filter( + Q(replaced_timestamp__gte=at_date) + | Q(replaced_timestamp__isnull=True), + timestamp__lte=at_date) - # Filter answers to relationship questions - for field, values in form.cleaned_data.items(): - if field.startswith(f'{form.question_prefix}question_') and values: - relationship_answerset_set = relationship_answerset_set.filter( - question_answers__in=values) + # Filter answers to relationship questions + for field, values in form.cleaned_data.items(): + if field.startswith(f'{form.question_prefix}question_') and values: + answerset_set = answerset_set.filter( + question_answers__in=values) - return models.Relationship.objects.filter( - pk__in=relationship_answerset_set.values_list('relationship', - flat=True)) + return model.objects.filter( + pk__in=answerset_set.values_list(relationship_key, flat=True)) + + return inner -def filter_people(form, at_date): - answerset_set = models.PersonAnswerSet.objects.filter( - Q(replaced_timestamp__gte=at_date) - | Q(replaced_timestamp__isnull=True), - timestamp__lte=at_date) +filter_relationships = filter_by_form_answers(models.Relationship, + models.RelationshipAnswerSet, + 'relationship') - # Filter answers to questions - for field, values in form.cleaned_data.items(): - if field.startswith(f'{form.question_prefix}question_') and values: - answerset_set = answerset_set.filter(question_answers__in=values) +filter_organisations = filter_by_form_answers(models.Organisation, + models.OrganisationAnswerSet, + 'organisation') - return models.Person.objects.filter( - pk__in=answerset_set.values_list('person', flat=True)) - - -def filter_organisations(form, at_date): - answerset_set = models.OrganisationAnswerSet.objects.filter( - Q(replaced_timestamp__gte=at_date) - | Q(replaced_timestamp__isnull=True), - timestamp__lte=at_date) - - # Filter answers to questions - for field, values in form.cleaned_data.items(): - if field.startswith(f'{form.question_prefix}question_') and values: - answerset_set = answerset_set.filter(question_answers__in=values) - - return models.Organisation.objects.filter( - pk__in=answerset_set.values_list('organisation', flat=True)) +filter_people = filter_by_form_answers(models.Person, models.PersonAnswerSet, + 'person') class NetworkView(LoginRequiredMixin, TemplateView): @@ -67,11 +54,11 @@ class NetworkView(LoginRequiredMixin, TemplateView): template_name = 'people/network.html' def post(self, request, *args, **kwargs): - forms = self.get_forms() - if all(map(lambda f: f.is_valid(), forms.values())): - return self.forms_valid(forms) + all_forms = self.get_forms() + if all(map(lambda f: f.is_valid(), all_forms.values())): + return self.forms_valid(all_forms) - return self.forms_invalid(forms) + return self.forms_invalid(all_forms) def get_forms(self): form_kwargs = self.get_form_kwargs() @@ -100,23 +87,23 @@ class NetworkView(LoginRequiredMixin, TemplateView): """ context = super().get_context_data(**kwargs) - forms = self.get_forms() - context['relationship_form'] = forms['relationship'] - context['person_form'] = forms['person'] - context['organisation_form'] = forms['organisation'] + all_forms = self.get_forms() + context['relationship_form'] = all_forms['relationship'] + context['person_form'] = all_forms['person'] + context['organisation_form'] = all_forms['organisation'] - if not all(map(lambda f: f.is_valid(), forms.values())): + if not all(map(lambda f: f.is_valid(), all_forms.values())): return context - relationship_at_date = forms['relationship'].cleaned_data['date'] + relationship_at_date = all_forms['relationship'].cleaned_data['date'] if not relationship_at_date: relationship_at_date = timezone.now().date() - person_at_date = forms['person'].cleaned_data['date'] + person_at_date = all_forms['person'].cleaned_data['date'] if not person_at_date: person_at_date = timezone.now().date() - organisation_at_date = forms['organisation'].cleaned_data['date'] + organisation_at_date = all_forms['organisation'].cleaned_data['date'] if not organisation_at_date: organisation_at_date = timezone.now().date() @@ -126,15 +113,15 @@ class NetworkView(LoginRequiredMixin, TemplateView): relationship_at_date += timezone.timedelta(days=1) context['person_set'] = serializers.PersonSerializer( - filter_people(forms['person'], person_at_date), + filter_people(all_forms['person'], person_at_date), many=True).data context['organisation_set'] = serializers.OrganisationSerializer( - filter_organisations(forms['organisation'], organisation_at_date), + filter_organisations(all_forms['organisation'], organisation_at_date), many=True).data context['relationship_set'] = serializers.RelationshipSerializer( - filter_relationships(forms['relationship'], relationship_at_date), + filter_relationships(all_forms['relationship'], relationship_at_date), many=True).data logger.info('Found %d distinct relationships matching filters', @@ -142,12 +129,12 @@ class NetworkView(LoginRequiredMixin, TemplateView): return context - def forms_valid(self, forms): + def forms_valid(self, all_forms): try: return self.render_to_response(self.get_context_data()) except ValidationError: - return self.forms_invalid(forms) + return self.forms_invalid(all_forms) - def forms_invalid(self, forms): + def forms_invalid(self, all_forms): return self.render_to_response(self.get_context_data()) From 7d1c05cfd85042f00cd07fbb02d61820e4ffb84a Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 25 Apr 2021 11:50:52 +0100 Subject: [PATCH 071/101] feat: add organisation relationships to network --- people/serializers.py | 13 +++++++++++++ people/static/js/network.js | 20 ++++++++++++++++++++ people/templates/people/network.html | 2 ++ people/views/network.py | 4 ++++ 4 files changed, 39 insertions(+) diff --git a/people/serializers.py b/people/serializers.py index 2303068..39f3578 100644 --- a/people/serializers.py +++ b/people/serializers.py @@ -36,3 +36,16 @@ class RelationshipSerializer(serializers.ModelSerializer): 'source', 'target', ] + + +class OrganisationRelationshipSerializer(serializers.ModelSerializer): + source = PersonSerializer() + target = OrganisationSerializer() + + class Meta: + model = models.OrganisationRelationship + fields = [ + 'pk', + 'source', + 'target', + ] diff --git a/people/static/js/network.js b/people/static/js/network.js index ea1a243..09714ed 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -103,6 +103,26 @@ function get_network() { } } + // Load organisation relationships and add to graph + relationship_set = JSON.parse(document.getElementById('organisation-relationship-set-data').textContent); + + for (var relationship of relationship_set) { + console.log(relationship) + try { + cy.add({ + group: 'edges', + data: { + id: 'organisation-relationship-' + relationship.pk.toString(), + source: 'person-' + relationship.source.pk.toString(), + target: 'organisation-' + relationship.target.pk.toString() + } + }) + } catch { + // Exception thrown if a node in the relationship does not exist + // This is probably because it's been filtered out + } + } + // Optimise graph layout var layout = cy.layout({ name: 'cose', diff --git a/people/templates/people/network.html b/people/templates/people/network.html index 60061c5..77bebd4 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -77,6 +77,8 @@ {{ relationship_set|json_script:'relationship-set-data' }} + {{ organisation_relationship_set|json_script:'organisation-relationship-set-data' }} + diff --git a/people/views/network.py b/people/views/network.py index cf81d61..74640e0 100644 --- a/people/views/network.py +++ b/people/views/network.py @@ -124,6 +124,10 @@ class NetworkView(LoginRequiredMixin, TemplateView): filter_relationships(all_forms['relationship'], relationship_at_date), many=True).data + context['organisation_relationship_set'] = serializers.OrganisationRelationshipSerializer( + models.OrganisationRelationship.objects.all(), many=True + ).data + logger.info('Found %d distinct relationships matching filters', len(context['relationship_set'])) From 0f4e39fcafcd0f091b5147101ab10130d53c110c Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 25 Apr 2021 12:00:57 +0100 Subject: [PATCH 072/101] feat: add network filter reset button --- people/templates/people/network.html | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/people/templates/people/network.html b/people/templates/people/network.html index 77bebd4..387d11c 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -50,26 +50,31 @@
+
+ {% buttons %} - +
+
+ +
+ +
+ +
+
{% endbuttons %}
- +
- - {% endblock %} {% block extra_script %} {{ person_set|json_script:'person-set-data' }} From bd13bb29e8f93ba90678f22348ffad0f962bbaff Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 25 Apr 2021 13:58:55 +0100 Subject: [PATCH 073/101] style: add yapf style config --- .style.yapf | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .style.yapf diff --git a/.style.yapf b/.style.yapf new file mode 100644 index 0000000..ccdfb55 --- /dev/null +++ b/.style.yapf @@ -0,0 +1,4 @@ +[style] +allow_split_before_dict_value=false +column_limit=100 +dedent_closing_brackets=true From 7d14fed90f5f14eacf8346e5a7e6392c53b1533c Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 25 Apr 2021 14:00:37 +0100 Subject: [PATCH 074/101] fix: distinguish kinds of relationship with orgs Refactor node/edge style method to improve performance --- people/serializers.py | 2 ++ people/static/js/network.js | 51 +++++++++++++------------- people/views/network.py | 72 ++++++++++++++++++++++--------------- 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/people/serializers.py b/people/serializers.py index 39f3578..684380d 100644 --- a/people/serializers.py +++ b/people/serializers.py @@ -41,6 +41,7 @@ class RelationshipSerializer(serializers.ModelSerializer): class OrganisationRelationshipSerializer(serializers.ModelSerializer): source = PersonSerializer() target = OrganisationSerializer() + kind = serializers.ReadOnlyField(default='organisation-relationship') class Meta: model = models.OrganisationRelationship @@ -48,4 +49,5 @@ class OrganisationRelationshipSerializer(serializers.ModelSerializer): 'pk', 'source', 'target', + 'kind', ] diff --git a/people/static/js/network.js b/people/static/js/network.js index 09714ed..0cb4991 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -7,33 +7,24 @@ var network_style = [ selector: 'node[name]', style: { label: 'data(name)', + width: '50px', + height: '50px', 'text-halign': 'center', 'text-valign': 'center', - 'font-size': 8, - 'background-color': function (ele) { - switch (ele.data('kind')) { - case 'person': - return '#0099cc' - default: - return '#669933' - } - }, - 'shape': function (ele) { - switch (ele.data('kind')) { - case 'person': - return 'ellipse' - default: - return 'rectangle' - } - } + 'text-wrap': 'wrap', + 'text-max-width': '100px', + 'font-size': 12, + 'background-color': 'data(nodeColor)', + 'shape': 'data(nodeShape)' } }, { selector: 'edge', style: { - 'mid-target-arrow-shape': 'triangle', + 'mid-target-arrow-shape': 'data(lineArrowShape)', 'curve-style': 'straight', 'width': 1, + 'line-color': 'data(lineColor)' } } ] @@ -65,7 +56,9 @@ function get_network() { data: { id: 'person-' + person.pk.toString(), name: person.name, - kind: 'person' + kind: 'person', + nodeColor: '#0099cc', + nodeShape: 'elipse' } }) } @@ -79,7 +72,8 @@ function get_network() { data: { id: 'organisation-' + item.pk.toString(), name: item.name, - kind: 'organisation' + nodeColor: '#669933', + nodeShape: 'rectangle' } }) } @@ -94,10 +88,11 @@ function get_network() { data: { id: 'relationship-' + relationship.pk.toString(), source: 'person-' + relationship.source.pk.toString(), - target: 'person-' + relationship.target.pk.toString() + target: 'person-' + relationship.target.pk.toString(), + lineArrowShape: 'triangle' } }) - } catch { + } catch (exc) { // Exception thrown if a node in the relationship does not exist // This is probably because it's been filtered out } @@ -107,17 +102,20 @@ function get_network() { relationship_set = JSON.parse(document.getElementById('organisation-relationship-set-data').textContent); for (var relationship of relationship_set) { - console.log(relationship) try { cy.add({ group: 'edges', data: { id: 'organisation-relationship-' + relationship.pk.toString(), source: 'person-' + relationship.source.pk.toString(), - target: 'organisation-' + relationship.target.pk.toString() + target: 'organisation-' + relationship.target.pk.toString(), + lineColor: { + 'organisation-membership': '#669933' + }[relationship.kind] || 'black', + lineArrowShape: 'none' } }) - } catch { + } catch (exc) { // Exception thrown if a node in the relationship does not exist // This is probably because it's been filtered out } @@ -128,7 +126,8 @@ function get_network() { name: 'cose', randomize: true, animate: false, - idealEdgeLength: function (edge) { return 64; } + idealEdgeLength: function (edge) { return 64; }, + nodeRepulsion: function (node) { return 8192; } }); layout.run(); diff --git a/people/views/network.py b/people/views/network.py index 74640e0..af35359 100644 --- a/people/views/network.py +++ b/people/views/network.py @@ -16,37 +16,34 @@ from people import forms, models, serializers logger = logging.getLogger(__name__) # pylint: disable=invalid-name -def filter_by_form_answers(model: typing.Type, answerset_model: typing.Type, - relationship_key: str): +def filter_by_form_answers(model: typing.Type, answerset_model: typing.Type, relationship_key: str): """Build a filter to select based on form responses.""" def inner(form, at_date): answerset_set = answerset_model.objects.filter( Q(replaced_timestamp__gte=at_date) | Q(replaced_timestamp__isnull=True), - timestamp__lte=at_date) + timestamp__lte=at_date + ) # Filter answers to relationship questions for field, values in form.cleaned_data.items(): if field.startswith(f'{form.question_prefix}question_') and values: - answerset_set = answerset_set.filter( - question_answers__in=values) + answerset_set = answerset_set.filter(question_answers__in=values) - return model.objects.filter( - pk__in=answerset_set.values_list(relationship_key, flat=True)) + return model.objects.filter(pk__in=answerset_set.values_list(relationship_key, flat=True)) return inner -filter_relationships = filter_by_form_answers(models.Relationship, - models.RelationshipAnswerSet, - 'relationship') +filter_relationships = filter_by_form_answers( + models.Relationship, models.RelationshipAnswerSet, 'relationship' +) -filter_organisations = filter_by_form_answers(models.Organisation, - models.OrganisationAnswerSet, - 'organisation') +filter_organisations = filter_by_form_answers( + models.Organisation, models.OrganisationAnswerSet, 'organisation' +) -filter_people = filter_by_form_answers(models.Person, models.PersonAnswerSet, - 'person') +filter_people = filter_by_form_answers(models.Person, models.PersonAnswerSet, 'person') class NetworkView(LoginRequiredMixin, TemplateView): @@ -95,41 +92,60 @@ class NetworkView(LoginRequiredMixin, TemplateView): if not all(map(lambda f: f.is_valid(), all_forms.values())): return context + # 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 to each + relationship_at_date = all_forms['relationship'].cleaned_data['date'] if not relationship_at_date: relationship_at_date = timezone.now().date() + relationship_at_date += timezone.timedelta(days=1) person_at_date = all_forms['person'].cleaned_data['date'] if not person_at_date: person_at_date = timezone.now().date() + person_at_date += timezone.timedelta(days=1) organisation_at_date = all_forms['organisation'].cleaned_data['date'] if not organisation_at_date: organisation_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 - relationship_at_date += timezone.timedelta(days=1) + organisation_at_date += timezone.timedelta(days=1) context['person_set'] = serializers.PersonSerializer( - filter_people(all_forms['person'], person_at_date), - many=True).data + filter_people(all_forms['person'], person_at_date), many=True + ).data context['organisation_set'] = serializers.OrganisationSerializer( - filter_organisations(all_forms['organisation'], organisation_at_date), - many=True).data + filter_organisations(all_forms['organisation'], organisation_at_date), many=True + ).data context['relationship_set'] = serializers.RelationshipSerializer( - filter_relationships(all_forms['relationship'], relationship_at_date), - many=True).data + filter_relationships(all_forms['relationship'], relationship_at_date), many=True + ).data context['organisation_relationship_set'] = serializers.OrganisationRelationshipSerializer( models.OrganisationRelationship.objects.all(), many=True ).data - logger.info('Found %d distinct relationships matching filters', - len(context['relationship_set'])) + for person in models.Person.objects.all(): + try: + context['organisation_relationship_set'].append( + { + 'pk': f'membership-{person.pk}', + 'source': serializers.PersonSerializer(person).data, + 'target': serializers.OrganisationSerializer( + person.current_answers.organisation + ).data, + 'kind': 'organisation-membership' + } + ) + + except AttributeError: + pass + + logger.info( + 'Found %d distinct relationships matching filters', len(context['relationship_set']) + ) return context From 7681e78a50792dc524764d2ba3ed89c9733bd3ca Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 25 Apr 2021 15:11:21 +0100 Subject: [PATCH 075/101] perf: prefetch for serializers on network view Reduces request time by more than 50% --- people/templates/people/network.html | 4 +- people/views/network.py | 59 +++++++++++++--------------- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/people/templates/people/network.html b/people/templates/people/network.html index 387d11c..ee693ee 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -84,8 +84,8 @@ {{ organisation_relationship_set|json_script:'organisation-relationship-set-data' }} - +{% endblock %} diff --git a/people/templates/people/person/update.html b/people/templates/people/person/update.html index 1049a8b..9833999 100644 --- a/people/templates/people/person/update.html +++ b/people/templates/people/person/update.html @@ -51,42 +51,5 @@ {% endblock %} {% block extra_script %} - + {% endblock %} diff --git a/people/templates/people/relationship/update.html b/people/templates/people/relationship/update.html index bd3bff1..08a587d 100644 --- a/people/templates/people/relationship/update.html +++ b/people/templates/people/relationship/update.html @@ -33,3 +33,8 @@ {% endblock %} + +{% block extra_script %} + {% load staticfiles %} + +{% endblock %} From bc28c238ea287576ac5585aa2186795238f73cec Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 9 May 2021 15:26:28 +0100 Subject: [PATCH 086/101] fix: use correct url in breadcrumb --- people/templates/people/relationship/update.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/people/templates/people/relationship/update.html b/people/templates/people/relationship/update.html index 08a587d..3da1df8 100644 --- a/people/templates/people/relationship/update.html +++ b/people/templates/people/relationship/update.html @@ -10,7 +10,7 @@ {{ person }} From 9ead2ab05fc742acd986f8d128922c109747f00b Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 10 May 2021 12:14:09 +0100 Subject: [PATCH 087/101] fix: fit node labels inside nodes --- people/static/js/network.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/people/static/js/network.js b/people/static/js/network.js index 7aa9ca0..62c82fd 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -7,12 +7,12 @@ var network_style = [ selector: 'node[name]', style: { label: 'data(name)', - width: '50px', - height: '50px', + width: '100px', + height: '100px', 'text-halign': 'center', 'text-valign': 'center', 'text-wrap': 'wrap', - 'text-max-width': '100px', + 'text-max-width': '90px', 'font-size': 12, 'background-color': 'data(nodeColor)', 'shape': 'data(nodeShape)' @@ -58,7 +58,7 @@ function get_network() { name: person.name, kind: 'person', nodeColor: '#0099cc', - nodeShape: 'elipse' + nodeShape: 'ellipse' } }) } @@ -89,6 +89,9 @@ function get_network() { id: 'relationship-' + relationship.pk.toString(), source: 'person-' + relationship.source.pk.toString(), target: 'person-' + relationship.target.pk.toString(), + lineColor: { + 'organisation-membership': '#669933' + }[relationship.kind] || 'grey', lineArrowShape: 'triangle' } }) From 4f1dfe16cd4315965c69b12fc5e13089419f0fc4 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 10 May 2021 12:21:20 +0100 Subject: [PATCH 088/101] feat: expand node and bring to front when selected --- people/static/js/network.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/people/static/js/network.js b/people/static/js/network.js index 62c82fd..46642df 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -13,11 +13,19 @@ var network_style = [ 'text-valign': 'center', 'text-wrap': 'wrap', 'text-max-width': '90px', - 'font-size': 12, + 'font-size': '12rem', 'background-color': 'data(nodeColor)', 'shape': 'data(nodeShape)' } }, + { + selector: 'node:selected', + style: { + 'text-max-width': '300px', + 'font-size': '40rem', + 'z-index': 100, + } + }, { selector: 'edge', style: { From c57392e83c3da27e4659f27db3c5ffe2e1fa735f Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 10 May 2021 13:21:37 +0100 Subject: [PATCH 089/101] feat: add configurable help text for relationships Displayed at the top of relationship forms --- breccia_mapper/settings.py | 15 ++++++++++++--- people/templates/people/relationship/create.html | 8 ++++++++ people/templates/people/relationship/update.html | 8 ++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index 2c74594..5cfa1ad 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -357,6 +357,9 @@ CONSTANCE_CONFIG = { 'ORGANISATION_LIST_HELP': ( '', 'Help text to display at the top of the organisaton list.'), + 'RELATIONSHIP_FORM_HELP': ( + '', + 'Help text to display at the top of relationship forms.'), } # yapf: disable CONSTANCE_CONFIG_FIELDSETS = { @@ -364,9 +367,15 @@ CONSTANCE_CONFIG_FIELDSETS = { 'NOTICE_TEXT', 'NOTICE_CLASS', ), - 'Data Collection': ('CONSENT_TEXT', ), - 'Help Text': ('PERSON_LIST_HELP', 'ORGANISATION_LIST_HELP'), -} + 'Data Collection': ( + 'CONSENT_TEXT', + ), + 'Help Text': ( + 'PERSON_LIST_HELP', + 'ORGANISATION_LIST_HELP', + 'RELATIONSHIP_FORM_HELP', + ), +} # yapf: disable CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' diff --git a/people/templates/people/relationship/create.html b/people/templates/people/relationship/create.html index 6f7d8e5..e9d1749 100644 --- a/people/templates/people/relationship/create.html +++ b/people/templates/people/relationship/create.html @@ -15,6 +15,14 @@

Add Relationship

+ {% with config.RELATIONSHIP_FORM_HELP as help_text %} + {% if help_text %} +
+ {{ help_text|linebreaks }} +
+ {% endif %} + {% endwith %} +
Update Relationship + {% with config.RELATIONSHIP_FORM_HELP as help_text %} + {% if help_text %} +
+ {{ help_text|linebreaks }} +
+ {% endif %} + {% endwith %} +
Date: Wed, 12 May 2021 19:37:46 +0100 Subject: [PATCH 090/101] feat: add button to hide organisations from graph --- people/static/js/network.js | 24 ++++++++++++++++++++++++ people/templates/people/network.html | 10 +++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/people/static/js/network.js b/people/static/js/network.js index 46642df..90b5737 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -2,6 +2,10 @@ // Global reference to Cytoscape graph - needed for `save_image` var cy; +var hide_organisations = false; +var organisation_nodes; +var organisation_edges; + var network_style = [ { selector: 'node[name]', @@ -44,6 +48,21 @@ function save_image() { saveAs(cy.png(), 'graph.png'); } +/** + * Hide or restore organisations and relationships with them. + */ +function toggle_organisations() { + hide_organisations = !hide_organisations; + + if (hide_organisations) { + organisation_nodes.remove(); + organisation_edges.remove(); + } else { + organisation_nodes.restore(); + organisation_edges.restore(); + } +} + /** * Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page. */ @@ -80,11 +99,13 @@ function get_network() { data: { id: 'organisation-' + item.pk.toString(), name: item.name, + kind: 'organisation', nodeColor: '#669933', nodeShape: 'rectangle' } }) } + organisation_nodes = cy.nodes('[kind = "organisation"]'); // Load relationships and add to graph var relationship_set = JSON.parse(document.getElementById('relationship-set-data').textContent); @@ -97,6 +118,7 @@ function get_network() { id: 'relationship-' + relationship.pk.toString(), source: 'person-' + relationship.source.pk.toString(), target: 'person-' + relationship.target.pk.toString(), + kind: 'person', lineColor: { 'organisation-membership': '#669933' }[relationship.kind] || 'grey', @@ -120,6 +142,7 @@ function get_network() { id: 'organisation-relationship-' + relationship.pk.toString(), source: 'person-' + relationship.source.pk.toString(), target: 'organisation-' + relationship.target.pk.toString(), + kind: 'organisation', lineColor: { 'organisation-membership': '#669933' }[relationship.kind] || 'black', @@ -131,6 +154,7 @@ function get_network() { // This is probably because it's been filtered out } } + organisation_edges = cy.edges('[kind = "organisation"]'); // Optimise graph layout var layout = cy.layout({ diff --git a/people/templates/people/network.html b/people/templates/people/network.html index ee693ee..a70f9e2 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -65,7 +65,15 @@ {% endbuttons %}
- +
+
+ +
+ +
+ +
+
Date: Wed, 12 May 2021 19:39:33 +0100 Subject: [PATCH 091/101] fix: add arrow to organisation relationships --- people/static/js/network.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/people/static/js/network.js b/people/static/js/network.js index 90b5737..4da4d68 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -146,7 +146,7 @@ function get_network() { lineColor: { 'organisation-membership': '#669933' }[relationship.kind] || 'black', - lineArrowShape: 'none' + lineArrowShape: 'triangle' } }) } catch (exc) { From e7a113c4eea1f9ba1450577dd5da28d52a037150 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 12 May 2021 19:56:18 +0100 Subject: [PATCH 092/101] feat: add buttons to anonymise nodes on graph --- people/static/js/network.js | 28 +++++++++++++++++++++++++++- people/templates/people/network.html | 5 +++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/people/static/js/network.js b/people/static/js/network.js index 4da4d68..06849a1 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -6,11 +6,21 @@ var hide_organisations = false; var organisation_nodes; var organisation_edges; +var anonymise_people = false; +var anonymise_organisations = false; + var network_style = [ { selector: 'node[name]', style: { - label: 'data(name)', + label: function (ele) { + var anonymise = anonymise_people; + if (ele.data('kind') == 'organisation') { + anonymise = anonymise_organisations; + } + + return anonymise ? ele.data('id') : ele.data('name') + }, width: '100px', height: '100px', 'text-halign': 'center', @@ -63,6 +73,22 @@ function toggle_organisations() { } } +/** + * Toggle person node labels between names and ids. + */ +function toggle_anonymise_people() { + anonymise_people = !anonymise_people + cy.elements().remove().restore(); +} + +/** + * Toggle organisation node labels between names and ids. + */ +function toggle_anonymise_organisations() { + anonymise_organisations = !anonymise_organisations + cy.elements().remove().restore(); +} + /** * Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page. */ diff --git a/people/templates/people/network.html b/people/templates/people/network.html index a70f9e2..12cbb08 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -73,6 +73,11 @@
+ +
+ + +
Date: Wed, 12 May 2021 20:32:18 +0100 Subject: [PATCH 093/101] feat: add panzoom widget to network view --- people/static/js/network.js | 3 +++ people/templates/people/network.html | 13 ++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/people/static/js/network.js b/people/static/js/network.js index 06849a1..8309cc5 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -100,6 +100,9 @@ function get_network() { style: network_style }); + // Add pan + zoom widget with cytoscape-panzoom + cy.panzoom(); + // Load people and add to graph var person_set = JSON.parse(document.getElementById('person-set-data').textContent); diff --git a/people/templates/people/network.html b/people/templates/people/network.html index 12cbb08..ee01a98 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -1,5 +1,12 @@ {% extends 'base.html' %} +{% block extra_head %} + +{% endblock %} + {% block content %}
{% endblock %} {% block extra_script %} @@ -101,6 +108,10 @@ integrity="sha512-CBGCXtszkG5rYlQSTNUzk54/731Kz28WPk2uT1GCPCqgfVRJ2v514vzzf16HuGX9WVtE7JLqRuAERNAzFZ9Hpw==" crossorigin="anonymous"> + + From 8f7767fa5ce2d7b0460a7efd7d907a1ef3ddc40b Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 12 May 2021 20:52:59 +0100 Subject: [PATCH 094/101] fix: button correctly resets filters after POST --- people/templates/people/network.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/people/templates/people/network.html b/people/templates/people/network.html index ee01a98..407d6d7 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -66,7 +66,7 @@
- +
{% endbuttons %} @@ -104,6 +104,12 @@ {{ organisation_relationship_set|json_script:'organisation-relationship-set-data' }} + + From 479ef038d46c81085a72318a2800740038259872 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 12 May 2021 21:05:37 +0100 Subject: [PATCH 095/101] fix: rendering of select2 fields in network filter --- people/templates/people/network.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/people/templates/people/network.html b/people/templates/people/network.html index 407d6d7..4d17f22 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -1,6 +1,9 @@ {% extends 'base.html' %} {% block extra_head %} + {# There's no 'form' so need to add this to load CSS / JS #} + {{ relationship_form.media.css }} + @@ -106,7 +111,7 @@ From f37d7c77c40d8d15229518861f399b29e6f0c27a Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 17 May 2021 18:26:52 +0100 Subject: [PATCH 096/101] fix: update exports for static answerset fields Resolves #99 --- breccia_mapper/settings.py | 1 - export/serializers/people.py | 94 ++++++++++++++---------------------- export/views/base.py | 8 ++- export/views/people.py | 1 + people/models/question.py | 21 +++++--- 5 files changed, 57 insertions(+), 68 deletions(-) diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index 5cfa1ad..6627eac 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -104,7 +104,6 @@ The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABAS Google Maps API key to display maps of people's locations """ -import collections import logging import logging.config import pathlib diff --git a/export/serializers/people.py b/export/serializers/people.py index ea00ef2..15dda49 100644 --- a/export/serializers/people.py +++ b/export/serializers/people.py @@ -5,6 +5,38 @@ from people import models from . import base +def underscore(slug: str) -> str: + """Replace hyphens with underscores in text.""" + return slug.replace('-', '_') + + +def underscore_dict_keys(dict_: typing.Mapping[str, typing.Any]): + return {underscore(key): value for key, value in dict_.items()} + + +class AnswerSetSerializer(base.FlattenedModelSerializer): + question_model = None + + @property + def column_headers(self) -> typing.List[str]: + headers = super().column_headers + + # Add relationship questions to columns + for question in self.question_model.objects.all(): + headers.append(underscore(question.slug)) + + return headers + + def to_representation(self, instance: models.question.AnswerSet): + rep = super().to_representation(instance) + + rep.update( + underscore_dict_keys(instance.build_question_answers(use_slugs=True, show_all=True)) + ) + + return rep + + class PersonSerializer(base.FlattenedModelSerializer): class Meta: model = models.Person @@ -14,7 +46,8 @@ class PersonSerializer(base.FlattenedModelSerializer): ] -class PersonAnswerSetSerializer(base.FlattenedModelSerializer): +class PersonAnswerSetSerializer(AnswerSetSerializer): + question_model = models.PersonQuestion person = PersonSerializer() class Meta: @@ -24,38 +57,10 @@ class PersonAnswerSetSerializer(base.FlattenedModelSerializer): 'person', 'timestamp', 'replaced_timestamp', - 'nationality', - 'country_of_residence', - 'organisation', - 'organisation_started_date', - 'job_title', 'latitude', 'longitude', ] - @property - def column_headers(self) -> typing.List[str]: - headers = super().column_headers - - # Add questions to columns - for question in models.PersonQuestion.objects.all(): - headers.append(underscore(question.slug)) - - return headers - - def to_representation(self, instance): - rep = super().to_representation(instance) - - try: - # Add relationship question answers to data - for answer in instance.question_answers.all(): - rep[underscore(answer.question.slug)] = underscore(answer.slug) - - except AttributeError: - pass - - return rep - class RelationshipSerializer(base.FlattenedModelSerializer): source = PersonSerializer() @@ -70,12 +75,8 @@ class RelationshipSerializer(base.FlattenedModelSerializer): ] -def underscore(slug: str) -> str: - """Replace hyphens with underscores in text.""" - return slug.replace('-', '_') - - -class RelationshipAnswerSetSerializer(base.FlattenedModelSerializer): +class RelationshipAnswerSetSerializer(AnswerSetSerializer): + question_model = models.RelationshipQuestion relationship = RelationshipSerializer() class Meta: @@ -86,26 +87,3 @@ class RelationshipAnswerSetSerializer(base.FlattenedModelSerializer): 'timestamp', 'replaced_timestamp', ] - - @property - def column_headers(self) -> typing.List[str]: - headers = super().column_headers - - # Add relationship questions to columns - for question in models.RelationshipQuestion.objects.all(): - headers.append(underscore(question.slug)) - - return headers - - def to_representation(self, instance): - rep = super().to_representation(instance) - - try: - # Add relationship question answers to data - for answer in instance.question_answers.all(): - rep[underscore(answer.question.slug)] = underscore(answer.slug) - - except AttributeError: - pass - - return rep diff --git a/export/views/base.py b/export/views/base.py index 8416e47..602437c 100644 --- a/export/views/base.py +++ b/export/views/base.py @@ -7,6 +7,10 @@ from django.views.generic import TemplateView from django.views.generic.list import BaseListView +class QuotedCsv(csv.excel): + quoting = csv.QUOTE_NONNUMERIC + + class CsvExportView(LoginRequiredMixin, BaseListView): model = None serializer_class = None @@ -18,10 +22,10 @@ class CsvExportView(LoginRequiredMixin, BaseListView): # Force ordering by PK - though this should be default anyway serializer = self.serializer_class(self.get_queryset().order_by('pk'), many=True) - writer = csv.DictWriter(response, fieldnames=serializer.child.column_headers) + writer = csv.DictWriter(response, dialect=QuotedCsv, fieldnames=serializer.child.column_headers) writer.writeheader() writer.writerows(serializer.data) - + return response diff --git a/export/views/people.py b/export/views/people.py index 3afa1f3..f7cea55 100644 --- a/export/views/people.py +++ b/export/views/people.py @@ -8,6 +8,7 @@ class PersonExportView(base.CsvExportView): model = models.person.Person serializer_class = serializers.people.PersonSerializer + class PersonAnswerSetExportView(base.CsvExportView): model = models.person.PersonAnswerSet serializer_class = serializers.people.PersonAnswerSetSerializer diff --git a/people/models/question.py b/people/models/question.py index b143db3..dd27eba 100644 --- a/people/models/question.py +++ b/people/models/question.py @@ -166,27 +166,34 @@ class AnswerSet(models.Model): def is_current(self) -> bool: return self.replaced_timestamp is None - def build_question_answers(self, show_all: bool = False) -> typing.Dict[str, str]: + def build_question_answers(self, + show_all: bool = False, + use_slugs: bool = False) -> typing.Dict[str, str]: """Collect answers to dynamic questions and join with commas.""" questions = self.question_model.objects.all() + if not show_all: questions = questions.filter(answer_is_public=True) question_answers = {} try: + answerset_answers = list(self.question_answers.order_by().values('text', 'question_id')) + for question in questions: + key = question.slug if use_slugs else question.text + if question.hardcoded_field: answer = getattr(self, question.hardcoded_field) if isinstance(answer, list): answer = ', '.join(map(str, answer)) - question_answers[question.text] = answer - else: - answers = self.question_answers.filter( - question=question) - question_answers[question.text] = ', '.join( - map(str, answers)) + answer = ', '.join( + answer['text'] for answer in answerset_answers + if answer['question_id'] == question.id + ) + + question_answers[key] = answer except AttributeError: # No AnswerSet yet From 264c353b1dc138fca9465366c05cf79f1346898d Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 17 May 2021 19:13:45 +0100 Subject: [PATCH 097/101] feat: add export views for organisation types --- export/serializers/people.py | 52 +++++++++++++++++++++++++++++ export/templates/export/export.html | 36 ++++++++++++++++++++ export/urls.py | 16 +++++++++ export/views/people.py | 20 +++++++++++ 4 files changed, 124 insertions(+) diff --git a/export/serializers/people.py b/export/serializers/people.py index 15dda49..071cf44 100644 --- a/export/serializers/people.py +++ b/export/serializers/people.py @@ -87,3 +87,55 @@ class RelationshipAnswerSetSerializer(AnswerSetSerializer): 'timestamp', 'replaced_timestamp', ] + + +class OrganisationSerializer(base.FlattenedModelSerializer): + class Meta: + model = models.Organisation + fields = [ + 'id', + 'name', + ] + + +class OrganisationAnswerSetSerializer(AnswerSetSerializer): + question_model = models.OrganisationQuestion + organisation = OrganisationSerializer() + + class Meta: + model = models.OrganisationAnswerSet + fields = [ + 'id', + 'organisation', + 'timestamp', + 'replaced_timestamp', + 'latitude', + 'longitude', + ] + + +class OrganisationRelationshipSerializer(base.FlattenedModelSerializer): + source = OrganisationSerializer() + target = OrganisationSerializer() + + class Meta: + model = models.OrganisationRelationship + fields = [ + 'id', + 'source', + 'target', + ] + + +class OrganisationRelationshipAnswerSetSerializer(AnswerSetSerializer): + question_model = models.OrganisationRelationshipQuestion + relationship = OrganisationRelationshipSerializer() + + class Meta: + model = models.OrganisationRelationshipAnswerSet + fields = [ + 'id', + 'relationship', + 'timestamp', + 'replaced_timestamp', + ] diff --git a/export/templates/export/export.html b/export/templates/export/export.html index 9151025..1eda477 100644 --- a/export/templates/export/export.html +++ b/export/templates/export/export.html @@ -57,6 +57,42 @@ + + Organisation + + + Export + + + + + Organisation Answer Sets + + + Export + + + + + Organisation Relationships + + + Export + + + + + Organisation Relationship Answer Sets + + + Export + + + Activities diff --git a/export/urls.py b/export/urls.py index d60d2b8..24ae2f7 100644 --- a/export/urls.py +++ b/export/urls.py @@ -26,6 +26,22 @@ urlpatterns = [ views.people.RelationshipAnswerSetExportView.as_view(), name='relationship-answer-set'), + path('export/organisation', + views.people.OrganisationExportView.as_view(), + name='organisation'), + + path('export/organisation-answer-sets', + views.people.OrganisationAnswerSetExportView.as_view(), + name='organisation-answer-set'), + + path('export/organisation-relationships', + views.people.OrganisationRelationshipExportView.as_view(), + name='organisation-relationship'), + + path('export/organisation-relationship-answer-sets', + views.people.OrganisationRelationshipAnswerSetExportView.as_view(), + name='organisation-relationship-answer-set'), + path('export/activities', views.activities.ActivityExportView.as_view(), name='activity'), diff --git a/export/views/people.py b/export/views/people.py index f7cea55..39d0fc3 100644 --- a/export/views/people.py +++ b/export/views/people.py @@ -22,3 +22,23 @@ class RelationshipExportView(base.CsvExportView): class RelationshipAnswerSetExportView(base.CsvExportView): model = models.relationship.RelationshipAnswerSet serializer_class = serializers.people.RelationshipAnswerSetSerializer + + +class OrganisationExportView(base.CsvExportView): + model = models.person.Organisation + serializer_class = serializers.people.OrganisationSerializer + + +class OrganisationAnswerSetExportView(base.CsvExportView): + model = models.organisation.OrganisationAnswerSet + serializer_class = serializers.people.OrganisationAnswerSetSerializer + + +class OrganisationRelationshipExportView(base.CsvExportView): + model = models.relationship.OrganisationRelationship + serializer_class = serializers.people.OrganisationRelationshipSerializer + + +class OrganisationRelationshipAnswerSetExportView(base.CsvExportView): + model = models.relationship.OrganisationRelationshipAnswerSet + serializer_class = serializers.people.OrganisationRelationshipAnswerSetSerializer From 48cce12c321ea1293251b1cebdb20c0388c60461 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 17 May 2021 19:29:26 +0100 Subject: [PATCH 098/101] feat: embed extra data in person csv Resolves #113 --- export/serializers/people.py | 2 ++ people/models/person.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/export/serializers/people.py b/export/serializers/people.py index 071cf44..ca77258 100644 --- a/export/serializers/people.py +++ b/export/serializers/people.py @@ -43,6 +43,8 @@ class PersonSerializer(base.FlattenedModelSerializer): fields = [ 'id', 'name', + 'organisation', + 'country_of_residence', ] diff --git a/people/models/person.py b/people/models/person.py index d0f5d41..618bbfb 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -123,6 +123,14 @@ class Person(models.Model): def current_answers(self) -> 'PersonAnswerSet': return self.answer_sets.last() + @property + def organisation(self) -> Organisation: + return self.current_answers.organisation + + @property + def country_of_residence(self): + return self.current_answers.country_of_residence + def get_absolute_url(self): return reverse('people:person.detail', kwargs={'pk': self.pk}) From 9d14cf4b3824c9df87e35f438a4234b4f6e08444 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 17 May 2021 19:29:53 +0100 Subject: [PATCH 099/101] fix: restrict csv exports to staff only --- export/views/base.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/export/views/base.py b/export/views/base.py index 602437c..3ef441b 100644 --- a/export/views/base.py +++ b/export/views/base.py @@ -1,7 +1,7 @@ import csv import typing -from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import UserPassesTestMixin from django.http import HttpResponse from django.views.generic import TemplateView from django.views.generic.list import BaseListView @@ -11,7 +11,12 @@ class QuotedCsv(csv.excel): quoting = csv.QUOTE_NONNUMERIC -class CsvExportView(LoginRequiredMixin, BaseListView): +class UserIsStaffMixin(UserPassesTestMixin): + def test_func(self) -> typing.Optional[bool]: + return self.request.user.is_staff + + +class CsvExportView(UserIsStaffMixin, BaseListView): model = None serializer_class = None @@ -29,5 +34,5 @@ class CsvExportView(LoginRequiredMixin, BaseListView): return response -class ExportListView(LoginRequiredMixin, TemplateView): +class ExportListView(UserIsStaffMixin, TemplateView): template_name = 'export/export.html' From 3ea4ea88a7d0d00b214e6ae3194db55fac9e64c9 Mon Sep 17 00:00:00 2001 From: James Graham Date: Thu, 20 May 2021 15:28:59 +0100 Subject: [PATCH 100/101] refactor: reorganise network page Backend form handling slightly simplified - date is own form now --- breccia_mapper/templates/base.html | 2 +- people/forms.py | 78 ++++++++----------- people/static/js/network.js | 4 + people/templates/people/network.html | 108 ++++++++++----------------- people/views/network.py | 19 ++--- 5 files changed, 84 insertions(+), 127 deletions(-) diff --git a/breccia_mapper/templates/base.html b/breccia_mapper/templates/base.html index b9bb55e..ef622cb 100644 --- a/breccia_mapper/templates/base.html +++ b/breccia_mapper/templates/base.html @@ -175,7 +175,7 @@ {% block before_content %}{% endblock %} -
+
{# Display Django messages as Bootstrap alerts #} {% bootstrap_messages %} diff --git a/people/forms.py b/people/forms.py index 3d128c4..7a41282 100644 --- a/people/forms.py +++ b/people/forms.py @@ -42,20 +42,22 @@ class DynamicAnswerSetBase(forms.Form): question_model: typing.Type[models.Question] answer_model: typing.Type[models.QuestionChoice] question_prefix: str = '' + as_filters: bool = False - def __init__(self, *args, as_filters: bool = False, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) field_order = [] for question in self.question_model.objects.all(): - if as_filters and not question.answer_is_public: + if self.as_filters and not question.answer_is_public: continue - # Placeholder question for sorting hardcoded questions - if (question.is_hardcoded - and (as_filters or - (question.hardcoded_field in self.Meta.fields))): + # Is a placeholder question just for sorting hardcoded questions? + if ( + question.is_hardcoded + and (self.as_filters or (question.hardcoded_field in self.Meta.fields)) + ): field_order.append(question.hardcoded_field) continue @@ -70,7 +72,7 @@ class DynamicAnswerSetBase(forms.Form): # If being used as a filter - do we have alternate text? field_label = question.text - if as_filters and question.filter_text: + if self.as_filters and question.filter_text: field_label = question.filter_text field = field_class( @@ -80,11 +82,11 @@ class DynamicAnswerSetBase(forms.Form): required=(self.field_required and not question.allow_free_text), initial=self.initial.get(field_name, None), - help_text=question.help_text if not as_filters else '') + help_text=question.help_text if not self.as_filters else '') self.fields[field_name] = field field_order.append(field_name) - if question.allow_free_text and not as_filters: + if question.allow_free_text and not self.as_filters: free_field = forms.CharField(label=f'{question} free text', required=False) self.fields[f'{field_name}_free'] = free_field @@ -312,58 +314,38 @@ class OrganisationRelationshipAnswerSetForm(forms.ModelForm, return self.instance -class NetworkRelationshipFilterForm(DynamicAnswerSetBase): - """Form to provide filtering on the network view.""" +class DateForm(forms.Form): + date = forms.DateField( + required=False, + widget=DatePickerInput(format='%Y-%m-%d'), + help_text='Show relationships as they were on this date' + ) + + +class FilterForm(DynamicAnswerSetBase): + """Filter objects by answerset responses.""" field_class = forms.ModelMultipleChoiceField field_widget = Select2MultipleWidget field_required = False + as_filters = True + + +class NetworkRelationshipFilterForm(FilterForm): + """Filer relationships by answerset responses.""" question_model = models.RelationshipQuestion answer_model = models.RelationshipQuestionChoice question_prefix = 'relationship_' - def __init__(self, *args, **kwargs): - super().__init__(*args, as_filters=True, **kwargs) - # Add date field to select relationships at a particular point in time - self.fields['date'] = forms.DateField( - required=False, - widget=DatePickerInput(format='%Y-%m-%d'), - help_text='Show relationships as they were on this date') - - -class NetworkPersonFilterForm(DynamicAnswerSetBase): - """Form to provide filtering on the network view.""" - field_class = forms.ModelMultipleChoiceField - field_widget = Select2MultipleWidget - field_required = False +class NetworkPersonFilterForm(FilterForm): + """Filer people by answerset responses.""" question_model = models.PersonQuestion answer_model = models.PersonQuestionChoice question_prefix = 'person_' - def __init__(self, *args, **kwargs): - super().__init__(*args, as_filters=True, **kwargs) - # Add date field to select relationships at a particular point in time - self.fields['date'] = forms.DateField( - required=False, - widget=DatePickerInput(format='%Y-%m-%d'), - help_text='Show relationships as they were on this date') - - -class NetworkOrganisationFilterForm(DynamicAnswerSetBase): - """Form to provide filtering on the network view.""" - field_class = forms.ModelMultipleChoiceField - field_widget = Select2MultipleWidget - field_required = False +class NetworkOrganisationFilterForm(FilterForm): + """Filer organisations by answerset responses.""" question_model = models.OrganisationQuestion answer_model = models.OrganisationQuestionChoice question_prefix = 'organisation_' - - def __init__(self, *args, **kwargs): - super().__init__(*args, as_filters=True, **kwargs) - - # Add date field to select relationships at a particular point in time - self.fields['date'] = forms.DateField( - required=False, - widget=DatePickerInput(format='%Y-%m-%d'), - help_text='Show relationships as they were on this date') diff --git a/people/static/js/network.js b/people/static/js/network.js index 8309cc5..3b421a5 100644 --- a/people/static/js/network.js +++ b/people/static/js/network.js @@ -195,6 +195,10 @@ function get_network() { }); layout.run(); + + setTimeout(function () { + document.getElementById('cy').style.height = '100%'; + }, 1000) } $(window).on('load', get_network()); diff --git a/people/templates/people/network.html b/people/templates/people/network.html index 4d17f22..4e36eff 100644 --- a/people/templates/people/network.html +++ b/people/templates/people/network.html @@ -2,6 +2,7 @@ {% block extra_head %} {# There's no 'form' so need to add this to load CSS / JS #} + {{ date_form.media.css }} {{ relationship_form.media.css }} -

Network View

- -
- -
- {% csrf_token %} - {% load bootstrap4 %} - -
-
-

Filter Relationships

- {% bootstrap_form relationship_form exclude='date' %} -
- -
-

Filter People

- {% bootstrap_form person_form exclude='date' %} -
- -
-

Filter Organisations

- {% bootstrap_form organisation_form exclude='date' %} -
-
- -
-
-
- {% bootstrap_field relationship_form.date %} -
- -
-
- {% bootstrap_field person_form.date %} -
- -
-
- {% bootstrap_field organisation_form.date %} -
-
- -
- - {% buttons %} -
-
- -
- -
- -
-
- {% endbuttons %} -
-
- +
+ {% csrf_token %} + {% load bootstrap4 %} + + {% buttons %} + + + {% endbuttons %} + + {% bootstrap_form date_form %} +
+ +

Filter Relationships

+ {% bootstrap_form relationship_form %} +
+ +

Filter People

+ {% bootstrap_form person_form %} +
+ +

Filter Organisations

+ {% bootstrap_form organisation_form %} +
-
- -
+
+
+
+ + +
-
- - +
+ + +
+
+ +
- -
{% endblock %} {% block extra_script %} + {{ date_form.media.js }} {{ relationship_form.media.js }}