Меня зовут Егор, я Full-stack разработчик в Leader-ID. В этой статье я хочу поделиться простым рецептом по созданию красивого и удобного веб-опросника наподобие тех, что делает Meduza. Он умеет показывать статистику после ответа на отдельные вопросы, подсчитывать общий балл, выдавать комментарии, выгружать данные для анализа и шарить результаты в соцсети. Для реализации этой задачи я выбрал Django, DRF, Python и базу данных PostgreSQL.

Все детали — под катом.
Спустя час разглядывания кирпичной кладки(залипательное занятие, однако) появился первый результат в виде готовых моделей, которые спустя десять минут были описаны в Джанге.
Итак, в проекте я использовал:
pip install Django — устанавливаем библиотеку.
django-admin startproject core — создаем проект на джанге.
cd core — переходим в директорию с проектом.
python manage.py startapp polls — добавляем приложение опроса.
Далее описываем модели в models.py в polls и создаем сериалайзер для DRF.

Затем пишем две вьюшки DRF в views.py, которые отдают все вопросы с вариантами и принимают все ответы от пользователя.

Теперь описываем ссылки в urls.py:
Добавляем модели в admin.py:

Следующим шагом добавляем в settings.py (в директории core) в INSTALLED_APPS наше приложение polls. И выполняем команды запуска:
Чтобы рассчитать общий балл по вопросам, добавляем к методу простенькую функцию обсчета, по которой рассчитывается начисление баллов. Код функции я не выложу, потому что с ее помощью можно взломать наш опросник. Теперь у каждого ответа есть свой «вес».
Заходим в админку через браузер по ссылке, которая указана в консоли (http://127.0.0.1:8000/admin по умолчанию), и создаем вопросы и ответы к ним, проставляем баллы.

Мне было важно отдавать нашим партнерам списки людей, прошедших опрос, и их ответы. Но для этого недостаточно просто связать ответы с вопросами. Поэтому я добавил еще одну таблицу — «Варианты». Так образовалась связь между ответами юзера на вопросы с несколькими вариантами ответов. Это позволяет нам выгружать данные в том виде, в котором партнеры могут их легко интерпретировать.
В итоге структура БД получилась вот такой:

Теперь подключаем фронт.
В нем забираем список вопросов и ответов, проходимся по каждому элементу до последнего. В зависимости от типа вопроса меняем компонент со своей логикой и стилем. Соответственно, когда вопросов в списке не осталось, отправляем результат на бэк и получаем ответ с количеством баллов. После получения количества баллов открываем страницу результата, в случае наличия баллов вопросы более не показываем.
На данном этапе у нас уже готов опросник, который умеет все, что должен: задавать вопросы, получать и собирать варианты ответов, выдавать результат в виде баллов и комментариев.
Во-первых, мне было необходимо периодически выгружать данные. Для этого я просто добавил management command.
Во-вторых, хорошо бы еще реализовать шаринг результатов опроса в социальные сети. ОК. Пилим функционал, который позволит поделиться картинкой с баллами ВКонтакте и Facebook.
Генерим сто вариантов картинок, отражающих баллы, для ВК и Facebook отдельно (разные разрешения). Теперь подключаем передачу ссылки на картинку в социальном компоненте фронтенд части. С ВКонтаке все оказалось просто: передаем параметр image с прямым URL-адресом нужной. А вот с Facebook пришлось повозиться. Оказалось, что они не принимают медиа по API, и если я передавал image или picture с URL картинки, то в посте показывалось большое пустое поле. Как потом оказалось, берет он картинку из метаинфы (og:image) самого сайта, которым поделились (передаем в ссылке параметр u). А ее, ко всему прочему, нужно было динамично менять. Мне не хотелось делать лишних редиректов и механик на бэке, и я решил переделать SPA (single page app) на SSR (server-side render) на фронте, чтобы в зависимости от запроса менялся url картинки с баллом в head-meta до запуска JavaScript в браузере. Благо, взятый за основу фреймворк Nuxt.js позволяет сделать это простым переключением режима. Теперь осталось набросать client-only теги и добавить логику смены head от наличия query балла.


Дополнительно на сервере понадобилось запустить daemon сервис, чтобы отдавать сформированные страницы, а статику оставить так же nginxу. Все, профит!

Для того, чтобы поддерживать уровень интереса участников в процессе заполнения опроса, я добавил динамический показ статистики к каждому отдельному вопросу. Ответив на вопрос, пользователь видит, как ответили другие. Иногда человеку бывает непонятно, зачем ему задают эти вопросы. Поэтому я дополнил каждый вопрос забавными пояснениями. Ну и самый главный трюк по оживлению моего опросника провернули дизайнеры нашей компании.

Такие медийные опросы достаточно просты в реализации и, главное, они очень нравятся пользователям. Их можно использовать и в хвост и в гриву: для социологических исследований, информирования/проверки знаний или создания интерактивных элементов на сайтах и сервисах. Я постарался подробно описать процесс их создания, но если остались вопросы, welcome в комментарии. Примеры реализации опросов на этом движке можно посмотреть по этим двум ссылкам: healthcare.leader-id.ru и covid.leader-id.ru.

Все детали — под катом.
Спустя час разглядывания кирпичной кладки
Если ты начинающий, то советую пройти Djnago tutorial, там как раз описывается пошаговое создание опроса. И вдогонку DRF tutorial, чтобы окончательно погрузиться в тему.
Итак, в проекте я использовал:
- Django 3.0.3. Для бэкенда;
- django-rest-framework. Для создания rest-api;
- Python;
- PostgreSQL в качестве БД;
- Front-end — Nuxt.js, Axios, Element-UI.
Теперь по шагам
pip install Django — устанавливаем библиотеку.
django-admin startproject core — создаем проект на джанге.
cd core — переходим в директорию с проектом.
python manage.py startapp polls — добавляем приложение опроса.
Далее описываем модели в models.py в polls и создаем сериалайзер для DRF.
class Question(models.Model): title = models.CharField(max_length=4096) visible = models.BooleanField(default=False) max_points = models.FloatField() def __str__(self): return self.title class Choice(models.Model): question = models.ForeignKey(Question, on_delete=models.DO_NOTHING) title = models.CharField(max_length=4096) points = models.FloatField() lock_other = models.BooleanField(default=False) def __str__(self): return self.title class Answer(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING) question = models.ForeignKey(Question, on_delete=models.DO_NOTHING) choice = models.ForeignKey(Choice, on_delete=models.DO_NOTHING) created = models.DateTimeField(auto_now_add=True) def __str__(self): return self.choice.title

Код текстом тут
from rest_framework import serializers from .models import Answer, Question, Choice class ChoiceSerializer(serializers.ModelSerializer): percent = serializers.SerializerMethodField() class Meta: model = Choice fields = ['pk', 'title', 'points', 'percent', 'lock_other', ] def get_percent(self, obj): total = Answer.objects.filter(question=obj.question).count() current = Answer.objects.filter(question=obj.question, choice=obj).count() if total != 0: return float(current * 100 / total) else: return float(0) class QuestionSerializer(serializers.ModelSerializer): choices = ChoiceSerializer(many=True, source='choice_set', ) class Meta: model = Question fields = ['pk', 'title', 'choices', 'max_points', ] class AnswerSerializer(serializers.Serializer): answers = serializers.JSONField() def validate_answers(self, answers): if not answers: raise serializers.Validationerror("Answers must be not null.") return answers def save(self): answers = self.data['answers'] user = self.context.user for question_id, in answers: # тут наверное лишняя запятая , ошибка в оригинальном коде question = Question.objects.get(pk=question_id) choices = answers[question_id] for choice_id in choices: choice = Choice.objects.get(pk=choice_id) Answer(user=user, question=question, choice=choice).save() user.is_answer = True user.save()
Затем пишем две вьюшки DRF в views.py, которые отдают все вопросы с вариантами и принимают все ответы от пользователя.

Код текстом тут
from .serializers import QuestionSerializer, AnswerSerializer from rest_framework.permissions import IsAuthenticated from rest_framework.generics import GenericAPIView from rest_framework.response import Response from .models import Question class GetQuestion(GenericAPIView): permission_classes = (IsAuthenticated,) serializer_class = QuestionSerializer def get(self, request, format=None): questions = Question.objects.filter(visible=True, ) last_point = QuestionSerializer(questions, many=True) return Response(last_point.data) class QuestionAnswer(GenericAPIView): permission_classes = (IsAuthenticated,) serializer_class = AnswerSerializer def post(self, request, format=None): answer = AnswerSerializer(data=request.data, context=request) if answer.is_valid(raise_exception=True): answer.save() return Response({'result': 'OK'})
Теперь описываем ссылки в urls.py:
urlpatterns = [ path('', GetQuestion.as_view()), path('answer/', QuestionAnswer.as_view()), ]
Добавляем модели в admin.py:

Код текстом тут
from django.contrib import admin from .models import Question, Answer, Choice class QuestionAdmin(admin.ModelAdmin): list_display = ( 'title', 'visible', 'max_points', ) class ChoiceAdmin(admin.ModelAdmin): list_display = ( 'title', 'question', 'points', 'lock_other', ) list_filter = ('question',) class AnswerAdmin(admin.ModelAdmin): list_display = ( 'user', 'question', 'choice', ) list_filter = ('user',) admin.site.register(Question, QuestionAdmin) admin.site.register(Choice, ChoiceAdmin) admin.site.register(Answer, AnswerAdmin)
Следующим шагом добавляем в settings.py (в директории core) в INSTALLED_APPS наше приложение polls. И выполняем команды запуска:
- python manage.py makemigrations — создаем миграцию для созданных моделей
- python manage.py migrate — выполняем миграцию в БД
- python manage.py createsuperuser — создаем суперюзера (админа)
- python manage.py runserver — запускаем сервер
Чтобы рассчитать общий балл по вопросам, добавляем к методу простенькую функцию обсчета, по которой рассчитывается начисление баллов. Код функции я не выложу, потому что с ее помощью можно взломать наш опросник. Теперь у каждого ответа есть свой «вес».
Заходим в админку через браузер по ссылке, которая указана в консоли (http://127.0.0.1:8000/admin по умолчанию), и создаем вопросы и ответы к ним, проставляем баллы.

Мне было важно отдавать нашим партнерам списки людей, прошедших опрос, и их ответы. Но для этого недостаточно просто связать ответы с вопросами. Поэтому я добавил еще одну таблицу — «Варианты». Так образовалась связь между ответами юзера на вопросы с несколькими вариантами ответов. Это позволяет нам выгружать данные в том виде, в котором партнеры могут их легко интерпретировать.
В итоге структура БД получилась вот такой:

Теперь подключаем фронт.
В нем забираем список вопросов и ответов, проходимся по каждому элементу до последнего. В зависимости от типа вопроса меняем компонент со своей логикой и стилем. Соответственно, когда вопросов в списке не осталось, отправляем результат на бэк и получаем ответ с количеством баллов. После получения количества баллов открываем страницу результата, в случае наличия баллов вопросы более не показываем.
На данном этапе у нас уже готов опросник, который умеет все, что должен: задавать вопросы, получать и собирать варианты ответов, выдавать результат в виде баллов и комментариев.
Добавляем плюшки
Во-первых, мне было необходимо периодически выгружать данные. Для этого я просто добавил management command.
Во-вторых, хорошо бы еще реализовать шаринг результатов опроса в социальные сети. ОК. Пилим функционал, который позволит поделиться картинкой с баллами ВКонтакте и Facebook.
Генерим сто вариантов картинок, отражающих баллы, для ВК и Facebook отдельно (разные разрешения). Теперь подключаем передачу ссылки на картинку в социальном компоненте фронтенд части. С ВКонтаке все оказалось просто: передаем параметр image с прямым URL-адресом нужной. А вот с Facebook пришлось повозиться. Оказалось, что они не принимают медиа по API, и если я передавал image или picture с URL картинки, то в посте показывалось большое пустое поле. Как потом оказалось, берет он картинку из метаинфы (og:image) самого сайта, которым поделились (передаем в ссылке параметр u). А ее, ко всему прочему, нужно было динамично менять. Мне не хотелось делать лишних редиректов и механик на бэке, и я решил переделать SPA (single page app) на SSR (server-side render) на фронте, чтобы в зависимости от запроса менялся url картинки с баллом в head-meta до запуска JavaScript в браузере. Благо, взятый за основу фреймворк Nuxt.js позволяет сделать это простым переключением режима. Теперь осталось набросать client-only теги и добавить логику смены head от наличия query балла.


Дополнительно на сервере понадобилось запустить daemon сервис, чтобы отдавать сформированные страницы, а статику оставить так же nginxу. Все, профит!

Оживляем опросник
Для того, чтобы поддерживать уровень интереса участников в процессе заполнения опроса, я добавил динамический показ статистики к каждому отдельному вопросу. Ответив на вопрос, пользователь видит, как ответили другие. Иногда человеку бывает непонятно, зачем ему задают эти вопросы. Поэтому я дополнил каждый вопрос забавными пояснениями. Ну и самый главный трюк по оживлению моего опросника провернули дизайнеры нашей компании.

Итоги
Такие медийные опросы достаточно просты в реализации и, главное, они очень нравятся пользователям. Их можно использовать и в хвост и в гриву: для социологических исследований, информирования/проверки знаний или создания интерактивных элементов на сайтах и сервисах. Я постарался подробно описать процесс их создания, но если остались вопросы, welcome в комментарии. Примеры реализации опросов на этом движке можно посмотреть по этим двум ссылкам: healthcare.leader-id.ru и covid.leader-id.ru.
