Merge branch 'master' into dev

This commit is contained in:
James Graham
2020-03-27 11:15:37 +00:00
9 changed files with 230 additions and 87 deletions

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# BRECcIA-Mapper (provisional name)
The canonical source for this project is hosted on [GitHub](https://github.com/Southampton-RSG/breccia-mapper),
please log any issues there.
BRECcIA-Mapper is a web app to collect and explore data about the relationships between researchers and their stakeholders on large-scale, multi-site research projects.
TODO motivations, usage, license
## Technology
This project is written in Python using the popular [Django](https://www.djangoproject.com/) framework.
An [Ansible](https://www.ansible.com/) playbook is provided which is designed for deployment on RHEL7 or CentOS7 Linux systems. This installs and configures:
- MySQL
- Nginx
- Django + BRECcIA-Mapper
TODO deployment instructions

View File

@@ -134,6 +134,16 @@ DBBACKUP_STORAGE_OPTIONS = {
} }
# Django REST Framework
# https://www.django-rest-framework.org/
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
# Password validation # Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators

View File

@@ -73,6 +73,10 @@
<a href="{% url 'activities:activity.list' %}" class="nav-link">Activities</a> <a href="{% url 'activities:activity.list' %}" class="nav-link">Activities</a>
</li> </li>
<li class="nav-item">
<a href="{% url 'people:network' %}" class="nav-link">Network</a>
</li>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
<li class="nav-item"> <li class="nav-item">
<a href="{% url 'admin:index' %}" class="nav-link">Admin</a> <a href="{% url 'admin:index' %}" class="nav-link">Admin</a>

View File

@@ -23,9 +23,28 @@ class PersonForm(forms.ModelForm):
'country_of_residence': Select2Widget(), 'country_of_residence': Select2Widget(),
'themes': Select2MultipleWidget(), 'themes': Select2MultipleWidget(),
} }
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): class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
""" """
Form to allow users to describe a relationship. Form to allow users to describe a relationship.
@@ -37,17 +56,6 @@ class RelationshipAnswerSetForm(forms.ModelForm):
'relationship', '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: def save(self, commit=True) -> models.RelationshipAnswerSet:
# Save Relationship model # Save Relationship model
self.instance = super().save(commit=commit) self.instance = super().save(commit=commit)
@@ -62,3 +70,12 @@ class RelationshipAnswerSetForm(forms.ModelForm):
self.instance.question_answers.add(answer) self.instance.question_answers.add(answer)
return self.instance 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) question_answers = models.ManyToManyField(RelationshipQuestionChoice)
#: When were these answers collected? #: 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> <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" <div id="cy"
style="width: 100%; min-height: 1000px; flex-grow: 1; border: 2px solid black"></div> style="width: 100%; min-height: 1000px; flex-grow: 1; border: 2px solid black"></div>
@@ -16,37 +37,54 @@
{% endblock %} {% endblock %}
{% block extra_script %} {% 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" <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.14.0/cytoscape.min.js"
integrity="sha256-rI7zH7xDqO306nxvXUw9gqkeBpvvmddDdlXJjJM7rEM=" integrity="sha256-rI7zH7xDqO306nxvXUw9gqkeBpvvmddDdlXJjJM7rEM="
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script type="application/javascript"> <script type="application/javascript">
var cy;
/** /**
* Get all :class:`Person` records from people:person.api.list endpoint. * Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page.
*
* @returns JQuery Promise from AJAX
*/ */
function get_people_ajax(){ function get_network() {
return $.ajax({ // Initialise Cytoscape graph
url: '{% url "people:person.api.list" %}', // See https://js.cytoscape.org/ for documentation
success: success_people_ajax, var cy = cytoscape({
error: function (xhr, status, error) { container: document.getElementById('cy'),
console.error(error); 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
* Add nodes to Cytoscape network from :class:`Person` JSON. var person_set = JSON.parse(document.getElementById('person-set-data').textContent);
*
* @param data: JSON representation of people for (var person of person_set) {
* @param status: unused
* @param xhr: unused
*/
function success_people_ajax(data, status, xhr) {
for (var person of data) {
cy.add({ cy.add({
group: 'nodes', group: 'nodes',
data: { data: {
@@ -55,32 +93,11 @@
} }
}) })
} }
}
/** // Load relationships and add to graph
* Get all :class:`Relationship` records from people:relationship.api.list endpoint. var relationship_set = JSON.parse(document.getElementById('relationship-set-data').textContent);
*
* @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);
}
});
}
/** for (var relationship of relationship_set) {
* 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({ cy.add({
group: 'edges', group: 'edges',
data: { data: {
@@ -90,29 +107,16 @@
} }
}) })
} }
}
/** // Optimise graph layout
* Populate a Cytoscape network from :class:`Person` and :class:`Relationship` API. var layout = cy.layout({
*/ name: 'cose',
function get_network() { randomize: true,
cy = cytoscape({ animate: false,
container: document.getElementById('cy') idealEdgeLength: function(edge) {return 64;}
});
$.when(get_people_ajax()).then(function() {
get_relationships_ajax()
}).then(function() {
var layout = cy.layout({
name: 'circle',
randomize: true,
animate: false
});
layout.run();
}); });
layout.run();
} }
$( window ).on('load', get_network()); $( window ).on('load', get_network());

View File

@@ -2,9 +2,11 @@
Views for displaying or manipulating models in the 'people' app. Views for displaying or manipulating models in the 'people' app.
""" """
from django.http import HttpResponseRedirect from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.urls import reverse 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 from rest_framework.views import APIView, Response
@@ -123,7 +125,7 @@ class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView):
return context return context
def get_success_url(self): def get_success_url(self):
return reverse('people:relationship.update', kwargs={'pk': self.object.pk}) return reverse('people:relationship.update', kwargs={'relationship_pk': self.object.pk})
class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView): class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
@@ -173,11 +175,18 @@ class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
def form_valid(self, form): 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): class PersonApiView(APIView):
@@ -206,8 +215,62 @@ class RelationshipApiView(APIView):
return Response(serializer.data) return Response(serializer.data)
class NetworkView(TemplateView): class NetworkView(LoginRequiredMixin, FormView):
""" """
View to display relationship network. View to display relationship network.
""" """
template_name = 'people/network.html' 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())

View File

@@ -1,6 +1,7 @@
[uwsgi] [uwsgi]
project = {{ project_name }} project = {{ project_name }}
uid = {{ web_user }} uid = {{ web_user }}
gid = {{ web_group }}
base = /var/www base = /var/www
chdir = %(base)/%(project) chdir = %(base)/%(project)