613 lines
22 KiB
Python
613 lines
22 KiB
Python
# backend/feed/views.py
|
||
from __future__ import annotations
|
||
|
||
from datetime import timedelta
|
||
|
||
from django import forms
|
||
from django.contrib import messages
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.http import HttpResponseRedirect, JsonResponse
|
||
from django.shortcuts import redirect, render
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
|
||
from api.client import ApiResponse, get_api_client
|
||
from .constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
|
||
from .forms import CommentForm, PostForm
|
||
|
||
# ---------- Константы путей API ----------
|
||
API_POSTS = "v1/posts/"
|
||
API_COMMENTS = "v1/comments/"
|
||
API_DEPARTMENTS = "v1/departments/"
|
||
API_EMPLOYEES = "v1/employees/"
|
||
|
||
|
||
# ---------- Вспомогательное: доступ к ApiClient ----------
|
||
def _api(request):
|
||
return get_api_client(request)
|
||
|
||
|
||
def _paged_or_list(resp: ApiResponse):
|
||
data = resp.json or {}
|
||
return data.get("results", data)
|
||
|
||
|
||
# ---------- Локальный multipart-враппер поверх ApiClient (без его модификации) ----------
|
||
def _api_request_multipart(
|
||
client, method: str, path: str, *, data=None, files=None
|
||
) -> ApiResponse:
|
||
"""
|
||
Отправляет multipart/form-data, используя ту же сессию и заголовки, что ApiClient.
|
||
Делает ровно один refresh на 401.
|
||
"""
|
||
url = client._make_url(
|
||
path
|
||
) # noqa: SLF001 — осознанно пользуемся внутренними методами
|
||
headers = dict(client.cfg.default_headers or {})
|
||
headers.update(client._auth_headers()) # noqa: SLF001
|
||
|
||
resp = client.session.request(
|
||
method,
|
||
url,
|
||
data=data,
|
||
files=files,
|
||
headers=headers,
|
||
timeout=client.cfg.timeout,
|
||
)
|
||
# 401 -> пробуем обновить access и повторить
|
||
if resp.status_code == 401 and client.refresh and client.refresh_tokens():
|
||
headers = dict(client.cfg.default_headers or {})
|
||
headers.update(client._auth_headers()) # noqa: SLF001
|
||
resp = client.session.request(
|
||
method,
|
||
url,
|
||
data=data,
|
||
files=files,
|
||
headers=headers,
|
||
timeout=client.cfg.timeout,
|
||
)
|
||
try:
|
||
payload = resp.json()
|
||
except Exception:
|
||
payload = None
|
||
return ApiResponse(
|
||
ok=resp.ok, status=resp.status_code, json=payload, text=resp.text
|
||
)
|
||
|
||
|
||
# ---------- Хелперы API для ленты ----------
|
||
def _api_list_posts(request, **params) -> list[dict]:
|
||
resp = _api(request).get(API_POSTS, params=params)
|
||
if not resp.ok:
|
||
messages.error(request, f"Не удалось получить публикации: {resp.status}")
|
||
return []
|
||
return _paged_or_list(resp)
|
||
|
||
|
||
def _api_get_post(request, post_id: int) -> dict | None:
|
||
resp = _api(request).get(f"{API_POSTS}{post_id}/")
|
||
if not resp.ok:
|
||
messages.error(request, f"Публикация недоступна: {resp.status}")
|
||
return None
|
||
return resp.json or None
|
||
|
||
|
||
def _api_get_comments_for_post(request, post_id: int) -> list[dict]:
|
||
resp = _api(request).get(
|
||
API_COMMENTS, params={"post": post_id, "ordering": "created_at"}
|
||
)
|
||
if not resp.ok:
|
||
messages.error(request, f"Не удалось получить комментарии: {resp.status}")
|
||
return []
|
||
return _paged_or_list(resp)
|
||
|
||
|
||
def _api_get_department(request, dept_id: int) -> dict | None:
|
||
resp = _api(request).get(f"{API_DEPARTMENTS}{dept_id}/")
|
||
return resp.json if resp.ok else None
|
||
|
||
|
||
def _api_get_employee(request, employee_id: int) -> dict | None:
|
||
resp = _api(request).get(f"{API_EMPLOYEES}{employee_id}/")
|
||
return resp.json if resp.ok else None
|
||
|
||
|
||
def _api_list_department_members(request, dept_id: int) -> list[dict]:
|
||
# Предпочтительно: /v1/employees/?department=<id>&is_active=true&ordering=last_name,first_name
|
||
resp = _api(request).get(
|
||
API_EMPLOYEES,
|
||
params={
|
||
"department": dept_id,
|
||
"is_active": "true",
|
||
"ordering": "last_name,first_name",
|
||
},
|
||
)
|
||
return _paged_or_list(resp) if resp.ok else []
|
||
|
||
|
||
def _api_list_new_employees(request, limit: int = 10) -> list[dict]:
|
||
since = (timezone.now() - timedelta(days=14)).isoformat()
|
||
resp = _api(request).get(
|
||
API_EMPLOYEES,
|
||
params={
|
||
"is_active": "true",
|
||
"created_at__gte": since, # или "created_at_after": since — см. ниже
|
||
"ordering": "-created_at",
|
||
"page_size": limit,
|
||
},
|
||
)
|
||
return _paged_or_list(resp) if resp.ok else []
|
||
|
||
|
||
# ---------- Навигационные хелперы ----------
|
||
def _post_back_url(post: dict, user) -> str:
|
||
t = post.get("type")
|
||
if t == TYPE_COMPANY:
|
||
return reverse("feed:feed_list")
|
||
if t == TYPE_DEPARTMENT and post.get("department_id"):
|
||
return reverse("employees:department_detail", args=[post["department_id"]])
|
||
# страница пользователя — все его публикации
|
||
author_id = post.get("author_id") or (
|
||
user.pk if getattr(user, "is_authenticated", False) else None
|
||
)
|
||
return reverse("feed:employee_feed", args=[author_id or 0])
|
||
|
||
|
||
async def _can_edit_post_locally(user, post: dict) -> bool:
|
||
# Мягкая проверка на стороне UI; фактическая авторизация — на API.
|
||
if not (user and user.is_authenticated):
|
||
return False
|
||
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
|
||
return True
|
||
t = post.get("type")
|
||
if t == TYPE_COMPANY:
|
||
return False
|
||
if t == TYPE_DEPARTMENT:
|
||
dept_id = post.get("department_id")
|
||
if dept_id:
|
||
# проверим, не является ли юзер руководителем отдела
|
||
# (берём минимальные данные отдела)
|
||
# Если API возвращает в деталях отдела head_id — используем его.
|
||
# Если нет — UI просто доверит решать бэкенду (можно упростить до False).
|
||
return post.get("author_id") == user.id or bool(
|
||
await _is_dept_head(user, dept_id)
|
||
)
|
||
return post.get("author_id") == user.id
|
||
return post.get("author_id") == user.id
|
||
|
||
|
||
async def _is_dept_head(user, dept_id: int) -> bool:
|
||
# Пытаемся определить head_id отдела через API
|
||
# Если эндпоинт не даёт head_id, возвращаем False — API всё равно провалидирует право.
|
||
# Для синхронных вьюх просто используем синхронный вызов:
|
||
# (оставлено async для потенциальной асинхронной интеграции)
|
||
from asgiref.sync import sync_to_async
|
||
|
||
def _sync(request, d_id):
|
||
dept = _api(
|
||
request=None
|
||
) # не нужен request, но get_api_client требует — обойдём ниже; см. комментарий
|
||
return False
|
||
|
||
return False # безопасное значение: разрешение решит API
|
||
|
||
|
||
# ---------- Ленты ----------
|
||
|
||
|
||
def feed_list(request):
|
||
"""
|
||
Лента компании из API (?type=company).
|
||
Блок «новые сотрудники» — тоже из API.
|
||
"""
|
||
posts = _api_list_posts(request, type=TYPE_COMPANY)
|
||
new_employees = _api_list_new_employees(request, limit=10)
|
||
return render(
|
||
request, "feed/feed_list.html", {"posts": posts, "new_employees": new_employees}
|
||
)
|
||
|
||
|
||
@login_required
|
||
def department_feed(request, pk):
|
||
department = _api_get_department(request, pk)
|
||
if not department:
|
||
messages.error(request, "Отдел недоступен.")
|
||
return redirect("feed:feed_list")
|
||
posts = _api_list_posts(request, type=TYPE_DEPARTMENT, department=pk)
|
||
emp_links = _api_list_department_members(request, pk)
|
||
return render(
|
||
request,
|
||
"feed/feed_list.html",
|
||
{
|
||
"department": department,
|
||
"posts": posts,
|
||
"new_employees": [],
|
||
"emp_links": emp_links,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def employee_feed(request, pk):
|
||
employee = _api_get_employee(request, pk)
|
||
if not employee:
|
||
messages.error(request, "Сотрудник недоступен.")
|
||
return redirect("feed:feed_list")
|
||
posts = _api_list_posts(request, author=pk)
|
||
return render(
|
||
request, "feed/feed_list.html", {"employee": employee, "posts": posts}
|
||
)
|
||
|
||
|
||
# ---------- Детальная + комментарии ----------
|
||
|
||
|
||
@login_required
|
||
def post_detail(request, pk):
|
||
post = _api_get_post(request, pk)
|
||
if not post:
|
||
return redirect("feed:feed_list")
|
||
|
||
comments = _api_get_comments_for_post(request, pk)
|
||
|
||
if request.method == "POST":
|
||
form = CommentForm(request.POST, request.FILES)
|
||
if form.is_valid():
|
||
cleaned = form.cleaned_data
|
||
data = {
|
||
"post": str(pk),
|
||
"text": cleaned.get("text") or "",
|
||
}
|
||
files = {}
|
||
if cleaned.get("image"):
|
||
files["image"] = (
|
||
cleaned["image"].name,
|
||
cleaned["image"],
|
||
getattr(cleaned["image"], "content_type", None),
|
||
)
|
||
if cleaned.get("attachment"):
|
||
files["attachment"] = (
|
||
cleaned["attachment"].name,
|
||
cleaned["attachment"],
|
||
getattr(cleaned["attachment"], "content_type", None),
|
||
)
|
||
|
||
client = _api(request)
|
||
resp = _api_request_multipart(
|
||
client, "POST", API_COMMENTS, data=data, files=files or None
|
||
)
|
||
if resp.ok:
|
||
messages.success(request, "Комментарий добавлен!")
|
||
next_url = request.POST.get("next")
|
||
return (
|
||
redirect(next_url)
|
||
if next_url
|
||
else redirect("feed:post_detail", pk=pk)
|
||
)
|
||
# покажем ошибки API
|
||
if isinstance(resp.json, dict):
|
||
for field, errs in resp.json.items():
|
||
messages.error(request, f"{field}: {errs}")
|
||
else:
|
||
messages.error(request, f"Ошибка API: {resp.status}")
|
||
else:
|
||
messages.error(request, "Исправьте ошибки формы комментария.")
|
||
else:
|
||
form = CommentForm(initial={"post": pk})
|
||
|
||
return render(
|
||
request,
|
||
"feed/post_detail.html",
|
||
{"post": post, "comments": comments, "comment_form": form},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def comment_update(request, pk):
|
||
r = _api(request).get(f"{API_COMMENTS}{pk}/")
|
||
if not r.ok:
|
||
messages.error(request, "Комментарий недоступен.")
|
||
return redirect("feed:feed_list")
|
||
comment = r.json or {}
|
||
post_id = comment.get("post_id")
|
||
|
||
if request.method == "POST":
|
||
form = CommentForm(request.POST) # шлём только text
|
||
if form.is_valid():
|
||
payload = {"text": form.cleaned_data.get("text") or ""}
|
||
resp = _api(request).patch(f"{API_COMMENTS}{pk}/", json=payload)
|
||
if resp.ok:
|
||
messages.success(request, "Комментарий обновлён.")
|
||
return redirect(f"{reverse('feed:post_detail', args=[post_id])}#c{pk}")
|
||
messages.error(request, f"Ошибка API: {resp.status}")
|
||
else:
|
||
messages.error(request, "Исправьте ошибки формы.")
|
||
else:
|
||
form = CommentForm(initial={"text": comment.get("text", "")})
|
||
|
||
# для шаблона нужен пост (dict)
|
||
post = _api_get_post(request, post_id) if post_id else None
|
||
return render(
|
||
request,
|
||
"feed/comment_form.html",
|
||
{"form": form, "comment": comment, "post": post},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def comment_delete(request, pk):
|
||
r = _api(request).get(f"{API_COMMENTS}{pk}/")
|
||
if not r.ok:
|
||
messages.error(request, "Комментарий недоступен.")
|
||
return redirect("feed:feed_list")
|
||
comment = r.json or {}
|
||
post_id = comment.get("post_id")
|
||
|
||
if request.method == "POST":
|
||
resp = _api(request).delete(f"{API_COMMENTS}{pk}/")
|
||
if resp.ok:
|
||
messages.success(request, "Комментарий удалён.")
|
||
else:
|
||
messages.error(request, f"Ошибка удаления: {resp.status}")
|
||
return redirect("feed:post_detail", pk=post_id)
|
||
|
||
post = _api_get_post(request, post_id) if post_id else None
|
||
return render(
|
||
request, "feed/comment_confirm_delete.html", {"comment": comment, "post": post}
|
||
)
|
||
|
||
|
||
# ---------- Создание/редактирование/удаление поста ----------
|
||
|
||
|
||
@login_required
|
||
def post_create(request):
|
||
dept_id = request.GET.get("department")
|
||
force_type = request.GET.get("type")
|
||
|
||
# Определяем контекст (куда публикуем)
|
||
if dept_id:
|
||
context_type = TYPE_DEPARTMENT
|
||
elif force_type in {TYPE_COMPANY, TYPE_EMPLOYEE}:
|
||
context_type = force_type
|
||
else:
|
||
context_type = TYPE_COMPANY
|
||
|
||
cancel_url = (
|
||
request.GET.get("next")
|
||
or (reverse("employees:department_detail", args=[dept_id]) if dept_id else None)
|
||
or (
|
||
reverse("feed:feed_list")
|
||
if context_type == TYPE_COMPANY
|
||
else reverse("feed:employee_feed", args=[request.user.pk])
|
||
)
|
||
)
|
||
|
||
if request.method == "POST":
|
||
form = PostForm(request.POST, request.FILES)
|
||
if form.is_valid():
|
||
cleaned = form.cleaned_data
|
||
data = {"title": cleaned.get("title"), "body": cleaned.get("body")}
|
||
if context_type == TYPE_DEPARTMENT:
|
||
data["type"] = TYPE_DEPARTMENT
|
||
data["department"] = str(dept_id)
|
||
elif context_type == TYPE_COMPANY:
|
||
data["type"] = TYPE_COMPANY
|
||
else:
|
||
data["type"] = TYPE_EMPLOYEE # API не допустит, это ок.
|
||
|
||
files = {}
|
||
if cleaned.get("image"):
|
||
files["image"] = (
|
||
cleaned["image"].name,
|
||
cleaned["image"],
|
||
getattr(cleaned["image"], "content_type", None),
|
||
)
|
||
if cleaned.get("attachment"):
|
||
files["attachment"] = (
|
||
cleaned["attachment"].name,
|
||
cleaned["attachment"],
|
||
getattr(cleaned["attachment"], "content_type", None),
|
||
)
|
||
|
||
client = _api(request)
|
||
resp = _api_request_multipart(
|
||
client, "POST", API_POSTS, data=data, files=files or None
|
||
)
|
||
if resp.ok:
|
||
messages.success(request, "Публикация создана!")
|
||
return redirect(_post_back_url(resp.json or {}, request.user))
|
||
if isinstance(resp.json, dict):
|
||
for field, errs in resp.json.items():
|
||
messages.error(request, f"{field}: {errs}")
|
||
else:
|
||
messages.error(request, f"Ошибка создания: {resp.status}")
|
||
else:
|
||
messages.error(request, "Исправьте ошибки формы.")
|
||
else:
|
||
initial = {}
|
||
if context_type == TYPE_DEPARTMENT:
|
||
initial.update({"type": TYPE_DEPARTMENT})
|
||
elif context_type == TYPE_COMPANY:
|
||
initial.update({"type": TYPE_COMPANY, "department": None})
|
||
else:
|
||
initial.update({"type": TYPE_EMPLOYEE})
|
||
form = PostForm(initial=initial)
|
||
# прячем лишние поля (визуально)
|
||
if "type" in form.fields:
|
||
form.fields["type"].initial = initial["type"]
|
||
form.fields["type"].widget = forms.HiddenInput()
|
||
if context_type == TYPE_DEPARTMENT and "department" in form.fields:
|
||
form.fields["department"].initial = dept_id
|
||
form.fields["department"].widget = forms.HiddenInput()
|
||
else:
|
||
form.fields.pop("department", None)
|
||
|
||
return render(
|
||
request,
|
||
"feed/post_form.html",
|
||
{
|
||
"form": form,
|
||
"context_type": context_type,
|
||
"department": {"id": dept_id} if dept_id else None,
|
||
"cancel_url": cancel_url,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def post_update(request, pk):
|
||
post = _api_get_post(request, pk)
|
||
if not post:
|
||
return redirect("feed:feed_list")
|
||
|
||
# UI-проверка — бэкенд всё равно решит правами
|
||
# (синхронно считаем, см. примечание к _can_edit_post_locally)
|
||
# Можно просто пропустить этот шаг, положившись на 403 от API.
|
||
# if not sync_can_edit: messages.error(...)
|
||
|
||
cancel_url = request.GET.get("next") or _post_back_url(post, request.user)
|
||
|
||
if request.method == "POST":
|
||
form = PostForm(request.POST, request.FILES)
|
||
if form.is_valid():
|
||
cleaned = form.cleaned_data
|
||
data = {"title": cleaned.get("title"), "body": cleaned.get("body")}
|
||
files = {}
|
||
if cleaned.get("image"):
|
||
files["image"] = (
|
||
cleaned["image"].name,
|
||
cleaned["image"],
|
||
getattr(cleaned["image"], "content_type", None),
|
||
)
|
||
if cleaned.get("attachment"):
|
||
files["attachment"] = (
|
||
cleaned["attachment"].name,
|
||
cleaned["attachment"],
|
||
getattr(cleaned["attachment"], "content_type", None),
|
||
)
|
||
client = _api(request)
|
||
resp = _api_request_multipart(
|
||
client, "PATCH", f"{API_POSTS}{pk}/", data=data, files=files or None
|
||
)
|
||
if resp.ok:
|
||
messages.success(request, "Публикация обновлена!")
|
||
return redirect(_post_back_url(post, request.user))
|
||
if isinstance(resp.json, dict):
|
||
for field, errs in resp.json.items():
|
||
messages.error(request, f"{field}: {errs}")
|
||
else:
|
||
messages.error(request, f"Ошибка обновления: {resp.status}")
|
||
else:
|
||
messages.error(request, "Исправьте ошибки формы.")
|
||
else:
|
||
form = PostForm(
|
||
initial={
|
||
"type": post.get("type"),
|
||
"title": post.get("title"),
|
||
"body": post.get("body"),
|
||
"department": post.get("department_id"),
|
||
}
|
||
)
|
||
if "type" in form.fields:
|
||
form.fields["type"].widget = forms.HiddenInput()
|
||
if "department" in form.fields:
|
||
form.fields["department"].widget = forms.HiddenInput()
|
||
|
||
context_type = post.get("type")
|
||
department = (
|
||
{"id": post.get("department_id")}
|
||
if (context_type == TYPE_DEPARTMENT and post.get("department_id"))
|
||
else None
|
||
)
|
||
return render(
|
||
request,
|
||
"feed/post_form.html",
|
||
{
|
||
"form": form,
|
||
"context_type": context_type,
|
||
"department": department,
|
||
"cancel_url": cancel_url,
|
||
"is_update": True,
|
||
},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def post_delete(request, pk):
|
||
post = _api_get_post(request, pk)
|
||
if not post:
|
||
return redirect("feed:feed_list")
|
||
|
||
cancel_url = request.GET.get("next") or _post_back_url(post, request.user)
|
||
|
||
if request.method == "POST":
|
||
resp = _api(request).delete(f"{API_POSTS}{pk}/")
|
||
if resp.ok:
|
||
messages.success(request, "Публикация удалена.")
|
||
return redirect(_post_back_url(post, request.user))
|
||
messages.error(request, f"Ошибка удаления: {resp.status}")
|
||
|
||
return render(
|
||
request,
|
||
"feed/post_confirm_delete.html",
|
||
{"post": post, "cancel_url": cancel_url},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def pin_post(request, pk):
|
||
post = _api_get_post(request, pk)
|
||
if not post:
|
||
return redirect("feed:feed_list")
|
||
action = "unpin" if post.get("pinned") else "pin"
|
||
resp = _api(request).post(f"{API_POSTS}{pk}/{action}/")
|
||
if resp.ok:
|
||
messages.success(
|
||
request, "Новость закреплена!" if action == "pin" else "Новость откреплена!"
|
||
)
|
||
else:
|
||
messages.error(request, f"Операция недоступна: {resp.status}")
|
||
return redirect(_post_back_url(post, request.user))
|
||
|
||
|
||
# ---------- Лайки ----------
|
||
|
||
|
||
@login_required
|
||
def toggle_like(request, pk):
|
||
if request.method != "POST":
|
||
return redirect("feed:post_detail", pk=pk)
|
||
|
||
op = (request.POST.get("op") or "like").lower()
|
||
if op not in {"like", "unlike"}:
|
||
op = "like"
|
||
|
||
endpoint = "like" if op == "like" else "unlike"
|
||
resp = _api(request).post(f"{API_POSTS}{pk}/{endpoint}/")
|
||
|
||
if not resp.ok:
|
||
messages.error(request, f"Не удалось {op} публикацию: {resp.status}")
|
||
|
||
wants_json = (
|
||
"application/json" in request.headers.get("Accept", "")
|
||
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||
)
|
||
if wants_json:
|
||
data = resp.json or {}
|
||
return JsonResponse(
|
||
{
|
||
"ok": bool(resp.ok),
|
||
"post_id": pk,
|
||
"liked": data.get("liked"),
|
||
"likes": data.get("likes_count"),
|
||
}
|
||
)
|
||
|
||
next_url = (
|
||
request.POST.get("next")
|
||
or request.META.get("HTTP_REFERER")
|
||
or reverse("feed:post_detail", args=[pk])
|
||
)
|
||
return HttpResponseRedirect(next_url)
|