refactor: migrate to question sets for person qs

This means we're starting to use the same system for person questions
as for relationship questions
This commit is contained in:
James Graham
2020-11-26 12:59:30 +00:00
parent a94db2713e
commit 5035b121a6
8 changed files with 304 additions and 31 deletions

View File

@@ -21,19 +21,34 @@ class OrganisationAdmin(admin.ModelAdmin):
pass pass
@admin.register(models.Role)
class RoleAdmin(admin.ModelAdmin):
pass
@admin.register(models.Theme) @admin.register(models.Theme)
class ThemeAdmin(admin.ModelAdmin): class ThemeAdmin(admin.ModelAdmin):
pass 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) @admin.register(models.Person)
class PersonAdmin(admin.ModelAdmin): class PersonAdmin(admin.ModelAdmin):
pass inlines = [
PersonAnswerSetInline,
]
class RelationshipQuestionChoiceInline(admin.TabularInline): class RelationshipQuestionChoiceInline(admin.TabularInline):

View File

@@ -40,7 +40,6 @@ class PersonForm(forms.ModelForm):
'organisation_started_date', 'organisation_started_date',
'job_title', 'job_title',
'disciplines', 'disciplines',
'role',
'themes', 'themes',
] ]
widgets = { widgets = {

View File

@@ -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'),
),
]

View File

@@ -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'),
]

View File

View File

@@ -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

View File

@@ -1,10 +1,12 @@
import logging import logging
import typing
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField from django_countries.fields import CountryField
@@ -18,9 +20,11 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name
__all__ = [ __all__ = [
'User', 'User',
'Organisation', 'Organisation',
'Role',
'Theme', 'Theme',
'PersonQuestion',
'PersonQuestionChoice',
'Person', 'Person',
'PersonAnswerSet',
] ]
@@ -36,7 +40,7 @@ class User(AbstractUser):
""" """
return hasattr(self, 'person') return hasattr(self, 'person')
def send_welcome_email(self): def send_welcome_email(self) -> None:
"""Send a welcome email to a new user.""" """Send a welcome email to a new user."""
# Get exported data from settings.py first # Get exported data from settings.py first
context = settings_export(None) context = settings_export(None)
@@ -71,16 +75,6 @@ class Organisation(models.Model):
return self.name 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): class Theme(models.Model):
""" """
Project theme within which a :class:`Person` works. Project theme within which a :class:`Person` works.
@@ -91,6 +85,79 @@ class Theme(models.Model):
return self.name 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): class Person(models.Model):
""" """
A person may be a member of the BRECcIA core team or an external stakeholder. 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 #: Discipline(s) within which this person works
disciplines = models.CharField(max_length=255, blank=True, null=True) 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 #: Project themes within this person works
themes = models.ManyToManyField(Theme, related_name='people', blank=True) themes = models.ManyToManyField(Theme, related_name='people', blank=True)
@@ -183,8 +243,42 @@ class Person(models.Model):
return self.relationships_as_source.all().union( return self.relationships_as_source.all().union(
self.relationships_as_target.all()) self.relationships_as_target.all())
@property
def current_answers(self) -> 'PersonAnswerSet':
return self.answer_sets.last()
def get_absolute_url(self): def get_absolute_url(self):
return reverse('people:person.detail', kwargs={'pk': self.pk}) return reverse('people:person.detail', kwargs={'pk': self.pk})
def __str__(self) -> str: def __str__(self) -> str:
return self.name 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()

View File

@@ -58,11 +58,6 @@
<dd>{{ person.job_title }}</dd> <dd>{{ person.job_title }}</dd>
{% endif %} {% endif %}
{% if person.role %}
<dt>Role</dt>
<dd>{{ person.role }}</dd>
{% endif %}
{% if person.disciplines %} {% if person.disciplines %}
<dt>Discipline(s)</dt> <dt>Discipline(s)</dt>
<dd>{{ person.disciplines }}</dd> <dd>{{ person.disciplines }}</dd>
@@ -79,6 +74,33 @@
</dl> </dl>
{% endif %} {% endif %}
{% with person.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>
<p>Last updated: {{ answer_set.timestamp }}</p>
{% endwith %}
<a class="btn btn-success" <a class="btn btn-success"
href="{% url 'people:person.update' pk=person.pk %}">Update</a> href="{% url 'people:person.update' pk=person.pk %}">Update</a>