Corp/backend/eusrr_backend/auth_backends.py
Игорь Надеин 9529b3f268 **feat(feed, perms, tests): новая политика доступа для фида + полный набор автотестов**
Ключевые изменения

* Перевели доступ к объектам отдела (посты) на **ролевую модель внутри отдела**:

  * Используются `DepartmentRole.scoped_permissions` (M2M к `DepartmentPermission`).
  * Проверка прав идёт по **коду** из `DeptPerm` (`CREATE_POST`/`MANAGE_FEED`), а не по `auth.Permission`.
* **Company-посты** теперь контролируются **модельными правами** (`add_post`, `change_post`, `delete_post`) или статусом staff/superuser.
* Убраны остатки старой логики `publish_company_post` / `publish_department_post` (как глобальные права).

Пермишены / утилиты

* `user_has_dept_perm(user, dept, code)`:

  * true для staff/superuser и для руководителя отдела;
  * иначе проверка активной связи `EmployeeDepartment(is_active=True)` и наличия `role__scoped_permissions__code=...`.
  * ВАЖНО: используем поле **`code`**, не `codename`.
* `has_dept_perm(user, department_id, code)`:

  * те же правила, оптимизирован поиск (select\_related для роли).
* `AdminOrDeptAllowed`:

  * Корректно извлекает `department_id` из `kwargs`/`data`/`query_params` и из объекта (`obj.department_id/department.id`).
  * Если код не задан — SAFE-методы разрешены (чтение), небезопасные запрещены.
* `AdminOrActionOrModelPerms`:

  * staff/superuser — полный доступ;
  * поддержка явного `required_perm_code`/`required_perms_by_action`;
  * SAFE-методы требуют `view_<model>`, небезопасные — стандартная карта DjangoModelPermissions.
* `IsSelfOrStaff`/`IsSelfOrStaffOrModelPerms`:

  * Владелец объекта (по `author_id`/`author` и др.) или staff/superuser может изменять/удалять;
  * SAFE-методы — для авторизованных.

API: PostViewSet

* Обновлён `get_permissions()`:

  * `list/retrieve/create/like/unlike` → `IsAuthenticated`;
  * `update/partial_update/destroy` → `AdminOrDeptAllowed` с `required_code_map`:

    * `update/partial_update/destroy` → `DeptPerm.MANAGE_FEED`.
* Бизнес-проверки:

  * `perform_create`:

    * `type=department` → разрешено staff/superuser, head отдела, либо роль с `DeptPerm.CREATE_POST`;
    * `type=company` → нужны модельные права `add_post` (или staff).
    * `author` проставляется на `request.user`.
  * `perform_update/perform_destroy`:

    * dept-посты: staff/superuser, head, либо роль с `DeptPerm.MANAGE_FEED`;
    * company-посты: модельные права `change_post`/`delete_post` (или staff).
* Производительность:

  * Аннотация `is_liked` (через `Exists`).
  * Аннотация `last_comment_id` + батч-подтягивание последнего комментария в `list()`.
* Лайки:

  * `like/unlike` — идемпотентные; счётчик корректируется атомарно.

API: CommentViewSet

* `permission_classes = [IsAuthenticated, (IsSelfOrStaff | AdminOrActionOrModelPerms)]`.
* `perform_create` всегда ставит `author=request.user`.
* Фильтры `?post=`/`?author=` и сортировка `created_at ASC`.
* Обновлён `IsSelfOrStaff` для чтения настроек владельца с вью (например, `owner_id_attrs=("author_id",)`).

Тесты

* Добавлен/переписан комплект для новой модели доступа (фид):

  * **Аноним**: все эндпоинты → 401.
  * **Обычный пользователь**:

    * `GET posts/comments` → 200;
    * `POST comment`, `like/unlike` → 201/200;
    * `POST/PUT/PATCH/DELETE dept-post` → 403 (без роли/прав).
  * **Роль отдела**:

    * С `DeptPerm.CREATE_POST` — может создать пост **в своём отделе**; **в чужом** → 403.
    * С `DeptPerm.MANAGE_FEED` — может редактировать/удалять **в своём отделе**.
  * **Head отдела** — полный доступ к постам своего отдела.
  * **Staff/superuser** — полный доступ; `pin/unpin` только staff/superuser.
  * **Глобальные модельные права на company**:

    * `add_post`/`change_post`/`delete_post` → CRUD **только** для company-постов; dept-посты по-прежнему 403.
  * **Комментарии**:

    * Создание — любой аутентифицированный;
    * Изменение/удаление — автор или staff/superuser.
  * **Лайки**:

    * Идемпотентность `like/unlike`, корректная синхронизация `is_liked`.
  * **Валидация**:

    * `type=department` требует `department`;
    * роль в одном отделе не даёт создавать в другом → 403.
* Тестовые хелперы:

  * `_ensure_permission(model, codename, name)` — создаёт/возвращает `auth.Permission` для company-кейсов;
  * `_ensure_dept_permission(code)` и `_give_dept_role_with_perms(...)` — для департаментных прав (через `DepartmentPermission`/`DepartmentRole.scoped_permissions`).

Fixed / багфиксы

* `FieldError: Unsupported lookup 'codename'…` — заменён на фильтр по `scoped_permissions__code`.
* `IntegrityError: NOT NULL constraint failed: feed_post.author_id` — в `perform_create` постов/комментариев author задаётся принудительно.
* Несоответствие тестам по правам чтения/изменения комментариев — исправлено через корректный пермишен владельца.

Файлы (основные)

* `api/v1/permissions.py`: `AdminOrDeptAllowed`, `AdminOrActionOrModelPerms`, `IsSelfOrStaff`(+вариант `IsSelfOrStaffOrModelPerms`), `user_has_dept_perm`, `has_dept_perm`.
* `api/v1/feed/views.py`: `PostViewSet`, `CommentViewSet` (правки прав, `perform_*`, аннотации, like/unlike).
* `tests/api/v1/feed/test_posts.py`, `tests/api/v1/feed/test_comments.py` (и/или `test_feed_api.py`): полный набор сценариев под новую политику.
* Хелперы тестов: функции создания отделов, прав и ролей, генераторы payload.

Итог

* Политика доступа фида приведена к спецификации: **dept-права — через роли внутри отдела**, **company — через модельные права**.
* Исправлены ошибки в проверках прав и авторстве, покрыто автотестами (CRUD, лайки, комменты, валидация, пермишены).
* Производительность чтения ленты улучшена (аннотации `is_liked`, `last_comment_id` без N+1).
2025-09-11 17:56:33 +03:00

153 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# eusrr_backend/auth_backends.py
import re
from typing import Optional
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission
from employees.models import Department, Employee, EmployeeDepartment
# phonenumbers — опционально, но очень желательно: pip install phonenumbers
try:
import phonenumbers
from phonenumbers import PhoneNumberFormat
except Exception:
phonenumbers = None
PhoneNumberFormat = None
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _looks_like_email(s: str) -> bool:
return bool(EMAIL_RE.match(s or ""))
def _normalize_phone(raw: str) -> Optional[str]:
"""
Приводим номер к формату E.164 (+79991234567).
Возвращаем None, если распарсить не удалось или пакет не установлен.
"""
if not raw:
return None
if phonenumbers is None:
# fallback — без нормализации
return str(raw).strip()
region = getattr(settings, "PHONE_DEFAULT_REGION", "RU")
try:
pn = phonenumbers.parse(str(raw), region)
if not phonenumbers.is_valid_number(pn):
return None
return phonenumbers.format_number(pn, PhoneNumberFormat.E164)
except Exception:
return None
def _detect_phone_field() -> Optional[str]:
"""
Определяем имя поля телефона в модели Employee.
Поддерживаем распространённые варианты.
"""
candidates = ("phone", "phone_number", "mobile", "msisdn", "tel")
field_names = {f.name for f in Employee._meta.get_fields()}
for name in candidates:
if name in field_names:
return name
return None
PHONE_FIELD = _detect_phone_field()
class EmailOrPhoneBackend(ModelBackend):
"""
Аутентификация по email ИЛИ по телефону (E.164) + стандартная проверка пароля.
Пользователь должен быть активен (is_active=True) — активируйте после verify_email().
"""
def authenticate(self, request, username=None, password=None, **kwargs):
login = username or kwargs.get("email") or kwargs.get("phone")
if not login or not password:
return None
user: Optional[Employee] = None
# 1) Email
if _looks_like_email(login):
user = Employee.objects.filter(email__iexact=login).first()
else:
# 2) Phone
if PHONE_FIELD:
normalized = _normalize_phone(login)
# сначала пробуем нормализованный E.164
if normalized:
user = Employee.objects.filter(**{PHONE_FIELD: normalized}).first()
# если не нашли — пробуем как ввёл пользователь (на случай старых данных)
if not user:
raw = str(login).strip()
user = Employee.objects.filter(**{PHONE_FIELD: raw}).first()
if not user:
return None
# допускаем логин только активных (email подтверждён → is_active=True)
if not user.is_active:
return None
if user.check_password(password):
return user
return None
class PositionRoleBackend(ModelBackend):
def get_all_permissions(self, user_obj, obj=None):
perms = super().get_all_permissions(user_obj, obj)
if not getattr(user_obj, "is_authenticated", False) or obj is not None:
return perms
# 1) Глобальные права от должности — оставить
pos = getattr(user_obj, "position", None)
if pos:
pos_perms = (
Permission.objects.filter(group__positions=pos)
.select_related("content_type")
.distinct()
)
perms |= {f"{p.content_type.app_label}.{p.codename}" for p in pos_perms}
# 2) ⚠️ НЕ добавляем сюда пермишены из DepartmentRole.
# Они должны проверяться только на уровне конкретного Department в has_perm(..., obj=dept)
return perms
def has_perm(self, user_obj, perm, obj=None):
# стандартная ветка + суперюзер/группы/пользовательские
if super().has_perm(user_obj, perm, obj=obj):
return True
if not getattr(user_obj, "is_authenticated", False):
return False
if obj is None:
# добавить глобальные (от должности)
return perm in self.get_all_permissions(user_obj)
# Объектные права на Department — как у вас
if isinstance(obj, Department):
if obj.head_id == user_obj.id:
return True
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee=user_obj, department=obj, is_active=True)
.first()
)
if not (link and link.role_id):
return False
app_label, sep, codename = perm.partition(".")
if not sep:
return False
return link.role.scoped_permissions.filter(
content_type__app_label=app_label,
codename=codename,
).exists()
return False