Skip to content

Commit

Permalink
Qbank Import Mutation
Browse files Browse the repository at this point in the history
- Add test
- Setup GlobalPermission
  • Loading branch information
thenav56 committed Oct 10, 2023
1 parent 715ac49 commit 5cc8ed6
Show file tree
Hide file tree
Showing 32 changed files with 762 additions and 21 deletions.
38 changes: 38 additions & 0 deletions apps/common/admin.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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(),
),
)
15 changes: 15 additions & 0 deletions apps/common/enums.py
Original file line number Diff line number Diff line change
@@ -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),
)
}
8 changes: 8 additions & 0 deletions apps/common/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from factory.django import DjangoModelFactory

from .models import GlobalPermission


class GlobalPermissionFactory(DjangoModelFactory):
class Meta:
model = GlobalPermission
24 changes: 24 additions & 0 deletions apps/common/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
Empty file.
18 changes: 18 additions & 0 deletions apps/common/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.db import models

from apps.user.models import User


Expand All @@ -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)
9 changes: 7 additions & 2 deletions apps/qbank/admin.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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:
Expand Down
25 changes: 20 additions & 5 deletions apps/qbank/importer/xlsxform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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),
),
]
7 changes: 7 additions & 0 deletions apps/qbank/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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}'

Expand Down
76 changes: 76 additions & 0 deletions apps/qbank/mutations.py
Original file line number Diff line number Diff line change
@@ -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,
)
33 changes: 33 additions & 0 deletions apps/qbank/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 5cc8ed6

Please sign in to comment.