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 %} + + +
| Question | +Answer | +
|---|---|
| {{ answer.question }} | +{{ answer }} | +
| No records | +