Corp/backend/feed/views_front.py

613 lines
22 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.

# 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)