feat: add relationships from person to org

Resolves #77
See #78
This commit is contained in:
James Graham
2021-03-02 09:03:10 +00:00
parent 6d5188af72
commit 936a375992
11 changed files with 454 additions and 30 deletions

View File

@@ -85,3 +85,19 @@ class RelationshipQuestionAdmin(admin.ModelAdmin):
@admin.register(models.Relationship)
class RelationshipAdmin(admin.ModelAdmin):
pass
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):
pass

View File

@@ -239,6 +239,48 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
return self.instance
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 NetworkFilterForm(DynamicAnswerSetBase):
"""
Form to provide filtering on the network view.

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

@@ -226,6 +226,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(

View File

@@ -2,11 +2,10 @@
Models describing relationships between people.
"""
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,10 @@ 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.
"""
class Meta:
constraints = [
models.UniqueConstraint(fields=['source', 'target'],
@@ -57,9 +46,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,15 +58,6 @@ 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)
@@ -100,8 +82,7 @@ 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):
@@ -119,3 +100,78 @@ 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)
#: 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) -> 'OrganisationRelationshipAnswerSet':
return self.answer_sets.last()
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."""
#: 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

@@ -0,0 +1,72 @@
{% 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 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>
<a class="btn btn-success"
href="{% url 'people:organisation.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>
<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

@@ -28,6 +28,19 @@
<td>
<a class="btn btn-sm btn-info"
href="{% url 'people:organisation.detail' pk=organisation.pk %}">Details</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 %}">New Relationship
</a>
{% endif %}
</td>
</tr>

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'),
@@ -54,6 +60,22 @@ urlpatterns = [
views.relationship.RelationshipUpdateView.as_view(),
name='relationship.update'),
################################
# 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:relationship_pk>/update',
views.relationship.OrganisationRelationshipUpdateView.as_view(),
name='organisation.relationship.update'),
############
# Data views
path('map',
views.person.PersonMapView.as_view(),
name='person.map'),

View File

@@ -19,6 +19,16 @@ class OrganisationListView(LoginRequiredMixin, ListView):
model = models.Organisation
template_name = 'people/organisation/list.html'
def get_context_data(self,
**kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
context = super().get_context_data(**kwargs)
context['existing_relationships'] = set(
self.request.user.person.organisation_relationship_targets.
values_list('pk', flat=True))
return context
class OrganisationDetailView(LoginRequiredMixin, DetailView):
"""View displaying details of a :class:`Organisation`."""

View File

@@ -24,7 +24,8 @@ class RelationshipCreateView(LoginRequiredMixin, RedirectView):
Redirects to a form containing the :class:`RelationshipQuestion`s.
"""
def get_redirect_url(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Optional[str]:
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)
@@ -96,3 +97,94 @@ class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
answer_set.save()
return response
class OrganisationRelationshipDetailView(permissions.UserIsLinkedPersonMixin,
DetailView):
"""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={'relationship_pk': relationship.pk})
class OrganisationRelationshipUpdateView(permissions.UserIsLinkedPersonMixin,
CreateView):
"""
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.OrganisationRelationshipAnswerSet
template_name = 'people/relationship/update.html'
form_class = forms.OrganisationRelationshipAnswerSetForm
def get_test_person(self) -> models.Person:
"""
Get the person instance which should be used for access control checks.
"""
relationship = models.OrganisationRelationship.objects.get(
pk=self.kwargs.get('relationship_pk'))
return relationship.source
def get(self, request, *args, **kwargs):
self.relationship = models.OrganisationRelationship.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.OrganisationRelationship.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):
"""
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(
pk=self.object.pk):
answer_set.replaced_timestamp = now_date
answer_set.save()
return response