Merge remote-tracking branch 'origin/dev'

This commit is contained in:
James Graham
2020-12-07 17:05:15 +00:00
22 changed files with 918 additions and 279 deletions

View File

@@ -19,7 +19,11 @@ from django.urls import include, path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/',
admin.site.urls),
path('select2/',
include('django_select2.urls')),
path('', path('',
include('django.contrib.auth.urls')), include('django.contrib.auth.urls')),
@@ -36,4 +40,4 @@ urlpatterns = [
path('', path('',
include('activities.urls')), include('activities.urls')),
] ] # yapf: disable

View File

@@ -2,3 +2,9 @@ from . import (
activities, activities,
people people
) )
__all__ = [
'activities',
'people',
]

View File

@@ -4,3 +4,10 @@ from . import (
activities, activities,
people people
) )
__all__ = [
'activities',
'people',
'ExportListView',
]

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

@@ -8,7 +8,7 @@ from django import forms
from django.forms.widgets import SelectDateWidget from django.forms.widgets import SelectDateWidget
from django.utils import timezone from django.utils import timezone
from django_select2.forms import Select2Widget, Select2MultipleWidget from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget
from . import models from . import models
@@ -25,22 +25,58 @@ def get_date_year_range() -> typing.Iterable[int]:
class PersonForm(forms.ModelForm): class PersonForm(forms.ModelForm):
""" """Form for creating / updating an instance of :class:`Person`."""
Form for creating / updating an instance of :class:`Person`.
"""
class Meta: class Meta:
model = models.Person model = models.Person
fields = [ fields = [
'name', 'name',
'gender', ]
'age_group',
class RelationshipForm(forms.Form):
target = forms.ModelChoiceField(
models.Person.objects.all(),
widget=ModelSelect2Widget(search_fields=['name__icontains']))
class DynamicAnswerSetBase(forms.Form):
field_class = forms.ModelChoiceField
field_widget = None
field_required = True
question_model = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for question in self.question_model.objects.all():
field_class = self.field_class
field_widget = self.field_widget
if question.is_multiple_choice:
field_class = forms.ModelMultipleChoiceField
field_widget = Select2MultipleWidget
field = field_class(label=question,
queryset=question.answers,
widget=field_widget,
required=self.field_required)
self.fields['question_{}'.format(question.pk)] = field
class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
"""Form for variable person attributes.
Dynamic fields inspired by https://jacobian.org/2010/feb/28/dynamic-form-generation/
"""
class Meta:
model = models.PersonAnswerSet
fields = [
'nationality', 'nationality',
'country_of_residence', 'country_of_residence',
'organisation', 'organisation',
'organisation_started_date', 'organisation_started_date',
'job_title', 'job_title',
'disciplines', 'disciplines',
'role',
'themes', 'themes',
] ]
widgets = { widgets = {
@@ -53,27 +89,24 @@ class PersonForm(forms.ModelForm):
'If you don\'t know the exact date, an approximate date is okay.', 'If you don\'t know the exact date, an approximate date is okay.',
} }
def __init__(self, *args, **kwargs): question_model = models.PersonQuestion
super().__init__(*args, **kwargs)
self.fields['organisation_started_date'].widget = SelectDateWidget( def save(self, commit=True) -> models.PersonAnswerSet:
years=get_date_year_range()) # Save Relationship model
self.instance = super().save(commit=commit)
if commit:
# Save answers to relationship questions
for key, value in self.cleaned_data.items():
if key.startswith('question_') and value:
try:
self.instance.question_answers.add(value)
class DynamicAnswerSetBase(forms.Form): except TypeError:
field_class = forms.ModelChoiceField # Value is a QuerySet - multiple choice question
field_widget = None self.instance.question_answers.add(*value.all())
field_required = True
def __init__(self, *args, **kwargs): return self.instance
super().__init__(*args, **kwargs)
for question in models.RelationshipQuestion.objects.all():
field = self.field_class(label=question,
queryset=question.answers,
widget=self.field_widget,
required=self.field_required)
self.fields['question_{}'.format(question.pk)] = field
class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase): class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
@@ -88,6 +121,8 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
'relationship', 'relationship',
] ]
question_model = models.RelationshipQuestion
def save(self, commit=True) -> models.RelationshipAnswerSet: def save(self, commit=True) -> models.RelationshipAnswerSet:
# Save Relationship model # Save Relationship model
self.instance = super().save(commit=commit) self.instance = super().save(commit=commit)
@@ -96,8 +131,13 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
# Save answers to relationship questions # Save answers to relationship questions
for key, value in self.cleaned_data.items(): for key, value in self.cleaned_data.items():
if key.startswith('question_') and value: if key.startswith('question_') and value:
try:
self.instance.question_answers.add(value) 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 return self.instance
@@ -108,6 +148,7 @@ class NetworkFilterForm(DynamicAnswerSetBase):
field_class = forms.ModelMultipleChoiceField field_class = forms.ModelMultipleChoiceField
field_widget = Select2MultipleWidget field_widget = Select2MultipleWidget
field_required = False field_required = False
question_model = models.RelationshipQuestion
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

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

@@ -0,0 +1,120 @@
# Generated by Django 2.2.10 on 2020-11-26 13:03
from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations
from backports.db.models.enums import TextChoices
from .utils.question_sets import port_question
class GenderChoices(TextChoices):
MALE = 'M', 'Male'
FEMALE = 'F', 'Female'
OTHER = 'O', 'Other'
PREFER_NOT_TO_SAY = 'N', 'Prefer not to say'
class AgeGroupChoices(TextChoices):
LTE_25 = '<=25', '25 or under'
BETWEEN_26_30 = '26-30', '26-30'
BETWEEN_31_35 = '31-35', '31-35'
BETWEEN_36_40 = '36-40', '36-40'
BETWEEN_41_45 = '41-45', '41-45'
BETWEEN_46_50 = '46-50', '46-50'
BETWEEN_51_55 = '51-55', '51-55'
BETWEEN_56_60 = '56-60', '56-60'
GTE_61 = '>=61', '61 or older'
PREFER_NOT_TO_SAY = 'N', 'Prefer not to say'
def migrate_forward(apps, schema_editor):
Person = apps.get_model('people', 'Person')
gender_question = port_question(apps, 'Gender', GenderChoices.labels)
age_question = port_question(apps, 'Age', AgeGroupChoices.labels)
for person in Person.objects.all():
try:
answer_set = person.answer_sets.latest('timestamp')
except ObjectDoesNotExist:
answer_set = person.answer_sets.create()
try:
gender = [
item for item in GenderChoices if item.value == person.gender
][0]
answer_set.question_answers.filter(
question__text=gender_question.text).delete()
answer_set.question_answers.add(
gender_question.answers.get(text__iexact=gender.label))
except (AttributeError, IndexError):
pass
try:
age = [
item for item in AgeGroupChoices
if item.value == person.age_group
][0]
answer_set.question_answers.filter(
question__text=age_question.text).delete()
answer_set.question_answers.add(
age_question.answers.get(text__iexact=age.label))
except (AttributeError, IndexError):
pass
def migrate_backward(apps, schema_editor):
Person = apps.get_model('people', 'Person')
for person in Person.objects.all():
try:
current_answers = person.answer_sets.latest('timestamp')
age_answer = current_answers.question_answers.get(
question__text='Age')
person.age_group = [
item for item in AgeGroupChoices
if item.label == age_answer.text
][0].value
person.save()
except ObjectDoesNotExist:
pass
try:
current_answers = person.answer_sets.latest('timestamp')
gender_answer = current_answers.question_answers.get(
question__text='Gender')
person.gender = [
item for item in GenderChoices
if item.label == gender_answer.text
][0].value
person.save()
except ObjectDoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
('people', '0023_remove_person_role'),
]
operations = [
migrations.RunPython(migrate_forward, migrate_backward),
migrations.RemoveField(
model_name='person',
name='age_group',
),
migrations.RemoveField(
model_name='person',
name='gender',
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 2.2.10 on 2020-11-27 08:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('people', '0024_remove_age_gender'),
]
operations = [
migrations.RemoveConstraint(
model_name='relationship',
name='unique_relationship',
),
migrations.RenameField(
model_name='relationship',
old_name='target',
new_name='target_person',
),
migrations.AddConstraint(
model_name='relationship',
constraint=models.UniqueConstraint(fields=('source', 'target_person'), name='unique_relationship'),
),
]

View File

@@ -0,0 +1,148 @@
# Generated by Django 2.2.10 on 2020-12-02 13:31
from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations, models
import django.db.models.deletion
import django_countries.fields
def migrate_forward(apps, schema_editor):
Person = apps.get_model('people', 'Person')
PersonAnswerset = apps.get_model('people', 'PersonAnswerSet')
fields = {
'country_of_residence',
'disciplines',
'job_title',
'nationality',
'organisation',
'organisation_started_date',
'themes',
}
for person in Person.objects.all():
try:
answer_set = person.answer_sets.last()
except ObjectDoesNotExist:
answer_set = person.answer_sets.create()
for field in fields:
value = getattr(person, field)
try:
setattr(answer_set, field, value)
except TypeError:
# Cannot directly set an m2m field
m2m = getattr(answer_set, field)
m2m.set(value.all())
answer_set.save()
def migrate_backward(apps, schema_editor):
Person = apps.get_model('people', 'Person')
PersonAnswerset = apps.get_model('people', 'PersonAnswerSet')
fields = {
'country_of_residence',
'disciplines',
'job_title',
'nationality',
'organisation',
'organisation_started_date',
'themes',
}
for person in Person.objects.all():
try:
answer_set = person.answer_sets.last()
for field in fields:
value = getattr(answer_set, field)
try:
setattr(person, field, value)
except TypeError:
# Cannot directly set an m2m field
m2m = getattr(person, field)
m2m.set(value.all())
person.save()
except ObjectDoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
('people', '0025_rename_relationship_target'),
]
operations = [
migrations.AddField(
model_name='personanswerset',
name='country_of_residence',
field=django_countries.fields.CountryField(blank=True, max_length=2, null=True),
),
migrations.AddField(
model_name='personanswerset',
name='disciplines',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='personanswerset',
name='job_title',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='personanswerset',
name='nationality',
field=django_countries.fields.CountryField(blank=True, max_length=2, null=True),
),
migrations.AddField(
model_name='personanswerset',
name='organisation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='members', to='people.Organisation'),
),
migrations.AddField(
model_name='personanswerset',
name='organisation_started_date',
field=models.DateField(null=True, verbose_name='Date started at this organisation'),
),
migrations.AddField(
model_name='personanswerset',
name='themes',
field=models.ManyToManyField(blank=True, related_name='people', to='people.Theme'),
),
migrations.RunPython(migrate_forward, migrate_backward),
migrations.RemoveField(
model_name='person',
name='country_of_residence',
),
migrations.RemoveField(
model_name='person',
name='disciplines',
),
migrations.RemoveField(
model_name='person',
name='job_title',
),
migrations.RemoveField(
model_name='person',
name='nationality',
),
migrations.RemoveField(
model_name='person',
name='organisation',
),
migrations.RemoveField(
model_name='person',
name='organisation_started_date',
),
migrations.RemoveField(
model_name='person',
name='themes',
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2.10 on 2020-12-07 16:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('people', '0026_move_static_person_questions'),
]
operations = [
migrations.AddField(
model_name='personquestion',
name='is_multiple_choice',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='relationshipquestion',
name='is_multiple_choice',
field=models.BooleanField(default=False),
),
]

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

@@ -11,16 +11,18 @@ from django_countries.fields import CountryField
from django_settings_export import settings_export from django_settings_export import settings_export
from post_office import mail from post_office import mail
from backports.db.models.enums import TextChoices from .question import AnswerSet, Question, QuestionChoice
logger = logging.getLogger(__name__) # pylint: disable=invalid-name logger = logging.getLogger(__name__) # pylint: disable=invalid-name
__all__ = [ __all__ = [
'User', 'User',
'Organisation', 'Organisation',
'Role',
'Theme', 'Theme',
'PersonQuestion',
'PersonQuestionChoice',
'Person', 'Person',
'PersonAnswerSet',
] ]
@@ -36,7 +38,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 +73,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 +83,20 @@ class Theme(models.Model):
return self.name return self.name
class PersonQuestion(Question):
"""Question which may be asked about a person."""
class PersonQuestionChoice(QuestionChoice):
"""Allowed answer to a :class:`PersonQuestion`."""
#: Question to which this answer belongs
question = models.ForeignKey(PersonQuestion,
related_name='answers',
on_delete=models.CASCADE,
blank=False,
null=False)
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.
@@ -113,39 +119,39 @@ class Person(models.Model):
'self', 'self',
related_name='relationship_sources', related_name='relationship_sources',
through='Relationship', through='Relationship',
through_fields=('source', 'target'), through_fields=('source', 'target_person'),
symmetrical=False) symmetrical=False)
############################################################### @property
# Data collected for analysis of community makeup and structure def relationships(self):
return self.relationships_as_source.all().union(
self.relationships_as_target.all())
class GenderChoices(TextChoices): @property
MALE = 'M', _('Male') def current_answers(self) -> 'PersonAnswerSet':
FEMALE = 'F', _('Female') return self.answer_sets.last()
OTHER = 'O', _('Other')
PREFER_NOT_TO_SAY = 'N', _('Prefer not to say')
gender = models.CharField(max_length=1, def get_absolute_url(self):
choices=GenderChoices.choices, return reverse('people:person.detail', kwargs={'pk': self.pk})
blank=True,
def __str__(self) -> str:
return self.name
class PersonAnswerSet(AnswerSet):
"""The answers to the person questions at a particular point in time."""
#: Person to which this answer set belongs
person = models.ForeignKey(Person,
on_delete=models.CASCADE,
related_name='answer_sets',
blank=False,
null=False) null=False)
class AgeGroupChoices(TextChoices): #: Answers to :class:`PersonQuestion`s
LTE_25 = '<=25', _('25 or under') question_answers = models.ManyToManyField(PersonQuestionChoice)
BETWEEN_26_30 = '26-30', _('26-30')
BETWEEN_31_35 = '31-35', _('31-35')
BETWEEN_36_40 = '36-40', _('36-40')
BETWEEN_41_45 = '41-45', _('41-45')
BETWEEN_46_50 = '46-50', _('46-50')
BETWEEN_51_55 = '51-55', _('51-55')
BETWEEN_56_60 = '56-60', _('56-60')
GTE_61 = '>=61', _('61 or older')
PREFER_NOT_TO_SAY = 'N', _('Prefer not to say')
age_group = models.CharField(max_length=5, ##################
choices=AgeGroupChoices.choices, # Static questions
blank=True,
null=False)
nationality = CountryField(blank=True, null=True) nationality = CountryField(blank=True, null=True)
@@ -168,23 +174,8 @@ 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)
@property
def relationships(self):
return self.relationships_as_source.all().union(
self.relationships_as_target.all())
def get_absolute_url(self): def get_absolute_url(self):
return reverse('people:person.detail', kwargs={'pk': self.pk}) return self.person.get_absolute_url()
def __str__(self) -> str:
return self.name

108
people/models/question.py Normal file
View File

@@ -0,0 +1,108 @@
"""Base models for configurable questions and response sets."""
import typing
from django.db import models
from django.utils.text import slugify
class Question(models.Model):
"""Questions from which a survey form can be created."""
class Meta:
abstract = True
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)
#: Should people be able to select multiple responses to this question?
is_multiple_choice = models.BooleanField(default=False,
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:`QuestionChoice`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 QuestionChoice(models.Model):
"""Allowed answer to a :class:`Question`."""
class Meta:
abstract = True
constraints = [
models.UniqueConstraint(fields=['question', 'text'],
name='unique_question_answer')
]
ordering = [
'question__order',
'order',
'text',
]
#: Question to which this answer belongs
#: This foreign key must be added to each concrete subclass
# question = models.ForeignKey(Question,
# 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 AnswerSet(models.Model):
"""The answers to a set of questions at a particular point in time."""
class Meta:
abstract = True
ordering = [
'timestamp',
]
#: Entity to which this answer set belongs
#: This foreign key must be added to each concrete subclass
# person = models.ForeignKey(Person,
# on_delete=models.CASCADE,
# related_name='answer_sets',
# blank=False,
# null=False)
#: Answers to :class:`Question`s
#: This many to many relation must be added to each concrete subclass
# question_answers = models.ManyToManyField(QuestionChoice)
#: When were these answers collected?
timestamp = models.DateTimeField(auto_now_add=True, editable=False)
#: When were these answers replaced? - happens when another set is collected
replaced_timestamp = models.DateTimeField(blank=True,
null=True,
editable=False)

View File

@@ -2,13 +2,12 @@
Models describing relationships between people. Models describing relationships between people.
""" """
import typing from django.core.exceptions import ObjectDoesNotExist
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 .person import Person from .person import Person
from .question import AnswerSet, Question, QuestionChoice
__all__ = [ __all__ = [
'RelationshipQuestion', 'RelationshipQuestion',
@@ -18,79 +17,32 @@ __all__ = [
] ]
class RelationshipQuestion(models.Model): class RelationshipQuestion(Question):
""" """Question which may be asked about a relationship."""
Question which may be asked about a relationship.
"""
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:`RelationshipQuestionChoice`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 RelationshipQuestionChoice(models.Model): class RelationshipQuestionChoice(QuestionChoice):
""" """Allowed answer to a :class:`RelationshipQuestion`."""
Allowed answer to a :class:`RelationshipQuestion`.
"""
class Meta:
constraints = [
models.UniqueConstraint(fields=['question', 'text'],
name='unique_question_answer')
]
ordering = [
'question__order',
'order',
'text',
]
#: Question to which this answer belongs #: Question to which this answer belongs
question = models.ForeignKey(RelationshipQuestion, related_name='answers', question = models.ForeignKey(RelationshipQuestion,
related_name='answers',
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=False, null=False) blank=False,
null=False)
#: Text of answer
text = models.CharField(max_length=255,
blank=False, null=False)
#: Position of this answer in the list # class ExternalPerson(models.Model):
order = models.SmallIntegerField(default=0, # """Model representing a person external to the project.
blank=False, null=False)
@property # These will never need to be linked to a :class:`User` as they
def slug(self) -> str: # will never log in to the system.
return slugify(self.text) # """
# name = models.CharField(max_length=255,
# blank=False, null=False)
def __str__(self) -> str: # def __str__(self) -> str:
return self.text # return self.name
class Relationship(models.Model): class Relationship(models.Model):
@@ -100,7 +52,7 @@ class Relationship(models.Model):
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=['source', 'target'], models.UniqueConstraint(fields=['source', 'target_person'],
name='unique_relationship'), name='unique_relationship'),
] ]
@@ -110,9 +62,20 @@ class Relationship(models.Model):
blank=False, null=False) blank=False, null=False)
#: Person with whom the relationship is reported #: Person with whom the relationship is reported
target = models.ForeignKey(Person, related_name='relationships_as_target', target_person = models.ForeignKey(Person,
related_name='relationships_as_target',
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=False, null=False) 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? #: When was this relationship defined?
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
@@ -120,6 +83,13 @@ class Relationship(models.Model):
#: When was this marked as expired? Default None means it has not expired #: When was this marked as expired? Default None means it has not expired
expired = models.DateTimeField(blank=True, null=True) expired = models.DateTimeField(blank=True, null=True)
@property
def target(self) -> Person:
if self.target_person:
return self.target_person
raise ObjectDoesNotExist('Relationship has no target linked')
@property @property
def current_answers(self) -> 'RelationshipAnswerSet': def current_answers(self) -> 'RelationshipAnswerSet':
return self.answer_sets.last() return self.answer_sets.last()
@@ -137,35 +107,22 @@ class Relationship(models.Model):
@raise Relationship.DoesNotExist: When the reverse relationship is not known @raise Relationship.DoesNotExist: When the reverse relationship is not known
""" """
return type(self).objects.get(source=self.target, return type(self).objects.get(source=self.target_person,
target=self.source) target_person=self.source)
class RelationshipAnswerSet(models.Model): class RelationshipAnswerSet(AnswerSet):
""" """The answers to the relationship questions at a particular point in time."""
The answers to the relationship questions at a particular point in time.
"""
class Meta:
ordering = [
'timestamp',
]
#: Relationship to which this answer set belongs #: Relationship to which this answer set belongs
relationship = models.ForeignKey(Relationship, relationship = models.ForeignKey(Relationship,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='answer_sets', related_name='answer_sets',
blank=False, null=False) blank=False,
null=False)
#: Answers to :class:`RelationshipQuestion`s #: Answers to :class:`RelationshipQuestion`s
question_answers = models.ManyToManyField(RelationshipQuestionChoice) question_answers = models.ManyToManyField(RelationshipQuestionChoice)
#: 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): def get_absolute_url(self):
return self.relationship.get_absolute_url() return self.relationship.get_absolute_url()

View File

@@ -23,7 +23,9 @@ class UserIsLinkedPersonMixin(UserPassesTestMixin):
test_person = self.get_object() test_person = self.get_object()
if not isinstance(test_person, models.Person): if not isinstance(test_person, models.Person):
raise AttributeError('View incorrectly configured: \'related_person_field\' must be defined.') raise AttributeError(
'View incorrectly configured: \'related_person_field\' must be defined.'
)
return test_person return test_person
@@ -34,4 +36,5 @@ class UserIsLinkedPersonMixin(UserPassesTestMixin):
Require that user is either staff or is the linked person. Require that user is either staff or is the linked person.
""" """
user = self.request.user user = self.request.user
return user.is_authenticated and (user.is_staff or self.get_test_person() == user.person) return user.is_authenticated and (
user.is_staff or self.get_test_person() == user.person)

View File

@@ -22,68 +22,80 @@
</div> </div>
{% endif %} {% endif %}
{% with person.current_answers as answer_set %}
<dl> <dl>
{% if person.gender %} </dl>
<dt>Gender</dt>
<dd>{{ person.get_gender_display }}</dd> <table class="table table-borderless">
<thead>
<tr>
<th>Question</th>
<th>Answer</th>
</tr>
</thead>
<tbody>
{% if answer_set.nationality %}
<tr><td>Nationality</td><td>{{ answer_set.nationality.name }}</td>
{% endif %} {% endif %}
{% if person.age_group %} {% if answer_set.country_of_residence %}
<dt>Age Group</dt> <tr><td>Country of Residence</td><td>{{ answer_set.country_of_residence.name }}</td></tr>
<dd>{{ person.get_age_group_display }}</dd>
{% endif %} {% endif %}
{% if person.nationality %} {% if answer_set.organisation %}
<dt>Nationality</dt> <tr><td>Organisation</td><td>{{ answer_set.organisation }}</td></tr>
<dd>{{ person.nationality.name }}</dd>
{% endif %} {% endif %}
{% if person.country_of_residence %} {% if answer_set.organisation_started_date %}
<dt>Country of Residence</dt> <tr><td>Organisation Started Date</td><td>{{ answer_set.organisation_started_date }}</td></tr>
<dd>{{ person.country_of_residence.name }}</dd>
{% endif %} {% endif %}
{% if person.organisation %} {% if answer_set.job_title %}
<dt>Organisation</dt> <tr><td>Job Title</td><td>{{ answer_set.job_title }}</td></tr>
<dd>{{ person.organisation }}</dd>
{% if person.organisation_started_date %}
<dt>Started Date</dt>
<dd>{{ person.organisation_started_date }}</dd>
{% endif %}
{% endif %} {% endif %}
{% if person.job_title %} {% if answer_set.disciplines %}
<dt>Job Title</dt> <tr><td>Discipline(s)</td><td>{{ answer_set.disciplines }}</td></tr>
<dd>{{ person.job_title }}</dd>
{% endif %} {% endif %}
{% if person.role %} {% if answer_set.themes.exists %}
<dt>Role</dt> <tr>
<dd>{{ person.role }}</dd> <td>Project Themes</td>
{% endif %} <td>
{% for theme in answer_set.themes.all %}
{% if person.disciplines %}
<dt>Discipline(s)</dt>
<dd>{{ person.disciplines }}</dd>
{% endif %}
{% if person.themes.exists %}
<dt>Project Themes</dt>
<dd>
{% for theme in person.themes.all %}
{{ theme }}{% if not forloop.last %}, {% endif %} {{ theme }}{% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
</dd> </td>
{% endif %} </tr>
</dl>
{% endif %} {% endif %}
{% 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>
{% if person.user == request.user %}
<a class="btn btn-info" <a class="btn btn-info"
href="{% url 'password_change' %}?next={{ person.get_absolute_url }}">Change Password</a> href="{% url 'password_change' %}?next={{ person.get_absolute_url }}">Change Password</a>
{% endif %}
{% endif %}
<hr> <hr>

View File

@@ -11,10 +11,8 @@
<hr> <hr>
{% if request.user.is_staff %}
<a class="btn btn-success" <a class="btn btn-success"
href="{% url 'people:person.create' %}">New Person</a> href="{% url 'people:person.create' %}">New Person</a>
{% endif %}
<table class="table table-borderless"> <table class="table table-borderless">
<thead> <thead>

View File

@@ -7,3 +7,10 @@ from . import (
person, person,
relationship relationship
) )
__all__ = [
'network',
'person',
'relationship',
]

View File

@@ -3,6 +3,7 @@ Views for displaying or manipulating instances of :class:`Person`.
""" """
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils import timezone
from django.views.generic import CreateView, DetailView, ListView, UpdateView from django.views.generic import CreateView, DetailView, ListView, UpdateView
from people import forms, models, permissions from people import forms, models, permissions
@@ -55,9 +56,40 @@ class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView):
class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView): class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
""" """View for updating a :class:`Person` record."""
View for updating a :class:`Person` record. model = models.PersonAnswerSet
"""
model = models.Person
template_name = 'people/person/update.html' template_name = 'people/person/update.html'
form_class = forms.PersonForm form_class = forms.PersonAnswerSetForm
def get_test_person(self) -> models.Person:
"""Get the person instance which should be used for access control checks."""
return models.Person.objects.get(pk=self.kwargs.get('pk'))
def get(self, request, *args, **kwargs):
self.person = models.Person.objects.get(pk=self.kwargs.get('pk'))
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.person = models.Person.objects.get(pk=self.kwargs.get('pk'))
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['person'] = self.person
return context
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.person.answer_sets.exclude(pk=self.object.pk):
answer_set.replaced_timestamp = now_date
answer_set.save()
return response

View File

@@ -2,9 +2,11 @@
Views for displaying or manipulating instances of :class:`Relationship`. Views for displaying or manipulating instances of :class:`Relationship`.
""" """
from django.db import IntegrityError
from django.forms import ValidationError
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic import CreateView, DetailView from django.views.generic import CreateView, DetailView, FormView
from people import forms, models, permissions from people import forms, models, permissions
@@ -18,7 +20,7 @@ class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView):
related_person_field = 'source' related_person_field = 'source'
class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView): class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, FormView):
""" """
View for creating a :class:`Relationship`. View for creating a :class:`Relationship`.
@@ -26,53 +28,45 @@ class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView):
""" """
model = models.Relationship model = models.Relationship
template_name = 'people/relationship/create.html' template_name = 'people/relationship/create.html'
fields = [ form_class = forms.RelationshipForm
'source',
'target',
]
def get_test_person(self) -> models.Person:
"""
Get the person instance which should be used for access control checks.
"""
if self.request.method == 'POST':
return models.Person.objects.get(pk=self.request.POST.get('source'))
def get_person(self) -> models.Person:
return models.Person.objects.get(pk=self.kwargs.get('person_pk')) return models.Person.objects.get(pk=self.kwargs.get('person_pk'))
def get(self, request, *args, **kwargs): def get_test_person(self) -> models.Person:
self.person = models.Person.objects.get(pk=self.kwargs.get('person_pk')) return self.get_person()
return super().get(request, *args, **kwargs) def form_valid(self, form):
try:
self.object = models.Relationship.objects.create(
source=self.get_person(), target=form.cleaned_data['target'])
def post(self, request, *args, **kwargs): except IntegrityError:
self.person = models.Person.objects.get(pk=self.kwargs.get('person_pk')) form.add_error(
None,
ValidationError('This relationship already exists',
code='already-exists'))
return self.form_invalid(form)
return super().post(request, *args, **kwargs) return super().form_valid(form)
def get_initial(self):
initial = super().get_initial()
initial['source'] = self.request.user.person
initial['target'] = self.person
return initial
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['person'] = self.person context['person'] = self.get_person()
return context return context
def get_success_url(self): def get_success_url(self):
return reverse('people:relationship.update', kwargs={'relationship_pk': self.object.pk}) return reverse('people:relationship.update',
kwargs={'relationship_pk': self.object.pk})
class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView): class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
""" """
View for creating a :class:`Relationship`. View for updating the details of a relationship.
Creates a new :class:`RelationshipAnswerSet` for the :class:`Relationship`.
Displays / processes a form containing the :class:`RelationshipQuestion`s. Displays / processes a form containing the :class:`RelationshipQuestion`s.
""" """
model = models.RelationshipAnswerSet model = models.RelationshipAnswerSet
@@ -83,18 +77,21 @@ class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
""" """
Get the person instance which should be used for access control checks. Get the person instance which should be used for access control checks.
""" """
relationship = models.Relationship.objects.get(pk=self.kwargs.get('relationship_pk')) relationship = models.Relationship.objects.get(
pk=self.kwargs.get('relationship_pk'))
return relationship.source return relationship.source
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.relationship = models.Relationship.objects.get(pk=self.kwargs.get('relationship_pk')) self.relationship = models.Relationship.objects.get(
pk=self.kwargs.get('relationship_pk'))
self.person = self.relationship.source self.person = self.relationship.source
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.relationship = models.Relationship.objects.get(pk=self.kwargs.get('relationship_pk')) self.relationship = models.Relationship.objects.get(
pk=self.kwargs.get('relationship_pk'))
self.person = self.relationship.source self.person = self.relationship.source
return super().post(request, *args, **kwargs) return super().post(request, *args, **kwargs)
@@ -122,7 +119,8 @@ class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
now_date = timezone.now().date() now_date = timezone.now().date()
# Shouldn't be more than one after initial updates after migration # Shouldn't be more than one after initial updates after migration
for answer_set in self.relationship.answer_sets.exclude(pk=self.object.pk): for answer_set in self.relationship.answer_sets.exclude(
pk=self.object.pk):
answer_set.replaced_timestamp = now_date answer_set.replaced_timestamp = now_date
answer_set.save() answer_set.save()