From 5a3b097532a13a3a623cc7bac3d8941d283dddfe Mon Sep 17 00:00:00 2001 From: Alexandr Karpov Date: Fri, 10 Mar 2023 13:50:02 +0300 Subject: [PATCH] added forms, utils --- akarpov/templates/test_platform/create.html | 27 +++++-- akarpov/templates/test_platform/view.html | 19 ++--- akarpov/test_platform/forms.py | 8 +- ...ion_options_basequestion_order_and_more.py | 26 +++++++ akarpov/test_platform/models.py | 12 ++- akarpov/test_platform/services/forms.py | 12 +-- akarpov/test_platform/services/generators.py | 8 ++ .../test_platform/templates/fields/base.html | 6 ++ .../templates/fields/number.html | 7 ++ .../test_platform/templates/fields/range.html | 7 ++ .../test_platform/templates/fields/text.html | 7 ++ akarpov/test_platform/urls.py | 7 +- akarpov/test_platform/views.py | 30 +++++-- akarpov/utils/channels.py | 24 ++++++ akarpov/utils/choices.py | 9 +++ akarpov/utils/commands.py | 26 +++++++ akarpov/utils/config.py | 29 +++++++ akarpov/utils/factory.py | 16 ++++ akarpov/utils/faker.py | 22 ++++++ akarpov/utils/forms.py | 11 +++ akarpov/utils/log.py | 8 ++ akarpov/utils/query.py | 8 ++ akarpov/utils/storage.py | 78 +++++++++++++++++++ akarpov/utils/validators.py | 20 +++++ akarpov/utils/zip_field.py | 15 ++++ poetry.lock | 49 +++++++++++- pyproject.toml | 2 + 27 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 akarpov/test_platform/migrations/0005_alter_basequestion_options_basequestion_order_and_more.py create mode 100644 akarpov/test_platform/services/generators.py create mode 100644 akarpov/test_platform/templates/fields/base.html create mode 100644 akarpov/test_platform/templates/fields/number.html create mode 100644 akarpov/test_platform/templates/fields/range.html create mode 100644 akarpov/test_platform/templates/fields/text.html create mode 100644 akarpov/utils/channels.py create mode 100644 akarpov/utils/choices.py create mode 100644 akarpov/utils/commands.py create mode 100644 akarpov/utils/config.py create mode 100644 akarpov/utils/factory.py create mode 100644 akarpov/utils/faker.py create mode 100644 akarpov/utils/forms.py create mode 100644 akarpov/utils/log.py create mode 100644 akarpov/utils/query.py create mode 100644 akarpov/utils/storage.py create mode 100644 akarpov/utils/validators.py create mode 100644 akarpov/utils/zip_field.py 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 %} +
+ {% csrf_token %} + {% autoescape off %} + {{ form }} + {% endautoescape %} + +
+{% 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]