Django: один пользователь для всего
- четверг, 4 июня 2020 г. в 00:28:34
Всем привет. При разработке API для очередного веб-портала я взял свой привычный стек:
Но в этот раз стояла довольно непривычная задача — сделать одну User модель, которая может иметь несколько разных профилей (Исполнитель, Заказчик). И наличие каждого из профилей дает разные полномочия на работу с одними и теми же ресурсами.
Такой подход позволяет пользователям не заводить несколько учетных записей для каждой роли, что зачастую было бы невозможно, ввиду ограничений на модель: уникальный email или номер телефона.
Итак, опишем возникшие перед нами проблемы:
Ниже я приведу свой способ решения этой задачи, который сложился из уже наработанных привычек по организации Django-проекта, а также попыток придумать наиболее гибкое и масштабируемое решение.
Над этой проблемой я почти не думал и шел по опыту предыдущих проектов, да и в документации фреймворка этот способ описан, как предпочтительный (https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#extending-the-existing-user-model)
# models.py
class User(AbstractUser):
username = None
email = models.EmailField(unique=True, verbose_name=_("email"))
phone = models.CharField(unique=True, max_length=11, verbose_name=_("phone"))
...
USERNAME_FIELD = "email"
EMAIL_FIELD = "email"
...
class Employee(models.Model):
...
user = models.OneToOneField(
"user.User",
on_delete=models.CASCADE,
related_name="employee",
verbose_name=_("user"),
)
class Company(models.Model):
...
class Member(models.Model):
is_owner = models.BooleanField(default=False, verbose_name=_("owner"))
is_manager = models.BooleanField(default=False, verbose_name=_("manager"))
is_active = models.BooleanField(default=True)
user = models.OneToOneField(
"user.User",
on_delete=models.CASCADE,
related_name="member",
verbose_name=_("user"),
)
company = models.ForeignKey(
"user.Company",
on_delete=models.CASCADE,
related_name="members",
verbose_name=_("company"),
)
Таким образом каждый пользователь может иметь от 0 до 2-х профилей (профиль Исполнителя, профиль Заказчика).
Данную организацию моделей я принял как исходную точку для разработки решения и считал, что здесь ошибок допущено не было.
Эту часть задачи я также решал по опыту предыдущих проектов, использовав Custom Permissions от drf (https://www.django-rest-framework.org/api-guide/permissions/#custom-permissions).
В этом решении мне нравится его простота и наглядность. Но допускаю, что данную задачу можно было решить и через джанго пермишны.
# permissions.py
from rest_framework.permissions import BasePermission
class EmployeeOnly(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and hasattr(request.user, "employee")
class CompanyManagerOnly(BasePermission):
def has_permission(self, request, view):
return (
request.user.is_authenticated
and hasattr(request.user, "member")
and request.user.member.is_active
and request.user.member.is_manager
)
Для действий, которые доступны пользователям с любым из профилей, можно сделать общий permission
# permissions.py
class EmployeeAndCompanyManagerOnly(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and (
hasattr(request.user, "employee")
or (
hasattr(request.user, "member")
and request.user.member.is_active
and request.user.member.is_manager
)
)
У такого подхода есть минус: если пермишнов по профилям будет больше 2-х, то множество таких сочетаний будет сильно расти. Но если вы можете с достаточной уверенностью утверждать, что пермишны не будут расширяться, то код останется довольно лаконичным.
В django-rest-framework пермишны, которые применяются к конкретной вью, указываются в атрибуте permissions_class, которые накладывается по принципу AND.
# views.py
class TaskView(APIView):
...
permission_classes = (CompanyManagerOnly, EmployeeOnly)
...
В этом примере доступ ко вью получат только пользователи, имеющие оба профиля.
Именно в таком случае нам приходится придумывать фокусы с составными пермишнами EmployeeAndCompanyManagerOnly
и использовать их в качестве одного общего пермишн класса.
# views.py
class TaskView(APIView):
...
permission_classes = (EmployeeAndCompanyManagerOnly,)
...
С версии 3.9 в drf появилась возможность конструировать различные условия из пермишнов, используя синтаксис битовых операций
# views.py
class TaskView(APIView):
...
permission_classes = ((CompanyManagerOnly|EmployeeOnly),)
...
Синтаксис не самый очевидный и симпатичный, но может быть удобнее, чем городить кучу составных пермишн классов.
В текущей версии проекта я использовал версию drf 3.11, поэтому решил использовать эту новую возможность. Это добавляет некоторой гибкости в масштабировании проекта, не так уж сильно усложняя чтение и понимание кода.
P.S. В дальнейшем у меня добавились еще пермишны для CompanyOwnerOnly и CompanyMemberOnly, и мне не пришлось ничего дописывать помимо этих классов, что хорошо.
Мы дошли до самого интересного и неочевидного места.
Когда-то давно, потратив несколько дней на изучение вопросов (Как же все-таки правильно писать REST API? Обязательно ли апи должно быть RESTful? Да и вообще как делать правильно? Должны же быть какие-то best practice?), я понял, что мнений много, а четких правил нет. Но все мнения сходятся в одном: не бойтесь делать то, что удобно в вашем случае.
И для себя я вывел следующие тезисы:
В своих проектах я использую viewsets от drf, которые из коробки генерят необходимые нам REST окончания для наших моделей. И в случае необходимости разбавляю его некоторыми экшнами.
Итак, опишем ресурс, с которым взаимодействуют различные пользователи:
# models.py
class Task(models.Model):
...
# views.py
class TaskViewSet(
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
serializer_class = TaskSerializer
def get_permissions(self):
if self.action in ["accept_invite", "reject_invite", "employee_me"]:
return (EmployeeOnly(),)
if self.action in [
"accept_employees",
"reject_employees",
"create",
"update",
"partial_update",
]:
return (CompanyManagerOnly(),)
return ((CompanyManagerOnly | EmployeeOnly)(),) # тоже страшно от этой конструкции
...
Cодержание экшнов не имеет значения ("create", "update", "partial_update" — название дефолтных экшнов drf), поэтому не буду приводить их описания.
Вот как будет выглядеть апи от имени Исполнителя (для Заказчика аналогично, только со своими методами):
И все бы было хорошо, если бы не тот факт, что один и тот же пользователь, может иметь сразу оба профиля, а значит сваггер нарисует нам следующую картинку:
Тут и становится понятно, что у нас есть некоторые проблемы:
И решить их навскидку можно 4-мя способами:
Написать разные TaskViewSet
для Исполнителя и Заказчика.
Плюсы | Минусы |
---|---|
Код получается прямолинейный, без лишних if | Спецификация сильно растет |
- | Несколько вьюх вместо одной (труднее поддерживать) |
- | Employee и Сompany это все-таки не путь к ресурсу, а роль, с которой я работаю в этом ресурсе, как-то не "по-рестовски" пихать ее в url |
Использовать кастомный хэдер в реквесте для определения, в качестве кого пользователь хочет запросить ресурс.
Плюсы | Минусы |
---|---|
Предположительная красота спецификации (ничего лишнего в урлах) | В логах ничего не понятно: если что-то где-то упадет, сложно понять, от имени кого пользователь запрашивал данные |
- | Автоматический генератор спецификации (drf_yasg) не поддерживает добавления кастомных хэдеров, поэтому разработка превратилась бы в ад |
Использовать query параметры в урле.
Плюсы | Минусы |
---|---|
Вью будет одна | Сразу понятно, что вью усложнится: будут постоянные if else |
Кажется, что спецификация будет симпатичная (вкусовщина) | - |
HATEOAS (расскажу подробнее в следующем посте!)
По этому решению я не могу описать плюсы-минусы, так как никогда не пытался внедрять в свои проекты. Читал, что это не всегда удобно и бывает не просто поддерживать.
Но достоверность этой информации подтвердить не могу.
Так же есть подозрение, что подход не решает проблему №1.
Я выбрал третье решение, так как в нем меньше всего минусов.
Написал специальный сериалайзер и подключаю его к спецификации тех экшнов, которые должны содержать этот query параметр.
# serializers.py
class ModeSerializer(serializers.Serializer):
mode = serializers.ChoiceField(MODE)
# views.py
@swagger_auto_schema(query_serializer=ModeSerializer)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(query_serializer=ModeSerializer)
def retrieve(self, request, pk, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
Получилось довольно симпатично и последним штрихом осталось определить queryset во вью в зависимости от выбранного mode.
# views.py
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return Task.objects.none()
mode = self.request.query_params.get("mode")
if mode == MODE.COMPANY:
return Task.objects.filter(author=self.request.user).order_by("-created_at")
elif mode == MODE.EMPLOYEE:
# сложная возня с фильтрацией, но главное что мы
# определяем другой queryset!
tasks = set(
taskemployee.task.id
for taskemployee in self.request.user.employee.taskemployee_set.exclude(
invite_status=SUGGEST_STATUS.REJECT
)
if taskemployee.task.status
in [
TASK_STATUS.IN_WORK,
TASK_STATUS.DONE,
]
)
return Task.objects.filter(id__in=tasks).order_by("-created_at")
Итак, мы решили проблему №1, теперь мы точно знаем, от какой роли запрашиваем задачи.
Следующим этапом надо было придумать, как ограничить действия на объекты, которые мы можем получить, например, как Исполнитель, но не можем редактировать как Заказчик (так как мы не ее автор, хоть у нас и есть профиль Заказчика).
И тут мне помогло то, как организован метод self.get_object() в drf view.
# rest_framework.generics.GenericAPIView
class GenericAPIView(views.APIView):
...
def get_object(self):
...
queryset = self.filter_queryset(self.get_queryset())
...
obj = get_object_or_404(queryset, **filter_kwargs)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
Он ищет объект в queryset = self.filter_queryset(self.get_queryset())
, который мы определили до этого, что в общем-то логично, так как другой связи с моделью у вью нет.
Соответственно небольшой модификацией кода мы решили и вторую проблему:
# views.py
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return Task.objects.none()
mode = self.request.query_params.get("mode")
if mode == MODE.COMPANY or self.action in [
"create",
"update",
"partial_update",
"accept_employees",
"reject_employees",
]:
...
elif mode == MODE.EMPLOYEE or self.action in [
"accept_invite",
"reject_invite",
]:
...
Технически проблема решена: при попытке изменить задачу, автором которой я не являюсь, мне вернется ошибка 404 NotFound, т.к. queryset для данного пользователя сформированный по ветке MODE.COMPANY
просто не будет содержать этот ресурс.
Дальше я попытался навести красоту: мне не нравилось то, что в if мы мешаем и mode из урла, и проверку по экшнам. А также при добавлении нового экшна надо не забыть его здесь дописать.
После небольшого рефакторинга стало лучше:
# views.py
class TaskViewSet(
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
...
action_default_mode = {
"create": MODE.COMPANY,
"update": MODE.COMPANY,
"partial_update": MODE.COMPANY,
"accept_invite": MODE.EMPLOYEE,
"reject_invite": MODE.EMPLOYEE,
"employee_me": MODE.EMPLOYEE,
}
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return Task.objects.none()
mode = self.request.query_params.get("mode") or self.action_default_mode.get(
self.action
)
assert mode, (
"You need to add required query param in your urls "
"or set an action default mode in self.action_default_mode map"
f" for action `{self.action}`"
)
if mode == MODE.COMPANY:
...
elif mode == MODE.EMPLOYEE:
...
Но от чего хочется избавиться так это от того, что при добавлении экшна я должен скролить до словаря и вписывать туда дефолтный мод. По-питонячи было бы использовать какой-нибудь декоратор и пометить экшн сразу, но эту доработку я оставлю на потом.
В первую очередь я набирал этот текст, чтобы попытаться логически выстроить свое решение и объяснить каждый следующую шаг.
В итоге, как мне кажется, получилось довольно лаконичное решение, которое хорошо масштабируется на произвольное количество профилей. При этом сложность кода почти не возрастает, и мы смогли удачно разобраться с пермишнами на объекты.
На хабр я решил запостить эту статью, т.к. пока решал данную задачу, долго искал что-то похожее в интернете, но нашел довольно мало материалов на тему "один пользователь — много ролей".
Хочется узнать не изобретал ли я велосипед в стеке Django, drf. А если это действительно нужный кейс, то можно даже обернуть это в небольшой плагинчик для джанго.
Всем спасибо!