diff --git a/apps/common/management/commands/load_dummy_data.py b/apps/common/management/commands/load_dummy_data.py index 0d90931..58b76e5 100644 --- a/apps/common/management/commands/load_dummy_data.py +++ b/apps/common/management/commands/load_dummy_data.py @@ -1,14 +1,17 @@ +import random + from django.core.management.base import BaseCommand from django.db import transaction +from django.conf import settings from apps.user.models import User from apps.user.factories import UserFactory from apps.project.models import Project, ProjectMembership from apps.project.factories import ProjectFactory -from apps.questionnaire.models import Question +from apps.questionnaire.models import Question, Questionnaire, QuestionLeafGroup from apps.questionnaire.factories import ( QuestionnaireFactory, - QuestionGroupFactory, + QuestionLeafGroupFactory, QuestionFactory, ChoiceCollectionFactory, ChoiceFactory, @@ -18,7 +21,18 @@ class Command(BaseCommand): help = 'Load dummy data' - def create_superuser(self): + def add_arguments(self, parser): + parser.add_argument( + '--delete-all', + dest='DELETE_ALL', + action='store_true', + default=False, + ) + + def get_or_create_superuser(self): + user = User.objects.filter(email='admin@test.com').first() + if user: + return user user = UserFactory.create( email='admin@test.com', password_text='admin123', @@ -29,13 +43,7 @@ def create_superuser(self): self.stdout.write(f'Added user with credentials: {user.email}:{user.password_text}') return user - def process_questionnare(self, questionnaire): - # Groups - group1, group2, _ = QuestionGroupFactory.create_batch( - 3, - **self.user_resource_params, - questionnaire=questionnaire, - ) + def process_questionnare(self, questionnaire: Questionnaire): # Choices # -- Collection choice_collection_1, choice_collection_2, choice_collection_3 = ChoiceCollectionFactory.create_batch( @@ -47,19 +55,38 @@ def process_questionnare(self, questionnaire): ChoiceFactory.create_batch(10, collection=choice_collection_1) ChoiceFactory.create_batch(5, collection=choice_collection_2) ChoiceFactory.create_batch(7, collection=choice_collection_3) + # Questions - # -- No group - question_params = { - **self.user_resource_params, - 'type': Question.Type.INTEGER, - 'questionnaire': questionnaire, + group_categories = QuestionLeafGroupFactory.random_category_generator(100) + group_order_by_type = { + QuestionLeafGroup.Type.MATRIX_1D: 100, + QuestionLeafGroup.Type.MATRIX_2D: 200, } - QuestionFactory.create_batch(5, **question_params) - # -- Group 1 - QuestionFactory.create_batch(3, **question_params, group=group1) - # -- Group 2 - QuestionFactory.create_batch(2, **question_params, group=group2, choice_collection=choice_collection_1) - QuestionFactory.create_batch(2, **question_params, group=group2, choice_collection=choice_collection_2) + for _type, *categories in group_categories: + # Group + group = QuestionLeafGroupFactory.create( + questionnaire=questionnaire, + type=_type, + category_1=categories[0], + category_2=categories[1], + category_3=categories[2], + category_4=categories[3], + order=group_order_by_type[_type], + **self.user_resource_params, + ) + group_order_by_type[_type] += 1 + # Questions + question_params = { + **self.user_resource_params, + 'type': Question.Type.INTEGER, + 'questionnaire': questionnaire, + 'leaf_group': group, + } + # Without choices + QuestionFactory.create_batch(random.randrange(4, 10), **question_params) + # With choices + QuestionFactory.create_batch(random.randrange(1, 3), **question_params, choice_collection=choice_collection_2) + QuestionFactory.create_batch(random.randrange(1, 4), **question_params, choice_collection=choice_collection_1) def process_project( self, @@ -77,13 +104,28 @@ def process_project( self.process_questionnare(questionnaire) @transaction.atomic - def handle(self, **_): - user = self.create_superuser() # Main user - UserFactory.create_batch(10) # Other users + def handle(self, **kwargs): + if not (settings.DEBUG and settings.ALLOW_DUMMY_DATA_SCRIPT): + self.stdout.write( + self.style.ERROR( + 'You need to enable DEBUG & ALLOW_DUMMY_DATA_SCRIPT to use this' + ) + ) + return + + user = self.get_or_create_superuser() # Main user self.user_resource_params = { 'created_by': user, 'modified_by': user, } + if not User.objects.exclude(pk=user.pk).exists(): + UserFactory.create_batch(10) # Other users + + if kwargs.get('DELETE_ALL', False): + self.stdout.write(self.style.WARNING('Removing existing Data')) + Question.objects.all().delete() + Project.objects.all().delete() + projects = ProjectFactory.create_batch(10, **self.user_resource_params) total_projects = len(projects) self.stdout.write(f'Created {total_projects} projects') @@ -103,7 +145,6 @@ def handle(self, **_): role, ) - # raise Exception('NOOOP') self.stdout.write( self.style.SUCCESS('Loaded sucessfully') ) diff --git a/apps/project/migrations/0001_initial.py b/apps/project/migrations/0001_initial.py index 0bd705b..f190b8f 100644 --- a/apps/project/migrations/0001_initial.py +++ b/apps/project/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.1 on 2023-06-07 10:35 +# Generated by Django 4.2.1 on 2023-08-29 08:10 from django.db import migrations, models @@ -18,6 +18,7 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True)), ('modified_at', models.DateTimeField(auto_now=True)), ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), ], options={ 'ordering': ['-id'], @@ -28,7 +29,7 @@ class Migration(migrations.Migration): name='ProjectMembership', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('role', models.PositiveSmallIntegerField(choices=[(0, 'Admin'), (1, 'Member')], default=1)), + ('role', models.PositiveSmallIntegerField(choices=[(1, 'Admin'), (2, 'Member'), (3, 'Viewer')], default=2)), ('joined_at', models.DateTimeField(auto_now_add=True)), ], ), diff --git a/apps/project/migrations/0002_initial.py b/apps/project/migrations/0002_initial.py index 417becc..c995d5d 100644 --- a/apps/project/migrations/0002_initial.py +++ b/apps/project/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.1 on 2023-06-07 10:35 +# Generated by Django 4.2.1 on 2023-08-29 08:10 from django.conf import settings from django.db import migrations, models @@ -10,8 +10,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('project', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ diff --git a/apps/project/migrations/0003_project_description.py b/apps/project/migrations/0003_project_description.py deleted file mode 100644 index 20a42c4..0000000 --- a/apps/project/migrations/0003_project_description.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.1 on 2023-08-07 06:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project', '0002_initial'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='description', - field=models.TextField(blank=True), - ), - ] diff --git a/apps/project/migrations/0004_alter_projectmembership_role.py b/apps/project/migrations/0004_alter_projectmembership_role.py deleted file mode 100644 index 547468d..0000000 --- a/apps/project/migrations/0004_alter_projectmembership_role.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.1 on 2023-08-08 11:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('project', '0003_project_description'), - ] - - operations = [ - migrations.AlterField( - model_name='projectmembership', - name='role', - field=models.PositiveSmallIntegerField(choices=[(1, 'Admin'), (2, 'Member'), (3, 'Viewer')], default=2), - ), - ] diff --git a/apps/qbank/apps.py b/apps/qbank/apps.py new file mode 100644 index 0000000..45bdc9b --- /dev/null +++ b/apps/qbank/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class QuestionBankConfig(AppConfig): + name = "apps.qbank" diff --git a/apps/qbank/models.py b/apps/qbank/models.py new file mode 100644 index 0000000..31115c8 --- /dev/null +++ b/apps/qbank/models.py @@ -0,0 +1,50 @@ +from django.db import models + +from apps.common.models import UserResource +from apps.questionnaire.models import ( + ChoiceCollection, + Choice, + QuestionGroup, + Question, +) + + +class QuestionBank(UserResource): + title = models.CharField(max_length=255) + is_draft = models.BooleanField(default=True) + + +class QBChoiceCollection(ChoiceCollection): + questionnaire = None + qbank = models.ForeignKey(QuestionBank, on_delete=models.CASCADE) + choice_set: models.QuerySet['QBChoice'] + + class Meta: + unique_together = ('qbank', 'name') + + +class QBChoice(Choice): + collection = models.ForeignKey(QBChoiceCollection, on_delete=models.CASCADE) + + class Meta: + unique_together = ('collection', 'name') + + +class QBGroup(QuestionGroup): + questionnaire = None + qbank = models.ForeignKey(QuestionBank, on_delete=models.CASCADE) + + class Meta: + unique_together = ('qbank', 'name') + + +class QBQuestion(Question): + questionnaire = None + qbank = models.ForeignKey(QuestionBank, on_delete=models.CASCADE) + group = models.ForeignKey(QBGroup, on_delete=models.CASCADE, null=True, blank=True) + choice_collection = models.ForeignKey( + QBChoiceCollection, + on_delete=models.PROTECT, + blank=True, + null=True, + ) diff --git a/apps/questionnaire/admin.py b/apps/questionnaire/admin.py index ef39eb6..74d40ac 100644 --- a/apps/questionnaire/admin.py +++ b/apps/questionnaire/admin.py @@ -1,7 +1,11 @@ from django.contrib import admin from admin_auto_filters.filters import AutocompleteFilterFactory -from .models import Questionnaire, Question +from .models import ( + Questionnaire, + Question, + QuestionLeafGroup, +) @admin.register(Questionnaire) @@ -27,3 +31,26 @@ class QuestionAdmin(admin.ModelAdmin): list_filter = ( AutocompleteFilterFactory('Questionnaire', 'questionnaire'), ) + + +@admin.register(QuestionLeafGroup) +class QuestionLeafGroupAdmin(admin.ModelAdmin): + search_fields = ('title',) + list_display = ( + 'name', + 'order', + 'type', + 'category_1', + 'category_2', + 'category_3', + 'category_4', + 'relevant', + ) + list_filter = ( + AutocompleteFilterFactory('Questionnaire', 'questionnaire'), + 'type', + 'category_1', + 'category_2', + 'category_3', + 'category_4', + ) diff --git a/apps/questionnaire/enums.py b/apps/questionnaire/enums.py index cd14f99..71188d7 100644 --- a/apps/questionnaire/enums.py +++ b/apps/questionnaire/enums.py @@ -1,16 +1,34 @@ import strawberry +from enum import Enum, auto from utils.strawberry.enums import get_enum_name_from_django_field -from .models import Question +from .models import Question, QuestionLeafGroup QuestionTypeEnum = strawberry.enum(Question.Type, name='QuestionTypeEnum') +QuestionLeafGroupTypeEnum = strawberry.enum(QuestionLeafGroup.Type, name='QuestionLeafGroupTypeEnum') +QuestionLeafGroupCategory1TypeEnum = strawberry.enum(QuestionLeafGroup.Category1, name='QuestionLeafGroupCategory1TypeEnum') +QuestionLeafGroupCategory2TypeEnum = strawberry.enum(QuestionLeafGroup.Category2, name='QuestionLeafGroupCategory2TypeEnum') +QuestionLeafGroupCategory3TypeEnum = strawberry.enum(QuestionLeafGroup.Category3, name='QuestionLeafGroupCategory3TypeEnum') +QuestionLeafGroupCategory4TypeEnum = strawberry.enum(QuestionLeafGroup.Category4, name='QuestionLeafGroupCategory4TypeEnum') enum_map = { get_enum_name_from_django_field(field): enum for field, enum in ( (Question.type, QuestionTypeEnum), + # QuestionLeafGroup + (QuestionLeafGroup.type, QuestionLeafGroupTypeEnum), + (QuestionLeafGroup.category_1, QuestionLeafGroupCategory1TypeEnum), + (QuestionLeafGroup.category_2, QuestionLeafGroupCategory2TypeEnum), + (QuestionLeafGroup.category_3, QuestionLeafGroupCategory3TypeEnum), + (QuestionLeafGroup.category_4, QuestionLeafGroupCategory4TypeEnum), ) } + + +@strawberry.enum +class QuestionLeafGroupVisibilityActionEnum(Enum): + SHOW = auto(), 'Show' + HIDE = auto(), 'Hide' diff --git a/apps/questionnaire/factories.py b/apps/questionnaire/factories.py index 6393f63..29c97ee 100644 --- a/apps/questionnaire/factories.py +++ b/apps/questionnaire/factories.py @@ -1,4 +1,6 @@ import factory +import functools +import random from factory.django import DjangoModelFactory from .models import ( @@ -6,7 +8,7 @@ Question, Choice, ChoiceCollection, - QuestionGroup, + QuestionLeafGroup, ) @@ -17,12 +19,103 @@ class Meta: model = Questionnaire -class QuestionGroupFactory(DjangoModelFactory): +class QuestionLeafGroupFactory(DjangoModelFactory): name = factory.Sequence(lambda n: f'Question-Group-{n}') - label = factory.Sequence(lambda n: f'Question-Group-{n}') class Meta: - model = QuestionGroup + model = QuestionLeafGroup + + @staticmethod + def random_category_generator(count): + categories = [] + _count = 0 + max_iter = 500 + while True: + _type = random.choice(list(QuestionLeafGroup.TYPE_CATEGORY_MAP.keys())) + category1, category2, category3, category4 = [None] * 4 + if _type == QuestionLeafGroup.Type.MATRIX_1D: + # Category 1 + category1_choices = list(QuestionLeafGroup.TYPE_CATEGORY_MAP[_type].keys()) + category1 = random.choice(category1_choices) + # Category 2 + category2_choices = list(QuestionLeafGroup.TYPE_CATEGORY_MAP[_type][category1]) + category2 = random.choice(category2_choices) + elif _type == QuestionLeafGroup.Type.MATRIX_2D: + # Rows + # -- Category 1 + category1_choices = list(QuestionLeafGroup.TYPE_CATEGORY_MAP[_type]['rows'].keys()) + category1 = random.choice(category1_choices) + # -- Category 2 + category2_choices = list(QuestionLeafGroup.TYPE_CATEGORY_MAP[_type]['rows'][category1]) + category2 = random.choice(category2_choices) + # Columns + # -- Category 3 + category3_choices = list(QuestionLeafGroup.TYPE_CATEGORY_MAP[_type]['columns'].keys()) + category3 = random.choice(category3_choices) + # -- Category 4 + category4_choices = list(QuestionLeafGroup.TYPE_CATEGORY_MAP[_type]['columns'][category3]) + if not category4_choices: + continue + if len(category4_choices) == 1: + category4 = category4_choices[0] + else: + category4 = random.choice(category4_choices) + categories.append((_type, category1, category2, category3, category4)) + _count += 1 + if count > max_iter or _count > count: + break + return set(categories) + + @staticmethod + @functools.lru_cache + def get_static_category_collections() -> dict: + matrix_1d = QuestionLeafGroup.Type.MATRIX_1D + matrix_2d = QuestionLeafGroup.Type.MATRIX_2D + return { + matrix_1d: [ + { + 'type': matrix_1d, + 'category_1': category_1, + 'category_2': category_2, + } + for category_1, categories_2 in QuestionLeafGroup.TYPE_CATEGORY_MAP[matrix_1d].items() + for category_2 in categories_2 + ], + matrix_2d: [ + { + 'type': matrix_1d, + 'category_1': category_1, + 'category_2': category_2, + 'category_3': category_3, + 'category_4': category_4, + } + for category_1, categories_2 in QuestionLeafGroup.TYPE_CATEGORY_MAP[matrix_2d]['rows'].items() + for category_2 in categories_2 + for category_3, categories_4 in QuestionLeafGroup.TYPE_CATEGORY_MAP[matrix_2d]['columns'].items() + for category_4 in categories_4 + if len(categories_4) > 0 # TODO: Support for empty category_4 + ], + } + + @classmethod + def static_generator(cls, count, **kwargs): + _type = kwargs.get('type', QuestionLeafGroup.Type.MATRIX_1D) + collections = cls.get_static_category_collections()[_type] + if len(collections) < count: + raise Exception('Provided count is higher then avaiable iteration') + leaf_groups = [] + for group_data in collections[:count]: + leaf_groups.append( + QuestionLeafGroupFactory( + type=group_data['type'], + category_1=group_data['category_1'], + category_2=group_data['category_2'], + category_3=group_data.get('category_3'), + category_4=group_data.get('category_4'), + **kwargs, + ) + ) + return leaf_groups class ChoiceFactory(DjangoModelFactory): @@ -44,6 +137,7 @@ class Meta: class QuestionFactory(DjangoModelFactory): name = factory.Sequence(lambda n: f'Question-{n}') label = factory.Sequence(lambda n: f'Question-{n}') + type = Question.Type.INTEGER class Meta: model = Question diff --git a/apps/questionnaire/filters.py b/apps/questionnaire/filters.py index c2ec59b..ff49858 100644 --- a/apps/questionnaire/filters.py +++ b/apps/questionnaire/filters.py @@ -1,11 +1,14 @@ import strawberry import strawberry_django -from .enums import QuestionTypeEnum +from .enums import ( + QuestionTypeEnum, + QuestionLeafGroupTypeEnum, +) from .models import ( Questionnaire, Question, - QuestionGroup, + QuestionLeafGroup, ChoiceCollection, ) @@ -17,13 +20,13 @@ class QuestionnaireFilter: title: strawberry.auto -@strawberry_django.filters.filter(QuestionGroup, lookups=True) -class QuestionGroupFilter: +@strawberry_django.filters.filter(QuestionLeafGroup, lookups=True) +class QuestionLeafGroupFilter: id: strawberry.auto questionnaire: strawberry.auto - parent: strawberry.auto name: strawberry.auto - label: strawberry.auto + is_hidden: strawberry.auto + type: QuestionLeafGroupTypeEnum @strawberry_django.filters.filter(ChoiceCollection, lookups=True) @@ -42,21 +45,21 @@ class QuestionFilter: type: QuestionTypeEnum name: strawberry.auto label: strawberry.auto - group: strawberry.auto + leaf_group: strawberry.auto include_child_group: bool | None = False - def filter_group(self, queryset): + def filter_leaf_group(self, queryset): # NOTE: logic is in filter_include_child_group return queryset def filter_include_child_group(self, queryset): - if self.group is strawberry.UNSET: + if self.leaf_group is strawberry.UNSET: # Nothing to do here return queryset if not self.include_child_group: - return queryset.filter(group=self.group.pk) + return queryset.filter(group=self.leaf_group.pk) all_groups = [ - self.group.pk, - # TODO: *get_child_groups_id(self.group.pk), + self.leaf_group.pk, + # TODO: *get_child_groups_id(self.leaf_group.pk), ] return queryset.filter(group__in=all_groups) diff --git a/apps/questionnaire/migrations/0001_initial.py b/apps/questionnaire/migrations/0001_initial.py index 40c8b56..6e17b76 100644 --- a/apps/questionnaire/migrations/0001_initial.py +++ b/apps/questionnaire/migrations/0001_initial.py @@ -1,9 +1,7 @@ -# Generated by Django 4.2.1 on 2023-08-09 06:24 +# Generated by Django 4.2.1 on 2023-08-29 08:10 -from django.conf import settings import django.contrib.gis.db.models.fields from django.db import migrations, models -import django.db.models.deletion import utils.common @@ -12,45 +10,17 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0004_alter_projectmembership_role'), ] operations = [ migrations.CreateModel( - name='Questionnaire', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.project')), - ], - options={ - 'ordering': ['-id'], - 'abstract': False, - }, - ), - migrations.CreateModel( - name='QuestionGroup', + name='Choice', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), ('name', models.CharField(max_length=255)), ('label', models.CharField(max_length=255)), - ('relevant', models.CharField(max_length=255)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questiongroup')), - ('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questionnaire')), + ('geometry', django.contrib.gis.db.models.fields.GeometryField(blank=True, null=True, srid=4326)), ], - options={ - 'unique_together': {('questionnaire', 'name')}, - }, ), migrations.CreateModel( name='ChoiceCollection', @@ -60,13 +30,7 @@ class Migration(migrations.Migration): ('modified_at', models.DateTimeField(auto_now=True)), ('name', models.CharField(max_length=255)), ('label', models.CharField(max_length=255)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)), - ('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questionnaire')), ], - options={ - 'unique_together': {('questionnaire', 'name')}, - }, ), migrations.CreateModel( name='Question', @@ -75,30 +39,63 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True)), ('modified_at', models.DateTimeField(auto_now=True)), ('type', models.PositiveSmallIntegerField(choices=[(1, 'Integer (i.e., whole number) input.'), (2, 'Decimal input.'), (3, 'Free text response.'), (4, 'Multiple choice question; only one answer can be selected.'), (5, 'Multiple choice question; multiple answers can be selected.'), (6, 'Rank question; order a list.'), (7, 'Range input (including rating)'), (10, 'Display a note on the screen, takes no input. Shorthand for type=text with readonly=true.'), (14, 'Date input.'), (15, 'Time input.'), (16, 'Accepts a date and a time input.'), (17, 'Take a picture or upload an image file.'), (18, 'Take an audio recording or upload an audio file.'), (20, 'Take a video recording or upload a video file.'), (21, 'Generic file input (txt, pdf, xls, xlsx, doc, docx, rtf, zip)'), (22, 'Scan a barcode, requires the barcode scanner app to be installed.'), (24, 'Acknowledge prompt that sets value to "OK" if selected.')])), + ('order', models.PositiveSmallIntegerField(default=0)), ('name', models.CharField(max_length=255, validators=[utils.common.validate_xlsform_name])), ('label', models.TextField()), ('hint', models.TextField(blank=True)), - ('choice_collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='questionnaire.choicecollection')), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)), - ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questiongroup')), - ('modified_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)), - ('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questionnaire')), + ('default', models.TextField(blank=True)), + ('guidance_hint', models.TextField(blank=True)), + ('trigger', models.CharField(blank=True, max_length=255)), + ('readonly', models.CharField(blank=True, max_length=255)), + ('required', models.BooleanField(default=False)), + ('required_message', models.CharField(blank=True, max_length=255)), + ('relevant', models.CharField(blank=True, max_length=255)), + ('constraint', models.CharField(blank=True, max_length=255)), + ('appearance', models.CharField(blank=True, max_length=255)), + ('calculation', models.CharField(blank=True, max_length=255)), + ('parameters', models.CharField(blank=True, max_length=255)), + ('choice_filter', models.CharField(blank=True, max_length=255)), + ('image', models.CharField(blank=True, max_length=255)), + ('video', models.CharField(blank=True, max_length=255)), + ('is_or_other', models.BooleanField(default=False)), + ('or_other_label', models.TextField(blank=True)), ], options={ - 'unique_together': {('questionnaire', 'name')}, + 'ordering': ('leaf_group__order', 'order'), }, ), migrations.CreateModel( - name='Choice', + name='QuestionLeafGroup', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), ('name', models.CharField(max_length=255)), ('label', models.CharField(max_length=255)), - ('geometry', django.contrib.gis.db.models.fields.GeometryField(blank=True, null=True, srid=4326)), - ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.choicecollection')), + ('type', models.PositiveSmallIntegerField(choices=[(1, 'Matrix 1D'), (2, 'Matrix 2D')])), + ('order', models.PositiveSmallIntegerField(default=0)), + ('category_1', models.PositiveSmallIntegerField(choices=[(101, '1. Context'), (102, '2. Event/Shock'), (103, '3. Displacement'), (104, '4. Casualties'), (105, '5. Information And Communication'), (106, '6. Humanitarian Access'), (201, '7. Impact'), (202, '8. Humanitarian Conditions'), (203, '9. At Risk'), (204, '10. Priorities'), (205, '11. Capacities Response')])), + ('category_2', models.PositiveSmallIntegerField(choices=[(1001, 'Politics'), (1002, 'Demography'), (1003, 'Socio Cultural'), (1004, 'Environment'), (1005, 'Security And Stability'), (1006, 'Economics'), (1101, 'Characteristics'), (1102, 'Drivers And Aggravating Factors'), (1103, 'Mitigating Factors'), (1104, 'Hazards And Threats'), (1201, 'Characteristics'), (1202, 'Push Factors'), (1203, 'Pull Factors'), (1204, 'Intentions'), (1205, 'Local Integration'), (1301, 'Dead'), (1302, 'Injured'), (1303, 'Missing'), (1401, 'Source And Means'), (1402, 'Challendges And Barriers'), (1403, 'Knowledge And Info Gaps (Humanitarian)'), (1404, 'Knowledge And Info Gaps POPULATION)'), (1501, 'Population To Relief'), (1502, 'Relief To Population'), (1503, 'Physical And Security'), (1504, 'Number Of People Facing Hum. Access Constraints'), (2001, 'Drivers'), (2002, 'Impact On People'), (2003, 'Impact On Systems Services Network'), (2101, 'Living Standards'), (2102, 'Coping Mechanisms'), (2103, 'Physical And Mental Wellbeing'), (2201, 'People At risk'), (2301, 'Priotiy Issues (Pop)'), (2302, 'Priotiy Issues (Hum)'), (2303, 'Priotiy Interventions (Pop)'), (2304, 'Priotiy Interventions (Hum)'), (2401, 'Government Local Authorities'), (2402, 'International Organisations'), (2403, 'Nation And Local Organisations'), (2404, 'Red Cross Red Crescent'), (2405, 'Humanitarian Coordination')])), + ('category_3', models.PositiveSmallIntegerField(blank=True, choices=[(1001, 'Cross'), (1002, 'Food'), (1003, 'Wash'), (1004, 'Health'), (1005, 'Protection'), (1006, 'Education'), (1007, 'Livelihood'), (1008, 'Nutrition'), (1009, 'Agriculture'), (1010, 'Logistics'), (1011, 'Shelter'), (1012, 'Analytical Outputs')], null=True)), + ('category_4', models.PositiveSmallIntegerField(blank=True, choices=[(3001, 'Water'), (3002, 'Sanitation'), (3003, 'Hygiene'), (3004, 'Waste Management'), (3005, 'Vector Control'), (4001, 'HEALTH CARE'), (4002, 'HEALTH STATUS'), (5001, 'DOCUMENTATION'), (5002, 'CIVIL AND POLITICAL RIGHTS'), (5003, 'PHYSICAL SAFETY AND SECURITY'), (5004, 'FREEDOM OF MOVEMENT'), (5005, 'LIBERTY'), (5006, 'CHILD PROTECTION'), (5007, 'SGBV'), (5008, 'HOUSING LAND AND PROPERTY'), (5009, 'JUSTICE AND RULE OF LAW'), (5010, 'MINES'), (5011, 'HUMAN TRAFFICKING'), (6001, 'LEARNING ENVIRONMENT'), (6002, 'FACILITIES AND AMENITIES'), (6003, 'TEACHER AND LEARNING'), (6004, 'TEACHERS AND EDUCATION PERSONNEL'), (7001, 'INCOME'), (7002, 'EXPENDITURES'), (7003, 'PRODUCTIVE ASSETS'), (7004, 'SKILLS AND QUALIFICATIONS'), (8001, 'NUTRITION GOODS AND SERVICES'), (8002, 'NUTRITION STATUS'), (12001, 'DWELLING ENVELOPPE'), (12002, 'INTERIOR DOMENSTIC LIFE')], null=True)), + ('relevant', models.CharField(blank=True, max_length=255)), + ], + options={ + 'ordering': ('order',), + }, + ), + migrations.CreateModel( + name='Questionnaire', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), ], options={ - 'unique_together': {('collection', 'name')}, + 'ordering': ['-id'], + 'abstract': False, }, ), ] diff --git a/apps/questionnaire/migrations/0002_initial.py b/apps/questionnaire/migrations/0002_initial.py new file mode 100644 index 0000000..afdeca2 --- /dev/null +++ b/apps/questionnaire/migrations/0002_initial.py @@ -0,0 +1,118 @@ +# Generated by Django 4.2.1 on 2023-08-29 08:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('questionnaire', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('project', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='questionnaire', + name='created_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='questionnaire', + name='modified_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='questionnaire', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.project'), + ), + migrations.AddField( + model_name='questionleafgroup', + name='created_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='questionleafgroup', + name='modified_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='questionleafgroup', + name='questionnaire', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questionnaire'), + ), + migrations.AddField( + model_name='question', + name='choice_collection', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='questionnaire.choicecollection'), + ), + migrations.AddField( + model_name='question', + name='created_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='question', + name='leaf_group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questionleafgroup'), + ), + migrations.AddField( + model_name='question', + name='modified_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='question', + name='questionnaire', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questionnaire'), + ), + migrations.AddField( + model_name='choicecollection', + name='created_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='choicecollection', + name='modified_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='choicecollection', + name='questionnaire', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.questionnaire'), + ), + migrations.AddField( + model_name='choice', + name='collection', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questionnaire.choicecollection'), + ), + migrations.AddIndex( + model_name='questionleafgroup', + index=models.Index(fields=['order'], name='questionnai_order_1aedf4_idx'), + ), + migrations.AlterUniqueTogether( + name='questionleafgroup', + unique_together={('questionnaire', 'name')}, + ), + migrations.AddIndex( + model_name='question', + index=models.Index(fields=['order'], name='order_idx'), + ), + migrations.AlterUniqueTogether( + name='question', + unique_together={('questionnaire', 'name')}, + ), + migrations.AlterUniqueTogether( + name='choicecollection', + unique_together={('questionnaire', 'name')}, + ), + migrations.AlterUniqueTogether( + name='choice', + unique_together={('collection', 'name')}, + ), + ] diff --git a/apps/questionnaire/migrations/0002_question_appearance_question_calculation_and_more.py b/apps/questionnaire/migrations/0002_question_appearance_question_calculation_and_more.py deleted file mode 100644 index e45514c..0000000 --- a/apps/questionnaire/migrations/0002_question_appearance_question_calculation_and_more.py +++ /dev/null @@ -1,98 +0,0 @@ -# Generated by Django 4.2.1 on 2023-08-17 05:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questionnaire', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='question', - name='appearance', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='calculation', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='choice_filter', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='constraint', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='default', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='question', - name='guidance_hint', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='question', - name='image', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='is_or_other', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='question', - name='or_other_label', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='question', - name='parameters', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='readonly', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='relevant', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='required', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='question', - name='required_message', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='trigger', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='question', - name='video', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AlterField( - model_name='questiongroup', - name='relevant', - field=models.CharField(blank=True, max_length=255), - ), - ] diff --git a/apps/questionnaire/migrations/0003_remove_questionleafgroup_label_and_more.py b/apps/questionnaire/migrations/0003_remove_questionleafgroup_label_and_more.py new file mode 100644 index 0000000..e322d95 --- /dev/null +++ b/apps/questionnaire/migrations/0003_remove_questionleafgroup_label_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2023-08-29 09:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('questionnaire', '0002_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='questionleafgroup', + name='label', + ), + migrations.AlterField( + model_name='questionleafgroup', + name='category_4', + field=models.PositiveSmallIntegerField(blank=True, choices=[(3001, 'Water'), (3002, 'Sanitation'), (3003, 'Hygiene'), (3004, 'Waste Management'), (3005, 'Vector Control'), (4001, 'Health Care'), (4002, 'Health Status'), (5001, 'Documentation'), (5002, 'Civil And Political Rights'), (5003, 'Physical Safety And Security'), (5004, 'Freedom Of Movement'), (5005, 'Liberty'), (5006, 'Child Protection'), (5007, 'SGBV'), (5008, 'housing Land And Property'), (5009, 'Justice And Rule Of Law'), (5010, 'MINES'), (5011, 'Human Trafficking'), (6001, 'Learning Environment'), (6002, 'Facilities And Amenities'), (6003, 'Teacher And Learning'), (6004, 'Teachers And Education Personnel'), (7001, 'Income'), (7002, 'Expenditures'), (7003, 'Productive Assets'), (7004, 'Skills And Qualifications'), (8001, 'Nutrition Goods And Services'), (8002, 'Nutrition Status'), (12001, 'Dwelling Enveloppe'), (12002, 'Interior Domenstic Life')], null=True), + ), + ] diff --git a/apps/questionnaire/migrations/0004_rename_order_idx_questionnai_order_435c27_idx_and_more.py b/apps/questionnaire/migrations/0004_rename_order_idx_questionnai_order_435c27_idx_and_more.py new file mode 100644 index 0000000..36be6e6 --- /dev/null +++ b/apps/questionnaire/migrations/0004_rename_order_idx_questionnai_order_435c27_idx_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.1 on 2023-08-31 08:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('questionnaire', '0003_remove_questionleafgroup_label_and_more'), + ] + + operations = [ + migrations.RenameIndex( + model_name='question', + new_name='questionnai_order_435c27_idx', + old_name='order_idx', + ), + migrations.AlterUniqueTogether( + name='questionleafgroup', + unique_together={('category_1', 'category_2', 'category_3', 'category_4'), ('questionnaire', 'name')}, + ), + migrations.AddField( + model_name='questionleafgroup', + name='is_hidden', + field=models.BooleanField(default=False), + ), + migrations.AddIndex( + model_name='question', + index=models.Index(fields=['name'], name='questionnai_name_992c67_idx'), + ), + migrations.AddIndex( + model_name='questionleafgroup', + index=models.Index(fields=['name'], name='questionnai_name_48d926_idx'), + ), + ] diff --git a/apps/questionnaire/models.py b/apps/questionnaire/models.py index 6afeb0c..f76ef5f 100644 --- a/apps/questionnaire/models.py +++ b/apps/questionnaire/models.py @@ -1,5 +1,7 @@ +import typing from django.db import models from django.contrib.gis.db import models as gid_models +from django.core.exceptions import ValidationError # from django.contrib.postgres.fields import ArrayField from utils.common import get_queryset_for_model, validate_xlsform_name @@ -21,6 +23,7 @@ class MetadataCollection(models.IntegerChoices): title = models.CharField(max_length=255) project = models.ForeignKey(Project, on_delete=models.CASCADE) description = models.TextField(blank=True) + # qbank = models.ForeignKey('qbank.QuestionBank', on_delete=models.PROTECT) # Metadata # https://xlsform.org/en/#metadata @@ -52,6 +55,7 @@ class MetadataCollection(models.IntegerChoices): # - style: For web forms, specify the form style. Learn more. # - name: XForms root node name. This is rarely needed, learn more. project_id: int + question_set: models.QuerySet['Question'] def __str__(self): return self.title @@ -61,6 +65,11 @@ def get_for(cls, user, queryset=None): project_qs = Project.get_for(user) return get_queryset_for_model(cls, queryset=queryset).filter(project__in=project_qs) + def delete(self): + # Delete questions first as question depends on other attributes which will through PROTECT error + self.question_set.all().delete() + return super().delete() + class ChoiceCollection(UserResource): questionnaire = models.ForeignKey(Questionnaire, on_delete=models.CASCADE) @@ -83,24 +92,382 @@ class Meta: unique_together = ('collection', 'name') -class QuestionGroup(UserResource): +class QuestionLeafGroup(UserResource): + # NOTE: Only created by system right now + + class Type(models.IntegerChoices): + MATRIX_1D = 1, 'Matrix 1D' + MATRIX_2D = 2, 'Matrix 2D' + + class Category1(models.IntegerChoices): + # MATRIX 1D (ROWS) + CONTEXT = 101, '1. Context' + EVENT_SHOCK = 102, '2. Event/Shock' + DISPLACEMENT = 103, '3. Displacement' + CASUALTIES = 104, '4. Casualties' + INFORMATION_AND_COMMUNICATION = 105, '5. Information And Communication' + HUMANITARIAN_ACCESS = 106, '6. Humanitarian Access' + # Matrix 2D (ROWS) + IMPACT = 201, '7. Impact' + HUMANITARIAN_CONDITIONS = 202, '8. Humanitarian Conditions' + AT_RISK = 203, '9. At Risk' + PRIORITIES = 204, '10. Priorities' + CAPACITIES_RESPONSE = 205, '11. Capacities Response' + + class Category2(models.IntegerChoices): + # MATRIX 1D (SUB-ROWS) + # -- CONTEXT + POLITICS = 1001, 'Politics' + DEMOGRAPHY = 1002, 'Demography' + SOCIO_CULTURAL = 1003, 'Socio Cultural' + ENVIRONMENT = 1004, 'Environment' + SECURITY_AND_STABILITY = 1005, 'Security And Stability' + ECONOMICS = 1006, 'Economics' + # -- EVENT_SHOCK + EVENT_SHOCK_CHARACTERISTICS = 1101, 'Characteristics' + DRIVERS_AND_AGGRAVATING_FACTORS = 1102, 'Drivers And Aggravating Factors' + MITIGATING_FACTORS = 1103, 'Mitigating Factors' + HAZARDS_AND_THREATS = 1104, 'Hazards And Threats' + # -- DISPLACEMENT + DISPLACEMENT_CHARACTERISTICS = 1201, 'Characteristics' + PUSH_FACTORS = 1202, 'Push Factors' + PULL_FACTORS = 1203, 'Pull Factors' + INTENTIONS = 1204, 'Intentions' + LOCAL_INTEGRATION = 1205, 'Local Integration' + # -- CASUALTIES + DEAD = 1301, 'Dead' + INJURED = 1302, 'Injured' + MISSING = 1303, 'Missing' + # -- INFORMATION_AND_COMMUNICATION + SOURCE_AND_MEANS = 1401, 'Source And Means' + CHALLENDGES_AND_BARRIERS = 1402, 'Challendges And Barriers' + KNOWLEDGE_AND_INFO_GAPS_HUMANITARIAN = 1403, 'Knowledge And Info Gaps (Humanitarian)' + KNOWLEDGE_AND_INFO_GAPS_POPULATION = 1404, 'Knowledge And Info Gaps POPULATION)' + # -- HUMANITARIAN_ACCESS + POPULATION_TO_RELIEF = 1501, 'Population To Relief' + RELIEF_TO_POPULATION = 1502, 'Relief To Population' + PHYSICAL_AND_SECURITY = 1503, 'Physical And Security' + NUMBER_OF_PEOPLE_FACING_HUMANITARIN_ACCESS_CONSTRAINTS = 1504, 'Number Of People Facing Hum. Access Constraints' + # Matrix 2D (Sub-ROWS) + # -- IMPACT + DRIVERS = 2001, 'Drivers' + IMPACT_ON_PEOPLE = 2002, 'Impact On People' + IMPACT_ON_SYSTEMS_SERVICES_NETWORK = 2003, 'Impact On Systems Services Network' + # -- HUMANITARIAN_CONDITIONS + LIVING_STANDARDS = 2101, 'Living Standards' + COPING_MECHANISMS = 2102, 'Coping Mechanisms' + PHYSICAL_AND_MENTAL_WELLBEING = 2103, 'Physical And Mental Wellbeing' + # -- AT_RISK + PEOPLE_AT_RISK = 2201, 'People At risk' + # -- PRIORITIES + PRIOTIY_ISSUES_POP = 2301, 'Priotiy Issues (Pop)' + PRIOTIY_ISSUES_HUM = 2302, 'Priotiy Issues (Hum)' + PRIOTIY_INTERVENTIONS_POP = 2303, 'Priotiy Interventions (Pop)' + PRIOTIY_INTERVENTIONS_HUM = 2304, 'Priotiy Interventions (Hum)' + # -- CAPACITIES_RESPONSE + GOVERNMENT_LOCAL_AUTHORITIES = 2401, 'Government Local Authorities' + INTERNATIONAL_ORGANISATIONS = 2402, 'International Organisations' + NATION_AND_LOCAL_ORGANISATIONS = 2403, 'Nation And Local Organisations' + RED_CROSS_RED_CRESCENT = 2404, 'Red Cross Red Crescent' + HUMANITARIAN_COORDINATION = 2405, 'Humanitarian Coordination' + + class Category3(models.IntegerChoices): + # MATRIX 2D (SUB-COLUMNS) + CROSS = 1001, 'Cross' + FOOD = 1002, 'Food' + WASH = 1003, 'Wash' + HEALTH = 1004, 'Health' + PROTECTION = 1005, 'Protection' + EDUCATION = 1006, 'Education' + LIVELIHOOD = 1007, 'Livelihood' + NUTRITION = 1008, 'Nutrition' + AGRICULTURE = 1009, 'Agriculture' + LOGISTICS = 1010, 'Logistics' + SHELTER = 1011, 'Shelter' + ANALYTICAL_OUTPUTS = 1012, 'Analytical Outputs' + + class Category4(models.IntegerChoices): + # MATRIX 2D (COLUMNS) + # -- CROSS + # -- FOOD + # -- WASH + WATER = 3001, 'Water' + SANITATION = 3002, 'Sanitation' + HYGIENE = 3003, 'Hygiene' + WASTE_MANAGEMENT = 3004, 'Waste Management' + VECTOR_CONTROL = 3005, 'Vector Control' + # -- HEALTH + HEALTH_CARE = 4001, 'Health Care' + HEALTH_STATUS = 4002, 'Health Status' + # -- PROTECTION + DOCUMENTATION = 5001, 'Documentation' + CIVIL_AND_POLITICAL_RIGHTS = 5002, 'Civil And Political Rights' + PHYSICAL_SAFETY_AND_SECURITY = 5003, 'Physical Safety And Security' + FREEDOM_OF_MOVEMENT = 5004, 'Freedom Of Movement' + LIBERTY = 5005, 'Liberty' + CHILD_PROTECTION = 5006, 'Child Protection' + SGBV = 5007, 'SGBV' + HOUSING_LAND_AND_PROPERTY = 5008, 'housing Land And Property' + JUSTICE_AND_RULE_OF_LAW = 5009, 'Justice And Rule Of Law' + MINES = 5010, 'MINES' + HUMAN_TRAFFICKING = 5011, 'Human Trafficking' + # -- EDUCATION + LEARNING_ENVIRONMENT = 6001, 'Learning Environment' + FACILITIES_AND_AMENITIES = 6002, 'Facilities And Amenities' + TEACHER_AND_LEARNING = 6003, 'Teacher And Learning' + TEACHERS_AND_EDUCATION_PERSONNEL = 6004, 'Teachers And Education Personnel' + # -- LIVELIHOOD + INCOME = 7001, 'Income' + EXPENDITURES = 7002, 'Expenditures' + PRODUCTIVE_ASSETS = 7003, 'Productive Assets' + SKILLS_AND_QUALIFICATIONS = 7004, 'Skills And Qualifications' + # -- NUTRITION + NUTRITION_GOODS_AND_SERVICES = 8001, 'Nutrition Goods And Services' + NUTRITION_STATUS = 8002, 'Nutrition Status' + # -- AGRICULTURE + # -- LOGISTICS + # -- SHELTER + DWELLING_ENVELOPPE = 12001, 'Dwelling Enveloppe' + INTERIOR_DOMENSTIC_LIFE = 12002, 'Interior Domenstic Life' + # -- ANALYTICAL_OUTPUTS + + TYPE_CATEGORY_MAP = { + Type.MATRIX_1D: { + Category1.CONTEXT: { + Category2.POLITICS, + Category2.DEMOGRAPHY, + Category2.SOCIO_CULTURAL, + Category2.ENVIRONMENT, + Category2.SECURITY_AND_STABILITY, + Category2.ECONOMICS, + }, + Category1.EVENT_SHOCK: { + Category2.EVENT_SHOCK_CHARACTERISTICS, + Category2.DRIVERS_AND_AGGRAVATING_FACTORS, + Category2.MITIGATING_FACTORS, + Category2.HAZARDS_AND_THREATS, + }, + Category1.DISPLACEMENT: { + Category2.DISPLACEMENT_CHARACTERISTICS, + Category2.PUSH_FACTORS, + Category2.PULL_FACTORS, + Category2.INTENTIONS, + Category2.LOCAL_INTEGRATION, + }, + Category1.CASUALTIES: { + Category2.DEAD, + Category2.INJURED, + Category2.MISSING, + }, + Category1.INFORMATION_AND_COMMUNICATION: { + Category2.SOURCE_AND_MEANS, + Category2.CHALLENDGES_AND_BARRIERS, + Category2.KNOWLEDGE_AND_INFO_GAPS_HUMANITARIAN, + Category2.KNOWLEDGE_AND_INFO_GAPS_POPULATION, + }, + Category1.HUMANITARIAN_ACCESS: { + Category2.POPULATION_TO_RELIEF, + Category2.RELIEF_TO_POPULATION, + Category2.PHYSICAL_AND_SECURITY, + Category2.NUMBER_OF_PEOPLE_FACING_HUMANITARIN_ACCESS_CONSTRAINTS, + }, + }, + Type.MATRIX_2D: { + # rows: sub-rows + 'rows': { + Category1.IMPACT: { + Category2.DRIVERS, + Category2.IMPACT_ON_PEOPLE, + Category2.IMPACT_ON_SYSTEMS_SERVICES_NETWORK, + }, + Category1.HUMANITARIAN_CONDITIONS: { + Category2.LIVING_STANDARDS, + Category2.COPING_MECHANISMS, + Category2.PHYSICAL_AND_MENTAL_WELLBEING, + }, + Category1.AT_RISK: { + Category2.PEOPLE_AT_RISK, + }, + Category1.PRIORITIES: { + Category2.PRIOTIY_ISSUES_POP, + Category2.PRIOTIY_ISSUES_HUM, + Category2.PRIOTIY_INTERVENTIONS_POP, + Category2.PRIOTIY_INTERVENTIONS_HUM, + }, + Category1.CAPACITIES_RESPONSE: { + Category2.GOVERNMENT_LOCAL_AUTHORITIES, + Category2.INTERNATIONAL_ORGANISATIONS, + Category2.NATION_AND_LOCAL_ORGANISATIONS, + Category2.RED_CROSS_RED_CRESCENT, + Category2.HUMANITARIAN_COORDINATION, + }, + }, + # columns: sub-columns + 'columns': { + Category3.CROSS: {}, + Category3.FOOD: {}, + Category3.WASH: { + Category4.WATER, + Category4.SANITATION, + Category4.HYGIENE, + Category4.WASTE_MANAGEMENT, + Category4.VECTOR_CONTROL, + }, + Category3.HEALTH: { + Category4.HEALTH_CARE, + Category4.HEALTH_STATUS, + }, + Category3.PROTECTION: { + Category4.DOCUMENTATION, + Category4.CIVIL_AND_POLITICAL_RIGHTS, + Category4.PHYSICAL_SAFETY_AND_SECURITY, + Category4.FREEDOM_OF_MOVEMENT, + Category4.LIBERTY, + Category4.CHILD_PROTECTION, + Category4.SGBV, + Category4.HOUSING_LAND_AND_PROPERTY, + Category4.JUSTICE_AND_RULE_OF_LAW, + Category4.MINES, + Category4.HUMAN_TRAFFICKING, + }, + Category3.EDUCATION: { + Category4.LEARNING_ENVIRONMENT, + Category4.FACILITIES_AND_AMENITIES, + Category4.TEACHER_AND_LEARNING, + Category4.TEACHERS_AND_EDUCATION_PERSONNEL, + }, + Category3.LIVELIHOOD: { + Category4.INCOME, + Category4.EXPENDITURES, + Category4.PRODUCTIVE_ASSETS, + Category4.SKILLS_AND_QUALIFICATIONS, + }, + Category3.NUTRITION: { + Category4.NUTRITION_GOODS_AND_SERVICES, + Category4.NUTRITION_STATUS, + }, + Category3.AGRICULTURE: {}, + Category3.LOGISTICS: {}, + Category3.SHELTER: { + Category4.DWELLING_ENVELOPPE, + Category4.INTERIOR_DOMENSTIC_LIFE, + }, + Category3.ANALYTICAL_OUTPUTS: {}, + }, + } + } + questionnaire = models.ForeignKey(Questionnaire, on_delete=models.CASCADE) - parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True) + # TODO: Make sure this is unique within questions and groups name = models.CharField(max_length=255) - label = models.CharField(max_length=255) + type = models.PositiveSmallIntegerField(choices=Type.choices) + order = models.PositiveSmallIntegerField(default=0) + is_hidden = models.BooleanField(default=False) + + # Categories + # TODO: UNIQUE CHECK + # -- For Matrix1D/Matrix2D + category_1 = models.PositiveSmallIntegerField(choices=Category1.choices) + category_2 = models.PositiveSmallIntegerField(choices=Category2.choices) + # -- For Matrix2D + category_3 = models.PositiveSmallIntegerField(choices=Category3.choices, null=True, blank=True) + category_4 = models.PositiveSmallIntegerField(choices=Category4.choices, null=True, blank=True) + + # Misc relevant = models.CharField(max_length=255, blank=True) # ${has_child} = 'yes' - # # Repeat attributes - # is_repeat = models.BooleanField(default=False) - # repeat_count = models.CharField(max_length=255) # Eg: static: 3, formula: ${num_hh_members} + + # Dynamic Types + get_type_display: typing.Callable[[], str] + get_category_1_display: typing.Callable[[], str] + get_category_2_display: typing.Callable[[], str] + get_category_3_display: typing.Callable[[], typing.Optional[str]] + get_category_4_display: typing.Callable[[], typing.Optional[str]] class Meta: - unique_together = ('questionnaire', 'name') + unique_together = [ + ('questionnaire', 'name'), + ('category_1', 'category_2', 'category_3', 'category_4'), + ] + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['order']), + ] + ordering = ('order',) + + def __str__(self): + if self.type == self.Type.MATRIX_1D: + return '::'.join([ + self.get_category_1_display(), + self.get_category_2_display(), + ]) + return '::'.join([ + self.get_category_1_display(), + self.get_category_2_display(), + self.get_category_3_display() or '-', + self.get_category_4_display() or '-', + ]) + + def clean(self): + # NOTE: For now this is generated from system, so validating here + # Matrix 1D + if self.type == QuestionLeafGroup.Type.MATRIX_1D: + if self.category_1 not in QuestionLeafGroup.TYPE_CATEGORY_MAP[self.type]: + raise ValidationError('Wrong category 1 provided for Matrix 1D') + if self.category_2 not in QuestionLeafGroup.TYPE_CATEGORY_MAP[self.type][self.category_1]: + raise ValidationError('Wrong category 2 provided for Matrix 1D') + if self.category_3 is not None or self.category_4 is not None: + raise ValidationError('Category 3/4 are only for Matrix 2D') + # Matrix 2D + elif self.type == QuestionLeafGroup.Type.MATRIX_2D: + if self.category_1 not in QuestionLeafGroup.TYPE_CATEGORY_MAP[self.type]['rows']: + raise ValidationError('Wrong category 1 provided for Matrix 2D') + if self.category_2 not in QuestionLeafGroup.TYPE_CATEGORY_MAP[self.type]['rows'][self.category_1]: + raise ValidationError('Wrong category 2 provided for Matrix 2D') + if self.category_3 is None or self.category_4 is None: + raise ValidationError('Category 3/4 needs to be defined for Matrix 2D') + if self.category_3 not in QuestionLeafGroup.TYPE_CATEGORY_MAP[self.type]['columns']: + raise ValidationError('Wrong category 3 provided for Matrix 2D') + if self.category_4 not in QuestionLeafGroup.TYPE_CATEGORY_MAP[self.type]['columns'][self.category_3]: + # TODO: Add support for nullable category 4 + raise ValidationError('Wrong category 4 provided for Matrix 2D') + else: + raise ValidationError('Not implemented type') + + def save(self, *args, **kwargs): + # NOTE: For now this is generated from system, so validating here + self.clean() + existing_leaf_groups_qs = QuestionLeafGroup.objects.filter( + # Scope by questionnaire + questionnaire=self.questionnaire, + ) + if self.pk: + existing_leaf_groups_qs = existing_leaf_groups_qs.exclude(pk=self.pk) + # Matrix 1D + if self.type == QuestionLeafGroup.Type.MATRIX_1D: + qs = existing_leaf_groups_qs.filter( + category_1=self.category_1, + category_2=self.category_2, + ) + if qs.exists(): + raise ValidationError('Already exists') + # Matrix 2D + elif self.type == QuestionLeafGroup.Type.MATRIX_2D: + qs = existing_leaf_groups_qs.filter( + category_1=self.category_1, + category_2=self.category_2, + category_3=self.category_3, + category_4=self.category_4, + ) + if qs.exists(): + raise ValidationError('Already exists') + else: + raise ValidationError('Not implemented type') + return super().save(*args, **kwargs) class Question(UserResource): class Type(models.IntegerChoices): # https://xlsform.org/en/#question-types - INTEGER = 1, 'Integer (i.e., whole number) input.' DECIMAL = 2, 'Decimal input.' TEXT = 3, 'Free text response.' @@ -135,10 +502,12 @@ class Type(models.IntegerChoices): } questionnaire = models.ForeignKey(Questionnaire, on_delete=models.CASCADE) - group = models.ForeignKey(QuestionGroup, on_delete=models.CASCADE, null=True, blank=True) + leaf_group = models.ForeignKey(QuestionLeafGroup, on_delete=models.CASCADE) type = models.PositiveSmallIntegerField(choices=Type.choices) + order = models.PositiveSmallIntegerField(default=0) # XXX: This needs to be also unique within Questionnaire & Question Bank + # TODO: Make sure this is also unique within questions and groups name = models.CharField(max_length=255, validators=[validate_xlsform_name]) label = models.TextField() choice_collection = models.ForeignKey( @@ -171,3 +540,8 @@ class Type(models.IntegerChoices): class Meta: unique_together = ('questionnaire', 'name') + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['order']), + ] + ordering = ('leaf_group__order', 'order',) diff --git a/apps/questionnaire/mutations.py b/apps/questionnaire/mutations.py index 9d7e9e2..a217f7b 100644 --- a/apps/questionnaire/mutations.py +++ b/apps/questionnaire/mutations.py @@ -1,27 +1,37 @@ import strawberry from strawberry.types import Info - -from utils.strawberry.mutations import MutationResponseType, ModelMutation +from asgiref.sync import sync_to_async +from django.shortcuts import get_object_or_404 + +from utils.strawberry.mutations import ( + MutationResponseType, + ModelMutation, + BulkBasicMutationResponseType, + process_input_data, +) +from utils.strawberry.transformers import convert_serializer_to_type from utils.common import get_object_or_404_async -from .models import Project +from .models import Project, QuestionLeafGroup from .serializers import ( QuestionnaireSerializer, QuestionSerializer, - QuestionGroupSerializer, QuestionChoiceCollectionSerializer, + QuestionLeafGroupOrderSerializer, ) from .types import ( QuestionnaireType, QuestionType, - QuestionGroupType, + QuestionLeafGroupType, QuestionChoiceCollectionType, ) +from .enums import QuestionLeafGroupVisibilityActionEnum QuestionnaireMutation = ModelMutation('Questionnaire', QuestionnaireSerializer) QuestionMutation = ModelMutation('Question', QuestionSerializer) -QuestionGroupMutation = ModelMutation('QuestionGroup', QuestionGroupSerializer) QuestionChoiceCollectionMutation = ModelMutation('QuestionChoiceCollection', QuestionChoiceCollectionSerializer) +QuestionLeafGroupOrderInputType = convert_serializer_to_type( + QuestionLeafGroupOrderSerializer, name='QuestionLeafGroupOrderInputType') # NOTE: strawberry_django.type doesn't let use arguments in the field @@ -110,44 +120,45 @@ async def delete_question( ) @strawberry.mutation - async def create_question_group( + @sync_to_async + def bulk_update_questionnair_question_groups_leaf_order( self, - data: QuestionGroupMutation.InputType, + questionnaire_id: strawberry.ID, + data: list[QuestionLeafGroupOrderInputType], info: Info, - ) -> MutationResponseType[QuestionGroupType]: - return await QuestionGroupMutation.handle_create_mutation( - data, - info, - Project.Permission.CREATE_QUESTION_GROUP, + ) -> BulkBasicMutationResponseType[QuestionLeafGroupType]: + if errors := ModelMutation.check_permissions(info, Project.Permission.UPDATE_QUESTION_GROUP): + return BulkBasicMutationResponseType(errors=[errors]) + _data = { + int(d['id']): d['order'] + for d in process_input_data(data) + } + queryset = QuestionLeafGroupType.get_queryset(None, None, info).filter( + questionnaire=questionnaire_id, + pk__in=_data.keys(), ) + to_update_groups = [] + for group in queryset: + group.order = _data[group.id] + to_update_groups.append(group) + QuestionLeafGroup.objects.bulk_update(to_update_groups, ('order',)) + return BulkBasicMutationResponseType(results=[i for i in queryset]) @strawberry.mutation - async def update_question_group( + @sync_to_async + def update_question_group_leaf_visibility( self, id: strawberry.ID, - data: QuestionGroupMutation.PartialInputType, + visibility: QuestionLeafGroupVisibilityActionEnum, info: Info, - ) -> MutationResponseType[QuestionGroupType]: - queryset = QuestionGroupType.get_queryset(None, None, info) - return await QuestionGroupMutation.handle_update_mutation( - data, - info, - Project.Permission.UPDATE_QUESTION_GROUP, - await get_object_or_404_async(queryset, id=id), - ) - - @strawberry.mutation - async def delete_question_group( - self, - id: strawberry.ID, - info: Info, - ) -> MutationResponseType[QuestionGroupType]: - queryset = QuestionGroupType.get_queryset(None, None, info) - return await QuestionGroupMutation.handle_delete_mutation( - await get_object_or_404_async(queryset, id=id), - info, - Project.Permission.DELETE_QUESTION_GROUP, - ) + ) -> MutationResponseType[QuestionLeafGroupType]: + if errors := ModelMutation.check_permissions(info, Project.Permission.UPDATE_QUESTION_GROUP): + return MutationResponseType(ok=False, errors=errors) + queryset = QuestionLeafGroupType.get_queryset(None, None, info) + group: QuestionLeafGroup = get_object_or_404(queryset, pk=id) + group.is_hidden = visibility == QuestionLeafGroupVisibilityActionEnum.HIDE + group.save(update_fields=('is_hidden',)) + return MutationResponseType(result=group) @strawberry.mutation async def create_question_choice_collection( diff --git a/apps/questionnaire/orders.py b/apps/questionnaire/orders.py index 0730a86..ff880e4 100644 --- a/apps/questionnaire/orders.py +++ b/apps/questionnaire/orders.py @@ -4,7 +4,7 @@ from .models import ( Questionnaire, Question, - QuestionGroup, + QuestionLeafGroup, ChoiceCollection, ) @@ -15,9 +15,10 @@ class QuestionnaireOrder: created_at: strawberry.auto -@strawberry_django.ordering.order(QuestionGroup) -class QuestionGroupOrder: +@strawberry_django.ordering.order(QuestionLeafGroup) +class QuestionLeafGroupOrder: id: strawberry.auto + order: strawberry.auto created_at: strawberry.auto diff --git a/apps/questionnaire/queries.py b/apps/questionnaire/queries.py index ba0bdfc..97bfcb8 100644 --- a/apps/questionnaire/queries.py +++ b/apps/questionnaire/queries.py @@ -8,18 +8,18 @@ from .filters import ( QuestionnaireFilter, QuestionFilter, - QuestionGroupFilter, + QuestionLeafGroupFilter, QuestionChoiceCollectionFilter, ) from .orders import ( QuestionnaireOrder, QuestionOrder, - QuestionGroupOrder, + QuestionLeafGroupOrder, QuestionChoiceCollectionOrder, ) from .types import ( QuestionnaireType, - QuestionGroupType, + QuestionLeafGroupType, QuestionType, QuestionChoiceCollectionType, ) @@ -33,10 +33,10 @@ class PrivateProjectQuery: order=QuestionnaireOrder, ) - groups: CountList[QuestionGroupType] = pagination_field( + leafGroups: CountList[QuestionLeafGroupType] = pagination_field( pagination=True, - filters=QuestionGroupFilter, - order=QuestionGroupOrder, + filters=QuestionLeafGroupFilter, + order=QuestionLeafGroupOrder, ) choice_collections: CountList[QuestionChoiceCollectionType] = pagination_field( @@ -58,8 +58,8 @@ async def questionnaire(self, info: Info, pk: strawberry.ID) -> QuestionnaireTyp .afirst() @strawberry_django.field - async def group(self, info: Info, pk: strawberry.ID) -> QuestionGroupType | None: - return await QuestionGroupType.get_queryset(None, None, info)\ + async def leaf_group(self, info: Info, pk: strawberry.ID) -> QuestionLeafGroupType | None: + return await QuestionLeafGroupType.get_queryset(None, None, info)\ .filter(pk=pk)\ .afirst() diff --git a/apps/questionnaire/serializers.py b/apps/questionnaire/serializers.py index 2c2b9f0..ded6df3 100644 --- a/apps/questionnaire/serializers.py +++ b/apps/questionnaire/serializers.py @@ -11,7 +11,6 @@ from .models import ( Questionnaire, Question, - QuestionGroup, Choice, ChoiceCollection, ) @@ -27,38 +26,6 @@ class Meta: instance: Questionnaire -class QuestionGroupSerializer(UserResourceSerializer): - class Meta: - model = QuestionGroup - fields = ( - # Parents - 'questionnaire', - 'parent', - # Question Group metadata - 'name', - 'label', - 'relevant', - ) - - instance: QuestionGroup - - def validate_questionnaire(self, questionnaire): - if questionnaire.project_id != self.project.id: - raise serializers.ValidationError('Invalid questionnaire') - return questionnaire - - def validate(self, data): - questionnaire = data.get( # Required field - 'questionnaire', - self.instance and self.instance.questionnaire - ) - parent = data.get('parent') - - if parent and parent.questionnaire_id != questionnaire.id: - raise serializers.ValidationError('Invalid parent question group') - return data - - class QuestionChoiceSerializer(TempClientIdMixin, ProjectScopeSerializerMixin, serializers.ModelSerializer): id = IntegerIDField(required=False) @@ -103,13 +70,18 @@ def validate_questionnaire(self, questionnaire): return questionnaire +class QuestionLeafGroupOrderSerializer(serializers.Serializer): + id = IntegerIDField(required=True) + order = serializers.IntegerField(required=True) + + class QuestionSerializer(UserResourceSerializer): class Meta: model = Question fields = ( # Parents 'questionnaire', - 'group', + 'leaf_group', # Question metadata 'type', 'name', @@ -149,14 +121,25 @@ def validate(self, data): ) _type = data.get('type', self.instance and self.instance.type) choice_collection = data.get('choice_collection', self.instance and self.instance.choice_collection) - group = data.get('group', self.instance and self.instance.group) + leaf_group = data.get('leaf_group', self.instance and self.instance.leaf_group) errors = [] - if 'group' in data and group and group.questionnaire_id != questionnaire.id: + if ( + 'leaf_group' in data and + leaf_group.questionnaire_id != questionnaire.id + ): errors.append('Invalid group') - if 'choice_collection' in data and choice_collection and choice_collection.questionnaire_id != questionnaire.id: + if ( + 'choice_collection' in data and + choice_collection and + choice_collection.questionnaire_id != questionnaire.id + ): errors.append('Invalid choices') - if 'type' in data and _type in Question.FIELDS_WITH_CHOICE_COLLECTION and choice_collection is None: + if ( + 'type' in data and + _type in Question.FIELDS_WITH_CHOICE_COLLECTION and + choice_collection is None + ): errors.append(f'Choices are required for {_type}') if errors: diff --git a/apps/questionnaire/tests/test_mutations.py b/apps/questionnaire/tests/test_mutations.py index 29a80ca..86ab7bb 100644 --- a/apps/questionnaire/tests/test_mutations.py +++ b/apps/questionnaire/tests/test_mutations.py @@ -2,17 +2,18 @@ from apps.project.models import ProjectMembership from apps.project.factories import ProjectFactory +from apps.questionnaire.enums import QuestionLeafGroupVisibilityActionEnum from apps.questionnaire.models import ( Questionnaire, Question, - QuestionGroup, + QuestionLeafGroup, ChoiceCollection, ) from apps.user.factories import UserFactory from apps.questionnaire.factories import ( QuestionnaireFactory, QuestionFactory, - QuestionGroupFactory, + QuestionLeafGroupFactory, ChoiceCollectionFactory, ) @@ -20,10 +21,10 @@ class TestQuestionnaireMutation(TestCase): class Mutation: QuestionnaireCreate = ''' - mutation MyMutation($projectID: ID!, $data: QuestionnaireCreateInput!) { + mutation MyMutation($projectId: ID!, $data: QuestionnaireCreateInput!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id createQuestionnaire(data: $data) { ok @@ -39,12 +40,12 @@ class Mutation: ''' QuestionnaireUpdate = ''' - mutation MyMutation($projectID: ID!, $questionnaireID: ID!, $data: QuestionnaireUpdateInput!) { + mutation MyMutation($projectId: ID!, $questionnaireId: ID!, $data: QuestionnaireUpdateInput!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id - updateQuestionnaire(id: $questionnaireID, data: $data) { + updateQuestionnaire(id: $questionnaireId, data: $data) { ok errors result { @@ -58,12 +59,12 @@ class Mutation: ''' QuestionnaireDelete = ''' - mutation MyMutation($projectID: ID!, $questionnaireID: ID!) { + mutation MyMutation($projectId: ID!, $questionnaireId: ID!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id - deleteQuestionnaire(id: $questionnaireID) { + deleteQuestionnaire(id: $questionnaireId) { ok errors result { @@ -85,7 +86,7 @@ def test_create_questionnaire(self): questionnaire_count = Questionnaire.objects.count() # Without login variables = { - 'projectID': self.gID(project.pk), + 'projectId': self.gID(project.pk), 'data': {'title': 'Questionnaire 1'}, } content = self.query_check(self.Mutation.QuestionnaireCreate, variables=variables, assert_errors=True) @@ -134,8 +135,8 @@ def test_update_questionnaire(self): # Without login variables = { - 'projectID': self.gID(project.pk), - 'questionnaireID': self.gID(q1.pk), + 'projectId': self.gID(project.pk), + 'questionnaireId': self.gID(q1.pk), 'data': {'title': 'Questionnaire 1'}, } content = self.query_check(self.Mutation.QuestionnaireUpdate, variables=variables, assert_errors=True) @@ -168,7 +169,7 @@ def test_update_questionnaire(self): # -- Another project questionnaire project.add_member(user, role=ProjectMembership.Role.MEMBER) - variables['questionnaireID'] = self.gID(q2.id) + variables['questionnaireId'] = self.gID(q2.id) content = self.query_check( self.Mutation.QuestionnaireUpdate, variables=variables, @@ -180,22 +181,64 @@ def test_delete_questionnaire(self): ur_params = dict(created_by=user, modified_by=user) # Create some projects project, project2 = ProjectFactory.create_batch(2, **ur_params) - q1 = QuestionnaireFactory.create(**ur_params, project=project) + q1, q1_2 = QuestionnaireFactory.create_batch(2, **ur_params, project=project) q2 = QuestionnaireFactory.create(**ur_params, project=project2) + # Create some questions, groups and choice collections + group1 = QuestionLeafGroupFactory.create( + **ur_params, + questionnaire=q1, + type=QuestionLeafGroup.Type.MATRIX_1D, + category_1=QuestionLeafGroup.Category1.CONTEXT, + category_2=QuestionLeafGroup.Category2.POLITICS, + ) + group1_2 = QuestionLeafGroupFactory.create( + **ur_params, + questionnaire=q1_2, + type=QuestionLeafGroup.Type.MATRIX_1D, + category_1=QuestionLeafGroup.Category1.CONTEXT, + category_2=QuestionLeafGroup.Category2.DEMOGRAPHY, + ) + group2 = QuestionLeafGroupFactory.create( + **ur_params, + questionnaire=q2, + type=QuestionLeafGroup.Type.MATRIX_1D, + category_1=QuestionLeafGroup.Category1.CONTEXT, + category_2=QuestionLeafGroup.Category2.POLITICS, + ) + # -- q1 + choice_collections = ChoiceCollectionFactory.create_batch(3, **ur_params, questionnaire=q1) + QuestionFactory.create_batch( + 2, **ur_params, questionnaire=q1, leaf_group=group1, choice_collection=choice_collections[0]) + QuestionFactory.create_batch( + 3, **ur_params, questionnaire=q1, leaf_group=group1, choice_collection=choice_collections[1]) + QuestionFactory.create_batch(3, **ur_params, questionnaire=q1, leaf_group=group1) + QuestionFactory.create_batch(3, **ur_params, questionnaire=q1_2, leaf_group=group1_2) + # -- q2 + QuestionFactory.create_batch(3, **ur_params, questionnaire=q1, leaf_group=group2) # Without login variables = { - 'projectID': self.gID(project.pk), - 'questionnaireID': self.gID(q1.pk), + 'projectId': self.gID(project.pk), + 'questionnaireId': self.gID(q1.pk), } content = self.query_check(self.Mutation.QuestionnaireDelete, variables=variables, assert_errors=True) assert content['data'] is None + # Current entities counts + def _get_counts(): + return { + 'questions': Question.objects.count(), + 'choice_collections': ChoiceCollection.objects.count(), + 'groups': QuestionLeafGroup.objects.count(), + 'questionnair': Questionnaire.objects.count(), + } + counts = _get_counts() # With login # -- Without membership self.force_login(user) content = self.query_check(self.Mutation.QuestionnaireDelete, variables=variables) assert content['data']['private']['projectScope'] is None + assert counts == _get_counts() # -- With membership - But read access only project.add_member(user, role=ProjectMembership.Role.VIEWER) @@ -205,6 +248,7 @@ def test_delete_questionnaire(self): )['data']['private']['projectScope']['deleteQuestionnaire'] assert content['ok'] is False, content assert content['errors'] is not None, content + assert counts == _get_counts() # -- With membership - With write access project.add_member(user, role=ProjectMembership.Role.MEMBER) @@ -214,24 +258,43 @@ def test_delete_questionnaire(self): )['data']['private']['projectScope']['deleteQuestionnaire'] assert content['ok'] is True, content assert content['errors'] is None, content + counts['questions'] -= 11 + counts['choice_collections'] -= 3 + counts['questionnair'] -= 1 + counts['groups'] -= 1 + assert counts == _get_counts() + + # -- Another questionnaire + variables['questionnaireId'] = self.gID(q1_2.id) + content = self.query_check( + self.Mutation.QuestionnaireDelete, + variables=variables, + )['data']['private']['projectScope']['deleteQuestionnaire'] + assert content['ok'] is True, content + assert content['errors'] is None, content + counts['questions'] -= 3 + counts['questionnair'] -= 1 + counts['groups'] -= 1 + assert counts == _get_counts() # -- Another project questionnaire project.add_member(user, role=ProjectMembership.Role.MEMBER) - variables['questionnaireID'] = self.gID(q2.id) + variables['questionnaireId'] = self.gID(q2.id) content = self.query_check( self.Mutation.QuestionnaireDelete, variables=variables, assert_errors=True, ) + assert counts == _get_counts() class TestQuestionMutation(TestCase): class Mutation: QuestionCreate = ''' - mutation MyMutation($projectID: ID!, $data: QuestionCreateInput!) { + mutation MyMutation($projectId: ID!, $data: QuestionCreateInput!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id createQuestion(data: $data) { ok @@ -248,10 +311,10 @@ class Mutation: ''' QuestionUpdate = ''' - mutation MyMutation($projectID: ID!, $questionID: ID!, $data: QuestionUpdateInput!) { + mutation MyMutation($projectId: ID!, $questionID: ID!, $data: QuestionUpdateInput!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id updateQuestion(id: $questionID, data: $data) { ok @@ -268,10 +331,10 @@ class Mutation: ''' QuestionDelete = ''' - mutation MyMutation($projectID: ID!, $questionID: ID!) { + mutation MyMutation($projectId: ID!, $questionID: ID!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id deleteQuestion(id: $questionID) { ok @@ -295,14 +358,18 @@ def test_create_question(self): q1 = QuestionnaireFactory.create(**ur_params, project=project) q2 = QuestionnaireFactory.create(**ur_params, project=project2) + [q1_group] = QuestionLeafGroupFactory.static_generator(1, **ur_params, questionnaire=q1) + [q2_group] = QuestionLeafGroupFactory.static_generator(1, **ur_params, questionnaire=q2) + question_count = Question.objects.count() # Without login variables = { - 'projectID': self.gID(project.pk), + 'projectId': self.gID(project.pk), 'data': { 'name': 'question_01', 'label': 'Question 1', 'questionnaire': self.gID(q2.pk), + 'leafGroup': self.gID(q2_group.pk), 'type': self.genum(Question.Type.INTEGER), }, } @@ -339,7 +406,16 @@ def test_create_question(self): )['data']['private']['projectScope']['createQuestion'] assert content['ok'] is False, content assert content['errors'] is not None, content - # -- Valid questionnaire id + # -- Invalid leaf group + project.add_member(user, role=ProjectMembership.Role.MEMBER) + content = self.query_check( + self.Mutation.QuestionCreate, + variables=variables, + )['data']['private']['projectScope']['createQuestion'] + assert content['ok'] is False, content + assert content['errors'] is not None, content + # -- Valid questionnaire id & leaf group + variables['data']['leafGroup'] = self.gID(q1_group.pk) variables['data']['questionnaire'] = self.gID(q1.pk) content = self.query_check( self.Mutation.QuestionCreate, @@ -369,14 +445,17 @@ def test_update_question(self): q1 = QuestionnaireFactory.create(**ur_params, project=project) q2 = QuestionnaireFactory.create(**ur_params, project=project2) + [group1] = QuestionLeafGroupFactory.static_generator(1, **ur_params, questionnaire=q1) + [group2] = QuestionLeafGroupFactory.static_generator(1, **ur_params, questionnaire=q2) + question_params = {**ur_params, 'type': Question.Type.INTEGER} - QuestionFactory.create(**question_params, questionnaire=q1, name='question_01') - question12 = QuestionFactory.create(**question_params, questionnaire=q1, name='question_02') - question2 = QuestionFactory.create(**question_params, questionnaire=q2, name='question_01') + QuestionFactory.create(**question_params, questionnaire=q1, name='question_01', leaf_group=group1) + question12 = QuestionFactory.create(**question_params, questionnaire=q1, leaf_group=group1, name='question_02') + question2 = QuestionFactory.create(**question_params, questionnaire=q2, leaf_group=group2, name='question_01') # Without login variables = { - 'projectID': self.gID(project.pk), + 'projectId': self.gID(project.pk), 'questionID': self.gID(question12.pk), 'data': { 'name': 'question_002', @@ -450,13 +529,16 @@ def test_delete_question(self): q1 = QuestionnaireFactory.create(**ur_params, project=project) q2 = QuestionnaireFactory.create(**ur_params, project=project2) + [group1] = QuestionLeafGroupFactory.static_generator(1, **ur_params, questionnaire=q1) + [group2] = QuestionLeafGroupFactory.static_generator(1, **ur_params, questionnaire=q2) + question_params = {**ur_params, 'type': Question.Type.INTEGER} - question1 = QuestionFactory.create(**question_params, questionnaire=q1, name='question_0101') - question2 = QuestionFactory.create(**question_params, questionnaire=q2, name='question_0201') + question1 = QuestionFactory.create(**question_params, questionnaire=q1, name='question_0101', leaf_group=group1) + question2 = QuestionFactory.create(**question_params, questionnaire=q2, name='question_0201', leaf_group=group2) # Without login variables = { - 'projectID': self.gID(project.pk), + 'projectId': self.gID(project.pk), 'questionID': self.gID(question1.pk), } content = self.query_check(self.Mutation.QuestionDelete, variables=variables, assert_errors=True) @@ -514,14 +596,17 @@ def setUpClass(cls): cls.choice_collection = ChoiceCollectionFactory.create(**cls.ur_params, questionnaire=cls.q1) def test_question_choices_types(self): + [group1] = QuestionLeafGroupFactory.static_generator(1, **self.ur_params, questionnaire=self.q1) + # Without login variables = { - 'projectID': self.gID(self.project.pk), + 'projectId': self.gID(self.project.pk), 'data': { 'name': 'question_01', 'label': 'Question 1', 'questionnaire': self.gID(self.q1.pk), 'type': self.genum(Question.Type.SELECT_ONE), + 'leafGroup': self.gID(group1.pk), }, } @@ -548,19 +633,23 @@ def _query_check(): class TestQuestionGroupMutation(TestCase): class Mutation: - QuestionGroupCreate = ''' - mutation MyMutation($projectID: ID!, $data: QuestionGroupCreateInput!) { + QuestionLeafGroupVisiblityUpdate = ''' + mutation MyMutation( + $projectId: ID!, + $questionLeafGroupID: ID!, + $visibility: QuestionLeafGroupVisibilityActionEnum! + ) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id - createQuestionGroup(data: $data) { + updateQuestionGroupLeafVisibility(id: $questionLeafGroupID, visibility: $visibility) { ok errors result { id name - label + isHidden } } } @@ -568,19 +657,22 @@ class Mutation: } ''' - QuestionGroupUpdate = ''' - mutation MyMutation($projectID: ID!, $questionGroupID: ID!, $data: QuestionGroupUpdateInput!) { + QuestionLeafGroupOrderBulkUpdate = ''' + mutation MyMutation( + $projectId: ID!, + $questionnairId: ID!, + $data: [QuestionLeafGroupOrderInputType!]! + ) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id - updateQuestionGroup(id: $questionGroupID, data: $data) { - ok + bulkUpdateQuestionnairQuestionGroupsLeafOrder(questionnaireId: $questionnairId, data: $data) { errors - result { + results { id name - label + order } } } @@ -588,101 +680,7 @@ class Mutation: } ''' - QuestionGroupDelete = ''' - mutation MyMutation($projectID: ID!, $questionGroupID: ID!) { - private { - id - projectScope(pk: $projectID) { - id - deleteQuestionGroup(id: $questionGroupID) { - ok - errors - result { - id - name - label - } - } - } - } - } - ''' - - def test_create_question_group(self): - user = UserFactory.create() - ur_params = dict(created_by=user, modified_by=user) - # Create some projects - project, project2 = ProjectFactory.create_batch(2, **ur_params) - q1 = QuestionnaireFactory.create(**ur_params, project=project) - q2 = QuestionnaireFactory.create(**ur_params, project=project2) - - question_group_count = QuestionGroup.objects.count() - # Without login - variables = { - 'projectID': self.gID(project.pk), - 'data': { - 'name': 'question_group_01', - 'label': 'Question Group 1', - 'relevant': 'Not relevant', - 'questionnaire': self.gID(q2.pk), - }, - } - content = self.query_check(self.Mutation.QuestionGroupCreate, variables=variables, assert_errors=True) - assert content['data'] is None - # No change - assert QuestionGroup.objects.count() == question_group_count - - # With login - # -- Without membership - self.force_login(user) - content = self.query_check(self.Mutation.QuestionGroupCreate, variables=variables) - assert content['data']['private']['projectScope'] is None - # No change - assert QuestionGroup.objects.count() == question_group_count - - # -- With membership - But read access only - project.add_member(user, role=ProjectMembership.Role.VIEWER) - content = self.query_check( - self.Mutation.QuestionGroupCreate, - variables=variables, - )['data']['private']['projectScope']['createQuestionGroup'] - assert content['ok'] is False, content - assert content['errors'] is not None, content - # No change - assert QuestionGroup.objects.count() == question_group_count - - # -- With membership - With write access - # -- Invalid questionnaire id - project.add_member(user, role=ProjectMembership.Role.MEMBER) - content = self.query_check( - self.Mutation.QuestionGroupCreate, - variables=variables, - )['data']['private']['projectScope']['createQuestionGroup'] - assert content['ok'] is False, content - assert content['errors'] is not None, content - # -- Valid questionnaire id - variables['data']['questionnaire'] = self.gID(q1.pk) - content = self.query_check( - self.Mutation.QuestionGroupCreate, - variables=variables, - )['data']['private']['projectScope']['createQuestionGroup'] - assert content['ok'] is True, content - assert content['errors'] is None, content - assert content['result']['name'] == variables['data']['name'], content - assert content['result']['label'] == variables['data']['label'], content - # 1 new - assert QuestionGroup.objects.count() == question_group_count + 1 - # -- Simple name unique validation - content = self.query_check( - self.Mutation.QuestionGroupCreate, - variables=variables, - )['data']['private']['projectScope']['createQuestionGroup'] - assert content['ok'] is not True, content - assert content['errors'] is not None, content - # Same as last - assert QuestionGroup.objects.count() == question_group_count + 1 - - def test_update_question_group(self): + def test_question_leaf_group_visibility(self): user = UserFactory.create() ur_params = dict(created_by=user, modified_by=user) # Create some projects @@ -690,138 +688,143 @@ def test_update_question_group(self): q1 = QuestionnaireFactory.create(**ur_params, project=project) q2 = QuestionnaireFactory.create(**ur_params, project=project2) - QuestionGroupFactory.create(**ur_params, questionnaire=q1, name='question_group_01') - question_group_12 = QuestionGroupFactory.create(**ur_params, questionnaire=q1, name='question_group_02') - question_group_2 = QuestionGroupFactory.create(**ur_params, questionnaire=q2, name='question_group_01') + [group1] = QuestionLeafGroupFactory.static_generator(1, **ur_params, questionnaire=q1) + [group2] = QuestionLeafGroupFactory.static_generator(1, **ur_params, questionnaire=q2) # Without login variables = { - 'projectID': self.gID(project.pk), - 'questionGroupID': self.gID(question_group_12.pk), - 'data': { - 'name': 'question_group_002', - 'label': 'QuestionGroup 2', - }, + 'projectId': self.gID(project.pk), + 'questionLeafGroupID': self.gID(group2.pk), + 'visibility': self.genum(QuestionLeafGroupVisibilityActionEnum.HIDE), } - content = self.query_check(self.Mutation.QuestionGroupUpdate, variables=variables, assert_errors=True) + content = self.query_check(self.Mutation.QuestionLeafGroupVisiblityUpdate, variables=variables, assert_errors=True) assert content['data'] is None # With login # -- Without membership self.force_login(user) - content = self.query_check(self.Mutation.QuestionGroupUpdate, variables=variables) + content = self.query_check(self.Mutation.QuestionLeafGroupVisiblityUpdate, variables=variables) assert content['data']['private']['projectScope'] is None # -- With membership - But read access only project.add_member(user, role=ProjectMembership.Role.VIEWER) content = self.query_check( - self.Mutation.QuestionGroupUpdate, + self.Mutation.QuestionLeafGroupVisiblityUpdate, variables=variables, - )['data']['private']['projectScope']['updateQuestionGroup'] + )['data']['private']['projectScope']['updateQuestionGroupLeafVisibility'] assert content['ok'] is False, content assert content['errors'] is not None, content # -- With membership - With write access + # -- Invalid question leaf group id project.add_member(user, role=ProjectMembership.Role.MEMBER) - content = self.query_check( - self.Mutation.QuestionGroupUpdate, - variables=variables, - )['data']['private']['projectScope']['updateQuestionGroup'] - assert content['ok'] is True, content - assert content['errors'] is None, content - assert content['result']['name'] == variables['data']['name'], content - assert content['result']['label'] == variables['data']['label'], content - - # -- Using another question name - project.add_member(user, role=ProjectMembership.Role.MEMBER) - variables['data']['name'] = 'question_group_01' - content = self.query_check( - self.Mutation.QuestionGroupUpdate, - variables=variables, - )['data']['private']['projectScope']['updateQuestionGroup'] - assert content['ok'] is False, content - assert content['errors'] is not None, content - variables['data']['name'] = 'question_group_02' - - # -- Using another project questionnaire name - project.add_member(user, role=ProjectMembership.Role.MEMBER) - variables['data']['questionnaire'] = self.gID(q2.pk) - content = self.query_check( - self.Mutation.QuestionGroupUpdate, - variables=variables, - )['data']['private']['projectScope']['updateQuestionGroup'] - assert content['ok'] is False, content - assert content['errors'] is not None, content - - # -- Another project question - project.add_member(user, role=ProjectMembership.Role.MEMBER) - variables['questionGroupID'] = self.gID(question_group_2.id) - content = self.query_check( - self.Mutation.QuestionGroupUpdate, - variables=variables, - assert_errors=True, - ) - - def test_delete_question_group(self): + content = self.query_check(self.Mutation.QuestionLeafGroupVisiblityUpdate, variables=variables, assert_errors=True) + + # -- Valid question leaf group id + variables['questionLeafGroupID'] = self.gID(group1.pk) + for visibility, is_hidden_value in [ + (self.genum(QuestionLeafGroupVisibilityActionEnum.HIDE), True), + (self.genum(QuestionLeafGroupVisibilityActionEnum.SHOW), False), + ]: + variables['visibility'] = visibility + content = self.query_check( + self.Mutation.QuestionLeafGroupVisiblityUpdate, + variables=variables, + )['data']['private']['projectScope']['updateQuestionGroupLeafVisibility'] + group1.refresh_from_db() + assert content['ok'] is True, content + assert content['errors'] is None, content + assert content['result']['id'] == variables['questionLeafGroupID'], content + assert content['result']['isHidden'] == is_hidden_value, content + assert group1.is_hidden == is_hidden_value + + def test_question_leaf_group_order_update(self): user = UserFactory.create() ur_params = dict(created_by=user, modified_by=user) # Create some projects project, project2 = ProjectFactory.create_batch(2, **ur_params) - q1 = QuestionnaireFactory.create(**ur_params, project=project) - q2 = QuestionnaireFactory.create(**ur_params, project=project2) - - question1 = QuestionGroupFactory.create(**ur_params, questionnaire=q1, name='question_group_0101') - question_group_2 = QuestionGroupFactory.create(**ur_params, questionnaire=q2, name='question_group_0201') - + q1_1 = QuestionnaireFactory.create(**ur_params, project=project) + q1_2 = QuestionnaireFactory.create(**ur_params, project=project) + q2_1 = QuestionnaireFactory.create(**ur_params, project=project2) + + [ + group1_1_1, + group1_1_2, + group1_1_3, + group1_1_4 + ] = QuestionLeafGroupFactory.static_generator(4, **ur_params, questionnaire=q1_1) + [group1_2_1, group1_2_2] = QuestionLeafGroupFactory.static_generator(2, **ur_params, questionnaire=q1_2) + [group2_1_1, group2_1_2] = QuestionLeafGroupFactory.static_generator(2, **ur_params, questionnaire=q2_1) + + valid_group_order_set = [ + (group1_1_1, 1001), + (group1_1_2, 1001), + (group1_1_3, 1001), + (group1_1_4, 1001), + ] # Without login variables = { - 'projectID': self.gID(project.pk), - 'questionGroupID': self.gID(question1.pk), + 'projectId': self.gID(project.pk), + 'questionnairId': self.gID(q1_1.pk), + 'data': [ + { + 'id': self.gID(group.pk), + 'order': order, + } + for group, order in ( + # Valid groups + *valid_group_order_set, + # Invalid groups + # -- Another questionnair + (group1_2_1, 1001), + (group1_2_2, 1001), + # -- Another questionnair another project + (group2_1_1, 1001), + (group2_1_2, 1001), + ) + ] } - content = self.query_check(self.Mutation.QuestionGroupDelete, variables=variables, assert_errors=True) + content = self.query_check(self.Mutation.QuestionLeafGroupOrderBulkUpdate, variables=variables, assert_errors=True) assert content['data'] is None # With login # -- Without membership self.force_login(user) - content = self.query_check(self.Mutation.QuestionGroupDelete, variables=variables) + content = self.query_check(self.Mutation.QuestionLeafGroupOrderBulkUpdate, variables=variables) assert content['data']['private']['projectScope'] is None # -- With membership - But read access only project.add_member(user, role=ProjectMembership.Role.VIEWER) content = self.query_check( - self.Mutation.QuestionGroupDelete, + self.Mutation.QuestionLeafGroupOrderBulkUpdate, variables=variables, - )['data']['private']['projectScope']['deleteQuestionGroup'] - assert content['ok'] is False, content + )['data']['private']['projectScope']['bulkUpdateQuestionnairQuestionGroupsLeafOrder'] assert content['errors'] is not None, content # -- With membership - With write access project.add_member(user, role=ProjectMembership.Role.MEMBER) content = self.query_check( - self.Mutation.QuestionGroupDelete, + self.Mutation.QuestionLeafGroupOrderBulkUpdate, variables=variables, - )['data']['private']['projectScope']['deleteQuestionGroup'] - assert content['ok'] is True, content + )['data']['private']['projectScope']['bulkUpdateQuestionnairQuestionGroupsLeafOrder'] assert content['errors'] is None, content - - # -- Another project question - project.add_member(user, role=ProjectMembership.Role.MEMBER) - variables['questionGroupID'] = self.gID(question_group_2.id) - content = self.query_check( - self.Mutation.QuestionGroupDelete, - variables=variables, - assert_errors=True, - ) + assert content['results'] == [ + { + 'id': self.gID(group.pk), + 'name': group.name, + 'order': order, + } + for group, order in valid_group_order_set + ] class TestChoiceCollectionMutation(TestCase): class Mutation: ChoiceCollectionCreate = ''' - mutation MyMutation($projectID: ID!, $data: QuestionChoiceCollectionCreateInput!) { + mutation MyMutation($projectId: ID!, $data: QuestionChoiceCollectionCreateInput!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id createQuestionChoiceCollection(data: $data) { ok @@ -845,10 +848,10 @@ class Mutation: ''' ChoiceCollectionUpdate = ''' - mutation MyMutation($projectID: ID!, $questionGroupID: ID!, $data: QuestionChoiceCollectionUpdateInput!) { + mutation MyMutation($projectId: ID!, $questionGroupID: ID!, $data: QuestionChoiceCollectionUpdateInput!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id updateQuestionChoiceCollection(id: $questionGroupID, data: $data) { ok @@ -872,10 +875,10 @@ class Mutation: ''' ChoiceCollectionDelete = ''' - mutation MyMutation($projectID: ID!, $questionGroupID: ID!) { + mutation MyMutation($projectId: ID!, $questionGroupID: ID!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id deleteQuestionChoiceCollection(id: $questionGroupID) { ok @@ -909,7 +912,7 @@ def test_create_choice_collection(self): choice_collection_count = ChoiceCollection.objects.count() # Without login variables = { - 'projectID': self.gID(project.pk), + 'projectId': self.gID(project.pk), 'data': { 'name': 'choice_collection_01', 'label': 'Question Group 1', @@ -1009,7 +1012,7 @@ def test_update_choice_collection(self): # Without login variables = { - 'projectID': self.gID(project.pk), + 'projectId': self.gID(project.pk), 'questionGroupID': self.gID(choice_collection_12.pk), 'data': { 'name': 'choice_collection_002', @@ -1088,7 +1091,7 @@ def test_delete_choice_collection(self): # Without login variables = { - 'projectID': self.gID(project.pk), + 'projectId': self.gID(project.pk), 'questionGroupID': self.gID(question1.pk), } content = self.query_check(self.Mutation.ChoiceCollectionDelete, variables=variables, assert_errors=True) diff --git a/apps/questionnaire/tests/test_queries.py b/apps/questionnaire/tests/test_queries.py index 9d3a0e1..abcb53b 100644 --- a/apps/questionnaire/tests/test_queries.py +++ b/apps/questionnaire/tests/test_queries.py @@ -6,7 +6,7 @@ from apps.questionnaire.factories import ( QuestionnaireFactory, QuestionFactory, - QuestionGroupFactory, + QuestionLeafGroupFactory, ChoiceCollectionFactory, ChoiceFactory, ) @@ -15,10 +15,10 @@ class TestQuestionnaireQuery(TestCase): class Query: QuestionnaireList = ''' - query MyQuery($projectID: ID!) { + query MyQuery($projectId: ID!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id questionnaires(order: {id: ASC}) { count @@ -42,12 +42,12 @@ class Query: ''' Questionnaire = ''' - query MyQuery($projectID: ID!, $questionnaireID: ID!) { + query MyQuery($projectId: ID!, $questionnaireId: ID!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id - questionnaire(pk: $questionnaireID) { + questionnaire(pk: $questionnaireId) { id title projectId @@ -80,7 +80,7 @@ def test_questionnaires(self): content = self.query_check( self.Query.QuestionnaireList, assert_errors=True, - variables={'projectID': self.gID(project1.id)}, + variables={'projectId': self.gID(project1.id)}, ) assert content['data'] is None @@ -95,7 +95,7 @@ def test_questionnaires(self): content = self.query_check( self.Query.QuestionnaireList, variables={ - 'projectID': self.gID(project.id) + 'projectId': self.gID(project.id) }, ) if questionnaires is None: @@ -133,7 +133,7 @@ def test_questionnaire(self): questionnaires = QuestionnaireFactory.create_batch(3, project=project, **user_resource_params) q2 = QuestionnaireFactory.create(project=project2, **user_resource_params) - variables = {'projectID': self.gID(project.id)} + variables = {'projectId': self.gID(project.id)} # Without authentication ----- content = self.query_check( self.Query.Questionnaire, @@ -149,7 +149,7 @@ def test_questionnaire(self): self.Query.Questionnaire, variables={ **variables, - 'questionnaireID': self.gID(questionnaires[0].id), + 'questionnaireId': self.gID(questionnaires[0].id), }, ) assert content['data']['private']['projectScope'] is None, content @@ -160,7 +160,7 @@ def test_questionnaire(self): self.Query.Questionnaire, variables={ **variables, - 'questionnaireID': self.gID(questionnaire.id), + 'questionnaireId': self.gID(questionnaire.id), }, ) assert_msg = (content, user, questionnaire) @@ -180,7 +180,7 @@ def test_questionnaire(self): }, assert_msg # Another project questionnaire - variables['questionnaireID'] = self.gID(q2.id) + variables['questionnaireId'] = self.gID(q2.id) content = self.query_check( self.Query.Questionnaire, variables=variables, @@ -191,17 +191,20 @@ def test_questionnaire(self): class TestQuestionGroupQuery(TestCase): class Query: QuestionGroupList = ''' - query MyQuery($projectID: ID!, $filterData: QuestionGroupFilter) { + query MyQuery($projectId: ID!, $filterData: QuestionLeafGroupFilter) { private { - projectScope(pk: $projectID) { - groups(order: {id: ASC}, filters: $filterData) { + projectScope(pk: $projectId) { + leafGroups(order: {id: ASC}, filters: $filterData) { count items { id - parentId questionnaireId name - label + type + category1 + category2 + category3 + category4 relevant createdAt createdBy { @@ -219,16 +222,19 @@ class Query: ''' QuestionGroup = ''' - query MyQuery($projectID: ID!, $questionGroupID: ID!) { + query MyQuery($projectId: ID!, $questionGroupId: ID!) { private { - projectScope(pk: $projectID) { - group(pk: $questionGroupID) { + projectScope(pk: $projectId) { + leafGroup(pk: $questionGroupId) { id questionnaireId - parentId name - label relevant + type + category1 + category2 + category3 + category4 createdAt createdBy { id @@ -243,7 +249,7 @@ class Query: } ''' - def test_groups(self): + def test_leaf_groups(self): # Create some users user = UserFactory.create() user_resource_params = {'created_by': user, 'modified_by': user} @@ -252,18 +258,13 @@ def test_groups(self): q1, q2, q3 = QuestionnaireFactory.create_batch(3, project=project, **user_resource_params) - q1_groups = QuestionGroupFactory.create_batch( - 2, - **user_resource_params, - questionnaire=q1, - label='[Group] Who are you?', - ) - q2_groups = QuestionGroupFactory.create_batch(3, **user_resource_params, questionnaire=q2) - q3_groups = QuestionGroupFactory.create_batch(5, **user_resource_params, questionnaire=q3) + q1_groups = QuestionLeafGroupFactory.static_generator(2, **user_resource_params, questionnaire=q1) + q2_groups = QuestionLeafGroupFactory.static_generator(3, **user_resource_params, questionnaire=q2) + q3_groups = QuestionLeafGroupFactory.static_generator(5, **user_resource_params, questionnaire=q3) q3_groups[0].name = 'question-group-unique-0001' q3_groups[0].save(update_fields=('name',)) - variables = {'projectID': self.gID(project.id)} + variables = {'projectId': self.gID(project.id)} # Without authentication ----- content = self.query_check( self.Query.QuestionGroupList, @@ -274,11 +275,10 @@ def test_groups(self): # With authentication ----- self.force_login(user) - for filter_data, question_groups in [ + for filter_data, question_leaf_groups in [ ({'questionnaire': {'pk': self.gID(q1.id)}}, q1_groups), ({'questionnaire': {'pk': self.gID(q2.id)}}, q2_groups), ({'questionnaire': {'pk': self.gID(q3.id)}}, q3_groups), - ({'label': {'exact': '[Group] Who are you?'}}, q1_groups), ({'name': {'exact': 'question-group-unique-0001'}}, [q3_groups[0]]), ]: content = self.query_check( @@ -288,32 +288,35 @@ def test_groups(self): 'filterData': filter_data, }, ) - assert_msg = (content, user, filter_data, question_groups) + assert_msg = (content, user, filter_data, question_leaf_groups) assert content['data']['private']['projectScope'] is not None, assert_msg - assert content['data']['private']['projectScope']['groups'] == { - 'count': len(question_groups), + assert content['data']['private']['projectScope']['leafGroups'] == { + 'count': len(question_leaf_groups), 'items': [ { - 'id': self.gID(question_group.pk), - 'questionnaireId': self.gID(question_group.questionnaire_id), - 'createdAt': self.gdatetime(question_group.created_at), + 'id': self.gID(question_leaf_group.pk), + 'questionnaireId': self.gID(question_leaf_group.questionnaire_id), + 'createdAt': self.gdatetime(question_leaf_group.created_at), 'createdBy': { - 'id': self.gID(question_group.created_by_id), + 'id': self.gID(question_leaf_group.created_by_id), }, - 'modifiedAt': self.gdatetime(question_group.modified_at), + 'modifiedAt': self.gdatetime(question_leaf_group.modified_at), 'modifiedBy': { - 'id': self.gID(question_group.modified_by_id), + 'id': self.gID(question_leaf_group.modified_by_id), }, - 'name': question_group.name, - 'label': question_group.label, - 'relevant': question_group.relevant, - 'parentId': self.gID(question_group.parent_id), + 'name': question_leaf_group.name, + 'relevant': question_leaf_group.relevant, + 'type': self.genum(question_leaf_group.type), + 'category1': self.genum(question_leaf_group.category_1), + 'category2': self.genum(question_leaf_group.category_2), + 'category3': self.genum(question_leaf_group.category_3), + 'category4': self.genum(question_leaf_group.category_4), } - for question_group in question_groups + for question_leaf_group in question_leaf_groups ] }, assert_msg - def test_group(self): + def test_leaf_group(self): # Create some users user = UserFactory.create() user_resource_params = {'created_by': user, 'modified_by': user} @@ -321,12 +324,12 @@ def test_group(self): q1 = QuestionnaireFactory.create(project=project, **user_resource_params) q2 = QuestionnaireFactory.create(project=project2, **user_resource_params) - q1_question_group, *_ = QuestionGroupFactory.create_batch(4, **user_resource_params, questionnaire=q1) - q2_question_group = QuestionGroupFactory.create(**user_resource_params, questionnaire=q2) + q1_group, *_ = QuestionLeafGroupFactory.static_generator(4, **user_resource_params, questionnaire=q1) + [q2_group] = QuestionLeafGroupFactory.static_generator(1, **user_resource_params, questionnaire=q2) variables = { - 'projectID': self.gID(project.id), - 'questionGroupID': self.gID(q1_question_group.id), + 'projectId': self.gID(project.id), + 'questionGroupId': self.gID(q1_group.id), } # Without authentication ----- content = self.query_check( @@ -345,38 +348,41 @@ def test_group(self): project.add_member(user) content = self.query_check(self.Query.QuestionGroup, variables=variables) assert content['data']['private']['projectScope'] is not None, content - assert content['data']['private']['projectScope']['group'] == { - 'id': self.gID(q1_question_group.pk), - 'questionnaireId': self.gID(q1_question_group.questionnaire_id), - 'createdAt': self.gdatetime(q1_question_group.created_at), + assert content['data']['private']['projectScope']['leafGroup'] == { + 'id': self.gID(q1_group.pk), + 'questionnaireId': self.gID(q1_group.questionnaire_id), + 'createdAt': self.gdatetime(q1_group.created_at), 'createdBy': { - 'id': self.gID(q1_question_group.created_by_id), + 'id': self.gID(q1_group.created_by_id), }, - 'modifiedAt': self.gdatetime(q1_question_group.modified_at), + 'modifiedAt': self.gdatetime(q1_group.modified_at), 'modifiedBy': { - 'id': self.gID(q1_question_group.modified_by_id), + 'id': self.gID(q1_group.modified_by_id), }, - 'name': q1_question_group.name, - 'label': q1_question_group.label, - 'relevant': q1_question_group.relevant, - 'parentId': self.gID(q1_question_group.parent_id), + 'name': q1_group.name, + 'relevant': q1_group.relevant, + 'type': self.genum(q1_group.type), + 'category1': self.genum(q1_group.category_1), + 'category2': self.genum(q1_group.category_2), + 'category3': self.genum(q1_group.category_3), + 'category4': self.genum(q1_group.category_4), }, content # Another project question group - variables['questionGroupID'] = self.gID(q2_question_group.id) + variables['questionGroupId'] = self.gID(q2_group.id) content = self.query_check( self.Query.QuestionGroup, variables=variables, ) - assert content['data']['private']['projectScope']['group'] is None, content + assert content['data']['private']['projectScope']['leafGroup'] is None, content class TestChoiceCollectionQuery(TestCase): class Query: ChoiceCollectionList = ''' - query MyQuery($projectID: ID!, $filterData: QuestionChoiceCollectionFilter) { + query MyQuery($projectId: ID!, $filterData: QuestionChoiceCollectionFilter) { private { - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { choiceCollections(order: {id: ASC}, filters: $filterData) { count items { @@ -406,9 +412,9 @@ class Query: ''' ChoiceCollection = ''' - query MyQuery($projectID: ID!, $choiceCollectionID: ID!) { + query MyQuery($projectId: ID!, $choiceCollectionID: ID!) { private { - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { choiceCollection(pk: $choiceCollectionID) { id label @@ -454,7 +460,7 @@ def test_choice_collections(self): q3_choice_collections[0].name = 'question-choice-collection-unique-0001' q3_choice_collections[0].save(update_fields=('name',)) - variables = {'projectID': self.gID(project.id)} + variables = {'projectId': self.gID(project.id)} # Without authentication ----- content = self.query_check( self.Query.ChoiceCollectionList, @@ -520,7 +526,7 @@ def test_choice_collection(self): ) variables = { - 'projectID': self.gID(project.id), + 'projectId': self.gID(project.id), 'choiceCollectionID': self.gID(q1_question_choice_collection.id), } # Without authentication ----- @@ -576,10 +582,10 @@ def test_choice_collection(self): class TestQuestionQuery(TestCase): class Query: QuestionList = ''' - query MyQuery($projectID: ID!, $filterData: QuestionFilter) { + query MyQuery($projectId: ID!, $filterData: QuestionFilter) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id questions(order: {id: ASC}, filters: $filterData) { count @@ -606,10 +612,10 @@ class Query: ''' Question = ''' - query MyQuery($projectID: ID!, $questionID: ID!) { + query MyQuery($projectId: ID!, $questionID: ID!) { private { id - projectScope(pk: $projectID) { + projectScope(pk: $projectId) { id question(pk: $questionID) { id @@ -651,14 +657,19 @@ def test_questions(self): q1, q2, q3 = QuestionnaireFactory.create_batch(3, project=project, **user_resource_params) + [group1] = QuestionLeafGroupFactory.static_generator(1, **user_resource_params, questionnaire=q1) + [group2] = QuestionLeafGroupFactory.static_generator(1, **user_resource_params, questionnaire=q2) + [group3] = QuestionLeafGroupFactory.static_generator(1, **user_resource_params, questionnaire=q3) + question_params = {**user_resource_params, 'type': Question.Type.DATE} - q1_questions = QuestionFactory.create_batch(2, **question_params, questionnaire=q1, label='Who are you?') - q2_questions = QuestionFactory.create_batch(3, **question_params, questionnaire=q2) - q3_questions = QuestionFactory.create_batch(5, **question_params, questionnaire=q3) + q1_questions = QuestionFactory.create_batch( + 2, **question_params, questionnaire=q1, leaf_group=group1, label='Who are you?') + q2_questions = QuestionFactory.create_batch(3, **question_params, questionnaire=q2, leaf_group=group2) + q3_questions = QuestionFactory.create_batch(5, **question_params, questionnaire=q3, leaf_group=group3) q3_questions[0].name = 'question-unique-0001' q3_questions[0].save(update_fields=('name',)) - variables = {'projectID': self.gID(project.id)} + variables = {'projectId': self.gID(project.id)} # Without authentication ----- content = self.query_check( self.Query.QuestionList, @@ -718,15 +729,19 @@ def test_question(self): q1 = QuestionnaireFactory.create(project=project, **user_resource_params) q2 = QuestionnaireFactory.create(project=project2, **user_resource_params) + [group1] = QuestionLeafGroupFactory.static_generator(1, **user_resource_params, questionnaire=q1) + [group2] = QuestionLeafGroupFactory.static_generator(1, **user_resource_params, questionnaire=q2) + q1_choice_collection = ChoiceCollectionFactory.create(**user_resource_params, questionnaire=q1) question, *_ = QuestionFactory.create_batch( 4, **question_params, questionnaire=q1, + leaf_group=group1, choice_collection=q1_choice_collection, ) - question2 = QuestionFactory.create(**question_params, questionnaire=q2) + question2 = QuestionFactory.create(**question_params, questionnaire=q2, leaf_group=group2) choice_collection_response = { 'id': self.gID(q1_choice_collection.pk), @@ -743,7 +758,7 @@ def test_question(self): } variables = { - 'projectID': self.gID(project.id), + 'projectId': self.gID(project.id), 'questionID': self.gID(question.id), } # Without authentication ----- diff --git a/apps/questionnaire/types.py b/apps/questionnaire/types.py index 0537bb5..cf096da 100644 --- a/apps/questionnaire/types.py +++ b/apps/questionnaire/types.py @@ -9,11 +9,18 @@ from apps.common.types import UserResourceTypeMixin, ClientIdMixin from apps.project.models import Project -from .enums import QuestionTypeEnum +from .enums import ( + QuestionTypeEnum, + QuestionLeafGroupTypeEnum, + QuestionLeafGroupCategory1TypeEnum, + QuestionLeafGroupCategory2TypeEnum, + QuestionLeafGroupCategory3TypeEnum, + QuestionLeafGroupCategory4TypeEnum, +) from .models import ( Questionnaire, Question, - QuestionGroup, + QuestionLeafGroup, ChoiceCollection, Choice, ) @@ -39,24 +46,30 @@ def project_id(self) -> strawberry.ID: return strawberry.ID(str(self.project_id)) -@strawberry_django.type(QuestionGroup) -class QuestionGroupType(UserResourceTypeMixin): +@strawberry_django.type(QuestionLeafGroup) +class QuestionLeafGroupType(UserResourceTypeMixin): id: strawberry.ID name: strawberry.auto - label: strawberry.auto + type: QuestionLeafGroupTypeEnum + order: strawberry.auto + is_hidden: strawberry.auto + # Categories + # -- For Matrix1D/Matrix2D + category_1: QuestionLeafGroupCategory1TypeEnum + category_2: QuestionLeafGroupCategory2TypeEnum + # -- For Matrix2D + category_3: typing.Optional[QuestionLeafGroupCategory3TypeEnum] + category_4: typing.Optional[QuestionLeafGroupCategory4TypeEnum] + # Misc relevant: strawberry.auto @strawberry.field def questionnaire_id(self) -> strawberry.ID: return strawberry.ID(str(self.questionnaire_id)) - @strawberry.field - def parent_id(self) -> typing.Optional[strawberry.ID]: - return self.parent_id and strawberry.ID(str(self.parent_id)) - @staticmethod def get_queryset(_, queryset: models.QuerySet | None, info: Info): - qs = get_queryset_for_model(QuestionGroup, queryset) + qs = get_queryset_for_model(QuestionLeafGroup, queryset) if ( info.context.active_project and info.context.has_perm(Project.Permission.VIEW_QUESTION_GROUP) @@ -147,9 +160,9 @@ def questionnaire_id(self) -> strawberry.ID: return strawberry.ID(str(self.questionnaire_id)) @strawberry.field - def group_id(self) -> typing.Optional[strawberry.ID]: - if self.group_id: - return strawberry.ID(str(self.group_id)) + def leaf_group_id(self) -> typing.Optional[strawberry.ID]: + if self.leaf_group_id: + return strawberry.ID(str(self.leaf_group_id)) @strawberry.field @sync_to_async diff --git a/apps/user/migrations/0001_initial.py b/apps/user/migrations/0001_initial.py index caa7fa0..22505ae 100644 --- a/apps/user/migrations/0001_initial.py +++ b/apps/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.1 on 2023-06-07 10:35 +# Generated by Django 4.2.1 on 2023-08-29 08:10 import django.contrib.postgres.fields from django.db import migrations, models diff --git a/main/settings.py b/main/settings.py index 0058295..e800d4e 100644 --- a/main/settings.py +++ b/main/settings.py @@ -75,6 +75,8 @@ SMTP_EMAIL_PORT=int, SMTP_EMAIL_USERNAME=str, SMTP_EMAIL_PASSWORD=str, + # MISC + ALLOW_DUMMY_DATA_SCRIPT=(bool, False), # WARNING ) @@ -119,6 +121,7 @@ 'apps.user', 'apps.project', 'apps.questionnaire', + # 'apps.qbank', ] MIDDLEWARE = [ @@ -397,3 +400,5 @@ 'LOCATION': 'local-memory-02', } } + +ALLOW_DUMMY_DATA_SCRIPT = env('ALLOW_DUMMY_DATA_SCRIPT') diff --git a/schema.graphql b/schema.graphql index 1c924a1..9c54d3e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -180,9 +180,8 @@ type ProjectScopeMutation { createQuestion(data: QuestionCreateInput!): QuestionTypeMutationResponseType! updateQuestion(id: ID!, data: QuestionUpdateInput!): QuestionTypeMutationResponseType! deleteQuestion(id: ID!): QuestionTypeMutationResponseType! - createQuestionGroup(data: QuestionGroupCreateInput!): QuestionGroupTypeMutationResponseType! - updateQuestionGroup(id: ID!, data: QuestionGroupUpdateInput!): QuestionGroupTypeMutationResponseType! - deleteQuestionGroup(id: ID!): QuestionGroupTypeMutationResponseType! + bulkUpdateQuestionnairQuestionGroupsLeafOrder(questionnaireId: ID!, data: [QuestionLeafGroupOrderInputType!]!): QuestionLeafGroupTypeBulkBasicMutationResponseType! + updateQuestionGroupLeafVisibility(id: ID!, visibility: QuestionLeafGroupVisibilityActionEnum!): QuestionLeafGroupTypeMutationResponseType! createQuestionChoiceCollection(data: QuestionChoiceCollectionCreateInput!): QuestionChoiceCollectionTypeMutationResponseType! updateQuestionChoiceCollection(id: ID!, data: QuestionChoiceCollectionUpdateInput!): QuestionChoiceCollectionTypeMutationResponseType! deleteQuestionChoiceCollection(id: ID!): QuestionChoiceCollectionTypeMutationResponseType! @@ -193,11 +192,11 @@ type ProjectScopeMutation { type ProjectScopeType { questionnaires(filters: QuestionnaireFilter, order: QuestionnaireOrder, pagination: OffsetPaginationInput): QuestionnaireTypeCountList! - groups(filters: QuestionGroupFilter, order: QuestionGroupOrder, pagination: OffsetPaginationInput): QuestionGroupTypeCountList! + leafGroups(filters: QuestionLeafGroupFilter, order: QuestionLeafGroupOrder, pagination: OffsetPaginationInput): QuestionLeafGroupTypeCountList! choiceCollections(filters: QuestionChoiceCollectionFilter, order: QuestionChoiceCollectionOrder, pagination: OffsetPaginationInput): QuestionChoiceCollectionTypeCountList! questions(filters: QuestionFilter, order: QuestionOrder, pagination: OffsetPaginationInput): QuestionTypeCountList! questionnaire(pk: ID!): QuestionnaireType - group(pk: ID!): QuestionGroupType + leafGroup(pk: ID!): QuestionLeafGroupType choiceCollection(pk: ID!): QuestionChoiceCollectionType question(pk: ID!): QuestionType id: ID! @@ -327,10 +326,10 @@ type QuestionChoiceType { input QuestionCreateInput { questionnaire: ID! + leafGroup: ID! type: QuestionTypeEnum! name: String! label: String! - group: ID hint: String choiceCollection: ID default: String @@ -358,63 +357,180 @@ input QuestionFilter { type: QuestionTypeEnum name: StrFilterLookup label: StrFilterLookup - group: DjangoModelFilterInput + leafGroup: DjangoModelFilterInput includeChildGroup: Boolean = false } -input QuestionGroupCreateInput { - questionnaire: ID! - name: String! - label: String! - parent: ID - relevant: String -} - -input QuestionGroupFilter { +enum QuestionLeafGroupCategory1TypeEnum { + CONTEXT + EVENT_SHOCK + DISPLACEMENT + CASUALTIES + INFORMATION_AND_COMMUNICATION + HUMANITARIAN_ACCESS + IMPACT + HUMANITARIAN_CONDITIONS + AT_RISK + PRIORITIES + CAPACITIES_RESPONSE +} + +enum QuestionLeafGroupCategory2TypeEnum { + POLITICS + DEMOGRAPHY + SOCIO_CULTURAL + ENVIRONMENT + SECURITY_AND_STABILITY + ECONOMICS + EVENT_SHOCK_CHARACTERISTICS + DRIVERS_AND_AGGRAVATING_FACTORS + MITIGATING_FACTORS + HAZARDS_AND_THREATS + DISPLACEMENT_CHARACTERISTICS + PUSH_FACTORS + PULL_FACTORS + INTENTIONS + LOCAL_INTEGRATION + DEAD + INJURED + MISSING + SOURCE_AND_MEANS + CHALLENDGES_AND_BARRIERS + KNOWLEDGE_AND_INFO_GAPS_HUMANITARIAN + KNOWLEDGE_AND_INFO_GAPS_POPULATION + POPULATION_TO_RELIEF + RELIEF_TO_POPULATION + PHYSICAL_AND_SECURITY + NUMBER_OF_PEOPLE_FACING_HUMANITARIN_ACCESS_CONSTRAINTS + DRIVERS + IMPACT_ON_PEOPLE + IMPACT_ON_SYSTEMS_SERVICES_NETWORK + LIVING_STANDARDS + COPING_MECHANISMS + PHYSICAL_AND_MENTAL_WELLBEING + PEOPLE_AT_RISK + PRIOTIY_ISSUES_POP + PRIOTIY_ISSUES_HUM + PRIOTIY_INTERVENTIONS_POP + PRIOTIY_INTERVENTIONS_HUM + GOVERNMENT_LOCAL_AUTHORITIES + INTERNATIONAL_ORGANISATIONS + NATION_AND_LOCAL_ORGANISATIONS + RED_CROSS_RED_CRESCENT + HUMANITARIAN_COORDINATION +} + +enum QuestionLeafGroupCategory3TypeEnum { + CROSS + FOOD + WASH + HEALTH + PROTECTION + EDUCATION + LIVELIHOOD + NUTRITION + AGRICULTURE + LOGISTICS + SHELTER + ANALYTICAL_OUTPUTS +} + +enum QuestionLeafGroupCategory4TypeEnum { + WATER + SANITATION + HYGIENE + WASTE_MANAGEMENT + VECTOR_CONTROL + HEALTH_CARE + HEALTH_STATUS + DOCUMENTATION + CIVIL_AND_POLITICAL_RIGHTS + PHYSICAL_SAFETY_AND_SECURITY + FREEDOM_OF_MOVEMENT + LIBERTY + CHILD_PROTECTION + SGBV + HOUSING_LAND_AND_PROPERTY + JUSTICE_AND_RULE_OF_LAW + MINES + HUMAN_TRAFFICKING + LEARNING_ENVIRONMENT + FACILITIES_AND_AMENITIES + TEACHER_AND_LEARNING + TEACHERS_AND_EDUCATION_PERSONNEL + INCOME + EXPENDITURES + PRODUCTIVE_ASSETS + SKILLS_AND_QUALIFICATIONS + NUTRITION_GOODS_AND_SERVICES + NUTRITION_STATUS + DWELLING_ENVELOPPE + INTERIOR_DOMENSTIC_LIFE +} + +input QuestionLeafGroupFilter { id: IDFilterLookup questionnaire: DjangoModelFilterInput - parent: DjangoModelFilterInput name: StrFilterLookup - label: StrFilterLookup + isHidden: Boolean + type: QuestionLeafGroupTypeEnum } -input QuestionGroupOrder { +input QuestionLeafGroupOrder { id: Ordering + order: Ordering createdAt: Ordering } -type QuestionGroupType { +input QuestionLeafGroupOrderInputType { + id: ID! + order: Int! +} + +type QuestionLeafGroupType { createdAt: DateTime! modifiedAt: DateTime! id: ID! name: String! - label: String! + type: QuestionLeafGroupTypeEnum! + order: Int! + isHidden: Boolean! + category1: QuestionLeafGroupCategory1TypeEnum! + category2: QuestionLeafGroupCategory2TypeEnum! + category3: QuestionLeafGroupCategory3TypeEnum + category4: QuestionLeafGroupCategory4TypeEnum relevant: String! createdBy: UserType! modifiedBy: UserType! - parentId: ID questionnaireId: ID! } -type QuestionGroupTypeCountList { +type QuestionLeafGroupTypeBulkBasicMutationResponseType { + errors: [CustomErrorType!] + results: [QuestionLeafGroupType!] +} + +type QuestionLeafGroupTypeCountList { limit: Int! offset: Int! count: Int! - items: [QuestionGroupType!]! + items: [QuestionLeafGroupType!]! +} + +enum QuestionLeafGroupTypeEnum { + MATRIX_1D + MATRIX_2D } -type QuestionGroupTypeMutationResponseType { +type QuestionLeafGroupTypeMutationResponseType { ok: Boolean! errors: CustomErrorType - result: QuestionGroupType + result: QuestionLeafGroupType } -input QuestionGroupUpdateInput { - questionnaire: ID - parent: ID - name: String - label: String - relevant: String +enum QuestionLeafGroupVisibilityActionEnum { + SHOW + HIDE } input QuestionOrder { @@ -448,7 +564,7 @@ type QuestionType { type: QuestionTypeEnum! choiceCollection: QuestionChoiceCollectionType createdBy: UserType! - groupId: ID + leafGroupId: ID modifiedBy: UserType! questionnaireId: ID! } @@ -488,7 +604,7 @@ type QuestionTypeMutationResponseType { input QuestionUpdateInput { questionnaire: ID - group: ID + leafGroup: ID type: QuestionTypeEnum name: String label: String diff --git a/utils/db.py b/utils/db.py new file mode 100644 index 0000000..4fa2eff --- /dev/null +++ b/utils/db.py @@ -0,0 +1,13 @@ +from django.db import connection + + +def execute_raw_query(query, params={}, flat=False): + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = cursor.fetchall() + if flat: + return [ + row[0] + for row in rows + ] + return rows diff --git a/utils/strawberry/mutations.py b/utils/strawberry/mutations.py index 64d418e..f18a063 100644 --- a/utils/strawberry/mutations.py +++ b/utils/strawberry/mutations.py @@ -29,11 +29,16 @@ ) -def process_input_data(data) -> dict: +def process_input_data(data) -> dict | list: """ Return dict from Strawberry Input Object """ # TODO: Write test + if type(data) in [tuple, list]: + return [ + process_input_data(datum) + for datum in data + ] native_dict = {} for key, value in data.__dict__.items(): if value == strawberry.UNSET: @@ -192,6 +197,12 @@ class MutationResponseType(typing.Generic[ResultTypeVar]): result: typing.Optional[ResultTypeVar] = None +@strawberry.type +class BulkBasicMutationResponseType(typing.Generic[ResultTypeVar]): + errors: typing.Optional[list[CustomErrorType]] = None + results: typing.Optional[list[ResultTypeVar]] = None + + @strawberry.type class BulkMutationResponseType(typing.Generic[ResultTypeVar]): errors: typing.Optional[list[CustomErrorType]] = None @@ -234,7 +245,8 @@ def __init__( partial=True, ) - def check_permissions(self, info, permission) -> CustomErrorType | None: + @staticmethod + def check_permissions(info, permission) -> CustomErrorType | None: if permission and not info.context.has_perm(permission): errors = CustomErrorType([ dict(