Corp/backend/feed/views.py
Игорь Надеин 5af1f9ec91 feat(api): регистрация/верификация через API, whitelist в middleware, iOS-экран подтверждения выхода
**Что сделано**

* **API**

  * Добавлен `POST /api/v1/auth/register/` — создание пользователя, генерация кода и письмо (повторная отправка, если email есть, но не подтверждён).
  * `POST /api/v1/auth/verify-email/` возвращает `user_id` при успехе.
  * `POST /api/v1/auth/resend-email/` — без изменений по контракту.
* **Фронт (views\_front)**

  * `RegisterView.form_valid` теперь вызывает API `auth/register` (без прямой работы с ORM).
  * `EmailVerifyView.post` переводён на API `auth/verify-email` + автологин при успехе.
  * Повторная отправка кода (`resend_email`) уже через API, добавлена защита от частых запросов по сессии.
  * Исправлена сериализация телефона (E.164/строка) при отправке в API.
* **Клиент API**

  * Новый модуль `employees/api_client.py`: безопасная сериализация payload, удобные ошибки (`api_bad_json status=… body=…`) и абсолютные URL через `request.build_absolute_uri`.
* **Middleware**

  * `AuthRequiredMiddleware` теперь **пропускает**: `/api/v1/**`, статику/медиа, страницы логина/регистрации/сброса пароля, админ-логин и `OPTIONS`. Остальное для анонима — редирект на `login`.
* **Аутентификация/выход**

  * Страница подтверждения выхода (в стиле iOS bottom sheet) + `ConfirmLogoutView`. Пункт «Выйти» ведёт на GET `/auth/logout/`, далее POST подтверждения.
  * Шаблон выхода принимает `next` из контекста (без обращений к `request.GET.next` в шаблоне).
* **UX/мелочи**

  * Устранены 500 при отсутствии `password_reset` — добавлены/учтены URL’ы потока сброса (или безопасные ссылки).
  * Уточнены сообщения об ошибках при регистрации/верификации.

**Как проверить**

1. Аноним может вызвать:

   * `POST /api/v1/auth/register/` → `201 {"ok":true}` / `200 {"ok":true,"resent":true}` / `400 {"error":"email_taken"}`.
   * `POST /api/v1/auth/verify-email/` с корректным кодом → `200 {"ok":true,"user_id":...}`.
   * `POST /api/v1/auth/resend-email/` → `200 {"ok":true}`.
2. UI:

   * Регистрация в форме → обращение к API, письмо с кодом, переход на ввод кода.
   * Ввод кода → API, автологин, редирект в ленту.
   * «Выйти» в меню → страница подтверждения → POST → сообщение «Вы вышли…» и редирект на логин/`next`.
3. Проверить, что `/api/v1/**` не редиректится на логин (middleware whitelist работает).
4. Телефон в форме не ломает JSON (нет `TypeError: PhoneNumber is not JSON serializable`).

**Миграции**: нет.
2025-08-20 17:49:38 +03:00

429 lines
16 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.

from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import BooleanField, Count, Exists, F, OuterRef, Prefetch, Value
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from employees.models import Department, Employee, EmployeeDepartment
from .constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
from .forms import CommentForm, PostForm
from .models import Comment, Post, PostLike
# ---------- Общие утилиты ----------
def _feed_queryset(base_qs, user):
"""
Базовый queryset для ленты:
- is_liked: отмечено ли текущим пользователем
- comments_count: количество комментариев
- last_comment_list: последний комментарий (to_attr)
"""
if user.is_authenticated:
is_liked_expr = Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user))
else:
is_liked_expr = Value(False, output_field=BooleanField())
last_comment_qs = (
Comment.objects.select_related("author")
.order_by("-created_at")[:1]
)
return (
base_qs
.select_related("author", "department")
.annotate(
is_liked=is_liked_expr,
comments_count=Count("comments"),
)
.prefetch_related(
Prefetch("comments", queryset=last_comment_qs, to_attr="last_comment_list")
)
.order_by("-pinned", "-created_at")
)
def is_department_head(user, department: Department) -> bool:
return bool(user and (user.is_staff or (department and department.head_id == user.id)))
def can_edit_post(user, post: Post) -> bool:
if not user or not user.is_authenticated:
return False
if user.is_staff:
return True
if post.type == TYPE_COMPANY:
return False # только staff
if post.type == TYPE_DEPARTMENT:
return post.author_id == user.id or is_department_head(user, post.department)
return post.author_id == user.id # личная публикация
def back_url_for_post(post: Post, user) -> str:
if post.type == TYPE_COMPANY:
return reverse("feed:feed_list")
if post.type == TYPE_DEPARTMENT and post.department_id:
return reverse("employees:department_detail", args=[post.department_id])
return reverse(
"feed:employee_feed",
args=[user.pk if user and user.is_authenticated else post.author_id],
)
# ---------- Ленты ----------
def feed_list(request):
"""
Лента компании (доступна анонимам).
Возвращаем posts с аннотациями (is_liked, comments_count, last_comment_list).
"""
posts = _feed_queryset(Post.objects.filter(type=TYPE_COMPANY), request.user)
# «Новые сотрудники»: верхние 10% активных, но минимум 1 (как было)
active_employees = sorted(
(emp for emp in Employee.objects.all() if emp.is_actually_active),
key=lambda e: e.created_at,
reverse=True,
)
new_count = max(1, int(len(active_employees) * 0.1))
new_employees = Employee.objects.get_active()[:new_count]
return render(
request,
"feed/feed_list.html",
{"posts": posts, "new_employees": new_employees},
)
@login_required
def department_feed(request, pk):
"""Историческая страница ленты отдела (основная — employees:department_detail)."""
department = get_object_or_404(Department, pk=pk)
posts = _feed_queryset(
Post.objects.filter(type=TYPE_DEPARTMENT, department=department),
request.user,
)
emp_links = (
EmployeeDepartment.objects.filter(department=department, is_active=True)
.select_related("employee")
.order_by("employee__last_name", "employee__first_name")
)
return render(
request,
"feed/feed_list.html",
{
"department": department,
"posts": posts,
"new_employees": getattr(department, "new_employees", []),
"emp_links": emp_links,
},
)
@login_required
def employee_feed(request, pk):
"""Лента конкретного сотрудника."""
employee = get_object_or_404(Employee, pk=pk)
posts = _feed_queryset(
Post.objects.filter(type=TYPE_EMPLOYEE, author=employee),
request.user,
)
return render(
request,
"feed/feed_list.html",
{"employee": employee, "posts": posts},
)
# ---------- Детальная + комментарии ----------
@login_required
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
comments = post.comments.select_related("author")
if request.method == "POST":
# инлайн-форма из ленты также постится сюда
text = request.POST.get("text")
if text:
comment_form = CommentForm(request.POST)
if comment_form.is_valid():
comment = comment_form.save(commit=False)
comment.author = request.user
comment.post = post
comment.save()
messages.success(request, "Комментарий добавлен!")
next_url = request.POST.get("next")
if next_url:
return redirect(next_url)
return redirect("feed:post_detail", pk=pk)
else:
comment_form = CommentForm(request.POST)
if comment_form.is_valid():
comment = comment_form.save(commit=False)
comment.author = request.user
comment.post = post
comment.save()
messages.success(request, "Комментарий добавлен!")
return redirect("feed:post_detail", pk=pk)
else:
comment_form = CommentForm()
return render(
request,
"feed/post_detail.html",
{"post": post, "comments": comments, "comment_form": comment_form},
)
@login_required
def comment_update(request, pk):
"""Редактирование комментария (автор или staff)."""
comment = get_object_or_404(Comment, pk=pk)
if not (request.user.is_staff or comment.author_id == request.user.id):
messages.error(request, "У вас нет прав для редактирования этого комментария.")
return redirect("feed:post_detail", pk=comment.post_id)
if request.method == "POST":
form = CommentForm(request.POST, instance=comment)
if form.is_valid():
form.save()
messages.success(request, "Комментарий обновлён.")
return redirect(f"{reverse('feed:post_detail', args=[comment.post_id])}#c{comment.pk}")
else:
form = CommentForm(instance=comment)
return render(
request,
"feed/comment_form.html",
{"form": form, "comment": comment, "post": comment.post},
)
@login_required
def comment_delete(request, pk):
"""Удаление комментария (автор или staff; подтверждение через GET-страницу)."""
comment = get_object_or_404(Comment, pk=pk)
if not (request.user.is_staff or comment.author_id == request.user.id):
messages.error(request, "У вас нет прав для удаления этого комментария.")
return redirect("feed:post_detail", pk=comment.post_id)
if request.method == "POST":
post_id = comment.post_id
comment.delete()
messages.success(request, "Комментарий удалён.")
return redirect("feed:post_detail", pk=post_id)
return render(
request,
"feed/comment_confirm_delete.html",
{"comment": comment, "post": comment.post},
)
# ---------- Создание/редактирование/удаление поста ----------
@login_required
def post_create(request):
"""
Универсальная форма создания поста.
?department=<id> -> пост отдела
?type=company -> новость компании
?type=employee -> личная публикация
По умолчанию — company.
"""
dept_id = request.GET.get("department")
force_type = request.GET.get("type")
department = get_object_or_404(Department, pk=dept_id) if dept_id else None
if department:
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=[department.pk]) if department 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():
post = form.save(commit=False)
post.author = request.user
if context_type == TYPE_DEPARTMENT:
post.type = TYPE_DEPARTMENT
post.department = department
elif context_type == TYPE_COMPANY:
post.type = TYPE_COMPANY
post.department = None
else:
post.type = TYPE_EMPLOYEE
post.department = None
if post.type == TYPE_DEPARTMENT and not post.department:
messages.error(request, "Для новостей отдела отдел обязателен.")
return render(
request,
"feed/post_form.html",
{"form": form, "context_type": context_type, "department": department, "cancel_url": cancel_url},
)
post.save()
messages.success(request, "Публикация создана!")
return redirect(back_url_for_post(post, request.user))
else:
initial = {}
if context_type == TYPE_DEPARTMENT:
initial.update({"type": TYPE_DEPARTMENT, "department": 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 = department.pk
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": department, "cancel_url": cancel_url},
)
@login_required
def post_update(request, pk):
post = get_object_or_404(Post, pk=pk)
if not can_edit_post(request.user, post):
messages.error(request, "Нет прав на редактирование этой публикации.")
return redirect("feed:post_detail", pk=pk)
cancel_url = request.GET.get("next") or back_url_for_post(post, request.user)
if request.method == "POST":
form = PostForm(request.POST, request.FILES, instance=post)
if form.is_valid():
edited = form.save(commit=False)
edited.type = post.type
edited.department = post.department
edited.author = post.author
edited.save()
messages.success(request, "Публикация обновлена!")
return redirect(back_url_for_post(post, request.user))
else:
form = PostForm(instance=post)
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.type
department = post.department if post.type == TYPE_DEPARTMENT else None
return render(
request,
"feed/post_form.html",
{"form": form, "context_type": context_type, "department": department, "cancel_url": cancel_url},
)
@login_required
def post_delete(request, pk):
post = get_object_or_404(Post, pk=pk)
if not can_edit_post(request.user, post):
messages.error(request, "Нет прав на удаление этой публикации.")
return redirect("feed:post_detail", pk=pk)
cancel_url = request.GET.get("next") or back_url_for_post(post, request.user)
if request.method == "POST":
back_url = back_url_for_post(post, request.user)
post.delete()
messages.success(request, "Публикация удалена.")
return redirect(back_url)
return render(
request,
"feed/post_confirm_delete.html",
{"post": post, "cancel_url": cancel_url},
)
@login_required
def pin_post(request, pk):
post = get_object_or_404(Post, pk=pk)
if post.type == TYPE_COMPANY and not request.user.is_staff:
messages.error(request, "Только администратор может закреплять новости компании.")
return redirect("feed:post_detail", pk=pk)
if post.type == TYPE_DEPARTMENT and not is_department_head(request.user, post.department):
messages.error(request, "Только руководитель отдела может закреплять новости отдела.")
return redirect("feed:post_detail", pk=pk)
post.pinned = not post.pinned
post.save()
messages.success(request, "Новость закреплена!" if post.pinned else "Новость откреплена!")
return redirect(back_url_for_post(post, request.user))
# ---------- Лайки ----------
@login_required
def toggle_like(request, pk):
"""
Переключение лайка. При AJAX/Fetch отдаёт JSON, иначе редиректит «назад».
Счётчик поддерживаем атомарно через F().
"""
if request.method != "POST":
return redirect("feed:post_detail", pk=pk)
post = get_object_or_404(Post, pk=pk)
with transaction.atomic():
like, created = PostLike.objects.select_for_update().get_or_create(
post=post, user=request.user
)
if created:
Post.objects.filter(pk=post.pk).update(likes_count=F("likes_count") + 1)
is_liked = True
else:
like.delete()
Post.objects.filter(pk=post.pk, likes_count__gt=0).update(
likes_count=F("likes_count") - 1
)
is_liked = False
post.refresh_from_db(fields=["likes_count"])
wants_json = (
"application/json" in request.headers.get("Accept", "")
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
)
if wants_json:
return JsonResponse(
{"ok": True, "post_id": post.pk, "is_liked": is_liked, "likes": post.likes_count}
)
next_url = (
request.POST.get("next")
or request.META.get("HTTP_REFERER")
or reverse("feed:post_detail", args=[post.pk])
)
return HttpResponseRedirect(next_url)