Пилим веб-опросник как у 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.

ссылка на оригинал статьи https://habr.com/ru/company/leader-id/blog/501570/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *