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 %}
+
+ Export
+
+
Admin
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 %}
+
+
+
+
+
+
+
+ | Type |
+ Records |
+ |
+
+
+
+
+
+ | 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