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

    Комментарии 3

      0
      Почему бы не сделать просто:
      class GetQuestion(ListAPIView):
          permission_classes = (IsAuthenticated,)
          serializer_class = QuestionSerializer
      
      class QuestionAnswer(CreateAPIView):
          permission_classes = (IsAuthenticated,)
          serializer_class = AnswerSerializer
      

      Использование вот здесь float совсем не нужен, так как в Py3 результат выражения это уже float:
      return float(current * 100 / total)
      
        0
        в GetQuestion нужно было только с флагом Visible отдавать
        в QuestionAnswer костыль для фронта, там свои заморочки на исключения в axios
          0
          в GetQuestion нужно было только с флагом Visible отдавать

          Это стандартно в DRF:
          class GetQuestion(ListAPIView):
              permission_classes = (IsAuthenticated,)
              serializer_class = QuestionSerializer
              queryset = Question.objects.filter(visible=True).all()

          в QuestionAnswer костыль для фронта, там свои заморочки на исключения в axios

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

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое