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 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..6627eac 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -16,6 +16,10 @@ https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ Many configuration settings are input from `settings.ini`. The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABASE_URL, PROJECT_*_NAME, EMAIL_* +- PARENT_PROJECT_NAME + default: Parent Project Name + Displayed in templates where the name of the parent project should be used + - PROJECT_LONG_NAME default: Project Long Name Displayed in templates where the full name of the project should be used @@ -100,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 @@ -115,11 +118,14 @@ import dj_database_url SETTINGS_EXPORT = [ 'DEBUG', + 'PARENT_PROJECT_NAME', 'PROJECT_LONG_NAME', 'PROJECT_SHORT_NAME', 'GOOGLE_MAPS_API_KEY', ] +PARENT_PROJECT_NAME = config('PARENT_PROJECT_NAME', + default='Parent Project Name') PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name') PROJECT_SHORT_NAME = config('PROJECT_SHORT_NAME', default='shortname') @@ -157,6 +163,9 @@ THIRD_PARTY_APPS = [ 'django_select2', 'rest_framework', 'post_office', + 'bootstrap_datepicker_plus', + 'hijack', + 'compat', ] FIRST_PARTY_APPS = [ @@ -264,7 +273,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/ @@ -327,24 +336,53 @@ LOGGING = { LOGGING_CONFIG = None logging.config.dictConfig(LOGGING) -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) # pylint: disable=invalid-name # Admin panel variables -CONSTANCE_CONFIG = collections.OrderedDict([ - ('NOTICE_TEXT', - ('', - 'Text to be displayed in a notice banner at the top of every page.')), - ('NOTICE_CLASS', ('alert-warning', - 'CSS class to use for background of notice banner.')), -]) +CONSTANCE_CONFIG = { + 'NOTICE_TEXT': ( + '', + 'Text to be displayed in a notice banner at the top of every page.'), + 'NOTICE_CLASS': ( + 'alert-warning', + 'CSS class to use for background of notice banner.'), + '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.'), + 'PERSON_LIST_HELP': ( + '', + 'Help text to display at the top of the people list.'), + '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 = { - 'Notice Banner': ('NOTICE_TEXT', 'NOTICE_CLASS'), -} + 'Notice Banner': ( + 'NOTICE_TEXT', + 'NOTICE_CLASS', + ), + 'Data Collection': ( + 'CONSENT_TEXT', + ), + 'Help Text': ( + 'PERSON_LIST_HELP', + 'ORGANISATION_LIST_HELP', + 'RELATIONSHIP_FORM_HELP', + ), +} # yapf: disable 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 @@ -379,12 +417,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..ef622cb 100644 --- a/breccia_mapper/templates/base.html +++ b/breccia_mapper/templates/base.html @@ -10,6 +10,7 @@ + {{ settings.PROJECT_LONG_NAME }} {% bootstrap_css %} @@ -27,6 +28,10 @@ {% load staticfiles %} + + {% if 'javascript_in_head'|bootstrap_setting %} {% if 'include_jquery'|bootstrap_setting %} {# jQuery JavaScript if it is in head #} @@ -79,7 +84,7 @@ @@ -18,6 +18,14 @@

Update Relationship

+ {% with config.RELATIONSHIP_FORM_HELP as help_text %} + {% if help_text %} +
+ {{ help_text|linebreaks }} +
+ {% endif %} + {% endwith %} +
{% endblock %} + +{% block extra_script %} + {% load staticfiles %} + +{% endblock %} diff --git a/people/urls.py b/people/urls.py index 955d343..25e526f 100644 --- a/people/urls.py +++ b/people/urls.py @@ -6,6 +6,8 @@ from . import views app_name = 'people' urlpatterns = [ + #################### + # Organisation views path('organisations/create', views.organisation.OrganisationCreateView.as_view(), name='organisation.create'), @@ -22,6 +24,8 @@ urlpatterns = [ views.organisation.OrganisationUpdateView.as_view(), name='organisation.update'), + ############## + # Person views path('profile/', views.person.ProfileView.as_view(), name='person.profile'), @@ -42,6 +46,8 @@ urlpatterns = [ views.person.PersonUpdateView.as_view(), name='person.update'), + #################### + # Relationship views path('people//relationships/create', views.relationship.RelationshipCreateView.as_view(), name='person.relationship.create'), @@ -50,13 +56,37 @@ urlpatterns = [ views.relationship.RelationshipDetailView.as_view(), name='relationship.detail'), - path('relationships//update', + path('relationships//update', 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', + views.relationship.OrganisationRelationshipCreateView.as_view(), + name='organisation.relationship.create'), + + path('organisation-relationships/', + views.relationship.OrganisationRelationshipDetailView.as_view(), + name='organisation.relationship.detail'), + + path('organisation-relationships//update', + 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', - views.person.PersonMapView.as_view(), - name='person.map'), + views.map.MapView.as_view(), + name='map'), path('network', views.network.NetworkView.as_view(), diff --git a/people/views/__init__.py b/people/views/__init__.py index 26a117e..353fc4a 100644 --- a/people/views/__init__.py +++ b/people/views/__init__.py @@ -3,6 +3,7 @@ Views for displaying or manipulating models within the `people` app. """ from . import ( + map, network, organisation, person, @@ -11,6 +12,7 @@ from . import ( __all__ = [ + 'map', 'network', 'organisation', 'person', diff --git a/people/views/map.py b/people/views/map.py new file mode 100644 index 0000000..cd34d5d --- /dev/null +++ b/people/views/map.py @@ -0,0 +1,52 @@ +import typing + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import QuerySet +from django.urls import reverse +from django.utils import timezone +from django.views.generic import TemplateView + +from people import forms, models, permissions + + +def get_map_data(obj: typing.Union[models.Person, models.Organisation]) -> typing.Dict[str, typing.Any]: + """Prepare data to mark people or organisations on a map.""" + answer_set = obj.current_answers + organisation = getattr(answer_set, 'organisation', None) + + try: + country = answer_set.country_of_residence.name + + except AttributeError: + country = None + + return { + 'name': obj.name, + 'lat': getattr(answer_set, 'latitude', None), + 'lng': getattr(answer_set, 'longitude', None), + 'organisation': getattr(organisation, 'name', None), + 'org_lat': getattr(organisation, 'latitude', None), + 'org_lng': getattr(organisation, 'longitude', None), + 'country': country, + 'url': obj.get_absolute_url(), + 'type': type(obj).__name__, + } + + +class MapView(LoginRequiredMixin, TemplateView): + """View displaying a map of :class:`Person` and :class:`Organisation` locations.""" + template_name = 'people/map.html' + + def get_context_data(self, + **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + context = super().get_context_data(**kwargs) + + map_markers = [] + + map_markers.extend( + get_map_data(person) for person in models.Person.objects.all()) + map_markers.extend( + get_map_data(org) for org in models.Organisation.objects.all()) + context['map_markers'] = map_markers + + return context diff --git a/people/views/network.py b/people/views/network.py index 58addd2..65dab85 100644 --- a/people/views/network.py +++ b/people/views/network.py @@ -5,90 +5,152 @@ Views for displaying networks of :class:`People` and :class:`Relationship`s. import logging from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import Q +from django.db.models import Q, QuerySet 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_by_form_answers(queryset: QuerySet, answerset_queryset: QuerySet, relationship_key: str): + """Build a filter to select based on form responses.""" + def inner(form, at_date=None): + # 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 + + if not at_date: + at_date = timezone.now().date() + at_date += timezone.timedelta(days=1) + + # Filter to answersets valid at required time + answerset_set = answerset_queryset.prefetch_related('question_answers').filter( + Q(replaced_timestamp__gte=at_date) + | Q(replaced_timestamp__isnull=True), + timestamp__lte=at_date + ) + + # Filter to answersets containing required answers + 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 queryset.filter(pk__in=answerset_set.values_list(relationship_key, flat=True)) + + return inner + + +filter_relationships = filter_by_form_answers( + models.Relationship.objects.prefetch_related('source', 'target'), + models.RelationshipAnswerSet.objects, 'relationship' +) + +filter_organisations = filter_by_form_answers( + models.Organisation.objects, models.OrganisationAnswerSet.objects, 'organisation' +) + +filter_people = filter_by_form_answers( + models.Person.objects, models.PersonAnswerSet.objects, 'person' +) + + +class NetworkView(LoginRequiredMixin, TemplateView): + """View to display relationship network.""" template_name = 'people/network.html' - form_class = forms.NetworkFilterForm + + def post(self, request, *args, **kwargs): + 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(all_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), + 'date': forms.DateForm(**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 def get_context_data(self, **kwargs): - """ - Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context. - """ + """Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context.""" context = super().get_context_data(**kwargs) - form: forms.NetworkFilterForm = context['form'] - if not form.is_valid(): + context['full_width_page'] = True + + all_forms = self.get_forms() + context['relationship_form'] = all_forms['relationship'] + context['person_form'] = all_forms['person'] + context['organisation_form'] = all_forms['organisation'] + context['date_form'] = all_forms['date'] + + if not all(map(lambda f: f.is_valid(), all_forms.values())): return context - at_date = form.cleaned_data['date'] - if not at_date: - at_date = timezone.now().date() - - # Filter on timestamp__date doesn't seem to work on MySQL - # To compare datetimes we need at_date to be midnight at - # the *end* of the day in question - so add one day here - at_date += timezone.timedelta(days=1) - - relationship_answerset_set = models.RelationshipAnswerSet.objects.filter( - Q(replaced_timestamp__gte=at_date) - | Q(replaced_timestamp__isnull=True), - timestamp__lte=at_date) - - logger.info('Found %d relationship answer sets for %s', - relationship_answerset_set.count(), at_date) - - # Filter answers to relationship questions - for field, values in form.cleaned_data.items(): - if field.startswith('question_') and values: - relationship_answerset_set = relationship_answerset_set.filter( - question_answers__in=values) - - logger.info('Found %d relationship answer sets matching filters', - relationship_answerset_set.count()) + date = all_forms['date'].cleaned_data['date'] context['person_set'] = serializers.PersonSerializer( - models.Person.objects.all(), many=True).data + filter_people(all_forms['person'], at_date=date), many=True + ).data + + context['organisation_set'] = serializers.OrganisationSerializer( + filter_organisations(all_forms['organisation'], at_date=date), many=True + ).data context['relationship_set'] = serializers.RelationshipSerializer( - models.Relationship.objects.filter( - pk__in=relationship_answerset_set.values_list('relationship', - flat=True)), - many=True).data + filter_relationships(all_forms['relationship'], at_date=date), many=True + ).data - logger.info('Found %d distinct relationships matching filters', - len(context['relationship_set'])) + context['organisation_relationship_set'] = serializers.OrganisationRelationshipSerializer( + models.OrganisationRelationship.objects.prefetch_related('source', 'target').all(), + many=True + ).data + + 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 - def form_valid(self, form): + def forms_valid(self, all_forms): try: return self.render_to_response(self.get_context_data()) except ValidationError: - return self.form_invalid(form) + return self.forms_invalid(all_forms) + + def forms_invalid(self, all_forms): + return self.render_to_response(self.get_context_data()) diff --git a/people/views/organisation.py b/people/views/organisation.py index c67542d..eb06938 100644 --- a/people/views/organisation.py +++ b/people/views/organisation.py @@ -1,9 +1,13 @@ 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 from people import forms, models +from .map import get_map_data class OrganisationCreateView(LoginRequiredMixin, CreateView): @@ -13,11 +17,100 @@ class OrganisationCreateView(LoginRequiredMixin, CreateView): form_class = forms.OrganisationForm +def try_copy_by_key(src_dict: typing.Mapping[str, typing.Any], + dest_dict: typing.MutableMapping[str, typing.Any], + key: str) -> None: + """Copy a value by key from one dictionary to another. + + If the key does not exist, skip it. + """ + value = src_dict.get(key, None) + if value is not None: + dest_dict[key] = value + + class OrganisationListView(LoginRequiredMixin, ListView): """View displaying a list of :class:`organisation` objects.""" model = models.Organisation template_name = 'people/organisation/list.html' + @staticmethod + def sort_organisation_countries( + orgs_by_country: typing.MutableMapping[str, typing.Any] + ) -> typing.Dict[str, typing.Any]: + """Sort dictionary of organisations by country. + + Sort order: + - Project partners + - International organisations + - Organisations by country alphabetically + - Organisations with unknown country + """ + orgs_sorted = {} + + try_copy_by_key(orgs_by_country, orgs_sorted, + f'{settings.PARENT_PROJECT_NAME} partners') + try_copy_by_key(orgs_by_country, orgs_sorted, 'International') + + special = { + f'{settings.PARENT_PROJECT_NAME} partners', 'International', + 'Unknown' + } + for country in sorted(k for k in orgs_by_country.keys() + if k not in special): + orgs_sorted[country] = orgs_by_country[country] + + try_copy_by_key(orgs_by_country, orgs_sorted, 'Unknown') + + return orgs_sorted + + def get_context_data(self, + **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + context = super().get_context_data(**kwargs) + + orgs_by_country = {} + for organisation in self.get_queryset().all(): + answers = organisation.current_answers + + country = 'Unknown' + try: + if len(answers.countries) == 1: + country = answers.countries[0].name + + elif len(answers.countries) > 1: + country = 'International' + + if answers.is_partner_organisation: + country = f'{settings.PARENT_PROJECT_NAME} partners' + + except AttributeError: + # Organisation has no AnswerSet - country is 'Unknown' + pass + + orgs = orgs_by_country.get(country, []) + orgs.append(organisation) + orgs_by_country[country] = orgs + + # Sort into meaningful order + context['orgs_by_country'] = self.sort_organisation_countries( + orgs_by_country) + + 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 + class OrganisationDetailView(LoginRequiredMixin, DetailView): """View displaying details of a :class:`Organisation`.""" @@ -30,11 +123,26 @@ class OrganisationDetailView(LoginRequiredMixin, DetailView): """Add map marker to context.""" context = super().get_context_data(**kwargs) - context['map_markers'] = [{ - 'name': self.object.name, - 'lat': self.object.latitude, - 'lng': self.object.longitude, - }] + answer_set = self.object.current_answers + context['answer_set'] = answer_set + context['map_markers'] = [get_map_data(self.object)] + + 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( + source=self.request.user.person, target=self.object) + + if relationship.is_current: + context['relationship'] = relationship + + except models.OrganisationRelationship.DoesNotExist: + pass return context @@ -44,17 +152,48 @@ class OrganisationUpdateView(LoginRequiredMixin, UpdateView): model = models.Organisation context_object_name = 'organisation' template_name = 'people/organisation/update.html' - form_class = forms.OrganisationForm + form_class = forms.OrganisationAnswerSetForm def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: """Add map marker to context.""" context = super().get_context_data(**kwargs) - context['map_markers'] = [{ - 'name': self.object.name, - 'lat': self.object.latitude, - 'lng': self.object.longitude, - }] + answerset = self.object.current_answers + context['map_markers'] = [get_map_data(self.object)] return context + + def get_initial(self) -> typing.Dict[str, typing.Any]: + try: + previous_answers = self.object.current_answers.as_dict() + + except AttributeError: + previous_answers = {} + + previous_answers.update({ + 'organisation_id': self.object.id, + }) + + return previous_answers + + def get_form_kwargs(self) -> typing.Dict[str, typing.Any]: + """Remove instance from form kwargs as it's an Organisation, but expects an OrganisationAnswerSet.""" + kwargs = super().get_form_kwargs() + kwargs.pop('instance') + + return kwargs + + def form_valid(self, form): + """Mark any previous answer sets as replaced.""" + response = super().form_valid(form) + now_date = timezone.now().date() + + # Saving the form made self.object an OrganisationAnswerSet - so go up, then back down + # Shouldn't be more than one after initial updates after migration + for answer_set in self.object.organisation.answer_sets.exclude( + pk=self.object.pk): + answer_set.replaced_timestamp = now_date + answer_set.save() + + return response diff --git a/people/views/person.py b/people/views/person.py index 73f2e3b..353e192 100644 --- a/people/views/person.py +++ b/people/views/person.py @@ -5,16 +5,18 @@ Views for displaying or manipulating instances of :class:`Person`. import typing from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse +from django.core.exceptions import ObjectDoesNotExist +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect from django.utils import timezone from django.views.generic import CreateView, DetailView, ListView, UpdateView from people import forms, models, permissions +from .map import get_map_data class PersonCreateView(LoginRequiredMixin, CreateView): - """ - View to create a new instance of :class:`Person`. + """View to create a new instance of :class:`Person`. If 'user' is passed as a URL parameter - link the new person to the current user. """ @@ -30,23 +32,57 @@ class PersonCreateView(LoginRequiredMixin, CreateView): class PersonListView(LoginRequiredMixin, ListView): - """ - View displaying a list of :class:`Person` objects - searchable. - """ + """View displaying a list of :class:`Person` objects - searchable.""" model = models.Person template_name = 'people/person/list.html' + def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + context = super().get_context_data(**kwargs) -class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView): - """ - View displaying the profile of a :class:`Person` - who may be a user. - """ + existing_relationships = set() + try: + existing_relationships = set( + 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 + pass + + context['existing_relationships'] = existing_relationships + + return context + + +class ProfileView(LoginRequiredMixin, DetailView): + """View displaying the profile of a :class:`Person` - who may be a user.""" model = models.Person - template_name = 'people/person/detail.html' + + def get(self, request: HttpRequest, *args: typing.Any, **kwargs: typing.Any) -> HttpResponse: + try: + self.object = self.get_object() # pylint: disable=attribute-defined-outside-init + + except ObjectDoesNotExist: + # User has no linked Person yet + return redirect('index') + + if self.object.user == self.request.user and self.object.current_answers is None: + return redirect('people:person.update', pk=self.object.pk) + + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + 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: - """ - Get the :class:`Person` object to be represented by this page. + """Get the :class:`Person` object to be represented by this page. If not determined from url get current user. """ @@ -57,14 +93,31 @@ class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView): # pk was not provided in URL return self.request.user.person - def get_context_data(self, - **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: """Add current :class:`PersonAnswerSet` to context.""" context = super().get_context_data(**kwargs) - context['answer_set'] = self.object.current_answers + answer_set = self.object.current_answers + context['answer_set'] = 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( + source=self.request.user.person, target=self.object + ) + + if relationship.is_current: + context['relationship'] = relationship + + except models.Relationship.DoesNotExist: + pass + return context @@ -75,8 +128,21 @@ class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView): template_name = 'people/person/update.html' form_class = forms.PersonAnswerSetForm - def get_context_data(self, - **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + def get(self, request: HttpRequest, *args: str, **kwargs: typing.Any) -> HttpResponse: + self.object = self.get_object() + + try: + if (self.object.user == self.request.user) and not self.request.user.consent_given: + return redirect('consent') + + except AttributeError: + # No linked user + pass + + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: context = super().get_context_data(**kwargs) context['map_markers'] = [get_map_data(self.object)] @@ -110,48 +176,8 @@ class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView): # Saving the form made self.object a PersonAnswerSet - so go up, then back down # Shouldn't be more than one after initial updates after migration - for answer_set in self.object.person.answer_sets.exclude( - pk=self.object.pk): + for answer_set in self.object.person.answer_sets.exclude(pk=self.object.pk): answer_set.replaced_timestamp = now_date answer_set.save() return response - - -def get_map_data(person: models.Person) -> typing.Dict[str, typing.Any]: - """Prepare data to mark people on a map.""" - answer_set = person.current_answers - organisation = getattr(answer_set, 'organisation', None) - - try: - country = answer_set.country_of_residence.name - - except AttributeError: - country = None - - return { - 'name': person.name, - 'lat': getattr(answer_set, 'latitude', None), - 'lng': getattr(answer_set, 'longitude', None), - 'organisation': getattr(organisation, 'name', None), - 'org_lat': getattr(organisation, 'latitude', None), - 'org_lng': getattr(organisation, 'longitude', None), - 'country': country, - 'url': reverse('people:person.detail', kwargs={'pk': person.pk}) - } - - -class PersonMapView(LoginRequiredMixin, ListView): - """View displaying a map of :class:`Person` locations.""" - model = models.Person - template_name = 'people/person/map.html' - - def get_context_data(self, - **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: - context = super().get_context_data(**kwargs) - - context['map_markers'] = [ - get_map_data(person) for person in self.object_list - ] - - return context diff --git a/people/views/relationship.py b/people/views/relationship.py index 3ea46d9..66589c6 100644 --- a/people/views/relationship.py +++ b/people/views/relationship.py @@ -1,12 +1,12 @@ -""" -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 DetailView, RedirectView, UpdateView +from django.views.generic.detail import SingleObjectMixin from people import forms, models, permissions @@ -19,109 +19,159 @@ class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView): template_name = 'people/relationship/detail.html' related_person_field = 'source' - -class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, FormView): - """ - View for creating a :class:`Relationship`. - - 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): + 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) - context['person'] = self.get_person() + 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 - def get_success_url(self): - return reverse('people:relationship.update', - kwargs={'relationship_pk': self.object.pk}) +class RelationshipCreateView(LoginRequiredMixin, RedirectView): + """View for creating a :class:`Relationship`. -class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView): + Redirects to a form containing the :class:`RelationshipQuestion`s. """ - View for updating the details of a 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) + + return reverse('people:relationship.update', + kwargs={'pk': relationship.pk}) + + +class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView): + """View for updating the details of a relationship. Creates a new :class:`RelationshipAnswerSet` for the :class:`Relationship`. Displays / processes a form containing the :class:`RelationshipQuestion`s. """ - model = models.RelationshipAnswerSet + model = models.Relationship + context_object_name = 'relationship' template_name = 'people/relationship/update.html' form_class = forms.RelationshipAnswerSetForm def get_test_person(self) -> models.Person: - """ - Get the person instance which should be used for access control checks. - """ - relationship = models.Relationship.objects.get( - pk=self.kwargs.get('relationship_pk')) - - return relationship.source - - def get(self, request, *args, **kwargs): - self.relationship = models.Relationship.objects.get( - pk=self.kwargs.get('relationship_pk')) - self.person = self.relationship.source - - return super().get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - self.relationship = models.Relationship.objects.get( - pk=self.kwargs.get('relationship_pk')) - self.person = self.relationship.source - - return super().post(request, *args, **kwargs) + """Get the person instance which should be used for access control checks.""" + return self.get_object().source def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - - context['person'] = self.person - context['relationship'] = self.relationship - + context['person'] = self.object.source return context def get_initial(self): - initial = super().get_initial() + try: + previous_answers = self.object.current_answers.as_dict() - initial['relationship'] = self.relationship + except AttributeError: + previous_answers = {} - return initial + previous_answers.update({ + 'relationship': self.object, + }) + + return previous_answers + + def get_form_kwargs(self) -> typing.Dict[str, typing.Any]: + """Remove instance from form kwargs as it's a person, but expects a PersonAnswerSet.""" + kwargs = super().get_form_kwargs() + kwargs.pop('instance') + + return kwargs def form_valid(self, form): - """ - Mark any previous answer sets as replaced. - """ + """Mark any previous answer sets as replaced.""" response = super().form_valid(form) now_date = timezone.now().date() # Shouldn't be more than one after initial updates after migration - for answer_set in self.relationship.answer_sets.exclude( + for answer_set in self.object.relationship.answer_sets.exclude( pk=self.object.pk): answer_set.replaced_timestamp = now_date answer_set.save() return response + + def get_success_url(self) -> str: + 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 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(RelationshipDetailView): + """View displaying details of an :class:`OrganisationRelationship`.""" + model = models.OrganisationRelationship + template_name = 'people/organisation-relationship/detail.html' + related_person_field = 'source' + context_object_name = 'relationship' + + +class OrganisationRelationshipCreateView(LoginRequiredMixin, RedirectView): + """View for creating a :class:`OrganisationRelationship`. + + Redirects to a form containing the :class:`OrganisationRelationshipQuestion`s. + """ + def get_redirect_url(self, *args: typing.Any, + **kwargs: typing.Any) -> typing.Optional[str]: + target = models.Organisation.objects.get( + pk=self.kwargs.get('organisation_pk')) + relationship, _ = models.OrganisationRelationship.objects.get_or_create( + source=self.request.user.person, target=target) + + return reverse('people:organisation.relationship.update', + kwargs={'pk': relationship.pk}) + + +class OrganisationRelationshipUpdateView(RelationshipUpdateView): + """View for updating the details of a Organisationrelationship. + + Creates a new :class:`OrganisationRelationshipAnswerSet` for the :class:`OrganisationRelationship`. + Displays / processes a form containing the :class:`OrganisationRelationshipQuestion`s. + """ + model = models.OrganisationRelationship + context_object_name = 'relationship' + template_name = 'people/relationship/update.html' + form_class = forms.OrganisationRelationshipAnswerSetForm diff --git a/requirements.txt b/requirements.txt index 2877175..069d417 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,13 @@ 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-compat==1.0.15 django-constance==2.6.0 django-countries==5.5 django-dbbackup==3.2.0 django-filter==2.2.0 +django-hijack==2.2.1 django-picklefield==2.1.1 django-post-office==3.4.0 django-select2==7.2.0 diff --git a/roles/webserver/defaults/main.yml b/roles/webserver/defaults/main.yml index bc0d0af..5608fa8 100644 --- a/roles/webserver/defaults/main.yml +++ b/roles/webserver/defaults/main.yml @@ -7,6 +7,7 @@ deploy_mode: 3 secret_key: '{{ lookup("password", "/dev/null") }}' +parent_project_name: 'BRECcIA' project_name: 'breccia-mapper' project_full_name: 'breccia_mapper' project_dir: '/var/www/{{ project_name }}' diff --git a/roles/webserver/templates/settings.j2 b/roles/webserver/templates/settings.j2 index e9ccb89..b270b08 100644 --- a/roles/webserver/templates/settings.j2 +++ b/roles/webserver/templates/settings.j2 @@ -11,6 +11,7 @@ ALLOWED_HOSTS={% for h in allowed_hosts %}{{ h }},{% endfor %} ALLOWED_HOSTS={{ inventory_hostname }},localhost,127.0.0.1 {% endif %} +PARENT_PROJECT_NAME={{ parent_project_name }} PROJECT_SHORT_NAME={{ display_short_name }} PROJECT_LONG_NAME={{ display_long_name }}