Пилим веб-опросник как у Meduza: пошаговый гайд для начинающих
Меня зовут Егор, я Full-stack разработчик в Leader-ID. В этой статье я хочу поделиться простым рецептом по созданию красивого и удобного веб-опросника наподобие тех, что делает Meduza. Он умеет показывать статистику после ответа на отдельные вопросы, подсчитывать общий балл, выдавать комментарии, выгружать данные для анализа и шарить результаты в соцсети. Для реализации этой задачи я выбрал Django, DRF, Python и базу данных PostgreSQL.
Все детали — под катом.
Спустя час разглядывания кирпичной кладки (залипательное занятие, однако) появился первый результат в виде готовых моделей, которые спустя десять минут были описаны в Джанге.
Если ты начинающий, то советую пройти 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 pools — добавляем приложение опроса.
Далее описываем модели в 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.