Merge branch dev

This commit is contained in:
James Graham
2021-07-30 16:43:13 +01:00
80 changed files with 3466 additions and 847 deletions

4
.style.yapf Normal file
View 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
View 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',
}

View File

@@ -16,6 +16,10 @@ https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
Many configuration settings are input from `settings.ini`.
The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABASE_URL, PROJECT_*_NAME, EMAIL_*
- PARENT_PROJECT_NAME
default: Parent Project Name
Displayed in templates where the name of the parent project should be used
- PROJECT_LONG_NAME
default: Project Long Name
Displayed in templates where the full name of the project should be used
@@ -100,7 +104,6 @@ The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABAS
Google Maps API key to display maps of people's locations
"""
import collections
import logging
import logging.config
import pathlib
@@ -115,11 +118,14 @@ import dj_database_url
SETTINGS_EXPORT = [
'DEBUG',
'PARENT_PROJECT_NAME',
'PROJECT_LONG_NAME',
'PROJECT_SHORT_NAME',
'GOOGLE_MAPS_API_KEY',
]
PARENT_PROJECT_NAME = config('PARENT_PROJECT_NAME',
default='Parent Project Name')
PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name')
PROJECT_SHORT_NAME = config('PROJECT_SHORT_NAME', default='shortname')
@@ -157,6 +163,9 @@ THIRD_PARTY_APPS = [
'django_select2',
'rest_framework',
'post_office',
'bootstrap_datepicker_plus',
'hijack',
'compat',
]
FIRST_PARTY_APPS = [
@@ -264,7 +273,7 @@ AUTH_USER_MODEL = 'people.User'
LOGIN_URL = reverse_lazy('login')
LOGIN_REDIRECT_URL = reverse_lazy('index')
LOGIN_REDIRECT_URL = reverse_lazy('people:person.profile')
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
@@ -327,24 +336,53 @@ LOGGING = {
LOGGING_CONFIG = None
logging.config.dictConfig(LOGGING)
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
# Admin panel variables
CONSTANCE_CONFIG = collections.OrderedDict([
('NOTICE_TEXT',
('',
'Text to be displayed in a notice banner at the top of every page.')),
('NOTICE_CLASS', ('alert-warning',
'CSS class to use for background of notice banner.')),
])
CONSTANCE_CONFIG = {
'NOTICE_TEXT': (
'',
'Text to be displayed in a notice banner at the top of every page.'),
'NOTICE_CLASS': (
'alert-warning',
'CSS class to use for background of notice banner.'),
'CONSENT_TEXT': (
'This is template consent text and should have been replaced. Please contact an admin.',
'Text to be displayed to ask for consent for data collection.'),
'PERSON_LIST_HELP': (
'',
'Help text to display at the top of the people list.'),
'ORGANISATION_LIST_HELP': (
'',
'Help text to display at the top of the organisaton list.'),
'RELATIONSHIP_FORM_HELP': (
'',
'Help text to display at the top of relationship forms.'),
} # yapf: disable
CONSTANCE_CONFIG_FIELDSETS = {
'Notice Banner': ('NOTICE_TEXT', 'NOTICE_CLASS'),
}
'Notice Banner': (
'NOTICE_TEXT',
'NOTICE_CLASS',
),
'Data Collection': (
'CONSENT_TEXT',
),
'Help Text': (
'PERSON_LIST_HELP',
'ORGANISATION_LIST_HELP',
'RELATIONSHIP_FORM_HELP',
),
} # yapf: disable
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
# Django Hijack settings
# See https://django-hijack.readthedocs.io/en/stable/
HIJACK_USE_BOOTSTRAP = True
# Bootstrap settings
# See https://django-bootstrap4.readthedocs.io/en/latest/settings.html
@@ -379,12 +417,10 @@ else:
default=(EMAIL_PORT == 465),
cast=bool)
# Upstream API keys
GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None)
# Import customisation app settings if present
try:

View File

@@ -10,6 +10,7 @@
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{{ settings.PROJECT_LONG_NAME }}</title>
<!-- Bootstrap CSS -->
{% bootstrap_css %}
@@ -27,6 +28,10 @@
{% load staticfiles %}
<link rel="stylesheet" href="{% static 'css/global.css' %}">
<link rel="stylesheet"
type="text/css"
href="{% static 'hijack/hijack-styles.css' %}" />
{% if 'javascript_in_head'|bootstrap_setting %}
{% if 'include_jquery'|bootstrap_setting %}
{# jQuery JavaScript if it is in head #}
@@ -79,7 +84,7 @@
</li>
<li class="nav-item">
<a href="{% url 'people:person.map' %}" class="nav-link">Map</a>
<a href="{% url 'people:map' %}" class="nav-link">Map</a>
</li>
<li class="nav-item">
@@ -144,6 +149,9 @@
</div>
{% endif %}
{% load hijack_tags %}
{% hijack_notification %}
{% if request.user.is_authenticated and not request.user.has_person %}
<div class="alert alert-info rounded-0" role="alert">
<p class="text-center mb-0">
@@ -156,9 +164,18 @@
</div>
{% endif %}
{% if request.user.is_authenticated and not request.user.consent_given %}
<div class="alert alert-warning rounded-0" role="alert">
<p class="text-center mb-0">
You have not yet given consent for your data to be collected and processed.
Please read and accept the <a href="{% url 'consent' %}">consent text</a>.
</p>
</div>
{% endif %}
{% block before_content %}{% endblock %}
<main class="container">
<main class="{{ full_width_page|yesno:'container-fluid,container' }}">
{# Display Django messages as Bootstrap alerts #}
{% bootstrap_messages %}

View 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 %}

View File

@@ -63,7 +63,7 @@
{% endblock %}
{% block content %}
<div class="row align-items-center">
<div class="row align-items-center" style="min-height: 400px;">
<div class="col-sm-8">
<h2 class="pb-2">About {{ settings.PROJECT_LONG_NAME }}</h2>

View File

@@ -25,6 +25,9 @@ urlpatterns = [
path('select2/',
include('django_select2.urls')),
path('hijack/',
include('hijack.urls', namespace='hijack')),
path('',
include('django.contrib.auth.urls')),
@@ -32,6 +35,10 @@ urlpatterns = [
views.IndexView.as_view(),
name='index'),
path('consent',
views.ConsentTextView.as_view(),
name='consent'),
path('',
include('export.urls')),

View File

@@ -1,13 +1,37 @@
"""
Views belonging to the core of the project.
"""Views belonging to the core of the project.
These views don't represent any of the models in the apps.
"""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.views.generic import TemplateView
from django.views.generic.edit import UpdateView
from . import forms
User = get_user_model() # pylint: disable=invalid-name
class IndexView(TemplateView):
# Template set in Django settings file - may be customised by a customisation app
template_name = settings.TEMPLATE_NAME_INDEX
class ConsentTextView(LoginRequiredMixin, UpdateView):
"""View with consent text and form for users to indicate consent."""
model = User
form_class = forms.ConsentForm
template_name = 'consent.html'
def get_success_url(self) -> str:
try:
return reverse('people:person.detail', kwargs={'pk': self.request.user.person.pk})
except AttributeError:
return reverse('index')
def get_object(self, *args, **kwargs) -> User:
return self.request.user

View File

@@ -65,7 +65,7 @@ class SimpleActivitySerializer(serializers.ModelSerializer):
class ActivityAttendanceSerializer(base.FlattenedModelSerializer):
activity = SimpleActivitySerializer()
person = people_serializers.SimplePersonSerializer()
person = people_serializers.PersonSerializer()
class Meta:
model = models.Activity.attendance_list.through

View File

@@ -1,20 +1,40 @@
import typing
from rest_framework import serializers
from people import models
from . import base
class SimplePersonSerializer(serializers.ModelSerializer):
class Meta:
model = models.Person
fields = [
'id',
# Name is excluded from exports
# See https://github.com/Southampton-RSG/breccia-mapper/issues/35
]
def underscore(slug: str) -> str:
"""Replace hyphens with underscores in text."""
return slug.replace('-', '_')
def underscore_dict_keys(dict_: typing.Mapping[str, typing.Any]):
return {underscore(key): value for key, value in dict_.items()}
class AnswerSetSerializer(base.FlattenedModelSerializer):
question_model = None
@property
def column_headers(self) -> typing.List[str]:
headers = super().column_headers
# Add relationship questions to columns
for question in self.question_model.objects.all():
headers.append(underscore(question.slug))
return headers
def to_representation(self, instance: models.question.AnswerSet):
rep = super().to_representation(instance)
rep.update(
underscore_dict_keys(instance.build_question_answers(use_slugs=True, show_all=True))
)
return rep
class PersonSerializer(base.FlattenedModelSerializer):
@@ -22,20 +42,31 @@ class PersonSerializer(base.FlattenedModelSerializer):
model = models.Person
fields = [
'id',
# Name is excluded from exports
# See https://github.com/Southampton-RSG/breccia-mapper/issues/35
'gender',
'age_group',
'nationality',
'country_of_residence',
'name',
'organisation',
'organisation_started_date',
'country_of_residence',
]
class PersonAnswerSetSerializer(AnswerSetSerializer):
question_model = models.PersonQuestion
person = PersonSerializer()
class Meta:
model = models.PersonAnswerSet
fields = [
'id',
'person',
'timestamp',
'replaced_timestamp',
'latitude',
'longitude',
]
class RelationshipSerializer(base.FlattenedModelSerializer):
source = SimplePersonSerializer()
target = SimplePersonSerializer()
source = PersonSerializer()
target = PersonSerializer()
class Meta:
model = models.Relationship
@@ -46,12 +77,8 @@ class RelationshipSerializer(base.FlattenedModelSerializer):
]
def underscore(slug: str) -> str:
"""Replace hyphens with underscores in text."""
return slug.replace('-', '_')
class RelationshipAnswerSetSerializer(base.FlattenedModelSerializer):
class RelationshipAnswerSetSerializer(AnswerSetSerializer):
question_model = models.RelationshipQuestion
relationship = RelationshipSerializer()
class Meta:
@@ -63,25 +90,54 @@ class RelationshipAnswerSetSerializer(base.FlattenedModelSerializer):
'replaced_timestamp',
]
@property
def column_headers(self) -> typing.List[str]:
headers = super().column_headers
# Add relationship questions to columns
for question in models.RelationshipQuestion.objects.all():
headers.append(underscore(question.slug))
class OrganisationSerializer(base.FlattenedModelSerializer):
class Meta:
model = models.Organisation
fields = [
'id',
'name',
]
return headers
def to_representation(self, instance):
rep = super().to_representation(instance)
class OrganisationAnswerSetSerializer(AnswerSetSerializer):
question_model = models.OrganisationQuestion
organisation = OrganisationSerializer()
try:
# Add relationship question answers to data
for answer in instance.question_answers.all():
rep[underscore(answer.question.slug)] = underscore(answer.slug)
class Meta:
model = models.OrganisationAnswerSet
fields = [
'id',
'organisation',
'timestamp',
'replaced_timestamp',
'latitude',
'longitude',
]
except AttributeError:
pass
return rep
class OrganisationRelationshipSerializer(base.FlattenedModelSerializer):
source = OrganisationSerializer()
target = OrganisationSerializer()
class Meta:
model = models.OrganisationRelationship
fields = [
'id',
'source',
'target',
]
class OrganisationRelationshipAnswerSetSerializer(AnswerSetSerializer):
question_model = models.OrganisationRelationshipQuestion
relationship = OrganisationRelationshipSerializer()
class Meta:
model = models.OrganisationRelationshipAnswerSet
fields = [
'id',
'relationship',
'timestamp',
'replaced_timestamp',
]

View File

@@ -30,6 +30,15 @@
</td>
</tr>
<tr>
<td>Person Answer Sets</td>
<td></td>
<td>
<a class="btn btn-info"
href="{% url 'export:person-answer-set' %}">Export</a>
</td>
</tr>
<tr>
<td>Relationships</td>
<td></td>
@@ -48,6 +57,42 @@
</td>
</tr>
<tr>
<td>Organisation</td>
<td></td>
<td>
<a class="btn btn-info"
href="{% url 'export:organisation' %}">Export</a>
</td>
</tr>
<tr>
<td>Organisation Answer Sets</td>
<td></td>
<td>
<a class="btn btn-info"
href="{% url 'export:organisation-answer-set' %}">Export</a>
</td>
</tr>
<tr>
<td>Organisation Relationships</td>
<td></td>
<td>
<a class="btn btn-info"
href="{% url 'export:organisation-relationship' %}">Export</a>
</td>
</tr>
<tr>
<td>Organisation Relationship Answer Sets</td>
<td></td>
<td>
<a class="btn btn-info"
href="{% url 'export:organisation-relationship-answer-set' %}">Export</a>
</td>
</tr>
<tr>
<td>Activities</td>
<td></td>

View File

@@ -14,6 +14,10 @@ urlpatterns = [
views.people.PersonExportView.as_view(),
name='person'),
path('export/person-answer-sets',
views.people.PersonAnswerSetExportView.as_view(),
name='person-answer-set'),
path('export/relationships',
views.people.RelationshipExportView.as_view(),
name='relationship'),
@@ -22,6 +26,22 @@ urlpatterns = [
views.people.RelationshipAnswerSetExportView.as_view(),
name='relationship-answer-set'),
path('export/organisation',
views.people.OrganisationExportView.as_view(),
name='organisation'),
path('export/organisation-answer-sets',
views.people.OrganisationAnswerSetExportView.as_view(),
name='organisation-answer-set'),
path('export/organisation-relationships',
views.people.OrganisationRelationshipExportView.as_view(),
name='organisation-relationship'),
path('export/organisation-relationship-answer-sets',
views.people.OrganisationRelationshipAnswerSetExportView.as_view(),
name='organisation-relationship-answer-set'),
path('export/activities',
views.activities.ActivityExportView.as_view(),
name='activity'),

View File

@@ -1,13 +1,22 @@
import csv
import typing
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import HttpResponse
from django.views.generic import TemplateView
from django.views.generic.list import BaseListView
class CsvExportView(LoginRequiredMixin, BaseListView):
class QuotedCsv(csv.excel):
quoting = csv.QUOTE_NONNUMERIC
class UserIsStaffMixin(UserPassesTestMixin):
def test_func(self) -> typing.Optional[bool]:
return self.request.user.is_staff
class CsvExportView(UserIsStaffMixin, BaseListView):
model = None
serializer_class = None
@@ -18,12 +27,12 @@ class CsvExportView(LoginRequiredMixin, BaseListView):
# Force ordering by PK - though this should be default anyway
serializer = self.serializer_class(self.get_queryset().order_by('pk'), many=True)
writer = csv.DictWriter(response, fieldnames=serializer.child.column_headers)
writer = csv.DictWriter(response, dialect=QuotedCsv, fieldnames=serializer.child.column_headers)
writer.writeheader()
writer.writerows(serializer.data)
return response
class ExportListView(LoginRequiredMixin, TemplateView):
class ExportListView(UserIsStaffMixin, TemplateView):
template_name = 'export/export.html'

View File

@@ -9,6 +9,11 @@ class PersonExportView(base.CsvExportView):
serializer_class = serializers.people.PersonSerializer
class PersonAnswerSetExportView(base.CsvExportView):
model = models.person.PersonAnswerSet
serializer_class = serializers.people.PersonAnswerSetSerializer
class RelationshipExportView(base.CsvExportView):
model = models.relationship.Relationship
serializer_class = serializers.people.RelationshipSerializer
@@ -17,3 +22,23 @@ class RelationshipExportView(base.CsvExportView):
class RelationshipAnswerSetExportView(base.CsvExportView):
model = models.relationship.RelationshipAnswerSet
serializer_class = serializers.people.RelationshipAnswerSetSerializer
class OrganisationExportView(base.CsvExportView):
model = models.person.Organisation
serializer_class = serializers.people.OrganisationSerializer
class OrganisationAnswerSetExportView(base.CsvExportView):
model = models.organisation.OrganisationAnswerSet
serializer_class = serializers.people.OrganisationAnswerSetSerializer
class OrganisationRelationshipExportView(base.CsvExportView):
model = models.relationship.OrganisationRelationship
serializer_class = serializers.people.OrganisationRelationshipSerializer
class OrganisationRelationshipAnswerSetExportView(base.CsvExportView):
model = models.relationship.OrganisationRelationshipAnswerSet
serializer_class = serializers.people.OrganisationRelationshipAnswerSetSerializer

View File

@@ -16,14 +16,29 @@ class CustomUserAdmin(UserAdmin):
) # yapf: disable
class OrganisationQuestionChoiceInline(admin.TabularInline):
model = models.OrganisationQuestionChoice
@admin.register(models.OrganisationQuestion)
class OrganisationQuestionAdmin(admin.ModelAdmin):
inlines = [
OrganisationQuestionChoiceInline,
]
class OrganisationAnswerSetInline(admin.TabularInline):
model = models.OrganisationAnswerSet
readonly_fields = [
'question_answers',
]
@admin.register(models.Organisation)
class OrganisationAdmin(admin.ModelAdmin):
pass
@admin.register(models.Theme)
class ThemeAdmin(admin.ModelAdmin):
pass
inlines = [
OrganisationAnswerSetInline,
]
class PersonQuestionChoiceInline(admin.TabularInline):
@@ -64,4 +79,20 @@ class RelationshipQuestionAdmin(admin.ModelAdmin):
@admin.register(models.Relationship)
class RelationshipAdmin(admin.ModelAdmin):
pass
ordering = ['source__name', 'target__name']
class OrganisationRelationshipQuestionChoiceInline(admin.TabularInline):
model = models.OrganisationRelationshipQuestionChoice
@admin.register(models.OrganisationRelationshipQuestion)
class OrganisationRelationshipQuestionAdmin(admin.ModelAdmin):
inlines = [
OrganisationRelationshipQuestionChoiceInline,
]
@admin.register(models.OrganisationRelationship)
class OrganisationRelationshipAdmin(admin.ModelAdmin):
ordering = ['source__name', 'target__name']

View File

@@ -3,30 +3,21 @@
import typing
from django import forms
from django.forms.widgets import SelectDateWidget
from django.utils import timezone
from django.conf import settings
from bootstrap_datepicker_plus import DatePickerInput
from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget
from . import models
def get_date_year_range() -> typing.Iterable[int]:
"""
Get sensible year range for SelectDateWidgets in the past.
By default these widgets show 10 years in the future.
"""
num_years_display = 60
this_year = timezone.datetime.now().year
return range(this_year, this_year - num_years_display, -1)
class OrganisationForm(forms.ModelForm):
"""Form for creating / updating an instance of :class:`Organisation`."""
class Meta:
model = models.Organisation
fields = ['name', 'latitude', 'longitude']
fields = [
'name',
]
class PersonForm(forms.ModelForm):
@@ -46,16 +37,30 @@ class RelationshipForm(forms.Form):
class DynamicAnswerSetBase(forms.Form):
field_class = forms.ModelChoiceField
field_widget = None
field_required = True
question_model = None
field_widget: typing.Optional[typing.Type[forms.Widget]] = None
question_model: typing.Type[models.Question]
answer_model: typing.Type[models.QuestionChoice]
question_prefix: str = ''
as_filters: bool = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
initial = kwargs.get('initial', {})
field_order = []
for question in self.question_model.objects.all():
if self.as_filters and not question.answer_is_public:
continue
# Is a placeholder question just for sorting hardcoded questions?
if (
question.is_hardcoded
and (self.as_filters or (question.hardcoded_field in self.Meta.fields))
):
field_order.append(question.hardcoded_field)
continue
field_class = self.field_class
field_widget = self.field_widget
@@ -63,14 +68,91 @@ class DynamicAnswerSetBase(forms.Form):
field_class = forms.ModelMultipleChoiceField
field_widget = Select2MultipleWidget
field_name = f'question_{question.pk}'
field_name = f'{self.question_prefix}question_{question.pk}'
field = field_class(label=question,
queryset=question.answers,
widget=field_widget,
required=self.field_required,
initial=initial.get(field_name, None))
# If being used as a filter - do we have alternate text?
field_label = question.text
if self.as_filters and question.filter_text:
field_label = question.filter_text
field = field_class(
label=field_label,
queryset=question.answers,
widget=field_widget,
required=(self.field_required
and not question.allow_free_text),
initial=self.initial.get(field_name, None),
help_text=question.help_text if not self.as_filters else '')
self.fields[field_name] = field
field_order.append(field_name)
if question.allow_free_text and not self.as_filters:
free_field = forms.CharField(label=f'{question} free text',
required=False)
self.fields[f'{field_name}_free'] = free_field
field_order.append(f'{field_name}_free')
self.order_fields(field_order)
class OrganisationAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
"""Form for variable organisation attributes.
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
"""
class Meta:
model = models.OrganisationAnswerSet
fields = [
'name',
'website',
'countries',
'hq_country',
'is_partner_organisation',
'latitude',
'longitude',
]
labels = {
'is_partner_organisation':
f'Is this organisation a {settings.PARENT_PROJECT_NAME} partner organisation?'
}
widgets = {
'countries': Select2MultipleWidget(),
'hq_country': Select2Widget(),
'latitude': forms.HiddenInput,
'longitude': forms.HiddenInput,
}
question_model = models.OrganisationQuestion
answer_model = models.OrganisationQuestionChoice
def save(self, commit=True) -> models.OrganisationAnswerSet:
# Save model
self.instance = super().save(commit=False)
self.instance.organisation_id = self.initial['organisation_id']
if commit:
self.instance.save()
# Need to call same_m2m manually since we use commit=False above
self.save_m2m()
if commit:
# Save answers to questions
for key, value in self.cleaned_data.items():
if key.startswith('question_') and value:
if key.endswith('_free'):
# Create new answer from free text
value, _ = self.answer_model.objects.get_or_create(
text=value,
question=self.question_model.objects.get(
pk=key.split('_')[1]))
try:
self.instance.question_answers.add(value)
except TypeError:
# Value is a QuerySet - multiple choice question
self.instance.question_answers.add(*value.all())
return self.instance
class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
@@ -85,37 +167,57 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
'country_of_residence',
'organisation',
'organisation_started_date',
'project_started_date',
'job_title',
'disciplines',
'themes',
'disciplinary_background',
'external_organisations',
'latitude',
'longitude',
]
widgets = {
'nationality': Select2Widget(),
'nationality': Select2MultipleWidget(),
'country_of_residence': Select2Widget(),
'themes': Select2MultipleWidget(),
'organisation_started_date': DatePickerInput(format='%Y-%m-%d'),
'project_started_date': DatePickerInput(format='%Y-%m-%d'),
'latitude': forms.HiddenInput,
'longitude': forms.HiddenInput,
}
labels = {
'project_started_date':
f'Date started on the {settings.PARENT_PROJECT_NAME} project',
'external_organisations':
'Please list the main organisations external to BRECcIA work that you have been working with since 1st January 2019 that are involved in food/water security in African dryland regions'
}
help_texts = {
'organisation_started_date':
'If you don\'t know the exact date, an approximate date is okay.',
'project_started_date':
'If you don\'t know the exact date, an approximate date is okay.',
}
question_model = models.PersonQuestion
answer_model = models.PersonQuestionChoice
def save(self, commit=True) -> models.PersonAnswerSet:
# Save Relationship model
# Save model
self.instance = super().save(commit=False)
self.instance.person_id = self.initial['person_id']
if commit:
self.instance.save()
# Need to call same_m2m manually since we use commit=False above
self.save_m2m()
if commit:
# Save answers to relationship questions
# Save answers to questions
for key, value in self.cleaned_data.items():
if key.startswith('question_') and value:
if key.endswith('_free'):
# Create new answer from free text
value, _ = self.answer_model.objects.get_or_create(
text=value,
question=self.question_model.objects.get(
pk=key.split('_')[1]))
try:
self.instance.question_answers.add(value)
@@ -137,17 +239,28 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
fields = [
'relationship',
]
widgets = {
'relationship': forms.HiddenInput,
}
question_model = models.RelationshipQuestion
answer_model = models.RelationshipQuestionChoice
def save(self, commit=True) -> models.RelationshipAnswerSet:
# Save Relationship model
# Save model
self.instance = super().save(commit=commit)
if commit:
# Save answers to relationship questions
# Save answers to questions
for key, value in self.cleaned_data.items():
if key.startswith('question_') and value:
if key.endswith('_free'):
# Create new answer from free text
value, _ = self.answer_model.objects.get_or_create(
text=value,
question=self.question_model.objects.get(
pk=key.split('_')[1]))
try:
self.instance.question_answers.add(value)
@@ -158,20 +271,81 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
return self.instance
class NetworkFilterForm(DynamicAnswerSetBase):
"""
Form to provide filtering on the network view.
class OrganisationRelationshipAnswerSetForm(forms.ModelForm,
DynamicAnswerSetBase):
"""Form to allow users to describe a relationship with an organisation.
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
"""
class Meta:
model = models.OrganisationRelationshipAnswerSet
fields = [
'relationship',
]
widgets = {
'relationship': forms.HiddenInput,
}
question_model = models.OrganisationRelationshipQuestion
answer_model = models.OrganisationRelationshipQuestionChoice
def save(self, commit=True) -> models.OrganisationRelationshipAnswerSet:
# Save model
self.instance = super().save(commit=commit)
if commit:
# Save answers to questions
for key, value in self.cleaned_data.items():
if key.startswith('question_') and value:
if key.endswith('_free'):
# Create new answer from free text
value, _ = self.answer_model.objects.get_or_create(
text=value,
question=self.question_model.objects.get(
pk=key.split('_')[1]))
try:
self.instance.question_answers.add(value)
except TypeError:
# Value is a QuerySet - multiple choice question
self.instance.question_answers.add(*value.all())
return self.instance
class DateForm(forms.Form):
date = forms.DateField(
required=False,
widget=DatePickerInput(format='%Y-%m-%d'),
help_text='Show relationships as they were on this date'
)
class FilterForm(DynamicAnswerSetBase):
"""Filter objects by answerset responses."""
field_class = forms.ModelMultipleChoiceField
field_widget = Select2MultipleWidget
field_required = False
as_filters = True
class NetworkRelationshipFilterForm(FilterForm):
"""Filer relationships by answerset responses."""
question_model = models.RelationshipQuestion
answer_model = models.RelationshipQuestionChoice
question_prefix = 'relationship_'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add date field to select relationships at a particular point in time
self.fields['date'] = forms.DateField(
required=False,
widget=SelectDateWidget(years=get_date_year_range()),
help_text='Show relationships as they were on this date')
class NetworkPersonFilterForm(FilterForm):
"""Filer people by answerset responses."""
question_model = models.PersonQuestion
answer_model = models.PersonQuestionChoice
question_prefix = 'person_'
class NetworkOrganisationFilterForm(FilterForm):
"""Filer organisations by answerset responses."""
question_model = models.OrganisationQuestion
answer_model = models.OrganisationQuestionChoice
question_prefix = 'organisation_'

View 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),
),
]

View 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),
),
]

View 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?'),
),
]

View 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'},
),
]

View 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',
),
]

View 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'),
),
]

View 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',
),
]

View 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),
),
]

View 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?'),
),
]

View 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'),
),
]

View File

@@ -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'),
),
]

View 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),
),
]

View 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),
),
]

View File

@@ -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),
),
]

View 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',
),
]

View 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),
),
]

View File

@@ -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),
),
]

View File

@@ -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',
),
]

View 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),
]

View 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']},
),
]

View 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',
),
]

View 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',
),
]

View 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,
),
]

View 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']},
),
]

View File

@@ -1,21 +1,20 @@
import typing
from django.core.exceptions import ObjectDoesNotExist
def port_question(apps, question_text: str,
answers_text: typing.Iterable[str]):
def port_question(apps, question_text: str, answers_text: typing.Iterable[str],
**kwargs):
PersonQuestion = apps.get_model('people', 'PersonQuestion')
try:
prev_question = PersonQuestion.objects.filter(
text=question_text).latest('version')
question = PersonQuestion.objects.create(
text=question_text, version=prev_question.version + 1)
text=question_text, version=prev_question.version + 1, **kwargs)
except ObjectDoesNotExist:
question = PersonQuestion.objects.create(text=question_text)
question = PersonQuestion.objects.create(text=question_text, **kwargs)
for answer_text in answers_text:
question.answers.get_or_create(text=answer_text)

View File

@@ -1,2 +1,4 @@
from .person import *
from .relationship import *
from .organisation import * # noqa
from .person import * # noqa
from .question import * # noqa
from .relationship import * # noqa

View 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()

View File

@@ -11,14 +11,13 @@ from django_countries.fields import CountryField
from django_settings_export import settings_export
from post_office import mail
from .organisation import Organisation
from .question import AnswerSet, Question, QuestionChoice
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
__all__ = [
'User',
'Organisation',
'Theme',
'PersonQuestion',
'PersonQuestionChoice',
'Person',
@@ -32,6 +31,9 @@ class User(AbstractUser):
"""
email = models.EmailField(_('email address'), blank=False, null=False)
#: Have they given consent to collect and store their data?
consent_given = models.BooleanField(default=False)
def has_person(self) -> bool:
"""
Does this user have a linked :class:`Person` record?
@@ -63,33 +65,6 @@ class User(AbstractUser):
self.username)
class Organisation(models.Model):
"""Organisation to which a :class:`Person` belongs."""
name = models.CharField(max_length=255, blank=False, null=False)
#: Latitude for displaying location on a map
latitude = models.FloatField(blank=True, null=True)
#: Longitude for displaying location on a map
longitude = models.FloatField(blank=True, null=True)
def __str__(self) -> str:
return self.name
def get_absolute_url(self):
return reverse('people:organisation.detail', kwargs={'pk': self.pk})
class Theme(models.Model):
"""
Project theme within which a :class:`Person` works.
"""
name = models.CharField(max_length=255, blank=False, null=False)
def __str__(self) -> str:
return self.name
class PersonQuestion(Question):
"""Question which may be asked about a person."""
@@ -110,6 +85,9 @@ class Person(models.Model):
"""
class Meta:
verbose_name_plural = 'people'
ordering = [
'name',
]
#: User account belonging to this person
user = models.OneToOneField(settings.AUTH_USER_MODEL,
@@ -129,6 +107,13 @@ class Person(models.Model):
through_fields=('source', 'target'),
symmetrical=False)
#: Organisations with whom this person has relationship - via intermediate :class:`OrganisationRelationship` model
organisation_relationship_targets = models.ManyToManyField(
Organisation,
related_name='relationship_sources',
through='OrganisationRelationship',
through_fields=('source', 'target'))
@property
def relationships(self):
return self.relationships_as_source.all().union(
@@ -138,6 +123,14 @@ class Person(models.Model):
def current_answers(self) -> 'PersonAnswerSet':
return self.answer_sets.last()
@property
def organisation(self) -> Organisation:
return self.current_answers.organisation
@property
def country_of_residence(self):
return self.current_answers.country_of_residence
def get_absolute_url(self):
return reverse('people:person.detail', kwargs={'pk': self.pk})
@@ -147,6 +140,8 @@ class Person(models.Model):
class PersonAnswerSet(AnswerSet):
"""The answers to the person questions at a particular point in time."""
question_model = PersonQuestion
#: Person to which this answer set belongs
person = models.ForeignKey(Person,
on_delete=models.CASCADE,
@@ -160,7 +155,7 @@ class PersonAnswerSet(AnswerSet):
##################
# Static questions
nationality = CountryField(blank=True, null=True)
nationality = CountryField(multiple=True, blank=True)
country_of_residence = CountryField(blank=True, null=True)
@@ -175,14 +170,25 @@ class PersonAnswerSet(AnswerSet):
organisation_started_date = models.DateField(
'Date started at this organisation', blank=False, null=True)
#: When did this person join the project?
project_started_date = models.DateField(blank=False, null=True)
#: Job title this person holds within their organisation
job_title = models.CharField(max_length=255, blank=True, null=False)
job_title = models.CharField(help_text='Contractual job title',
max_length=255,
blank=True,
null=False)
#: Discipline(s) within which this person works
disciplines = models.CharField(max_length=255, blank=True, null=True)
disciplinary_background = models.CharField(
help_text='Research discipline(s) you feel most affiliated with',
max_length=255,
blank=True,
null=False)
#: Project themes within this person works
themes = models.ManyToManyField(Theme, related_name='people', blank=True)
#: Organisations worked with which aren't in the Organisations list
external_organisations = models.CharField(max_length=1023,
blank=True,
null=False)
#: Latitude for displaying location on a map
latitude = models.FloatField(blank=True, null=True)
@@ -190,15 +196,22 @@ class PersonAnswerSet(AnswerSet):
#: Longitude for displaying location on a map
longitude = models.FloatField(blank=True, null=True)
@property
def location_set(self) -> bool:
return self.latitude and self.longitude
def public_answers(self) -> models.QuerySet:
"""Get answers to questions which are public."""
return self.question_answers.filter(question__answer_is_public=True)
def as_dict(self):
"""Get the answers from this set as a dictionary for use in Form.initial."""
exclude_fields = {
'id',
'timestemp',
'timestamp',
'replaced_timestamp',
'person_id',
'question_answers',
'themes',
}
def field_value_repr(field):
@@ -215,25 +228,14 @@ class PersonAnswerSet(AnswerSet):
answers = {
# Foreign key fields have _id at end in model _meta but don't in forms
field.attname.rstrip('_id'): field_value_repr(field)
# str.rstrip strips a set of characters, not a suffix, so doesn't work here
field.attname.rsplit('_id')[0]: field_value_repr(field)
for field in self._meta.get_fields()
if field.attname not in exclude_fields
}
for answer in self.question_answers.all():
question = answer.question
field_name = f'question_{question.pk}'
if question.is_multiple_choice:
if field_name not in answers:
answers[field_name] = []
answers[field_name].append(answer.pk)
else:
answers[field_name] = answer.pk
return answers
# Add answers to dynamic questions
return super().as_dict(answers=answers)
def get_absolute_url(self):
return self.person.get_absolute_url()

View File

@@ -1,9 +1,15 @@
"""Base models for configurable questions and response sets."""
import abc
import typing
from django.db import models
from django.utils.text import slugify
__all__ = [
'Question',
'QuestionChoice',
]
class Question(models.Model):
"""Questions from which a survey form can be created."""
@@ -19,14 +25,51 @@ class Question(models.Model):
blank=False,
null=False)
#: Text of question
#: Text of question - 1st person
text = models.CharField(max_length=255, blank=False, null=False)
#: Alternative text to be displayed in network filters - 3rd person
filter_text = models.CharField(
max_length=255,
blank=True,
null=False,
help_text='Alternative text to be displayed in network filters - 3rd person')
help_text = models.CharField(
help_text='Additional hint text to be displayed with the question',
max_length=255,
blank=True,
null=False
)
#: Should answers to this question be considered public?
answer_is_public = models.BooleanField(
help_text='Should answers to this question be considered public?',
default=True,
blank=False,
null=False)
#: Should people be able to select multiple responses to this question?
is_multiple_choice = models.BooleanField(default=False,
blank=False,
null=False)
@property
def is_hardcoded(self) -> bool:
return bool(self.hardcoded_field)
hardcoded_field = models.CharField(
help_text='Which hardcoded field does this question represent?',
max_length=255,
blank=True,
null=False
)
#: Should people be able to add their own answers?
allow_free_text = models.BooleanField(default=False,
blank=False,
null=False)
#: Position of this question in the list
order = models.SmallIntegerField(default=0, blank=False, null=False)
@@ -86,6 +129,13 @@ class AnswerSet(models.Model):
ordering = [
'timestamp',
]
get_latest_by = 'timestamp'
@classmethod
@abc.abstractproperty
def question_model(cls) -> models.Model:
"""Model representing questions to be answered in this AnswerSet."""
raise NotImplementedError
#: Entity to which this answer set belongs
#: This foreign key must be added to each concrete subclass
@@ -95,9 +145,14 @@ class AnswerSet(models.Model):
# blank=False,
# null=False)
#: Answers to :class:`Question`s
#: This many to many relation must be added to each concrete subclass
# question_answers = models.ManyToManyField(QuestionChoice)
@abc.abstractproperty
def question_answers(self) -> models.QuerySet:
"""Answers to :class:`Question`s.
This many to many relation must be added to each concrete subclass
question_answers = models.ManyToManyField(<X>QuestionChoice)
"""
raise NotImplementedError
#: When were these answers collected?
timestamp = models.DateTimeField(auto_now_add=True, editable=False)
@@ -106,3 +161,62 @@ class AnswerSet(models.Model):
replaced_timestamp = models.DateTimeField(blank=True,
null=True,
editable=False)
@property
def is_current(self) -> bool:
return self.replaced_timestamp is None
def build_question_answers(self,
show_all: bool = False,
use_slugs: bool = False) -> typing.Dict[str, str]:
"""Collect answers to dynamic questions and join with commas."""
questions = self.question_model.objects.all()
if not show_all:
questions = questions.filter(answer_is_public=True)
question_answers = {}
try:
answerset_answers = list(self.question_answers.order_by().values('text', 'question_id'))
for question in questions:
key = question.slug if use_slugs else question.text
if question.hardcoded_field:
answer = getattr(self, question.hardcoded_field)
if isinstance(answer, list):
answer = ', '.join(map(str, answer))
else:
answer = ', '.join(
answer['text'] for answer in answerset_answers
if answer['question_id'] == question.id
)
question_answers[key] = answer
except AttributeError:
# No AnswerSet yet
pass
return question_answers
def as_dict(self, answers: typing.Optional[typing.Dict[str, typing.Any]] = None):
"""Get the answers from this set as a dictionary for use in Form.initial."""
if answers is None:
answers = {}
for answer in self.question_answers.all():
question = answer.question
field_name = f'question_{question.pk}'
if question.is_multiple_choice:
if field_name not in answers:
answers[field_name] = []
answers[field_name].append(answer.pk)
else:
answers[field_name] = answer.pk
return answers

View File

@@ -1,12 +1,11 @@
"""
Models describing relationships between people.
"""
"""Models describing relationships between people."""
import typing
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.urls import reverse
from .person import Person
from .person import Organisation, Person
from .question import AnswerSet, Question, QuestionChoice
__all__ = [
@@ -14,6 +13,10 @@ __all__ = [
'RelationshipQuestionChoice',
'RelationshipAnswerSet',
'Relationship',
'OrganisationRelationshipQuestion',
'OrganisationRelationshipQuestionChoice',
'OrganisationRelationshipAnswerSet',
'OrganisationRelationship',
]
@@ -32,24 +35,8 @@ class RelationshipQuestionChoice(QuestionChoice):
null=False)
# class ExternalPerson(models.Model):
# """Model representing a person external to the project.
# These will never need to be linked to a :class:`User` as they
# will never log in to the system.
# """
# name = models.CharField(max_length=255,
# blank=False, null=False)
# def __str__(self) -> str:
# return self.name
class Relationship(models.Model):
"""
A directional relationship between two people allowing linked questions.
"""
"""A directional relationship between two people allowing linked questions."""
class Meta:
constraints = [
models.UniqueConstraint(fields=['source', 'target'],
@@ -57,9 +44,11 @@ class Relationship(models.Model):
]
#: Person reporting the relationship
source = models.ForeignKey(Person, related_name='relationships_as_source',
source = models.ForeignKey(Person,
related_name='relationships_as_source',
on_delete=models.CASCADE,
blank=False, null=False)
blank=False,
null=False)
#: Person with whom the relationship is reported
target = models.ForeignKey(Person,
@@ -67,25 +56,23 @@ class Relationship(models.Model):
on_delete=models.CASCADE,
blank=False,
null=False)
# blank=True,
# null=True)
# target_external_person = models.ForeignKey(
# ExternalPerson,
# related_name='relationships_as_target',
# on_delete=models.CASCADE,
# blank=True,
# null=True)
#: When was this relationship defined?
created = models.DateTimeField(auto_now_add=True)
#: When was this marked as expired? Default None means it has not expired
expired = models.DateTimeField(blank=True, null=True)
@property
def current_answers(self) -> 'RelationshipAnswerSet':
return self.answer_sets.last()
def current_answers(self) -> typing.Optional['RelationshipAnswerSet']:
try:
answer_set = self.answer_sets.latest()
if answer_set.is_current:
return answer_set
except RelationshipAnswerSet.DoesNotExist:
# No AnswerSet created yet
pass
return None
@property
def is_current(self) -> bool:
return self.current_answers is not None
def get_absolute_url(self):
return reverse('people:relationship.detail', kwargs={'pk': self.pk})
@@ -100,13 +87,14 @@ class Relationship(models.Model):
@raise Relationship.DoesNotExist: When the reverse relationship is not known
"""
return type(self).objects.get(source=self.target,
target=self.source)
return type(self).objects.get(source=self.target, target=self.source)
class RelationshipAnswerSet(AnswerSet):
"""The answers to the relationship questions at a particular point in time."""
question_model = RelationshipQuestion
#: Relationship to which this answer set belongs
relationship = models.ForeignKey(Relationship,
on_delete=models.CASCADE,
@@ -119,3 +107,87 @@ class RelationshipAnswerSet(AnswerSet):
def get_absolute_url(self):
return self.relationship.get_absolute_url()
class OrganisationRelationshipQuestion(Question):
"""Question which may be asked about an :class:`OrganisationRelationship`."""
class OrganisationRelationshipQuestionChoice(QuestionChoice):
"""Allowed answer to a :class:`OrganisationRelationshipQuestion`."""
#: Question to which this answer belongs
question = models.ForeignKey(OrganisationRelationshipQuestion,
related_name='answers',
on_delete=models.CASCADE,
blank=False,
null=False)
class OrganisationRelationship(models.Model):
"""A directional relationship between a person and an organisation with linked questions."""
class Meta:
constraints = [
models.UniqueConstraint(fields=['source', 'target'],
name='unique_relationship'),
]
#: Person reporting the relationship
source = models.ForeignKey(
Person,
related_name='organisation_relationships_as_source',
on_delete=models.CASCADE,
blank=False,
null=False)
#: Organisation with which the relationship is reported
target = models.ForeignKey(
Organisation,
related_name='organisation_relationships_as_target',
on_delete=models.CASCADE,
blank=False,
null=False)
@property
def current_answers(self) -> typing.Optional['OrganisationRelationshipAnswerSet']:
try:
answer_set = self.answer_sets.latest()
if answer_set.is_current:
return answer_set
except OrganisationRelationshipAnswerSet.DoesNotExist:
# No AnswerSet created yet
pass
return None
@property
def is_current(self) -> bool:
return self.current_answers is not None
def get_absolute_url(self):
return reverse('people:organisation.relationship.detail',
kwargs={'pk': self.pk})
def __str__(self) -> str:
return f'{self.source} -> {self.target}'
class OrganisationRelationshipAnswerSet(AnswerSet):
"""The answers to the organisation relationship questions at a particular point in time."""
question_model = OrganisationRelationshipQuestion
#: OrganisationRelationship to which this answer set belongs
relationship = models.ForeignKey(OrganisationRelationship,
on_delete=models.CASCADE,
related_name='answer_sets',
blank=False,
null=False)
#: Answers to :class:`OrganisationRelationshipQuestion`s
question_answers = models.ManyToManyField(
OrganisationRelationshipQuestionChoice)
def get_absolute_url(self):
return self.relationship.get_absolute_url()

View File

@@ -16,6 +16,15 @@ class PersonSerializer(serializers.ModelSerializer):
]
class OrganisationSerializer(serializers.ModelSerializer):
class Meta:
model = models.Organisation
fields = [
'pk',
'name',
]
class RelationshipSerializer(serializers.ModelSerializer):
source = PersonSerializer()
target = PersonSerializer()
@@ -27,3 +36,18 @@ class RelationshipSerializer(serializers.ModelSerializer):
'source',
'target',
]
class OrganisationRelationshipSerializer(serializers.ModelSerializer):
source = PersonSerializer()
target = OrganisationSerializer()
kind = serializers.ReadOnlyField(default='organisation-relationship')
class Meta:
model = models.OrganisationRelationship
fields = [
'pk',
'source',
'target',
'kind',
]

View 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);
});
}
})
});

View File

@@ -1,5 +1,4 @@
let marker = null;
let search_markers = []
/**
@@ -7,9 +6,9 @@ let search_markers = []
* @param {Event} event - Click event from a Google Map.
*/
function selectLocation(event) {
if (marker === null) {
if (selected_marker === null) {
// Generate a new marker
marker = new google.maps.Marker({
selected_marker = new google.maps.Marker({
position: event.latLng,
map: map,
icon: {
@@ -17,17 +16,17 @@ function selectLocation(event) {
strokeColor: marker_edge_colour,
strokeWeight: marker_edge_width,
strokeOpacity: marker_edge_alpha,
fillColor: marker_fill_colour,
fillColor: '#0099cc',
fillOpacity: marker_fill_alpha,
scale: marker_scale,
labelOrigin: new google.maps.Point(0, -marker_label_offset)
},
});
} else {
marker.setPosition(event.latLng);
selected_marker.setPosition(event.latLng);
}
const pos = marker.getPosition();
const pos = selected_marker.getPosition();
document.getElementById('id_latitude').value = pos.lat();
document.getElementById('id_longitude').value = pos.lng();
}

View File

@@ -1,9 +1,8 @@
const marker_fill_alpha = 1.0;
const marker_edge_colour = 'white';
const marker_fill_colour = 'gray';
// Size of the arrow markers used on the map
const marker_scale = 9;
const marker_scale = 7;
// Offset for the place type icon (multiplier for marker scale)
const marker_label_offset = 0.27 * marker_scale;
// Width and transparency for the edges of the markers
@@ -11,7 +10,9 @@ const marker_edge_alpha = 1.0;
const marker_edge_width = 1.0;
let map = null;
let selected_marker = null;
let selected_marker_info = null;
let markers = [];
function createMarker(map, marker_data) {
// Get the lat-long position from the data
@@ -34,13 +35,15 @@ function createMarker(map, marker_data) {
strokeColor: marker_edge_colour,
strokeWeight: marker_edge_width,
strokeOpacity: marker_edge_alpha,
fillColor: marker_fill_colour,
fillColor: marker_data.type === 'Organisation' ? '#669933' : '#0099cc',
fillOpacity: marker_fill_alpha,
scale: marker_scale,
labelOrigin: new google.maps.Point(0, -marker_label_offset)
},
});
marker.type = marker_data.type;
marker.info = new google.maps.InfoWindow({
content: "<div id='content'>" +
"<h3><a href=" + marker_data.url + ">" + marker_data.name.replace('&apos;', "'") + "</a></h3>" +
@@ -70,22 +73,32 @@ function initMap() {
const markers_data = JSON.parse(
document.getElementById('map-markers').textContent)
let markers_loaded = false
// For each data entry in the json...
for (const marker_data of markers_data) {
try {
const marker = createMarker(map, marker_data);
markers.push(marker);
bounds.extend(marker.position);
if (markers_data.length === 1) {
selected_marker = marker;
}
markers_loaded = true
} catch (exc) {
// Just skip and move on to next
}
}
map.fitBounds(bounds)
const max_zoom = 10
if (map.getZoom() > max_zoom) {
map.setZoom(max_zoom)
if (!markers_loaded) {
map.panTo({lat: 0, lng: 0})
}
setMaxZoom()
setTimeout(setMaxZoom, 100)
@@ -96,8 +109,8 @@ function initMap() {
* Zoom to set level if map is zoomed in more than this.
*/
function setMaxZoom() {
const max_zoom = 10
if (map.getZoom() > max_zoom) {
map.setZoom(max_zoom)
}
const max_zoom = 4
const min_zoom = 2
const zoom = Math.min(Math.max(min_zoom, map.getZoom()), max_zoom)
map.setZoom(zoom)
}

204
people/static/js/network.js Normal file
View 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());

View 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>

View 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 %}

View File

@@ -1,5 +1,16 @@
{% extends 'base.html' %}
{% block extra_head %}
{# There's no 'form' so need to add this to load CSS / JS #}
{{ date_form.media.css }}
{{ relationship_form.media.css }}
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/cytoscape-panzoom/2.5.3/cytoscape.js-panzoom.min.css"
integrity="sha512-MJrzp+ZGajx6AWCCCmjBWo0rPFavM1aBghVUSVVa0uYv8THryrtEygjj5r2rUg/ms33SkEC5xJ3E4ycCmxWdrw=="
crossorigin="anonymous" />
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
@@ -7,126 +18,85 @@
</ol>
</nav>
<h1>Network View</h1>
<hr>
<form class="form"
method="POST">
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<h3>Filter Relationships</h3>
<div class="row">
<div class="col-md-4">
<form class="form" method="POST">
{% csrf_token %}
{% load bootstrap4 %}
{% bootstrap_form form exclude='date' %}
{% buttons %}
<input class="btn btn-block btn-danger mb-3" type="button" value="Reset Filters" onClick="reset_filters();" />
<button class="btn btn-block btn-success mb-3" type="submit">Filter</button>
{% endbuttons %}
{% bootstrap_form date_form %}
<hr>
{% bootstrap_field form.date %}
</div>
<h3>Filter Relationships</h3>
{% bootstrap_form relationship_form %}
<hr>
<div class="col-md-6">
<h3>Filter People</h3>
</div>
{% bootstrap_form person_form %}
<hr>
<h3>Filter Organisations</h3>
{% bootstrap_form organisation_form %}
</form>
</div>
{% buttons %}
<button class="btn btn-block btn-info" type="submit">Filter</button>
{% endbuttons %}
</form>
<div id="cy"
class="mb-2"
style="width: 100%; min-height: 1000px; flex-grow: 1; border: 2px solid black"></div>
<div class="col-md-8" style="display: flex; flex-direction: column;">
<div class="row">
<div class="col-md-6">
<button class="btn btn-block btn-info mb-3" onclick="save_image();">Save Image</button>
<button class="btn btn-block btn-info mb-3" onclick="toggle_anonymise_people();">Anonymise People</button>
</div>
<div class="col-md-6">
<button class="btn btn-block btn-info mb-3" onclick="toggle_organisations();">Hide Organisations</button>
<button class="btn btn-block btn-info mb-3" onclick="toggle_anonymise_organisations();">Anonymise Organisations</button>
</div>
</div>
<div id="cy" class="mb-2"
style="width: 100%; min-height: 1000px; border: 2px solid black; z-index: 999"></div>
</div>
</div>
{% endblock %}
{% block extra_script %}
{{ date_form.media.js }}
{{ relationship_form.media.js }}
<!--
Embedding graph data in page as JSON allows filtering to be performed entirely on the backend when we send a POST.
This is useful since one of the most popular browsers in several of the target countries is Opera Mini,
which renders JavaScript on a proxy server to avoid running it on the frontend.
-->
{{ person_set|json_script:'person-set-data' }}
{{ organisation_set|json_script:'organisation-set-data' }}
{{ relationship_set|json_script:'relationship-set-data' }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.14.0/cytoscape.min.js"
integrity="sha256-rI7zH7xDqO306nxvXUw9gqkeBpvvmddDdlXJjJM7rEM="
crossorigin="anonymous"></script>
{{ organisation_relationship_set|json_script:'organisation-relationship-set-data' }}
<script type="application/javascript">
/**
* Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page.
*/
function get_network() {
// Initialise Cytoscape graph
// See https://js.cytoscape.org/ for documentation
var cy = cytoscape({
container: document.getElementById('cy'),
style: [
{
selector: 'node[name]',
style: {
label: 'data(name)',
'text-halign': 'center',
'text-valign': 'center',
'font-size': 8
}
},
{
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();
function reset_filters() {
$('select').val(null).trigger('change');
}
$( window ).on('load', get_network());
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.18.2/cytoscape.min.js"
integrity="sha512-CBGCXtszkG5rYlQSTNUzk54/731Kz28WPk2uT1GCPCqgfVRJ2v514vzzf16HuGX9WVtE7JLqRuAERNAzFZ9Hpw=="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape-panzoom/2.5.3/cytoscape-panzoom.min.js"
integrity="sha512-coQmIYa/SKS8wyZw14FTLJhHmp5jqIO2WxyGhjAnLGdym6RsLX412wLO1hqnFifU0NacrJvlUukRJEwjRkm0Xg=="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"
integrity="sha512-Qlv6VSKh1gDKGoJbnyA5RMXYcvnpIqhO++MhIM2fStMcGT9i2T//tSwYFlcyoRRDcDZ+TYHpH8azBBCyhpSeqw=="
crossorigin="anonymous"></script>
{% load staticfiles %}
<script src="{% static 'js/network.js' %}"></script>
{% endblock %}

View File

@@ -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 %}

View File

@@ -20,16 +20,48 @@
</ol>
</nav>
<h1>{{ organisation.name }}</h1>
<h1>{{ organisation }}</h1>
<hr>
<a class="btn btn-success"
href="{% url 'people:organisation.update' pk=organisation.pk %}">Update</a>
<div class="row justify-content-md-center">
<div class="col-md-3">
<a class="btn btn-warning btn-block"
href="{% url 'people:organisation.update' pk=organisation.pk %}">Update Organisation</a>
</div>
<div class="col-md-3">
{% if relationship %}
<a class="btn btn-warning btn-block"
href="{% url 'people:organisation.relationship.create' organisation_pk=organisation.pk %}">Update Relationship
</a>
{% else %}
<a class="btn btn-success btn-block"
href="{% url 'people:organisation.relationship.create' organisation_pk=organisation.pk %}">Add Relationship
</a>
{% endif %}
</div>
{% if relationship %}
<div class="col-md-3">
<a class="btn btn-danger btn-block"
href="{% url 'people:organisation.relationship.end' pk=relationship.pk %}">End Relationship
</a>
</div>
{% endif %}
</div>
<hr>
<div id="map" style="height: 800px; width: 100%"></div>
{% include 'people/includes/answer_set.html' %}
<hr>
{% if organisation.current_answers.location_set %}
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
{% endif %}
<hr>

View File

@@ -7,34 +7,48 @@
</ol>
</nav>
<h1>People</h1>
<h1>Organisations</h1>
<hr>
<a class="btn btn-success"
href="{% url 'people:organisation.create' %}">New Organisation</a>
{% with config.ORGANISATION_LIST_HELP as help_text %}
{% if help_text %}
<div class="alert alert-info mt-3 pb-0">
{{ help_text|linebreaks }}
</div>
{% endif %}
{% endwith %}
<table class="table table-borderless">
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{% for organisation in organisation_list.all %}
<tr>
<td>{{ organisation }}</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:organisation.detail' pk=organisation.pk %}">Details</a>
</td>
</tr>
{% for country, organisations in orgs_by_country.items %}
<tr><th>{{ country }}</th></tr>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% for organisation in organisations %}
<tr>
<td>{{ organisation }}</td>
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:organisation.detail' pk=organisation.pk %}">Profile</a>
{% if organisation.pk in existing_relationships %}
<a class="btn btn-sm btn-warning"
style="width: 10rem"
href="{% url 'people:organisation.relationship.create' organisation_pk=organisation.pk %}">Update Relationship
</a>
{% else %}
<a class="btn btn-sm btn-success"
style="width: 10rem"
href="{% url 'people:organisation.relationship.create' organisation_pk=organisation.pk %}">Add Relationship
</a>
{% endif %}
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>

View File

@@ -24,7 +24,7 @@
</ol>
</nav>
<h1>{{ organisation.name }}</h1>
<h1>{{ organisation }}</h1>
<hr>
@@ -51,3 +51,7 @@
<hr>
{% endblock %}
{% block extra_script %}
<script async defer src="{% static 'js/hide_free_text.js' %}"></script>
{% endblock %}

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -14,6 +14,14 @@
<a class="btn btn-success"
href="{% url 'people:person.create' %}">New Person</a>
{% with config.PERSON_LIST_HELP as help_text %}
{% if help_text %}
<div class="alert alert-info mt-3 pb-0">
{{ help_text|linebreaks }}
</div>
{% endif %}
{% endwith %}
<table class="table table-borderless">
<thead>
<tr>
@@ -28,6 +36,21 @@
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:person.detail' pk=person.pk %}">Profile</a>
{% if person.user != request.user %}
{% if person.pk in existing_relationships %}
<a class="btn btn-sm btn-warning"
style="width: 10rem"
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Update Relationship
</a>
{% else %}
<a class="btn btn-sm btn-success"
style="width: 10rem"
href="{% url 'people:person.relationship.create' person_pk=person.pk %}">Add Relationship
</a>
{% endif %}
{% endif %}
</td>
</tr>

View File

@@ -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 %}

View File

@@ -48,6 +48,8 @@
<input id="location-search" class="controls" type="text" placeholder="Location Search"/>
<div id="map" style="height: 800px; width: 100%"></div>
<hr>
{% endblock %}
{% block extra_script %}
<script async defer src="{% static 'js/hide_free_text.js' %}"></script>
{% endblock %}

View File

@@ -13,7 +13,15 @@
</ol>
</nav>
<h1>New Relationship</h1>
<h1>Add Relationship</h1>
{% with config.RELATIONSHIP_FORM_HELP as help_text %}
{% if help_text %}
<div class="alert alert-info mt-3 pb-0">
{{ help_text|linebreaks }}
</div>
{% endif %}
{% endwith %}
<hr>

View File

@@ -17,6 +17,24 @@
<hr>
<div class="row justify-content-md-center">
<div class="col-md-3">
<a class="btn btn-warning btn-block"
href="{% url 'people:relationship.update' pk=relationship.pk %}">Update Relationship
</a>
</div>
{% if relationship.is_current %}
<div class="col-md-3">
<a class="btn btn-danger btn-block"
href="{% url 'people:relationship.end' pk=relationship.pk %}">End Relationship
</a>
</div>
{% endif %}
</div>
<hr>
<div class="row align-content-center align-items-center">
<div class="col-md-5 text-center">
<h2>Source</h2>
@@ -45,34 +63,15 @@
<hr>
<a class="btn btn-success"
href="{% url 'people:relationship.update' relationship_pk=relationship.pk %}">Update</a>
{% with relationship.current_answers as answer_set %}
<table class="table table-borderless">
<thead>
<tr>
<th>Question</th>
<th>Answer</th>
</tr>
</thead>
{% if answer_set is None %}
<div class="alert alert-warning mt-3">
This relationship has ended. You can start it again by updating it.
</div>
<tbody>
{% for answer in answer_set.question_answers.all %}
<tr>
<td>{{ answer.question }}</td>
<td>{{ answer }}</td>
</tr>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% endfor %}
</tbody>
</table>
Last updated: {{ answer_set.timestamp }}
{% else %}
{% include 'people/includes/answer_set.html' %}
{% endif %}
{% endwith %}
{% endblock %}

View File

@@ -10,7 +10,7 @@
<a href="{% url 'people:person.detail' pk=person.pk %}">{{ person }}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'people:relationship.detail' pk=relationship.pk %}">{{ relationship.target }}</a>
<a href="{{ relationship.get_absolute_url }}">{{ relationship.target }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Update Relationship</li>
</ol>
@@ -18,6 +18,14 @@
<h1>Update Relationship</h1>
{% with config.RELATIONSHIP_FORM_HELP as help_text %}
{% if help_text %}
<div class="alert alert-info mt-3 pb-0">
{{ help_text|linebreaks }}
</div>
{% endif %}
{% endwith %}
<hr>
<form class="form"
@@ -33,3 +41,8 @@
</form>
{% endblock %}
{% block extra_script %}
{% load staticfiles %}
<script async defer src="{% static 'js/hide_free_text.js' %}"></script>
{% endblock %}

View File

@@ -6,6 +6,8 @@ from . import views
app_name = 'people'
urlpatterns = [
####################
# Organisation views
path('organisations/create',
views.organisation.OrganisationCreateView.as_view(),
name='organisation.create'),
@@ -22,6 +24,8 @@ urlpatterns = [
views.organisation.OrganisationUpdateView.as_view(),
name='organisation.update'),
##############
# Person views
path('profile/',
views.person.ProfileView.as_view(),
name='person.profile'),
@@ -42,6 +46,8 @@ urlpatterns = [
views.person.PersonUpdateView.as_view(),
name='person.update'),
####################
# Relationship views
path('people/<int:person_pk>/relationships/create',
views.relationship.RelationshipCreateView.as_view(),
name='person.relationship.create'),
@@ -50,13 +56,37 @@ urlpatterns = [
views.relationship.RelationshipDetailView.as_view(),
name='relationship.detail'),
path('relationships/<int:relationship_pk>/update',
path('relationships/<int:pk>/update',
views.relationship.RelationshipUpdateView.as_view(),
name='relationship.update'),
path('relationships/<int:pk>/end',
views.relationship.RelationshipEndView.as_view(),
name='relationship.end'),
################################
# OrganisationRelationship views
path('organisations/<int:organisation_pk>/relationships/create',
views.relationship.OrganisationRelationshipCreateView.as_view(),
name='organisation.relationship.create'),
path('organisation-relationships/<int:pk>',
views.relationship.OrganisationRelationshipDetailView.as_view(),
name='organisation.relationship.detail'),
path('organisation-relationships/<int:pk>/update',
views.relationship.OrganisationRelationshipUpdateView.as_view(),
name='organisation.relationship.update'),
path('organisation-relationships/<int:pk>/end',
views.relationship.OrganisationRelationshipEndView.as_view(),
name='organisation.relationship.end'),
############
# Data views
path('map',
views.person.PersonMapView.as_view(),
name='person.map'),
views.map.MapView.as_view(),
name='map'),
path('network',
views.network.NetworkView.as_view(),

View File

@@ -3,6 +3,7 @@ Views for displaying or manipulating models within the `people` app.
"""
from . import (
map,
network,
organisation,
person,
@@ -11,6 +12,7 @@ from . import (
__all__ = [
'map',
'network',
'organisation',
'person',

52
people/views/map.py Normal file
View 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

View File

@@ -5,90 +5,152 @@ Views for displaying networks of :class:`People` and :class:`Relationship`s.
import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.db.models import Q, QuerySet
from django.forms import ValidationError
from django.utils import timezone
from django.views.generic import FormView
from django.views.generic import TemplateView
from people import forms, models, serializers
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
class NetworkView(LoginRequiredMixin, FormView):
"""
View to display relationship network.
"""
def filter_by_form_answers(queryset: QuerySet, answerset_queryset: QuerySet, relationship_key: str):
"""Build a filter to select based on form responses."""
def inner(form, at_date=None):
# Filter on timestamp__date doesn't seem to work on MySQL
# To compare datetimes we need at_date to be midnight at
# the *end* of the day in question - so add one day
if not at_date:
at_date = timezone.now().date()
at_date += timezone.timedelta(days=1)
# Filter to answersets valid at required time
answerset_set = answerset_queryset.prefetch_related('question_answers').filter(
Q(replaced_timestamp__gte=at_date)
| Q(replaced_timestamp__isnull=True),
timestamp__lte=at_date
)
# Filter to answersets containing required answers
for field, values in form.cleaned_data.items():
if field.startswith(f'{form.question_prefix}question_') and values:
answerset_set = answerset_set.filter(question_answers__in=values)
return queryset.filter(pk__in=answerset_set.values_list(relationship_key, flat=True))
return inner
filter_relationships = filter_by_form_answers(
models.Relationship.objects.prefetch_related('source', 'target'),
models.RelationshipAnswerSet.objects, 'relationship'
)
filter_organisations = filter_by_form_answers(
models.Organisation.objects, models.OrganisationAnswerSet.objects, 'organisation'
)
filter_people = filter_by_form_answers(
models.Person.objects, models.PersonAnswerSet.objects, 'person'
)
class NetworkView(LoginRequiredMixin, TemplateView):
"""View to display relationship network."""
template_name = 'people/network.html'
form_class = forms.NetworkFilterForm
def post(self, request, *args, **kwargs):
all_forms = self.get_forms()
if all(map(lambda f: f.is_valid(), all_forms.values())):
return self.forms_valid(all_forms)
return self.forms_invalid(all_forms)
def get_forms(self):
form_kwargs = self.get_form_kwargs()
return {
'relationship': forms.NetworkRelationshipFilterForm(**form_kwargs),
'person': forms.NetworkPersonFilterForm(**form_kwargs),
'organisation': forms.NetworkOrganisationFilterForm(**form_kwargs),
'date': forms.DateForm(**form_kwargs),
}
def get_form_kwargs(self):
"""
Add GET params to form data.
"""
kwargs = super().get_form_kwargs()
"""Add GET params to form data."""
kwargs = {}
if self.request.method == 'GET':
if 'data' in kwargs:
kwargs['data'].update(self.request.GET)
kwargs['data'] = self.request.GET
else:
kwargs['data'] = self.request.GET
if self.request.method in ('POST', 'PUT'):
kwargs['data'] = self.request.POST
return kwargs
def get_context_data(self, **kwargs):
"""
Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context.
"""
"""Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context."""
context = super().get_context_data(**kwargs)
form: forms.NetworkFilterForm = context['form']
if not form.is_valid():
context['full_width_page'] = True
all_forms = self.get_forms()
context['relationship_form'] = all_forms['relationship']
context['person_form'] = all_forms['person']
context['organisation_form'] = all_forms['organisation']
context['date_form'] = all_forms['date']
if not all(map(lambda f: f.is_valid(), all_forms.values())):
return context
at_date = form.cleaned_data['date']
if not at_date:
at_date = timezone.now().date()
# Filter on timestamp__date doesn't seem to work on MySQL
# To compare datetimes we need at_date to be midnight at
# the *end* of the day in question - so add one day here
at_date += timezone.timedelta(days=1)
relationship_answerset_set = models.RelationshipAnswerSet.objects.filter(
Q(replaced_timestamp__gte=at_date)
| Q(replaced_timestamp__isnull=True),
timestamp__lte=at_date)
logger.info('Found %d relationship answer sets for %s',
relationship_answerset_set.count(), at_date)
# Filter answers to relationship questions
for field, values in form.cleaned_data.items():
if field.startswith('question_') and values:
relationship_answerset_set = relationship_answerset_set.filter(
question_answers__in=values)
logger.info('Found %d relationship answer sets matching filters',
relationship_answerset_set.count())
date = all_forms['date'].cleaned_data['date']
context['person_set'] = serializers.PersonSerializer(
models.Person.objects.all(), many=True).data
filter_people(all_forms['person'], at_date=date), many=True
).data
context['organisation_set'] = serializers.OrganisationSerializer(
filter_organisations(all_forms['organisation'], at_date=date), many=True
).data
context['relationship_set'] = serializers.RelationshipSerializer(
models.Relationship.objects.filter(
pk__in=relationship_answerset_set.values_list('relationship',
flat=True)),
many=True).data
filter_relationships(all_forms['relationship'], at_date=date), many=True
).data
logger.info('Found %d distinct relationships matching filters',
len(context['relationship_set']))
context['organisation_relationship_set'] = serializers.OrganisationRelationshipSerializer(
models.OrganisationRelationship.objects.prefetch_related('source', 'target').all(),
many=True
).data
for person in models.Person.objects.all():
try:
context['organisation_relationship_set'].append(
{
'pk': f'membership-{person.pk}',
'source': serializers.PersonSerializer(person).data,
'target': serializers.OrganisationSerializer(
person.current_answers.organisation
).data,
'kind': 'organisation-membership'
}
)
except AttributeError:
pass
logger.info(
'Found %d distinct relationships matching filters', len(context['relationship_set'])
)
return context
def form_valid(self, form):
def forms_valid(self, all_forms):
try:
return self.render_to_response(self.get_context_data())
except ValidationError:
return self.form_invalid(form)
return self.forms_invalid(all_forms)
def forms_invalid(self, all_forms):
return self.render_to_response(self.get_context_data())

View File

@@ -1,9 +1,13 @@
import typing
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from django.views.generic import CreateView, DetailView, ListView, UpdateView
from people import forms, models
from .map import get_map_data
class OrganisationCreateView(LoginRequiredMixin, CreateView):
@@ -13,11 +17,100 @@ class OrganisationCreateView(LoginRequiredMixin, CreateView):
form_class = forms.OrganisationForm
def try_copy_by_key(src_dict: typing.Mapping[str, typing.Any],
dest_dict: typing.MutableMapping[str, typing.Any],
key: str) -> None:
"""Copy a value by key from one dictionary to another.
If the key does not exist, skip it.
"""
value = src_dict.get(key, None)
if value is not None:
dest_dict[key] = value
class OrganisationListView(LoginRequiredMixin, ListView):
"""View displaying a list of :class:`organisation` objects."""
model = models.Organisation
template_name = 'people/organisation/list.html'
@staticmethod
def sort_organisation_countries(
orgs_by_country: typing.MutableMapping[str, typing.Any]
) -> typing.Dict[str, typing.Any]:
"""Sort dictionary of organisations by country.
Sort order:
- Project partners
- International organisations
- Organisations by country alphabetically
- Organisations with unknown country
"""
orgs_sorted = {}
try_copy_by_key(orgs_by_country, orgs_sorted,
f'{settings.PARENT_PROJECT_NAME} partners')
try_copy_by_key(orgs_by_country, orgs_sorted, 'International')
special = {
f'{settings.PARENT_PROJECT_NAME} partners', 'International',
'Unknown'
}
for country in sorted(k for k in orgs_by_country.keys()
if k not in special):
orgs_sorted[country] = orgs_by_country[country]
try_copy_by_key(orgs_by_country, orgs_sorted, 'Unknown')
return orgs_sorted
def get_context_data(self,
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
context = super().get_context_data(**kwargs)
orgs_by_country = {}
for organisation in self.get_queryset().all():
answers = organisation.current_answers
country = 'Unknown'
try:
if len(answers.countries) == 1:
country = answers.countries[0].name
elif len(answers.countries) > 1:
country = 'International'
if answers.is_partner_organisation:
country = f'{settings.PARENT_PROJECT_NAME} partners'
except AttributeError:
# Organisation has no AnswerSet - country is 'Unknown'
pass
orgs = orgs_by_country.get(country, [])
orgs.append(organisation)
orgs_by_country[country] = orgs
# Sort into meaningful order
context['orgs_by_country'] = self.sort_organisation_countries(
orgs_by_country)
existing_relationships = set()
try:
existing_relationships = set(
self.request.user.person.organisation_relationships_as_source.filter(
answer_sets__replaced_timestamp__isnull=True
).values_list('target_id', flat=True)
)
except ObjectDoesNotExist:
# No linked Person yet
pass
context['existing_relationships'] = existing_relationships
return context
class OrganisationDetailView(LoginRequiredMixin, DetailView):
"""View displaying details of a :class:`Organisation`."""
@@ -30,11 +123,26 @@ class OrganisationDetailView(LoginRequiredMixin, DetailView):
"""Add map marker to context."""
context = super().get_context_data(**kwargs)
context['map_markers'] = [{
'name': self.object.name,
'lat': self.object.latitude,
'lng': self.object.longitude,
}]
answer_set = self.object.current_answers
context['answer_set'] = answer_set
context['map_markers'] = [get_map_data(self.object)]
context['question_answers'] = {}
if answer_set is not None:
show_all = self.request.user.is_superuser
context['question_answers'] = answer_set.build_question_answers(
show_all)
context['relationship'] = None
try:
relationship = models.OrganisationRelationship.objects.get(
source=self.request.user.person, target=self.object)
if relationship.is_current:
context['relationship'] = relationship
except models.OrganisationRelationship.DoesNotExist:
pass
return context
@@ -44,17 +152,48 @@ class OrganisationUpdateView(LoginRequiredMixin, UpdateView):
model = models.Organisation
context_object_name = 'organisation'
template_name = 'people/organisation/update.html'
form_class = forms.OrganisationForm
form_class = forms.OrganisationAnswerSetForm
def get_context_data(self,
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
"""Add map marker to context."""
context = super().get_context_data(**kwargs)
context['map_markers'] = [{
'name': self.object.name,
'lat': self.object.latitude,
'lng': self.object.longitude,
}]
answerset = self.object.current_answers
context['map_markers'] = [get_map_data(self.object)]
return context
def get_initial(self) -> typing.Dict[str, typing.Any]:
try:
previous_answers = self.object.current_answers.as_dict()
except AttributeError:
previous_answers = {}
previous_answers.update({
'organisation_id': self.object.id,
})
return previous_answers
def get_form_kwargs(self) -> typing.Dict[str, typing.Any]:
"""Remove instance from form kwargs as it's an Organisation, but expects an OrganisationAnswerSet."""
kwargs = super().get_form_kwargs()
kwargs.pop('instance')
return kwargs
def form_valid(self, form):
"""Mark any previous answer sets as replaced."""
response = super().form_valid(form)
now_date = timezone.now().date()
# Saving the form made self.object an OrganisationAnswerSet - so go up, then back down
# Shouldn't be more than one after initial updates after migration
for answer_set in self.object.organisation.answer_sets.exclude(
pk=self.object.pk):
answer_set.replaced_timestamp = now_date
answer_set.save()
return response

View File

@@ -5,16 +5,18 @@ Views for displaying or manipulating instances of :class:`Person`.
import typing
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.utils import timezone
from django.views.generic import CreateView, DetailView, ListView, UpdateView
from people import forms, models, permissions
from .map import get_map_data
class PersonCreateView(LoginRequiredMixin, CreateView):
"""
View to create a new instance of :class:`Person`.
"""View to create a new instance of :class:`Person`.
If 'user' is passed as a URL parameter - link the new person to the current user.
"""
@@ -30,23 +32,57 @@ class PersonCreateView(LoginRequiredMixin, CreateView):
class PersonListView(LoginRequiredMixin, ListView):
"""
View displaying a list of :class:`Person` objects - searchable.
"""
"""View displaying a list of :class:`Person` objects - searchable."""
model = models.Person
template_name = 'people/person/list.html'
def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
context = super().get_context_data(**kwargs)
class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView):
"""
View displaying the profile of a :class:`Person` - who may be a user.
"""
existing_relationships = set()
try:
existing_relationships = set(
self.request.user.person.relationships_as_source.filter(
answer_sets__replaced_timestamp__isnull=True
).values_list('target_id', flat=True)
)
except ObjectDoesNotExist:
# No linked Person yet
pass
context['existing_relationships'] = existing_relationships
return context
class ProfileView(LoginRequiredMixin, DetailView):
"""View displaying the profile of a :class:`Person` - who may be a user."""
model = models.Person
template_name = 'people/person/detail.html'
def get(self, request: HttpRequest, *args: typing.Any, **kwargs: typing.Any) -> HttpResponse:
try:
self.object = self.get_object() # pylint: disable=attribute-defined-outside-init
except ObjectDoesNotExist:
# User has no linked Person yet
return redirect('index')
if self.object.user == self.request.user and self.object.current_answers is None:
return redirect('people:person.update', pk=self.object.pk)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def get_template_names(self) -> typing.List[str]:
"""Return template depending on level of access."""
if (self.object.user == self.request.user) or self.request.user.is_superuser:
return ['people/person/detail_full.html']
return ['people/person/detail_partial.html']
def get_object(self, queryset=None) -> models.Person:
"""
Get the :class:`Person` object to be represented by this page.
"""Get the :class:`Person` object to be represented by this page.
If not determined from url get current user.
"""
@@ -57,14 +93,31 @@ class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView):
# pk was not provided in URL
return self.request.user.person
def get_context_data(self,
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
"""Add current :class:`PersonAnswerSet` to context."""
context = super().get_context_data(**kwargs)
context['answer_set'] = self.object.current_answers
answer_set = self.object.current_answers
context['answer_set'] = answer_set
context['map_markers'] = [get_map_data(self.object)]
context['question_answers'] = {}
if answer_set is not None:
show_all = (self.object.user == self.request.user) or self.request.user.is_superuser
context['question_answers'] = answer_set.build_question_answers(show_all)
context['relationship'] = None
try:
relationship = models.Relationship.objects.get(
source=self.request.user.person, target=self.object
)
if relationship.is_current:
context['relationship'] = relationship
except models.Relationship.DoesNotExist:
pass
return context
@@ -75,8 +128,21 @@ class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
template_name = 'people/person/update.html'
form_class = forms.PersonAnswerSetForm
def get_context_data(self,
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
def get(self, request: HttpRequest, *args: str, **kwargs: typing.Any) -> HttpResponse:
self.object = self.get_object()
try:
if (self.object.user == self.request.user) and not self.request.user.consent_given:
return redirect('consent')
except AttributeError:
# No linked user
pass
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def get_context_data(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
context = super().get_context_data(**kwargs)
context['map_markers'] = [get_map_data(self.object)]
@@ -110,48 +176,8 @@ class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
# Saving the form made self.object a PersonAnswerSet - so go up, then back down
# Shouldn't be more than one after initial updates after migration
for answer_set in self.object.person.answer_sets.exclude(
pk=self.object.pk):
for answer_set in self.object.person.answer_sets.exclude(pk=self.object.pk):
answer_set.replaced_timestamp = now_date
answer_set.save()
return response
def get_map_data(person: models.Person) -> typing.Dict[str, typing.Any]:
"""Prepare data to mark people on a map."""
answer_set = person.current_answers
organisation = getattr(answer_set, 'organisation', None)
try:
country = answer_set.country_of_residence.name
except AttributeError:
country = None
return {
'name': person.name,
'lat': getattr(answer_set, 'latitude', None),
'lng': getattr(answer_set, 'longitude', None),
'organisation': getattr(organisation, 'name', None),
'org_lat': getattr(organisation, 'latitude', None),
'org_lng': getattr(organisation, 'longitude', None),
'country': country,
'url': reverse('people:person.detail', kwargs={'pk': person.pk})
}
class PersonMapView(LoginRequiredMixin, ListView):
"""View displaying a map of :class:`Person` locations."""
model = models.Person
template_name = 'people/person/map.html'
def get_context_data(self,
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
context = super().get_context_data(**kwargs)
context['map_markers'] = [
get_map_data(person) for person in self.object_list
]
return context

View File

@@ -1,12 +1,12 @@
"""
Views for displaying or manipulating instances of :class:`Relationship`.
"""
"""Views for displaying or manipulating instances of :class:`Relationship`."""
from django.db import IntegrityError
from django.forms import ValidationError
import typing
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.utils import timezone
from django.views.generic import CreateView, DetailView, FormView
from django.views.generic import DetailView, RedirectView, UpdateView
from django.views.generic.detail import SingleObjectMixin
from people import forms, models, permissions
@@ -19,109 +19,159 @@ class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView):
template_name = 'people/relationship/detail.html'
related_person_field = 'source'
class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, FormView):
"""
View for creating a :class:`Relationship`.
Displays / processes a form containing the :class:`RelationshipQuestion`s.
"""
model = models.Relationship
template_name = 'people/relationship/create.html'
form_class = forms.RelationshipForm
def get_person(self) -> models.Person:
return models.Person.objects.get(pk=self.kwargs.get('person_pk'))
def get_test_person(self) -> models.Person:
return self.get_person()
def form_valid(self, form):
try:
self.object = models.Relationship.objects.create(
source=self.get_person(), target=form.cleaned_data['target'])
except IntegrityError:
form.add_error(
None,
ValidationError('This relationship already exists',
code='already-exists'))
return self.form_invalid(form)
return super().form_valid(form)
def get_context_data(self, **kwargs):
def get_context_data(self,
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
"""Add current :class:`RelationshipAnswerSet` to context."""
context = super().get_context_data(**kwargs)
context['person'] = self.get_person()
answer_set = self.object.current_answers
context['answer_set'] = answer_set
context['question_answers'] = {}
if answer_set is not None:
show_all = ((self.object.source == self.request.user)
or self.request.user.is_superuser)
context['question_answers'] = answer_set.build_question_answers(
show_all)
return context
def get_success_url(self):
return reverse('people:relationship.update',
kwargs={'relationship_pk': self.object.pk})
class RelationshipCreateView(LoginRequiredMixin, RedirectView):
"""View for creating a :class:`Relationship`.
class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
Redirects to a form containing the :class:`RelationshipQuestion`s.
"""
View for updating the details of a relationship.
def get_redirect_url(self, *args: typing.Any,
**kwargs: typing.Any) -> typing.Optional[str]:
target = models.Person.objects.get(pk=self.kwargs.get('person_pk'))
relationship, _ = models.Relationship.objects.get_or_create(
source=self.request.user.person, target=target)
return reverse('people:relationship.update',
kwargs={'pk': relationship.pk})
class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
"""View for updating the details of a relationship.
Creates a new :class:`RelationshipAnswerSet` for the :class:`Relationship`.
Displays / processes a form containing the :class:`RelationshipQuestion`s.
"""
model = models.RelationshipAnswerSet
model = models.Relationship
context_object_name = 'relationship'
template_name = 'people/relationship/update.html'
form_class = forms.RelationshipAnswerSetForm
def get_test_person(self) -> models.Person:
"""
Get the person instance which should be used for access control checks.
"""
relationship = models.Relationship.objects.get(
pk=self.kwargs.get('relationship_pk'))
return relationship.source
def get(self, request, *args, **kwargs):
self.relationship = models.Relationship.objects.get(
pk=self.kwargs.get('relationship_pk'))
self.person = self.relationship.source
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.relationship = models.Relationship.objects.get(
pk=self.kwargs.get('relationship_pk'))
self.person = self.relationship.source
return super().post(request, *args, **kwargs)
"""Get the person instance which should be used for access control checks."""
return self.get_object().source
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['person'] = self.person
context['relationship'] = self.relationship
context['person'] = self.object.source
return context
def get_initial(self):
initial = super().get_initial()
try:
previous_answers = self.object.current_answers.as_dict()
initial['relationship'] = self.relationship
except AttributeError:
previous_answers = {}
return initial
previous_answers.update({
'relationship': self.object,
})
return previous_answers
def get_form_kwargs(self) -> typing.Dict[str, typing.Any]:
"""Remove instance from form kwargs as it's a person, but expects a PersonAnswerSet."""
kwargs = super().get_form_kwargs()
kwargs.pop('instance')
return kwargs
def form_valid(self, form):
"""
Mark any previous answer sets as replaced.
"""
"""Mark any previous answer sets as replaced."""
response = super().form_valid(form)
now_date = timezone.now().date()
# Shouldn't be more than one after initial updates after migration
for answer_set in self.relationship.answer_sets.exclude(
for answer_set in self.object.relationship.answer_sets.exclude(
pk=self.object.pk):
answer_set.replaced_timestamp = now_date
answer_set.save()
return response
def get_success_url(self) -> str:
return self.object.get_absolute_url()
class RelationshipEndView(permissions.UserIsLinkedPersonMixin,
SingleObjectMixin, RedirectView):
"""View for marking a relationship as ended.
Sets `replaced_timestamp` on all answer sets where this is currently null.
"""
model = models.Relationship
def get_test_person(self) -> models.Person:
"""Get the person instance which should be used for access control checks."""
return self.get_object().source
def get_redirect_url(self, *args, **kwargs):
"""Mark any previous answer sets as replaced."""
now_date = timezone.now().date()
relationship = self.get_object()
relationship.answer_sets.filter(
replaced_timestamp__isnull=True).update(
replaced_timestamp=now_date)
return relationship.target.get_absolute_url()
class OrganisationRelationshipEndView(RelationshipEndView):
"""View for marking an organisation relationship as ended.
Sets `replaced_timestamp` on all answer sets where this is currently null.
"""
model = models.OrganisationRelationship
class OrganisationRelationshipDetailView(RelationshipDetailView):
"""View displaying details of an :class:`OrganisationRelationship`."""
model = models.OrganisationRelationship
template_name = 'people/organisation-relationship/detail.html'
related_person_field = 'source'
context_object_name = 'relationship'
class OrganisationRelationshipCreateView(LoginRequiredMixin, RedirectView):
"""View for creating a :class:`OrganisationRelationship`.
Redirects to a form containing the :class:`OrganisationRelationshipQuestion`s.
"""
def get_redirect_url(self, *args: typing.Any,
**kwargs: typing.Any) -> typing.Optional[str]:
target = models.Organisation.objects.get(
pk=self.kwargs.get('organisation_pk'))
relationship, _ = models.OrganisationRelationship.objects.get_or_create(
source=self.request.user.person, target=target)
return reverse('people:organisation.relationship.update',
kwargs={'pk': relationship.pk})
class OrganisationRelationshipUpdateView(RelationshipUpdateView):
"""View for updating the details of a Organisationrelationship.
Creates a new :class:`OrganisationRelationshipAnswerSet` for the :class:`OrganisationRelationship`.
Displays / processes a form containing the :class:`OrganisationRelationshipQuestion`s.
"""
model = models.OrganisationRelationship
context_object_name = 'relationship'
template_name = 'people/relationship/update.html'
form_class = forms.OrganisationRelationshipAnswerSetForm

View File

@@ -4,10 +4,13 @@ dj-database-url==0.5.0
Django==2.2.10
django-appconf==1.0.3
django-bootstrap4==1.1.1
django-bootstrap-datepicker-plus==3.0.5
django-compat==1.0.15
django-constance==2.6.0
django-countries==5.5
django-dbbackup==3.2.0
django-filter==2.2.0
django-hijack==2.2.1
django-picklefield==2.1.1
django-post-office==3.4.0
django-select2==7.2.0

View File

@@ -7,6 +7,7 @@ deploy_mode: 3
secret_key: '{{ lookup("password", "/dev/null") }}'
parent_project_name: 'BRECcIA'
project_name: 'breccia-mapper'
project_full_name: 'breccia_mapper'
project_dir: '/var/www/{{ project_name }}'

View File

@@ -11,6 +11,7 @@ ALLOWED_HOSTS={% for h in allowed_hosts %}{{ h }},{% endfor %}
ALLOWED_HOSTS={{ inventory_hostname }},localhost,127.0.0.1
{% endif %}
PARENT_PROJECT_NAME={{ parent_project_name }}
PROJECT_SHORT_NAME={{ display_short_name }}
PROJECT_LONG_NAME={{ display_long_name }}