Files
ACORE/apps/feed/views.py
T
lokses 35f2091e0b Модуль ленты (feed): посты, комментарии, TinyMCE, санитизация
- Post (категории, важное/закреплено/опубликовано, автор) + PostComment;
  тело — HTML из TinyMCE, санитизация nh3 в save() (XSS), загрузка картинок
  отдельным endpoint с проверкой содержимого (data:-URI вырезаются)
- Лента с полным выводом постов, фильтры, сворачивание длинных;
  детальная страница с комментариями; edit — автор/staff, delete — автор
- Публикация постов в каналы Mattermost (mattermost_targets), сигналы
- Тесты (40): публикация/черновики, права, санитизация, картинки,
  выравнивание, комментарии, лента/дашборд

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:31:20 +03:00

234 lines
8.9 KiB
Python

import uuid
from pathlib import Path
from django.contrib.auth.decorators import login_required
from django.core.files.storage import default_storage
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.views.decorators.http import require_POST
from apps.feed.models import Post, PostComment
from core.utils.htmx import render_page
from core.utils.permissions import is_owner, is_owner_or_staff
FILTERS = {'important', 'general', 'hr', 'it', 'network'}
ALLOWED_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5 МБ
def can_edit_post(user, post) -> bool:
"""Редактировать может администратор или автор поста"""
return is_owner_or_staff(user, post, 'author_id')
def can_delete_post(user, post) -> bool:
"""Удалять пост из портала может только автор (админ — через админку)"""
return is_owner(user, post, 'author_id')
def _post_content_valid(title: str, body: str) -> bool:
"""Тело — HTML из редактора: пустая разметка (в т.ч. &nbsp;) не считается"""
from html import unescape
from django.utils.html import strip_tags
has_content = bool(unescape(strip_tags(body)).strip()) or '<img' in body
return bool(title) and has_content
def _valid_mattermost_targets(raw_values: list[str]) -> list[str]:
"""Чекбокси mattermost_targets на формі — залишаємо тільки відомі коди команд."""
from apps.feed.services import TARGET_TEAMS
return [v for v in raw_values if v in TARGET_TEAMS]
def _target_teams_context() -> list[tuple[str, str]]:
from apps.feed.services import TARGET_TEAMS
return list(TARGET_TEAMS.items())
@login_required
def news_view(request):
"""Лента постов: закреплённые сверху, фильтры по категориям, создание для staff"""
from django.db.models import Count
form_error = ''
if request.method == 'POST' and request.user.is_staff:
title = request.POST.get('title', '').strip()
body = request.POST.get('body', '').strip()
if _post_content_valid(title, body):
category = request.POST.get('category', 'general')
targets = _valid_mattermost_targets(request.POST.getlist('mattermost_targets'))
post = Post.objects.create(
title=title,
body=body,
category=category if category in dict(Post.CATEGORY_CHOICES) else 'general',
is_important=request.POST.get('is_important') == 'on',
is_pinned=request.POST.get('is_pinned') == 'on',
author=request.user,
mattermost_targets=targets,
)
if targets:
from apps.feed.services import publish_post_to_mattermost
publish_post_to_mattermost(post)
else:
form_error = 'Публікацію не збережено: заповніть заголовок і текст.'
active_filter = request.GET.get('category', '')
if active_filter not in FILTERS:
active_filter = ''
posts = (
Post.objects.filter(is_published=True)
.select_related('author')
.annotate(num_comments=Count('comments'))
)
if active_filter == 'important':
posts = posts.filter(is_important=True)
elif active_filter:
posts = posts.filter(category=active_filter)
posts = list(posts[:50])
for post in posts:
post.can_edit = can_edit_post(request.user, post)
post.can_delete = can_delete_post(request.user, post)
pinned = [p for p in posts if p.is_pinned]
regular = [p for p in posts if not p.is_pinned]
context = {
'pinned_posts': pinned,
'posts': regular,
'active_filter': active_filter,
'categories': Post.CATEGORY_CHOICES,
'form_error': form_error,
'mattermost_target_teams': _target_teams_context(),
}
return render_page(request, 'news.html', context)
@login_required
@require_POST
def upload_image_view(request):
"""Загрузка изображений из TinyMCE: сохраняем в media, возвращаем URL.
Санитизатор пропускает только обычные src — data:-URI вырезаются."""
if not request.user.is_staff:
return JsonResponse({'error': 'Недостатньо прав'}, status=403)
file = request.FILES.get('file')
if not file:
return JsonResponse({'error': 'Файл не передано'}, status=400)
if file.size > MAX_IMAGE_SIZE:
return JsonResponse({'error': 'Файл завеликий (макс. 5 МБ)'}, status=400)
ext = Path(file.name).suffix.lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
return JsonResponse({'error': 'Дозволені лише зображення (PNG, JPG, GIF, WebP)'}, status=400)
# Расширению не доверяем — проверяем содержимое
from PIL import Image
try:
image = Image.open(file)
image.verify()
except Exception:
return JsonResponse({'error': 'Файл не є зображенням'}, status=400)
file.seek(0)
filename = f'feed/{timezone.now():%Y/%m}/{uuid.uuid4().hex}{ext}'
path = default_storage.save(filename, file)
return JsonResponse({'location': default_storage.url(path)})
@login_required
def news_detail_view(request, pk):
"""Пост целиком с комментариями"""
post = get_object_or_404(
Post.objects.select_related('author'), pk=pk, is_published=True,
)
if request.method == 'POST':
text = request.POST.get('text', '').strip()
if text:
PostComment.objects.create(post=post, author=request.user, text=text)
if post.mattermost_post_ids:
from apps.feed.services import sync_comments_from_mattermost
sync_comments_from_mattermost(post)
context = {
'post': post,
'comments': post.comments.select_related('author'),
'can_edit': can_edit_post(request.user, post),
'can_delete': can_delete_post(request.user, post),
}
return render_page(request, 'news-detail.html', context)
@login_required
def news_edit_view(request, pk):
"""Редактирование поста: администратор или автор"""
post = get_object_or_404(Post.objects.select_related('author'), pk=pk)
if not can_edit_post(request.user, post):
raise Http404
form_error = ''
if request.method == 'POST':
title = request.POST.get('title', '').strip()
body = request.POST.get('body', '').strip()
if _post_content_valid(title, body):
category = request.POST.get('category', 'general')
post.title = title
post.body = body
post.category = category if category in dict(Post.CATEGORY_CHOICES) else 'general'
post.is_important = request.POST.get('is_important') == 'on'
post.is_pinned = request.POST.get('is_pinned') == 'on'
post.mattermost_targets = _valid_mattermost_targets(request.POST.getlist('mattermost_targets'))
post.save()
if post.mattermost_targets:
from apps.feed.services import publish_post_to_mattermost
publish_post_to_mattermost(post)
return redirect('news_detail', pk=post.pk)
form_error = 'Зміни не збережено: заповніть заголовок і текст.'
context = {
'post': post,
'categories': Post.CATEGORY_CHOICES,
'form_error': form_error,
'mattermost_target_teams': _target_teams_context(),
}
return render_page(request, 'news-edit.html', context)
@login_required
@require_POST
def news_delete_view(request, pk):
"""Удаление поста: только автор"""
post = get_object_or_404(Post, pk=pk)
if not can_delete_post(request.user, post):
raise Http404
if post.mattermost_post_ids:
from apps.feed.services import delete_post_from_mattermost
delete_post_from_mattermost(post)
post.delete()
return redirect('news')
@login_required
@require_POST
def news_publish_to_mattermost_view(request, pk):
"""Опублікувати пост у Mattermost (town-square відповідних команд за категорією).
Можна натискати повторно — дублі не блокуються, лише оновлюється mattermost_posted_at."""
if not request.user.is_staff:
raise Http404
post = get_object_or_404(Post, pk=pk, is_published=True)
from apps.feed.services import publish_post_to_mattermost
publish_post_to_mattermost(post)
return redirect('news_detail', pk=post.pk)