diff --git a/people/admin.py b/people/admin.py index a6f4ae9..82c6f46 100644 --- a/people/admin.py +++ b/people/admin.py @@ -21,19 +21,34 @@ class OrganisationAdmin(admin.ModelAdmin): pass -@admin.register(models.Role) -class RoleAdmin(admin.ModelAdmin): - pass - - @admin.register(models.Theme) class ThemeAdmin(admin.ModelAdmin): pass +class PersonQuestionChoiceInline(admin.TabularInline): + model = models.PersonQuestionChoice + + +@admin.register(models.PersonQuestion) +class PersonQuestionAdmin(admin.ModelAdmin): + inlines = [ + PersonQuestionChoiceInline, + ] + + +class PersonAnswerSetInline(admin.TabularInline): + model = models.PersonAnswerSet + readonly_fields = [ + 'question_answers', + ] + + @admin.register(models.Person) class PersonAdmin(admin.ModelAdmin): - pass + inlines = [ + PersonAnswerSetInline, + ] class RelationshipQuestionChoiceInline(admin.TabularInline): diff --git a/people/forms.py b/people/forms.py index 7ddf909..2b9850f 100644 --- a/people/forms.py +++ b/people/forms.py @@ -40,7 +40,6 @@ class PersonForm(forms.ModelForm): 'organisation_started_date', 'job_title', 'disciplines', - 'role', 'themes', ] widgets = { diff --git a/people/migrations/0022_refactor_person_questions.py b/people/migrations/0022_refactor_person_questions.py new file mode 100644 index 0000000..2da386b --- /dev/null +++ b/people/migrations/0022_refactor_person_questions.py @@ -0,0 +1,55 @@ +# Generated by Django 2.2.10 on 2020-11-23 14:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0021_refactor_person_disciplines'), + ] + + operations = [ + migrations.CreateModel( + name='PersonQuestion', + 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)), + ('order', models.SmallIntegerField(default=0)), + ], + options={ + 'ordering': ['order', 'text'], + }, + ), + migrations.CreateModel( + name='PersonQuestionChoice', + 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.PersonQuestion')), + ], + options={ + 'ordering': ['question__order', 'order', 'text'], + }, + ), + migrations.CreateModel( + name='PersonAnswerSet', + 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)), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answer_sets', to='people.Person')), + ('question_answers', models.ManyToManyField(to='people.PersonQuestionChoice')), + ], + options={ + 'ordering': ['timestamp'], + }, + ), + migrations.AddConstraint( + model_name='personquestionchoice', + constraint=models.UniqueConstraint(fields=('question', 'text'), name='unique_question_answer'), + ), + ] diff --git a/people/migrations/0023_remove_person_role.py b/people/migrations/0023_remove_person_role.py new file mode 100644 index 0000000..f67c4a0 --- /dev/null +++ b/people/migrations/0023_remove_person_role.py @@ -0,0 +1,65 @@ +# Generated by Django 2.2.10 on 2020-11-25 15:50 + +from django.core.exceptions import ObjectDoesNotExist +from django.db import migrations +from django.utils import timezone + +from .utils.question_sets import port_question + + +def migrate_forward(apps, schema_editor): + Person = apps.get_model('people', 'Person') + Role = apps.get_model('people', 'Role') + + role_question = port_question(apps, 'Role', + Role.objects.values_list('name', flat=True)) + + for person in Person.objects.all(): + try: + prev_set = person.answer_sets.latest('timestamp') + + except ObjectDoesNotExist: + prev_set = None + + try: + + answer_set = person.answer_sets.create() + answer_set.question_answers.add( + role_question.answers.get(text=person.role.name)) + + prev_set.replaced_timestamp = timezone.datetime.now() + + except AttributeError: + pass + + +def migrate_backward(apps, schema_editor): + Person = apps.get_model('people', 'Person') + Role = apps.get_model('people', 'Role') + + for person in Person.objects.all(): + try: + current_answers = person.answer_sets.latest('timestamp') + role_answer = current_answers.question_answers.get( + question__text='Role') + person.role, _ = Role.objects.get_or_create(name=role_answer.text) + person.save() + + except ObjectDoesNotExist: + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0022_refactor_person_questions'), + ] + + operations = [ + migrations.RunPython(migrate_forward, migrate_backward), + migrations.RemoveField( + model_name='person', + name='role', + ), + migrations.DeleteModel('Role'), + ] diff --git a/people/migrations/utils/__init__.py b/people/migrations/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/people/migrations/utils/question_sets.py b/people/migrations/utils/question_sets.py new file mode 100644 index 0000000..6dfa082 --- /dev/null +++ b/people/migrations/utils/question_sets.py @@ -0,0 +1,23 @@ + +import typing + +from django.core.exceptions import ObjectDoesNotExist + + +def port_question(apps, question_text: str, + answers_text: typing.Iterable[str]): + PersonQuestion = apps.get_model('people', 'PersonQuestion') + + try: + prev_question = PersonQuestion.objects.filter( + text=question_text).latest('version') + question = PersonQuestion.objects.create( + text=question_text, version=prev_question.version + 1) + + except ObjectDoesNotExist: + question = PersonQuestion.objects.create(text=question_text) + + for answer_text in answers_text: + question.answers.get_or_create(text=answer_text) + + return question diff --git a/people/models/person.py b/people/models/person.py index 6127f66..c7f0ec5 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -1,10 +1,12 @@ import logging +import typing from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField @@ -18,9 +20,11 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name __all__ = [ 'User', 'Organisation', - 'Role', 'Theme', + 'PersonQuestion', + 'PersonQuestionChoice', 'Person', + 'PersonAnswerSet', ] @@ -36,7 +40,7 @@ class User(AbstractUser): """ return hasattr(self, 'person') - def send_welcome_email(self): + def send_welcome_email(self) -> None: """Send a welcome email to a new user.""" # Get exported data from settings.py first context = settings_export(None) @@ -71,16 +75,6 @@ class Organisation(models.Model): return self.name -class Role(models.Model): - """ - Role which a :class:`Person` holds within the project. - """ - name = models.CharField(max_length=255, blank=False, null=False) - - def __str__(self) -> str: - return self.name - - class Theme(models.Model): """ Project theme within which a :class:`Person` works. @@ -91,6 +85,79 @@ class Theme(models.Model): return self.name +class PersonQuestion(models.Model): + """Question which may be asked about a person.""" + class Meta: + ordering = [ + 'order', + 'text', + ] + + #: Version number of this question - to allow modification without invalidating existing data + version = models.PositiveSmallIntegerField(default=1, + blank=False, null=False) + + #: Text of question + text = models.CharField(max_length=255, + blank=False, null=False) + + #: Position of this question in the list + order = models.SmallIntegerField(default=0, + blank=False, null=False) + + @property + def choices(self) -> typing.List[typing.List[str]]: + """ + Convert the :class:`PersonQuestionChoice`s for this question into Django choices. + """ + return [ + [choice.pk, str(choice)] for choice in self.answers.all() + ] + + @property + def slug(self) -> str: + return slugify(self.text) + + def __str__(self) -> str: + return self.text + + +class PersonQuestionChoice(models.Model): + """ + Allowed answer to a :class:`PersonQuestion`. + """ + class Meta: + constraints = [ + models.UniqueConstraint(fields=['question', 'text'], + name='unique_question_answer') + ] + ordering = [ + 'question__order', + 'order', + 'text', + ] + + #: Question to which this answer belongs + question = models.ForeignKey(PersonQuestion, related_name='answers', + on_delete=models.CASCADE, + blank=False, null=False) + + #: Text of answer + text = models.CharField(max_length=255, + blank=False, null=False) + + #: Position of this answer in the list + order = models.SmallIntegerField(default=0, + blank=False, null=False) + + @property + def slug(self) -> str: + return slugify(self.text) + + def __str__(self) -> str: + return self.text + + class Person(models.Model): """ A person may be a member of the BRECcIA core team or an external stakeholder. @@ -168,13 +235,6 @@ class Person(models.Model): #: Discipline(s) within which this person works disciplines = models.CharField(max_length=255, blank=True, null=True) - #: Role this person holds within the project - role = models.ForeignKey(Role, - on_delete=models.PROTECT, - related_name='holders', - blank=True, - null=True) - #: Project themes within this person works themes = models.ManyToManyField(Theme, related_name='people', blank=True) @@ -183,8 +243,42 @@ class Person(models.Model): return self.relationships_as_source.all().union( self.relationships_as_target.all()) + @property + def current_answers(self) -> 'PersonAnswerSet': + return self.answer_sets.last() + def get_absolute_url(self): return reverse('people:person.detail', kwargs={'pk': self.pk}) def __str__(self) -> str: return self.name + + +class PersonAnswerSet(models.Model): + """ + The answers to the person questions at a particular point in time. + """ + + class Meta: + ordering = [ + 'timestamp', + ] + + #: Person to which this answer set belongs + person = models.ForeignKey(Person, + on_delete=models.CASCADE, + related_name='answer_sets', + blank=False, null=False) + + #: Answers to :class:`PersonQuestion`s + question_answers = models.ManyToManyField(PersonQuestionChoice) + + #: When were these answers collected? + 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.person.get_absolute_url() diff --git a/people/templates/people/person/detail.html b/people/templates/people/person/detail.html index 5e49e83..27f7fd4 100644 --- a/people/templates/people/person/detail.html +++ b/people/templates/people/person/detail.html @@ -58,11 +58,6 @@
| Question | +Answer | +
|---|---|
| {{ answer.question }} | +{{ answer }} | +
| No records | +
Last updated: {{ answer_set.timestamp }}
+ {% endwith %} + Update