feat(network): Add initial filtering to network

See #19
This commit is contained in:
James Graham
2020-03-10 10:52:20 +00:00
parent 09b7fc334b
commit 440de19c56
5 changed files with 191 additions and 103 deletions

View File

@@ -25,7 +25,26 @@ class PersonForm(forms.ModelForm):
}
class RelationshipAnswerSetForm(forms.ModelForm):
class DynamicAnswerSetBase(forms.Form):
field_class = forms.ChoiceField
field_widget = None
field_required = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for question in models.RelationshipQuestion.objects.all():
# Get choices from model and add default 'not selected' option
choices = question.choices + [['', '---------']]
field = self.field_class(label=question,
choices=choices,
widget=self.field_widget,
required=self.field_required)
self.fields['question_{}'.format(question.pk)] = field
class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
"""
Form to allow users to describe a relationship.
@@ -37,17 +56,6 @@ class RelationshipAnswerSetForm(forms.ModelForm):
'relationship',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for question in models.RelationshipQuestion.objects.all():
# Get choices from model and add default 'not selected' option
choices = question.choices + [['', '---------']]
field = forms.ChoiceField(label=question,
choices=choices)
self.fields['question_{}'.format(question.pk)] = field
def save(self, commit=True) -> models.RelationshipAnswerSet:
# Save Relationship model
self.instance = super().save(commit=commit)
@@ -62,3 +70,12 @@ class RelationshipAnswerSetForm(forms.ModelForm):
self.instance.question_answers.add(answer)
return self.instance
class NetworkFilterForm(DynamicAnswerSetBase):
"""
Form to provide filtering on the network view.
"""
field_class = forms.MultipleChoiceField
field_widget = Select2MultipleWidget
field_required = False

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-03-09 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('people', '0016_add_answer_set'),
]
operations = [
migrations.AddField(
model_name='relationshipanswerset',
name='replaced_timestamp',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]

View File

@@ -152,4 +152,11 @@ class RelationshipAnswerSet(models.Model):
question_answers = models.ManyToManyField(RelationshipQuestionChoice)
#: When were these answers collected?
timestamp = models.DateTimeField(auto_now_add=True)
timestamp = models.DateTimeField(auto_now_add=True,
editable=False)
replaced_timestamp = models.DateTimeField(blank=True, null=True,
editable=False)
def get_absolute_url(self):
return self.relationship.get_absolute_url()

View File

@@ -9,6 +9,27 @@
<hr>
<form class="form"
method="POST">
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<h3>Filter Relationships</h3>
{% load bootstrap4 %}
{% bootstrap_form form %}
</div>
<div class="col-md-6">
<h3>Filter People</h3>
</div>
</div>
{% buttons %}
<button class="btn btn-block btn-info" type="submit">Filter</button>
{% endbuttons %}
</form>
<div id="cy"
style="width: 100%; min-height: 1000px; flex-grow: 1; border: 2px solid black"></div>
@@ -16,88 +37,28 @@
{% endblock %}
{% block extra_script %}
<!--
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' }}
{{ 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>
<script type="application/javascript">
// Holder for Cytoscape.js graph - see https://js.cytoscape.org/
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.
* Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page.
*/
function get_network() {
cy = cytoscape({
// Initialise Cytoscape graph
// See https://js.cytoscape.org/ for documentation
var cy = cytoscape({
container: document.getElementById('cy'),
style: [
{
@@ -120,8 +81,34 @@
]
});
$.when(get_people_ajax()).then(function() {
$.when(get_relationships_ajax()).then(function() {
// 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.toString(),
target: 'person-' + relationship.target.toString()
}
})
}
// Optimise graph layout
var layout = cy.layout({
name: 'cose',
randomize: true,
@@ -130,9 +117,6 @@
});
layout.run();
})
});
}
$( window ).on('load', get_network());

View File

@@ -3,9 +3,10 @@ Views for displaying or manipulating models in the 'people' app.
"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django.db.models import Q
from django.urls import reverse
from django.views.generic import CreateView, DetailView, ListView, TemplateView, UpdateView
from django.utils import timezone
from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView
from rest_framework.views import APIView, Response
@@ -174,11 +175,18 @@ class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
def form_valid(self, form):
"""
Don't rebind self.object to be the result of the form - it is a :class:`RelationshipAnswerSet`.
Mark any previous answer sets as replaced.
"""
form.save()
previous_valid_answer_sets = self.relationship.answer_sets.filter(replaced_timestamp__isnull=True)
return HttpResponseRedirect(self.relationship.get_absolute_url())
response = super().form_valid(form)
# Shouldn't be more than one after initial updates after migration
for answer_set in previous_valid_answer_sets:
answer_set.replaced_timestamp = timezone.now()
answer_set.save()
return response
class PersonApiView(APIView):
@@ -207,8 +215,62 @@ class RelationshipApiView(APIView):
return Response(serializer.data)
class NetworkView(LoginRequiredMixin, TemplateView):
class NetworkView(LoginRequiredMixin, FormView):
"""
View to display relationship network.
"""
template_name = 'people/network.html'
form_class = forms.NetworkFilterForm
def get_form_kwargs(self):
"""
Add GET params to form data.
"""
kwargs = super().get_form_kwargs()
if self.request.method == 'GET':
if 'data' in kwargs:
kwargs['data'].update(self.request.GET)
else:
kwargs['data'] = self.request.GET
return kwargs
def get_context_data(self, **kwargs):
"""
Add filtered QuerySets of :class:`Person` and :class:`Relationship` to the context.
"""
context = super().get_context_data(**kwargs)
form = context['form']
at_time = timezone.now()
relationship_set = models.Relationship.objects.all()
# Filter answers to relationship questions
for key, value in form.data.items():
if key.startswith('question_') and value:
question_id = key.replace('question_', '', 1)
answer = models.RelationshipQuestionChoice.objects.get(pk=value,
question__pk=question_id)
relationship_set = relationship_set.filter(
Q(answer_sets__replaced_timestamp__gt=at_time) | Q(answer_sets__replaced_timestamp__isnull=True),
answer_sets__timestamp__lte=at_time,
answer_sets__question_answers=answer
)
context['person_set'] = serializers.PersonSerializer(
models.Person.objects.all(),
many=True
).data
context['relationship_set'] = serializers.RelationshipSerializer(
relationship_set,
many=True
).data
return context
def form_valid(self, form):
return self.render_to_response(self.get_context_data())