diff --git a/akarpov/templates/test_platform/create.html b/akarpov/templates/test_platform/create.html
index 3819a34..e54efb6 100644
--- a/akarpov/templates/test_platform/create.html
+++ b/akarpov/templates/test_platform/create.html
@@ -46,21 +46,32 @@ const select = document.getElementById('inputGroupSelect')
select.innerHTML += ``
questionPrototype = `
-
-
- {% for field in question_form %}
- {{ field|as_crispy_field }}
- {% endfor %}
+
+
+
+ {% for field in question_form %}
+ {{ field|as_crispy_field }}
+ {% endfor %}
+
+
+
+
+
+
+
+
`
questions.push(questionPrototype)
{% endfor %}
button.onclick = function () {
- console.log(q_index)
let index = select.selectedIndex;
- let div = questions[index];
- form.innerHTML += div;
+ let div = document.createElement("div");
+ div.id = q_index;
+ div.className = "px-2 py-4 rounded-2 border mt-5";
+ div.innerHTML = questions[index]
+ form.appendChild(div);
q_index += 1;
}
diff --git a/akarpov/templates/test_platform/view.html b/akarpov/templates/test_platform/view.html
index 04e75bc..e583046 100644
--- a/akarpov/templates/test_platform/view.html
+++ b/akarpov/templates/test_platform/view.html
@@ -1,10 +1,11 @@
-
-
-
-
-
Title
-
-
+{% extends "base.html" %}
-
-
+{% block content %}
+
+{% endblock %}
diff --git a/akarpov/test_platform/forms.py b/akarpov/test_platform/forms.py
index e0f41de..85673f0 100644
--- a/akarpov/test_platform/forms.py
+++ b/akarpov/test_platform/forms.py
@@ -27,7 +27,7 @@ class Meta:
class BaseQuestionForm(forms.ModelForm):
class Meta:
model = BaseQuestion
- fields = ["question", "help", "required"]
+ fields = ["question", "help"]
class TextQuestionForm(BaseQuestionForm):
@@ -67,6 +67,11 @@ class Meta(BaseQuestionForm.Meta):
class SelectQuestionForm(BaseQuestionForm):
+ # {} to add index on fronted for submission
+ answer = forms.CharField(
+ widget=forms.TextInput(attrs={"name": "{}_answers"}), required=False
+ )
+
def __init__(self, *args, **kwargs):
super(BaseQuestionForm, self).__init__(*args, **kwargs)
@@ -75,6 +80,7 @@ class Meta(BaseQuestionForm.Meta):
fields = BaseQuestionForm.Meta.fields + [
"min_required_answers",
"max_required_answers",
+ "answer",
]
diff --git a/akarpov/test_platform/migrations/0005_alter_basequestion_options_basequestion_order_and_more.py b/akarpov/test_platform/migrations/0005_alter_basequestion_options_basequestion_order_and_more.py
new file mode 100644
index 0000000..2550dcf
--- /dev/null
+++ b/akarpov/test_platform/migrations/0005_alter_basequestion_options_basequestion_order_and_more.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.1.7 on 2023-02-27 06:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("test_platform", "0004_alter_numberquestion_correct_answer"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="basequestion",
+ options={},
+ ),
+ migrations.AddField(
+ model_name="basequestion",
+ name="order",
+ field=models.IntegerField(default=0),
+ ),
+ migrations.AlterUniqueTogether(
+ name="basequestion",
+ unique_together={("form", "order")},
+ ),
+ ]
diff --git a/akarpov/test_platform/models.py b/akarpov/test_platform/models.py
index 28b5304..e8ec18b 100644
--- a/akarpov/test_platform/models.py
+++ b/akarpov/test_platform/models.py
@@ -1,6 +1,7 @@
import uuid
from django.db import models
+from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -41,7 +42,7 @@ def available(self) -> bool:
def get_absolute_url(self):
# TODO change to admin
- return reverse("test_platform:create")
+ return reverse("test_platform:view", kwargs={"slug": self.slug})
def __str__(self):
return f"form: {self.name}"
@@ -53,6 +54,7 @@ class BaseQuestion(PolymorphicModel, SubclassesMixin):
form: Form = models.ForeignKey(
"test_platform.Form", related_name="fields", on_delete=models.CASCADE
)
+ order = models.IntegerField(default=0)
question = models.CharField(max_length=250, blank=False)
help = models.CharField(max_length=200, blank=True)
required = models.BooleanField(default=True)
@@ -60,6 +62,14 @@ class BaseQuestion(PolymorphicModel, SubclassesMixin):
def __str__(self):
return f"{self.type} - {self.question}"
+ def generate_html(self, num) -> str:
+ return render_to_string(
+ f"fields/{self.type}.html", context={"question": self, "order": num}
+ )
+
+ class Meta:
+ unique_together = ["form", "order"]
+
class TextQuestion(BaseQuestion):
type = "text"
diff --git a/akarpov/test_platform/services/forms.py b/akarpov/test_platform/services/forms.py
index 295dbc0..ddc3c01 100644
--- a/akarpov/test_platform/services/forms.py
+++ b/akarpov/test_platform/services/forms.py
@@ -21,11 +21,11 @@
}
-def _get_fields_from_type(type: str):
+def _get_fields_and_class_from_type(type: str):
for question in BaseQuestion.get_subclasses()[::-1]:
if question.type == type:
form = question_forms[question]
- return form.Meta.fields
+ return question, form.Meta.fields + ["required"]
raise ValueError
@@ -39,17 +39,19 @@ def get_question_types() -> dict[BaseQuestion, BaseQuestionForm]:
def parse_form_create(values) -> list[dict[str, str]]:
offset: dict[str, int] = {}
- res: list[dict[str, str]] = []
+ res: list[dict[str, str | bool]] = []
question_amount = len(values.getlist("type"))
for i in range(question_amount):
type = values.getlist("type")[i]
- res.append({"type": type})
- fields = _get_fields_from_type(type)
+ question, fields = _get_fields_and_class_from_type(type)
+ res.append({"type": question})
for field in fields:
if field in offset:
offset[field] += 1
else:
offset[field] = 0
value = values.getlist(field)[offset[field]]
+ if field == "required":
+ value = True if value != "off" else False
res[i][field] = value
return res
diff --git a/akarpov/test_platform/services/generators.py b/akarpov/test_platform/services/generators.py
new file mode 100644
index 0000000..ff4f13a
--- /dev/null
+++ b/akarpov/test_platform/services/generators.py
@@ -0,0 +1,8 @@
+from akarpov.test_platform.models import Form
+
+
+def generate_form_question(form: Form) -> str:
+ res = ""
+ for i, question in enumerate(form.fields.all()):
+ res += question.generate_html(i)
+ return res
diff --git a/akarpov/test_platform/templates/fields/base.html b/akarpov/test_platform/templates/fields/base.html
new file mode 100644
index 0000000..e60766a
--- /dev/null
+++ b/akarpov/test_platform/templates/fields/base.html
@@ -0,0 +1,6 @@
+
+
{{ question.question }}
+
{{ question.help }}
+ {% block content %}
+ {% endblock %}
+
diff --git a/akarpov/test_platform/templates/fields/number.html b/akarpov/test_platform/templates/fields/number.html
new file mode 100644
index 0000000..462acb5
--- /dev/null
+++ b/akarpov/test_platform/templates/fields/number.html
@@ -0,0 +1,7 @@
+{% extends "fields/base.html" %}
+
+{% block content %}
+
+
+
+{% endblock %}
diff --git a/akarpov/test_platform/templates/fields/range.html b/akarpov/test_platform/templates/fields/range.html
new file mode 100644
index 0000000..462acb5
--- /dev/null
+++ b/akarpov/test_platform/templates/fields/range.html
@@ -0,0 +1,7 @@
+{% extends "fields/base.html" %}
+
+{% block content %}
+
+
+
+{% endblock %}
diff --git a/akarpov/test_platform/templates/fields/text.html b/akarpov/test_platform/templates/fields/text.html
new file mode 100644
index 0000000..6fe3104
--- /dev/null
+++ b/akarpov/test_platform/templates/fields/text.html
@@ -0,0 +1,7 @@
+{% extends "fields/base.html" %}
+
+{% block content %}
+
+
+
+{% endblock %}
diff --git a/akarpov/test_platform/urls.py b/akarpov/test_platform/urls.py
index 59bba23..3d1b2e6 100644
--- a/akarpov/test_platform/urls.py
+++ b/akarpov/test_platform/urls.py
@@ -1,6 +1,9 @@
from django.urls import path
-from akarpov.test_platform.views import from_create_view
+from akarpov.test_platform.views import form_create_view, form_view
app_name = "test_platform"
-urlpatterns = [path("create", from_create_view, name="create")]
+urlpatterns = [
+ path("create", form_create_view, name="create"),
+ path("
", form_view, name="view"),
+]
diff --git a/akarpov/test_platform/views.py b/akarpov/test_platform/views.py
index 4c9079b..e04af8f 100644
--- a/akarpov/test_platform/views.py
+++ b/akarpov/test_platform/views.py
@@ -1,10 +1,11 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
-from django.views.generic import CreateView
+from django.views.generic import CreateView, DetailView
from akarpov.test_platform.forms import FormFormClass
-from akarpov.test_platform.models import Form
+from akarpov.test_platform.models import BaseQuestion, Form
from akarpov.test_platform.services.forms import get_question_types, parse_form_create
+from akarpov.test_platform.services.generators import generate_form_question
class FromCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
@@ -20,8 +21,27 @@ def get_context_data(self, **kwargs):
def form_valid(self, form):
form.instance.creator = self.request.user
- print(parse_form_create(self.request.POST))
- return super().form_valid(form)
+ s = super().form_valid(form)
+ fields = parse_form_create(self.request.POST)
+ for i, field in enumerate(fields):
+ question: BaseQuestion = field["type"]
+ field.pop("type")
+ question.objects.create(**field, order=i, form_id=form.instance.id)
+ return s
-from_create_view = FromCreateView.as_view()
+form_create_view = FromCreateView.as_view()
+
+
+class FormView(DetailView):
+ template_name = "test_platform/view.html"
+ model = Form
+ slug_field = "slug"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["form"] = generate_form_question(self.object)
+ return context
+
+
+form_view = FormView.as_view()
diff --git a/akarpov/utils/channels.py b/akarpov/utils/channels.py
new file mode 100644
index 0000000..f6df84b
--- /dev/null
+++ b/akarpov/utils/channels.py
@@ -0,0 +1,24 @@
+import functools
+
+from channels.generic.websocket import AsyncJsonWebsocketConsumer, JsonWebsocketConsumer
+
+
+def login_required(func):
+ @functools.wraps(func)
+ def wrapper(self, *args, **kwargs):
+ if not self.scope.get("user", False) or not self.scope["user"].is_authenticated:
+ self.send_error("Требуется авторизация")
+ else:
+ return func(self, *args, **kwargs)
+
+ return wrapper
+
+
+class BaseConsumer(AsyncJsonWebsocketConsumer):
+ async def send_error(self, msg):
+ await self.send_json({"type": "error", "data": {"msg": msg}})
+
+
+class SyncBaseConsumer(JsonWebsocketConsumer):
+ def send_error(self, msg):
+ self.send_json({"type": "error", "data": {"msg": msg}})
diff --git a/akarpov/utils/choices.py b/akarpov/utils/choices.py
new file mode 100644
index 0000000..5f986b3
--- /dev/null
+++ b/akarpov/utils/choices.py
@@ -0,0 +1,9 @@
+from collections.abc import Iterable
+
+from django.db.models.enums import ChoicesMeta
+
+
+def count_max_length(choices: Iterable | ChoicesMeta):
+ if isinstance(choices, ChoicesMeta):
+ return max([len(val) for val in choices.values])
+ return max([len(val) for val, _ in choices])
diff --git a/akarpov/utils/commands.py b/akarpov/utils/commands.py
new file mode 100644
index 0000000..f6b3966
--- /dev/null
+++ b/akarpov/utils/commands.py
@@ -0,0 +1,26 @@
+from collections.abc import Iterable
+
+from django.db.models import QuerySet
+
+from .query import batch_qs
+
+
+def progress_tracker(
+ a_list: list, a_len: int = 0, step: int = 10, template: str = "Done %d/%d"
+) -> Iterable:
+ if not a_len:
+ a_len = len(a_list)
+ for n, item in enumerate(a_list):
+ if n % step == 0:
+ print(template % (n, a_len))
+ yield item
+
+
+def iterate_big_queryset(
+ qs: QuerySet, batch_size: int = 1000, step: int = 10
+) -> Iterable:
+ return progress_tracker(
+ a_list=batch_qs(qs, batch_size=batch_size),
+ a_len=qs.count(),
+ step=step,
+ )
diff --git a/akarpov/utils/config.py b/akarpov/utils/config.py
new file mode 100644
index 0000000..5751f94
--- /dev/null
+++ b/akarpov/utils/config.py
@@ -0,0 +1,29 @@
+def build_redis_uri(
+ host: str,
+ port: int,
+ username: str | None = None,
+ password: str | None = None,
+ db: str | None = None,
+):
+ return build_conn_uri("redis", host, port, username, password, db)
+
+
+def build_conn_uri(
+ proto: str,
+ host: str,
+ port: int,
+ username: str | None = None,
+ password: str | None = None,
+ db: str | None = None,
+):
+ creds_part = ""
+ if username or password:
+ if username:
+ creds_part = username
+ if password:
+ creds_part += f":{password}"
+ creds_part += "@"
+ uri = f"{proto}://{creds_part}{host}:{port}"
+ if db:
+ uri += f"/{db}"
+ return uri
diff --git a/akarpov/utils/factory.py b/akarpov/utils/factory.py
new file mode 100644
index 0000000..5dd0fc4
--- /dev/null
+++ b/akarpov/utils/factory.py
@@ -0,0 +1,16 @@
+import factory
+
+
+class M2MPostAddition(factory.PostGeneration):
+ def __init__(self, rel_name):
+ self.rel_name = rel_name
+ super().__init__(self.handler)
+
+ def handler(self, obj, create, extracted, **kwargs):
+ if not create:
+ return
+
+ if extracted:
+ rel_manager = getattr(obj, self.rel_name)
+ for item in extracted:
+ rel_manager.add(item)
diff --git a/akarpov/utils/faker.py b/akarpov/utils/faker.py
new file mode 100644
index 0000000..8802873
--- /dev/null
+++ b/akarpov/utils/faker.py
@@ -0,0 +1,22 @@
+from decimal import Decimal as D
+
+from faker.providers.python import Provider as PythonProvider
+
+
+class MoneyProvider(PythonProvider):
+ def money(self):
+ return self.pydecimal(right_digits=2, min_value=D(1), max_value=D(1000))
+
+
+additional_providers = [MoneyProvider]
+
+
+def configure_factory_faker(factory_faker):
+ factory_faker._DEFAULT_LOCALE = "ru_RU"
+ for provider in additional_providers:
+ factory_faker.add_provider(provider, locale="ru_RU")
+
+
+def configure_faker(faker):
+ for provider in additional_providers:
+ faker.add_provider(provider)
diff --git a/akarpov/utils/forms.py b/akarpov/utils/forms.py
new file mode 100644
index 0000000..a793b62
--- /dev/null
+++ b/akarpov/utils/forms.py
@@ -0,0 +1,11 @@
+def set_field_html_name(cls, new_name):
+ """
+ This creates wrapper around the normal widget rendering,
+ allowing for a custom field name (new_name).
+ """
+ old_render = cls.widget.render
+
+ def _widget_render_wrapper(name, value, attrs=None):
+ return old_render(new_name, value, attrs)
+
+ cls.widget.render = _widget_render_wrapper
diff --git a/akarpov/utils/log.py b/akarpov/utils/log.py
new file mode 100644
index 0000000..0caff53
--- /dev/null
+++ b/akarpov/utils/log.py
@@ -0,0 +1,8 @@
+import logging
+
+from django.conf import settings
+
+
+class RequireDbDebugTrue(logging.Filter):
+ def filter(self, record) -> bool:
+ return settings.DB_DEBUG
diff --git a/akarpov/utils/query.py b/akarpov/utils/query.py
new file mode 100644
index 0000000..81b783b
--- /dev/null
+++ b/akarpov/utils/query.py
@@ -0,0 +1,8 @@
+from django.db.models import QuerySet
+
+
+def batch_qs(qs: QuerySet, batch_size: int = 1000):
+ total = qs.count()
+ for start in range(0, total, batch_size):
+ end = min(start + batch_size, total)
+ yield from qs[start:end]
diff --git a/akarpov/utils/storage.py b/akarpov/utils/storage.py
new file mode 100644
index 0000000..9f0b9fa
--- /dev/null
+++ b/akarpov/utils/storage.py
@@ -0,0 +1,78 @@
+import gzip
+import mimetypes
+import shutil
+from contextlib import closing
+
+from django.utils.encoding import force_bytes
+from django_s3_storage.storage import (
+ _UNCOMPRESSED_SIZE_META_KEY,
+ S3Storage,
+ _wrap_errors,
+)
+
+
+class FixedS3Storage(S3Storage):
+ """
+ Стандартный S3Storage использует boto3 для загрузки файла, который закрывает переданный
+ content-файл. Однако imagekit должен повторно его использовать, поэтому нужно обернуть
+ переданный контент во временный файл.
+ """
+
+ @_wrap_errors
+ def _save(self, name, content):
+ put_params = self._object_put_params(name)
+ temp_files = []
+ # The Django file storage API always rewinds the file before saving,
+ # therefor so should we.
+ content.seek(0)
+ # Convert content to bytes.
+ temp_file = self.new_temporary_file()
+ temp_files.append(temp_file)
+ for chunk in content.chunks():
+ temp_file.write(force_bytes(chunk))
+ temp_file.seek(0)
+ content = temp_file
+ # Calculate the content type.
+ content_type, _ = mimetypes.guess_type(name, strict=False)
+ content_type = content_type or "application/octet-stream"
+ put_params["ContentType"] = content_type
+ # Calculate the content encoding.
+ if self.settings.AWS_S3_GZIP:
+ # Check if the content type is compressible.
+ content_type_family, content_type_subtype = content_type.lower().split("/")
+ content_type_subtype = content_type_subtype.split("+")[-1]
+ if content_type_family == "text" or content_type_subtype in (
+ "xml",
+ "json",
+ "html",
+ "javascript",
+ ):
+ # Compress the content.
+ temp_file = self.new_temporary_file()
+ temp_files.append(temp_file)
+ with closing(gzip.GzipFile(name, "wb", 9, temp_file)) as gzip_file:
+ shutil.copyfileobj(content, gzip_file)
+ # Only use the compressed version if the zipped version is actually smaller!
+ orig_size = content.tell()
+ if temp_file.tell() < orig_size:
+ temp_file.seek(0)
+ content = temp_file
+ put_params["ContentEncoding"] = "gzip"
+ put_params["Metadata"][_UNCOMPRESSED_SIZE_META_KEY] = "{:d}".format(
+ orig_size
+ )
+ else:
+ content.seek(0)
+ # Save the file.
+ self.s3_connection.upload_fileobj(
+ content,
+ put_params.pop("Bucket"),
+ put_params.pop("Key"),
+ ExtraArgs=put_params,
+ Config=self._transfer_config,
+ )
+ # Close all temp files.
+ for temp_file in temp_files:
+ temp_file.close()
+ # All done!
+ return name
diff --git a/akarpov/utils/validators.py b/akarpov/utils/validators.py
new file mode 100644
index 0000000..956a253
--- /dev/null
+++ b/akarpov/utils/validators.py
@@ -0,0 +1,20 @@
+from upload_validator import FileTypeValidator
+
+validate_excel = FileTypeValidator(
+ allowed_types=[
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/zip",
+ ],
+ allowed_extensions=[".xlsx"],
+)
+
+validate_zip = FileTypeValidator(
+ allowed_types=[
+ "application/zip",
+ "application/octet-stream",
+ "application/x-zip-compressed",
+ "multipart/x-zip",
+ ],
+ allowed_extensions=[".zip"],
+)
diff --git a/akarpov/utils/zip_field.py b/akarpov/utils/zip_field.py
new file mode 100644
index 0000000..5eff816
--- /dev/null
+++ b/akarpov/utils/zip_field.py
@@ -0,0 +1,15 @@
+import zipfile
+
+from django import forms
+
+from .validators import validate_zip
+
+
+class ZipfileField(forms.FileField):
+ file_validators = [validate_zip]
+
+ def to_python(self, value):
+ value = super().to_python(value)
+ for validator in self.file_validators:
+ validator(value)
+ return zipfile.ZipFile(value)
diff --git a/poetry.lock b/poetry.lock
index 3519c03..3370139 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -445,6 +445,26 @@ files = [
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
]
+[[package]]
+name = "channels"
+version = "4.0.0"
+description = "Brings async, event-driven capabilities to Django 3.2 and up."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "channels-4.0.0-py3-none-any.whl", hash = "sha256:2253334ac76f67cba68c2072273f7e0e67dbdac77eeb7e318f511d2f9a53c5e4"},
+ {file = "channels-4.0.0.tar.gz", hash = "sha256:0ce53507a7da7b148eaa454526e0e05f7da5e5d1c23440e4886cf146981d8420"},
+]
+
+[package.dependencies]
+asgiref = ">=3.5.0,<4"
+Django = ">=3.2"
+
+[package.extras]
+daphne = ["daphne (>=4.0.0)"]
+tests = ["async-timeout", "coverage (>=4.5,<5.0)", "pytest", "pytest-asyncio", "pytest-django"]
+
[[package]]
name = "charset-normalizer"
version = "3.0.1"
@@ -1348,6 +1368,21 @@ files = [
[package.dependencies]
Django = ">=3.2"
+[[package]]
+name = "django-upload-validator"
+version = "1.1.6"
+description = "A simple Django file type validator using python-magic"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+ {file = "django-upload-validator-1.1.6.tar.gz", hash = "sha256:dbf93740fb42f8c9310b936838bbf99f873339c2f860de1c7c0d7a27464f7c8a"},
+ {file = "django_upload_validator-1.1.6-py3-none-any.whl", hash = "sha256:b4c1a2d2138b319288a20069c5dbdbbdb1257a447ec2f0b4e6cf9e8d6fd3d1bc"},
+]
+
+[package.dependencies]
+python-magic = "*"
+
[[package]]
name = "djangocms-admin-style"
version = "3.2.3"
@@ -2921,6 +2956,18 @@ files = [
[package.dependencies]
six = ">=1.5"
+[[package]]
+name = "python-magic"
+version = "0.4.27"
+description = "File type identification using libmagic"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+ {file = "python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b"},
+ {file = "python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3"},
+]
+
[[package]]
name = "python-slugify"
version = "7.0.0"
@@ -3793,4 +3840,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
-content-hash = "47d4a706ed11e7da8599131523b32f44c6f9703559efd8b4f00245abd40a4a68"
+content-hash = "eb14567f6a4fa6a89dce0c68e386ca5d09e26a1e823a3d11eeaf387aae0eba9f"
diff --git a/pyproject.toml b/pyproject.toml
index 0565c2d..5d49d95 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -69,6 +69,8 @@ amzqr = "^0.0.1"
django-chunked-upload = "^2.0.0"
drf-chunked-upload = "^0.5.1"
django-active-link = "^0.1.8"
+channels = "^4.0.0"
+django-upload-validator = "^1.1.6"
[build-system]