diff --git a/apps/common/admin.py b/apps/common/admin.py index d4a851c..e78cbcc 100644 --- a/apps/common/admin.py +++ b/apps/common/admin.py @@ -1,4 +1,9 @@ +from django.contrib import admin +from django.db import models from django.conf import settings +from admin_auto_filters.filters import AutocompleteFilterFactory + +from .models import GlobalPermission class ReadOnlyMixin(): @@ -10,3 +15,36 @@ def has_change_permission(self, *args, **kwargs): def has_delete_permission(self, *args, **kwargs): return settings.ENABLE_BREAKING_MODE + + +@admin.register(GlobalPermission) +class GlobalPermissionAdmin(admin.ModelAdmin): + search_fields = ('type',) + list_display = ( + 'type', + 'user_count', + ) + list_filter = ( + AutocompleteFilterFactory('User', 'users'), + ) + autocomplete_fields = ('users',) + + def user_count(self, instance): + if not instance: + return + return instance.user_count + + user_count.short_description = 'User Count' + + def get_queryset(self, request): + return super().get_queryset(request).annotate( + user_count=models.Subquery( + GlobalPermission.users.through.objects + .filter(globalpermission=models.OuterRef('pk')) + .order_by().values('globalpermission') + .annotate( + count=models.Count('user'), + ).values('count')[:1], + output_field=models.IntegerField(), + ), + ) diff --git a/apps/common/enums.py b/apps/common/enums.py new file mode 100644 index 0000000..40db822 --- /dev/null +++ b/apps/common/enums.py @@ -0,0 +1,15 @@ +import strawberry + +from utils.strawberry.enums import get_enum_name_from_django_field + +from .models import GlobalPermission + +GlobalPermissionTypeEnum = strawberry.enum(GlobalPermission.Type, name='GlobalPermissionTypeEnum') + + +enum_map = { + get_enum_name_from_django_field(field): enum + for field, enum in ( + (GlobalPermission.type, GlobalPermissionTypeEnum), + ) +} diff --git a/apps/common/factories.py b/apps/common/factories.py new file mode 100644 index 0000000..97a0e12 --- /dev/null +++ b/apps/common/factories.py @@ -0,0 +1,8 @@ +from factory.django import DjangoModelFactory + +from .models import GlobalPermission + + +class GlobalPermissionFactory(DjangoModelFactory): + class Meta: + model = GlobalPermission diff --git a/apps/common/migrations/0001_initial.py b/apps/common/migrations/0001_initial.py new file mode 100644 index 0000000..18a60a1 --- /dev/null +++ b/apps/common/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2023-10-10 08:50 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GlobalPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.SmallIntegerField(choices=[(1, 'Upload Question Bank'), (2, 'Activate Question Bank')], unique=True)), + ('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/apps/common/migrations/__init__.py b/apps/common/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/common/models.py b/apps/common/models.py index 52786be..a97be86 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -1,4 +1,5 @@ from django.db import models + from apps.user.models import User @@ -23,3 +24,20 @@ class UserResource(models.Model): class Meta: abstract = True ordering = ['-id'] + + +class GlobalPermission(models.Model): + class Type(models.IntegerChoices): + UPLOAD_QBANK = 1, 'Upload Question Bank' + ACTIVATE_QBANK = 2, 'Activate Question Bank' + + type = models.SmallIntegerField(unique=True, choices=Type.choices) + users = models.ManyToManyField(User, blank=True) + + def __str__(self): + return self.Type(self.type).label + + def add_user(self, user): + if self.users.filter(pk=user.id).exists(): + return + self.users.add(user) diff --git a/apps/qbank/admin.py b/apps/qbank/admin.py index c365fd0..8f07cf7 100644 --- a/apps/qbank/admin.py +++ b/apps/qbank/admin.py @@ -1,6 +1,8 @@ -from django.contrib import admin -from django.contrib import messages +from django.contrib import admin, messages +from django.db import models + from admin_auto_filters.filters import AutocompleteFilterFactory +from prettyjson import PrettyJSONWidget from apps.common.admin import ReadOnlyMixin from .models import ( @@ -24,6 +26,9 @@ class QuestionBankAdmin(ReadOnlyMixin, admin.ModelAdmin): AutocompleteFilterFactory('Created By', 'created_by'), 'is_active', ) + formfield_overrides = { + models.JSONField: {'widget': PrettyJSONWidget} + } def save_model(self, request, obj, form, change): if change and form.initial.get('is_active') != obj.is_active and obj.is_active: diff --git a/apps/qbank/importer/xlsxform.py b/apps/qbank/importer/xlsxform.py index 536aeda..702df0c 100644 --- a/apps/qbank/importer/xlsxform.py +++ b/apps/qbank/importer/xlsxform.py @@ -3,7 +3,7 @@ from django.core.files.temp import NamedTemporaryFile from django.conf import settings from django.db import transaction -from pyxform.xls2json import parse_file_to_json +from pyxform.xls2json import parse_file_to_json, PyXFormError from apps.qbank.base_models import BaseQuestion, BaseQuestionLeafGroup from apps.qbank.models import ( @@ -17,6 +17,13 @@ logger = logging.getLogger(__name__) +class XlsFormValidationError(Exception): + def __init__(self, errors): + self.errors = errors + self.message = 'Invalid XLSForm: ' + ';; '.join(errors) + super().__init__(self.message) + + class Parser: @staticmethod def no_op(value): @@ -255,6 +262,10 @@ def create_leaf_groups(self): } def process_each(self, data): + # XXX: This is added by PyXForm which we don't need for now + if data == {'name': 'instanceID', 'bind': {'readonly': 'true()', 'jr:preload': 'uid'}, 'type': 'calculate'}: + return + _type = data['type'].lower() # Groups (Ignoring groups for now as we have our own grouping using custom column) @@ -333,9 +344,13 @@ def process(self): self.leaf_group_map = self.create_leaf_groups() self.questions = [] self.errors = [] - self.process_each(self.validate_xlsform(self.qbank.import_file)) + try: + self.process_each( + self.validate_xlsform(self.qbank.import_file) + ) + except PyXFormError as e: + # It only sends one error at a time + raise XlsFormValidationError([str(e)]) QBQuestion.objects.bulk_create(self.questions) if self.errors: - print(f'----------------------------- ERRORS ({len(self.errors)}) -----------------------------') - for error in self.errors: - print(error) + raise XlsFormValidationError(self.errors) diff --git a/apps/qbank/migrations/0003_questionbank_ended_at_questionbank_errors_and_more.py b/apps/qbank/migrations/0003_questionbank_ended_at_questionbank_errors_and_more.py new file mode 100644 index 0000000..28a9383 --- /dev/null +++ b/apps/qbank/migrations/0003_questionbank_ended_at_questionbank_errors_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.5 on 2023-10-06 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('qbank', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='questionbank', + name='ended_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='questionbank', + name='errors', + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name='questionbank', + name='started_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/apps/qbank/models.py b/apps/qbank/models.py index d13f400..dca7a3b 100644 --- a/apps/qbank/models.py +++ b/apps/qbank/models.py @@ -28,6 +28,8 @@ class Status(models.IntegerChoices): title = models.CharField(max_length=255) status = models.PositiveSmallIntegerField(choices=Status.choices, default=Status.PENDING) + started_at = models.DateTimeField(null=True, blank=True) + ended_at = models.DateTimeField(null=True, blank=True) description = models.TextField(blank=True) is_active = models.BooleanField(default=False) import_file = models.FileField( @@ -36,6 +38,11 @@ class Status(models.IntegerChoices): max_length=255, ) + errors = models.JSONField(default=list) + + # Types + get_status_display: typing.Callable[..., str] + def __str__(self): return f'{self.pk}: {self.title}' diff --git a/apps/qbank/mutations.py b/apps/qbank/mutations.py index e69de29..5ec2248 100644 --- a/apps/qbank/mutations.py +++ b/apps/qbank/mutations.py @@ -0,0 +1,76 @@ +import strawberry +from asgiref.sync import sync_to_async +from strawberry.types import Info +from django.core.exceptions import ValidationError +from django.shortcuts import get_object_or_404 + +from utils.strawberry.mutations import ( + MutationResponseType, + mutation_is_not_valid, +) +from utils.strawberry.transformers import convert_serializer_to_type +from utils.strawberry.mutations import process_input_data, generate_error_message + +from apps.common.models import GlobalPermission +from .serializers import CreateQuestionBankSerializer +from .types import ( + QuestionBankType, +) + +CreateQuestionBankInput = convert_serializer_to_type(CreateQuestionBankSerializer, name='CreateQuestionBankInput') + + +@strawberry.type +class PrivateMutation: + + @strawberry.mutation + @sync_to_async + def create_question_bank( + self, + data: CreateQuestionBankInput, + info: Info, + ) -> MutationResponseType[QuestionBankType]: + if error := info.context.has_global_perm(GlobalPermission.Type.UPLOAD_QBANK): + return MutationResponseType( + ok=False, + errors=generate_error_message(error), + ) + serializer = CreateQuestionBankSerializer( + data=process_input_data(data), + context={'request': info.context.request}, + ) + if errors := mutation_is_not_valid(serializer): + return MutationResponseType( + ok=False, + errors=errors, + ) + instance = serializer.save() + return MutationResponseType( + result=instance, + ) + + @strawberry.mutation + @sync_to_async + def activate_question_bank( + self, + id: strawberry.ID, + info: Info, + ) -> MutationResponseType[QuestionBankType]: + if error := info.context.has_global_perm(GlobalPermission.Type.ACTIVATE_QBANK): + return MutationResponseType( + ok=False, + errors=generate_error_message(error), + ) + queryset = QuestionBankType.get_queryset(None, None, info) + qbank = get_object_or_404(queryset, id=id) + try: + qbank.activate() + except ValidationError as e: + return MutationResponseType( + ok=False, + errors=generate_error_message(str(e)), + ) + return MutationResponseType( + result=qbank, + ok=True, + ) diff --git a/apps/qbank/serializers.py b/apps/qbank/serializers.py new file mode 100644 index 0000000..2e8cc7f --- /dev/null +++ b/apps/qbank/serializers.py @@ -0,0 +1,33 @@ +from django.db import transaction +from rest_framework import serializers + +from apps.common.serializers import UserResourceSerializer +from apps.qbank.models import QuestionBank +from apps.qbank.tasks import import_task + + +class CreateQuestionBankSerializer(UserResourceSerializer): + class Meta: + model = QuestionBank + fields = ( + 'title', + 'description', + 'import_file', + ) + + def validate_import_file(self, import_file): + # Basic extension check + if not import_file.name.endswith('.xlsx'): + raise serializers.ValidationError('Only XLSX file allowed. ') + return import_file + + def update(self, _): + raise Exception('Update not allowed') + + def create(self, data): + instance = super().create(data) + # Trigger import + transaction.on_commit( + lambda: import_task.delay(instance.pk) + ) + return instance diff --git a/apps/qbank/tasks.py b/apps/qbank/tasks.py new file mode 100644 index 0000000..982b6b1 --- /dev/null +++ b/apps/qbank/tasks.py @@ -0,0 +1,58 @@ +import logging + +from celery import shared_task +from django.utils import timezone +from django.db import transaction + +from main.celery import CeleryQueue +from apps.qbank.models import QuestionBank +from apps.qbank.importer.xlsxform import XlsFormImport, XlsFormValidationError + +logger = logging.getLogger(__name__) + + +@shared_task(queue=CeleryQueue.DEFAULT) +def import_task(qbank_id, force=False): + try: + qbank = QuestionBank.objects.get(pk=qbank_id) + # Skip if qbank is already started + if not force and qbank.status != QuestionBank.Status.PENDING: + logger.warning(f'qbank status is {qbank.get_status_display()}') + return 'SKIPPED' + + # Update status to STARTED + qbank.status = QuestionBank.Status.STARTED + qbank.started_at = timezone.now() + with transaction.atomic(): + qbank.save(update_fields=('status', 'started_at',)) + # Process + importer = XlsFormImport(qbank) + with transaction.atomic(): # Revert back if there are any error + importer.process() + qbank.status = QuestionBank.Status.SUCCESS + qbank.ended_at = timezone.now() + with transaction.atomic(): + qbank.save() + return True + + except Exception as e: + qbank = QuestionBank.objects.filter(id=qbank_id).first() + # Update status to FAILURE + if qbank: + qbank.status = QuestionBank.Status.FAILURE + if qbank.started_at: + qbank.ended_at = timezone.now() + if isinstance(e, XlsFormValidationError): + qbank.errors = e.errors + with transaction.atomic(): + qbank.save(update_fields=('status', 'errors', 'ended_at',)) + logger.error( + 'QuestionBank import Failed!!', + exc_info=True, + extra={ + 'data': { + 'qbank_id': qbank_id, + }, + }, + ) + return False diff --git a/apps/qbank/tests/__init__.py b/apps/qbank/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/qbank/tests/test_mutations.py b/apps/qbank/tests/test_mutations.py new file mode 100644 index 0000000..da1177e --- /dev/null +++ b/apps/qbank/tests/test_mutations.py @@ -0,0 +1,277 @@ +import gzip +import os +from pathlib import Path +from unittest import mock + +from django.core.files.temp import NamedTemporaryFile + +from main.tests import TestCase + +from apps.common.models import GlobalPermission +from apps.user.factories import UserFactory +from apps.qbank.factories import QuestionBankFactory +from apps.qbank.models import QuestionBank +from apps.qbank.tasks import import_task + +BASE_DIR = Path(__file__).resolve().parent + + +class TestQbankMutation(TestCase): + class Query: + Qbank = ''' + query Query($id: ID!) { + private { + questionBank(pk: $id) { + id + isActive + title + errors + } + } + } + ''' + + ActiveQbank = ''' + query MyQuery { + private { + activeQuestionBank { + id + isActive + } + } + } + ''' + + class Mutation: + QbankCreate = ''' + mutation MyMutation($data: CreateQuestionBankInput!) { + private { + createQuestionBank(data: $data) { + errors + ok + result { + id + isActive + title + errors + } + } + } + } + ''' + + QbankActivate = ''' + mutation MyMutation($id: ID!) { + private { + id + activateQuestionBank(id: $id) { + ok + errors + result { + id + isActive + errors + } + } + } + } + ''' + + @mock.patch('apps.qbank.serializers.import_task.delay') + def test_create_qbank(self, import_task_mock): + user = UserFactory.create() + + data = { + 'title': 'Question bank 1', + 'description': 'Basic description', + } + + def _query_check(_id, **kwargs): + return self.query_check( + self.Query.Qbank, + variables={ + 'id': _id, + }, + **kwargs, + ) + + with ( + NamedTemporaryFile(suffix='.png') as invalid_file, + gzip.GzipFile(os.path.join(BASE_DIR, 'xlsform-valid.xlsx.gz'), mode='rb') as clean_file, + gzip.GzipFile( + os.path.join(BASE_DIR, 'xlsform-invalid-name.xlsx.gz'), + mode='rb' + ) as xlsform_error_file, # XLSForm Error + gzip.GzipFile( + os.path.join(BASE_DIR, 'xlsform-invalid.xlsx.gz'), + mode='rb', + ) as qber_error_file, # Qber Error + ): + clean_file.name = clean_file.name.replace('.gz', '') + xlsform_error_file.name = xlsform_error_file.name.replace('.gz', '') + qber_error_file.name = qber_error_file.name.replace('.gz', '') + + invalid_file.write(b'test-data') + + def _mutation_check(data, file=None, **kwargs): + if file is None: + file = clean_file + file.seek(0) + return self.query_check( + self.Mutation.QbankCreate, + variables={ + 'data': data, + }, + files={ + 't_file': file, + }, + map={ + 't_file': ['variables.data.importFile'] + }, + **kwargs, + ) + + # Without login + _mutation_check(data=data, assert_errors=True) + # With login - Without permission + self.force_login(user) + response = _mutation_check(data=data)['data']['private']['createQuestionBank'] + assert response['ok'] is False + assert response['errors'] not in ([], None) + # - Without permission + self.global_permissions[GlobalPermission.Type.UPLOAD_QBANK].add_user(user) + with self.captureOnCommitCallbacks(execute=True): + response_success = _mutation_check(data=data)['data']['private']['createQuestionBank'] + with self.captureOnCommitCallbacks(execute=True): + response_xlsform_error = _mutation_check( + data=data, file=xlsform_error_file)['data']['private']['createQuestionBank'] + with self.captureOnCommitCallbacks(execute=True): + response_qber_error = _mutation_check( + data=data, file=qber_error_file)['data']['private']['createQuestionBank'] + with self.captureOnCommitCallbacks(execute=True): + response_invalid = _mutation_check(data=data, file=invalid_file)['data']['private']['createQuestionBank'] + qbank_success_id = int(response_success['result'].pop('id')) + qbank_xlsform_error_id = int(response_xlsform_error['result'].pop('id')) + qbank_qber_error_id = int(response_qber_error['result'].pop('id')) + + import_task_mock.assert_has_calls([ + mock.call(qbank_success_id), + mock.call(qbank_xlsform_error_id), + mock.call(qbank_qber_error_id), + ]) + assert response_success['ok'] is True + assert response_xlsform_error['ok'] is True + assert response_qber_error['ok'] is True + assert response_invalid['ok'] is False + + assert response_success['result'] == \ + response_qber_error['result'] == \ + response_xlsform_error['result'] == { + 'title': data['title'], + 'isActive': False, + 'errors': [], + } + # Run import task + import_task(qbank_success_id) + import_task(qbank_xlsform_error_id) + import_task(qbank_qber_error_id) + qbank_success = QuestionBank.objects.get(pk=qbank_success_id) + qbank_xlsform_error = QuestionBank.objects.get(pk=qbank_xlsform_error_id) + qbank_qber_error = QuestionBank.objects.get(pk=qbank_qber_error_id) + # Success + response = _query_check(qbank_success_id)['data']['private']['questionBank'] + assert qbank_success.status == QuestionBank.Status.SUCCESS + assert qbank_success.errors == [] + assert response == { + 'id': str(qbank_success_id), + 'title': data['title'], + 'isActive': False, + 'errors': [] + } + del response + # Failures - XLSForm Structure + response = _query_check(qbank_xlsform_error_id)['data']['private']['questionBank'] + assert qbank_xlsform_error.status == QuestionBank.Status.FAILURE + assert qbank_xlsform_error.errors != [] + assert response.pop('errors') == ['On the choices sheet there is a option with no name. [list_name : list_name]'] + assert response == { + 'id': str(qbank_xlsform_error_id), + 'title': data['title'], + 'isActive': False, + } + del response + # Failures - Qber Structure + response = _query_check(qbank_qber_error_id)['data']['private']['questionBank'] + assert qbank_qber_error.status == QuestionBank.Status.FAILURE + assert qbank_qber_error.errors != [] + assert response.pop('errors') != [] + assert response == { + 'id': str(qbank_qber_error_id), + 'title': data['title'], + 'isActive': False, + } + del response + + def test_activate_qbank(self): + user = UserFactory.create() + ur_params = dict(created_by=user, modified_by=user) + qbank = QuestionBankFactory.create(**ur_params) + + def _check_qb_active_status(is_active): + qbank.refresh_from_db() + assert qbank.is_active == is_active + + def _mutation_check(**kwargs): + return self.query_check( + self.Mutation.QbankActivate, + variables={ + 'id': str(qbank.pk), + }, + **kwargs, + ) + + def _query_check(**kwargs): + return self.query_check( + self.Query.ActiveQbank, + **kwargs, + ) + + # Without login + _mutation_check(assert_errors=True) + _query_check(assert_errors=True) + # With login - Without permission + self.force_login(user) + # -- ActiveQbank Query + assert _query_check()['data']['private']['activeQuestionBank'] is None + # -- Mutation Query + response = _mutation_check()['data']['private']['activateQuestionBank'] + assert response['ok'] is False + assert response['errors'] not in ([], None) + # - Without correct permission + self.global_permissions[GlobalPermission.Type.UPLOAD_QBANK].add_user(user) + response = _mutation_check()['data']['private']['activateQuestionBank'] + assert response['ok'] is False + assert response['errors'] not in ([], None) + # - Without permission + self.global_permissions[GlobalPermission.Type.ACTIVATE_QBANK].add_user(user) + # -- Still None + assert _query_check()['data']['private']['activeQuestionBank'] is None + for qbank_status, is_active_status in [ + # Order matters + (QuestionBank.Status.PENDING, False), + (QuestionBank.Status.STARTED, False), + (QuestionBank.Status.FAILURE, False), + (QuestionBank.Status.SUCCESS, True), + ]: + qbank.status = qbank_status + qbank.save(update_fields=('status',)) + response = _mutation_check()['data']['private']['activateQuestionBank'] + assert response['ok'] is is_active_status + _check_qb_active_status(is_active_status) + + # Check query as well + response = _query_check()['data']['private']['activeQuestionBank'] + assert response == { + 'id': str(qbank.id), + 'isActive': True, + } diff --git a/apps/qbank/tests/xlsform-invalid-name.xlsx.gz b/apps/qbank/tests/xlsform-invalid-name.xlsx.gz new file mode 100644 index 0000000..451dd95 Binary files /dev/null and b/apps/qbank/tests/xlsform-invalid-name.xlsx.gz differ diff --git a/apps/qbank/tests/xlsform-invalid.xlsx.gz b/apps/qbank/tests/xlsform-invalid.xlsx.gz new file mode 100644 index 0000000..e8722de Binary files /dev/null and b/apps/qbank/tests/xlsform-invalid.xlsx.gz differ diff --git a/apps/qbank/tests/xlsform-valid.xlsx.gz b/apps/qbank/tests/xlsform-valid.xlsx.gz new file mode 100644 index 0000000..d6c2284 Binary files /dev/null and b/apps/qbank/tests/xlsform-valid.xlsx.gz differ diff --git a/apps/qbank/types.py b/apps/qbank/types.py index 8bfd455..1c0efce 100644 --- a/apps/qbank/types.py +++ b/apps/qbank/types.py @@ -57,6 +57,7 @@ class QuestionBankType(UserResourceTypeMixin): id: strawberry.ID title: strawberry.auto is_active: strawberry.auto + errors: list[str] @staticmethod def get_queryset(_, queryset: models.QuerySet | None, info: Info): diff --git a/apps/questionnaire/tests/test_mutations.py b/apps/questionnaire/tests/test_mutations.py index a26cd1b..5eef378 100644 --- a/apps/questionnaire/tests/test_mutations.py +++ b/apps/questionnaire/tests/test_mutations.py @@ -1,5 +1,4 @@ from main.tests import TestCase - from apps.project.models import ProjectMembership from apps.project.factories import ProjectFactory from apps.questionnaire.enums import VisibilityActionEnum diff --git a/apps/user/admin.py b/apps/user/admin.py index 0328286..c04e678 100644 --- a/apps/user/admin.py +++ b/apps/user/admin.py @@ -9,6 +9,7 @@ @admin.register(User) class CustomUserAdmin(UserAdmin): + search_fields = ('email', 'first_name', 'last_name',) fieldsets = ( (None, { 'fields': ( diff --git a/apps/user/models.py b/apps/user/models.py index 53791db..cd8b5eb 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -1,9 +1,14 @@ +# from __future__ import annotations +import typing from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import ArrayField from django.db import models from .managers import CustomUserManager +if typing.TYPE_CHECKING: + from apps.common.models import GlobalPermission + class EmailNotificationType(models.IntegerChoices): ACCOUNT_ACTIVATION = 1, 'Account Activation' @@ -69,3 +74,13 @@ def is_email_subscribed_for(self, email_type): ): return False return True + + def get_global_permissions(self) -> set['GlobalPermission.Type']: + # Circular depencency + from apps.common.models import GlobalPermission + + types = GlobalPermission.objects.filter(users=self).values_list('type', flat=True).distinct() + return set([ + GlobalPermission.Type(_type) + for _type in types + ]) diff --git a/apps/user/types.py b/apps/user/types.py index 4712b84..6ac2508 100644 --- a/apps/user/types.py +++ b/apps/user/types.py @@ -1,8 +1,10 @@ import strawberry import strawberry_django +from strawberry.types import Info from utils.strawberry.enums import enum_field, enum_display_field +from apps.common.enums import GlobalPermissionTypeEnum from .models import User @@ -27,3 +29,7 @@ class UserMeType(UserType): email: strawberry.auto email_opt_outs = enum_field(User.email_opt_outs) email_opt_outs_display = enum_display_field(User.email_opt_outs) + + @strawberry.field + def global_permissions(self, info: Info) -> list[GlobalPermissionTypeEnum]: + return info.context.global_permissions diff --git a/main/enums.py b/main/enums.py index ad2023a..1445096 100644 --- a/main/enums.py +++ b/main/enums.py @@ -2,6 +2,7 @@ import dataclasses from apps.user.enums import enum_map as user_enum_map +from apps.common.enums import enum_map as common_enum_map from apps.project.enums import enum_map as project_enum_map from apps.qbank.enums import enum_map as qbank_enum_map from apps.questionnaire.enums import enum_map as questionnaire_enum_map @@ -9,6 +10,7 @@ ENUM_TO_STRAWBERRY_ENUM_MAP: dict[str, type] = { + **common_enum_map, **user_enum_map, **project_enum_map, **qbank_enum_map, diff --git a/main/graphql/schema.py b/main/graphql/schema.py index f443ba8..3be4896 100644 --- a/main/graphql/schema.py +++ b/main/graphql/schema.py @@ -9,11 +9,13 @@ from main.enums import AppEnumCollection, AppEnumCollectionData from apps.project.models import Project +from apps.common.enums import GlobalPermissionTypeEnum +from apps.user.models import User from apps.user import queries as user_queries, mutations as user_mutations from apps.project import queries as project_queries from apps.project import mutations as project_mutations -from apps.qbank import queries as qbank_queries +from apps.qbank import queries as qbank_queries, mutations as qbank_mutations from .permissions import IsAuthenticated from .dataloaders import GlobalDataLoader @@ -28,6 +30,7 @@ class ProjectContext: @dataclass class GraphQLContext(StrawberryDjangoContext): dl: GlobalDataLoader + global_permissions: set[GlobalPermissionTypeEnum] active_project: ProjectContext | None = None @sync_to_async @@ -49,12 +52,25 @@ def has_perm(self, permission: Project.Permission): raise Exception('There is no active project to select permissions from.') return permission in self.active_project.permissions + def has_global_perm(self, permission) -> str | None: + if permission not in self.global_permissions: + return f"You don't have permission for {permission.label}" + class CustomAsyncGraphQLView(AsyncGraphQLView): - async def get_context(self, *args, **kwargs) -> GraphQLContext: + @staticmethod + @sync_to_async + def get_global_permissions(user: User) -> set[GlobalPermissionTypeEnum]: + if not user.is_anonymous: + return user.get_global_permissions() + return set() + + async def get_context(self, request, **kwargs) -> GraphQLContext: + global_permissions = await self.get_global_permissions(request.user) return GraphQLContext( - *args, + request, **kwargs, + global_permissions=global_permissions, dl=GlobalDataLoader(), ) @@ -86,6 +102,7 @@ class PublicMutation( @strawberry.type class PrivateMutation( user_mutations.PrivateMutation, + qbank_mutations.PrivateMutation, project_mutations.PrivateMutation, ): id: strawberry.ID = strawberry.ID('private') diff --git a/main/settings.py b/main/settings.py index 9a7c75b..9b09322 100644 --- a/main/settings.py +++ b/main/settings.py @@ -120,6 +120,7 @@ 'django.contrib.gis', # External apps + 'prettyjson', 'admin_auto_filters', 'django_premailer', 'storages', diff --git a/main/tests/base.py b/main/tests/base.py index 7f587ba..6a4498f 100644 --- a/main/tests/base.py +++ b/main/tests/base.py @@ -5,6 +5,9 @@ from django.conf import settings from django.db import models +from apps.common.models import GlobalPermission +from apps.common.factories import GlobalPermissionFactory + TEST_CACHES = { 'default': { @@ -60,12 +63,21 @@ CELERY_TASK_ALWAYS_EAGER=True, ) class TestCase(BaseTestCase): + global_permissions: dict[GlobalPermission.Type, GlobalPermission] + def setUp(self): from django.core.cache import cache # Clear all test cache cache.clear() + self.setup_global_permissions() super().setUp() + def setup_global_permissions(self): + self.global_permissions = { + _type: GlobalPermissionFactory.create(type=_type) + for _type in GlobalPermission.Type + } + def force_login(self, user): self.client.force_login(user) @@ -77,17 +89,35 @@ def query_check( query: str, assert_errors: bool = False, variables: dict | None = None, + files: dict | None = None, **kwargs, ) -> Dict: - response = self.client.post( - "/graphql/", - data={ - "query": query, - "variables": variables, - }, - content_type="application/json", - **kwargs, - ) + import json + if files: + # Request type: form data + response = self.client.post( + "/graphql/", + data={ + 'operations': json.dumps({ + 'query': query, + 'variables': variables, + }), + **files, + 'map': json.dumps(kwargs.pop('map')), + }, + **kwargs + ) + else: + # Request type: json + response = self.client.post( + "/graphql/", + data={ + 'query': query, + 'variables': variables, + }, + content_type="application/json", + **kwargs, + ) if assert_errors: self.assertResponseHasErrors(response) else: diff --git a/poetry.lock b/poetry.lock index 09fa5c9..78c0a57 100644 --- a/poetry.lock +++ b/poetry.lock @@ -481,6 +481,22 @@ files = [ [package.dependencies] premailer = "3.0.0" +[[package]] +name = "django-prettyjson" +version = "0.4.1" +description = "Enables pretty JSON viewer in Django forms, admin, or templates" +optional = false +python-versions = "*" +files = [ + {file = "django-prettyjson-0.4.1.tar.gz", hash = "sha256:b758a5f3c073db93e17485b4eb9cb19e9838f6e5d6cb91096d795e015e28d3bd"}, + {file = "django_prettyjson-0.4.1-py2.py3-none-any.whl", hash = "sha256:f9f4d73899947f17a67f61b57216612373195937fa936ba9335549fe878b3355"}, +] + +[package.dependencies] +django = ">=1.8" +six = ">=1.10.0" +standardjson = ">=0.3.1" + [[package]] name = "django-redis" version = "5.3.0" @@ -1581,6 +1597,17 @@ pure-eval = "*" [package.extras] tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] +[[package]] +name = "standardjson" +version = "0.3.1" +description = "JSON encoder that aims to be fully compliant with specifications ECMA-262 and ECMA-404." +optional = false +python-versions = "*" +files = [ + {file = "standardjson-0.3.1-py2.py3-none-any.whl", hash = "sha256:69e79b090d3f7dd887ae4a9db226ea79cd3fd3d7cfa8491d23ec6b06126e24b0"}, + {file = "standardjson-0.3.1.tar.gz", hash = "sha256:71b7b0a649d5e3bd343a02737e752c054c218242dcaa739abab98086e537fbab"}, +] + [[package]] name = "strawberry-django-plus" version = "2.6.4" @@ -1819,4 +1846,4 @@ test = ["pytest", "pytest-cov"] [metadata] lock-version = "2.0" python-versions = "^3.11.3" -content-hash = "88baec7c5267420f44eaddfe27f66f1a23545d8a03c70c8e972c1a1346a05e64" +content-hash = "211c958a76058b2a6ae3d56e3465a0cb11c4a58b5ffdaa00650e0eb652412fd2" diff --git a/pyproject.toml b/pyproject.toml index 818f809..69d7bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ pandas = "2.0.3" pyxform = "^1.12.1" openpyxl = "*" celery-types = "*" +django-prettyjson = "*" [tool.poetry.dev-dependencies] pytest = "*" diff --git a/schema.graphql b/schema.graphql index 14ae24b..3b88d00 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,4 +1,5 @@ type AppEnumCollection { + GlobalPermissionType: [AppEnumCollectionGlobalPermissionType!]! UserEmailOptOuts: [AppEnumCollectionUserEmailOptOuts!]! ProjectMembershipRole: [AppEnumCollectionProjectMembershipRole!]! QBQuestionType: [AppEnumCollectionQBQuestionType!]! @@ -25,6 +26,11 @@ type AppEnumCollection { QuestionnaireExportStatus: [AppEnumCollectionQuestionnaireExportStatus!]! } +type AppEnumCollectionGlobalPermissionType { + key: GlobalPermissionTypeEnum! + label: String! +} + type AppEnumCollectionProjectMembershipRole { key: ProjectMembershipRoleTypeEnum! label: String! @@ -145,6 +151,12 @@ type AppEnumCollectionUserEmailOptOuts { label: String! } +input CreateQuestionBankInput { + title: String! + importFile: Upload! + description: String +} + """A generic type to return error messages""" scalar CustomErrorType @@ -180,6 +192,11 @@ type FileFieldType { url: String! } +enum GlobalPermissionTypeEnum { + UPLOAD_QBANK + ACTIVATE_QBANK +} + input IDFilterLookup { exact: ID iExact: ID @@ -249,6 +266,8 @@ input PasswordResetTriggerInput { type PrivateMutation { changeUserPassword(data: PasswordChangeInput!): MutationEmptyResponseType! updateMe(data: UserMeInput!): UserMeTypeMutationResponseType! + createQuestionBank(data: CreateQuestionBankInput!): QuestionBankTypeMutationResponseType! + activateQuestionBank(id: ID!): QuestionBankTypeMutationResponseType! createProject(data: ProjectCreateInput!): ProjectTypeMutationResponseType! projectScope(pk: ID!): ProjectScopeMutation id: ID! @@ -579,6 +598,7 @@ type QuestionBankType { id: ID! title: String! isActive: Boolean! + errors: [String!]! choiceCollections: [QBChoiceCollectionType!]! createdBy: UserType! leafGroups: [QBLeafGroupType!]! @@ -593,6 +613,12 @@ type QuestionBankTypeCountList { items: [QuestionBankType!]! } +type QuestionBankTypeMutationResponseType { + ok: Boolean! + errors: CustomErrorType + result: QuestionBankType +} + input QuestionChoiceCollectionCreateInput { questionnaire: ID! name: String! @@ -1130,6 +1156,8 @@ input StrFilterLookup { iRegex: String } +scalar Upload + input UserFilter { id: IDFilterLookup search: String @@ -1151,6 +1179,7 @@ type UserMeType { email: String! emailOptOuts: [OptEmailNotificationTypeEnum!]! emailOptOutsDisplay: [String!]! + globalPermissions: [GlobalPermissionTypeEnum!]! } type UserMeTypeMutationResponseType { diff --git a/utils/strawberry/mutations.py b/utils/strawberry/mutations.py index f18a063..d609df8 100644 --- a/utils/strawberry/mutations.py +++ b/utils/strawberry/mutations.py @@ -391,3 +391,7 @@ async def handle_bulk_mutation( results=results, deleted=deleted_instances, ) + + +def generate_error_message(message: str = _CustomErrorType.DEFAULT_ERROR_MESSAGE) -> CustomErrorType: + return _CustomErrorType.generate_message(message) diff --git a/utils/strawberry/transformers.py b/utils/strawberry/transformers.py index f5894de..9fd028e 100644 --- a/utils/strawberry/transformers.py +++ b/utils/strawberry/transformers.py @@ -9,6 +9,7 @@ from functools import singledispatch from rest_framework import serializers, fields as drf_fields from strawberry.field import StrawberryField +from strawberry.file_uploads import Upload as StrawberryUploadField from strawberry.annotation import StrawberryAnnotation from django.core.exceptions import ImproperlyConfigured @@ -107,6 +108,11 @@ def convert_serializer_field_to_time(_): return datetime.time +@get_strawberry_type_from_serializer_field.register(serializers.FileField) +def convert_serializer_field_to_file_field(_): + return StrawberryUploadField + + @get_strawberry_type_from_serializer_field.register(serializers.ChoiceField) def convert_serializer_field_to_enum(field): # Try normal TextChoices/IntegerChoices enum