Представления
Представления - основная логика нашего сайта, именно здесь мы будем создавать наши эндпоинты.
Для этого перейдём в файл views.py
в наших приложениях.
Для большинства функций написан длинный декоратор @extend_schema
, он используется для конкретизации документации
Swagger. Об этом подробнее здесь.
from urllib.parse import urlencode, quote
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from allauth.socialaccount.providers.openid_connect.views import OpenIDConnectAdapter
from dj_rest_auth.registration.views import SocialLoginView
from django.conf import settings
from django.http import HttpResponseRedirect
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter
from rest_framework import status, serializers
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
BE_BASE_URL = settings.BE_BASE_URL
FE_BASE_URL = settings.FE_BASE_URL
class ItmoIdAuthUrl(APIView):
@extend_schema(
summary="Return ITMO.ID authentication URL",
responses=inline_serializer(
name="ItmoIdAuthUrl",
fields={"url": serializers.URLField()},
),
)
def get(self, request: Request):
query_params = {
"client_id": settings.ITMO_ID_CLIENT_ID,
"response_type": "code",
"redirect_uri": f"{BE_BASE_URL}/oidc/auth",
"scope": "openid name edu",
}
url = f"https://id.itmo.pro/auth/realms/itmo/protocol/openid-connect/auth?{urlencode(query_params)}"
return Response({"url": url}, status=status.HTTP_200_OK)
class ItmoIdAuth(APIView):
@extend_schema(
summary="Get 'code' from ITMO.ID and return it with redirect to FE app",
parameters=[OpenApiParameter(name="code", type=OpenApiTypes.STR)],
responses={
status.HTTP_400_BAD_REQUEST: inline_serializer(
name="ItmoIdAuthError",
fields={"detail": serializers.CharField()},
),
},
)
def get(self, request: Request):
auth_code = request.query_params.get("code")
if not auth_code:
return Response({"detail": "Missing 'code' parameter"}, status=status.HTTP_400_BAD_REQUEST)
redirect_url = f"{FE_BASE_URL}/login?code={quote(auth_code)}"
return HttpResponseRedirect(redirect_url)
class ItmoIdAdapter(OpenIDConnectAdapter):
def __init__(self, request: Request):
provider_id = "itmo_id"
super().__init__(request, provider_id)
class ItmoIdLogin(SocialLoginView):
"""Get 'code' and request ITMO.ID for access token and userinfo. Create new User and ITMO.ID Profile"""
adapter_class = ItmoIdAdapter
callback_url = f"{BE_BASE_URL}/oidc/auth"
client_class = OAuth2Client
В начале файла импортируем из настроек базовые URL для наших бэкенда и фронтенда, они нам понадобятся для редиректов.
В классе ItmoIdAuthUrl
создадим метод get
, который будет создавать и возвращать ссылку на авторизацию в ITMO.ID
на основе данных имеющегося клиента.
Класс ItmoIdAdapter
- наследник от OpenIDConnectAdapter
(из библиотеки django-allauth
), который переопределяет
только его метод инициализации, дополнительно указывая название используемого OIDC провайдера.
В классе ItmoIdAuth
создадим метод get
, который из query параметров получит code
и отправит его во фронтенд приложение.
Эндпоинт, связанный с этим классом, указывается, как redirect_uri
в настройках провайдера OIDC, и после успешной авторизации
в нём, он редиректнет пользователя на него вместе с необходимыми параметрами.
Класс ItmoIdLogin
- наследник от SocialLoginView
(из библиотеки dj-rest-auth
). Именно он отвечает за основной процесс
авторизации в стороннем провайдере: обмен кода авторизации на JWT токены провайдера, получение из токена информации
о пользователе, создание нового пользователя в БД, создание "социального аккаунта" пользователя (со всей полученной
информацией), выдача собственных JWT токенов.
Также, для удобной работы создадим Django-сигнал, который при создании социального аккаунта так же создаст запись в нашей таблице профилей ITMO.ID, и привяжет эту запись к нужному пользователю:
import logging
from allauth.socialaccount.models import SocialAccount
from django.db import DatabaseError
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.oidc.models import ItmoIdProfile
logger = logging.getLogger("root")
@receiver(post_save, sender=SocialAccount)
def itmo_id_profile_create(sender, instance: SocialAccount, created: bool, **kwargs):
if not created or instance.provider != "itmo_id":
return
data = instance.extra_data
group = data.get("groups", [{}])[-1]
try:
ItmoIdProfile.objects.create(
user_id=instance.user_id,
isu=data.get("isu"),
course=group.get("course"),
faculty=group.get("faculty", {}).get("short_name", ""),
group=group.get("name", ""),
)
except DatabaseError as exc:
logger.exception(exc)
Этот сигнал получает информацию из полученного от ITMO.ID токена (сохранённую в социальном аккаунте в виде словаря) и распрасит её в поля модели, создав запись в ней.
Чтобы он работал, его нужно импортировать при инициализации приложения (в методе ready
):
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import status, serializers
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
class ItmoIdProfileGroupView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
summary="Get user's education group from ITMO.ID",
responses={
status.HTTP_400_BAD_REQUEST: inline_serializer(
name="ItmoIdProfileGroup",
fields={"group": serializers.CharField()},
),
status.HTTP_404_NOT_FOUND: inline_serializer(
name="ItmoIdProfileGroupNotFound", fields={"error": serializers.CharField()}
),
},
)
def get(self, request: Request):
user = request.user
if not hasattr(user, "profile"):
return Response({"error": "Profile does not exist"}, status.HTTP_404_NOT_FOUND)
group = user.profile.group
return Response({"group": group})
В этом же приложении и в этом же файле также создадим класс ItmoIdProfileGroupView
. В нём метод get
будет
возвращать учебную группу пользователя. Для этого класса нужно указать доступ только для авторизованных пользователей
с помощью permission_classes = [IsAuthenticated]
.
from rest_framework import generics
from rest_framework.permissions import IsAuthenticated
from .serializers import SurveyQuestionSerializer
from .models import SurveyQuestion
class SurveyQuestionList(generics.ListAPIView):
"""Get survey's questions with all needed information"""
queryset = SurveyQuestion.objects.order_by("order").all()
serializer_class = SurveyQuestionSerializer
permission_classes = [IsAuthenticated]
def get_serializer_context(self):
return {"request": self.request}
В этом классе за основу возьму generics.ListAPIView
, возвращающий список объектов. Укажем необходимый запрос
с сортировкой по полю order
, ранее созданный сериализатор и доступ только авторизованным пользователем.
Также в методе get_serializer_context
передадим полученный запрос для корректной работы нашего сериализатора.
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import status, serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import SurveyFaculty, SurveyGroup, SurveyAnswer
class SurveyCheckUserView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
summary="Get the user's suitability to take the survey",
responses={
status.HTTP_200_OK: inline_serializer(
name="SurveyCheckUser",
fields={
"access": serializers.BooleanField(),
"is_first_course": serializers.BooleanField(),
"is_faculty_active": serializers.BooleanField(),
"has_adapters": serializers.BooleanField(),
"is_done": serializers.BooleanField(),
},
),
status.HTTP_404_NOT_FOUND: inline_serializer(
name="SurveyCheckUserNotFound",
fields={"error": serializers.CharField()},
),
},
)
def get(self, request: Request):
user = request.user
if not hasattr(user, "profile"):
return Response({"error": "Profile does not exist!"}, status.HTTP_404_NOT_FOUND)
is_first_course = user.profile.course and user.profile.course == 1
is_faculty_active = (
SurveyFaculty.objects.filter(name=user.profile.faculty).values_list("is_active", flat=True).first()
)
has_adapters = SurveyGroup.objects.filter(name=user.profile.group, adapters__isnull=False).exists()
is_done = SurveyAnswer.objects.filter(user=user).exists()
return Response(
{
"access": all((is_first_course, is_faculty_active, has_adapters, not is_done)),
"is_first_course": is_first_course,
"is_faculty_active": is_faculty_active,
"has_adapters": has_adapters,
"is_done": is_done,
}
)
Опрос должен быть доступен не всем, поэтому создадим эндпоинт, который проверит все условия для пользователя и вернёт ответ, чтобы фронтенд мог решить, что делать дальше.
Проверим курс пользователя, его факультет и статус активности, его учебную группу и наличие привязанных к ней адаптеров и факт прохождения опроса.
import logging
import requests
from django.conf import settings
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import status, serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import SurveyAnswerSerializer
from .models import SurveyQuestion, SurveyAnswer
logger = logging.getLogger("root")
BE_BASE_URL = settings.BE_BASE_URL
class CreateSurveyAnswersView(APIView):
permission_classes = [IsAuthenticated]
serializer_class = SurveyAnswerSerializer(many=True)
@extend_schema(
summary="Save user's answers and calculate scores",
responses={
status.HTTP_200_OK: inline_serializer(
name="CreateSurveyAnswers",
fields={
"status": serializers.CharField(),
},
),
status.HTTP_400_BAD_REQUEST: SurveyAnswerSerializer,
status.HTTP_404_NOT_FOUND: inline_serializer(
name="SurveyCheckUserProfileNotFound",
fields={"error": serializers.CharField()},
),
status.HTTP_409_CONFLICT: inline_serializer(
name="SurveyCheckUserConflict",
fields={"error": serializers.CharField()},
),
status.HTTP_500_INTERNAL_SERVER_ERROR: inline_serializer(
name="SurveyCheckUserServerError",
fields={"error": serializers.CharField()},
),
},
)
def post(self, request: Request):
user = request.user
if not hasattr(user, "profile"):
return Response({"error": "Profile does not exist!"}, status.HTTP_404_NOT_FOUND)
response = requests.get(f"{BE_BASE_URL}/api/survey/user/check/", headers=request.headers)
response.raise_for_status()
if not response.json().get("access", False):
return Response({"error": "The survey checks has not been passed!"}, status=status.HTTP_409_CONFLICT)
serializer = SurveyAnswerSerializer(data=request.data, many=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
answers = serializer.validated_data
answers_to_create = []
for answer in answers:
question_id = answer["id"]
value = answer["value"]
question = SurveyQuestion.objects.filter(id=question_id).first()
if not question:
continue
question_type = SurveyQuestion.Type(question.component)
if question_type == SurveyQuestion.Type.TEXT_AREA:
answers_to_create.append(SurveyAnswer(user=request.user, question=question, value=str(value), score=0))
elif question_type in SurveyQuestion.Type.SELECT:
answers_to_create.append(
SurveyAnswer(user=request.user, question=question, value=value, score=5 if value == "Да" else 0)
)
elif question_type in [SurveyQuestion.Type.SLIDER, SurveyQuestion.Type.TRAINING_SELECT]:
answers_to_create.append(SurveyAnswer(user=request.user, question=question, value=value, score=value))
elif question_type == SurveyQuestion.Type.ADAPTER_SLIDER:
answers_to_create.extend(
SurveyAnswer(
user=request.user,
question=question,
adapter_id=adapter["id"],
value=str(adapter["value"]),
score=adapter["value"],
)
for adapter in value
)
else:
continue
try:
SurveyAnswer.objects.bulk_create(answers_to_create)
except Exception as exc:
logger.exception(exc)
return Response(
{"error": "An error occurred while creating answers!"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({"status": "success"})
В этом эндпоинте в POST запросе будем проверять отправленные ответы.
Для начала нам снова нужно проверить условия для прохождения опроса пользователем, для этого отправим запрос на эндпоинт проверки.
Если условия соблюдены, нужно проверить входные данные с помощью написанного ранее сериализатора для ответов.
Если все данные корректны, создадим список объектов SurveyAnswer
, который будем создавать, проверяя различные
условия, затем массово создадим все записи в БД с помощью метода bulk_create
.