Merge branch 'dev'

This commit is contained in:
James Graham
2020-03-06 15:12:53 +00:00
15 changed files with 572 additions and 148 deletions

View File

@@ -71,6 +71,7 @@ THIRD_PARTY_APPS = [
'dbbackup',
'django_countries',
'django_select2',
'rest_framework',
]
FIRST_PARTY_APPS = [

View File

@@ -31,11 +31,6 @@ class PersonAdmin(admin.ModelAdmin):
pass
@admin.register(models.RelationshipQuestionChoice)
class RelationshipQuestionChoiceAdmin(admin.ModelAdmin):
pass
class RelationshipQuestionChoiceInline(admin.TabularInline):
model = models.RelationshipQuestionChoice

View File

@@ -25,22 +25,17 @@ class PersonForm(forms.ModelForm):
}
class RelationshipForm(forms.ModelForm):
class RelationshipAnswerSetForm(forms.ModelForm):
"""
Form to allow users to describe a relationship - includes :class:`RelationshipQuestion`s.
Form to allow users to describe a relationship.
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
"""
class Meta:
model = models.Relationship
model = models.RelationshipAnswerSet
fields = [
'source',
'target',
'relationship',
]
widgets = {
'source': Select2Widget(),
'target': Select2Widget(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -53,7 +48,7 @@ class RelationshipForm(forms.ModelForm):
choices=choices)
self.fields['question_{}'.format(question.pk)] = field
def save(self, commit=True) -> models.Relationship:
def save(self, commit=True) -> models.RelationshipAnswerSet:
# Save Relationship model
self.instance = super().save(commit=commit)
@@ -61,7 +56,9 @@ class RelationshipForm(forms.ModelForm):
# Save answers to relationship questions
for key, value in self.cleaned_data.items():
if key.startswith('question_'):
answer = models.RelationshipQuestionChoice.objects.get(pk=value)
question_id = key.replace('question_', '', 1)
answer = models.RelationshipQuestionChoice.objects.get(pk=value,
question__pk=question_id)
self.instance.question_answers.add(answer)
return self.instance

View File

@@ -0,0 +1,78 @@
# Generated by Django 2.2.10 on 2020-03-04 12:09
import logging
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
logger = logging.getLogger(__name__)
def forward_migration(apps, schema_editor):
"""
Move existing data forward into answer sets from the relationship.
"""
Relationship = apps.get_model('people', 'Relationship')
for relationship in Relationship.objects.all():
answer_set = relationship.answer_sets.first()
if answer_set is None:
answer_set = relationship.answer_sets.create()
for answer in relationship.question_answers.all():
answer_set.question_answers.add(answer)
def backward_migration(apps, schema_editor):
"""
Move data backward from answer sets onto the relationship.
"""
Relationship = apps.get_model('people', 'Relationship')
for relationship in Relationship.objects.all():
answer_set = relationship.answer_sets.last()
try:
for answer in answer_set.question_answers.all():
relationship.question_answers.add(answer)
except AttributeError:
pass
class Migration(migrations.Migration):
dependencies = [
('people', '0015_shrink_name_fields_to_255'),
]
operations = [
migrations.AddField(
model_name='relationship',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='relationship',
name='expired',
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name='RelationshipAnswerSet',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(auto_now_add=True)),
('question_answers', models.ManyToManyField(to='people.RelationshipQuestionChoice')),
('relationship', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_sets', to='people.Relationship')),
],
options={
'ordering': ['timestamp'],
},
),
migrations.RunPython(forward_migration, backward_migration),
migrations.RemoveField(
model_name='relationship',
name='question_answers',
),
]

View File

@@ -0,0 +1,2 @@
from .person import *
from .relationship import *

View File

@@ -10,6 +10,15 @@ from django_countries.fields import CountryField
from backports.db.models.enums import TextChoices
__all__ = [
'User',
'Organisation',
'Role',
'Discipline',
'Theme',
'Person',
]
class User(AbstractUser):
"""
@@ -175,109 +184,3 @@ class Person(models.Model):
return self.name
class RelationshipQuestion(models.Model):
"""
Question which may be asked about a relationship.
"""
class Meta:
ordering = [
'order',
'text',
]
#: Version number of this question - to allow modification without invalidating existing data
version = models.PositiveSmallIntegerField(default=1,
blank=False, null=False)
#: Text of question
text = models.CharField(max_length=255,
blank=False, null=False)
#: Position of this question in the list
order = models.SmallIntegerField(default=0,
blank=False, null=False)
@property
def choices(self) -> typing.List[typing.List[str]]:
"""
Convert the :class:`RelationshipQuestionChoices` for this question into Django choices.
"""
return [
[choice.pk, str(choice)] for choice in self.answers.all()
]
def __str__(self) -> str:
return self.text
class RelationshipQuestionChoice(models.Model):
"""
Allowed answer to a :class:`RelationshipQuestion`.
"""
class Meta:
constraints = [
models.UniqueConstraint(fields=['question', 'text'],
name='unique_question_answer')
]
ordering = [
'question__order',
'order',
'text',
]
#: Question to which this answer belongs
question = models.ForeignKey(RelationshipQuestion, related_name='answers',
on_delete=models.CASCADE,
blank=False, null=False)
#: Text of answer
text = models.CharField(max_length=255,
blank=False, null=False)
#: Position of this answer in the list
order = models.SmallIntegerField(default=0,
blank=False, null=False)
def __str__(self) -> str:
return self.text
class Relationship(models.Model):
"""
A directional relationship between two people allowing linked questions.
"""
class Meta:
constraints = [
models.UniqueConstraint(fields=['source', 'target'],
name='unique_relationship'),
]
#: Person reporting the relationship
source = models.ForeignKey(Person, related_name='relationships_as_source',
on_delete=models.CASCADE,
blank=False, null=False)
#: Person with whom the relationship is reported
target = models.ForeignKey(Person, related_name='relationships_as_target',
on_delete=models.CASCADE,
blank=False, null=False)
#: Answers to :class:`RelationshipQuestion`s
question_answers = models.ManyToManyField(RelationshipQuestionChoice)
def get_absolute_url(self):
return reverse('people:relationship.detail', kwargs={'pk': self.pk})
def __str__(self) -> str:
return f'{self.source} -> {self.target}'
@property
def reverse(self):
"""
Get the reverse of this relationship.
@raise Relationship.DoesNotExist: When the reverse relationship is not known
"""
return type(self).objects.get(source=self.target,
target=self.source)

View File

@@ -0,0 +1,155 @@
"""
Models describing relationships between people.
"""
import typing
from django.db import models
from django.urls import reverse
from .person import Person
__all__ = [
'RelationshipQuestion',
'RelationshipQuestionChoice',
'RelationshipAnswerSet',
'Relationship',
]
class RelationshipQuestion(models.Model):
"""
Question which may be asked about a relationship.
"""
class Meta:
ordering = [
'order',
'text',
]
#: Version number of this question - to allow modification without invalidating existing data
version = models.PositiveSmallIntegerField(default=1,
blank=False, null=False)
#: Text of question
text = models.CharField(max_length=255,
blank=False, null=False)
#: Position of this question in the list
order = models.SmallIntegerField(default=0,
blank=False, null=False)
@property
def choices(self) -> typing.List[typing.List[str]]:
"""
Convert the :class:`RelationshipQuestionChoices` for this question into Django choices.
"""
return [
[choice.pk, str(choice)] for choice in self.answers.all()
]
def __str__(self) -> str:
return self.text
class RelationshipQuestionChoice(models.Model):
"""
Allowed answer to a :class:`RelationshipQuestion`.
"""
class Meta:
constraints = [
models.UniqueConstraint(fields=['question', 'text'],
name='unique_question_answer')
]
ordering = [
'question__order',
'order',
'text',
]
#: Question to which this answer belongs
question = models.ForeignKey(RelationshipQuestion, related_name='answers',
on_delete=models.CASCADE,
blank=False, null=False)
#: Text of answer
text = models.CharField(max_length=255,
blank=False, null=False)
#: Position of this answer in the list
order = models.SmallIntegerField(default=0,
blank=False, null=False)
def __str__(self) -> str:
return self.text
class Relationship(models.Model):
"""
A directional relationship between two people allowing linked questions.
"""
class Meta:
constraints = [
models.UniqueConstraint(fields=['source', 'target'],
name='unique_relationship'),
]
#: Person reporting the relationship
source = models.ForeignKey(Person, related_name='relationships_as_source',
on_delete=models.CASCADE,
blank=False, null=False)
#: Person with whom the relationship is reported
target = models.ForeignKey(Person, related_name='relationships_as_target',
on_delete=models.CASCADE,
blank=False, null=False)
#: 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 get_absolute_url(self):
return reverse('people:relationship.detail', kwargs={'pk': self.pk})
def __str__(self) -> str:
return f'{self.source} -> {self.target}'
@property
def reverse(self):
"""
Get the reverse of this relationship.
@raise Relationship.DoesNotExist: When the reverse relationship is not known
"""
return type(self).objects.get(source=self.target,
target=self.source)
class RelationshipAnswerSet(models.Model):
"""
The answers to the relationship questions at a particular point in time.
"""
class Meta:
ordering = [
'timestamp',
]
#: Relationship to which this answer set belongs
relationship = models.ForeignKey(Relationship,
on_delete=models.CASCADE,
related_name='answer_sets',
blank=False, null=False)
#: Answers to :class:`RelationshipQuestion`s
question_answers = models.ManyToManyField(RelationshipQuestionChoice)
#: When were these answers collected?
timestamp = models.DateTimeField(auto_now_add=True)

26
people/serializers.py Normal file
View File

@@ -0,0 +1,26 @@
"""
Serialize models to and deserialize from JSON.
"""
from rest_framework import serializers
from . import models
class PersonSerializer(serializers.ModelSerializer):
class Meta:
model = models.Person
fields = [
'pk',
'name',
]
class RelationshipSerializer(serializers.ModelSerializer):
class Meta:
model = models.Relationship
fields = [
'pk',
'source',
'target',
]

View File

@@ -0,0 +1,120 @@
{% extends 'base.html' %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">Network</li>
</ol>
</nav>
<hr>
<div id="cy"
style="width: 100%; min-height: 1000px; flex-grow: 1; border: 2px solid black"></div>
{% endblock %}
{% block extra_script %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.14.0/cytoscape.min.js"
integrity="sha256-rI7zH7xDqO306nxvXUw9gqkeBpvvmddDdlXJjJM7rEM="
crossorigin="anonymous"></script>
<script type="application/javascript">
var cy;
/**
* Get all :class:`Person` records from people:person.api.list endpoint.
*
* @returns JQuery Promise from AJAX
*/
function get_people_ajax(){
return $.ajax({
url: '{% url "people:person.api.list" %}',
success: success_people_ajax,
error: function (xhr, status, error) {
console.error(error);
}
});
}
/**
* Add nodes to Cytoscape network from :class:`Person` JSON.
*
* @param data: JSON representation of people
* @param status: unused
* @param xhr: unused
*/
function success_people_ajax(data, status, xhr) {
for (var person of data) {
cy.add({
group: 'nodes',
data: {
id: 'person-' + person.pk.toString(),
name: person.name
}
})
}
}
/**
* Get all :class:`Relationship` records from people:relationship.api.list endpoint.
*
* @returns JQuery Promise from AJAX
*/
function get_relationships_ajax() {
return $.ajax({
url: '{% url "people:relationship.api.list" %}',
success: success_relationships_ajax,
error: function (xhr, status, error) {
console.error(error);
}
});
}
/**
* Add edges to Cytoscape network from :class:`Relationship` JSON.
*
* @param data: JSON representation of relationships
* @param status: unused
* @param xhr: unused
*/
function success_relationships_ajax(data, status, xhr) {
for (var relationship of data) {
cy.add({
group: 'edges',
data: {
id: 'relationship-' + relationship.pk.toString(),
source: 'person-' + relationship.source.toString(),
target: 'person-' + relationship.target.toString()
}
})
}
}
/**
* Populate a Cytoscape network from :class:`Person` and :class:`Relationship` API.
*/
function get_network() {
cy = cytoscape({
container: document.getElementById('cy')
});
$.when(get_people_ajax()).then(function() {
get_relationships_ajax()
}).then(function() {
var layout = cy.layout({
name: 'circle',
randomize: true,
animate: false
});
layout.run();
});
}
$( window ).on('load', get_network());
</script>
{% endblock %}

View File

@@ -43,27 +43,34 @@
<hr>
<table class="table table-borderless">
<thead>
<tr>
<th>Question</th>
<th>Answer</th>
</tr>
</thead>
<a class="btn btn-success"
href="{% url 'people:relationship.update' relationship_pk=relationship.pk %}">Update</a>
<tbody>
{% for answer in relationship.question_answers.all %}
{% with relationship.current_answers as answer_set %}
<table class="table table-borderless">
<thead>
<tr>
<td>{{ answer.question }}</td>
<td>{{ answer }}</td>
<th>Question</th>
<th>Answer</th>
</tr>
</thead>
{% empty %}
<tr>
<td>No records</td>
</tr>
{% endfor %}
</tbody>
</table>
<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 }}
{% endwith %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% 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=person.pk %}">{{ person }}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'people:relationship.detail' pk=relationship.pk %}">{{ relationship.target }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Update Relationship</li>
</ol>
</nav>
<hr>
<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

@@ -33,4 +33,20 @@ urlpatterns = [
path('relationships/<int:pk>',
views.RelationshipDetailView.as_view(),
name='relationship.detail'),
path('relationships/<int:relationship_pk>/update',
views.RelationshipUpdateView.as_view(),
name='relationship.update'),
path('api/people',
views.PersonApiView.as_view(),
name='person.api.list'),
path('api/relationships',
views.RelationshipApiView.as_view(),
name='relationship.api.list'),
path('network',
views.NetworkView.as_view(),
name='network'),
]

View File

@@ -3,9 +3,12 @@ Views for displaying or manipulating models in the 'people' app.
"""
from django.http import HttpResponseRedirect
from django.views.generic import CreateView, DetailView, ListView, UpdateView
from django.urls import reverse
from django.views.generic import CreateView, DetailView, ListView, TemplateView, UpdateView
from . import forms, models, permissions
from rest_framework.views import APIView, Response
from . import forms, models, permissions, serializers
class PersonCreateView(CreateView):
@@ -80,11 +83,14 @@ class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView):
"""
model = models.Relationship
template_name = 'people/relationship/create.html'
form_class = forms.RelationshipForm
fields = [
'source',
'target',
]
def get_test_person(self) -> models.Person:
"""
Get the person instance which should be used for access control checks.
"""
if self.request.method == 'POST':
return models.Person.objects.get(pk=self.request.POST.get('source'))
@@ -116,10 +122,92 @@ class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView):
return context
def get_success_url(self):
return reverse('people:relationship.update', kwargs={'pk': self.object.pk})
class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
"""
View for creating a :class:`Relationship`.
Displays / processes a form containing the :class:`RelationshipQuestion`s.
"""
model = models.RelationshipAnswerSet
template_name = 'people/relationship/update.html'
form_class = forms.RelationshipAnswerSetForm
def get_test_person(self) -> models.Person:
"""
Get the person instance which should be used for access control checks.
"""
relationship = models.Relationship.objects.get(pk=self.kwargs.get('relationship_pk'))
return relationship.source
def get(self, request, *args, **kwargs):
self.relationship = models.Relationship.objects.get(pk=self.kwargs.get('relationship_pk'))
self.person = self.relationship.source
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.relationship = models.Relationship.objects.get(pk=self.kwargs.get('relationship_pk'))
self.person = self.relationship.source
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['person'] = self.person
context['relationship'] = self.relationship
return context
def get_initial(self):
initial = super().get_initial()
initial['relationship'] = self.relationship
return initial
def form_valid(self, form):
"""
Form is valid - create :class:`Relationship` and save answers to questions.
Don't rebind self.object to be the result of the form - it is a :class:`RelationshipAnswerSet`.
"""
self.object = form.save()
form.save()
return HttpResponseRedirect(self.object.get_absolute_url())
return HttpResponseRedirect(self.relationship.get_absolute_url())
class PersonApiView(APIView):
"""
List all :class:`Person` instances.
"""
def get(self, request, format=None):
"""
List all :class:`Person` instances.
"""
serializer = serializers.PersonSerializer(models.Person.objects.all(),
many=True)
return Response(serializer.data)
class RelationshipApiView(APIView):
"""
List all :class:`Relationship` instances.
"""
def get(self, request, format=None):
"""
List all :class:`Relationship` instances.
"""
serializer = serializers.RelationshipSerializer(models.Relationship.objects.all(),
many=True)
return Response(serializer.data)
class NetworkView(TemplateView):
"""
View to display relationship network.
"""
template_name = 'people/network.html'

View File

@@ -7,9 +7,11 @@ django-bootstrap4==1.1.1
django-constance==2.6.0
django-countries==5.5
django-dbbackup==3.2.0
django-filter==2.2.0
django-picklefield==2.1.1
django-select2==7.2.0
django-settings-export==1.2.1
djangorestframework==3.11.0
dodgy==0.2.1
isort==4.3.21
lazy-object-proxy==1.4.3

View File

@@ -107,6 +107,7 @@
virtualenv: '{{ venv_dir }}'
become_user: '{{ web_user }}'
with_items:
- dbbackup
- migrate
- collectstatic