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
urlpatterns = [
path('admin/', admin.site.urls),
path('admin/',
admin.site.urls),
path('select2/',
include('django_select2.urls')),
path('',
include('django.contrib.auth.urls')),
@@ -36,4 +40,4 @@ urlpatterns = [
path('',
include('activities.urls')),
]
] # yapf: disable

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ from django import forms
from django.forms.widgets import SelectDateWidget
from django.utils import timezone
from django_select2.forms import Select2Widget, Select2MultipleWidget
from django_select2.forms import ModelSelect2Widget, Select2Widget, Select2MultipleWidget
from . import models
@@ -25,22 +25,58 @@ def get_date_year_range() -> typing.Iterable[int]:
class PersonForm(forms.ModelForm):
"""
Form for creating / updating an instance of :class:`Person`.
"""
"""Form for creating / updating an instance of :class:`Person`."""
class Meta:
model = models.Person
fields = [
'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',
'country_of_residence',
'organisation',
'organisation_started_date',
'job_title',
'disciplines',
'role',
'themes',
]
widgets = {
@@ -53,27 +89,24 @@ class PersonForm(forms.ModelForm):
'If you don\'t know the exact date, an approximate date is okay.',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
question_model = models.PersonQuestion
self.fields['organisation_started_date'].widget = SelectDateWidget(
years=get_date_year_range())
def save(self, commit=True) -> models.PersonAnswerSet:
# 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):
field_class = forms.ModelChoiceField
field_widget = None
field_required = True
except TypeError:
# Value is a QuerySet - multiple choice question
self.instance.question_answers.add(*value.all())
def __init__(self, *args, **kwargs):
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
return self.instance
class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
@@ -88,6 +121,8 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
'relationship',
]
question_model = models.RelationshipQuestion
def save(self, commit=True) -> models.RelationshipAnswerSet:
# Save Relationship model
self.instance = super().save(commit=commit)
@@ -96,8 +131,13 @@ class RelationshipAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase):
# 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)
except TypeError:
# Value is a QuerySet - multiple choice question
self.instance.question_answers.add(*value.all())
return self.instance
@@ -108,6 +148,7 @@ class NetworkFilterForm(DynamicAnswerSetBase):
field_class = forms.ModelMultipleChoiceField
field_widget = Select2MultipleWidget
field_required = False
question_model = models.RelationshipQuestion
def __init__(self, *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 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
__all__ = [
'User',
'Organisation',
'Role',
'Theme',
'PersonQuestion',
'PersonQuestionChoice',
'Person',
'PersonAnswerSet',
]
@@ -36,7 +38,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 +73,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 +83,20 @@ class Theme(models.Model):
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):
"""
A person may be a member of the BRECcIA core team or an external stakeholder.
@@ -113,39 +119,39 @@ class Person(models.Model):
'self',
related_name='relationship_sources',
through='Relationship',
through_fields=('source', 'target'),
through_fields=('source', 'target_person'),
symmetrical=False)
###############################################################
# Data collected for analysis of community makeup and structure
@property
def relationships(self):
return self.relationships_as_source.all().union(
self.relationships_as_target.all())
class GenderChoices(TextChoices):
MALE = 'M', _('Male')
FEMALE = 'F', _('Female')
OTHER = 'O', _('Other')
PREFER_NOT_TO_SAY = 'N', _('Prefer not to say')
@property
def current_answers(self) -> 'PersonAnswerSet':
return self.answer_sets.last()
gender = models.CharField(max_length=1,
choices=GenderChoices.choices,
blank=True,
def get_absolute_url(self):
return reverse('people:person.detail', kwargs={'pk': self.pk})
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)
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')
#: Answers to :class:`PersonQuestion`s
question_answers = models.ManyToManyField(PersonQuestionChoice)
age_group = models.CharField(max_length=5,
choices=AgeGroupChoices.choices,
blank=True,
null=False)
##################
# Static questions
nationality = CountryField(blank=True, null=True)
@@ -168,23 +174,8 @@ 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)
@property
def relationships(self):
return self.relationships_as_source.all().union(
self.relationships_as_target.all())
def get_absolute_url(self):
return reverse('people:person.detail', kwargs={'pk': self.pk})
def __str__(self) -> str:
return self.name
return self.person.get_absolute_url()

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.
"""
import typing
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from .person import Person
from .question import AnswerSet, Question, QuestionChoice
__all__ = [
'RelationshipQuestion',
@@ -18,79 +17,32 @@ __all__ = [
]
class RelationshipQuestion(models.Model):
"""
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 RelationshipQuestion(Question):
"""Question which may be asked about a relationship."""
class RelationshipQuestionChoice(models.Model):
"""
Allowed answer to a :class:`RelationshipQuestion`.
"""
class Meta:
constraints = [
models.UniqueConstraint(fields=['question', 'text'],
name='unique_question_answer')
]
ordering = [
'question__order',
'order',
'text',
]
class RelationshipQuestionChoice(QuestionChoice):
"""Allowed answer to a :class:`RelationshipQuestion`."""
#: Question to which this answer belongs
question = models.ForeignKey(RelationshipQuestion, related_name='answers',
question = models.ForeignKey(RelationshipQuestion,
related_name='answers',
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
order = models.SmallIntegerField(default=0,
blank=False, null=False)
# class ExternalPerson(models.Model):
# """Model representing a person external to the project.
@property
def slug(self) -> str:
return slugify(self.text)
# 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.text
# def __str__(self) -> str:
# return self.name
class Relationship(models.Model):
@@ -100,7 +52,7 @@ class Relationship(models.Model):
class Meta:
constraints = [
models.UniqueConstraint(fields=['source', 'target'],
models.UniqueConstraint(fields=['source', 'target_person'],
name='unique_relationship'),
]
@@ -110,9 +62,20 @@ class Relationship(models.Model):
blank=False, null=False)
#: 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,
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?
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
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
def current_answers(self) -> 'RelationshipAnswerSet':
return self.answer_sets.last()
@@ -137,35 +107,22 @@ 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_person,
target_person=self.source)
class RelationshipAnswerSet(models.Model):
"""
The answers to the relationship questions at a particular point in time.
"""
class Meta:
ordering = [
'timestamp',
]
class RelationshipAnswerSet(AnswerSet):
"""The answers to the relationship questions at a particular point in time."""
#: Relationship to which this answer set belongs
relationship = models.ForeignKey(Relationship,
on_delete=models.CASCADE,
related_name='answer_sets',
blank=False, null=False)
blank=False,
null=False)
#: Answers to :class:`RelationshipQuestion`s
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):
return self.relationship.get_absolute_url()

View File

@@ -23,7 +23,9 @@ class UserIsLinkedPersonMixin(UserPassesTestMixin):
test_person = self.get_object()
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
@@ -34,4 +36,5 @@ class UserIsLinkedPersonMixin(UserPassesTestMixin):
Require that user is either staff or is the linked person.
"""
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>
{% endif %}
{% with person.current_answers as answer_set %}
<dl>
{% if person.gender %}
<dt>Gender</dt>
<dd>{{ person.get_gender_display }}</dd>
</dl>
<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 %}
{% if person.age_group %}
<dt>Age Group</dt>
<dd>{{ person.get_age_group_display }}</dd>
{% if answer_set.country_of_residence %}
<tr><td>Country of Residence</td><td>{{ answer_set.country_of_residence.name }}</td></tr>
{% endif %}
{% if person.nationality %}
<dt>Nationality</dt>
<dd>{{ person.nationality.name }}</dd>
{% if answer_set.organisation %}
<tr><td>Organisation</td><td>{{ answer_set.organisation }}</td></tr>
{% endif %}
{% if person.country_of_residence %}
<dt>Country of Residence</dt>
<dd>{{ person.country_of_residence.name }}</dd>
{% if answer_set.organisation_started_date %}
<tr><td>Organisation Started Date</td><td>{{ answer_set.organisation_started_date }}</td></tr>
{% endif %}
{% if person.organisation %}
<dt>Organisation</dt>
<dd>{{ person.organisation }}</dd>
{% if person.organisation_started_date %}
<dt>Started Date</dt>
<dd>{{ person.organisation_started_date }}</dd>
{% endif %}
{% if answer_set.job_title %}
<tr><td>Job Title</td><td>{{ answer_set.job_title }}</td></tr>
{% endif %}
{% if person.job_title %}
<dt>Job Title</dt>
<dd>{{ person.job_title }}</dd>
{% if answer_set.disciplines %}
<tr><td>Discipline(s)</td><td>{{ answer_set.disciplines }}</td></tr>
{% endif %}
{% if person.role %}
<dt>Role</dt>
<dd>{{ person.role }}</dd>
{% endif %}
{% 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 %}
{% if answer_set.themes.exists %}
<tr>
<td>Project Themes</td>
<td>
{% for theme in answer_set.themes.all %}
{{ theme }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</dd>
{% endif %}
</dl>
</td>
</tr>
{% 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"
href="{% url 'people:person.update' pk=person.pk %}">Update</a>
{% if person.user == request.user %}
<a class="btn btn-info"
href="{% url 'password_change' %}?next={{ person.get_absolute_url }}">Change Password</a>
{% endif %}
{% endif %}
<hr>

View File

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

View File

@@ -7,3 +7,10 @@ from . import (
person,
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.utils import timezone
from django.views.generic import CreateView, DetailView, ListView, UpdateView
from people import forms, models, permissions
@@ -55,9 +56,40 @@ class ProfileView(permissions.UserIsLinkedPersonMixin, DetailView):
class PersonUpdateView(permissions.UserIsLinkedPersonMixin, UpdateView):
"""
View for updating a :class:`Person` record.
"""
model = models.Person
"""View for updating a :class:`Person` record."""
model = models.PersonAnswerSet
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`.
"""
from django.db import IntegrityError
from django.forms import ValidationError
from django.urls import reverse
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
@@ -18,7 +20,7 @@ class RelationshipDetailView(permissions.UserIsLinkedPersonMixin, DetailView):
related_person_field = 'source'
class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView):
class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, FormView):
"""
View for creating a :class:`Relationship`.
@@ -26,53 +28,45 @@ class RelationshipCreateView(permissions.UserIsLinkedPersonMixin, CreateView):
"""
model = models.Relationship
template_name = 'people/relationship/create.html'
fields = [
'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'))
form_class = forms.RelationshipForm
def get_person(self) -> models.Person:
return models.Person.objects.get(pk=self.kwargs.get('person_pk'))
def get(self, request, *args, **kwargs):
self.person = models.Person.objects.get(pk=self.kwargs.get('person_pk'))
def get_test_person(self) -> models.Person:
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):
self.person = models.Person.objects.get(pk=self.kwargs.get('person_pk'))
except IntegrityError:
form.add_error(
None,
ValidationError('This relationship already exists',
code='already-exists'))
return self.form_invalid(form)
return super().post(request, *args, **kwargs)
def get_initial(self):
initial = super().get_initial()
initial['source'] = self.request.user.person
initial['target'] = self.person
return initial
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['person'] = self.person
context['person'] = self.get_person()
return context
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):
"""
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.
"""
model = models.RelationshipAnswerSet
@@ -83,18 +77,21 @@ class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
"""
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
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
return super().get(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
return super().post(request, *args, **kwargs)
@@ -122,7 +119,8 @@ class RelationshipUpdateView(permissions.UserIsLinkedPersonMixin, CreateView):
now_date = timezone.now().date()
# 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.save()