mirror of
https://github.com/Southampton-RSG/breccia-mapper.git
synced 2026-03-03 11:27:09 +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`.
|
Many configuration settings are input from `settings.ini`.
|
||||||
The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABASE_URL, PROJECT_*_NAME, EMAIL_*
|
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
|
- PROJECT_LONG_NAME
|
||||||
default: Project Long Name
|
default: Project Long Name
|
||||||
Displayed in templates where the full name of the project should be used
|
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
|
Google Maps API key to display maps of people's locations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import collections
|
|
||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -115,11 +118,14 @@ import dj_database_url
|
|||||||
|
|
||||||
SETTINGS_EXPORT = [
|
SETTINGS_EXPORT = [
|
||||||
'DEBUG',
|
'DEBUG',
|
||||||
|
'PARENT_PROJECT_NAME',
|
||||||
'PROJECT_LONG_NAME',
|
'PROJECT_LONG_NAME',
|
||||||
'PROJECT_SHORT_NAME',
|
'PROJECT_SHORT_NAME',
|
||||||
'GOOGLE_MAPS_API_KEY',
|
'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_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name')
|
||||||
PROJECT_SHORT_NAME = config('PROJECT_SHORT_NAME', default='shortname')
|
PROJECT_SHORT_NAME = config('PROJECT_SHORT_NAME', default='shortname')
|
||||||
|
|
||||||
@@ -157,6 +163,9 @@ THIRD_PARTY_APPS = [
|
|||||||
'django_select2',
|
'django_select2',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'post_office',
|
'post_office',
|
||||||
|
'bootstrap_datepicker_plus',
|
||||||
|
'hijack',
|
||||||
|
'compat',
|
||||||
]
|
]
|
||||||
|
|
||||||
FIRST_PARTY_APPS = [
|
FIRST_PARTY_APPS = [
|
||||||
@@ -264,7 +273,7 @@ AUTH_USER_MODEL = 'people.User'
|
|||||||
|
|
||||||
LOGIN_URL = reverse_lazy('login')
|
LOGIN_URL = reverse_lazy('login')
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = reverse_lazy('index')
|
LOGIN_REDIRECT_URL = reverse_lazy('people:person.profile')
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||||
@@ -327,24 +336,53 @@ LOGGING = {
|
|||||||
|
|
||||||
LOGGING_CONFIG = None
|
LOGGING_CONFIG = None
|
||||||
logging.config.dictConfig(LOGGING)
|
logging.config.dictConfig(LOGGING)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||||
|
|
||||||
# Admin panel variables
|
# Admin panel variables
|
||||||
|
|
||||||
CONSTANCE_CONFIG = collections.OrderedDict([
|
CONSTANCE_CONFIG = {
|
||||||
('NOTICE_TEXT',
|
'NOTICE_TEXT': (
|
||||||
('',
|
'',
|
||||||
'Text to be displayed in a notice banner at the top of every page.')),
|
'Text to be displayed in a notice banner at the top of every page.'),
|
||||||
('NOTICE_CLASS', ('alert-warning',
|
'NOTICE_CLASS': (
|
||||||
'CSS class to use for background of notice banner.')),
|
'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 = {
|
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'
|
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
|
||||||
|
|
||||||
|
# Django Hijack settings
|
||||||
|
# See https://django-hijack.readthedocs.io/en/stable/
|
||||||
|
|
||||||
|
HIJACK_USE_BOOTSTRAP = True
|
||||||
|
|
||||||
# Bootstrap settings
|
# Bootstrap settings
|
||||||
# See https://django-bootstrap4.readthedocs.io/en/latest/settings.html
|
# See https://django-bootstrap4.readthedocs.io/en/latest/settings.html
|
||||||
|
|
||||||
@@ -379,12 +417,10 @@ else:
|
|||||||
default=(EMAIL_PORT == 465),
|
default=(EMAIL_PORT == 465),
|
||||||
cast=bool)
|
cast=bool)
|
||||||
|
|
||||||
|
|
||||||
# Upstream API keys
|
# Upstream API keys
|
||||||
|
|
||||||
GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None)
|
GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None)
|
||||||
|
|
||||||
|
|
||||||
# Import customisation app settings if present
|
# Import customisation app settings if present
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<!-- Required meta tags -->
|
<!-- Required meta tags -->
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<title>{{ settings.PROJECT_LONG_NAME }}</title>
|
||||||
|
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
{% bootstrap_css %}
|
{% bootstrap_css %}
|
||||||
@@ -27,6 +28,10 @@
|
|||||||
{% load staticfiles %}
|
{% load staticfiles %}
|
||||||
<link rel="stylesheet" href="{% static 'css/global.css' %}">
|
<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 'javascript_in_head'|bootstrap_setting %}
|
||||||
{% if 'include_jquery'|bootstrap_setting %}
|
{% if 'include_jquery'|bootstrap_setting %}
|
||||||
{# jQuery JavaScript if it is in head #}
|
{# jQuery JavaScript if it is in head #}
|
||||||
@@ -79,7 +84,7 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="nav-item">
|
<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>
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -144,6 +149,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% load hijack_tags %}
|
||||||
|
{% hijack_notification %}
|
||||||
|
|
||||||
{% if request.user.is_authenticated and not request.user.has_person %}
|
{% if request.user.is_authenticated and not request.user.has_person %}
|
||||||
<div class="alert alert-info rounded-0" role="alert">
|
<div class="alert alert-info rounded-0" role="alert">
|
||||||
<p class="text-center mb-0">
|
<p class="text-center mb-0">
|
||||||
@@ -156,9 +164,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% block before_content %}{% endblock %}
|
||||||
|
|
||||||
<main class="container">
|
<main class="{{ full_width_page|yesno:'container-fluid,container' }}">
|
||||||
{# Display Django messages as Bootstrap alerts #}
|
{# Display Django messages as Bootstrap alerts #}
|
||||||
{% bootstrap_messages %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center" style="min-height: 400px;">
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
<h2 class="pb-2">About {{ settings.PROJECT_LONG_NAME }}</h2>
|
<h2 class="pb-2">About {{ settings.PROJECT_LONG_NAME }}</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ urlpatterns = [
|
|||||||
path('select2/',
|
path('select2/',
|
||||||
include('django_select2.urls')),
|
include('django_select2.urls')),
|
||||||
|
|
||||||
|
path('hijack/',
|
||||||
|
include('hijack.urls', namespace='hijack')),
|
||||||
|
|
||||||
path('',
|
path('',
|
||||||
include('django.contrib.auth.urls')),
|
include('django.contrib.auth.urls')),
|
||||||
|
|
||||||
@@ -32,6 +35,10 @@ urlpatterns = [
|
|||||||
views.IndexView.as_view(),
|
views.IndexView.as_view(),
|
||||||
name='index'),
|
name='index'),
|
||||||
|
|
||||||
|
path('consent',
|
||||||
|
views.ConsentTextView.as_view(),
|
||||||
|
name='consent'),
|
||||||
|
|
||||||
path('',
|
path('',
|
||||||
include('export.urls')),
|
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.
|
These views don't represent any of the models in the apps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.conf import settings
|
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 import TemplateView
|
||||||
|
from django.views.generic.edit import UpdateView
|
||||||
|
|
||||||
|
from . import forms
|
||||||
|
|
||||||
|
User = get_user_model() # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
class IndexView(TemplateView):
|
class IndexView(TemplateView):
|
||||||
# Template set in Django settings file - may be customised by a customisation app
|
# Template set in Django settings file - may be customised by a customisation app
|
||||||
template_name = settings.TEMPLATE_NAME_INDEX
|
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):
|
class ActivityAttendanceSerializer(base.FlattenedModelSerializer):
|
||||||
activity = SimpleActivitySerializer()
|
activity = SimpleActivitySerializer()
|
||||||
person = people_serializers.SimplePersonSerializer()
|
person = people_serializers.PersonSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Activity.attendance_list.through
|
model = models.Activity.attendance_list.through
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
import typing
|
import typing
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from people import models
|
from people import models
|
||||||
|
|
||||||
from . import base
|
from . import base
|
||||||
|
|
||||||
|
|
||||||
class SimplePersonSerializer(serializers.ModelSerializer):
|
def underscore(slug: str) -> str:
|
||||||
class Meta:
|
"""Replace hyphens with underscores in text."""
|
||||||
model = models.Person
|
return slug.replace('-', '_')
|
||||||
fields = [
|
|
||||||
'id',
|
|
||||||
# Name is excluded from exports
|
def underscore_dict_keys(dict_: typing.Mapping[str, typing.Any]):
|
||||||
# See https://github.com/Southampton-RSG/breccia-mapper/issues/35
|
return {underscore(key): value for key, value in dict_.items()}
|
||||||
]
|
|
||||||
|
|
||||||
|
class AnswerSetSerializer(base.FlattenedModelSerializer):
|
||||||
|
question_model = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def column_headers(self) -> typing.List[str]:
|
||||||
|
headers = super().column_headers
|
||||||
|
|
||||||
|
# Add relationship questions to columns
|
||||||
|
for question in self.question_model.objects.all():
|
||||||
|
headers.append(underscore(question.slug))
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def to_representation(self, instance: models.question.AnswerSet):
|
||||||
|
rep = super().to_representation(instance)
|
||||||
|
|
||||||
|
rep.update(
|
||||||
|
underscore_dict_keys(instance.build_question_answers(use_slugs=True, show_all=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
class PersonSerializer(base.FlattenedModelSerializer):
|
class PersonSerializer(base.FlattenedModelSerializer):
|
||||||
@@ -22,20 +42,31 @@ class PersonSerializer(base.FlattenedModelSerializer):
|
|||||||
model = models.Person
|
model = models.Person
|
||||||
fields = [
|
fields = [
|
||||||
'id',
|
'id',
|
||||||
# Name is excluded from exports
|
'name',
|
||||||
# See https://github.com/Southampton-RSG/breccia-mapper/issues/35
|
|
||||||
'gender',
|
|
||||||
'age_group',
|
|
||||||
'nationality',
|
|
||||||
'country_of_residence',
|
|
||||||
'organisation',
|
'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):
|
class RelationshipSerializer(base.FlattenedModelSerializer):
|
||||||
source = SimplePersonSerializer()
|
source = PersonSerializer()
|
||||||
target = SimplePersonSerializer()
|
target = PersonSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Relationship
|
model = models.Relationship
|
||||||
@@ -46,12 +77,8 @@ class RelationshipSerializer(base.FlattenedModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def underscore(slug: str) -> str:
|
class RelationshipAnswerSetSerializer(AnswerSetSerializer):
|
||||||
"""Replace hyphens with underscores in text."""
|
question_model = models.RelationshipQuestion
|
||||||
return slug.replace('-', '_')
|
|
||||||
|
|
||||||
|
|
||||||
class RelationshipAnswerSetSerializer(base.FlattenedModelSerializer):
|
|
||||||
relationship = RelationshipSerializer()
|
relationship = RelationshipSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -63,25 +90,54 @@ class RelationshipAnswerSetSerializer(base.FlattenedModelSerializer):
|
|||||||
'replaced_timestamp',
|
'replaced_timestamp',
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
|
||||||
def column_headers(self) -> typing.List[str]:
|
|
||||||
headers = super().column_headers
|
|
||||||
|
|
||||||
# Add relationship questions to columns
|
class OrganisationSerializer(base.FlattenedModelSerializer):
|
||||||
for question in models.RelationshipQuestion.objects.all():
|
class Meta:
|
||||||
headers.append(underscore(question.slug))
|
model = models.Organisation
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
]
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def to_representation(self, instance):
|
class OrganisationAnswerSetSerializer(AnswerSetSerializer):
|
||||||
rep = super().to_representation(instance)
|
question_model = models.OrganisationQuestion
|
||||||
|
organisation = OrganisationSerializer()
|
||||||
|
|
||||||
try:
|
class Meta:
|
||||||
# Add relationship question answers to data
|
model = models.OrganisationAnswerSet
|
||||||
for answer in instance.question_answers.all():
|
fields = [
|
||||||
rep[underscore(answer.question.slug)] = underscore(answer.slug)
|
'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>
|
</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Relationships</td>
|
<td>Relationships</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
@@ -48,6 +57,42 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Activities</td>
|
<td>Activities</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ urlpatterns = [
|
|||||||
views.people.PersonExportView.as_view(),
|
views.people.PersonExportView.as_view(),
|
||||||
name='person'),
|
name='person'),
|
||||||
|
|
||||||
|
path('export/person-answer-sets',
|
||||||
|
views.people.PersonAnswerSetExportView.as_view(),
|
||||||
|
name='person-answer-set'),
|
||||||
|
|
||||||
path('export/relationships',
|
path('export/relationships',
|
||||||
views.people.RelationshipExportView.as_view(),
|
views.people.RelationshipExportView.as_view(),
|
||||||
name='relationship'),
|
name='relationship'),
|
||||||
@@ -22,6 +26,22 @@ urlpatterns = [
|
|||||||
views.people.RelationshipAnswerSetExportView.as_view(),
|
views.people.RelationshipAnswerSetExportView.as_view(),
|
||||||
name='relationship-answer-set'),
|
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',
|
path('export/activities',
|
||||||
views.activities.ActivityExportView.as_view(),
|
views.activities.ActivityExportView.as_view(),
|
||||||
name='activity'),
|
name='activity'),
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import csv
|
import csv
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django.views.generic.list import BaseListView
|
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
|
model = None
|
||||||
serializer_class = None
|
serializer_class = None
|
||||||
|
|
||||||
@@ -18,12 +27,12 @@ class CsvExportView(LoginRequiredMixin, BaseListView):
|
|||||||
# Force ordering by PK - though this should be default anyway
|
# Force ordering by PK - though this should be default anyway
|
||||||
serializer = self.serializer_class(self.get_queryset().order_by('pk'), many=True)
|
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.writeheader()
|
||||||
writer.writerows(serializer.data)
|
writer.writerows(serializer.data)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ExportListView(LoginRequiredMixin, TemplateView):
|
class ExportListView(UserIsStaffMixin, TemplateView):
|
||||||
template_name = 'export/export.html'
|
template_name = 'export/export.html'
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ class PersonExportView(base.CsvExportView):
|
|||||||
serializer_class = serializers.people.PersonSerializer
|
serializer_class = serializers.people.PersonSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class PersonAnswerSetExportView(base.CsvExportView):
|
||||||
|
model = models.person.PersonAnswerSet
|
||||||
|
serializer_class = serializers.people.PersonAnswerSetSerializer
|
||||||
|
|
||||||
|
|
||||||
class RelationshipExportView(base.CsvExportView):
|
class RelationshipExportView(base.CsvExportView):
|
||||||
model = models.relationship.Relationship
|
model = models.relationship.Relationship
|
||||||
serializer_class = serializers.people.RelationshipSerializer
|
serializer_class = serializers.people.RelationshipSerializer
|
||||||
@@ -17,3 +22,23 @@ class RelationshipExportView(base.CsvExportView):
|
|||||||
class RelationshipAnswerSetExportView(base.CsvExportView):
|
class RelationshipAnswerSetExportView(base.CsvExportView):
|
||||||
model = models.relationship.RelationshipAnswerSet
|
model = models.relationship.RelationshipAnswerSet
|
||||||
serializer_class = serializers.people.RelationshipAnswerSetSerializer
|
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
|
) # 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)
|
@admin.register(models.Organisation)
|
||||||
class OrganisationAdmin(admin.ModelAdmin):
|
class OrganisationAdmin(admin.ModelAdmin):
|
||||||
pass
|
inlines = [
|
||||||
|
OrganisationAnswerSetInline,
|
||||||
|
]
|
||||||
@admin.register(models.Theme)
|
|
||||||
class ThemeAdmin(admin.ModelAdmin):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PersonQuestionChoiceInline(admin.TabularInline):
|
class PersonQuestionChoiceInline(admin.TabularInline):
|
||||||
@@ -64,4 +79,20 @@ class RelationshipQuestionAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(models.Relationship)
|
@admin.register(models.Relationship)
|
||||||
class RelationshipAdmin(admin.ModelAdmin):
|
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
|
import typing
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.forms.widgets import SelectDateWidget
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
from bootstrap_datepicker_plus import DatePickerInput
|
||||||
from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget
|
from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget
|
||||||
|
|
||||||
from . import models
|
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):
|
class OrganisationForm(forms.ModelForm):
|
||||||
"""Form for creating / updating an instance of :class:`Organisation`."""
|
"""Form for creating / updating an instance of :class:`Organisation`."""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Organisation
|
model = models.Organisation
|
||||||
fields = ['name', 'latitude', 'longitude']
|
fields = [
|
||||||
|
'name',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PersonForm(forms.ModelForm):
|
class PersonForm(forms.ModelForm):
|
||||||
@@ -46,16 +37,30 @@ class RelationshipForm(forms.Form):
|
|||||||
|
|
||||||
class DynamicAnswerSetBase(forms.Form):
|
class DynamicAnswerSetBase(forms.Form):
|
||||||
field_class = forms.ModelChoiceField
|
field_class = forms.ModelChoiceField
|
||||||
field_widget = None
|
|
||||||
field_required = True
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
initial = kwargs.get('initial', {})
|
field_order = []
|
||||||
|
|
||||||
for question in self.question_model.objects.all():
|
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_class = self.field_class
|
||||||
field_widget = self.field_widget
|
field_widget = self.field_widget
|
||||||
|
|
||||||
@@ -63,14 +68,91 @@ class DynamicAnswerSetBase(forms.Form):
|
|||||||
field_class = forms.ModelMultipleChoiceField
|
field_class = forms.ModelMultipleChoiceField
|
||||||
field_widget = Select2MultipleWidget
|
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,
|
queryset=question.answers,
|
||||||
widget=field_widget,
|
widget=field_widget,
|
||||||
required=self.field_required,
|
required=(self.field_required
|
||||||
initial=initial.get(field_name, None))
|
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
|
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):
|
class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
||||||
@@ -85,37 +167,57 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
|||||||
'country_of_residence',
|
'country_of_residence',
|
||||||
'organisation',
|
'organisation',
|
||||||
'organisation_started_date',
|
'organisation_started_date',
|
||||||
|
'project_started_date',
|
||||||
'job_title',
|
'job_title',
|
||||||
'disciplines',
|
'disciplinary_background',
|
||||||
'themes',
|
'external_organisations',
|
||||||
'latitude',
|
'latitude',
|
||||||
'longitude',
|
'longitude',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'nationality': Select2Widget(),
|
'nationality': Select2MultipleWidget(),
|
||||||
'country_of_residence': Select2Widget(),
|
'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,
|
'latitude': forms.HiddenInput,
|
||||||
'longitude': 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 = {
|
help_texts = {
|
||||||
'organisation_started_date':
|
'organisation_started_date':
|
||||||
'If you don\'t know the exact date, an approximate date is okay.',
|
'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
|
question_model = models.PersonQuestion
|
||||||
|
answer_model = models.PersonQuestionChoice
|
||||||
|
|
||||||
def save(self, commit=True) -> models.PersonAnswerSet:
|
def save(self, commit=True) -> models.PersonAnswerSet:
|
||||||
# Save Relationship model
|
# Save model
|
||||||
self.instance = super().save(commit=False)
|
self.instance = super().save(commit=False)
|
||||||
self.instance.person_id = self.initial['person_id']
|
self.instance.person_id = self.initial['person_id']
|
||||||
if commit:
|
if commit:
|
||||||
self.instance.save()
|
self.instance.save()
|
||||||
|
# Need to call same_m2m manually since we use commit=False above
|
||||||
|
self.save_m2m()
|
||||||
|
|
||||||
if commit:
|
if commit:
|
||||||
# Save answers to relationship questions
|
# Save answers to questions
|
||||||
for key, value in self.cleaned_data.items():
|
for key, value in self.cleaned_data.items():
|
||||||
if key.startswith('question_') and value:
|
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:
|
try:
|
||||||
self.instance.question_answers.add(value)
|
self.instance.question_answers.add(value)
|
||||||
|
|
||||||
@@ -137,17 +239,28 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
|||||||
fields = [
|
fields = [
|
||||||
'relationship',
|
'relationship',
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
|
'relationship': forms.HiddenInput,
|
||||||
|
}
|
||||||
|
|
||||||
question_model = models.RelationshipQuestion
|
question_model = models.RelationshipQuestion
|
||||||
|
answer_model = models.RelationshipQuestionChoice
|
||||||
|
|
||||||
def save(self, commit=True) -> models.RelationshipAnswerSet:
|
def save(self, commit=True) -> models.RelationshipAnswerSet:
|
||||||
# Save Relationship model
|
# Save model
|
||||||
self.instance = super().save(commit=commit)
|
self.instance = super().save(commit=commit)
|
||||||
|
|
||||||
if commit:
|
if commit:
|
||||||
# Save answers to relationship questions
|
# Save answers to questions
|
||||||
for key, value in self.cleaned_data.items():
|
for key, value in self.cleaned_data.items():
|
||||||
if key.startswith('question_') and value:
|
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:
|
try:
|
||||||
self.instance.question_answers.add(value)
|
self.instance.question_answers.add(value)
|
||||||
|
|
||||||
@@ -158,20 +271,81 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
|
|||||||
return self.instance
|
return self.instance
|
||||||
|
|
||||||
|
|
||||||
class NetworkFilterForm(DynamicAnswerSetBase):
|
class OrganisationRelationshipAnswerSetForm(forms.ModelForm,
|
||||||
"""
|
DynamicAnswerSetBase):
|
||||||
Form to provide filtering on the network view.
|
"""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_class = forms.ModelMultipleChoiceField
|
||||||
field_widget = Select2MultipleWidget
|
field_widget = Select2MultipleWidget
|
||||||
field_required = False
|
field_required = False
|
||||||
|
as_filters = True
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkRelationshipFilterForm(FilterForm):
|
||||||
|
"""Filer relationships by answerset responses."""
|
||||||
question_model = models.RelationshipQuestion
|
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
|
class NetworkPersonFilterForm(FilterForm):
|
||||||
self.fields['date'] = forms.DateField(
|
"""Filer people by answerset responses."""
|
||||||
required=False,
|
question_model = models.PersonQuestion
|
||||||
widget=SelectDateWidget(years=get_date_year_range()),
|
answer_model = models.PersonQuestionChoice
|
||||||
help_text='Show relationships as they were on this date')
|
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
|
import typing
|
||||||
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
|
|
||||||
def port_question(apps, question_text: str,
|
def port_question(apps, question_text: str, answers_text: typing.Iterable[str],
|
||||||
answers_text: typing.Iterable[str]):
|
**kwargs):
|
||||||
PersonQuestion = apps.get_model('people', 'PersonQuestion')
|
PersonQuestion = apps.get_model('people', 'PersonQuestion')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
prev_question = PersonQuestion.objects.filter(
|
prev_question = PersonQuestion.objects.filter(
|
||||||
text=question_text).latest('version')
|
text=question_text).latest('version')
|
||||||
question = PersonQuestion.objects.create(
|
question = PersonQuestion.objects.create(
|
||||||
text=question_text, version=prev_question.version + 1)
|
text=question_text, version=prev_question.version + 1, **kwargs)
|
||||||
|
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
question = PersonQuestion.objects.create(text=question_text)
|
question = PersonQuestion.objects.create(text=question_text, **kwargs)
|
||||||
|
|
||||||
for answer_text in answers_text:
|
for answer_text in answers_text:
|
||||||
question.answers.get_or_create(text=answer_text)
|
question.answers.get_or_create(text=answer_text)
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
from .person import *
|
from .organisation import * # noqa
|
||||||
from .relationship import *
|
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 django_settings_export import settings_export
|
||||||
from post_office import mail
|
from post_office import mail
|
||||||
|
|
||||||
|
from .organisation import Organisation
|
||||||
from .question import AnswerSet, Question, QuestionChoice
|
from .question import AnswerSet, Question, QuestionChoice
|
||||||
|
|
||||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'User',
|
'User',
|
||||||
'Organisation',
|
|
||||||
'Theme',
|
|
||||||
'PersonQuestion',
|
'PersonQuestion',
|
||||||
'PersonQuestionChoice',
|
'PersonQuestionChoice',
|
||||||
'Person',
|
'Person',
|
||||||
@@ -32,6 +31,9 @@ class User(AbstractUser):
|
|||||||
"""
|
"""
|
||||||
email = models.EmailField(_('email address'), blank=False, null=False)
|
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:
|
def has_person(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Does this user have a linked :class:`Person` record?
|
Does this user have a linked :class:`Person` record?
|
||||||
@@ -63,33 +65,6 @@ class User(AbstractUser):
|
|||||||
self.username)
|
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):
|
class PersonQuestion(Question):
|
||||||
"""Question which may be asked about a person."""
|
"""Question which may be asked about a person."""
|
||||||
|
|
||||||
@@ -110,6 +85,9 @@ class Person(models.Model):
|
|||||||
"""
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = 'people'
|
verbose_name_plural = 'people'
|
||||||
|
ordering = [
|
||||||
|
'name',
|
||||||
|
]
|
||||||
|
|
||||||
#: User account belonging to this person
|
#: User account belonging to this person
|
||||||
user = models.OneToOneField(settings.AUTH_USER_MODEL,
|
user = models.OneToOneField(settings.AUTH_USER_MODEL,
|
||||||
@@ -129,6 +107,13 @@ class Person(models.Model):
|
|||||||
through_fields=('source', 'target'),
|
through_fields=('source', 'target'),
|
||||||
symmetrical=False)
|
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
|
@property
|
||||||
def relationships(self):
|
def relationships(self):
|
||||||
return self.relationships_as_source.all().union(
|
return self.relationships_as_source.all().union(
|
||||||
@@ -138,6 +123,14 @@ class Person(models.Model):
|
|||||||
def current_answers(self) -> 'PersonAnswerSet':
|
def current_answers(self) -> 'PersonAnswerSet':
|
||||||
return self.answer_sets.last()
|
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):
|
def get_absolute_url(self):
|
||||||
return reverse('people:person.detail', kwargs={'pk': self.pk})
|
return reverse('people:person.detail', kwargs={'pk': self.pk})
|
||||||
|
|
||||||
@@ -147,6 +140,8 @@ class Person(models.Model):
|
|||||||
|
|
||||||
class PersonAnswerSet(AnswerSet):
|
class PersonAnswerSet(AnswerSet):
|
||||||
"""The answers to the person questions at a particular point in time."""
|
"""The answers to the person questions at a particular point in time."""
|
||||||
|
question_model = PersonQuestion
|
||||||
|
|
||||||
#: Person to which this answer set belongs
|
#: Person to which this answer set belongs
|
||||||
person = models.ForeignKey(Person,
|
person = models.ForeignKey(Person,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@@ -160,7 +155,7 @@ class PersonAnswerSet(AnswerSet):
|
|||||||
##################
|
##################
|
||||||
# Static questions
|
# Static questions
|
||||||
|
|
||||||
nationality = CountryField(blank=True, null=True)
|
nationality = CountryField(multiple=True, blank=True)
|
||||||
|
|
||||||
country_of_residence = CountryField(blank=True, null=True)
|
country_of_residence = CountryField(blank=True, null=True)
|
||||||
|
|
||||||
@@ -175,14 +170,25 @@ class PersonAnswerSet(AnswerSet):
|
|||||||
organisation_started_date = models.DateField(
|
organisation_started_date = models.DateField(
|
||||||
'Date started at this organisation', blank=False, null=True)
|
'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 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
|
disciplinary_background = models.CharField(
|
||||||
disciplines = models.CharField(max_length=255, blank=True, null=True)
|
help_text='Research discipline(s) you feel most affiliated with',
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=False)
|
||||||
|
|
||||||
#: Project themes within this person works
|
#: Organisations worked with which aren't in the Organisations list
|
||||||
themes = models.ManyToManyField(Theme, related_name='people', blank=True)
|
external_organisations = models.CharField(max_length=1023,
|
||||||
|
blank=True,
|
||||||
|
null=False)
|
||||||
|
|
||||||
#: Latitude for displaying location on a map
|
#: Latitude for displaying location on a map
|
||||||
latitude = models.FloatField(blank=True, null=True)
|
latitude = models.FloatField(blank=True, null=True)
|
||||||
@@ -190,15 +196,22 @@ class PersonAnswerSet(AnswerSet):
|
|||||||
#: Longitude for displaying location on a map
|
#: Longitude for displaying location on a map
|
||||||
longitude = models.FloatField(blank=True, null=True)
|
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):
|
def as_dict(self):
|
||||||
"""Get the answers from this set as a dictionary for use in Form.initial."""
|
"""Get the answers from this set as a dictionary for use in Form.initial."""
|
||||||
exclude_fields = {
|
exclude_fields = {
|
||||||
'id',
|
'id',
|
||||||
'timestemp',
|
'timestamp',
|
||||||
'replaced_timestamp',
|
'replaced_timestamp',
|
||||||
'person_id',
|
'person_id',
|
||||||
'question_answers',
|
'question_answers',
|
||||||
'themes',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def field_value_repr(field):
|
def field_value_repr(field):
|
||||||
@@ -215,25 +228,14 @@ class PersonAnswerSet(AnswerSet):
|
|||||||
|
|
||||||
answers = {
|
answers = {
|
||||||
# Foreign key fields have _id at end in model _meta but don't in forms
|
# 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()
|
for field in self._meta.get_fields()
|
||||||
if field.attname not in exclude_fields
|
if field.attname not in exclude_fields
|
||||||
}
|
}
|
||||||
|
|
||||||
for answer in self.question_answers.all():
|
# Add answers to dynamic questions
|
||||||
question = answer.question
|
return super().as_dict(answers=answers)
|
||||||
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):
|
def get_absolute_url(self):
|
||||||
return self.person.get_absolute_url()
|
return self.person.get_absolute_url()
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"""Base models for configurable questions and response sets."""
|
"""Base models for configurable questions and response sets."""
|
||||||
|
import abc
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'Question',
|
||||||
|
'QuestionChoice',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Question(models.Model):
|
class Question(models.Model):
|
||||||
"""Questions from which a survey form can be created."""
|
"""Questions from which a survey form can be created."""
|
||||||
@@ -19,14 +25,51 @@ class Question(models.Model):
|
|||||||
blank=False,
|
blank=False,
|
||||||
null=False)
|
null=False)
|
||||||
|
|
||||||
#: Text of question
|
#: Text of question - 1st person
|
||||||
text = models.CharField(max_length=255, blank=False, null=False)
|
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?
|
#: Should people be able to select multiple responses to this question?
|
||||||
is_multiple_choice = models.BooleanField(default=False,
|
is_multiple_choice = models.BooleanField(default=False,
|
||||||
blank=False,
|
blank=False,
|
||||||
null=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
|
#: Position of this question in the list
|
||||||
order = models.SmallIntegerField(default=0, blank=False, null=False)
|
order = models.SmallIntegerField(default=0, blank=False, null=False)
|
||||||
|
|
||||||
@@ -86,6 +129,13 @@ class AnswerSet(models.Model):
|
|||||||
ordering = [
|
ordering = [
|
||||||
'timestamp',
|
'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
|
#: Entity to which this answer set belongs
|
||||||
#: This foreign key must be added to each concrete subclass
|
#: This foreign key must be added to each concrete subclass
|
||||||
@@ -95,9 +145,14 @@ class AnswerSet(models.Model):
|
|||||||
# blank=False,
|
# blank=False,
|
||||||
# null=False)
|
# null=False)
|
||||||
|
|
||||||
#: Answers to :class:`Question`s
|
@abc.abstractproperty
|
||||||
#: This many to many relation must be added to each concrete subclass
|
def question_answers(self) -> models.QuerySet:
|
||||||
# question_answers = models.ManyToManyField(QuestionChoice)
|
"""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?
|
#: When were these answers collected?
|
||||||
timestamp = models.DateTimeField(auto_now_add=True, editable=False)
|
timestamp = models.DateTimeField(auto_now_add=True, editable=False)
|
||||||
@@ -106,3 +161,62 @@ class AnswerSet(models.Model):
|
|||||||
replaced_timestamp = models.DateTimeField(blank=True,
|
replaced_timestamp = models.DateTimeField(blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
editable=False)
|
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.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .person import Person
|
from .person import Organisation, Person
|
||||||
from .question import AnswerSet, Question, QuestionChoice
|
from .question import AnswerSet, Question, QuestionChoice
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -14,6 +13,10 @@ __all__ = [
|
|||||||
'RelationshipQuestionChoice',
|
'RelationshipQuestionChoice',
|
||||||
'RelationshipAnswerSet',
|
'RelationshipAnswerSet',
|
||||||
'Relationship',
|
'Relationship',
|
||||||
|
'OrganisationRelationshipQuestion',
|
||||||
|
'OrganisationRelationshipQuestionChoice',
|
||||||
|
'OrganisationRelationshipAnswerSet',
|
||||||
|
'OrganisationRelationship',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -32,24 +35,8 @@ class RelationshipQuestionChoice(QuestionChoice):
|
|||||||
null=False)
|
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):
|
class Relationship(models.Model):
|
||||||
"""
|
"""A directional relationship between two people allowing linked questions."""
|
||||||
A directional relationship between two people allowing linked questions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(fields=['source', 'target'],
|
models.UniqueConstraint(fields=['source', 'target'],
|
||||||
@@ -57,9 +44,11 @@ class Relationship(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
#: Person reporting the relationship
|
#: 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,
|
on_delete=models.CASCADE,
|
||||||
blank=False, null=False)
|
blank=False,
|
||||||
|
null=False)
|
||||||
|
|
||||||
#: Person with whom the relationship is reported
|
#: Person with whom the relationship is reported
|
||||||
target = models.ForeignKey(Person,
|
target = models.ForeignKey(Person,
|
||||||
@@ -67,25 +56,23 @@ class Relationship(models.Model):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
blank=False,
|
blank=False,
|
||||||
null=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
|
@property
|
||||||
def current_answers(self) -> 'RelationshipAnswerSet':
|
def current_answers(self) -> typing.Optional['RelationshipAnswerSet']:
|
||||||
return self.answer_sets.last()
|
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):
|
def get_absolute_url(self):
|
||||||
return reverse('people:relationship.detail', kwargs={'pk': self.pk})
|
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
|
@raise Relationship.DoesNotExist: When the reverse relationship is not known
|
||||||
"""
|
"""
|
||||||
return type(self).objects.get(source=self.target,
|
return type(self).objects.get(source=self.target, target=self.source)
|
||||||
target=self.source)
|
|
||||||
|
|
||||||
|
|
||||||
class RelationshipAnswerSet(AnswerSet):
|
class RelationshipAnswerSet(AnswerSet):
|
||||||
"""The answers to the relationship questions at a particular point in time."""
|
"""The answers to the relationship questions at a particular point in time."""
|
||||||
|
|
||||||
|
question_model = RelationshipQuestion
|
||||||
|
|
||||||
#: Relationship to which this answer set belongs
|
#: Relationship to which this answer set belongs
|
||||||
relationship = models.ForeignKey(Relationship,
|
relationship = models.ForeignKey(Relationship,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@@ -119,3 +107,87 @@ class RelationshipAnswerSet(AnswerSet):
|
|||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return self.relationship.get_absolute_url()
|
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):
|
class RelationshipSerializer(serializers.ModelSerializer):
|
||||||
source = PersonSerializer()
|
source = PersonSerializer()
|
||||||
target = PersonSerializer()
|
target = PersonSerializer()
|
||||||
@@ -27,3 +36,18 @@ class RelationshipSerializer(serializers.ModelSerializer):
|
|||||||
'source',
|
'source',
|
||||||
'target',
|
'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 = []
|
let search_markers = []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -7,9 +6,9 @@ let search_markers = []
|
|||||||
* @param {Event} event - Click event from a Google Map.
|
* @param {Event} event - Click event from a Google Map.
|
||||||
*/
|
*/
|
||||||
function selectLocation(event) {
|
function selectLocation(event) {
|
||||||
if (marker === null) {
|
if (selected_marker === null) {
|
||||||
// Generate a new marker
|
// Generate a new marker
|
||||||
marker = new google.maps.Marker({
|
selected_marker = new google.maps.Marker({
|
||||||
position: event.latLng,
|
position: event.latLng,
|
||||||
map: map,
|
map: map,
|
||||||
icon: {
|
icon: {
|
||||||
@@ -17,17 +16,17 @@ function selectLocation(event) {
|
|||||||
strokeColor: marker_edge_colour,
|
strokeColor: marker_edge_colour,
|
||||||
strokeWeight: marker_edge_width,
|
strokeWeight: marker_edge_width,
|
||||||
strokeOpacity: marker_edge_alpha,
|
strokeOpacity: marker_edge_alpha,
|
||||||
fillColor: marker_fill_colour,
|
fillColor: '#0099cc',
|
||||||
fillOpacity: marker_fill_alpha,
|
fillOpacity: marker_fill_alpha,
|
||||||
scale: marker_scale,
|
scale: marker_scale,
|
||||||
labelOrigin: new google.maps.Point(0, -marker_label_offset)
|
labelOrigin: new google.maps.Point(0, -marker_label_offset)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} 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_latitude').value = pos.lat();
|
||||||
document.getElementById('id_longitude').value = pos.lng();
|
document.getElementById('id_longitude').value = pos.lng();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
const marker_fill_alpha = 1.0;
|
const marker_fill_alpha = 1.0;
|
||||||
const marker_edge_colour = 'white';
|
const marker_edge_colour = 'white';
|
||||||
const marker_fill_colour = 'gray';
|
|
||||||
|
|
||||||
// Size of the arrow markers used on the map
|
// 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)
|
// Offset for the place type icon (multiplier for marker scale)
|
||||||
const marker_label_offset = 0.27 * marker_scale;
|
const marker_label_offset = 0.27 * marker_scale;
|
||||||
// Width and transparency for the edges of the markers
|
// 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;
|
const marker_edge_width = 1.0;
|
||||||
|
|
||||||
let map = null;
|
let map = null;
|
||||||
|
let selected_marker = null;
|
||||||
let selected_marker_info = null;
|
let selected_marker_info = null;
|
||||||
|
let markers = [];
|
||||||
|
|
||||||
function createMarker(map, marker_data) {
|
function createMarker(map, marker_data) {
|
||||||
// Get the lat-long position from the data
|
// Get the lat-long position from the data
|
||||||
@@ -34,13 +35,15 @@ function createMarker(map, marker_data) {
|
|||||||
strokeColor: marker_edge_colour,
|
strokeColor: marker_edge_colour,
|
||||||
strokeWeight: marker_edge_width,
|
strokeWeight: marker_edge_width,
|
||||||
strokeOpacity: marker_edge_alpha,
|
strokeOpacity: marker_edge_alpha,
|
||||||
fillColor: marker_fill_colour,
|
fillColor: marker_data.type === 'Organisation' ? '#669933' : '#0099cc',
|
||||||
fillOpacity: marker_fill_alpha,
|
fillOpacity: marker_fill_alpha,
|
||||||
scale: marker_scale,
|
scale: marker_scale,
|
||||||
labelOrigin: new google.maps.Point(0, -marker_label_offset)
|
labelOrigin: new google.maps.Point(0, -marker_label_offset)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
marker.type = marker_data.type;
|
||||||
|
|
||||||
marker.info = new google.maps.InfoWindow({
|
marker.info = new google.maps.InfoWindow({
|
||||||
content: "<div id='content'>" +
|
content: "<div id='content'>" +
|
||||||
"<h3><a href=" + marker_data.url + ">" + marker_data.name.replace(''', "'") + "</a></h3>" +
|
"<h3><a href=" + marker_data.url + ">" + marker_data.name.replace(''', "'") + "</a></h3>" +
|
||||||
@@ -70,22 +73,32 @@ function initMap() {
|
|||||||
const markers_data = JSON.parse(
|
const markers_data = JSON.parse(
|
||||||
document.getElementById('map-markers').textContent)
|
document.getElementById('map-markers').textContent)
|
||||||
|
|
||||||
|
let markers_loaded = false
|
||||||
|
|
||||||
// For each data entry in the json...
|
// For each data entry in the json...
|
||||||
for (const marker_data of markers_data) {
|
for (const marker_data of markers_data) {
|
||||||
try {
|
try {
|
||||||
const marker = createMarker(map, marker_data);
|
const marker = createMarker(map, marker_data);
|
||||||
|
markers.push(marker);
|
||||||
|
|
||||||
bounds.extend(marker.position);
|
bounds.extend(marker.position);
|
||||||
|
|
||||||
|
if (markers_data.length === 1) {
|
||||||
|
selected_marker = marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
markers_loaded = true
|
||||||
|
|
||||||
} catch (exc) {
|
} catch (exc) {
|
||||||
// Just skip and move on to next
|
// Just skip and move on to next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
map.fitBounds(bounds)
|
map.fitBounds(bounds)
|
||||||
const max_zoom = 10
|
if (!markers_loaded) {
|
||||||
if (map.getZoom() > max_zoom) {
|
map.panTo({lat: 0, lng: 0})
|
||||||
map.setZoom(max_zoom)
|
|
||||||
}
|
}
|
||||||
|
setMaxZoom()
|
||||||
|
|
||||||
setTimeout(setMaxZoom, 100)
|
setTimeout(setMaxZoom, 100)
|
||||||
|
|
||||||
@@ -96,8 +109,8 @@ function initMap() {
|
|||||||
* Zoom to set level if map is zoomed in more than this.
|
* Zoom to set level if map is zoomed in more than this.
|
||||||
*/
|
*/
|
||||||
function setMaxZoom() {
|
function setMaxZoom() {
|
||||||
const max_zoom = 10
|
const max_zoom = 4
|
||||||
if (map.getZoom() > max_zoom) {
|
const min_zoom = 2
|
||||||
map.setZoom(max_zoom)
|
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' %}
|
{% 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 %}
|
{% block content %}
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
@@ -7,126 +18,85 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>Network View</h1>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<form class="form"
|
|
||||||
method="POST">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<h3>Filter Relationships</h3>
|
<form class="form" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
{% load bootstrap4 %}
|
{% 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 %}
|
{% 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 %}
|
{% 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>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="cy"
|
<div class="col-md-8" style="display: flex; flex-direction: column;">
|
||||||
class="mb-2"
|
<div class="row">
|
||||||
style="width: 100%; min-height: 1000px; flex-grow: 1; border: 2px solid black"></div>
|
<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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_script %}
|
{% 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.
|
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' }}
|
{{ person_set|json_script:'person-set-data' }}
|
||||||
|
|
||||||
|
{{ organisation_set|json_script:'organisation-set-data' }}
|
||||||
|
|
||||||
{{ relationship_set|json_script:'relationship-set-data' }}
|
{{ relationship_set|json_script:'relationship-set-data' }}
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.14.0/cytoscape.min.js"
|
{{ organisation_relationship_set|json_script:'organisation-relationship-set-data' }}
|
||||||
integrity="sha256-rI7zH7xDqO306nxvXUw9gqkeBpvvmddDdlXJjJM7rEM="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
|
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
/**
|
function reset_filters() {
|
||||||
* Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page.
|
$('select').val(null).trigger('change');
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
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>
|
||||||
|
|
||||||
|
<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 %}
|
{% 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>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>{{ organisation.name }}</h1>
|
<h1>{{ organisation }}</h1>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<a class="btn btn-success"
|
<div class="row justify-content-md-center">
|
||||||
href="{% url 'people:organisation.update' pk=organisation.pk %}">Update</a>
|
<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>
|
<hr>
|
||||||
|
|
||||||
|
{% include 'people/includes/answer_set.html' %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{% if organisation.current_answers.location_set %}
|
||||||
<div id="map" style="height: 800px; width: 100%"></div>
|
<div id="map" style="height: 800px; width: 100%"></div>
|
||||||
|
<hr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
|||||||
@@ -7,34 +7,48 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>People</h1>
|
<h1>Organisations</h1>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<a class="btn btn-success"
|
<a class="btn btn-success"
|
||||||
href="{% url 'people:organisation.create' %}">New Organisation</a>
|
href="{% url 'people:organisation.create' %}">New Organisation</a>
|
||||||
|
|
||||||
<table class="table table-borderless">
|
{% with config.ORGANISATION_LIST_HELP as help_text %}
|
||||||
<thead>
|
{% if help_text %}
|
||||||
<tr>
|
<div class="alert alert-info mt-3 pb-0">
|
||||||
<th>Name</th>
|
{{ help_text|linebreaks }}
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<table class="table table-borderless">
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td>{{ organisation }}</td>
|
<td>{{ organisation }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="btn btn-sm btn-info"
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td>No records</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>{{ organisation.name }}</h1>
|
<h1>{{ organisation }}</h1>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
@@ -51,3 +51,7 @@
|
|||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
{% endblock %}
|
{% 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"
|
<a class="btn btn-success"
|
||||||
href="{% url 'people:person.create' %}">New Person</a>
|
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">
|
<table class="table table-borderless">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -28,6 +36,21 @@
|
|||||||
<td>
|
<td>
|
||||||
<a class="btn btn-sm btn-info"
|
<a class="btn btn-sm btn-info"
|
||||||
href="{% url 'people:person.detail' pk=person.pk %}">Profile</a>
|
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>
|
</td>
|
||||||
</tr>
|
</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"/>
|
<input id="location-search" class="controls" type="text" placeholder="Location Search"/>
|
||||||
<div id="map" style="height: 800px; width: 100%"></div>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,7 +13,15 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</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>
|
<hr>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,24 @@
|
|||||||
|
|
||||||
<hr>
|
<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="row align-content-center align-items-center">
|
||||||
<div class="col-md-5 text-center">
|
<div class="col-md-5 text-center">
|
||||||
<h2>Source</h2>
|
<h2>Source</h2>
|
||||||
@@ -45,34 +63,15 @@
|
|||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<a class="btn btn-success"
|
|
||||||
href="{% url 'people:relationship.update' relationship_pk=relationship.pk %}">Update</a>
|
|
||||||
|
|
||||||
{% with relationship.current_answers as answer_set %}
|
{% with relationship.current_answers as answer_set %}
|
||||||
<table class="table table-borderless">
|
{% if answer_set is None %}
|
||||||
<thead>
|
<div class="alert alert-warning mt-3">
|
||||||
<tr>
|
This relationship has ended. You can start it again by updating it.
|
||||||
<th>Question</th>
|
</div>
|
||||||
<th>Answer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
{% else %}
|
||||||
{% for answer in answer_set.question_answers.all %}
|
{% include 'people/includes/answer_set.html' %}
|
||||||
<tr>
|
{% endif %}
|
||||||
<td>{{ answer.question }}</td>
|
|
||||||
<td>{{ answer }}</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td>No records</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
Last updated: {{ answer_set.timestamp }}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<a href="{% url 'people:person.detail' pk=person.pk %}">{{ person }}</a>
|
<a href="{% url 'people:person.detail' pk=person.pk %}">{{ person }}</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="breadcrumb-item">
|
<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>
|
||||||
<li class="breadcrumb-item active" aria-current="page">Update Relationship</li>
|
<li class="breadcrumb-item active" aria-current="page">Update Relationship</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -18,6 +18,14 @@
|
|||||||
|
|
||||||
<h1>Update Relationship</h1>
|
<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>
|
<hr>
|
||||||
|
|
||||||
<form class="form"
|
<form class="form"
|
||||||
@@ -33,3 +41,8 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% 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'
|
app_name = 'people'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
####################
|
||||||
|
# Organisation views
|
||||||
path('organisations/create',
|
path('organisations/create',
|
||||||
views.organisation.OrganisationCreateView.as_view(),
|
views.organisation.OrganisationCreateView.as_view(),
|
||||||
name='organisation.create'),
|
name='organisation.create'),
|
||||||
@@ -22,6 +24,8 @@ urlpatterns = [
|
|||||||
views.organisation.OrganisationUpdateView.as_view(),
|
views.organisation.OrganisationUpdateView.as_view(),
|
||||||
name='organisation.update'),
|
name='organisation.update'),
|
||||||
|
|
||||||
|
##############
|
||||||
|
# Person views
|
||||||
path('profile/',
|
path('profile/',
|
||||||
views.person.ProfileView.as_view(),
|
views.person.ProfileView.as_view(),
|
||||||
name='person.profile'),
|
name='person.profile'),
|
||||||
@@ -42,6 +46,8 @@ urlpatterns = [
|
|||||||
views.person.PersonUpdateView.as_view(),
|
views.person.PersonUpdateView.as_view(),
|
||||||
name='person.update'),
|
name='person.update'),
|
||||||
|
|
||||||
|
####################
|
||||||
|
# Relationship views
|
||||||
path('people/<int:person_pk>/relationships/create',
|
path('people/<int:person_pk>/relationships/create',
|
||||||
views.relationship.RelationshipCreateView.as_view(),
|
views.relationship.RelationshipCreateView.as_view(),
|
||||||
name='person.relationship.create'),
|
name='person.relationship.create'),
|
||||||
@@ -50,13 +56,37 @@ urlpatterns = [
|
|||||||
views.relationship.RelationshipDetailView.as_view(),
|
views.relationship.RelationshipDetailView.as_view(),
|
||||||
name='relationship.detail'),
|
name='relationship.detail'),
|
||||||
|
|
||||||
path('relationships/<int:relationship_pk>/update',
|
path('relationships/<int:pk>/update',
|
||||||
views.relationship.RelationshipUpdateView.as_view(),
|
views.relationship.RelationshipUpdateView.as_view(),
|
||||||
name='relationship.update'),
|
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',
|
path('map',
|
||||||
views.person.PersonMapView.as_view(),
|
views.map.MapView.as_view(),
|
||||||
name='person.map'),
|
name='map'),
|
||||||
|
|
||||||
path('network',
|
path('network',
|
||||||
views.network.NetworkView.as_view(),
|
views.network.NetworkView.as_view(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Views for displaying or manipulating models within the `people` app.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
|
map,
|
||||||
network,
|
network,
|
||||||
organisation,
|
organisation,
|
||||||
person,
|
person,
|
||||||
@@ -11,6 +12,7 @@ from . import (
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'map',
|
||||||
'network',
|
'network',
|
||||||
'organisation',
|
'organisation',
|
||||||
'person',
|
'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
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
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.forms import ValidationError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic import FormView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from people import forms, models, serializers
|
from people import forms, models, serializers
|
||||||
|
|
||||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
class NetworkView(LoginRequiredMixin, FormView):
|
def filter_by_form_answers(queryset: QuerySet, answerset_queryset: QuerySet, relationship_key: str):
|
||||||
"""
|
"""Build a filter to select based on form responses."""
|
||||||
View to display relationship network.
|
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'
|
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):
|
def get_form_kwargs(self):
|
||||||
"""
|
"""Add GET params to form data."""
|
||||||
Add GET params to form data.
|
kwargs = {}
|
||||||
"""
|
|
||||||
kwargs = super().get_form_kwargs()
|
|
||||||
|
|
||||||
if self.request.method == 'GET':
|
if self.request.method == 'GET':
|
||||||
if 'data' in kwargs:
|
|
||||||
kwargs['data'].update(self.request.GET)
|
|
||||||
|
|
||||||
else:
|
|
||||||
kwargs['data'] = self.request.GET
|
kwargs['data'] = self.request.GET
|
||||||
|
|
||||||
|
if self.request.method in ('POST', 'PUT'):
|
||||||
|
kwargs['data'] = self.request.POST
|
||||||
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_context_data(self, **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)
|
context = super().get_context_data(**kwargs)
|
||||||
form: forms.NetworkFilterForm = context['form']
|
context['full_width_page'] = True
|
||||||
if not form.is_valid():
|
|
||||||
|
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
|
return context
|
||||||
|
|
||||||
at_date = form.cleaned_data['date']
|
date = all_forms['date'].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())
|
|
||||||
|
|
||||||
context['person_set'] = serializers.PersonSerializer(
|
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(
|
context['relationship_set'] = serializers.RelationshipSerializer(
|
||||||
models.Relationship.objects.filter(
|
filter_relationships(all_forms['relationship'], at_date=date), many=True
|
||||||
pk__in=relationship_answerset_set.values_list('relationship',
|
).data
|
||||||
flat=True)),
|
|
||||||
many=True).data
|
|
||||||
|
|
||||||
logger.info('Found %d distinct relationships matching filters',
|
context['organisation_relationship_set'] = serializers.OrganisationRelationshipSerializer(
|
||||||
len(context['relationship_set']))
|
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
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def forms_valid(self, all_forms):
|
||||||
try:
|
try:
|
||||||
return self.render_to_response(self.get_context_data())
|
return self.render_to_response(self.get_context_data())
|
||||||
|
|
||||||
except ValidationError:
|
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
|
import typing
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
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 django.views.generic import CreateView, DetailView, ListView, UpdateView
|
||||||
|
|
||||||
from people import forms, models
|
from people import forms, models
|
||||||
|
from .map import get_map_data
|
||||||
|
|
||||||
|
|
||||||
class OrganisationCreateView(LoginRequiredMixin, CreateView):
|
class OrganisationCreateView(LoginRequiredMixin, CreateView):
|
||||||
@@ -13,11 +17,100 @@ class OrganisationCreateView(LoginRequiredMixin, CreateView):
|
|||||||
form_class = forms.OrganisationForm
|
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):
|
class OrganisationListView(LoginRequiredMixin, ListView):
|
||||||
"""View displaying a list of :class:`organisation` objects."""
|
"""View displaying a list of :class:`organisation` objects."""
|
||||||
model = models.Organisation
|
model = models.Organisation
|
||||||
template_name = 'people/organisation/list.html'
|
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):
|
class OrganisationDetailView(LoginRequiredMixin, DetailView):
|
||||||
"""View displaying details of a :class:`Organisation`."""
|
"""View displaying details of a :class:`Organisation`."""
|
||||||
@@ -30,11 +123,26 @@ class OrganisationDetailView(LoginRequiredMixin, DetailView):
|
|||||||
"""Add map marker to context."""
|
"""Add map marker to context."""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
context['map_markers'] = [{
|
answer_set = self.object.current_answers
|
||||||
'name': self.object.name,
|
context['answer_set'] = answer_set
|
||||||
'lat': self.object.latitude,
|
context['map_markers'] = [get_map_data(self.object)]
|
||||||
'lng': self.object.longitude,
|
|
||||||
}]
|
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
|
return context
|
||||||
|
|
||||||
@@ -44,17 +152,48 @@ class OrganisationUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
model = models.Organisation
|
model = models.Organisation
|
||||||
context_object_name = 'organisation'
|
context_object_name = 'organisation'
|
||||||
template_name = 'people/organisation/update.html'
|
template_name = 'people/organisation/update.html'
|
||||||
form_class = forms.OrganisationForm
|
form_class = forms.OrganisationAnswerSetForm
|
||||||
|
|
||||||
def get_context_data(self,
|
def get_context_data(self,
|
||||||
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
|
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
|
||||||
"""Add map marker to context."""
|
"""Add map marker to context."""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
context['map_markers'] = [{
|
answerset = self.object.current_answers
|
||||||
'name': self.object.name,
|
context['map_markers'] = [get_map_data(self.object)]
|
||||||
'lat': self.object.latitude,
|
|
||||||
'lng': self.object.longitude,
|
|
||||||
}]
|
|
||||||
|
|
||||||
return context
|
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
|
import typing
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
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.utils import timezone
|
||||||
from django.views.generic import CreateView, DetailView, ListView, UpdateView
|
from django.views.generic import CreateView, DetailView, ListView, UpdateView
|
||||||
|
|
||||||
from people import forms, models, permissions
|
from people import forms, models, permissions
|
||||||
|
from .map import get_map_data
|
||||||
|
|
||||||
|
|
||||||
class PersonCreateView(LoginRequiredMixin, CreateView):
|
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.
|
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):
|
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
|
model = models.Person
|
||||||
template_name = 'people/person/list.html'
|
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):
|
existing_relationships = set()
|
||||||
"""
|
try:
|
||||||
View displaying the profile of a :class:`Person` - who may be a user.
|
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
|
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:
|
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.
|
If not determined from url get current user.
|
||||||
"""
|
"""
|
||||||
@@ -57,14 +93,31 @@ class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView):
|
|||||||
# pk was not provided in URL
|
# pk was not provided in URL
|
||||||
return self.request.user.person
|
return self.request.user.person
|
||||||
|
|
||||||
def get_context_data(self,
|
def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
|
||||||
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
|
|
||||||
"""Add current :class:`PersonAnswerSet` to context."""
|
"""Add current :class:`PersonAnswerSet` to context."""
|
||||||
context = super().get_context_data(**kwargs)
|
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['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
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -75,8 +128,21 @@ class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
|
|||||||
template_name = 'people/person/update.html'
|
template_name = 'people/person/update.html'
|
||||||
form_class = forms.PersonAnswerSetForm
|
form_class = forms.PersonAnswerSetForm
|
||||||
|
|
||||||
def get_context_data(self,
|
def get(self, request: HttpRequest, *args: str, **kwargs: typing.Any) -> HttpResponse:
|
||||||
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
|
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 = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
context['map_markers'] = [get_map_data(self.object)]
|
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
|
# 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
|
# Shouldn't be more than one after initial updates after migration
|
||||||
for answer_set in self.object.person.answer_sets.exclude(
|
for answer_set in self.object.person.answer_sets.exclude(pk=self.object.pk):
|
||||||
pk=self.object.pk):
|
|
||||||
answer_set.replaced_timestamp = now_date
|
answer_set.replaced_timestamp = now_date
|
||||||
answer_set.save()
|
answer_set.save()
|
||||||
|
|
||||||
return response
|
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
|
import typing
|
||||||
from django.forms import ValidationError
|
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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
|
from people import forms, models, permissions
|
||||||
|
|
||||||
@@ -19,109 +19,159 @@ class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView):
|
|||||||
template_name = 'people/relationship/detail.html'
|
template_name = 'people/relationship/detail.html'
|
||||||
related_person_field = 'source'
|
related_person_field = 'source'
|
||||||
|
|
||||||
|
def get_context_data(self,
|
||||||
class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, FormView):
|
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
|
||||||
"""
|
"""Add current :class:`RelationshipAnswerSet` to context."""
|
||||||
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):
|
|
||||||
context = super().get_context_data(**kwargs)
|
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
|
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`.
|
Creates a new :class:`RelationshipAnswerSet` for the :class:`Relationship`.
|
||||||
Displays / processes a form containing the :class:`RelationshipQuestion`s.
|
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'
|
template_name = 'people/relationship/update.html'
|
||||||
form_class = forms.RelationshipAnswerSetForm
|
form_class = forms.RelationshipAnswerSetForm
|
||||||
|
|
||||||
def get_test_person(self) -> models.Person:
|
def get_test_person(self) -> models.Person:
|
||||||
"""
|
"""Get the person instance which should be used for access control checks."""
|
||||||
Get the person instance which should be used for access control checks.
|
return self.get_object().source
|
||||||
"""
|
|
||||||
relationship = models.Relationship.objects.get(
|
|
||||||
pk=self.kwargs.get('relationship_pk'))
|
|
||||||
|
|
||||||
return relationship.source
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
self.relationship = models.Relationship.objects.get(
|
|
||||||
pk=self.kwargs.get('relationship_pk'))
|
|
||||||
self.person = self.relationship.source
|
|
||||||
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
self.relationship = models.Relationship.objects.get(
|
|
||||||
pk=self.kwargs.get('relationship_pk'))
|
|
||||||
self.person = self.relationship.source
|
|
||||||
|
|
||||||
return super().post(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['person'] = self.object.source
|
||||||
context['person'] = self.person
|
|
||||||
context['relationship'] = self.relationship
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_initial(self):
|
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):
|
def form_valid(self, form):
|
||||||
"""
|
"""Mark any previous answer sets as replaced."""
|
||||||
Mark any previous answer sets as replaced.
|
|
||||||
"""
|
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
now_date = timezone.now().date()
|
now_date = timezone.now().date()
|
||||||
|
|
||||||
# Shouldn't be more than one after initial updates after migration
|
# 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):
|
pk=self.object.pk):
|
||||||
answer_set.replaced_timestamp = now_date
|
answer_set.replaced_timestamp = now_date
|
||||||
answer_set.save()
|
answer_set.save()
|
||||||
|
|
||||||
return response
|
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==2.2.10
|
||||||
django-appconf==1.0.3
|
django-appconf==1.0.3
|
||||||
django-bootstrap4==1.1.1
|
django-bootstrap4==1.1.1
|
||||||
|
django-bootstrap-datepicker-plus==3.0.5
|
||||||
|
django-compat==1.0.15
|
||||||
django-constance==2.6.0
|
django-constance==2.6.0
|
||||||
django-countries==5.5
|
django-countries==5.5
|
||||||
django-dbbackup==3.2.0
|
django-dbbackup==3.2.0
|
||||||
django-filter==2.2.0
|
django-filter==2.2.0
|
||||||
|
django-hijack==2.2.1
|
||||||
django-picklefield==2.1.1
|
django-picklefield==2.1.1
|
||||||
django-post-office==3.4.0
|
django-post-office==3.4.0
|
||||||
django-select2==7.2.0
|
django-select2==7.2.0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ deploy_mode: 3
|
|||||||
|
|
||||||
secret_key: '{{ lookup("password", "/dev/null") }}'
|
secret_key: '{{ lookup("password", "/dev/null") }}'
|
||||||
|
|
||||||
|
parent_project_name: 'BRECcIA'
|
||||||
project_name: 'breccia-mapper'
|
project_name: 'breccia-mapper'
|
||||||
project_full_name: 'breccia_mapper'
|
project_full_name: 'breccia_mapper'
|
||||||
project_dir: '/var/www/{{ project_name }}'
|
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
|
ALLOWED_HOSTS={{ inventory_hostname }},localhost,127.0.0.1
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
PARENT_PROJECT_NAME={{ parent_project_name }}
|
||||||
PROJECT_SHORT_NAME={{ display_short_name }}
|
PROJECT_SHORT_NAME={{ display_short_name }}
|
||||||
PROJECT_LONG_NAME={{ display_long_name }}
|
PROJECT_LONG_NAME={{ display_long_name }}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user