From adf12442a4c6f055b00807988af90d979bca9a0e Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 24 Feb 2021 12:08:52 +0000 Subject: [PATCH] feat: add organisation questions and admin pages See #76 --- people/admin.py | 22 ++++- .../0035_add_organisation_questions.py | 61 +++++++++++++ people/models/person.py | 91 +++++++++++++++++++ 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 people/migrations/0035_add_organisation_questions.py diff --git a/people/admin.py b/people/admin.py index 82c6f46..3aa835f 100644 --- a/people/admin.py +++ b/people/admin.py @@ -16,9 +16,29 @@ class CustomUserAdmin(UserAdmin): ) # yapf: disable +class OrganisationQuestionChoiceInline(admin.TabularInline): + model = models.OrganisationQuestionChoice + + +@admin.register(models.OrganisationQuestion) +class OrganisationQuestionAdmin(admin.ModelAdmin): + inlines = [ + OrganisationQuestionChoiceInline, + ] + + +class OrganisationAnswerSetInline(admin.TabularInline): + model = models.OrganisationAnswerSet + readonly_fields = [ + 'question_answers', + ] + + @admin.register(models.Organisation) class OrganisationAdmin(admin.ModelAdmin): - pass + inlines = [ + OrganisationAnswerSetInline, + ] @admin.register(models.Theme) diff --git a/people/migrations/0035_add_organisation_questions.py b/people/migrations/0035_add_organisation_questions.py new file mode 100644 index 0000000..19fccaf --- /dev/null +++ b/people/migrations/0035_add_organisation_questions.py @@ -0,0 +1,61 @@ +# Generated by Django 2.2.10 on 2021-02-23 13:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0034_remove_personanswerset_disciplines'), + ] + + operations = [ + migrations.CreateModel( + name='OrganisationQuestion', + 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)), + ('is_multiple_choice', models.BooleanField(default=False)), + ('allow_free_text', models.BooleanField(default=False)), + ('order', models.SmallIntegerField(default=0)), + ('answer_is_public', models.BooleanField(default=True, help_text='Should answers to this question be displayed on profiles?')), + ], + options={ + 'ordering': ['order', 'text'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OrganisationQuestionChoice', + 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.OrganisationQuestion')), + ], + options={ + 'ordering': ['question__order', 'order', 'text'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OrganisationAnswerSet', + 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)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_sets', to='people.Organisation')), + ('question_answers', models.ManyToManyField(to='people.OrganisationQuestionChoice')), + ], + options={ + 'ordering': ['timestamp'], + 'abstract': False, + }, + ), + migrations.AddConstraint( + model_name='organisationquestionchoice', + constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'), + ), + ] diff --git a/people/models/person.py b/people/models/person.py index 6543491..015800f 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -17,7 +17,10 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name __all__ = [ 'User', + 'OrganisationQuestion', + 'OrganisationQuestionChoice', 'Organisation', + 'OrganisationAnswerSet', 'Theme', 'PersonQuestion', 'PersonQuestionChoice', @@ -66,6 +69,26 @@ class User(AbstractUser): self.username) +class OrganisationQuestion(Question): + """Question which may be asked about a Organisation.""" + #: Should answers to this question be displayed on public profiles? + answer_is_public = models.BooleanField( + help_text='Should answers to this question be displayed on profiles?', + default=True, + blank=False, + null=False) + + +class OrganisationQuestionChoice(QuestionChoice): + """Allowed answer to a :class:`OrganisationQuestion`.""" + #: Question to which this answer belongs + question = models.ForeignKey(OrganisationQuestion, + related_name='answers', + on_delete=models.CASCADE, + blank=False, + null=False) + + class Organisation(models.Model): """Organisation to which a :class:`Person` belongs.""" name = models.CharField(max_length=255, blank=False, null=False) @@ -79,10 +102,78 @@ class Organisation(models.Model): def __str__(self) -> str: return self.name + @property + def current_answers(self) -> 'OrganisationAnswerSet': + return self.answer_sets.last() + def get_absolute_url(self): return reverse('people:organisation.detail', kwargs={'pk': self.pk}) +class OrganisationAnswerSet(AnswerSet): + """The answers to the organisation questions at a particular point in time.""" + #: Organisation to which this answer set belongs + organisation = models.ForeignKey(Organisation, + on_delete=models.CASCADE, + related_name='answer_sets', + blank=False, + null=False) + + #: Answers to :class:`OrganisationQuestion`s + question_answers = models.ManyToManyField(OrganisationQuestionChoice) + + def public_answers(self) -> models.QuerySet: + """Get answers to questions which are public.""" + return self.question_answers.filter(question__answer_is_public=True) + + def as_dict(self): + """Get the answers from this set as a dictionary for use in Form.initial.""" + exclude_fields = { + 'id', + 'timestemp', + 'replaced_timestamp', + 'organisation_id', + 'question_answers', + } + + def field_value_repr(field): + """Get the representation of a field's value as required by Form.initial.""" + attr_val = getattr(self, field.attname) + + # Relation fields need to return PKs + if isinstance(field, models.ManyToManyField): + return [obj.pk for obj in attr_val.all()] + + # But foreign key fields are a PK already so no extra work + + return attr_val + + answers = { + # Foreign key fields have _id at end in model _meta but don't in forms + field.attname.rstrip('_id'): field_value_repr(field) + for field in self._meta.get_fields() + if field.attname not in exclude_fields + } + + for answer in self.question_answers.all(): + question = answer.question + field_name = f'question_{question.pk}' + + if question.is_multiple_choice: + if field_name not in answers: + answers[field_name] = [] + + answers[field_name].append(answer.pk) + + else: + answers[field_name] = answer.pk + + return answers + + def get_absolute_url(self): + return self.organisation.get_absolute_url() + + class Theme(models.Model): """ Project theme within which a :class:`Person` works.