фикс коментов
This commit is contained in:
parent
81bce43f8a
commit
540995633b
@ -1,5 +1,6 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@ -246,4 +247,12 @@ REST_FRAMEWORK = {
|
||||
"PAGE_SIZE": 20,
|
||||
}
|
||||
|
||||
API_BASE_URL = "http://127.0.0.1:9000/api"
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=int(os.getenv("JWT_ACCESS_MIN", "30"))),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=int(os.getenv("JWT_REFRESH_DAYS", "7"))),
|
||||
"ALGORITHM": "HS256",
|
||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||
"LEEWAY": 30, # допускаем ±30с рассинхронизации времени
|
||||
}
|
||||
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
|
||||
@ -3,6 +3,8 @@
|
||||
{% block title %}Удалить комментарий{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% firstof post.id post.pk as post_id %}
|
||||
{% firstof comment.id comment.pk as comment_id %}
|
||||
<div class="container">
|
||||
<div class="card p-3 shadow-sm">
|
||||
<h5 class="mb-3">Удалить комментарий?</h5>
|
||||
@ -10,7 +12,7 @@
|
||||
<form method="post" class="d-flex gap-2">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger">Удалить</button>
|
||||
<a class="btn btn-secondary" href="{% url 'feed:post_detail' post.pk %}#c{{ comment.pk }}">Отмена</a>
|
||||
<a class="btn btn-secondary" href="{% url 'feed:post_detail' post_id %}#c{{ comment_id }}">Отмена</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
{% block title %}Редактировать комментарий{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% firstof post.id post.pk as post_id %}
|
||||
{% firstof comment.id comment.pk as comment_id %}
|
||||
<div class="container">
|
||||
<h4 class="mb-3">Редактировать комментарий к: “{{ post.title }}”</h4>
|
||||
<form method="post" class="card p-3 shadow-sm">
|
||||
@ -11,7 +13,7 @@
|
||||
{% bootstrap_form form %}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<a class="btn btn-secondary" href="{% url 'feed:post_detail' post.pk %}#c{{ comment.pk }}">Отмена</a>
|
||||
<a class="btn btn-secondary" href="{% url 'feed:post_detail' post_id %}#c{{ comment_id }}">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
261
backend/tests/api/v1/requests_app/conftest.py
Normal file
261
backend/tests/api/v1/requests_app/conftest.py
Normal file
@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Any, Callable, Dict, Final, Optional
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
# === при необходимости поменяй URL выдачи токена (SimpleJWT) ===
|
||||
TOKEN_OBTAIN_URL: Final[str] = "/api/auth/token/"
|
||||
|
||||
|
||||
# Путь коллекции и деталки (поменяйте при необходимости)
|
||||
LIST_URL: Final[str] = "/api/v1/requests/"
|
||||
DETAIL_URL_FMT: Final[str] = "/api/v1/requests/{id}/"
|
||||
|
||||
# Модельное право, дающее доступ видеть чужие заявки (укажите ваш codename)
|
||||
# пример: "requests_app.view_request"
|
||||
MODEL_VIEW_PERMISSION: Final[str] = "requests_app.view_request"
|
||||
|
||||
# Минимальный валидный payload для создания заявления
|
||||
# ❗ Заполните под ваш сериализатор (обязательные поля!)
|
||||
SAMPLE_CREATE_PAYLOAD: Dict[str, Any] = {
|
||||
# "title": "Командировка",
|
||||
# "body": "По проекту А",
|
||||
}
|
||||
# =============================================================
|
||||
|
||||
_phone_counter = itertools.count(100_000_000) # 9 цифр для номера после +79
|
||||
|
||||
|
||||
def _detect_phone_field(User: type[models.Model]) -> Optional[str]:
|
||||
"""Возвращает имя телефонного поля модели пользователя, если оно есть.
|
||||
|
||||
Проверяются популярные варианты: 'phone', 'phone_number', 'mobile', 'mobile_phone'.
|
||||
|
||||
Args:
|
||||
User (type[models.Model]): Класс пользовательской модели.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Имя поля телефона или None, если поле не найдено.
|
||||
|
||||
Raises:
|
||||
AttributeError: Если у модели отсутствует _meta (не должно случиться у Django-моделей).
|
||||
"""
|
||||
candidates = ("phone", "phone_number", "mobile", "mobile_phone")
|
||||
for name in candidates:
|
||||
try:
|
||||
User._meta.get_field(name) # type: ignore[attr-defined]
|
||||
return name
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _next_ru_phone() -> str:
|
||||
"""Генерирует уникальный российский номер в формате E.164 (+79XXXXXXXXX).
|
||||
|
||||
Returns:
|
||||
str: Телефон в формате +79XXXXXXXXX.
|
||||
|
||||
Raises:
|
||||
OverflowError: Если счётчик телефонов переполнится (практически нереально в тестах).
|
||||
"""
|
||||
return f"+79{next(_phone_counter):09d}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client() -> APIClient:
|
||||
"""Чистый DRF-клиент без авторизации.
|
||||
|
||||
Returns:
|
||||
APIClient: Клиент без заголовков авторизации.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если DRF не установлен/не сконфигурирован.
|
||||
"""
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_user() -> Callable[..., models.Model]:
|
||||
"""Фабрика пользователей, совместимая с кастомной моделью (телефон может быть обязателен).
|
||||
|
||||
По умолчанию:
|
||||
- задаёт уникальный email;
|
||||
- автоматически проставляет телефон в найденное поле;
|
||||
- при необходимости выставляет флаги is_staff/is_superuser;
|
||||
- устанавливает пароль.
|
||||
|
||||
Returns:
|
||||
Callable[..., models.Model]: Функция создания пользователя.
|
||||
|
||||
Raises:
|
||||
Exception: Любые ошибки создания пользователя прокидываются наверх.
|
||||
"""
|
||||
User = get_user_model()
|
||||
phone_field = _detect_phone_field(User)
|
||||
|
||||
def _make_user(
|
||||
email: str = "user@example.com",
|
||||
*,
|
||||
password: str = "pass12345",
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
phone: Optional[str] = None,
|
||||
**extra: object,
|
||||
) -> models.Model:
|
||||
"""Создаёт пользователя и возвращает его инстанс.
|
||||
|
||||
Args:
|
||||
email (str): Email пользователя (должен быть уникален, если так настроена модель).
|
||||
password (str): Пароль (если пустой — пароль не устанавливается).
|
||||
is_staff (bool): Флаг персонала админки.
|
||||
is_superuser (bool): Флаг суперпользователя.
|
||||
phone (Optional[str]): Номер телефона; если не задан — сгенерируется автоматически, если поле найдено.
|
||||
**extra (object): Дополнительные поля, передаются в create_user.
|
||||
|
||||
Returns:
|
||||
models.Model: Созданный пользователь.
|
||||
|
||||
Raises:
|
||||
ValueError: Если менеджер модели требует телефон, а мы не смогли его задать.
|
||||
"""
|
||||
kwargs: dict[str, object] = {"email": email, **extra}
|
||||
|
||||
# Если модель содержит тел. поле — заполним его (некоторые менеджеры делают его обязательным).
|
||||
if phone_field:
|
||||
kwargs[phone_field] = phone or _next_ru_phone()
|
||||
elif phone is not None:
|
||||
# Если тест явно передал phone, а поле называется иначе — пусть упадёт в менеджере;
|
||||
# это подсветит реальное имя поля.
|
||||
kwargs["phone"] = phone # type: ignore[assignment]
|
||||
|
||||
user = User.objects.create_user(**kwargs) # type: ignore[arg-type]
|
||||
if password:
|
||||
user.set_password(password)
|
||||
if is_staff:
|
||||
user.is_staff = True
|
||||
if is_superuser:
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
return user
|
||||
|
||||
return _make_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regular_user(make_user: Callable[..., models.Model]) -> models.Model:
|
||||
"""Обычный пользователь без спецправ.
|
||||
|
||||
Args:
|
||||
make_user (Callable[..., models.Model]): Фабрика пользователей.
|
||||
|
||||
Returns:
|
||||
models.Model: Пользователь.
|
||||
"""
|
||||
return make_user(email="regular@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(make_user: Callable[..., models.Model]) -> models.Model:
|
||||
"""Администратор (суперпользователь).
|
||||
|
||||
Args:
|
||||
make_user (Callable[..., models.Model]): Фабрика пользователей.
|
||||
|
||||
Returns:
|
||||
models.Model: Суперпользователь.
|
||||
"""
|
||||
return make_user(email="admin@example.com", is_staff=True, is_superuser=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client() -> Callable[[models.Model], APIClient]:
|
||||
"""Фабрика авторизованных DRF-клиентов с использованием force_authenticate.
|
||||
|
||||
Это быстрый и надёжный способ авторизации в unit-тестах API без зависимости от JWT.
|
||||
|
||||
Returns:
|
||||
Callable[[models.Model], APIClient]: Функция, принимающая пользователя и возвращающая авторизованный клиент.
|
||||
|
||||
Raises:
|
||||
AssertionError: Если передан объект, не являющийся Django-моделью.
|
||||
"""
|
||||
|
||||
def _build(user: models.Model) -> APIClient:
|
||||
if not isinstance(user, models.Model):
|
||||
raise AssertionError("user должен быть Django-моделью.")
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
return client
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
def _obtain_access_token(api_client: APIClient, username: str, password: str) -> str:
|
||||
"""Получает access-токен через эндпоинт SimpleJWT.
|
||||
|
||||
Args:
|
||||
api_client (APIClient): Клиент без авторизации.
|
||||
username (str): Имя пользователя.
|
||||
password (str): Пароль пользователя.
|
||||
|
||||
Returns:
|
||||
str: Строка access-токена.
|
||||
|
||||
Raises:
|
||||
AssertionError: Если эндпоинт вернул не 200/201 или тело ответа без токена.
|
||||
KeyError: Если формат тела ответа неожиданный.
|
||||
"""
|
||||
resp = api_client.post(TOKEN_OBTAIN_URL, {"username": username, "password": password}, format="json")
|
||||
assert resp.status_code in (200, 201), f"Token obtain failed: {resp.status_code} {resp.content!r}"
|
||||
access = resp.data.get("access") or resp.data.get("token")
|
||||
assert access, f"No access token in response: {resp.data}"
|
||||
return str(access)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_auth_client(api_client: APIClient, regular_user: models.Model) -> APIClient:
|
||||
"""DRF-клиент, авторизованный через Bearer-токен (SimpleJWT).
|
||||
|
||||
Полезно для интеграционных тестов, где важно пройти реальный путь аутентификации.
|
||||
|
||||
Args:
|
||||
api_client (APIClient): Неаутентифицированный клиент.
|
||||
regular_user (models.Model): Пользователь.
|
||||
|
||||
Returns:
|
||||
APIClient: Клиент с заголовком Authorization.
|
||||
|
||||
Raises:
|
||||
AssertionError: Если не удалось получить токен или эндпоинт недоступен.
|
||||
"""
|
||||
# В менеджере пользователя может быть username/email — берём .get_username() для совместимости
|
||||
username = getattr(regular_user, "get_username", lambda: getattr(regular_user, "username", "regular"))()
|
||||
token = _obtain_access_token(api_client, username, "pass12345")
|
||||
api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_admin_auth_client(api_client: APIClient, admin_user: models.Model) -> APIClient:
|
||||
"""DRF-клиент с Bearer-токеном суперпользователя (SimpleJWT).
|
||||
|
||||
Args:
|
||||
api_client (APIClient): Неаутентифицированный клиент.
|
||||
admin_user (models.Model): Суперпользователь.
|
||||
|
||||
Returns:
|
||||
APIClient: Клиент с заголовком Authorization.
|
||||
|
||||
Raises:
|
||||
AssertionError: Если не удалось получить токен.
|
||||
"""
|
||||
username = getattr(admin_user, "get_username", lambda: getattr(admin_user, "username", "admin"))()
|
||||
token = _obtain_access_token(api_client, username, "pass12345")
|
||||
api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
return api_client
|
||||
Loading…
x
Reference in New Issue
Block a user