diff --git a/people/admin.py b/people/admin.py index 3aa835f..b8bda69 100644 --- a/people/admin.py +++ b/people/admin.py @@ -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 diff --git a/people/forms.py b/people/forms.py index f458689..dcacc85 100644 --- a/people/forms.py +++ b/people/forms.py @@ -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. diff --git a/people/migrations/0039_add_organisation_relationship.py b/people/migrations/0039_add_organisation_relationship.py new file mode 100644 index 0000000..461d897 --- /dev/null +++ b/people/migrations/0039_add_organisation_relationship.py @@ -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'), + ), + ] diff --git a/people/migrations/0040_person_organisation_relationship_targets.py b/people/migrations/0040_person_organisation_relationship_targets.py new file mode 100644 index 0000000..c7942c7 --- /dev/null +++ b/people/migrations/0040_person_organisation_relationship_targets.py @@ -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'), + ), + ] diff --git a/people/models/person.py b/people/models/person.py index 7489f97..4143a65 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -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( diff --git a/people/models/relationship.py b/people/models/relationship.py index f6cdbe0..8b41553 100644 --- a/people/models/relationship.py +++ b/people/models/relationship.py @@ -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() diff --git a/people/templates/people/organisation-relationship/detail.html b/people/templates/people/organisation-relationship/detail.html new file mode 100644 index 0000000..3ed68d3 --- /dev/null +++ b/people/templates/people/organisation-relationship/detail.html @@ -0,0 +1,72 @@ +{% extends 'base.html' %} + +{% block content %} + + +

Organisation Relationship

+ +
+ +
+
+

Source

+

{{ relationship.source }}

+ + Profile +
+ +
+ +
+

Target

+

{{ relationship.target }}

+ + Profile +
+
+ +
+ + Update + + {% with relationship.current_answers as answer_set %} + + + + + + + + + + {% for answer in answer_set.question_answers.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
QuestionAnswer
{{ answer.question }}{{ answer }}
No records
+ + Last updated: {{ answer_set.timestamp }} + {% endwith %} + +{% endblock %} diff --git a/people/templates/people/organisation/list.html b/people/templates/people/organisation/list.html index bc776c3..cf2961a 100644 --- a/people/templates/people/organisation/list.html +++ b/people/templates/people/organisation/list.html @@ -28,6 +28,19 @@ Details + + {% if organisation.pk in existing_relationships %} + Update Relationship + + + {% else %} + New Relationship + + {% endif %} diff --git a/people/urls.py b/people/urls.py index 955d343..b61a5a7 100644 --- a/people/urls.py +++ b/people/urls.py @@ -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//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//relationships/create', + views.relationship.OrganisationRelationshipCreateView.as_view(), + name='organisation.relationship.create'), + + path('organisation-relationships/', + views.relationship.OrganisationRelationshipDetailView.as_view(), + name='organisation.relationship.detail'), + + path('organisation-relationships//update', + views.relationship.OrganisationRelationshipUpdateView.as_view(), + name='organisation.relationship.update'), + + ############ + # Data views path('map', views.person.PersonMapView.as_view(), name='person.map'), diff --git a/people/views/organisation.py b/people/views/organisation.py index 6054ac9..ae4d14b 100644 --- a/people/views/organisation.py +++ b/people/views/organisation.py @@ -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`.""" diff --git a/people/views/relationship.py b/people/views/relationship.py index f11f147..d9e2a51 100644 --- a/people/views/relationship.py +++ b/people/views/relationship.py @@ -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