diff --git a/.gitignore b/.gitignore index 5a7cc0c..0d631fd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ deployment-key deployment-key.pub # Deployment +/.dbbackup/ .vagrant/ staging.yml -/.dbbackup/ +production.yml diff --git a/Makefile b/Makefile index adc709c..4187306 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,8 @@ lint: .PHONY: staging staging: - ansible-playbook -v -i staging.yml playbook.yml -u jag1e17 -K + env ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i staging.yml playbook.yml -u jag1e17 -K .PHONY: production production: - ansible-playbook -v -i production.yml playbook.yml -u jag1e17 -K + env ANSIBLE_STDOUT_CALLBACK=debug ansible-playbook -v -i production.yml playbook.yml -u jag1e17 -K diff --git a/activities/views.py b/activities/views.py index a2b4a0e..72b2d55 100644 --- a/activities/views.py +++ b/activities/views.py @@ -3,6 +3,7 @@ Views for displaying / manipulating models within the Activities app. """ import json +from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponse from django.views.generic import DetailView, ListView, View from django.views.generic.detail import SingleObjectMixin @@ -12,7 +13,7 @@ from people import permissions from . import models -class ActivitySeriesListView(ListView): +class ActivitySeriesListView(LoginRequiredMixin, ListView): """ View displaying a list of :class:`ActivitySeries`. """ @@ -21,7 +22,7 @@ class ActivitySeriesListView(ListView): context_object_name = 'activity_series_list' -class ActivitySeriesDetailView(DetailView): +class ActivitySeriesDetailView(LoginRequiredMixin, DetailView): """ View displaying details of a single :class:`ActivitySeries`. """ @@ -30,7 +31,7 @@ class ActivitySeriesDetailView(DetailView): context_object_name = 'activity_series' -class ActivityListView(ListView): +class ActivityListView(LoginRequiredMixin, ListView): """ View displaying a list of :class:`Activity`. """ @@ -38,7 +39,7 @@ class ActivityListView(ListView): template_name = 'activities/activity/list.html' -class ActivityDetailView(DetailView): +class ActivityDetailView(LoginRequiredMixin, DetailView): """ View displaying details of a single :class:`Activity`. """ diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index a8c06c4..16d6569 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -77,6 +77,7 @@ THIRD_PARTY_APPS = [ FIRST_PARTY_APPS = [ 'people', 'activities', + 'export', ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + FIRST_PARTY_APPS @@ -141,6 +142,11 @@ REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', ], + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + 'rest_framework_csv.renderers.CSVRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer', + ], } diff --git a/breccia_mapper/templates/base.html b/breccia_mapper/templates/base.html index 123d2ea..3d2ddc0 100644 --- a/breccia_mapper/templates/base.html +++ b/breccia_mapper/templates/base.html @@ -78,6 +78,10 @@ {% if request.user.is_superuser %} + + diff --git a/breccia_mapper/urls.py b/breccia_mapper/urls.py index e5f431b..2806827 100644 --- a/breccia_mapper/urls.py +++ b/breccia_mapper/urls.py @@ -28,6 +28,9 @@ urlpatterns = [ views.IndexView.as_view(), name='index'), + path('', + include('export.urls')), + path('', include('people.urls')), diff --git a/breccia_mapper/views.py b/breccia_mapper/views.py index 2844b2c..272f7c9 100644 --- a/breccia_mapper/views.py +++ b/breccia_mapper/views.py @@ -1,3 +1,9 @@ +""" +Views belonging to the core of the project. + +These views don't represent any of the models in the apps. +""" + from django.views.generic import TemplateView diff --git a/export/__init__.py b/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/export/apps.py b/export/apps.py new file mode 100644 index 0000000..15c9d19 --- /dev/null +++ b/export/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ExportConfig(AppConfig): + name = 'export' diff --git a/export/serializers/__init__.py b/export/serializers/__init__.py new file mode 100644 index 0000000..104caf6 --- /dev/null +++ b/export/serializers/__init__.py @@ -0,0 +1,4 @@ +from . import ( + activities, + people +) diff --git a/export/serializers/activities.py b/export/serializers/activities.py new file mode 100644 index 0000000..a2069cd --- /dev/null +++ b/export/serializers/activities.py @@ -0,0 +1,52 @@ +from rest_framework import serializers + +from activities import models + +from . import base + + +class ActivityTypeSerializer(serializers.ModelSerializer): + class Meta: + model = models.ActivityType + fields = [ + 'pk', + 'name', + ] + + +class ActivityMediumSerializer(serializers.ModelSerializer): + class Meta: + model = models.ActivityMedium + fields = [ + 'pk', + 'name', + ] + + +class ActivitySeriesSerializer(serializers.ModelSerializer): + type = ActivityTypeSerializer() + medium = ActivityMediumSerializer() + + class Meta: + model = models.ActivitySeries + fields = [ + 'pk', + 'name', + 'type', + 'medium', + ] + + +class ActivitySerializer(base.FlattenedModelSerializer): + series = ActivitySeriesSerializer() + type = ActivityTypeSerializer() + medium = ActivityMediumSerializer() + + class Meta: + model = models.Activity + fields = [ + 'name', + 'series', + 'type', + 'medium', + ] diff --git a/export/serializers/base.py b/export/serializers/base.py new file mode 100644 index 0000000..f8e359c --- /dev/null +++ b/export/serializers/base.py @@ -0,0 +1,65 @@ +""" +Serialize models to and deserialize from JSON. +""" + +from collections import OrderedDict +import typing + +from rest_framework import serializers + + +class FlattenedModelSerializer(serializers.ModelSerializer): + @classmethod + def flatten_data(cls, data, + sub_type: typing.Type = dict, + sub_value_accessor: typing.Callable = lambda x: x.items()) -> OrderedDict: + """ + Flatten a dictionary so that subdictionaries become a series of `key[.subkey[.subsubkey ...]]` entries + in the top level dictionary. + + Works for other data structures (e.g. DRF Serializers) by providing suitable values for the + `sub_type` and `sub_value_accessor` parameters. + + :param data: Dictionary or other data structure to flatten + :param sub_type: Type to recursively flatten + :param sub_value_accessor: Function to access keys and values contained within sub_type. + """ + data_out = OrderedDict() + + for key, value in sub_value_accessor(data): + if isinstance(value, sub_type): + # Recursively flatten nested structures of type `sub_type` + sub_flattened = cls.flatten_data(value, + sub_type=sub_type, + sub_value_accessor=sub_value_accessor).items() + + # Enter recursively flattened values into result dictionary + for sub_key, sub_value in sub_flattened: + # Keys in result dictionary are of format `key[.subkey[.subsubkey ...]]` + data_out[f'{key}.{sub_key}'] = sub_value + + elif value is not None: + data_out[key] = value + + return data_out + + @property + def column_headers(self) -> typing.List[str]: + """ + Get all column headers that will be output by this serializer. + """ + fields = self.flatten_data(self.fields, + sub_type=serializers.BaseSerializer, + sub_value_accessor=lambda x: x.fields.items()) + + return list(fields) + + def to_representation(self, instance) -> OrderedDict: + """ + + """ + rep = super().to_representation(instance) + + rep = self.flatten_data(rep) + + return rep diff --git a/export/serializers/people.py b/export/serializers/people.py new file mode 100644 index 0000000..baed3bb --- /dev/null +++ b/export/serializers/people.py @@ -0,0 +1,66 @@ +import typing + +from rest_framework import serializers + +from people import models + +from . import base + + +class SimplePersonSerializer(serializers.ModelSerializer): + class Meta: + model = models.Person + fields = [ + 'pk', + 'name', + ] + + +class PersonSerializer(base.FlattenedModelSerializer): + class Meta: + model = models.Person + fields = [ + 'pk', + 'name', + 'core_member', + 'gender', + 'age_group', + 'nationality', + 'country_of_residence', + ] + + +class RelationshipSerializer(base.FlattenedModelSerializer): + source = SimplePersonSerializer() + target = SimplePersonSerializer() + + class Meta: + model = models.Relationship + fields = [ + 'pk', + 'source', + 'target', + ] + + @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(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.current_answers.question_answers.all(): + rep[answer.question.slug] = answer.slug + + except AttributeError: + pass + + return rep diff --git a/export/templates/export/export.html b/export/templates/export/export.html new file mode 100644 index 0000000..e4aa7f0 --- /dev/null +++ b/export/templates/export/export.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} + +{% block content %} + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeRecords
People + Export +
Relationships + Export +
Activities + Export +
+ + +{% endblock %} diff --git a/export/urls.py b/export/urls.py new file mode 100644 index 0000000..7f99467 --- /dev/null +++ b/export/urls.py @@ -0,0 +1,24 @@ +from django.urls import path + +from . import views + + +app_name = 'export' + +urlpatterns = [ + path('export', + views.ExportListView.as_view(), + name='index'), + + path('export/people', + views.people.PersonExportView.as_view(), + name='person'), + + path('export/relationships', + views.people.RelationshipExportView.as_view(), + name='relationship'), + + path('export/activities', + views.activities.ActivityExportView.as_view(), + name='activity'), +] diff --git a/export/views/__init__.py b/export/views/__init__.py new file mode 100644 index 0000000..da2cdd2 --- /dev/null +++ b/export/views/__init__.py @@ -0,0 +1,6 @@ +from .base import ExportListView + +from . import ( + activities, + people +) diff --git a/export/views/activities.py b/export/views/activities.py new file mode 100644 index 0000000..8a74f78 --- /dev/null +++ b/export/views/activities.py @@ -0,0 +1,9 @@ +from . import base +from .. import serializers + +from activities import models + + +class ActivityExportView(base.CsvExportView): + model = models.Activity + serializer_class = serializers.activities.ActivitySerializer diff --git a/export/views/base.py b/export/views/base.py new file mode 100644 index 0000000..8416e47 --- /dev/null +++ b/export/views/base.py @@ -0,0 +1,29 @@ +import csv +import typing + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponse +from django.views.generic import TemplateView +from django.views.generic.list import BaseListView + + +class CsvExportView(LoginRequiredMixin, BaseListView): + model = None + serializer_class = None + + def render_to_response(self, context: typing.Dict) -> HttpResponse: + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="{self.get_context_object_name(self.object_list)}.csv"' + + # 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.writeheader() + writer.writerows(serializer.data) + + return response + + +class ExportListView(LoginRequiredMixin, TemplateView): + template_name = 'export/export.html' diff --git a/export/views/people.py b/export/views/people.py new file mode 100644 index 0000000..7f73d5f --- /dev/null +++ b/export/views/people.py @@ -0,0 +1,14 @@ +from . import base +from .. import serializers + +from people import models + + +class PersonExportView(base.CsvExportView): + model = models.person.Person + serializer_class = serializers.people.PersonSerializer + + +class RelationshipExportView(base.CsvExportView): + model = models.relationship.Relationship + serializer_class = serializers.people.RelationshipSerializer diff --git a/people/models/relationship.py b/people/models/relationship.py index 9065ac8..2776f71 100644 --- a/people/models/relationship.py +++ b/people/models/relationship.py @@ -6,6 +6,7 @@ import typing from django.db import models from django.urls import reverse +from django.utils.text import slugify from .person import Person @@ -42,11 +43,15 @@ class RelationshipQuestion(models.Model): @property def choices(self) -> typing.List[typing.List[str]]: """ - Convert the :class:`RelationshipQuestionChoices` for this question into Django choices. + Convert the :class:`RelationshipQuestionChoice`s for this question into Django choices. """ return [ [choice.pk, str(choice)] for choice in self.answers.all() ] + + @property + def slug(self) -> str: + return slugify(self.text) def __str__(self) -> str: return self.text @@ -80,6 +85,10 @@ class RelationshipQuestionChoice(models.Model): order = models.SmallIntegerField(default=0, blank=False, null=False) + @property + def slug(self) -> str: + return slugify(self.text) + def __str__(self) -> str: return self.text diff --git a/people/serializers.py b/people/serializers.py index 3c98db3..f11004e 100644 --- a/people/serializers.py +++ b/people/serializers.py @@ -14,9 +14,12 @@ class PersonSerializer(serializers.ModelSerializer): 'pk', 'name', ] - + class RelationshipSerializer(serializers.ModelSerializer): + source = PersonSerializer() + target = PersonSerializer() + class Meta: model = models.Relationship fields = [ diff --git a/people/urls.py b/people/urls.py index de38945..eb73fe1 100644 --- a/people/urls.py +++ b/people/urls.py @@ -7,46 +7,38 @@ app_name = 'people' urlpatterns = [ path('profile/', - views.ProfileView.as_view(), + views.person.ProfileView.as_view(), name='person.profile'), path('people/create', - views.PersonCreateView.as_view(), + views.person.PersonCreateView.as_view(), name='person.create'), path('people', - views.PersonListView.as_view(), + views.person.PersonListView.as_view(), name='person.list'), path('people/', - views.ProfileView.as_view(), + views.person.ProfileView.as_view(), name='person.detail'), path('people//update', - views.PersonUpdateView.as_view(), + views.person.PersonUpdateView.as_view(), name='person.update'), path('people//relationships/create', - views.RelationshipCreateView.as_view(), + views.relationship.RelationshipCreateView.as_view(), name='person.relationship.create'), path('relationships/', - views.RelationshipDetailView.as_view(), + views.relationship.RelationshipDetailView.as_view(), name='relationship.detail'), path('relationships//update', - views.RelationshipUpdateView.as_view(), + views.relationship.RelationshipUpdateView.as_view(), name='relationship.update'), - path('api/people', - views.PersonApiView.as_view(), - name='person.api.list'), - - path('api/relationships', - views.RelationshipApiView.as_view(), - name='relationship.api.list'), - path('network', - views.NetworkView.as_view(), + views.network.NetworkView.as_view(), name='network'), ] diff --git a/people/views.py b/people/views.py deleted file mode 100644 index 35c06ea..0000000 --- a/people/views.py +++ /dev/null @@ -1,276 +0,0 @@ -""" -Views for displaying or manipulating models in the 'people' app. -""" - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import Q -from django.urls import reverse -from django.utils import timezone -from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView - -from rest_framework.views import APIView, Response - -from . import forms, models, permissions, serializers - - -class PersonCreateView(CreateView): - """ - 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. - """ - model = models.Person - template_name = 'people/person/create.html' - form_class = forms.PersonForm - - def form_valid(self, form): - if 'user' in self.request.GET: - form.instance.user = self.request.user - - return super().form_valid(form) - - -class PersonListView(ListView): - """ - View displaying a list of :class:`Person` objects - searchable. - """ - model = models.Person - template_name = 'people/person/list.html' - - -class ProfileView(permissions.UserIsLinkedPersonMixin, 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_object(self, queryset=None) -> models.Person: - """ - Get the :class:`Person` object to be represented by this page. - - If not determined from url get current user. - """ - try: - return super().get_object(queryset) - - except AttributeError: - # pk was not provided in URL - return self.request.user.person - - -class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView): - """ - View for updating a :class:`Person` record. - """ - model = models.Person - template_name = 'people/person/update.html' - form_class = forms.PersonForm - - -class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView): - """ - View displaying details of a :class:`Relationship`. - """ - model = models.Relationship - template_name = 'people/relationship/detail.html' - related_person_field = 'source' - - -class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView): - """ - View for creating a :class:`Relationship`. - - Displays / processes a form containing the :class:`RelationshipQuestion`s. - """ - model = models.Relationship - template_name = 'people/relationship/create.html' - fields = [ - 'source', - 'target', - ] - - def get_test_person(self) -> models.Person: - """ - Get the person instance which should be used for access control checks. - """ - if self.request.method == 'POST': - return models.Person.objects.get(pk=self.request.POST.get('source')) - - return models.Person.objects.get(pk=self.kwargs.get('person_pk')) - - def get(self, request, *args, **kwargs): - self.person = models.Person.objects.get(pk=self.kwargs.get('person_pk')) - - return super().get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - self.person = models.Person.objects.get(pk=self.kwargs.get('person_pk')) - - return super().post(request, *args, **kwargs) - - def get_initial(self): - initial = super().get_initial() - - initial['source'] = self.request.user.person - initial['target'] = self.person - - return initial - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['person'] = self.person - - return context - - def get_success_url(self): - return reverse('people:relationship.update', kwargs={'relationship_pk': self.object.pk}) - - -class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView): - """ - View for creating a :class:`Relationship`. - - Displays / processes a form containing the :class:`RelationshipQuestion`s. - """ - model = models.RelationshipAnswerSet - 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) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['person'] = self.person - context['relationship'] = self.relationship - - return context - - def get_initial(self): - initial = super().get_initial() - - initial['relationship'] = self.relationship - - return initial - - def form_valid(self, form): - """ - Mark any previous answer sets as replaced. - """ - previous_valid_answer_sets = self.relationship.answer_sets.filter(replaced_timestamp__isnull=True) - - response = super().form_valid(form) - - # Shouldn't be more than one after initial updates after migration - for answer_set in previous_valid_answer_sets: - answer_set.replaced_timestamp = timezone.now() - answer_set.save() - - return response - - -class PersonApiView(APIView): - """ - List all :class:`Person` instances. - """ - def get(self, request, format=None): - """ - List all :class:`Person` instances. - """ - serializer = serializers.PersonSerializer(models.Person.objects.all(), - many=True) - return Response(serializer.data) - - -class RelationshipApiView(APIView): - """ - List all :class:`Relationship` instances. - """ - def get(self, request, format=None): - """ - List all :class:`Relationship` instances. - """ - serializer = serializers.RelationshipSerializer(models.Relationship.objects.all(), - many=True) - return Response(serializer.data) - - -class NetworkView(LoginRequiredMixin, FormView): - """ - View to display relationship network. - """ - template_name = 'people/network.html' - form_class = forms.NetworkFilterForm - - def get_form_kwargs(self): - """ - Add GET params to form data. - """ - kwargs = super().get_form_kwargs() - - if self.request.method == 'GET': - if 'data' in kwargs: - kwargs['data'].update(self.request.GET) - - else: - kwargs['data'] = self.request.GET - - return kwargs - - def get_context_data(self, **kwargs): - """ - Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context. - """ - context = super().get_context_data(**kwargs) - form = context['form'] - - at_time = timezone.now() - - relationship_set = models.Relationship.objects.all() - - # Filter answers to relationship questions - for key, value in form.data.items(): - if key.startswith('question_') and value: - question_id = key.replace('question_', '', 1) - answer = models.RelationshipQuestionChoice.objects.get(pk=value, - question__pk=question_id) - relationship_set = relationship_set.filter( - Q(answer_sets__replaced_timestamp__gt=at_time) | Q(answer_sets__replaced_timestamp__isnull=True), - answer_sets__timestamp__lte=at_time, - answer_sets__question_answers=answer - ) - - context['person_set'] = serializers.PersonSerializer( - models.Person.objects.all(), - many=True - ).data - - context['relationship_set'] = serializers.RelationshipSerializer( - relationship_set, - many=True - ).data - - return context - - def form_valid(self, form): - return self.render_to_response(self.get_context_data()) diff --git a/people/views/__init__.py b/people/views/__init__.py new file mode 100644 index 0000000..39dd5c9 --- /dev/null +++ b/people/views/__init__.py @@ -0,0 +1,9 @@ +""" +Views for displaying or manipulating models within the `people` app. +""" + +from . import ( + network, + person, + relationship +) diff --git a/people/views/network.py b/people/views/network.py new file mode 100644 index 0000000..3e02efa --- /dev/null +++ b/people/views/network.py @@ -0,0 +1,72 @@ +""" +Views for displaying networks of :class:`People` and :class:`Relationship`s. +""" + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Q +from django.utils import timezone +from django.views.generic import FormView + + +from people import forms, models, serializers + + +class NetworkView(LoginRequiredMixin, FormView): + """ + View to display relationship network. + """ + template_name = 'people/network.html' + form_class = forms.NetworkFilterForm + + def get_form_kwargs(self): + """ + Add GET params to form data. + """ + kwargs = super().get_form_kwargs() + + if self.request.method == 'GET': + if 'data' in kwargs: + kwargs['data'].update(self.request.GET) + + else: + kwargs['data'] = self.request.GET + + return kwargs + + def get_context_data(self, **kwargs): + """ + Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context. + """ + context = super().get_context_data(**kwargs) + form = context['form'] + + at_time = timezone.now() + + relationship_set = models.Relationship.objects.all() + + # Filter answers to relationship questions + for key, value in form.data.items(): + if key.startswith('question_') and value: + question_id = key.replace('question_', '', 1) + answer = models.RelationshipQuestionChoice.objects.get(pk=value, + question__pk=question_id) + relationship_set = relationship_set.filter( + Q(answer_sets__replaced_timestamp__gt=at_time) | Q(answer_sets__replaced_timestamp__isnull=True), + answer_sets__timestamp__lte=at_time, + answer_sets__question_answers=answer + ) + + context['person_set'] = serializers.PersonSerializer( + models.Person.objects.all(), + many=True + ).data + + context['relationship_set'] = serializers.RelationshipSerializer( + relationship_set, + many=True + ).data + + return context + + def form_valid(self, form): + return self.render_to_response(self.get_context_data()) diff --git a/people/views/person.py b/people/views/person.py new file mode 100644 index 0000000..92e7ee9 --- /dev/null +++ b/people/views/person.py @@ -0,0 +1,63 @@ +""" +Views for displaying or manipulating instances of :class:`Person`. +""" + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import CreateView, DetailView, ListView, UpdateView + +from people import forms, models, permissions + + +class PersonCreateView(LoginRequiredMixin, CreateView): + """ + 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. + """ + model = models.Person + template_name = 'people/person/create.html' + form_class = forms.PersonForm + + def form_valid(self, form): + if 'user' in self.request.GET: + form.instance.user = self.request.user + + return super().form_valid(form) + + +class PersonListView(LoginRequiredMixin, ListView): + """ + View displaying a list of :class:`Person` objects - searchable. + """ + model = models.Person + template_name = 'people/person/list.html' + + +class ProfileView(permissions.UserIsLinkedPersonMixin, 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_object(self, queryset=None) -> models.Person: + """ + Get the :class:`Person` object to be represented by this page. + + If not determined from url get current user. + """ + try: + return super().get_object(queryset) + + except AttributeError: + # pk was not provided in URL + return self.request.user.person + + +class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView): + """ + View for updating a :class:`Person` record. + """ + model = models.Person + template_name = 'people/person/update.html' + form_class = forms.PersonForm diff --git a/people/views/relationship.py b/people/views/relationship.py new file mode 100644 index 0000000..eeb2ea6 --- /dev/null +++ b/people/views/relationship.py @@ -0,0 +1,130 @@ +""" +Views for displaying or manipulating instances of :class:`Relationship`. +""" + +from django.urls import reverse +from django.utils import timezone +from django.views.generic import CreateView, DetailView + +from people import forms, models, permissions + + +class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView): + """ + View displaying details of a :class:`Relationship`. + """ + model = models.Relationship + template_name = 'people/relationship/detail.html' + related_person_field = 'source' + + +class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView): + """ + View for creating a :class:`Relationship`. + + Displays / processes a form containing the :class:`RelationshipQuestion`s. + """ + model = models.Relationship + template_name = 'people/relationship/create.html' + fields = [ + 'source', + 'target', + ] + + def get_test_person(self) -> models.Person: + """ + Get the person instance which should be used for access control checks. + """ + if self.request.method == 'POST': + return models.Person.objects.get(pk=self.request.POST.get('source')) + + return models.Person.objects.get(pk=self.kwargs.get('person_pk')) + + def get(self, request, *args, **kwargs): + self.person = models.Person.objects.get(pk=self.kwargs.get('person_pk')) + + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + self.person = models.Person.objects.get(pk=self.kwargs.get('person_pk')) + + return super().post(request, *args, **kwargs) + + def get_initial(self): + initial = super().get_initial() + + initial['source'] = self.request.user.person + initial['target'] = self.person + + return initial + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['person'] = self.person + + return context + + def get_success_url(self): + return reverse('people:relationship.update', kwargs={'relationship_pk': self.object.pk}) + + +class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView): + """ + View for creating a :class:`Relationship`. + + Displays / processes a form containing the :class:`RelationshipQuestion`s. + """ + model = models.RelationshipAnswerSet + 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) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['person'] = self.person + context['relationship'] = self.relationship + + return context + + def get_initial(self): + initial = super().get_initial() + + initial['relationship'] = self.relationship + + return initial + + def form_valid(self, form): + """ + Mark any previous answer sets as replaced. + """ + previous_valid_answer_sets = self.relationship.answer_sets.filter(replaced_timestamp__isnull=True) + + response = super().form_valid(form) + + # Shouldn't be more than one after initial updates after migration + for answer_set in previous_valid_answer_sets: + answer_set.replaced_timestamp = timezone.now() + answer_set.save() + + return response diff --git a/roles/database/tasks/main.yml b/roles/database/tasks/main.yml index 24c5fff..6bc72b9 100644 --- a/roles/database/tasks/main.yml +++ b/roles/database/tasks/main.yml @@ -22,6 +22,7 @@ name: mariadb state: restarted enabled: yes + daemon_reload: yes - name: Create database mysql_db: diff --git a/roles/webserver/defaults/main.yml b/roles/webserver/defaults/main.yml index cfb895e..5d9fbcf 100644 --- a/roles/webserver/defaults/main.yml +++ b/roles/webserver/defaults/main.yml @@ -5,7 +5,7 @@ deploy_mode_dict: 3: Development deploy_mode: 3 -secret_key: '{{ lookup("password", "/tmp/secretkeyfile") }}' +secret_key: '{{ lookup("password", "/dev/null") }}' project_name: 'breccia-mapper' project_full_name: 'breccia_mapper' diff --git a/roles/webserver/tasks/main.yml b/roles/webserver/tasks/main.yml index 74c6ab5..ef16b98 100644 --- a/roles/webserver/tasks/main.yml +++ b/roles/webserver/tasks/main.yml @@ -12,6 +12,17 @@ name: '*' state: latest +- name: Enable RedHat Software Collections - RHEL + rhsm_repository: + name: rhel-server-rhscl-7-rpms + when: ansible_distribution == "RedHat" + +- name: Enable RedHat Software Collections - CentOS + yum: + name: centos-release-scl + state: latest + when: ansible_distribution == "CentOS" + - name: Install system prerequisites yum: name: '{{ packages }}' @@ -20,12 +31,8 @@ packages: - gcc - git - - nginx - - python36 - - python36-devel - - python36-pip - - python36-setuptools - - python36-virtualenv + - rh-nginx114 + - rh-python36 - policycoreutils-python - python - python-setuptools @@ -86,11 +93,15 @@ group: '{{ web_group }}' recurse: yes +- name: Create venv + shell: | + source scl_source enable rh-python36 + python3 -m venv {{ venv_dir }} + - name: Install pip requirements pip: requirements: '{{ project_dir }}/requirements.txt' virtualenv: '{{ venv_dir }}' - virtualenv_command: virtualenv-3 - name: Create static directory file: @@ -124,10 +135,9 @@ when: deploy_mode > 1 - name: Install uWSGI - pip: - name: uwsgi - state: latest - executable: pip3 + shell: | + source scl_source enable rh-python36 + pip3 install uwsgi - name: Setup uWSGI config file: @@ -145,6 +155,7 @@ name: uwsgi state: started enabled: yes + daemon_reload: yes - name: Copy web config files template: @@ -189,7 +200,7 @@ - name: Copy Nginx site template: src: nginx-site-ssl.j2 - dest: '/etc/nginx/conf.d/{{ project_name }}-ssl.conf' + dest: '/etc/opt/rh/rh-nginx114/nginx/conf.d/{{ project_name }}-ssl.conf' owner: '{{ web_user }}' group: '{{ web_group }}' @@ -198,7 +209,7 @@ - name: Copy Nginx site template: src: nginx-site.j2 - dest: '/etc/nginx/conf.d/{{ project_name }}.conf' + dest: '/etc/opt/rh/rh-nginx114/nginx/conf.d/{{ project_name }}.conf' owner: '{{ web_user }}' group: '{{ web_group }}' @@ -207,9 +218,13 @@ name: "{{ item }}" state: restarted enabled: yes + daemon_reload: yes with_items: - uwsgi - - nginx + - rh-nginx114-nginx + +- name: Populate service facts + service_facts: - name: Open webserver ports on firewall firewalld: @@ -218,6 +233,7 @@ permanent: yes immediate: yes loop: + - ssh - http - https - when: vagrant_dir.stat.exists == False + when: ansible_facts.services['firewalld.service'] is defined and ansible_facts.services['firewalld.service'].state == 'running' diff --git a/roles/webserver/templates/uwsgi-service.j2 b/roles/webserver/templates/uwsgi-service.j2 index fffb99c..97f5738 100644 --- a/roles/webserver/templates/uwsgi-service.j2 +++ b/roles/webserver/templates/uwsgi-service.j2 @@ -2,8 +2,8 @@ Description=uWSGI Emperor Service [Service] -ExecStartPre=/bin/bash -c 'mkdir -p /run/uwsgi; chown {{ web_user }}:{{ web_group }} /run/uwsgi' -ExecStart=/usr/local/bin/uwsgi --emperor /etc/uwsgi/sites +ExecStartPre=/bin/bash -c 'mkdir -p /run/uwsgi; chown {{ web_user }}:{{ web_group }} /run/uwsgi; source scl_source rh-python36' +ExecStart=/bin/scl enable rh-python36 "uwsgi --emperor /etc/uwsgi/sites" Restart=always KillSignal=SIGQUIT Type=notify