35f2091e0b
- 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>
234 lines
8.9 KiB
Python
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 из редактора: пустая разметка (в т.ч. ) не считается"""
|
|
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)
|