diff --git a/people/models/person.py b/people/models/person.py index e0b367d..aebc680 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -1,18 +1,18 @@ import logging -import typing from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField from django_settings_export import settings_export from post_office import mail +from .question import AnswerSet, Question, QuestionChoice + logger = logging.getLogger(__name__) # pylint: disable=invalid-name __all__ = [ @@ -83,77 +83,18 @@ class Theme(models.Model): return self.name -class PersonQuestion(models.Model): +class PersonQuestion(Question): """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', - ] - +class PersonQuestionChoice(QuestionChoice): + """Allowed answer to a :class:`PersonQuestion`.""" #: Question to which this answer belongs - question = models.ForeignKey(PersonQuestion, related_name='answers', + 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 + blank=False, + null=False) class Person(models.Model): @@ -197,13 +138,8 @@ class Person(models.Model): return self.name -class PersonAnswerSet(models.Model): +class PersonAnswerSet(AnswerSet): """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, @@ -214,13 +150,6 @@ class PersonAnswerSet(models.Model): #: 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) - ################## # Static questions diff --git a/people/models/question.py b/people/models/question.py new file mode 100644 index 0000000..700a4df --- /dev/null +++ b/people/models/question.py @@ -0,0 +1,103 @@ +"""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) + + #: 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) diff --git a/people/models/relationship.py b/people/models/relationship.py index a739190..ce68a3c 100644 --- a/people/models/relationship.py +++ b/people/models/relationship.py @@ -2,14 +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', @@ -19,79 +17,19 @@ __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) - - #: 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 + blank=False, + null=False) # class ExternalPerson(models.Model): @@ -129,8 +67,8 @@ class Relationship(models.Model): on_delete=models.CASCADE, blank=False, null=False) - # blank=True, - # null=True) + # blank=True, + # null=True) # target_external_person = models.ForeignKey( # ExternalPerson, @@ -173,31 +111,18 @@ class Relationship(models.Model): 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()