Модуль ленты (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>
This commit is contained in:
2026-07-22 17:31:20 +03:00
parent 78475b58e9
commit 35f2091e0b
19 changed files with 1690 additions and 104 deletions
+57
View File
@@ -0,0 +1,57 @@
{% load common_tags %}
{# Карточка поста в ленте: полный вывод. Контекст: post, pinned #}
<div style="{% if pinned %}background:linear-gradient(135deg, hsl(0 60% 97%) 0%, hsl(214 60% 97%) 100%);{% else %}background:var(--color-card);{% endif %} border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); padding:1.5rem; margin-bottom:1rem;">
<!-- Шапка: бейджи + дата -->
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:0.625rem;">
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
{% if post.is_pinned %}
<span style="font-size:0.6875rem; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--color-primary); background:hsl(0 72% 44% / 0.1); border-radius:999px; padding:0.1875rem 0.625rem;">📌 Закріплено</span>
{% endif %}
{% if post.is_important %}<span class="badge badge-danger">Важливо</span>{% endif %}
<span class="badge {{ post.badge_class }}">{{ post.get_category_display }}</span>
</div>
<span style="font-size:0.75rem; color:var(--color-muted-foreground); white-space:nowrap; flex-shrink:0;">{{ post.created_at|date:"d.m.Y H:i" }}</span>
</div>
<!-- Заголовок -->
<a href="/news/{{ post.pk }}/" hx-get="/news/{{ post.pk }}/" hx-target="#main-content" hx-push-url="/news/{{ post.pk }}/" hx-indicator="#loader"
style="text-decoration:none;">
<h2 style="font-size:1.0625rem; font-weight:600; color:var(--color-foreground); margin-bottom:0.75rem;">{{ post.title }}</h2>
</a>
<!-- Тіло повністю (HTML санітизовано при збереженні); довгі пости згортаються -->
<div class="post-collapse">
<div class="post-body" style="font-size:0.9075rem; color:var(--color-foreground); line-height:1.65;">{{ post.body|safe }}</div>
<div class="post-fade" {% if pinned %}style="background:linear-gradient(transparent, hsl(214 60% 97%));"{% endif %}></div>
</div>
<button type="button" class="post-toggle" onclick="feedTogglePost(this)"
style="display:none; margin-bottom:1rem; background:none; border:none; cursor:pointer; font-size:0.8125rem; font-weight:500; color:var(--color-primary); padding:0;">Показати повністю ↓</button>
<!-- Футер: автор, коментарі, дії -->
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; padding-top:0.875rem; border-top:1px solid var(--color-border);">
<div style="display:flex; align-items:center; gap:0.5rem; font-size:0.75rem; color:var(--color-muted-foreground);">
<div style="width:1.5rem; height:1.5rem; border-radius:50%; background:hsl(270 60% 55% / 0.12); color:hsl(270 60% 42%); display:flex; align-items:center; justify-content:center; font-size:0.5625rem; font-weight:700;">{{ post.author|initials }}</div>
{{ post.author.get_full_name|default:"—" }}
</div>
<div style="display:flex; align-items:center; gap:0.375rem;">
<a href="/news/{{ post.pk }}/" hx-get="/news/{{ post.pk }}/" hx-target="#main-content" hx-push-url="/news/{{ post.pk }}/" hx-indicator="#loader"
class="btn btn-ghost" style="font-size:0.75rem; padding:0.25rem 0.625rem; text-decoration:none; display:inline-flex; align-items:center; gap:0.375rem;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>
{{ post.num_comments|default:0 }}
</a>
{% if post.can_edit %}
<a href="/news/{{ post.pk }}/edit/" hx-get="/news/{{ post.pk }}/edit/" hx-target="#main-content" hx-push-url="/news/{{ post.pk }}/edit/" hx-indicator="#loader"
class="btn btn-ghost" style="font-size:0.75rem; padding:0.25rem 0.625rem; text-decoration:none;">Редагувати</a>
{% endif %}
{% if post.can_delete %}
<form hx-post="/news/{{ post.pk }}/delete/" hx-target="#main-content" hx-push-url="/news/" hx-indicator="#loader"
hx-confirm="Видалити публікацію «{{ post.title }}»? Це незворотньо."
style="display:inline; margin:0;">
{% csrf_token %}
<button type="submit" class="btn btn-ghost" style="font-size:0.75rem; padding:0.25rem 0.625rem; color:var(--color-destructive);">Видалити</button>
</form>
{% endif %}
</div>
</div>
</div>