"""
Модуль представлений (views.py) приложения core.
Содержит логику обработки веб-запросов, рендеринг шаблонов и управление API-ответами
для редактора кода, графической доски, профиля и панели администратора.
"""
import random
import json
import gzip
import os
import re
import html
from PIL import Image
import io
import base64
from django.core.mail import send_mail
from django.utils.encoding import escape_uri_path
from django.contrib.admin.views.decorators import staff_member_required
from django.utils import timezone
from django.db.models import Count, Max
from datetime import timedelta
from .models import Complaint, UserPunishment
from .models import Comment
from .models import PasswordResetCode, Profile
from django.contrib.auth import login, logout, update_session_auth_hash
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
from django.http import HttpResponse, JsonResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib import messages
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
from django.views.static import serve
from .models import Document
from .forms import RegisterForm
[документация]
def check_doc_access(request, doc):
"""
Проверяет права доступа текущего пользователя к указанному документу.
Защищает от IDOR, проверяя токены приглашения или права в базе данных.
:param request: Объект HTTP-запроса.
:param doc: Экземпляр модели Document.
:return: True, если доступ разрешен, иначе False.
:rtype: bool
"""
if request.user.is_authenticated:
return doc.user_has_access(request.user)
token_in_url = request.GET.get('token')
if token_in_url and doc.invite_token == token_in_url:
allowed = request.session.get('allowed_docs', [])
if doc.id not in allowed:
allowed.append(doc.id)
request.session['allowed_docs'] = allowed
request.session.modified = True
return True
if doc.id in request.session.get('allowed_docs', []):
return True
return False
[документация]
def home_view(request):
"""
Отображает главную страницу. Если пользователь авторизован,
выводит список его последних документов.
"""
docs = None
if request.user.is_authenticated:
docs = Document.objects.filter(owner=request.user).order_by("-updated_at")
return render(request, "core/index.html", {"documents": docs})
[документация]
def login_view(request):
"""
Отображает и обрабатывает форму авторизации пользователя.
"""
if request.user.is_authenticated: return redirect("home")
if request.method == "POST":
form = AuthenticationForm(data=request.POST)
if form.is_valid():
login(request, form.get_user())
return redirect("home")
else:
form = AuthenticationForm()
return render(request, "core/login.html", {"form": form})
[документация]
def register_view(request):
"""
Отображает и обрабатывает форму регистрации нового пользователя.
"""
if request.user.is_authenticated: return redirect("home")
if request.method == "POST":
form = RegisterForm(request.POST)
if form.is_valid():
user = User.objects.create_user(
username=form.cleaned_data['username'],
email=form.cleaned_data['email'],
password=form.cleaned_data['password']
)
login(request, user)
return redirect("home")
else:
for field, errors in form.errors.items():
for error in errors: messages.error(request, error)
else:
form = RegisterForm()
return render(request, "core/register.html", {"form": form})
[документация]
@login_required
def logout_view(request):
"""
Разлогинивает текущего пользователя.
"""
logout(request)
return redirect("login")
[документация]
@login_required
def create_project(request):
"""
Создает новый документ или доску на основе переданного типа (doc_type).
"""
if request.method == "POST":
title = request.POST.get("title", "Без названия")
doc_type = request.POST.get("doc_type", "text")
doc = Document.objects.create(owner=request.user, title=title, doc_type=doc_type)
if doc_type == 'paint': return redirect("paint_editor", doc_id=doc.id)
return redirect("editor", doc_id=doc.id)
return redirect("profile")
[документация]
def editor_view(request, doc_id):
"""
Отображает текстовый редактор кода для указанного документа.
"""
if int(doc_id) == 0:
if not request.user.is_authenticated: return redirect('login')
doc = Document.objects.create(owner=request.user, title="Новый текст", doc_type='text')
return redirect("editor", doc_id=doc.id)
doc = get_object_or_404(Document, id=doc_id)
if not check_doc_access(request, doc):
return HttpResponseForbidden("Нет доступа")
if not request.user.is_authenticated:
session_view_only = request.session.get(f'view_only_{doc.id}', False)
view_only = True
else:
view_only = not doc.can_edit_doc(request.user)
return render(request, "core/editor.html", {
"document": doc,
"can_edit": not view_only,
"content": doc.get_content(),
"username": request.user.username if request.user.is_authenticated else "Анонимный читатель",
"view_only": view_only,
"is_owner": request.user.is_authenticated and request.user == doc.owner,
})
[документация]
def paint_editor_view(request, doc_id):
"""
Отображает графический редактор (доску) для указанного документа.
"""
if int(doc_id) == 0:
if not request.user.is_authenticated: return redirect('login')
doc = Document.objects.create(owner=request.user, title="Новая доска", doc_type='paint')
return redirect("paint_editor", doc_id=doc.id)
doc = get_object_or_404(Document, id=doc_id)
if not check_doc_access(request, doc):
return HttpResponseForbidden("Нет доступа")
if not check_doc_access(request, doc):
return HttpResponseForbidden(
"У вас нет доступа к этому проекту. Попросите владельца прислать вам ссылку-приглашение.")
if not request.user.is_authenticated:
session_view_only = request.session.get(f'view_only_{doc.id}', False)
view_only = True
else:
view_only = not doc.can_edit_doc(request.user)
return render(request, "core/paint.html", {
"document": doc,
"snapshot": doc.get_content(),
"can_edit": not view_only,
"username": request.user.username if request.user.is_authenticated else "Анонимный читатель",
"view_only": view_only,
"is_owner": request.user.is_authenticated and request.user == doc.owner,
})
[документация]
@login_required
def paint_view(request):
"""Редирект на создание новой доски."""
return redirect('paint_editor', doc_id=0)
[документация]
@login_required
def save_compressed(request, doc_id):
"""
Сохраняет содержимое документа в сжатом виде из входящего JSON запроса.
Поддерживает текстовый контент и снимки (canvas/base64) доски.
"""
doc = get_object_or_404(Document, id=doc_id)
can_edit = False
if request.user.is_authenticated:
can_edit = doc.can_edit_doc(request.user)
if not can_edit:
allowed_ids = [int(x) for x in request.session.get('allowed_docs', [])]
is_in_session = int(doc_id) in allowed_ids
is_not_view_only = not request.session.get(f'view_only_{doc.id}', False)
can_edit = is_in_session and is_not_view_only
if not can_edit:
return JsonResponse({"status": "fail", "error": "Нет прав на сохранение"}, status=403)
try:
data = json.loads(request.body.decode("utf-8"))
if doc.doc_type == 'paint':
content_data = data.get("snapshot") or data.get("image")
else:
content_data = data.get("content")
if content_data is not None:
doc.set_content(content_data)
doc.save(update_fields=["content", "updated_at"])
return JsonResponse({"ok": True})
else:
return JsonResponse({"status": "fail", "error": "Данные пусты"}, status=400)
except Exception as e:
return JsonResponse({"ok": False, "error": "..."}, status=400)
[документация]
@login_required
def grant_edit_access(request, doc_id):
"""
Выдает права на редактирование документа указанному пользователю.
"""
doc = get_object_or_404(Document, id=doc_id)
if request.user != doc.owner:
if doc.viewers.filter(id=request.user.id).exists():
return HttpResponseForbidden("Пользователь с правами чтения не может выдавать редактирование")
if request.user in doc.viewers.all(): doc.viewers.remove(request.user)
doc.editors.add(request.user)
return redirect('paint_editor' if doc.doc_type == 'paint' else 'editor', doc_id=doc.id)
[документация]
@login_required
def grant_view_access(request, doc_id):
"""
Выдает права только на чтение документа.
"""
doc = get_object_or_404(Document, id=doc_id)
if request.user != doc.owner:
if request.user in doc.editors.all(): doc.editors.remove(request.user)
doc.viewers.add(request.user)
prefix = 'paint' if doc.doc_type == 'paint' else 'editor'
return redirect(f'/{prefix}/{doc.id}/?view_only=true')
[документация]
def join_document(request, token):
"""
Обрабатывает присоединение к документу по токену-приглашению.
"""
document = get_object_or_404(Document, invite_token=token)
view_only = request.GET.get('view_only') == 'true'
if request.user.is_authenticated:
if request.user != document.owner:
if view_only:
document.editors.remove(request.user)
document.viewers.add(request.user)
else:
document.viewers.remove(request.user)
document.editors.add(request.user)
else:
allowed = request.session.get('allowed_docs', [])
if document.id not in allowed:
allowed.append(document.id)
request.session['allowed_docs'] = allowed
request.session.modified = True
request.session[f'view_only_{document.id}'] = view_only
target_view = 'paint_editor' if document.doc_type == 'paint' else 'editor'
return redirect(f'/{target_view.replace("_editor","")}/{document.id}/?token={token}')
[документация]
@login_required
def profile_view(request):
"""
Отображает профиль пользователя со статистикой и документами.
"""
user = request.user
documents = Document.objects.filter(owner=user).order_by('-created_at')
comments = Comment.objects.filter(receiver=user).order_by('-created_at')
return render(request, 'core/profile.html', {
'documents': documents,
'total_projects': documents.count(),
'text_files': documents.filter(doc_type='text').count(),
'boards': documents.filter(doc_type='paint').count(),
'comments': comments,
'user': user,
})
[документация]
@login_required
def download_compressed(request, doc_id):
"""
Скачивает документ в сыром формате gzip.
"""
doc = get_object_or_404(Document, id=doc_id)
if not check_doc_access(request, doc):
return HttpResponseForbidden("Нет доступа")
payload = gzip.compress(doc.get_content().encode("utf-8"))
response = HttpResponse(payload, content_type='application/gzip')
response['Content-Disposition'] = 'attachment; filename="document.gz"'
return response
# --- ЭКСПОРТ ---
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import simpleSplit, ImageReader
from reportlab.pdfgen import canvas
from bs4 import BeautifulSoup
def _register_arial_font():
"""
Регистрирует шрифт Arial для генерации PDF.
"""
font_path = r"C:\Windows\Fonts\arial.ttf"
font_name = "Arial"
if not os.path.exists(font_path):
raise FileNotFoundError(f"Не найден шрифт: {font_path}")
if font_name not in pdfmetrics.getRegisteredFontNames():
pdfmetrics.registerFont(TTFont(font_name, font_path))
return font_name
def _html_to_plain_text(content: str) -> str:
"""
Очищает HTML-строку от скриптов и тегов, конвертируя ее в простой текст.
"""
if not content:
return ""
content = re.sub(r"(?is)<(script|style).*?>.*?(</\1>)", "", content)
soup = BeautifulSoup(content, "html.parser")
text = soup.get_text("\n")
text = html.unescape(text)
return text.strip()
def _draw_wrapped_text(pdf, text, font_name="Arial", font_size=12, margin=50):
"""
Вспомогательная функция для отрисовки длинного текста в PDF с учетом переносов.
"""
page_width, page_height = A4
usable_width = page_width - margin * 2
y = page_height - margin
pdf.setFont(font_name, font_size)
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
y -= font_size * 0.8
if y < margin:
pdf.showPage()
pdf.setFont(font_name, font_size)
y = page_height - margin
continue
wrapped_lines = simpleSplit(line, font_name, font_size, usable_width)
for wrapped in wrapped_lines:
if y < margin:
pdf.showPage()
pdf.setFont(font_name, font_size)
y = page_height - margin
pdf.drawString(margin, y, wrapped)
y -= font_size * 1.35
[документация]
@login_required
def download_document(request, doc_id, file_format='pdf'):
"""
Обрабатывает запрос на скачивание документа в выбранном формате (PDF, PNG, JPG).
"""
doc = get_object_or_404(Document, id=doc_id)
if not check_doc_access(request, doc):
return HttpResponseForbidden("Нет доступа")
content = doc.get_content()
if file_format == "pdf":
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{doc.title}.pdf"'
pdf = canvas.Canvas(response, pagesize=A4)
font_name = _register_arial_font()
if doc.doc_type == "paint" or content.startswith("data:image"):
if not content.startswith("data:image"):
return HttpResponse("Документ не является графической доской", status=400)
header, data = content.split(",", 1)
image_data = base64.b64decode(data)
image = Image.open(io.BytesIO(image_data))
if image.mode not in ("RGB", "L"):
image = image.convert("RGB")
page_width, page_height = A4
margin = 30
max_w = page_width - margin * 2
max_h = page_height - margin * 2
scale = min(max_w / image.width, max_h / image.height)
draw_w = image.width * scale
draw_h = image.height * scale
x = (page_width - draw_w) / 2
y = (page_height - draw_h) / 2
img_buffer = io.BytesIO()
image.save(img_buffer, format="PNG")
img_buffer.seek(0)
pdf.drawImage(
ImageReader(img_buffer),
x, y, width=draw_w, height=draw_h,
preserveAspectRatio=True, mask='auto'
)
else:
plain_text = _html_to_plain_text(content)
if not plain_text:
plain_text = "Пустой документ"
_draw_wrapped_text(pdf, plain_text, font_name=font_name, font_size=12, margin=50)
pdf.save()
return response
elif file_format == "png":
if not content.startswith("data:image"):
return HttpResponse("Документ не является графической доской", status=400)
header, data = content.split(",", 1)
image_data = base64.b64decode(data)
response = HttpResponse(content_type='image/png')
response['Content-Disposition'] = f'attachment; filename="{escape_uri_path(doc.title)}.png"'
response.write(image_data)
return response
elif file_format in ["jpg", "jpeg"]:
if not content.startswith("data:image"):
return HttpResponse("Документ не является графической доской", status=400)
header, data = content.split(",", 1)
image_data = base64.b64decode(data)
image = Image.open(io.BytesIO(image_data)).convert("RGB")
jpg_io = io.BytesIO()
image.save(jpg_io, format="JPEG")
response = HttpResponse(content_type='image/jpeg')
response['Content-Disposition'] = f'attachment; filename="{escape_uri_path(doc.title)}.jpg"'
response.write(jpg_io.getvalue())
return response
return HttpResponse("Неподдерживаемый формат", status=400)
[документация]
def docs_view(request):
"""
Возвращает сгенерированную страницу с документацией кода Sphinx.
"""
docs_path = os.path.join(settings.BASE_DIR, 'docs', 'build', 'index.html')
if os.path.exists(docs_path):
with open(docs_path, 'r', encoding='utf-8') as f:
return HttpResponse(f.read())
return HttpResponse(
"Документация еще не собрана. Запустите 'python -m sphinx.cmd.build -b html source build' в папке docs.",
status=404)
[документация]
def document_members(request, doc_id):
"""
Возвращает JSON со списком участников, имеющих доступ к документу.
"""
doc = get_object_or_404(Document, id=doc_id)
if not request.user.is_authenticated or not doc.user_has_access(request.user):
return HttpResponseForbidden("Нет доступа")
return JsonResponse({
"owner": doc.owner.username,
"owner_id": doc.owner.id,
"editors": [u.username for u in doc.editors.all()],
"viewers": [u.username for u in doc.viewers.all()],
"editors_data": [{"id": u.id, "username": u.username} for u in doc.editors.all()],
"viewers_data": [{"id": u.id, "username": u.username} for u in doc.viewers.all()],
})
[документация]
def change_access(request, doc_id, user_id, role):
"""
Изменяет уровень доступа пользователя к конкретному документу (только для владельцев).
"""
doc = get_object_or_404(Document, id=doc_id)
if request.user != doc.owner:
return HttpResponseForbidden("Только владелец")
user = get_object_or_404(User, id=user_id)
doc.editors.remove(user)
doc.viewers.remove(user)
if role == "viewer":
doc.viewers.add(user)
elif role == "editor":
doc.editors.add(user)
elif role == "revoke":
pass
else:
return HttpResponse("Неизвестная роль", status=400)
return HttpResponse("Доступ обновлён")
[документация]
@login_required
def settings_view(request):
"""
Отображает и обрабатывает форму настроек пользователя (изменение почты, имени и пароля).
"""
user = request.user
if request.method == "POST":
if request.POST.get("update_profile"):
username = request.POST.get("username", "").strip()
email = request.POST.get("email", "").strip()
if username:
user.username = username
if email:
user.email = email
user.save()
update_session_auth_hash(request, user)
messages.success(request, "Профиль успешно обновлён")
return redirect("settings")
if "change_password" in request.POST:
old_password = request.POST.get("old_password")
new_password = request.POST.get("new_password")
confirm_password = request.POST.get("confirm_password")
if not old_password or not new_password or not confirm_password:
messages.error(request, "Все поля пароля должны быть заполнены")
return redirect("settings")
if not user.check_password(old_password):
messages.error(request, "Неверный текущий пароль")
return redirect("settings")
if new_password != confirm_password:
messages.error(request, "Новый пароль и подтверждение не совпадают")
return redirect("settings")
user.set_password(new_password)
user.save()
update_session_auth_hash(request, user)
messages.success(request, "Пароль успешно изменён")
return redirect("settings")
return render(request, "core/settings.html", {'user': request.user})
[документация]
@login_required
def delete_account(request):
"""
Удаляет аккаунт текущего пользователя.
"""
if request.method == "POST":
user = request.user
logout(request)
user.delete()
return redirect("home")
return redirect("settings")
[документация]
def serve_docs(request, path):
"""
Обслуживает статические файлы сгенерированной документации Sphinx.
"""
docs_path = os.path.join(settings.BASE_DIR, 'docs', 'build')
if not path:
path = 'index.html'
if not os.path.exists(docs_path):
return HttpResponse("Папка docs/build не найдена. Собери документацию!", status=404)
return serve(request, path, document_root=docs_path)
[документация]
def password_reset_request(request):
"""
Запрос на восстановление пароля (генерация 6-значного кода и отправка на почту).
"""
if request.method == 'POST':
email = request.POST.get('email')
user = User.objects.filter(email=email).first()
if user:
code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
PasswordResetCode.objects.create(user=user, code=code)
send_mail(
'Код восстановления пароля',
f'Ваш код подтверждения: {code}',
'myaxora-noreply@mail.ru',
[email],
fail_silently=False,
)
request.session['reset_email'] = email
return redirect('password_reset_verify')
return render(request, 'core/password_reset.html')
[документация]
def password_reset_verify(request):
"""
Подтверждение кода сброса пароля и установка нового.
"""
email = request.session.get('reset_email')
if not email:
return redirect('password_reset_request')
if request.method == 'POST':
code_input = request.POST.get('code')
new_password = request.POST.get('password')
user = User.objects.filter(email=email).first()
reset_obj = PasswordResetCode.objects.filter(user=user, code=code_input).last()
if reset_obj and reset_obj.is_valid():
user.set_password(new_password)
user.save()
reset_obj.delete()
del request.session['reset_email']
return redirect('login')
else:
return render(request, 'core/password_reset_verify.html', {'error': 'Неверный код или срок действия истек'})
return render(request, 'core/password_reset_verify.html')
[документация]
@login_required
def change_avatar_view(request):
"""
Позволяет пользователю изменить аватар профиля.
"""
avatars = [
"/static/core/images/profile/penguin.png",
"/static/core/images/profile/cat.png",
"/static/core/images/profile/fox.png",
"/static/core/images/profile/owl.png",
"/static/core/images/profile/rabbit.png",
"/static/core/images/profile/bear.png",
"/static/core/images/profile/dog.png",
"/static/core/images/profile/lion.png",
"/static/core/images/profile/frog.png",
"/static/core/images/profile/dolphin.png",
"/static/core/images/profile/shark.png",
"/static/core/images/profile/panda.png",
]
if request.method == "POST":
selected_avatar = request.POST.get("avatar")
if selected_avatar in avatars:
profile, created = Profile.objects.get_or_create(user=request.user)
profile.avatar = selected_avatar
profile.save()
messages.success(request, "Аватар успешно изменён!")
return redirect("profile")
try:
current_avatar = request.user.profile.avatar
except Profile.DoesNotExist:
current_avatar = "/static/core/images/profile/penguin.png"
Profile.objects.create(user=request.user)
return render(request, "core/change_avatar.html", {
"current_avatar": current_avatar,
"user": request.user
})
[документация]
@login_required
def punish_user(request, user_id, action):
"""
Панель администратора: Управление наказаниями пользователей (бан, мут, разбан).
"""
if not request.user.is_superuser:
return HttpResponseForbidden("Только для суперпользователя")
user = get_object_or_404(User, id=user_id)
punishment, _ = UserPunishment.objects.get_or_create(user=user)
if action == "mute":
minutes = int(request.POST.get("minutes", 60))
reason = request.POST.get("reason", "").strip()
punishment.is_banned = False
punishment.banned_at = None
punishment.ban_reason = ""
punishment.muted_at = timezone.now()
punishment.muted_until = timezone.now() + timedelta(minutes=minutes)
punishment.mute_reason = reason
user.is_active = True
elif action == "unmute":
punishment.muted_until = None
punishment.muted_at = None
punishment.mute_reason = ""
elif action == "ban":
reason = request.POST.get("reason", "").strip()
punishment.is_banned = True
punishment.banned_at = timezone.now()
punishment.ban_reason = reason
punishment.muted_until = None
punishment.muted_at = None
punishment.mute_reason = ""
user.is_active = True
elif action == "unban":
punishment.is_banned = False
punishment.banned_at = None
punishment.ban_reason = ""
else:
return HttpResponse("Неизвестное действие", status=400)
punishment.save()
user.save()
return redirect("admin_complaints")
[документация]
@login_required
def create_complaint(request, comment_id):
"""
Создает жалобу на указанный комментарий от текущего пользователя.
"""
if request.method != "POST":
return JsonResponse({"ok": False}, status=400)
comment = get_object_or_404(Comment, id=comment_id)
if comment.sender == request.user:
return JsonResponse({"ok": False, "error": "Нельзя жаловаться на себя"})
if request.user != comment.document.owner:
return HttpResponseForbidden("Нет доступа")
reason = request.POST.get("reason", "").strip()
if not reason:
return JsonResponse({"ok": False, "error": "Укажите причину"})
already_exists = Complaint.objects.filter(comment=comment, reporter=request.user).exists()
if already_exists:
return JsonResponse({"ok": False, "error": "Вы уже пожаловались"})
Complaint.objects.create(
comment=comment,
reporter=request.user,
reported_user=comment.sender,
reason=reason
)
return JsonResponse({"ok": True})
[документация]
@staff_member_required
def admin_complaints(request):
"""
Админка: Отображение общего списка жалоб на комментарии.
"""
complaints = Complaint.objects.select_related(
"reported_user",
"comment"
).annotate(
total_reports=Count("reported_user__complaints_received")
).order_by("-created_at")
return render(request, "core/admin_complaints.html", {
"complaints": complaints
})
[документация]
@staff_member_required
def ban_user(request, user_id):
"""
Админка: Быстрый бан пользователя.
"""
user = get_object_or_404(User, id=user_id)
punishment, _ = UserPunishment.objects.get_or_create(user=user)
punishment.is_banned = True
punishment.muted_until = None
punishment.save()
user.is_active = False
user.save()
return redirect("admin_complaints")
[документация]
@staff_member_required
def mute_user(request, user_id):
"""
Админка: Быстрый мут пользователя на 15 дней.
"""
user = get_object_or_404(User, id=user_id)
punishment, _ = UserPunishment.objects.get_or_create(user=user)
punishment.muted_until = timezone.now() + timedelta(days=15)
punishment.save()
return redirect("admin_complaints")
[документация]
@staff_member_required
def complaints_view(request):
"""
Админка: Отображает пользователей, отсортированных по количеству жалоб.
"""
users = (
User.objects
.filter(reports__isnull=False)
.annotate(
reports_count=Count("reports"),
last_report=Max("reports__created_at")
)
.order_by("-reports_count")
)
return render(request, "core/complaints.html", {
"users": users
})
[документация]
@user_passes_test(lambda u: u.is_superuser)
def admin_reports(request):
"""
Суперпользователь: Просмотр репортов на пользователей.
"""
users = (
User.objects
.filter(reports__isnull=False)
.annotate(
reports_count=Count("reports"),
last_report=Max("reports__created_at")
)
.order_by("-reports_count")
)
return render(request, "core/admin_reports.html", {
"users": users
})
[документация]
@user_passes_test(lambda u: u.is_superuser)
def admin_users(request):
"""
Суперпользователь: Просмотр списка всех пользователей.
"""
users = User.objects.all().select_related("useractivity")
return render(request, "core/admin_users.html", {
"users": users
})
[документация]
@login_required
def my_status(request):
"""
Возвращает текущий статус наказаний авторизованного пользователя.
"""
punishment, _ = UserPunishment.objects.get_or_create(user=request.user)
return JsonResponse({
"banned": punishment.is_banned,
"ban_reason": punishment.ban_reason,
"banned_at": punishment.banned_at.strftime("%d.%m.%Y %H:%M") if punishment.banned_at else None,
"muted": punishment.is_muted(),
"mute_reason": punishment.mute_reason,
"muted_until": punishment.muted_until.strftime("%d.%m.%Y %H:%M") if punishment.muted_until else None,
})
[документация]
@staff_member_required
def admin_punishments(request):
"""
Админка: История выданных наказаний (банов и мутов).
"""
punishments = UserPunishment.objects.select_related("user").order_by("-banned_at", "-muted_at")
return render(request, "core/admin_punishments.html", {
"punishments": punishments
})