mirror of
https://github.com/Southampton-RSG/breccia-mapper.git
synced 2026-03-03 19:37:06 +00:00
Merge branch dev
This commit is contained in:
4
.style.yapf
Normal file
4
.style.yapf
Normal file
@@ -0,0 +1,4 @@
|
||||
[style]
|
||||
allow_split_before_dict_value=false
|
||||
column_limit=100
|
||||
dedent_closing_brackets=true
|
||||
15
breccia_mapper/forms.py
Normal file
15
breccia_mapper/forms.py
Normal file
@@ -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',
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>{{ settings.PROJECT_LONG_NAME }}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
{% bootstrap_css %}
|
||||
@@ -27,6 +28,10 @@
|
||||
{% load staticfiles %}
|
||||
<link rel="stylesheet" href="{% static 'css/global.css' %}">
|
||||
|
||||
<link rel="stylesheet"
|
||||
type="text/css"
|
||||
href="{% static 'hijack/hijack-styles.css' %}" />
|
||||
|
||||
{% if 'javascript_in_head'|bootstrap_setting %}
|
||||
{% if 'include_jquery'|bootstrap_setting %}
|
||||
{# jQuery JavaScript if it is in head #}
|
||||
@@ -79,7 +84,7 @@
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'people:person.map' %}" class="nav-link">Map</a>
|
||||
<a href="{% url 'people:map' %}" class="nav-link">Map</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
@@ -144,6 +149,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% load hijack_tags %}
|
||||
{% hijack_notification %}
|
||||
|
||||
{% if request.user.is_authenticated and not request.user.has_person %}
|
||||
<div class="alert alert-info rounded-0" role="alert">
|
||||
<p class="text-center mb-0">
|
||||
@@ -156,9 +164,18 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.is_authenticated and not request.user.consent_given %}
|
||||
<div class="alert alert-warning rounded-0" role="alert">
|
||||
<p class="text-center mb-0">
|
||||
You have not yet given consent for your data to be collected and processed.
|
||||
Please read and accept the <a href="{% url 'consent' %}">consent text</a>.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block before_content %}{% endblock %}
|
||||
|
||||
<main class="container">
|
||||
<main class="{{ full_width_page|yesno:'container-fluid,container' }}">
|
||||
{# Display Django messages as Bootstrap alerts #}
|
||||
{% bootstrap_messages %}
|
||||
|
||||
|
||||
21
breccia_mapper/templates/consent.html
Normal file
21
breccia_mapper/templates/consent.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Consent</h2>
|
||||
|
||||
<p>
|
||||
{{ config.CONSENT_TEXT|linebreaks }}
|
||||
</p>
|
||||
|
||||
<form class="form"
|
||||
method="POST">
|
||||
{% csrf_token %}
|
||||
|
||||
{% load bootstrap4 %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<button class="btn btn-success" type="submit">Submit</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -63,7 +63,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row align-items-center">
|
||||
<div class="row align-items-center" style="min-height: 400px;">
|
||||
<div class="col-sm-8">
|
||||
<h2 class="pb-2">About {{ settings.PROJECT_LONG_NAME }}</h2>
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ urlpatterns = [
|
||||
path('select2/',
|
||||
include('django_select2.urls')),
|
||||
|
||||
path('hijack/',
|
||||
include('hijack.urls', namespace='hijack')),
|
||||
|
||||
path('',
|
||||
include('django.contrib.auth.urls')),
|
||||
|
||||
@@ -32,6 +35,10 @@ urlpatterns = [
|
||||
views.IndexView.as_view(),
|
||||
name='index'),
|
||||
|
||||
path('consent',
|
||||
views.ConsentTextView.as_view(),
|
||||
name='consent'),
|
||||
|
||||
path('',
|
||||
include('export.urls')),
|
||||
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
"""
|
||||
Views belonging to the core of the project.
|
||||
"""Views belonging to the core of the project.
|
||||
|
||||
These views don't represent any of the models in the apps.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.edit import UpdateView
|
||||
|
||||
from . import forms
|
||||
|
||||
User = get_user_model() # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class IndexView(TemplateView):
|
||||
# Template set in Django settings file - may be customised by a customisation app
|
||||
template_name = settings.TEMPLATE_NAME_INDEX
|
||||
|
||||
|
||||
class ConsentTextView(LoginRequiredMixin, UpdateView):
|
||||
"""View with consent text and form for users to indicate consent."""
|
||||
model = User
|
||||
form_class = forms.ConsentForm
|
||||
template_name = 'consent.html'
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
try:
|
||||
return reverse('people:person.detail', kwargs={'pk': self.request.user.person.pk})
|
||||
|
||||
except AttributeError:
|
||||
return reverse('index')
|
||||
|
||||
def get_object(self, *args, **kwargs) -> User:
|
||||
return self.request.user
|
||||
|
||||
@@ -65,7 +65,7 @@ class SimpleActivitySerializer(serializers.ModelSerializer):
|
||||
|
||||
class ActivityAttendanceSerializer(base.FlattenedModelSerializer):
|
||||
activity = SimpleActivitySerializer()
|
||||
person = people_serializers.SimplePersonSerializer()
|
||||
person = people_serializers.PersonSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.Activity.attendance_list.through
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
import typing
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from people import models
|
||||
|
||||
from . import base
|
||||
|
||||
|
||||
class SimplePersonSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Person
|
||||
fields = [
|
||||
'id',
|
||||
# Name is excluded from exports
|
||||
# See https://github.com/Southampton-RSG/breccia-mapper/issues/35
|
||||
]
|
||||
def underscore(slug: str) -> str:
|
||||
"""Replace hyphens with underscores in text."""
|
||||
return slug.replace('-', '_')
|
||||
|
||||
|
||||
def underscore_dict_keys(dict_: typing.Mapping[str, typing.Any]):
|
||||
return {underscore(key): value for key, value in dict_.items()}
|
||||
|
||||
|
||||
class AnswerSetSerializer(base.FlattenedModelSerializer):
|
||||
question_model = None
|
||||
|
||||
@property
|
||||
def column_headers(self) -> typing.List[str]:
|
||||
headers = super().column_headers
|
||||
|
||||
# Add relationship questions to columns
|
||||
for question in self.question_model.objects.all():
|
||||
headers.append(underscore(question.slug))
|
||||
|
||||
return headers
|
||||
|
||||
def to_representation(self, instance: models.question.AnswerSet):
|
||||
rep = super().to_representation(instance)
|
||||
|
||||
rep.update(
|
||||
underscore_dict_keys(instance.build_question_answers(use_slugs=True, show_all=True))
|
||||
)
|
||||
|
||||
return rep
|
||||
|
||||
|
||||
class PersonSerializer(base.FlattenedModelSerializer):
|
||||
@@ -22,20 +42,31 @@ class PersonSerializer(base.FlattenedModelSerializer):
|
||||
model = models.Person
|
||||
fields = [
|
||||
'id',
|
||||
# Name is excluded from exports
|
||||
# See https://github.com/Southampton-RSG/breccia-mapper/issues/35
|
||||
'gender',
|
||||
'age_group',
|
||||
'nationality',
|
||||
'country_of_residence',
|
||||
'name',
|
||||
'organisation',
|
||||
'organisation_started_date',
|
||||
'country_of_residence',
|
||||
]
|
||||
|
||||
|
||||
class PersonAnswerSetSerializer(AnswerSetSerializer):
|
||||
question_model = models.PersonQuestion
|
||||
person = PersonSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.PersonAnswerSet
|
||||
fields = [
|
||||
'id',
|
||||
'person',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
'latitude',
|
||||
'longitude',
|
||||
]
|
||||
|
||||
|
||||
class RelationshipSerializer(base.FlattenedModelSerializer):
|
||||
source = SimplePersonSerializer()
|
||||
target = SimplePersonSerializer()
|
||||
source = PersonSerializer()
|
||||
target = PersonSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.Relationship
|
||||
@@ -46,12 +77,8 @@ class RelationshipSerializer(base.FlattenedModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
def underscore(slug: str) -> str:
|
||||
"""Replace hyphens with underscores in text."""
|
||||
return slug.replace('-', '_')
|
||||
|
||||
|
||||
class RelationshipAnswerSetSerializer(base.FlattenedModelSerializer):
|
||||
class RelationshipAnswerSetSerializer(AnswerSetSerializer):
|
||||
question_model = models.RelationshipQuestion
|
||||
relationship = RelationshipSerializer()
|
||||
|
||||
class Meta:
|
||||
@@ -63,25 +90,54 @@ class RelationshipAnswerSetSerializer(base.FlattenedModelSerializer):
|
||||
'replaced_timestamp',
|
||||
]
|
||||
|
||||
@property
|
||||
def column_headers(self) -> typing.List[str]:
|
||||
headers = super().column_headers
|
||||
|
||||
# Add relationship questions to columns
|
||||
for question in models.RelationshipQuestion.objects.all():
|
||||
headers.append(underscore(question.slug))
|
||||
class OrganisationSerializer(base.FlattenedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Organisation
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
]
|
||||
|
||||
return headers
|
||||
|
||||
def to_representation(self, instance):
|
||||
rep = super().to_representation(instance)
|
||||
class OrganisationAnswerSetSerializer(AnswerSetSerializer):
|
||||
question_model = models.OrganisationQuestion
|
||||
organisation = OrganisationSerializer()
|
||||
|
||||
try:
|
||||
# Add relationship question answers to data
|
||||
for answer in instance.question_answers.all():
|
||||
rep[underscore(answer.question.slug)] = underscore(answer.slug)
|
||||
class Meta:
|
||||
model = models.OrganisationAnswerSet
|
||||
fields = [
|
||||
'id',
|
||||
'organisation',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
'latitude',
|
||||
'longitude',
|
||||
]
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return rep
|
||||
class OrganisationRelationshipSerializer(base.FlattenedModelSerializer):
|
||||
source = OrganisationSerializer()
|
||||
target = OrganisationSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.OrganisationRelationship
|
||||
fields = [
|
||||
'id',
|
||||
'source',
|
||||
'target',
|
||||
]
|
||||
|
||||
|
||||
class OrganisationRelationshipAnswerSetSerializer(AnswerSetSerializer):
|
||||
question_model = models.OrganisationRelationshipQuestion
|
||||
relationship = OrganisationRelationshipSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.OrganisationRelationshipAnswerSet
|
||||
fields = [
|
||||
'id',
|
||||
'relationship',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
]
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Person Answer Sets</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:person-answer-set' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Relationships</td>
|
||||
<td></td>
|
||||
@@ -48,6 +57,42 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Organisation</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:organisation' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Organisation Answer Sets</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:organisation-answer-set' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Organisation Relationships</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:organisation-relationship' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Organisation Relationship Answer Sets</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'export:organisation-relationship-answer-set' %}">Export</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Activities</td>
|
||||
<td></td>
|
||||
|
||||
@@ -14,6 +14,10 @@ urlpatterns = [
|
||||
views.people.PersonExportView.as_view(),
|
||||
name='person'),
|
||||
|
||||
path('export/person-answer-sets',
|
||||
views.people.PersonAnswerSetExportView.as_view(),
|
||||
name='person-answer-set'),
|
||||
|
||||
path('export/relationships',
|
||||
views.people.RelationshipExportView.as_view(),
|
||||
name='relationship'),
|
||||
@@ -22,6 +26,22 @@ urlpatterns = [
|
||||
views.people.RelationshipAnswerSetExportView.as_view(),
|
||||
name='relationship-answer-set'),
|
||||
|
||||
path('export/organisation',
|
||||
views.people.OrganisationExportView.as_view(),
|
||||
name='organisation'),
|
||||
|
||||
path('export/organisation-answer-sets',
|
||||
views.people.OrganisationAnswerSetExportView.as_view(),
|
||||
name='organisation-answer-set'),
|
||||
|
||||
path('export/organisation-relationships',
|
||||
views.people.OrganisationRelationshipExportView.as_view(),
|
||||
name='organisation-relationship'),
|
||||
|
||||
path('export/organisation-relationship-answer-sets',
|
||||
views.people.OrganisationRelationshipAnswerSetExportView.as_view(),
|
||||
name='organisation-relationship-answer-set'),
|
||||
|
||||
path('export/activities',
|
||||
views.activities.ActivityExportView.as_view(),
|
||||
name='activity'),
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import csv
|
||||
import typing
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.http import HttpResponse
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.list import BaseListView
|
||||
|
||||
|
||||
class CsvExportView(LoginRequiredMixin, BaseListView):
|
||||
class QuotedCsv(csv.excel):
|
||||
quoting = csv.QUOTE_NONNUMERIC
|
||||
|
||||
|
||||
class UserIsStaffMixin(UserPassesTestMixin):
|
||||
def test_func(self) -> typing.Optional[bool]:
|
||||
return self.request.user.is_staff
|
||||
|
||||
|
||||
class CsvExportView(UserIsStaffMixin, BaseListView):
|
||||
model = None
|
||||
serializer_class = None
|
||||
|
||||
@@ -18,12 +27,12 @@ class CsvExportView(LoginRequiredMixin, BaseListView):
|
||||
# Force ordering by PK - though this should be default anyway
|
||||
serializer = self.serializer_class(self.get_queryset().order_by('pk'), many=True)
|
||||
|
||||
writer = csv.DictWriter(response, fieldnames=serializer.child.column_headers)
|
||||
writer = csv.DictWriter(response, dialect=QuotedCsv, fieldnames=serializer.child.column_headers)
|
||||
writer.writeheader()
|
||||
writer.writerows(serializer.data)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ExportListView(LoginRequiredMixin, TemplateView):
|
||||
class ExportListView(UserIsStaffMixin, TemplateView):
|
||||
template_name = 'export/export.html'
|
||||
|
||||
@@ -9,6 +9,11 @@ class PersonExportView(base.CsvExportView):
|
||||
serializer_class = serializers.people.PersonSerializer
|
||||
|
||||
|
||||
class PersonAnswerSetExportView(base.CsvExportView):
|
||||
model = models.person.PersonAnswerSet
|
||||
serializer_class = serializers.people.PersonAnswerSetSerializer
|
||||
|
||||
|
||||
class RelationshipExportView(base.CsvExportView):
|
||||
model = models.relationship.Relationship
|
||||
serializer_class = serializers.people.RelationshipSerializer
|
||||
@@ -17,3 +22,23 @@ class RelationshipExportView(base.CsvExportView):
|
||||
class RelationshipAnswerSetExportView(base.CsvExportView):
|
||||
model = models.relationship.RelationshipAnswerSet
|
||||
serializer_class = serializers.people.RelationshipAnswerSetSerializer
|
||||
|
||||
|
||||
class OrganisationExportView(base.CsvExportView):
|
||||
model = models.person.Organisation
|
||||
serializer_class = serializers.people.OrganisationSerializer
|
||||
|
||||
|
||||
class OrganisationAnswerSetExportView(base.CsvExportView):
|
||||
model = models.organisation.OrganisationAnswerSet
|
||||
serializer_class = serializers.people.OrganisationAnswerSetSerializer
|
||||
|
||||
|
||||
class OrganisationRelationshipExportView(base.CsvExportView):
|
||||
model = models.relationship.OrganisationRelationship
|
||||
serializer_class = serializers.people.OrganisationRelationshipSerializer
|
||||
|
||||
|
||||
class OrganisationRelationshipAnswerSetExportView(base.CsvExportView):
|
||||
model = models.relationship.OrganisationRelationshipAnswerSet
|
||||
serializer_class = serializers.people.OrganisationRelationshipAnswerSetSerializer
|
||||
|
||||
@@ -16,14 +16,29 @@ class CustomUserAdmin(UserAdmin):
|
||||
) # yapf: disable
|
||||
|
||||
|
||||
class OrganisationQuestionChoiceInline(admin.TabularInline):
|
||||
model = models.OrganisationQuestionChoice
|
||||
|
||||
|
||||
@admin.register(models.OrganisationQuestion)
|
||||
class OrganisationQuestionAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
OrganisationQuestionChoiceInline,
|
||||
]
|
||||
|
||||
|
||||
class OrganisationAnswerSetInline(admin.TabularInline):
|
||||
model = models.OrganisationAnswerSet
|
||||
readonly_fields = [
|
||||
'question_answers',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(models.Organisation)
|
||||
class OrganisationAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(models.Theme)
|
||||
class ThemeAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
inlines = [
|
||||
OrganisationAnswerSetInline,
|
||||
]
|
||||
|
||||
|
||||
class PersonQuestionChoiceInline(admin.TabularInline):
|
||||
@@ -64,4 +79,20 @@ class RelationshipQuestionAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(models.Relationship)
|
||||
class RelationshipAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
ordering = ['source__name', 'target__name']
|
||||
|
||||
|
||||
class OrganisationRelationshipQuestionChoiceInline(admin.TabularInline):
|
||||
model = models.OrganisationRelationshipQuestionChoice
|
||||
|
||||
|
||||
@admin.register(models.OrganisationRelationshipQuestion)
|
||||
class OrganisationRelationshipQuestionAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
OrganisationRelationshipQuestionChoiceInline,
|
||||
]
|
||||
|
||||
|
||||
@admin.register(models.OrganisationRelationship)
|
||||
class OrganisationRelationshipAdmin(admin.ModelAdmin):
|
||||
ordering = ['source__name', 'target__name']
|
||||
|
||||
252
people/forms.py
252
people/forms.py
@@ -3,30 +3,21 @@
|
||||
import typing
|
||||
|
||||
from django import forms
|
||||
from django.forms.widgets import SelectDateWidget
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
from bootstrap_datepicker_plus import DatePickerInput
|
||||
from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def get_date_year_range() -> typing.Iterable[int]:
|
||||
"""
|
||||
Get sensible year range for SelectDateWidgets in the past.
|
||||
|
||||
By default these widgets show 10 years in the future.
|
||||
"""
|
||||
num_years_display = 60
|
||||
this_year = timezone.datetime.now().year
|
||||
return range(this_year, this_year - num_years_display, -1)
|
||||
|
||||
|
||||
class OrganisationForm(forms.ModelForm):
|
||||
"""Form for creating / updating an instance of :class:`Organisation`."""
|
||||
class Meta:
|
||||
model = models.Organisation
|
||||
fields = ['name', 'latitude', 'longitude']
|
||||
fields = [
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class PersonForm(forms.ModelForm):
|
||||
@@ -46,16 +37,30 @@ class RelationshipForm(forms.Form):
|
||||
|
||||
class DynamicAnswerSetBase(forms.Form):
|
||||
field_class = forms.ModelChoiceField
|
||||
field_widget = None
|
||||
field_required = True
|
||||
question_model = None
|
||||
field_widget: typing.Optional[typing.Type[forms.Widget]] = None
|
||||
question_model: typing.Type[models.Question]
|
||||
answer_model: typing.Type[models.QuestionChoice]
|
||||
question_prefix: str = ''
|
||||
as_filters: bool = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
initial = kwargs.get('initial', {})
|
||||
field_order = []
|
||||
|
||||
for question in self.question_model.objects.all():
|
||||
if self.as_filters and not question.answer_is_public:
|
||||
continue
|
||||
|
||||
# Is a placeholder question just for sorting hardcoded questions?
|
||||
if (
|
||||
question.is_hardcoded
|
||||
and (self.as_filters or (question.hardcoded_field in self.Meta.fields))
|
||||
):
|
||||
field_order.append(question.hardcoded_field)
|
||||
continue
|
||||
|
||||
field_class = self.field_class
|
||||
field_widget = self.field_widget
|
||||
|
||||
@@ -63,14 +68,91 @@ class DynamicAnswerSetBase(forms.Form):
|
||||
field_class = forms.ModelMultipleChoiceField
|
||||
field_widget = Select2MultipleWidget
|
||||
|
||||
field_name = f'question_{question.pk}'
|
||||
field_name = f'{self.question_prefix}question_{question.pk}'
|
||||
|
||||
field = field_class(label=question,
|
||||
# If being used as a filter - do we have alternate text?
|
||||
field_label = question.text
|
||||
if self.as_filters and question.filter_text:
|
||||
field_label = question.filter_text
|
||||
|
||||
field = field_class(
|
||||
label=field_label,
|
||||
queryset=question.answers,
|
||||
widget=field_widget,
|
||||
required=self.field_required,
|
||||
initial=initial.get(field_name, None))
|
||||
required=(self.field_required
|
||||
and not question.allow_free_text),
|
||||
initial=self.initial.get(field_name, None),
|
||||
help_text=question.help_text if not self.as_filters else '')
|
||||
self.fields[field_name] = field
|
||||
field_order.append(field_name)
|
||||
|
||||
if question.allow_free_text and not self.as_filters:
|
||||
free_field = forms.CharField(label=f'{question} free text',
|
||||
required=False)
|
||||
self.fields[f'{field_name}_free'] = free_field
|
||||
field_order.append(f'{field_name}_free')
|
||||
|
||||
self.order_fields(field_order)
|
||||
|
||||
|
||||
class OrganisationAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||
"""Form for variable organisation attributes.
|
||||
|
||||
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
|
||||
"""
|
||||
class Meta:
|
||||
model = models.OrganisationAnswerSet
|
||||
fields = [
|
||||
'name',
|
||||
'website',
|
||||
'countries',
|
||||
'hq_country',
|
||||
'is_partner_organisation',
|
||||
'latitude',
|
||||
'longitude',
|
||||
]
|
||||
labels = {
|
||||
'is_partner_organisation':
|
||||
f'Is this organisation a {settings.PARENT_PROJECT_NAME} partner organisation?'
|
||||
}
|
||||
widgets = {
|
||||
'countries': Select2MultipleWidget(),
|
||||
'hq_country': Select2Widget(),
|
||||
'latitude': forms.HiddenInput,
|
||||
'longitude': forms.HiddenInput,
|
||||
}
|
||||
|
||||
question_model = models.OrganisationQuestion
|
||||
answer_model = models.OrganisationQuestionChoice
|
||||
|
||||
def save(self, commit=True) -> models.OrganisationAnswerSet:
|
||||
# Save model
|
||||
self.instance = super().save(commit=False)
|
||||
self.instance.organisation_id = self.initial['organisation_id']
|
||||
if commit:
|
||||
self.instance.save()
|
||||
# Need to call same_m2m manually since we use commit=False above
|
||||
self.save_m2m()
|
||||
|
||||
if commit:
|
||||
# Save answers to questions
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('question_') and value:
|
||||
if key.endswith('_free'):
|
||||
# Create new answer from free text
|
||||
value, _ = self.answer_model.objects.get_or_create(
|
||||
text=value,
|
||||
question=self.question_model.objects.get(
|
||||
pk=key.split('_')[1]))
|
||||
|
||||
try:
|
||||
self.instance.question_answers.add(value)
|
||||
|
||||
except TypeError:
|
||||
# Value is a QuerySet - multiple choice question
|
||||
self.instance.question_answers.add(*value.all())
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||
@@ -85,37 +167,57 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||
'country_of_residence',
|
||||
'organisation',
|
||||
'organisation_started_date',
|
||||
'project_started_date',
|
||||
'job_title',
|
||||
'disciplines',
|
||||
'themes',
|
||||
'disciplinary_background',
|
||||
'external_organisations',
|
||||
'latitude',
|
||||
'longitude',
|
||||
]
|
||||
widgets = {
|
||||
'nationality': Select2Widget(),
|
||||
'nationality': Select2MultipleWidget(),
|
||||
'country_of_residence': Select2Widget(),
|
||||
'themes': Select2MultipleWidget(),
|
||||
'organisation_started_date': DatePickerInput(format='%Y-%m-%d'),
|
||||
'project_started_date': DatePickerInput(format='%Y-%m-%d'),
|
||||
'latitude': forms.HiddenInput,
|
||||
'longitude': forms.HiddenInput,
|
||||
}
|
||||
labels = {
|
||||
'project_started_date':
|
||||
f'Date started on the {settings.PARENT_PROJECT_NAME} project',
|
||||
'external_organisations':
|
||||
'Please list the main organisations external to BRECcIA work that you have been working with since 1st January 2019 that are involved in food/water security in African dryland regions'
|
||||
}
|
||||
help_texts = {
|
||||
'organisation_started_date':
|
||||
'If you don\'t know the exact date, an approximate date is okay.',
|
||||
'project_started_date':
|
||||
'If you don\'t know the exact date, an approximate date is okay.',
|
||||
}
|
||||
|
||||
question_model = models.PersonQuestion
|
||||
answer_model = models.PersonQuestionChoice
|
||||
|
||||
def save(self, commit=True) -> models.PersonAnswerSet:
|
||||
# Save Relationship model
|
||||
# Save model
|
||||
self.instance = super().save(commit=False)
|
||||
self.instance.person_id = self.initial['person_id']
|
||||
if commit:
|
||||
self.instance.save()
|
||||
# Need to call same_m2m manually since we use commit=False above
|
||||
self.save_m2m()
|
||||
|
||||
if commit:
|
||||
# Save answers to relationship questions
|
||||
# Save answers to questions
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('question_') and value:
|
||||
if key.endswith('_free'):
|
||||
# Create new answer from free text
|
||||
value, _ = self.answer_model.objects.get_or_create(
|
||||
text=value,
|
||||
question=self.question_model.objects.get(
|
||||
pk=key.split('_')[1]))
|
||||
|
||||
try:
|
||||
self.instance.question_answers.add(value)
|
||||
|
||||
@@ -137,17 +239,28 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||
fields = [
|
||||
'relationship',
|
||||
]
|
||||
widgets = {
|
||||
'relationship': forms.HiddenInput,
|
||||
}
|
||||
|
||||
question_model = models.RelationshipQuestion
|
||||
answer_model = models.RelationshipQuestionChoice
|
||||
|
||||
def save(self, commit=True) -> models.RelationshipAnswerSet:
|
||||
# Save Relationship model
|
||||
# Save model
|
||||
self.instance = super().save(commit=commit)
|
||||
|
||||
if commit:
|
||||
# Save answers to relationship questions
|
||||
# Save answers to questions
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('question_') and value:
|
||||
if key.endswith('_free'):
|
||||
# Create new answer from free text
|
||||
value, _ = self.answer_model.objects.get_or_create(
|
||||
text=value,
|
||||
question=self.question_model.objects.get(
|
||||
pk=key.split('_')[1]))
|
||||
|
||||
try:
|
||||
self.instance.question_answers.add(value)
|
||||
|
||||
@@ -158,20 +271,81 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||
return self.instance
|
||||
|
||||
|
||||
class NetworkFilterForm(DynamicAnswerSetBase):
|
||||
"""
|
||||
Form to provide filtering on the network view.
|
||||
class OrganisationRelationshipAnswerSetForm(forms.ModelForm,
|
||||
DynamicAnswerSetBase):
|
||||
"""Form to allow users to describe a relationship with an organisation.
|
||||
|
||||
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
|
||||
"""
|
||||
class Meta:
|
||||
model = models.OrganisationRelationshipAnswerSet
|
||||
fields = [
|
||||
'relationship',
|
||||
]
|
||||
widgets = {
|
||||
'relationship': forms.HiddenInput,
|
||||
}
|
||||
|
||||
question_model = models.OrganisationRelationshipQuestion
|
||||
answer_model = models.OrganisationRelationshipQuestionChoice
|
||||
|
||||
def save(self, commit=True) -> models.OrganisationRelationshipAnswerSet:
|
||||
# Save model
|
||||
self.instance = super().save(commit=commit)
|
||||
|
||||
if commit:
|
||||
# Save answers to questions
|
||||
for key, value in self.cleaned_data.items():
|
||||
if key.startswith('question_') and value:
|
||||
if key.endswith('_free'):
|
||||
# Create new answer from free text
|
||||
value, _ = self.answer_model.objects.get_or_create(
|
||||
text=value,
|
||||
question=self.question_model.objects.get(
|
||||
pk=key.split('_')[1]))
|
||||
|
||||
try:
|
||||
self.instance.question_answers.add(value)
|
||||
|
||||
except TypeError:
|
||||
# Value is a QuerySet - multiple choice question
|
||||
self.instance.question_answers.add(*value.all())
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class DateForm(forms.Form):
|
||||
date = forms.DateField(
|
||||
required=False,
|
||||
widget=DatePickerInput(format='%Y-%m-%d'),
|
||||
help_text='Show relationships as they were on this date'
|
||||
)
|
||||
|
||||
|
||||
class FilterForm(DynamicAnswerSetBase):
|
||||
"""Filter objects by answerset responses."""
|
||||
field_class = forms.ModelMultipleChoiceField
|
||||
field_widget = Select2MultipleWidget
|
||||
field_required = False
|
||||
as_filters = True
|
||||
|
||||
|
||||
class NetworkRelationshipFilterForm(FilterForm):
|
||||
"""Filer relationships by answerset responses."""
|
||||
question_model = models.RelationshipQuestion
|
||||
answer_model = models.RelationshipQuestionChoice
|
||||
question_prefix = 'relationship_'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Add date field to select relationships at a particular point in time
|
||||
self.fields['date'] = forms.DateField(
|
||||
required=False,
|
||||
widget=SelectDateWidget(years=get_date_year_range()),
|
||||
help_text='Show relationships as they were on this date')
|
||||
class NetworkPersonFilterForm(FilterForm):
|
||||
"""Filer people by answerset responses."""
|
||||
question_model = models.PersonQuestion
|
||||
answer_model = models.PersonQuestionChoice
|
||||
question_prefix = 'person_'
|
||||
|
||||
|
||||
class NetworkOrganisationFilterForm(FilterForm):
|
||||
"""Filer organisations by answerset responses."""
|
||||
question_model = models.OrganisationQuestion
|
||||
answer_model = models.OrganisationQuestionChoice
|
||||
question_prefix = 'organisation_'
|
||||
|
||||
18
people/migrations/0030_user_consent_given.py
Normal file
18
people/migrations/0030_user_consent_given.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2021-01-20 11:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0029_organisation_location_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='consent_given',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
23
people/migrations/0031_question_allow_free_text.py
Normal file
23
people/migrations/0031_question_allow_free_text.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2021-01-20 13:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0030_user_consent_given'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='allow_free_text',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='allow_free_text',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
18
people/migrations/0032_personquestion_answer_is_public.py
Normal file
18
people/migrations/0032_personquestion_answer_is_public.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2021-02-08 13:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0031_question_allow_free_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='answer_is_public',
|
||||
field=models.BooleanField(default=True, help_text='Should answers to this question be displayed on profiles?'),
|
||||
),
|
||||
]
|
||||
17
people/migrations/0033_person_sort_by_name.py
Normal file
17
people/migrations/0033_person_sort_by_name.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.10 on 2021-02-08 15:01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0032_personquestion_answer_is_public'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='person',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'people'},
|
||||
),
|
||||
]
|
||||
63
people/migrations/0034_remove_personanswerset_disciplines.py
Normal file
63
people/migrations/0034_remove_personanswerset_disciplines.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 2.2.10 on 2021-02-15 13:54
|
||||
|
||||
import re
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from .utils.question_sets import port_question
|
||||
|
||||
|
||||
def migrate_forward(apps, schema_editor):
|
||||
"""Replace discipline text field with admin-editable question."""
|
||||
PersonAnswerSet = apps.get_model('people', 'PersonAnswerSet')
|
||||
|
||||
discipline_question = port_question(apps,
|
||||
'Disciplines', [],
|
||||
is_multiple_choice=True,
|
||||
allow_free_text=True)
|
||||
|
||||
for answerset in PersonAnswerSet.objects.all():
|
||||
try:
|
||||
disciplines = [
|
||||
d.strip() for d in re.split(r'[,;]+', answerset.disciplines)
|
||||
]
|
||||
|
||||
except TypeError:
|
||||
continue
|
||||
|
||||
for discipline in disciplines:
|
||||
answer, _ = discipline_question.answers.get_or_create(
|
||||
text=discipline)
|
||||
answerset.question_answers.add(answer)
|
||||
|
||||
|
||||
def migrate_backward(apps, schema_editor):
|
||||
"""Replace discipline admin-editable question with text field."""
|
||||
PersonAnswerSet = apps.get_model('people', 'PersonAnswerSet')
|
||||
PersonQuestion = apps.get_model('people', 'PersonQuestion')
|
||||
|
||||
discipline_question = PersonQuestion.objects.filter(
|
||||
text='Disciplines').latest('version')
|
||||
|
||||
for answerset in PersonAnswerSet.objects.all():
|
||||
answerset.disciplines = ', '.join(
|
||||
answerset.question_answers.filter(
|
||||
question=discipline_question).values_list('text', flat=True))
|
||||
answerset.save()
|
||||
|
||||
PersonQuestion.objects.filter(text='Disciplines').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0033_person_sort_by_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_forward, migrate_backward),
|
||||
migrations.RemoveField(
|
||||
model_name='personanswerset',
|
||||
name='disciplines',
|
||||
),
|
||||
]
|
||||
61
people/migrations/0035_add_organisation_questions.py
Normal file
61
people/migrations/0035_add_organisation_questions.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 2.2.10 on 2021-02-23 13:40
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0034_remove_personanswerset_disciplines'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OrganisationQuestion',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.PositiveSmallIntegerField(default=1)),
|
||||
('text', models.CharField(max_length=255)),
|
||||
('is_multiple_choice', models.BooleanField(default=False)),
|
||||
('allow_free_text', models.BooleanField(default=False)),
|
||||
('order', models.SmallIntegerField(default=0)),
|
||||
('answer_is_public', models.BooleanField(default=True, help_text='Should answers to this question be displayed on profiles?')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', 'text'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganisationQuestionChoice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.CharField(max_length=255)),
|
||||
('order', models.SmallIntegerField(default=0)),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='people.OrganisationQuestion')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['question__order', 'order', 'text'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganisationAnswerSet',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('replaced_timestamp', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_sets', to='people.Organisation')),
|
||||
('question_answers', models.ManyToManyField(to='people.OrganisationQuestionChoice')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['timestamp'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='organisationquestionchoice',
|
||||
constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'),
|
||||
),
|
||||
]
|
||||
91
people/migrations/0036_move_latlng_to_answerset.py
Normal file
91
people/migrations/0036_move_latlng_to_answerset.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 2.2.10 on 2021-02-24 15:29
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_forward(apps, schema_editor):
|
||||
Organisation = apps.get_model('people', 'Organisation')
|
||||
|
||||
fields = {
|
||||
'latitude',
|
||||
'longitude',
|
||||
}
|
||||
|
||||
for obj in Organisation.objects.all():
|
||||
try:
|
||||
answer_set = obj.answer_sets.last()
|
||||
if answer_set is None:
|
||||
raise ObjectDoesNotExist
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
answer_set = obj.answer_sets.create()
|
||||
|
||||
for field in fields:
|
||||
value = getattr(obj, field)
|
||||
try:
|
||||
setattr(answer_set, field, value)
|
||||
|
||||
except TypeError:
|
||||
# Cannot directly set an m2m field
|
||||
m2m = getattr(answer_set, field)
|
||||
m2m.set(value.all())
|
||||
|
||||
answer_set.save()
|
||||
|
||||
|
||||
def migrate_backward(apps, schema_editor):
|
||||
Organisation = apps.get_model('people', 'Organisation')
|
||||
|
||||
fields = {
|
||||
'latitude',
|
||||
'longitude',
|
||||
}
|
||||
|
||||
for obj in Organisation.objects.all():
|
||||
try:
|
||||
answer_set = obj.answer_sets.last()
|
||||
|
||||
for field in fields:
|
||||
value = getattr(answer_set, field)
|
||||
try:
|
||||
setattr(obj, field, value)
|
||||
|
||||
except TypeError:
|
||||
# Cannot directly set an m2m field
|
||||
m2m = getattr(obj, field)
|
||||
m2m.set(value.all())
|
||||
|
||||
obj.save()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0035_add_organisation_questions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organisationanswerset',
|
||||
name='latitude',
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organisationanswerset',
|
||||
name='longitude',
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.RunPython(migrate_forward, migrate_backward),
|
||||
migrations.RemoveField(
|
||||
model_name='organisation',
|
||||
name='latitude',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organisation',
|
||||
name='longitude',
|
||||
),
|
||||
]
|
||||
33
people/migrations/0037_alternate_filter_text.py
Normal file
33
people/migrations/0037_alternate_filter_text.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-01 18:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0036_move_latlng_to_answerset'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organisationquestion',
|
||||
name='filter_text',
|
||||
field=models.CharField(blank=True, help_text='Text to be displayed in network filters - 3rd person', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='filter_text',
|
||||
field=models.CharField(blank=True, help_text='Text to be displayed in network filters - 3rd person', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='answer_is_public',
|
||||
field=models.BooleanField(default=True, help_text='Should answers to this question be considered public?'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='filter_text',
|
||||
field=models.CharField(blank=True, help_text='Text to be displayed in network filters - 3rd person', max_length=255),
|
||||
),
|
||||
]
|
||||
23
people/migrations/0038_project_started_date.py
Normal file
23
people/migrations/0038_project_started_date.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-01 19:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0037_alternate_filter_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='project_started_date',
|
||||
field=models.DateField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='personquestion',
|
||||
name='answer_is_public',
|
||||
field=models.BooleanField(default=True, help_text='Should answers to this question be considered public?'),
|
||||
),
|
||||
]
|
||||
76
people/migrations/0039_add_organisation_relationship.py
Normal file
76
people/migrations/0039_add_organisation_relationship.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-02 08:36
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0038_project_started_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OrganisationRelationship',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('expired', models.DateTimeField(blank=True, null=True)),
|
||||
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organisation_relationships_as_source', to='people.Person')),
|
||||
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organisation_relationships_as_target', to='people.Organisation')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganisationRelationshipQuestion',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.PositiveSmallIntegerField(default=1)),
|
||||
('text', models.CharField(max_length=255)),
|
||||
('filter_text', models.CharField(blank=True, help_text='Text to be displayed in network filters - 3rd person', max_length=255)),
|
||||
('answer_is_public', models.BooleanField(default=True, help_text='Should answers to this question be considered public?')),
|
||||
('is_multiple_choice', models.BooleanField(default=False)),
|
||||
('allow_free_text', models.BooleanField(default=False)),
|
||||
('order', models.SmallIntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', 'text'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganisationRelationshipQuestionChoice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.CharField(max_length=255)),
|
||||
('order', models.SmallIntegerField(default=0)),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='people.OrganisationRelationshipQuestion')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['question__order', 'order', 'text'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganisationRelationshipAnswerSet',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('replaced_timestamp', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
('question_answers', models.ManyToManyField(to='people.OrganisationRelationshipQuestionChoice')),
|
||||
('relationship', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_sets', to='people.OrganisationRelationship')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['timestamp'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='organisationrelationshipquestionchoice',
|
||||
constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='organisationrelationship',
|
||||
constraint=models.UniqueConstraint(fields=('source', 'target'), name='unique_relationship'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-02 08:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0039_add_organisation_relationship'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='organisation_relationship_targets',
|
||||
field=models.ManyToManyField(related_name='relationship_sources', through='people.OrganisationRelationship', to='people.Organisation'),
|
||||
),
|
||||
]
|
||||
34
people/migrations/0041_add_static_org_questions.py
Normal file
34
people/migrations/0041_add_static_org_questions.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-05 11:53
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_countries.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0040_person_organisation_relationship_targets'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organisationanswerset',
|
||||
name='countries',
|
||||
field=django_countries.fields.CountryField(blank=True, help_text='Geographical spread - in which countries does this organisation have offices? Select all that apply', max_length=746, multiple=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organisationanswerset',
|
||||
name='hq_country',
|
||||
field=django_countries.fields.CountryField(blank=True, help_text='In which country does this organisation have its main location?', max_length=2),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organisationanswerset',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organisationanswerset',
|
||||
name='website',
|
||||
field=models.URLField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
38
people/migrations/0042_is_hardcoded_questions.py
Normal file
38
people/migrations/0042_is_hardcoded_questions.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-08 17:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0041_add_static_org_questions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organisationquestion',
|
||||
name='is_hardcoded',
|
||||
field=models.BooleanField(default=False, help_text='Only the order field has any effect for a hardcoded question.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organisationrelationshipquestion',
|
||||
name='is_hardcoded',
|
||||
field=models.BooleanField(default=False, help_text='Only the order field has any effect for a hardcoded question.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='is_hardcoded',
|
||||
field=models.BooleanField(default=False, help_text='Only the order field has any effect for a hardcoded question.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='is_hardcoded',
|
||||
field=models.BooleanField(default=False, help_text='Only the order field has any effect for a hardcoded question.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='personanswerset',
|
||||
name='job_title',
|
||||
field=models.CharField(blank=True, help_text='Contractual job title', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-09 09:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0042_is_hardcoded_questions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organisationanswerset',
|
||||
name='is_partner_organisation',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
39
people/migrations/0044_themes_to_admin_question.py
Normal file
39
people/migrations/0044_themes_to_admin_question.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-10 09:05
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from .utils.question_sets import port_question
|
||||
|
||||
|
||||
def migrate_forward(apps, schema_editor):
|
||||
# Make question
|
||||
Theme = apps.get_model('people', 'Theme')
|
||||
theme_question = port_question(
|
||||
apps, 'Research theme affiliation',
|
||||
Theme.objects.all().values_list('name', flat=True),
|
||||
is_multiple_choice=True)
|
||||
|
||||
PersonAnswerSet = apps.get_model('people', 'PersonAnswerSet')
|
||||
for answerset in PersonAnswerSet.objects.all():
|
||||
for theme in answerset.themes.all():
|
||||
answerset.question_answers.add(
|
||||
theme_question.answers.get(text=theme.name)
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0043_organisationanswerset_is_partner_organisation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_forward),
|
||||
migrations.RemoveField(
|
||||
model_name='personanswerset',
|
||||
name='themes',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Theme',
|
||||
),
|
||||
]
|
||||
53
people/migrations/0045_question_help_text.py
Normal file
53
people/migrations/0045_question_help_text.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-10 09:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0044_themes_to_admin_question'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organisationquestion',
|
||||
name='help_text',
|
||||
field=models.CharField(blank=True, help_text='Additional hint text to be displayed with the question', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organisationrelationshipquestion',
|
||||
name='help_text',
|
||||
field=models.CharField(blank=True, help_text='Additional hint text to be displayed with the question', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='help_text',
|
||||
field=models.CharField(blank=True, help_text='Additional hint text to be displayed with the question', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='help_text',
|
||||
field=models.CharField(blank=True, help_text='Additional hint text to be displayed with the question', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organisationquestion',
|
||||
name='filter_text',
|
||||
field=models.CharField(blank=True, help_text='Alternative text to be displayed in network filters - 3rd person', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organisationrelationshipquestion',
|
||||
name='filter_text',
|
||||
field=models.CharField(blank=True, help_text='Alternative text to be displayed in network filters - 3rd person', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='personquestion',
|
||||
name='filter_text',
|
||||
field=models.CharField(blank=True, help_text='Alternative text to be displayed in network filters - 3rd person', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='relationshipquestion',
|
||||
name='filter_text',
|
||||
field=models.CharField(blank=True, help_text='Alternative text to be displayed in network filters - 3rd person', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-10 10:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0045_question_help_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='external_organisations',
|
||||
field=models.CharField(blank=True, max_length=1023),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-10 10:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0046_personanswerset_external_organisations'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='personanswerset',
|
||||
name='external_organisations',
|
||||
),
|
||||
]
|
||||
67
people/migrations/0048_disciplines_and_organisations.py
Normal file
67
people/migrations/0048_disciplines_and_organisations.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-16 08:14
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forward_disciplines(apps, schema_editor):
|
||||
PersonQuestion = apps.get_model('people', 'PersonQuestion')
|
||||
|
||||
try:
|
||||
question = PersonQuestion.objects.filter(
|
||||
text='Disciplinary background').latest('version')
|
||||
|
||||
PersonAnswerSet = apps.get_model('people', 'PersonAnswerSet')
|
||||
for answerset in PersonAnswerSet.objects.all():
|
||||
answerset.disciplinary_background = ', '.join(
|
||||
answerset.question_answers.filter(question=question).values_list(
|
||||
'text', flat=True))
|
||||
answerset.save()
|
||||
|
||||
question.delete()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
def forward_organisations(apps, schema_editor):
|
||||
PersonQuestion = apps.get_model('people', 'PersonQuestion')
|
||||
|
||||
try:
|
||||
question = PersonQuestion.objects.filter(
|
||||
text='Please list the main organisations external to BRECcIA work that you have been working with since 1st January 2019 that are involved in food/water security in African dryland regions'
|
||||
).latest('version')
|
||||
|
||||
PersonAnswerSet = apps.get_model('people', 'PersonAnswerSet')
|
||||
for answerset in PersonAnswerSet.objects.all():
|
||||
answerset.external_organisations = ', '.join(
|
||||
answerset.question_answers.filter(question=question).values_list(
|
||||
'text', flat=True))
|
||||
answerset.save()
|
||||
|
||||
question.delete()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0047_remove_personanswerset_external_organisations'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='disciplinary_background',
|
||||
field=models.CharField(blank=True, help_text='Research discipline(s) you feel most affiliated with', max_length=255),
|
||||
),
|
||||
migrations.RunPython(forward_disciplines),
|
||||
migrations.AddField(
|
||||
model_name='personanswerset',
|
||||
name='external_organisations',
|
||||
field=models.CharField(blank=True, max_length=1023),
|
||||
),
|
||||
migrations.RunPython(forward_organisations),
|
||||
]
|
||||
37
people/migrations/0049_relationship_latest_by_timestamp.py
Normal file
37
people/migrations/0049_relationship_latest_by_timestamp.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-19 10:50
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0048_disciplines_and_organisations'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='organisationanswerset',
|
||||
options={'get_latest_by': 'timestamp', 'ordering': ['timestamp']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='organisationrelationship',
|
||||
options={'get_latest_by': 'created'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='organisationrelationshipanswerset',
|
||||
options={'get_latest_by': 'timestamp', 'ordering': ['timestamp']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='personanswerset',
|
||||
options={'get_latest_by': 'timestamp', 'ordering': ['timestamp']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='relationship',
|
||||
options={'get_latest_by': 'created'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='relationshipanswerset',
|
||||
options={'get_latest_by': 'timestamp', 'ordering': ['timestamp']},
|
||||
),
|
||||
]
|
||||
37
people/migrations/0050_relationship_remove_timestamps.py
Normal file
37
people/migrations/0050_relationship_remove_timestamps.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-19 11:09
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0049_relationship_latest_by_timestamp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='organisationrelationship',
|
||||
options={},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organisationrelationship',
|
||||
name='created',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organisationrelationship',
|
||||
name='expired',
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='relationship',
|
||||
options={},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='relationship',
|
||||
name='created',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='relationship',
|
||||
name='expired',
|
||||
),
|
||||
]
|
||||
81
people/migrations/0051_refactor_hardcoded_questions.py
Normal file
81
people/migrations/0051_refactor_hardcoded_questions.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-19 14:37
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
"""Move `text` field to `hardcoded_field`."""
|
||||
models = [
|
||||
'OrganisationQuestion',
|
||||
'OrganisationRelationshipQuestion',
|
||||
'PersonQuestion',
|
||||
'RelationshipQuestion',
|
||||
]
|
||||
models = map(lambda m: apps.get_model('people', m), models)
|
||||
|
||||
for model in models:
|
||||
model.objects.filter(is_hardcoded=True).update(
|
||||
hardcoded_field=F('text'))
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
"""Move `hardcoded_field` to `text` field."""
|
||||
models = [
|
||||
'OrganisationQuestion',
|
||||
'OrganisationRelationshipQuestion',
|
||||
'PersonQuestion',
|
||||
'RelationshipQuestion',
|
||||
]
|
||||
models = map(lambda m: apps.get_model('people', m), models)
|
||||
|
||||
for model in models:
|
||||
model.objects.exclude(hardcoded_field='').update(
|
||||
text=F('hardcoded_field'), is_hardcoded=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0050_relationship_remove_timestamps'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organisationquestion',
|
||||
name='hardcoded_field',
|
||||
field=models.CharField(blank=True, help_text='Which hardcoded field does this question represent?', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organisationrelationshipquestion',
|
||||
name='hardcoded_field',
|
||||
field=models.CharField(blank=True, help_text='Which hardcoded field does this question represent?', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='personquestion',
|
||||
name='hardcoded_field',
|
||||
field=models.CharField(blank=True, help_text='Which hardcoded field does this question represent?', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relationshipquestion',
|
||||
name='hardcoded_field',
|
||||
field=models.CharField(blank=True, help_text='Which hardcoded field does this question represent?', max_length=255),
|
||||
),
|
||||
migrations.RunPython(forward, backward),
|
||||
migrations.RemoveField(
|
||||
model_name='organisationquestion',
|
||||
name='is_hardcoded',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organisationrelationshipquestion',
|
||||
name='is_hardcoded',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='personquestion',
|
||||
name='is_hardcoded',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='relationshipquestion',
|
||||
name='is_hardcoded',
|
||||
),
|
||||
]
|
||||
20
people/migrations/0052_allow_multiple_nationalities.py
Normal file
20
people/migrations/0052_allow_multiple_nationalities.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.10 on 2021-03-19 15:39
|
||||
|
||||
from django.db import migrations
|
||||
import django_countries.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0051_refactor_hardcoded_questions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='personanswerset',
|
||||
name='nationality',
|
||||
field=django_countries.fields.CountryField(blank=True, default=[], max_length=746, multiple=True),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
17
people/migrations/0053_organisation_order_name.py
Normal file
17
people/migrations/0053_organisation_order_name.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.10 on 2021-05-09 12:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('people', '0052_allow_multiple_nationalities'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='organisation',
|
||||
options={'ordering': ['name']},
|
||||
),
|
||||
]
|
||||
@@ -1,21 +1,20 @@
|
||||
|
||||
import typing
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
|
||||
def port_question(apps, question_text: str,
|
||||
answers_text: typing.Iterable[str]):
|
||||
def port_question(apps, question_text: str, answers_text: typing.Iterable[str],
|
||||
**kwargs):
|
||||
PersonQuestion = apps.get_model('people', 'PersonQuestion')
|
||||
|
||||
try:
|
||||
prev_question = PersonQuestion.objects.filter(
|
||||
text=question_text).latest('version')
|
||||
question = PersonQuestion.objects.create(
|
||||
text=question_text, version=prev_question.version + 1)
|
||||
text=question_text, version=prev_question.version + 1, **kwargs)
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
question = PersonQuestion.objects.create(text=question_text)
|
||||
question = PersonQuestion.objects.create(text=question_text, **kwargs)
|
||||
|
||||
for answer_text in answers_text:
|
||||
question.answers.get_or_create(text=answer_text)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
from .person import *
|
||||
from .relationship import *
|
||||
from .organisation import * # noqa
|
||||
from .person import * # noqa
|
||||
from .question import * # noqa
|
||||
from .relationship import * # noqa
|
||||
|
||||
161
people/models/organisation.py
Normal file
161
people/models/organisation.py
Normal file
@@ -0,0 +1,161 @@
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
from .question import AnswerSet, Question, QuestionChoice
|
||||
|
||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
__all__ = [
|
||||
'OrganisationQuestion',
|
||||
'OrganisationQuestionChoice',
|
||||
'Organisation',
|
||||
'OrganisationAnswerSet',
|
||||
]
|
||||
|
||||
|
||||
class OrganisationQuestion(Question):
|
||||
"""Question which may be asked about a Organisation."""
|
||||
#: Should answers to this question be displayed on public profiles?
|
||||
answer_is_public = models.BooleanField(
|
||||
help_text='Should answers to this question be displayed on profiles?',
|
||||
default=True,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
|
||||
class OrganisationQuestionChoice(QuestionChoice):
|
||||
"""Allowed answer to a :class:`OrganisationQuestion`."""
|
||||
#: Question to which this answer belongs
|
||||
question = models.ForeignKey(OrganisationQuestion,
|
||||
related_name='answers',
|
||||
on_delete=models.CASCADE,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
|
||||
class Organisation(models.Model):
|
||||
"""Organisation to which a :class:`Person` belongs."""
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
name = models.CharField(max_length=255, blank=False, null=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
# Prefer name as in latest OrganisationAnswerSet
|
||||
try:
|
||||
name = self.current_answers.name
|
||||
|
||||
except AttributeError:
|
||||
name = ''
|
||||
|
||||
return name or self.name
|
||||
|
||||
@property
|
||||
def current_answers(self) -> 'OrganisationAnswerSet':
|
||||
return self.answer_sets.last()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('people:organisation.detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class OrganisationAnswerSet(AnswerSet):
|
||||
"""The answers to the organisation questions at a particular point in time."""
|
||||
|
||||
question_model = OrganisationQuestion
|
||||
|
||||
#: Organisation to which this answer set belongs
|
||||
organisation = models.ForeignKey(Organisation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='answer_sets',
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
name = models.CharField(max_length=255, blank=True, null=False)
|
||||
|
||||
website = models.URLField(max_length=255, blank=True, null=False)
|
||||
|
||||
#: Which countries does this organisation operate in?
|
||||
countries = CountryField(
|
||||
multiple=True,
|
||||
blank=True,
|
||||
null=False,
|
||||
help_text=(
|
||||
'Geographical spread - in which countries does this organisation '
|
||||
'have offices? Select all that apply'))
|
||||
|
||||
#: Which country is this organisation based in?
|
||||
hq_country = CountryField(
|
||||
blank=True,
|
||||
null=False,
|
||||
help_text=(
|
||||
'In which country does this organisation have its main location?'))
|
||||
|
||||
is_partner_organisation = models.BooleanField(default=False,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
latitude = models.FloatField(blank=True, null=True)
|
||||
|
||||
longitude = models.FloatField(blank=True, null=True)
|
||||
|
||||
#: Answers to :class:`OrganisationQuestion`s
|
||||
question_answers = models.ManyToManyField(OrganisationQuestionChoice)
|
||||
|
||||
@property
|
||||
def location_set(self) -> bool:
|
||||
return self.latitude and self.longitude
|
||||
|
||||
def public_answers(self) -> models.QuerySet:
|
||||
"""Get answers to questions which are public."""
|
||||
return self.question_answers.filter(question__answer_is_public=True)
|
||||
|
||||
def as_dict(self):
|
||||
"""Get the answers from this set as a dictionary for use in Form.initial."""
|
||||
exclude_fields = {
|
||||
'id',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
'organisation_id',
|
||||
'question_answers',
|
||||
}
|
||||
|
||||
def field_value_repr(field):
|
||||
"""Get the representation of a field's value as required by Form.initial."""
|
||||
attr_val = getattr(self, field.attname)
|
||||
|
||||
# Relation fields need to return PKs
|
||||
if isinstance(field, models.ManyToManyField):
|
||||
return [obj.pk for obj in attr_val.all()]
|
||||
|
||||
# But foreign key fields are a PK already so no extra work
|
||||
|
||||
return attr_val
|
||||
|
||||
answers = {
|
||||
# Foreign key fields have _id at end in model _meta but don't in forms
|
||||
field.attname.rstrip('_id'): field_value_repr(field)
|
||||
for field in self._meta.get_fields()
|
||||
if field.attname not in exclude_fields
|
||||
}
|
||||
|
||||
for answer in self.question_answers.all():
|
||||
question = answer.question
|
||||
field_name = f'question_{question.pk}'
|
||||
|
||||
if question.is_multiple_choice:
|
||||
if field_name not in answers:
|
||||
answers[field_name] = []
|
||||
|
||||
answers[field_name].append(answer.pk)
|
||||
|
||||
else:
|
||||
answers[field_name] = answer.pk
|
||||
|
||||
return answers
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.organisation.get_absolute_url()
|
||||
@@ -11,14 +11,13 @@ from django_countries.fields import CountryField
|
||||
from django_settings_export import settings_export
|
||||
from post_office import mail
|
||||
|
||||
from .organisation import Organisation
|
||||
from .question import AnswerSet, Question, QuestionChoice
|
||||
|
||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'Organisation',
|
||||
'Theme',
|
||||
'PersonQuestion',
|
||||
'PersonQuestionChoice',
|
||||
'Person',
|
||||
@@ -32,6 +31,9 @@ class User(AbstractUser):
|
||||
"""
|
||||
email = models.EmailField(_('email address'), blank=False, null=False)
|
||||
|
||||
#: Have they given consent to collect and store their data?
|
||||
consent_given = models.BooleanField(default=False)
|
||||
|
||||
def has_person(self) -> bool:
|
||||
"""
|
||||
Does this user have a linked :class:`Person` record?
|
||||
@@ -63,33 +65,6 @@ class User(AbstractUser):
|
||||
self.username)
|
||||
|
||||
|
||||
class Organisation(models.Model):
|
||||
"""Organisation to which a :class:`Person` belongs."""
|
||||
name = models.CharField(max_length=255, blank=False, null=False)
|
||||
|
||||
#: Latitude for displaying location on a map
|
||||
latitude = models.FloatField(blank=True, null=True)
|
||||
|
||||
#: Longitude for displaying location on a map
|
||||
longitude = models.FloatField(blank=True, null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('people:organisation.detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class Theme(models.Model):
|
||||
"""
|
||||
Project theme within which a :class:`Person` works.
|
||||
"""
|
||||
name = models.CharField(max_length=255, blank=False, null=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class PersonQuestion(Question):
|
||||
"""Question which may be asked about a person."""
|
||||
|
||||
@@ -110,6 +85,9 @@ class Person(models.Model):
|
||||
"""
|
||||
class Meta:
|
||||
verbose_name_plural = 'people'
|
||||
ordering = [
|
||||
'name',
|
||||
]
|
||||
|
||||
#: User account belonging to this person
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL,
|
||||
@@ -129,6 +107,13 @@ class Person(models.Model):
|
||||
through_fields=('source', 'target'),
|
||||
symmetrical=False)
|
||||
|
||||
#: Organisations with whom this person has relationship - via intermediate :class:`OrganisationRelationship` model
|
||||
organisation_relationship_targets = models.ManyToManyField(
|
||||
Organisation,
|
||||
related_name='relationship_sources',
|
||||
through='OrganisationRelationship',
|
||||
through_fields=('source', 'target'))
|
||||
|
||||
@property
|
||||
def relationships(self):
|
||||
return self.relationships_as_source.all().union(
|
||||
@@ -138,6 +123,14 @@ class Person(models.Model):
|
||||
def current_answers(self) -> 'PersonAnswerSet':
|
||||
return self.answer_sets.last()
|
||||
|
||||
@property
|
||||
def organisation(self) -> Organisation:
|
||||
return self.current_answers.organisation
|
||||
|
||||
@property
|
||||
def country_of_residence(self):
|
||||
return self.current_answers.country_of_residence
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('people:person.detail', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -147,6 +140,8 @@ class Person(models.Model):
|
||||
|
||||
class PersonAnswerSet(AnswerSet):
|
||||
"""The answers to the person questions at a particular point in time."""
|
||||
question_model = PersonQuestion
|
||||
|
||||
#: Person to which this answer set belongs
|
||||
person = models.ForeignKey(Person,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -160,7 +155,7 @@ class PersonAnswerSet(AnswerSet):
|
||||
##################
|
||||
# Static questions
|
||||
|
||||
nationality = CountryField(blank=True, null=True)
|
||||
nationality = CountryField(multiple=True, blank=True)
|
||||
|
||||
country_of_residence = CountryField(blank=True, null=True)
|
||||
|
||||
@@ -175,14 +170,25 @@ class PersonAnswerSet(AnswerSet):
|
||||
organisation_started_date = models.DateField(
|
||||
'Date started at this organisation', blank=False, null=True)
|
||||
|
||||
#: When did this person join the project?
|
||||
project_started_date = models.DateField(blank=False, null=True)
|
||||
|
||||
#: Job title this person holds within their organisation
|
||||
job_title = models.CharField(max_length=255, blank=True, null=False)
|
||||
job_title = models.CharField(help_text='Contractual job title',
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=False)
|
||||
|
||||
#: Discipline(s) within which this person works
|
||||
disciplines = models.CharField(max_length=255, blank=True, null=True)
|
||||
disciplinary_background = models.CharField(
|
||||
help_text='Research discipline(s) you feel most affiliated with',
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=False)
|
||||
|
||||
#: Project themes within this person works
|
||||
themes = models.ManyToManyField(Theme, related_name='people', blank=True)
|
||||
#: Organisations worked with which aren't in the Organisations list
|
||||
external_organisations = models.CharField(max_length=1023,
|
||||
blank=True,
|
||||
null=False)
|
||||
|
||||
#: Latitude for displaying location on a map
|
||||
latitude = models.FloatField(blank=True, null=True)
|
||||
@@ -190,15 +196,22 @@ class PersonAnswerSet(AnswerSet):
|
||||
#: Longitude for displaying location on a map
|
||||
longitude = models.FloatField(blank=True, null=True)
|
||||
|
||||
@property
|
||||
def location_set(self) -> bool:
|
||||
return self.latitude and self.longitude
|
||||
|
||||
def public_answers(self) -> models.QuerySet:
|
||||
"""Get answers to questions which are public."""
|
||||
return self.question_answers.filter(question__answer_is_public=True)
|
||||
|
||||
def as_dict(self):
|
||||
"""Get the answers from this set as a dictionary for use in Form.initial."""
|
||||
exclude_fields = {
|
||||
'id',
|
||||
'timestemp',
|
||||
'timestamp',
|
||||
'replaced_timestamp',
|
||||
'person_id',
|
||||
'question_answers',
|
||||
'themes',
|
||||
}
|
||||
|
||||
def field_value_repr(field):
|
||||
@@ -215,25 +228,14 @@ class PersonAnswerSet(AnswerSet):
|
||||
|
||||
answers = {
|
||||
# Foreign key fields have _id at end in model _meta but don't in forms
|
||||
field.attname.rstrip('_id'): field_value_repr(field)
|
||||
# str.rstrip strips a set of characters, not a suffix, so doesn't work here
|
||||
field.attname.rsplit('_id')[0]: field_value_repr(field)
|
||||
for field in self._meta.get_fields()
|
||||
if field.attname not in exclude_fields
|
||||
}
|
||||
|
||||
for answer in self.question_answers.all():
|
||||
question = answer.question
|
||||
field_name = f'question_{question.pk}'
|
||||
|
||||
if question.is_multiple_choice:
|
||||
if field_name not in answers:
|
||||
answers[field_name] = []
|
||||
|
||||
answers[field_name].append(answer.pk)
|
||||
|
||||
else:
|
||||
answers[field_name] = answer.pk
|
||||
|
||||
return answers
|
||||
# Add answers to dynamic questions
|
||||
return super().as_dict(answers=answers)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.person.get_absolute_url()
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Base models for configurable questions and response sets."""
|
||||
import abc
|
||||
import typing
|
||||
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
|
||||
__all__ = [
|
||||
'Question',
|
||||
'QuestionChoice',
|
||||
]
|
||||
|
||||
|
||||
class Question(models.Model):
|
||||
"""Questions from which a survey form can be created."""
|
||||
@@ -19,14 +25,51 @@ class Question(models.Model):
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
#: Text of question
|
||||
#: Text of question - 1st person
|
||||
text = models.CharField(max_length=255, blank=False, null=False)
|
||||
|
||||
#: Alternative text to be displayed in network filters - 3rd person
|
||||
filter_text = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=False,
|
||||
help_text='Alternative text to be displayed in network filters - 3rd person')
|
||||
|
||||
help_text = models.CharField(
|
||||
help_text='Additional hint text to be displayed with the question',
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=False
|
||||
)
|
||||
|
||||
#: Should answers to this question be considered public?
|
||||
answer_is_public = models.BooleanField(
|
||||
help_text='Should answers to this question be considered public?',
|
||||
default=True,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
#: Should people be able to select multiple responses to this question?
|
||||
is_multiple_choice = models.BooleanField(default=False,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
@property
|
||||
def is_hardcoded(self) -> bool:
|
||||
return bool(self.hardcoded_field)
|
||||
|
||||
hardcoded_field = models.CharField(
|
||||
help_text='Which hardcoded field does this question represent?',
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=False
|
||||
)
|
||||
|
||||
#: Should people be able to add their own answers?
|
||||
allow_free_text = models.BooleanField(default=False,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
#: Position of this question in the list
|
||||
order = models.SmallIntegerField(default=0, blank=False, null=False)
|
||||
|
||||
@@ -86,6 +129,13 @@ class AnswerSet(models.Model):
|
||||
ordering = [
|
||||
'timestamp',
|
||||
]
|
||||
get_latest_by = 'timestamp'
|
||||
|
||||
@classmethod
|
||||
@abc.abstractproperty
|
||||
def question_model(cls) -> models.Model:
|
||||
"""Model representing questions to be answered in this AnswerSet."""
|
||||
raise NotImplementedError
|
||||
|
||||
#: Entity to which this answer set belongs
|
||||
#: This foreign key must be added to each concrete subclass
|
||||
@@ -95,9 +145,14 @@ class AnswerSet(models.Model):
|
||||
# blank=False,
|
||||
# null=False)
|
||||
|
||||
#: Answers to :class:`Question`s
|
||||
#: This many to many relation must be added to each concrete subclass
|
||||
# question_answers = models.ManyToManyField(QuestionChoice)
|
||||
@abc.abstractproperty
|
||||
def question_answers(self) -> models.QuerySet:
|
||||
"""Answers to :class:`Question`s.
|
||||
|
||||
This many to many relation must be added to each concrete subclass
|
||||
question_answers = models.ManyToManyField(<X>QuestionChoice)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
#: When were these answers collected?
|
||||
timestamp = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
@@ -106,3 +161,62 @@ class AnswerSet(models.Model):
|
||||
replaced_timestamp = models.DateTimeField(blank=True,
|
||||
null=True,
|
||||
editable=False)
|
||||
|
||||
@property
|
||||
def is_current(self) -> bool:
|
||||
return self.replaced_timestamp is None
|
||||
|
||||
def build_question_answers(self,
|
||||
show_all: bool = False,
|
||||
use_slugs: bool = False) -> typing.Dict[str, str]:
|
||||
"""Collect answers to dynamic questions and join with commas."""
|
||||
questions = self.question_model.objects.all()
|
||||
|
||||
if not show_all:
|
||||
questions = questions.filter(answer_is_public=True)
|
||||
|
||||
question_answers = {}
|
||||
try:
|
||||
answerset_answers = list(self.question_answers.order_by().values('text', 'question_id'))
|
||||
|
||||
for question in questions:
|
||||
key = question.slug if use_slugs else question.text
|
||||
|
||||
if question.hardcoded_field:
|
||||
answer = getattr(self, question.hardcoded_field)
|
||||
if isinstance(answer, list):
|
||||
answer = ', '.join(map(str, answer))
|
||||
|
||||
else:
|
||||
answer = ', '.join(
|
||||
answer['text'] for answer in answerset_answers
|
||||
if answer['question_id'] == question.id
|
||||
)
|
||||
|
||||
question_answers[key] = answer
|
||||
|
||||
except AttributeError:
|
||||
# No AnswerSet yet
|
||||
pass
|
||||
|
||||
return question_answers
|
||||
|
||||
def as_dict(self, answers: typing.Optional[typing.Dict[str, typing.Any]] = None):
|
||||
"""Get the answers from this set as a dictionary for use in Form.initial."""
|
||||
if answers is None:
|
||||
answers = {}
|
||||
|
||||
for answer in self.question_answers.all():
|
||||
question = answer.question
|
||||
field_name = f'question_{question.pk}'
|
||||
|
||||
if question.is_multiple_choice:
|
||||
if field_name not in answers:
|
||||
answers[field_name] = []
|
||||
|
||||
answers[field_name].append(answer.pk)
|
||||
|
||||
else:
|
||||
answers[field_name] = answer.pk
|
||||
|
||||
return answers
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""
|
||||
Models describing relationships between people.
|
||||
"""
|
||||
"""Models describing relationships between people."""
|
||||
|
||||
import typing
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from .person import Person
|
||||
from .person import Organisation, Person
|
||||
from .question import AnswerSet, Question, QuestionChoice
|
||||
|
||||
__all__ = [
|
||||
@@ -14,6 +13,10 @@ __all__ = [
|
||||
'RelationshipQuestionChoice',
|
||||
'RelationshipAnswerSet',
|
||||
'Relationship',
|
||||
'OrganisationRelationshipQuestion',
|
||||
'OrganisationRelationshipQuestionChoice',
|
||||
'OrganisationRelationshipAnswerSet',
|
||||
'OrganisationRelationship',
|
||||
]
|
||||
|
||||
|
||||
@@ -32,24 +35,8 @@ class RelationshipQuestionChoice(QuestionChoice):
|
||||
null=False)
|
||||
|
||||
|
||||
# class ExternalPerson(models.Model):
|
||||
# """Model representing a person external to the project.
|
||||
|
||||
# These will never need to be linked to a :class:`User` as they
|
||||
# will never log in to the system.
|
||||
# """
|
||||
# name = models.CharField(max_length=255,
|
||||
# blank=False, null=False)
|
||||
|
||||
# def __str__(self) -> str:
|
||||
# return self.name
|
||||
|
||||
|
||||
class Relationship(models.Model):
|
||||
"""
|
||||
A directional relationship between two people allowing linked questions.
|
||||
"""
|
||||
|
||||
"""A directional relationship between two people allowing linked questions."""
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['source', 'target'],
|
||||
@@ -57,9 +44,11 @@ class Relationship(models.Model):
|
||||
]
|
||||
|
||||
#: Person reporting the relationship
|
||||
source = models.ForeignKey(Person, related_name='relationships_as_source',
|
||||
source = models.ForeignKey(Person,
|
||||
related_name='relationships_as_source',
|
||||
on_delete=models.CASCADE,
|
||||
blank=False, null=False)
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
#: Person with whom the relationship is reported
|
||||
target = models.ForeignKey(Person,
|
||||
@@ -67,25 +56,23 @@ class Relationship(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
blank=False,
|
||||
null=False)
|
||||
# blank=True,
|
||||
# null=True)
|
||||
|
||||
# target_external_person = models.ForeignKey(
|
||||
# ExternalPerson,
|
||||
# related_name='relationships_as_target',
|
||||
# on_delete=models.CASCADE,
|
||||
# blank=True,
|
||||
# null=True)
|
||||
|
||||
#: When was this relationship defined?
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
#: When was this marked as expired? Default None means it has not expired
|
||||
expired = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
@property
|
||||
def current_answers(self) -> 'RelationshipAnswerSet':
|
||||
return self.answer_sets.last()
|
||||
def current_answers(self) -> typing.Optional['RelationshipAnswerSet']:
|
||||
try:
|
||||
answer_set = self.answer_sets.latest()
|
||||
if answer_set.is_current:
|
||||
return answer_set
|
||||
|
||||
except RelationshipAnswerSet.DoesNotExist:
|
||||
# No AnswerSet created yet
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_current(self) -> bool:
|
||||
return self.current_answers is not None
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('people:relationship.detail', kwargs={'pk': self.pk})
|
||||
@@ -100,13 +87,14 @@ class Relationship(models.Model):
|
||||
|
||||
@raise Relationship.DoesNotExist: When the reverse relationship is not known
|
||||
"""
|
||||
return type(self).objects.get(source=self.target,
|
||||
target=self.source)
|
||||
return type(self).objects.get(source=self.target, target=self.source)
|
||||
|
||||
|
||||
class RelationshipAnswerSet(AnswerSet):
|
||||
"""The answers to the relationship questions at a particular point in time."""
|
||||
|
||||
question_model = RelationshipQuestion
|
||||
|
||||
#: Relationship to which this answer set belongs
|
||||
relationship = models.ForeignKey(Relationship,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -119,3 +107,87 @@ class RelationshipAnswerSet(AnswerSet):
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.relationship.get_absolute_url()
|
||||
|
||||
|
||||
class OrganisationRelationshipQuestion(Question):
|
||||
"""Question which may be asked about an :class:`OrganisationRelationship`."""
|
||||
|
||||
|
||||
class OrganisationRelationshipQuestionChoice(QuestionChoice):
|
||||
"""Allowed answer to a :class:`OrganisationRelationshipQuestion`."""
|
||||
|
||||
#: Question to which this answer belongs
|
||||
question = models.ForeignKey(OrganisationRelationshipQuestion,
|
||||
related_name='answers',
|
||||
on_delete=models.CASCADE,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
|
||||
class OrganisationRelationship(models.Model):
|
||||
"""A directional relationship between a person and an organisation with linked questions."""
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['source', 'target'],
|
||||
name='unique_relationship'),
|
||||
]
|
||||
|
||||
#: Person reporting the relationship
|
||||
source = models.ForeignKey(
|
||||
Person,
|
||||
related_name='organisation_relationships_as_source',
|
||||
on_delete=models.CASCADE,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
#: Organisation with which the relationship is reported
|
||||
target = models.ForeignKey(
|
||||
Organisation,
|
||||
related_name='organisation_relationships_as_target',
|
||||
on_delete=models.CASCADE,
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
@property
|
||||
def current_answers(self) -> typing.Optional['OrganisationRelationshipAnswerSet']:
|
||||
try:
|
||||
answer_set = self.answer_sets.latest()
|
||||
if answer_set.is_current:
|
||||
return answer_set
|
||||
|
||||
except OrganisationRelationshipAnswerSet.DoesNotExist:
|
||||
# No AnswerSet created yet
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_current(self) -> bool:
|
||||
return self.current_answers is not None
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('people:organisation.relationship.detail',
|
||||
kwargs={'pk': self.pk})
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.source} -> {self.target}'
|
||||
|
||||
|
||||
class OrganisationRelationshipAnswerSet(AnswerSet):
|
||||
"""The answers to the organisation relationship questions at a particular point in time."""
|
||||
|
||||
question_model = OrganisationRelationshipQuestion
|
||||
|
||||
#: OrganisationRelationship to which this answer set belongs
|
||||
relationship = models.ForeignKey(OrganisationRelationship,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='answer_sets',
|
||||
blank=False,
|
||||
null=False)
|
||||
|
||||
#: Answers to :class:`OrganisationRelationshipQuestion`s
|
||||
question_answers = models.ManyToManyField(
|
||||
OrganisationRelationshipQuestionChoice)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.relationship.get_absolute_url()
|
||||
|
||||
@@ -16,6 +16,15 @@ class PersonSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class OrganisationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Organisation
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class RelationshipSerializer(serializers.ModelSerializer):
|
||||
source = PersonSerializer()
|
||||
target = PersonSerializer()
|
||||
@@ -27,3 +36,18 @@ class RelationshipSerializer(serializers.ModelSerializer):
|
||||
'source',
|
||||
'target',
|
||||
]
|
||||
|
||||
|
||||
class OrganisationRelationshipSerializer(serializers.ModelSerializer):
|
||||
source = PersonSerializer()
|
||||
target = OrganisationSerializer()
|
||||
kind = serializers.ReadOnlyField(default='organisation-relationship')
|
||||
|
||||
class Meta:
|
||||
model = models.OrganisationRelationship
|
||||
fields = [
|
||||
'pk',
|
||||
'source',
|
||||
'target',
|
||||
'kind',
|
||||
]
|
||||
|
||||
36
people/static/js/hide_free_text.js
Normal file
36
people/static/js/hide_free_text.js
Normal file
@@ -0,0 +1,36 @@
|
||||
function hasOption(select, option) {
|
||||
var exists = false;
|
||||
for (var i = 0; i < select.options.length; i++) {
|
||||
if (select.options[i].text.toLowerCase().startsWith(option)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function setFreeTextState(select, freeTextField) {
|
||||
var other_selected = false;
|
||||
for (var i = 0; i < select.selectedOptions.length; i++) {
|
||||
if (select.selectedOptions[i].text.toLowerCase().startsWith('other')) {
|
||||
other_selected = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (other_selected) {
|
||||
freeTextField.show();
|
||||
} else {
|
||||
freeTextField.hide();
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
$('select').each(function (index, element) {
|
||||
if (hasOption(element, 'other')) {
|
||||
var freeTextField = $('#' + element.id + '_free').parent();
|
||||
setFreeTextState(element, freeTextField);
|
||||
|
||||
$('#' + element.id).on('change', function (event) {
|
||||
setFreeTextState(event.target, freeTextField);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
let marker = null;
|
||||
let search_markers = []
|
||||
|
||||
/**
|
||||
@@ -7,9 +6,9 @@ let search_markers = []
|
||||
* @param {Event} event - Click event from a Google Map.
|
||||
*/
|
||||
function selectLocation(event) {
|
||||
if (marker === null) {
|
||||
if (selected_marker === null) {
|
||||
// Generate a new marker
|
||||
marker = new google.maps.Marker({
|
||||
selected_marker = new google.maps.Marker({
|
||||
position: event.latLng,
|
||||
map: map,
|
||||
icon: {
|
||||
@@ -17,17 +16,17 @@ function selectLocation(event) {
|
||||
strokeColor: marker_edge_colour,
|
||||
strokeWeight: marker_edge_width,
|
||||
strokeOpacity: marker_edge_alpha,
|
||||
fillColor: marker_fill_colour,
|
||||
fillColor: '#0099cc',
|
||||
fillOpacity: marker_fill_alpha,
|
||||
scale: marker_scale,
|
||||
labelOrigin: new google.maps.Point(0, -marker_label_offset)
|
||||
},
|
||||
});
|
||||
} else {
|
||||
marker.setPosition(event.latLng);
|
||||
selected_marker.setPosition(event.latLng);
|
||||
}
|
||||
|
||||
const pos = marker.getPosition();
|
||||
const pos = selected_marker.getPosition();
|
||||
document.getElementById('id_latitude').value = pos.lat();
|
||||
document.getElementById('id_longitude').value = pos.lng();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
const marker_fill_alpha = 1.0;
|
||||
const marker_edge_colour = 'white';
|
||||
const marker_fill_colour = 'gray';
|
||||
|
||||
// Size of the arrow markers used on the map
|
||||
const marker_scale = 9;
|
||||
const marker_scale = 7;
|
||||
// Offset for the place type icon (multiplier for marker scale)
|
||||
const marker_label_offset = 0.27 * marker_scale;
|
||||
// Width and transparency for the edges of the markers
|
||||
@@ -11,7 +10,9 @@ const marker_edge_alpha = 1.0;
|
||||
const marker_edge_width = 1.0;
|
||||
|
||||
let map = null;
|
||||
let selected_marker = null;
|
||||
let selected_marker_info = null;
|
||||
let markers = [];
|
||||
|
||||
function createMarker(map, marker_data) {
|
||||
// Get the lat-long position from the data
|
||||
@@ -34,13 +35,15 @@ function createMarker(map, marker_data) {
|
||||
strokeColor: marker_edge_colour,
|
||||
strokeWeight: marker_edge_width,
|
||||
strokeOpacity: marker_edge_alpha,
|
||||
fillColor: marker_fill_colour,
|
||||
fillColor: marker_data.type === 'Organisation' ? '#669933' : '#0099cc',
|
||||
fillOpacity: marker_fill_alpha,
|
||||
scale: marker_scale,
|
||||
labelOrigin: new google.maps.Point(0, -marker_label_offset)
|
||||
},
|
||||
});
|
||||
|
||||
marker.type = marker_data.type;
|
||||
|
||||
marker.info = new google.maps.InfoWindow({
|
||||
content: "<div id='content'>" +
|
||||
"<h3><a href=" + marker_data.url + ">" + marker_data.name.replace(''', "'") + "</a></h3>" +
|
||||
@@ -70,22 +73,32 @@ function initMap() {
|
||||
const markers_data = JSON.parse(
|
||||
document.getElementById('map-markers').textContent)
|
||||
|
||||
let markers_loaded = false
|
||||
|
||||
// For each data entry in the json...
|
||||
for (const marker_data of markers_data) {
|
||||
try {
|
||||
const marker = createMarker(map, marker_data);
|
||||
markers.push(marker);
|
||||
|
||||
bounds.extend(marker.position);
|
||||
|
||||
if (markers_data.length === 1) {
|
||||
selected_marker = marker;
|
||||
}
|
||||
|
||||
markers_loaded = true
|
||||
|
||||
} catch (exc) {
|
||||
// Just skip and move on to next
|
||||
}
|
||||
}
|
||||
|
||||
map.fitBounds(bounds)
|
||||
const max_zoom = 10
|
||||
if (map.getZoom() > max_zoom) {
|
||||
map.setZoom(max_zoom)
|
||||
if (!markers_loaded) {
|
||||
map.panTo({lat: 0, lng: 0})
|
||||
}
|
||||
setMaxZoom()
|
||||
|
||||
setTimeout(setMaxZoom, 100)
|
||||
|
||||
@@ -96,8 +109,8 @@ function initMap() {
|
||||
* Zoom to set level if map is zoomed in more than this.
|
||||
*/
|
||||
function setMaxZoom() {
|
||||
const max_zoom = 10
|
||||
if (map.getZoom() > max_zoom) {
|
||||
map.setZoom(max_zoom)
|
||||
}
|
||||
const max_zoom = 4
|
||||
const min_zoom = 2
|
||||
const zoom = Math.min(Math.max(min_zoom, map.getZoom()), max_zoom)
|
||||
map.setZoom(zoom)
|
||||
}
|
||||
|
||||
204
people/static/js/network.js
Normal file
204
people/static/js/network.js
Normal file
@@ -0,0 +1,204 @@
|
||||
|
||||
// Global reference to Cytoscape graph - needed for `save_image`
|
||||
var cy;
|
||||
|
||||
var hide_organisations = false;
|
||||
var organisation_nodes;
|
||||
var organisation_edges;
|
||||
|
||||
var anonymise_people = false;
|
||||
var anonymise_organisations = false;
|
||||
|
||||
var network_style = [
|
||||
{
|
||||
selector: 'node[name]',
|
||||
style: {
|
||||
label: function (ele) {
|
||||
var anonymise = anonymise_people;
|
||||
if (ele.data('kind') == 'organisation') {
|
||||
anonymise = anonymise_organisations;
|
||||
}
|
||||
|
||||
return anonymise ? ele.data('id') : ele.data('name')
|
||||
},
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
'text-halign': 'center',
|
||||
'text-valign': 'center',
|
||||
'text-wrap': 'wrap',
|
||||
'text-max-width': '90px',
|
||||
'font-size': '12rem',
|
||||
'background-color': 'data(nodeColor)',
|
||||
'shape': 'data(nodeShape)'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'text-max-width': '300px',
|
||||
'font-size': '40rem',
|
||||
'z-index': 100,
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'mid-target-arrow-shape': 'data(lineArrowShape)',
|
||||
'curve-style': 'straight',
|
||||
'width': 1,
|
||||
'line-color': 'data(lineColor)'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Save the network as an image using the browser's normal file download flow.
|
||||
*/
|
||||
function save_image() {
|
||||
saveAs(cy.png(), 'graph.png');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide or restore organisations and relationships with them.
|
||||
*/
|
||||
function toggle_organisations() {
|
||||
hide_organisations = !hide_organisations;
|
||||
|
||||
if (hide_organisations) {
|
||||
organisation_nodes.remove();
|
||||
organisation_edges.remove();
|
||||
} else {
|
||||
organisation_nodes.restore();
|
||||
organisation_edges.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle person node labels between names and ids.
|
||||
*/
|
||||
function toggle_anonymise_people() {
|
||||
anonymise_people = !anonymise_people
|
||||
cy.elements().remove().restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle organisation node labels between names and ids.
|
||||
*/
|
||||
function toggle_anonymise_organisations() {
|
||||
anonymise_organisations = !anonymise_organisations
|
||||
cy.elements().remove().restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page.
|
||||
*/
|
||||
function get_network() {
|
||||
// Initialise Cytoscape graph
|
||||
// See https://js.cytoscape.org/ for documentation
|
||||
cy = cytoscape({
|
||||
container: document.getElementById('cy'),
|
||||
style: network_style
|
||||
});
|
||||
|
||||
// Add pan + zoom widget with cytoscape-panzoom
|
||||
cy.panzoom();
|
||||
|
||||
// Load people and add to graph
|
||||
var person_set = JSON.parse(document.getElementById('person-set-data').textContent);
|
||||
|
||||
for (var person of person_set) {
|
||||
cy.add({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: 'person-' + person.pk.toString(),
|
||||
name: person.name,
|
||||
kind: 'person',
|
||||
nodeColor: '#0099cc',
|
||||
nodeShape: 'ellipse'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Load organisations and add to graph
|
||||
var organisation_set = JSON.parse(document.getElementById('organisation-set-data').textContent);
|
||||
|
||||
for (var item of organisation_set) {
|
||||
cy.add({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: 'organisation-' + item.pk.toString(),
|
||||
name: item.name,
|
||||
kind: 'organisation',
|
||||
nodeColor: '#669933',
|
||||
nodeShape: 'rectangle'
|
||||
}
|
||||
})
|
||||
}
|
||||
organisation_nodes = cy.nodes('[kind = "organisation"]');
|
||||
|
||||
// Load relationships and add to graph
|
||||
var relationship_set = JSON.parse(document.getElementById('relationship-set-data').textContent);
|
||||
|
||||
for (var relationship of relationship_set) {
|
||||
try {
|
||||
cy.add({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: 'relationship-' + relationship.pk.toString(),
|
||||
source: 'person-' + relationship.source.pk.toString(),
|
||||
target: 'person-' + relationship.target.pk.toString(),
|
||||
kind: 'person',
|
||||
lineColor: {
|
||||
'organisation-membership': '#669933'
|
||||
}[relationship.kind] || 'grey',
|
||||
lineArrowShape: 'triangle'
|
||||
}
|
||||
})
|
||||
} catch (exc) {
|
||||
// Exception thrown if a node in the relationship does not exist
|
||||
// This is probably because it's been filtered out
|
||||
}
|
||||
}
|
||||
|
||||
// Load organisation relationships and add to graph
|
||||
relationship_set = JSON.parse(document.getElementById('organisation-relationship-set-data').textContent);
|
||||
|
||||
for (var relationship of relationship_set) {
|
||||
try {
|
||||
cy.add({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: 'organisation-relationship-' + relationship.pk.toString(),
|
||||
source: 'person-' + relationship.source.pk.toString(),
|
||||
target: 'organisation-' + relationship.target.pk.toString(),
|
||||
kind: 'organisation',
|
||||
lineColor: {
|
||||
'organisation-membership': '#669933'
|
||||
}[relationship.kind] || 'black',
|
||||
lineArrowShape: 'triangle'
|
||||
}
|
||||
})
|
||||
} catch (exc) {
|
||||
// Exception thrown if a node in the relationship does not exist
|
||||
// This is probably because it's been filtered out
|
||||
}
|
||||
}
|
||||
organisation_edges = cy.edges('[kind = "organisation"]');
|
||||
|
||||
// Optimise graph layout
|
||||
var layout = cy.layout({
|
||||
name: 'cose',
|
||||
randomize: true,
|
||||
animate: false,
|
||||
idealEdgeLength: function (edge) { return 40; },
|
||||
nodeRepulsion: function (node) { return 1e7; }
|
||||
});
|
||||
|
||||
layout.run();
|
||||
|
||||
setTimeout(function () {
|
||||
document.getElementById('cy').style.height = '100%';
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
$(window).on('load', get_network());
|
||||
25
people/templates/people/includes/answer_set.html
Normal file
25
people/templates/people/includes/answer_set.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50%">Question</th>
|
||||
<th>Answer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for question, answers in question_answers.items %}
|
||||
<tr>
|
||||
<td>{{ question }}</td>
|
||||
<td>{{ answers }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if answer_set is None %}
|
||||
<tr>
|
||||
<td colspan="2">No answers</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>Last updated: {{ answer_set.timestamp }}</p>
|
||||
44
people/templates/people/map.html
Normal file
44
people/templates/people/map.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ map_markers|json_script:'map-markers' }}
|
||||
|
||||
{% load staticfiles %}
|
||||
<script src="{% static 'js/map.js' %}"></script>
|
||||
|
||||
<script async defer
|
||||
src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
|
||||
type="text/javascript"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active" aria-current="page">Map</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>Map</h1>
|
||||
|
||||
<script type="application/javascript">
|
||||
function toggleMarkerType(type) {
|
||||
for (var i = 0; i < markers.length; i++) {
|
||||
if (markers[i].type === type) {
|
||||
markers[i].setVisible(!markers[i].getVisible());
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-info btn-block" onclick="toggleMarkerType('Person');">Toggle People</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-info btn-block" onclick="toggleMarkerType('Organisation');">Toggle Organisations</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map" style="height: 800px; width: 100%"></div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,16 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block extra_head %}
|
||||
{# There's no 'form' so need to add this to load CSS / JS #}
|
||||
{{ date_form.media.css }}
|
||||
{{ relationship_form.media.css }}
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/cytoscape-panzoom/2.5.3/cytoscape.js-panzoom.min.css"
|
||||
integrity="sha512-MJrzp+ZGajx6AWCCCmjBWo0rPFavM1aBghVUSVVa0uYv8THryrtEygjj5r2rUg/ms33SkEC5xJ3E4ycCmxWdrw=="
|
||||
crossorigin="anonymous" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
@@ -7,126 +18,85 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>Network View</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form"
|
||||
method="POST">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h3>Filter Relationships</h3>
|
||||
<div class="col-md-4">
|
||||
<form class="form" method="POST">
|
||||
{% csrf_token %}
|
||||
{% load bootstrap4 %}
|
||||
{% bootstrap_form form exclude='date' %}
|
||||
|
||||
<hr>
|
||||
{% bootstrap_field form.date %}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h3>Filter People</h3>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% buttons %}
|
||||
<button class="btn btn-block btn-info" type="submit">Filter</button>
|
||||
<input class="btn btn-block btn-danger mb-3" type="button" value="Reset Filters" onClick="reset_filters();" />
|
||||
<button class="btn btn-block btn-success mb-3" type="submit">Filter</button>
|
||||
{% endbuttons %}
|
||||
|
||||
{% bootstrap_form date_form %}
|
||||
<hr>
|
||||
|
||||
<h3>Filter Relationships</h3>
|
||||
{% bootstrap_form relationship_form %}
|
||||
<hr>
|
||||
|
||||
<h3>Filter People</h3>
|
||||
{% bootstrap_form person_form %}
|
||||
<hr>
|
||||
|
||||
<h3>Filter Organisations</h3>
|
||||
{% bootstrap_form organisation_form %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="cy"
|
||||
class="mb-2"
|
||||
style="width: 100%; min-height: 1000px; flex-grow: 1; border: 2px solid black"></div>
|
||||
<div class="col-md-8" style="display: flex; flex-direction: column;">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button class="btn btn-block btn-info mb-3" onclick="save_image();">Save Image</button>
|
||||
<button class="btn btn-block btn-info mb-3" onclick="toggle_anonymise_people();">Anonymise People</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<button class="btn btn-block btn-info mb-3" onclick="toggle_organisations();">Hide Organisations</button>
|
||||
<button class="btn btn-block btn-info mb-3" onclick="toggle_anonymise_organisations();">Anonymise Organisations</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cy" class="mb-2"
|
||||
style="width: 100%; min-height: 1000px; border: 2px solid black; z-index: 999"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
{{ date_form.media.js }}
|
||||
{{ relationship_form.media.js }}
|
||||
|
||||
<!--
|
||||
Embedding graph data in page as JSON allows filtering to be performed entirely on the backend when we send a POST.
|
||||
|
||||
This is useful since one of the most popular browsers in several of the target countries is Opera Mini,
|
||||
which renders JavaScript on a proxy server to avoid running it on the frontend.
|
||||
-->
|
||||
{{ person_set|json_script:'person-set-data' }}
|
||||
|
||||
{{ organisation_set|json_script:'organisation-set-data' }}
|
||||
|
||||
{{ relationship_set|json_script:'relationship-set-data' }}
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.14.0/cytoscape.min.js"
|
||||
integrity="sha256-rI7zH7xDqO306nxvXUw9gqkeBpvvmddDdlXJjJM7rEM="
|
||||
crossorigin="anonymous"></script>
|
||||
{{ organisation_relationship_set|json_script:'organisation-relationship-set-data' }}
|
||||
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
* Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page.
|
||||
*/
|
||||
function get_network() {
|
||||
// Initialise Cytoscape graph
|
||||
// See https://js.cytoscape.org/ for documentation
|
||||
var cy = cytoscape({
|
||||
container: document.getElementById('cy'),
|
||||
style: [
|
||||
{
|
||||
selector: 'node[name]',
|
||||
style: {
|
||||
label: 'data(name)',
|
||||
'text-halign': 'center',
|
||||
'text-valign': 'center',
|
||||
'font-size': 8
|
||||
function reset_filters() {
|
||||
$('select').val(null).trigger('change');
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'mid-target-arrow-shape': 'triangle',
|
||||
'curve-style': 'straight',
|
||||
'width': 1,
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Load people and add to graph
|
||||
var person_set = JSON.parse(document.getElementById('person-set-data').textContent);
|
||||
|
||||
for (var person of person_set) {
|
||||
cy.add({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: 'person-' + person.pk.toString(),
|
||||
name: person.name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Load relationships and add to graph
|
||||
var relationship_set = JSON.parse(document.getElementById('relationship-set-data').textContent);
|
||||
|
||||
for (var relationship of relationship_set) {
|
||||
cy.add({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: 'relationship-' + relationship.pk.toString(),
|
||||
source: 'person-' + relationship.source.pk.toString(),
|
||||
target: 'person-' + relationship.target.pk.toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Optimise graph layout
|
||||
var layout = cy.layout({
|
||||
name: 'cose',
|
||||
randomize: true,
|
||||
animate: false,
|
||||
idealEdgeLength: function(edge) {return 64;}
|
||||
});
|
||||
|
||||
layout.run();
|
||||
}
|
||||
|
||||
$( window ).on('load', get_network());
|
||||
</script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.18.2/cytoscape.min.js"
|
||||
integrity="sha512-CBGCXtszkG5rYlQSTNUzk54/731Kz28WPk2uT1GCPCqgfVRJ2v514vzzf16HuGX9WVtE7JLqRuAERNAzFZ9Hpw=="
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape-panzoom/2.5.3/cytoscape-panzoom.min.js"
|
||||
integrity="sha512-coQmIYa/SKS8wyZw14FTLJhHmp5jqIO2WxyGhjAnLGdym6RsLX412wLO1hqnFifU0NacrJvlUukRJEwjRkm0Xg=="
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"
|
||||
integrity="sha512-Qlv6VSKh1gDKGoJbnyA5RMXYcvnpIqhO++MhIM2fStMcGT9i2T//tSwYFlcyoRRDcDZ+TYHpH8azBBCyhpSeqw=="
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
{% load staticfiles %}
|
||||
<script src="{% static 'js/network.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'people:person.list' %}">People</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'people:person.detail' pk=relationship.source.pk %}">{{ relationship.source }}</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ relationship.target }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>Organisation Relationship</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row justify-content-md-center">
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-warning btn-block"
|
||||
href="{% url 'people:organisation.relationship.update' pk=relationship.pk %}">Update Relationship
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if relationship.is_current %}
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-danger btn-block"
|
||||
href="{% url 'people:organisation.relationship.end' pk=relationship.pk %}">End Relationship
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row align-content-center align-items-center">
|
||||
<div class="col-md-5 text-center">
|
||||
<h2>Source</h2>
|
||||
<p>{{ relationship.source }}</p>
|
||||
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:person.detail' pk=relationship.source.pk %}">Profile</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 text-center"></div>
|
||||
|
||||
<div class="col-md-5 text-center">
|
||||
<h2>Target</h2>
|
||||
<p>{{ relationship.target }}</p>
|
||||
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:organisation.detail' pk=relationship.target.pk %}">Profile</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
{% with relationship.current_answers as answer_set %}
|
||||
{% if answer_set is None %}
|
||||
<div class="alert alert-warning mt-3">
|
||||
This relationship has ended. You can start it again by updating it.
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% include 'people/includes/answer_set.html' %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -20,16 +20,48 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>{{ organisation.name }}</h1>
|
||||
<h1>{{ organisation }}</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<a class="btn btn-success"
|
||||
href="{% url 'people:organisation.update' pk=organisation.pk %}">Update</a>
|
||||
<div class="row justify-content-md-center">
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-warning btn-block"
|
||||
href="{% url 'people:organisation.update' pk=organisation.pk %}">Update Organisation</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
{% if relationship %}
|
||||
<a class="btn btn-warning btn-block"
|
||||
href="{% url 'people:organisation.relationship.create' organisation_pk=organisation.pk %}">Update Relationship
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<a class="btn btn-success btn-block"
|
||||
href="{% url 'people:organisation.relationship.create' organisation_pk=organisation.pk %}">Add Relationship
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if relationship %}
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-danger btn-block"
|
||||
href="{% url 'people:organisation.relationship.end' pk=relationship.pk %}">End Relationship
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
{% include 'people/includes/answer_set.html' %}
|
||||
|
||||
<hr>
|
||||
|
||||
{% if organisation.current_answers.location_set %}
|
||||
<div id="map" style="height: 800px; width: 100%"></div>
|
||||
<hr>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
@@ -7,34 +7,48 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>People</h1>
|
||||
<h1>Organisations</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
<a class="btn btn-success"
|
||||
href="{% url 'people:organisation.create' %}">New Organisation</a>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% with config.ORGANISATION_LIST_HELP as help_text %}
|
||||
{% if help_text %}
|
||||
<div class="alert alert-info mt-3 pb-0">
|
||||
{{ help_text|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
{% for organisation in organisation_list.all %}
|
||||
{% for country, organisations in orgs_by_country.items %}
|
||||
<tr><th>{{ country }}</th></tr>
|
||||
|
||||
{% for organisation in organisations %}
|
||||
<tr>
|
||||
<td>{{ organisation }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:organisation.detail' pk=organisation.pk %}">Details</a>
|
||||
href="{% url 'people:organisation.detail' pk=organisation.pk %}">Profile</a>
|
||||
|
||||
{% if organisation.pk in existing_relationships %}
|
||||
<a class="btn btn-sm btn-warning"
|
||||
style="width: 10rem"
|
||||
href="{% url 'people:organisation.relationship.create' organisation_pk=organisation.pk %}">Update Relationship
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-success"
|
||||
style="width: 10rem"
|
||||
href="{% url 'people:organisation.relationship.create' organisation_pk=organisation.pk %}">Add Relationship
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>{{ organisation.name }}</h1>
|
||||
<h1>{{ organisation }}</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
@@ -51,3 +51,7 @@
|
||||
<hr>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script async defer src="{% static 'js/hide_free_text.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ map_markers|json_script:'map-markers' }}
|
||||
|
||||
{% load staticfiles %}
|
||||
<script src="{% static 'js/map.js' %}"></script>
|
||||
|
||||
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
|
||||
type="text/javascript"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'people:person.list' %}">People</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>{{ person.name }}</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
{% if person.user == request.user or request.user.is_superuser %}
|
||||
{% if person.user != request.user and request.user.is_superuser %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>NB:</strong> You are able to see the details of this person because you are an admin.
|
||||
Regular users are not able to see this information for people other than themselves.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% include 'people/person/includes/answer_set.html' %}
|
||||
|
||||
<a class="btn btn-success"
|
||||
href="{% url 'people:person.update' pk=person.pk %}">Update</a>
|
||||
|
||||
{% if person.user == request.user %}
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'password_change' %}?next={{ person.get_absolute_url }}">Change Password</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="map" style="height: 800px; width: 100%"></div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h2>Relationships As Source</h2>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact Name</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for relationship in person.relationships_as_source.all %}
|
||||
<tr>
|
||||
<td>{{ relationship.target }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:person.detail' pk=relationship.target.pk %}">Profile</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:relationship.detail' pk=relationship.pk %}">Relationship Detail</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2">No known relationships</td>
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a class="btn btn-success btn-block"
|
||||
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">New Relationship
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h2>Relationships As Target</h2>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact Name</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for relationship in person.relationships_as_target.all %}
|
||||
<tr>
|
||||
<td>{{ relationship.source }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:person.detail' pk=relationship.source.pk %}">Profile</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:relationship.detail' pk=relationship.pk %}">Relationship Detail</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2">No known relationships</td>
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Activities</h2>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for activity in person.activities.all %}
|
||||
<tr>
|
||||
<td>{{ activity }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'activities:activity.detail' pk=activity.pk %}">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
{% endblock %}
|
||||
95
people/templates/people/person/detail_full.html
Normal file
95
people/templates/people/person/detail_full.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ map_markers|json_script:'map-markers' }}
|
||||
|
||||
{% load staticfiles %}
|
||||
<script src="{% static 'js/map.js' %}"></script>
|
||||
|
||||
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
|
||||
type="text/javascript"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'people:person.list' %}">People</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>{{ person.name }}</h1>
|
||||
|
||||
{% if person.user != request.user %}
|
||||
<hr>
|
||||
|
||||
<div class="row justify-content-md-center">
|
||||
<div class="col-md-3">
|
||||
{% if relationship %}
|
||||
<a class="btn btn-warning btn-block"
|
||||
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Update Relationship
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<a class="btn btn-success btn-block"
|
||||
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Add Relationship
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if relationship %}
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-danger btn-block"
|
||||
href="{% url 'people:relationship.end' pk=relationship.pk %}">End Relationship
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
{% if person.user != request.user and request.user.is_superuser %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>NB:</strong> You are able to see the details of this person because you are an admin.
|
||||
Regular users are not able to see this information for people other than themselves.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% include 'people/includes/answer_set.html' %}
|
||||
|
||||
<a class="btn btn-success"
|
||||
href="{% url 'people:person.update' pk=person.pk %}">Update</a>
|
||||
|
||||
{% load hijack_tags %}
|
||||
{% if person.user == request.user and not request|is_hijacked %}
|
||||
<a class="btn btn-info"
|
||||
href="{% url 'password_change' %}?next={{ person.get_absolute_url }}">Change Password</a>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.is_superuser and person.user and person.user != request.user %}
|
||||
<form style="display: inline;" action="/hijack/{{ person.user.pk }}/" method="post">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-warning" type="submit">Become {{ person.name }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
{% if person.current_answers.location_set %}
|
||||
<div id="map" style="height: 800px; width: 100%"></div>
|
||||
<hr>
|
||||
{% endif %}
|
||||
|
||||
{% include 'people/person/includes/relationships_full.html' %}
|
||||
|
||||
<hr>
|
||||
|
||||
{% include 'people/person/includes/activities_full.html' %}
|
||||
|
||||
<hr>
|
||||
|
||||
{% endblock %}
|
||||
63
people/templates/people/person/detail_partial.html
Normal file
63
people/templates/people/person/detail_partial.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ map_markers|json_script:'map-markers' }}
|
||||
|
||||
{% load staticfiles %}
|
||||
<script src="{% static 'js/map.js' %}"></script>
|
||||
|
||||
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
|
||||
type="text/javascript"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'people:person.list' %}">People</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ object }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>{{ person.name }}</h1>
|
||||
|
||||
{% if person.user != request.user %}
|
||||
<hr>
|
||||
|
||||
<div class="row justify-content-md-center">
|
||||
<div class="col-md-3">
|
||||
{% if relationship %}
|
||||
<a class="btn btn-warning btn-block"
|
||||
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Update Relationship
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<a class="btn btn-success btn-block"
|
||||
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Add Relationship
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if relationship %}
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-danger btn-block"
|
||||
href="{% url 'people:relationship.end' pk=relationship.pk %}">End Relationship
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
{% include 'people/includes/answer_set.html' %}
|
||||
|
||||
<hr>
|
||||
|
||||
{% if person.current_answers.location_set %}
|
||||
<div id="map" style="height: 800px; width: 100%"></div>
|
||||
<hr>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
26
people/templates/people/person/includes/activities_full.html
Normal file
26
people/templates/people/person/includes/activities_full.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<h2>Activities</h2>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for activity in person.activities.all %}
|
||||
<tr>
|
||||
<td>{{ activity }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'activities:activity.detail' pk=activity.pk %}">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1,59 +0,0 @@
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Question</th>
|
||||
<th>Answer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% if answer_set.nationality %}
|
||||
<tr><td>Nationality</td><td>{{ answer_set.nationality.name }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if answer_set.country_of_residence %}
|
||||
<tr><td>Country of Residence</td><td>{{ answer_set.country_of_residence.name }}</td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% if answer_set.organisation %}
|
||||
<tr><td>Organisation</td><td>{{ answer_set.organisation }}</td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% if answer_set.organisation_started_date %}
|
||||
<tr><td>Organisation Started Date</td><td>{{ answer_set.organisation_started_date }}</td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% if answer_set.job_title %}
|
||||
<tr><td>Job Title</td><td>{{ answer_set.job_title }}</td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% if answer_set.disciplines %}
|
||||
<tr><td>Discipline(s)</td><td>{{ answer_set.disciplines }}</td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% if answer_set.themes.exists %}
|
||||
<tr>
|
||||
<td>Project Themes</td>
|
||||
<td>
|
||||
{% for theme in answer_set.themes.all %}
|
||||
{{ theme }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% for answer in answer_set.question_answers.all %}
|
||||
<tr>
|
||||
<td>{{ answer.question }}</td>
|
||||
<td>{{ answer }}</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>Last updated: {{ answer_set.timestamp }}</p>
|
||||
@@ -0,0 +1,87 @@
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<h2>People I've Answered Questions About</h2>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact Name</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for relationship in person.relationships_as_source.all %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if relationship.is_current %}
|
||||
{{ relationship.target }}
|
||||
{% else %}
|
||||
<del>
|
||||
{{ relationship.target }}
|
||||
</del>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:person.detail' pk=relationship.target.pk %}">Profile</a>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:relationship.detail' pk=relationship.pk %}">Relationship Detail</a>
|
||||
<a class="btn btn-sm btn-success"
|
||||
href="{% url 'people:relationship.update' pk=relationship.pk %}">Update</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2">No known relationships</td>
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<h2>Organisations I've Answered Questions About</h2>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organisation Name</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for relationship in person.organisation_relationships_as_source.all %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if relationship.is_current %}
|
||||
{{ relationship.target }}
|
||||
{% else %}
|
||||
<del>
|
||||
{{ relationship.target }}
|
||||
</del>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:organisation.detail' pk=relationship.target.pk %}">Profile</a>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:organisation.relationship.detail' pk=relationship.pk %}">Relationship Detail</a>
|
||||
<a class="btn btn-sm btn-success"
|
||||
href="{% url 'people:organisation.relationship.update' pk=relationship.pk %}">Update</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2">No known relationships</td>
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,6 +14,14 @@
|
||||
<a class="btn btn-success"
|
||||
href="{% url 'people:person.create' %}">New Person</a>
|
||||
|
||||
{% with config.PERSON_LIST_HELP as help_text %}
|
||||
{% if help_text %}
|
||||
<div class="alert alert-info mt-3 pb-0">
|
||||
{{ help_text|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -28,6 +36,21 @@
|
||||
<td>
|
||||
<a class="btn btn-sm btn-info"
|
||||
href="{% url 'people:person.detail' pk=person.pk %}">Profile</a>
|
||||
|
||||
{% if person.user != request.user %}
|
||||
{% if person.pk in existing_relationships %}
|
||||
<a class="btn btn-sm btn-warning"
|
||||
style="width: 10rem"
|
||||
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Update Relationship
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-success"
|
||||
style="width: 10rem"
|
||||
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Add Relationship
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ map_markers|json_script:'map-markers' }}
|
||||
|
||||
{% load staticfiles %}
|
||||
<script src="{% static 'js/map.js' %}"></script>
|
||||
|
||||
<script async defer
|
||||
src="https://maps.googleapis.com/maps/api/js?key={{ settings.GOOGLE_MAPS_API_KEY }}&callback=initMap"
|
||||
type="text/javascript"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'people:person.list' %}">People</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Map</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>Map</h1>
|
||||
|
||||
<div id="map" style="height: 800px; width: 100%"></div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -48,6 +48,8 @@
|
||||
<input id="location-search" class="controls" type="text" placeholder="Location Search"/>
|
||||
<div id="map" style="height: 800px; width: 100%"></div>
|
||||
|
||||
<hr>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script async defer src="{% static 'js/hide_free_text.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,7 +13,15 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1>New Relationship</h1>
|
||||
<h1>Add Relationship</h1>
|
||||
|
||||
{% with config.RELATIONSHIP_FORM_HELP as help_text %}
|
||||
{% if help_text %}
|
||||
<div class="alert alert-info mt-3 pb-0">
|
||||
{{ help_text|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
@@ -17,6 +17,24 @@
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row justify-content-md-center">
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-warning btn-block"
|
||||
href="{% url 'people:relationship.update' pk=relationship.pk %}">Update Relationship
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if relationship.is_current %}
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-danger btn-block"
|
||||
href="{% url 'people:relationship.end' pk=relationship.pk %}">End Relationship
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row align-content-center align-items-center">
|
||||
<div class="col-md-5 text-center">
|
||||
<h2>Source</h2>
|
||||
@@ -45,34 +63,15 @@
|
||||
|
||||
<hr>
|
||||
|
||||
<a class="btn btn-success"
|
||||
href="{% url 'people:relationship.update' relationship_pk=relationship.pk %}">Update</a>
|
||||
|
||||
{% with relationship.current_answers as answer_set %}
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Question</th>
|
||||
<th>Answer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% if answer_set is None %}
|
||||
<div class="alert alert-warning mt-3">
|
||||
This relationship has ended. You can start it again by updating it.
|
||||
</div>
|
||||
|
||||
<tbody>
|
||||
{% for answer in answer_set.question_answers.all %}
|
||||
<tr>
|
||||
<td>{{ answer.question }}</td>
|
||||
<td>{{ answer }}</td>
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>No records</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Last updated: {{ answer_set.timestamp }}
|
||||
{% else %}
|
||||
{% include 'people/includes/answer_set.html' %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<a href="{% url 'people:person.detail' pk=person.pk %}">{{ person }}</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'people:relationship.detail' pk=relationship.pk %}">{{ relationship.target }}</a>
|
||||
<a href="{{ relationship.get_absolute_url }}">{{ relationship.target }}</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Update Relationship</li>
|
||||
</ol>
|
||||
@@ -18,6 +18,14 @@
|
||||
|
||||
<h1>Update Relationship</h1>
|
||||
|
||||
{% with config.RELATIONSHIP_FORM_HELP as help_text %}
|
||||
{% if help_text %}
|
||||
<div class="alert alert-info mt-3 pb-0">
|
||||
{{ help_text|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form"
|
||||
@@ -33,3 +41,8 @@
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
{% load staticfiles %}
|
||||
<script async defer src="{% static 'js/hide_free_text.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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/<int:person_pk>/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/<int:relationship_pk>/update',
|
||||
path('relationships/<int:pk>/update',
|
||||
views.relationship.RelationshipUpdateView.as_view(),
|
||||
name='relationship.update'),
|
||||
|
||||
path('relationships/<int:pk>/end',
|
||||
views.relationship.RelationshipEndView.as_view(),
|
||||
name='relationship.end'),
|
||||
|
||||
################################
|
||||
# OrganisationRelationship views
|
||||
path('organisations/<int:organisation_pk>/relationships/create',
|
||||
views.relationship.OrganisationRelationshipCreateView.as_view(),
|
||||
name='organisation.relationship.create'),
|
||||
|
||||
path('organisation-relationships/<int:pk>',
|
||||
views.relationship.OrganisationRelationshipDetailView.as_view(),
|
||||
name='organisation.relationship.detail'),
|
||||
|
||||
path('organisation-relationships/<int:pk>/update',
|
||||
views.relationship.OrganisationRelationshipUpdateView.as_view(),
|
||||
name='organisation.relationship.update'),
|
||||
|
||||
path('organisation-relationships/<int:pk>/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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
52
people/views/map.py
Normal file
52
people/views/map.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user