added forms, utils

This commit is contained in:
Alexander Karpov 2023-03-10 13:50:02 +03:00
parent d1b10a0067
commit 5a3b097532
27 changed files with 461 additions and 32 deletions

View File

@ -46,21 +46,32 @@ const select = document.getElementById('inputGroupSelect')
select.innerHTML += `<option value="${type}">${description}</option>` select.innerHTML += `<option value="${type}">${description}</option>`
questionPrototype = ` questionPrototype = `
<div class="border p-4 mt-5 rounded-2"> <div class="row ms-2">
<input type="hidden" value="${type}" name="type" /> <div class="col col-11">
{% for field in question_form %} <input type="hidden" value="${type}" name="type" />
{{ field|as_crispy_field }} {% for field in question_form %}
{% endfor %} {{ field|as_crispy_field }}
{% endfor %}
<div class="form-check form-switch">
<input name="required" class="form-check-input" type="checkbox" role="switch" id="flexSwitchCheckDefault">
<label class="form-check-label" for="flexSwitchCheckDefault">Required</label>
</div>
</div>
<div class="col col-1">
<button onclick="this.parentNode.parentNode.parentNode.remove()" type="button" class="m-2 btn border-0"><i class="bi bi-x-lg"></i></button>
</div>
</div> </div>
` `
questions.push(questionPrototype) questions.push(questionPrototype)
{% endfor %} {% endfor %}
button.onclick = function () { button.onclick = function () {
console.log(q_index)
let index = select.selectedIndex; let index = select.selectedIndex;
let div = questions[index]; let div = document.createElement("div");
form.innerHTML += 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; q_index += 1;
} }
</script> </script>

View File

@ -1,10 +1,11 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body> {% block content %}
</html> <form method="post">
{% csrf_token %}
{% autoescape off %}
{{ form }}
{% endautoescape %}
<button class="btn btn-success ms-4">Отправить</button>
</form>
{% endblock %}

View File

@ -27,7 +27,7 @@ class Meta:
class BaseQuestionForm(forms.ModelForm): class BaseQuestionForm(forms.ModelForm):
class Meta: class Meta:
model = BaseQuestion model = BaseQuestion
fields = ["question", "help", "required"] fields = ["question", "help"]
class TextQuestionForm(BaseQuestionForm): class TextQuestionForm(BaseQuestionForm):
@ -67,6 +67,11 @@ class Meta(BaseQuestionForm.Meta):
class SelectQuestionForm(BaseQuestionForm): 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): def __init__(self, *args, **kwargs):
super(BaseQuestionForm, self).__init__(*args, **kwargs) super(BaseQuestionForm, self).__init__(*args, **kwargs)
@ -75,6 +80,7 @@ class Meta(BaseQuestionForm.Meta):
fields = BaseQuestionForm.Meta.fields + [ fields = BaseQuestionForm.Meta.fields + [
"min_required_answers", "min_required_answers",
"max_required_answers", "max_required_answers",
"answer",
] ]

View File

@ -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")},
),
]

View File

@ -1,6 +1,7 @@
import uuid import uuid
from django.db import models from django.db import models
from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -41,7 +42,7 @@ def available(self) -> bool:
def get_absolute_url(self): def get_absolute_url(self):
# TODO change to admin # TODO change to admin
return reverse("test_platform:create") return reverse("test_platform:view", kwargs={"slug": self.slug})
def __str__(self): def __str__(self):
return f"form: {self.name}" return f"form: {self.name}"
@ -53,6 +54,7 @@ class BaseQuestion(PolymorphicModel, SubclassesMixin):
form: Form = models.ForeignKey( form: Form = models.ForeignKey(
"test_platform.Form", related_name="fields", on_delete=models.CASCADE "test_platform.Form", related_name="fields", on_delete=models.CASCADE
) )
order = models.IntegerField(default=0)
question = models.CharField(max_length=250, blank=False) question = models.CharField(max_length=250, blank=False)
help = models.CharField(max_length=200, blank=True) help = models.CharField(max_length=200, blank=True)
required = models.BooleanField(default=True) required = models.BooleanField(default=True)
@ -60,6 +62,14 @@ class BaseQuestion(PolymorphicModel, SubclassesMixin):
def __str__(self): def __str__(self):
return f"{self.type} - {self.question}" 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): class TextQuestion(BaseQuestion):
type = "text" type = "text"

View File

@ -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]: for question in BaseQuestion.get_subclasses()[::-1]:
if question.type == type: if question.type == type:
form = question_forms[question] form = question_forms[question]
return form.Meta.fields return question, form.Meta.fields + ["required"]
raise ValueError raise ValueError
@ -39,17 +39,19 @@ def get_question_types() -> dict[BaseQuestion, BaseQuestionForm]:
def parse_form_create(values) -> list[dict[str, str]]: def parse_form_create(values) -> list[dict[str, str]]:
offset: dict[str, int] = {} offset: dict[str, int] = {}
res: list[dict[str, str]] = [] res: list[dict[str, str | bool]] = []
question_amount = len(values.getlist("type")) question_amount = len(values.getlist("type"))
for i in range(question_amount): for i in range(question_amount):
type = values.getlist("type")[i] type = values.getlist("type")[i]
res.append({"type": type}) question, fields = _get_fields_and_class_from_type(type)
fields = _get_fields_from_type(type) res.append({"type": question})
for field in fields: for field in fields:
if field in offset: if field in offset:
offset[field] += 1 offset[field] += 1
else: else:
offset[field] = 0 offset[field] = 0
value = values.getlist(field)[offset[field]] value = values.getlist(field)[offset[field]]
if field == "required":
value = True if value != "off" else False
res[i][field] = value res[i][field] = value
return res return res

View File

@ -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

View File

@ -0,0 +1,6 @@
<div order="{{ order }}" class="border-3 p-4">
<h1>{{ question.question }}</h1>
<p>{{ question.help }}</p>
{% block content %}
{% endblock %}
</div>

View File

@ -0,0 +1,7 @@
{% extends "fields/base.html" %}
{% block content %}
<p>
<input name="{{ order }}/{{ question.type }}" class="form-control" placeholder="Введите число" type="number" required="{{ question.required }}">
</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "fields/base.html" %}
{% block content %}
<p>
<input name="{{ order }}/{{ question.type }}" class="form-control" placeholder="Введите число" type="number" required="{{ question.required }}">
</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "fields/base.html" %}
{% block content %}
<p>
<input name="{{ order }}/{{ question.type }}" class="form-control" placeholder="Введите ответ" type="text" required="{{ question.required }}">
</p>
{% endblock %}

View File

@ -1,6 +1,9 @@
from django.urls import path 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" app_name = "test_platform"
urlpatterns = [path("create", from_create_view, name="create")] urlpatterns = [
path("create", form_create_view, name="create"),
path("<str:slug>", form_view, name="view"),
]

View File

@ -1,10 +1,11 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin 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.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.forms import get_question_types, parse_form_create
from akarpov.test_platform.services.generators import generate_form_question
class FromCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): class FromCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
@ -20,8 +21,27 @@ def get_context_data(self, **kwargs):
def form_valid(self, form): def form_valid(self, form):
form.instance.creator = self.request.user form.instance.creator = self.request.user
print(parse_form_create(self.request.POST)) s = super().form_valid(form)
return 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()

24
akarpov/utils/channels.py Normal file
View File

@ -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}})

9
akarpov/utils/choices.py Normal file
View File

@ -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])

26
akarpov/utils/commands.py Normal file
View File

@ -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,
)

29
akarpov/utils/config.py Normal file
View File

@ -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

16
akarpov/utils/factory.py Normal file
View File

@ -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)

22
akarpov/utils/faker.py Normal file
View File

@ -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)

11
akarpov/utils/forms.py Normal file
View File

@ -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

8
akarpov/utils/log.py Normal file
View File

@ -0,0 +1,8 @@
import logging
from django.conf import settings
class RequireDbDebugTrue(logging.Filter):
def filter(self, record) -> bool:
return settings.DB_DEBUG

8
akarpov/utils/query.py Normal file
View File

@ -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]

78
akarpov/utils/storage.py Normal file
View File

@ -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

View File

@ -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"],
)

View File

@ -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)

49
poetry.lock generated
View File

@ -445,6 +445,26 @@ files = [
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, {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]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.0.1" version = "3.0.1"
@ -1348,6 +1368,21 @@ files = [
[package.dependencies] [package.dependencies]
Django = ">=3.2" 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]] [[package]]
name = "djangocms-admin-style" name = "djangocms-admin-style"
version = "3.2.3" version = "3.2.3"
@ -2921,6 +2956,18 @@ files = [
[package.dependencies] [package.dependencies]
six = ">=1.5" 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]] [[package]]
name = "python-slugify" name = "python-slugify"
version = "7.0.0" version = "7.0.0"
@ -3793,4 +3840,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "47d4a706ed11e7da8599131523b32f44c6f9703559efd8b4f00245abd40a4a68" content-hash = "eb14567f6a4fa6a89dce0c68e386ca5d09e26a1e823a3d11eeaf387aae0eba9f"

View File

@ -69,6 +69,8 @@ amzqr = "^0.0.1"
django-chunked-upload = "^2.0.0" django-chunked-upload = "^2.0.0"
drf-chunked-upload = "^0.5.1" drf-chunked-upload = "^0.5.1"
django-active-link = "^0.1.8" django-active-link = "^0.1.8"
channels = "^4.0.0"
django-upload-validator = "^1.1.6"
[build-system] [build-system]