mirror of
https://github.com/Southampton-RSG/breccia-mapper.git
synced 2026-03-03 03:17:07 +00:00
Merge branch 'master' into dev
This commit is contained in:
19
README.md
Normal file
19
README.md
Normal 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
|
||||
@@ -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
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
<a href="{% url 'activities:activity.list' %}" class="nav-link">Activities</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'people:network' %}" class="nav-link">Network</a>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'admin:index' %}" class="nav-link">Admin</a>
|
||||
|
||||
@@ -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
|
||||
|
||||
18
people/migrations/0017_answerset_replaced_timestamp.py
Normal file
18
people/migrations/0017_answerset_replaced_timestamp.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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,37 +37,54 @@
|
||||
{% 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">
|
||||
var cy;
|
||||
|
||||
/**
|
||||
* Get all :class:`Person` records from people:person.api.list endpoint.
|
||||
*
|
||||
* @returns JQuery Promise from AJAX
|
||||
* Populate a Cytoscape network from :class:`Person` and :class:`Relationship` JSON embedded in page.
|
||||
*/
|
||||
function get_people_ajax(){
|
||||
return $.ajax({
|
||||
url: '{% url "people:person.api.list" %}',
|
||||
success: success_people_ajax,
|
||||
error: function (xhr, status, error) {
|
||||
console.error(error);
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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: {
|
||||
@@ -55,32 +93,11 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Load relationships and add to graph
|
||||
var relationship_set = JSON.parse(document.getElementById('relationship-set-data').textContent);
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
for (var relationship of relationship_set) {
|
||||
cy.add({
|
||||
group: 'edges',
|
||||
data: {
|
||||
@@ -90,29 +107,16 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
// Optimise graph layout
|
||||
var layout = cy.layout({
|
||||
name: 'cose',
|
||||
randomize: true,
|
||||
animate: false,
|
||||
idealEdgeLength: function(edge) {return 64;}
|
||||
});
|
||||
|
||||
layout.run();
|
||||
}
|
||||
|
||||
$( window ).on('load', get_network());
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
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.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
|
||||
|
||||
@@ -123,7 +125,7 @@ class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView):
|
||||
return context
|
||||
|
||||
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):
|
||||
@@ -173,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):
|
||||
@@ -206,8 +215,62 @@ class RelationshipApiView(APIView):
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class NetworkView(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())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[uwsgi]
|
||||
project = {{ project_name }}
|
||||
uid = {{ web_user }}
|
||||
gid = {{ web_group }}
|
||||
base = /var/www
|
||||
|
||||
chdir = %(base)/%(project)
|
||||
|
||||
Reference in New Issue
Block a user