Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
924af7a
chore(deps): estende ignore de django para minor (LTS upgrade e sprin…
GabeMarques-Intetsu May 23, 2026
94f55c8
fix(auth): cadastro funcional + guarda de sessão no AdminRoute
GabeMarques-Intetsu May 28, 2026
7b62ff5
feat(newsletter): página de cancelamento de inscrição (/newsletter/ca…
GabeMarques-Intetsu May 28, 2026
0f43830
fix(articles): autorização de dono, capa+destaque obrigatórios, pagin…
GabeMarques-Intetsu May 28, 2026
ec572c0
fix(ui): chips de categoria com cor da editoria + breadcrumb navegável
GabeMarques-Intetsu May 28, 2026
fef3e22
fix(moderation): incluir DEV nas notificações de novo BanRequest
GabeMarques-Intetsu May 28, 2026
3571d1b
fix(comments): paginação "ver mais", feedback de erro e contagem correta
GabeMarques-Intetsu May 28, 2026
8e35805
fix(a11y): id único de Modal (useId) + contraste do botão da página S…
GabeMarques-Intetsu May 28, 2026
1898431
chore(backend): elimina RemovedInDjango60Warning do conversor uslug
GabeMarques-Intetsu May 28, 2026
456084a
fix(metrics): like_count ignora curtidas de comentários soft-deleted
GabeMarques-Intetsu May 28, 2026
633946d
fix(ui): texto do chip de categoria some no estado ativo+hover
GabeMarques-Intetsu May 28, 2026
25874ac
refactor(users): renomeia label de role Redator → Editor
GabeMarques-Intetsu May 28, 2026
fe812d3
feat(security): política de complexidade de senha (backend + checklis…
GabeMarques-Intetsu May 28, 2026
c8c96e5
feat(profile): username editável + checklist de senha na troca
GabeMarques-Intetsu May 28, 2026
6864d7c
feat(ui): exibe @username abaixo do nome (author card + navbar)
GabeMarques-Intetsu May 28, 2026
e315146
fix(a11y): contraste do badge de categoria literatura (WCAG AA)
GabeMarques-Intetsu May 28, 2026
5eb944f
fix(a11y): corrige salto de nível de heading em /sobre e /noticias
GabeMarques-Intetsu May 28, 2026
d5a06c6
fix(a11y): dl/dt/dd nos cartões de KPI do admin (remove "possible hea…
GabeMarques-Intetsu May 28, 2026
88a3498
fix(a11y): landmarks + label na página de publicação (nova/editar)
GabeMarques-Intetsu May 28, 2026
688fc2b
fix(a11y): h2 no destaque da home + dl no centro do donut de editorias
GabeMarques-Intetsu May 28, 2026
b5bb714
fix(ui): checklist de senha fixo + paleta sóbria (pendente muted, cum…
GabeMarques-Intetsu May 28, 2026
babefba
chore: ignora entregáveis locais (design-options, presentations)
GabeMarques-Intetsu May 29, 2026
d3c42a9
feat(users): management command seed_team_users (substitui seed_users…
GabeMarques-Intetsu May 29, 2026
1e0241e
feat(moderation): dev é superadmin — único que bane/desbane admins
GabeMarques-Intetsu May 29, 2026
c017e1f
feat(search): bootstrap app apps.search com models managed=False [T30…
GabeMarques-Intetsu Jun 3, 2026
103e5ea
feat(search): migration 0001 — extension unaccent + pt_unaccent + tab…
GabeMarques-Intetsu Jun 3, 2026
d43e17d
feat(search): migration 0002 indexes GIN+partial+covering [T30.1.3]
GabeMarques-Intetsu Jun 3, 2026
df98846
feat(search): migration 0003 triggers SQL sync Article -> SearchIndex…
GabeMarques-Intetsu Jun 3, 2026
64c49d9
feat(search): migration 0004 vacuum tuning GIN + autovacuum [T30.1.X1]
GabeMarques-Intetsu Jun 3, 2026
6402a6e
docs(search): spec bundle v3 — DESIGN + BACKLOG + 35 ADRs + validators
GabeMarques-Intetsu Jun 3, 2026
ffb88f6
feat(search): migration 0005 ENABLE ALWAYS triggers [T30.1.5d] [DESIG…
GabeMarques-Intetsu Jun 3, 2026
1e3f1d3
feat(search): settings SEARCH_* constants + throttle scopes [DESIGN-v3]
GabeMarques-Intetsu Jun 3, 2026
3c98825
feat(search): utils.normalize_search_text simetrico [T30.1.X2] [DESIG…
GabeMarques-Intetsu Jun 3, 2026
e33b999
feat(search): DTOs frozen QuerySpec/CursorPayload/ResultItem/SearchRe…
GabeMarques-Intetsu Jun 3, 2026
0bd7e33
feat(search): cache key sha256+auth_tier helpers [T30.4.X4] [DESIGN-v3]
GabeMarques-Intetsu Jun 3, 2026
36b21e2
feat(search): signal post_save/post_delete invalidate cache [T30.1.5c…
GabeMarques-Intetsu Jun 3, 2026
dc4680c
feat(search): throttles DRF anon/user/global [T30.4.1-4] [DESIGN-v3]
GabeMarques-Intetsu Jun 4, 2026
a0e8516
feat(search): cursor HMAC base64 encode/decode [T30.1.7][Inv 5,6,9] […
GabeMarques-Intetsu Jun 4, 2026
f5b226c
feat(search): SearchService.query() — 12 invariantes algorithms [T30.…
GabeMarques-Intetsu Jun 4, 2026
e4ce5df
feat(search): SearchView + Serializer + URL + feature flag [T30.1.8/9]
GabeMarques-Intetsu Jun 4, 2026
284997a
docs(search): REVIEW-PHASE-2 + TX-18 Lighthouse baseline (4 JSONs + R…
GabeMarques-Intetsu Jun 4, 2026
74a9dc9
feat(buscar): deps + QueryClientProvider + tokens herdados [T30.1.X11…
GabeMarques-Intetsu Jun 4, 2026
ce18826
feat(buscar): useDebouncedValue<T>(value, delayMs) — 15 LoC, zero-dep…
GabeMarques-Intetsu Jun 4, 2026
2259605
feat(buscar): types + searchService + useSearch hook (Bug 6 fix) [T30…
GabeMarques-Intetsu Jun 4, 2026
816e3fb
feat(buscar): SearchInput component (input type=search + a11y) [T30.1…
GabeMarques-Intetsu Jun 4, 2026
871f53a
feat(buscar): HighlightedText + ResultCard thumb-left 120x80 [T30.1.1…
GabeMarques-Intetsu Jun 4, 2026
c1caa0c
feat(buscar): FilterChips shell + Empty/RateLimited/Skeleton/ErrorFal…
GabeMarques-Intetsu Jun 4, 2026
db4b2a2
feat(buscar): rota /buscar + Buscar page + SearchResults 5 estados [T…
GabeMarques-Intetsu Jun 6, 2026
f0b3f34
test(buscar): integration Buscar + SearchResults + a11y axe-core [T30…
GabeMarques-Intetsu Jun 6, 2026
6633dc2
docs(search): REVIEW-PHASE-3 — APROVADO COM RESSALVAS (2 BLOQUEIOs + …
GabeMarques-Intetsu Jun 6, 2026
25bb5f9
fix(buscar): H-01 H-02 H-03 do REVIEW-PHASE-3 (DRY consts + mark.js c…
GabeMarques-Intetsu Jun 6, 2026
d45478f
fix(buscar): H-04 ResultCard categoria com tokens editoriais clr-cat-…
GabeMarques-Intetsu Jun 6, 2026
cbb9001
test(buscar): BLOQUEIO-2 a11y axe-core 12 testes + fix landmark Skele…
GabeMarques-Intetsu Jun 6, 2026
ffa5150
feat(buscar): BLOQUEIO-1 MSW handlers + worker DEV-only [T30.1.X12][B…
GabeMarques-Intetsu Jun 6, 2026
2bdf681
fix(mocks): handler MSW captura cross-origin localhost:8000 (smoke ma…
GabeMarques-Intetsu Jun 6, 2026
9885a83
docs(search): BACKLOG v5 + tracker — entregas pos-REVIEW-PHASE-3
GabeMarques-Intetsu Jun 6, 2026
96cdad5
fix(search): F2-B-03 hard-fail HMAC secret igual a SECRET_KEY em prod…
GabeMarques-Intetsu Jun 6, 2026
2362305
fix(search): F2-B-02 Cache-Control private para autenticado [REVIEW-P…
GabeMarques-Intetsu Jun 6, 2026
14649d7
fix(search): F2-B-01 @transaction.atomic em _query_postgres [REVIEW-P…
GabeMarques-Intetsu Jun 6, 2026
55439f6
docs(search): BACKLOG v6 — F2-B-01/02/03 entregues, PR US30.1 100% ga…
GabeMarques-Intetsu Jun 6, 2026
1205f6b
Merge main into develop — alinhar PR #30 dependabot config
GabeMarques-Intetsu Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ coverage/
# Documentos internos de planejamento (não versionados)
docs/planning/

# Entregáveis locais (paletas/screenshots de review + apresentações de cliente):
# artefatos pesados/gerados, ficam fora do repo.
docs/design-options/
docs/design-options.zip
docs/presentations/

# Reports de teste (histórico só local — evita inflate do repo).
# Mantém .gitkeep pra preservar diretórios vazios. Decisão registrada em
# docs/planning/reorganization-proposal-2026-05-21.md §9 item 2.
Expand Down
5 changes: 5 additions & 0 deletions backend/apps/articles/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ def ready(self) -> None:
# Wire up signals that auto-notify newsletter subscribers
# when an article is published.
from . import signals # noqa: F401

# Registra o conversor de URL 'uslug' (slug unicode) uma única vez no
# boot do app. Antes articles/urls.py e comments/urls.py registravam
# cada um → 2ª chamada disparava RemovedInDjango60Warning.
from . import converters # noqa: F401
9 changes: 9 additions & 0 deletions backend/apps/articles/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
any script plus digits and underscore — a drop-in superset of Django's slug
character class.
"""
from django.urls import register_converter


class UnicodeSlugConverter:
Expand All @@ -20,3 +21,11 @@ def to_python(self, value: str) -> str:

def to_url(self, value: str) -> str:
return value


# Registra o conversor uma única vez, no import deste módulo. Antes articles/urls.py
# E comments/urls.py chamavam register_converter('uslug') cada → a 2ª chamada disparava
# RemovedInDjango60Warning (override de conversor já registrado). Um módulo Python roda
# só 1x (cache em sys.modules), então registrar aqui garante registro único, qualquer
# que seja a ordem de import dos urlconfs.
register_converter(UnicodeSlugConverter, 'uslug')
13 changes: 12 additions & 1 deletion backend/apps/articles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,20 @@ def _unique_slug(self) -> str:
return slug

def save(self, *args, **kwargs):
from django.db import transaction

if not self.slug:
self.slug = self._unique_slug()
super().save(*args, **kwargs)
# Destaque único: marcar este como featured desmarca todos os outros.
# Padrão NYT/Substack — só 1 matéria ocupa o hero da home. Sem isso,
# Home.tsx (find(is_featured)) pegaria um featured arbitrário quando
# houvesse 2+. 2 writes (save + update) → atomic por ADR-012.
with transaction.atomic():
super().save(*args, **kwargs)
if self.is_featured:
Article.objects.filter(is_featured=True).exclude(pk=self.pk).update(
is_featured=False
)

def __str__(self):
return self.title
21 changes: 21 additions & 0 deletions backend/apps/articles/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ class ArticleWriteSerializer(serializers.ModelSerializer):
category_id = serializers.PrimaryKeyRelatedField(
queryset=Category.objects.all(), source='category', required=False, allow_null=True
)
# Legenda obrigatória: todo artigo publicado precisa de crédito da capa
# (padrão G1/Folha — "Foto: Agência"). Model permite blank por
# retrocompat com artigos antigos, mas a escrita via API exige.
cover_caption = serializers.CharField(
max_length=300, required=True, allow_blank=False,
error_messages={
'blank': 'A legenda da capa é obrigatória (ex.: "Foto: Agência").',
'required': 'A legenda da capa é obrigatória.',
},
)

class Meta:
model = Article
Expand All @@ -42,6 +52,17 @@ class Meta:
'category_id', 'status', 'is_featured',
]

def validate(self, attrs):
# Imagem de capa obrigatória NA CRIAÇÃO — legenda sem imagem é
# incoerente. No update (partial), não força reenvio da imagem
# existente. self.instance é None em create.
is_create = self.instance is None
if is_create and not attrs.get('cover_image'):
raise serializers.ValidationError(
{'cover_image': 'A imagem de capa é obrigatória.'}
)
return attrs

def create(self, validated_data):
validated_data['author'] = self.context['request'].user
return super().create(validated_data)
156 changes: 142 additions & 14 deletions backend/apps/articles/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ def _make(author, status=Article.Status.PUBLISHED, title='Test Article', **kw):
return _make


@pytest.fixture
def tiny_image():
"""SimpleUploadedFile com PNG 1x1 válido — ImageField (Pillow) exige
imagem real, não bytes arbitrários. Usado nos testes de create que agora
exigem cover_image obrigatória."""
from django.core.files.uploadedfile import SimpleUploadedFile
import io
from PIL import Image

buf = io.BytesIO()
Image.new('RGB', (16, 9), color=(20, 20, 76)).save(buf, format='PNG')
return SimpleUploadedFile('cover.png', buf.getvalue(), content_type='image/png')


@pytest.fixture(autouse=True)
def _clear_cache():
"""View_count usa cache.add — limpa entre testes pra evitar contaminação."""
Expand Down Expand Up @@ -109,7 +123,7 @@ def test_list_articles_reader_does_not_see_drafts(
])
def test_create_article_permission_matrix(
request, category, api_client, authed_client_factory,
fixture_name, expected_status,
tiny_image, fixture_name, expected_status,
):
if fixture_name:
user = request.getfixturevalue(fixture_name)
Expand All @@ -123,13 +137,111 @@ def test_create_article_permission_matrix(
'body': 'A reasonably sized body for the test to pass any min-length validation.',
'category_id': category.id,
'status': 'draft',
})
'cover_caption': 'Foto: Agência Teste', # agora obrigatória
'cover_image': tiny_image, # agora obrigatória no create
}, format='multipart')
assert resp.status_code == expected_status, (
f'{fixture_name or "anon"} → expected {expected_status}, got {resp.status_code}: '
f'{resp.content[:200]}'
)


# ── Validação obrigatória: cover_caption + cover_image (create) ───────────────

def test_create_article_requires_cover_caption(
category, editor_user, authed_client_factory, tiny_image,
):
"""Legenda da capa é obrigatória na criação (padrão G1/Folha — crédito
da foto). POST sem cover_caption → 400."""
client = authed_client_factory(editor_user)
resp = client.post(ARTICLES_URL, data={
'title': 'Sem legenda',
'excerpt': 'Excerpt.',
'body': 'Body suficiente para passar validação.',
'category_id': category.id,
'status': 'draft',
'cover_image': tiny_image,
# cover_caption ausente
}, format='multipart')
assert resp.status_code == 400
assert 'cover_caption' in resp.json()


def test_create_article_rejects_blank_cover_caption(
category, editor_user, authed_client_factory, tiny_image,
):
"""Legenda em branco (string vazia) também é rejeitada — não basta o
campo existir, precisa ter conteúdo."""
client = authed_client_factory(editor_user)
resp = client.post(ARTICLES_URL, data={
'title': 'Legenda vazia',
'excerpt': 'Excerpt.',
'body': 'Body suficiente para passar validação.',
'category_id': category.id,
'status': 'draft',
'cover_caption': ' ', # só espaços
'cover_image': tiny_image,
}, format='multipart')
assert resp.status_code == 400
assert 'cover_caption' in resp.json()


def test_create_article_requires_cover_image(
category, editor_user, authed_client_factory,
):
"""Imagem de capa obrigatória na criação — legenda sem imagem é
incoerente. POST sem cover_image → 400."""
client = authed_client_factory(editor_user)
resp = client.post(ARTICLES_URL, data={
'title': 'Sem capa',
'excerpt': 'Excerpt.',
'body': 'Body suficiente para passar validação.',
'category_id': category.id,
'status': 'draft',
'cover_caption': 'Foto: Agência',
# cover_image ausente
}, format='multipart')
assert resp.status_code == 400
assert 'cover_image' in resp.json()


def test_update_article_does_not_require_cover_image_resend(
make_article, editor_user, authed_client_factory,
):
"""REGRESSÃO: editar artigo existente NÃO deve exigir reenvio da imagem
(a capa já existe). PATCH só do título deve passar."""
art = make_article(editor_user, title='Para editar', cover_caption='Foto: X')
client = authed_client_factory(editor_user)
resp = client.patch(
f'/api/v1/articles/{art.slug}/',
data={'title': 'Título editado'},
format='multipart',
)
assert resp.status_code == 200, resp.content[:200]


# ── is_featured: destaque único ───────────────────────────────────────────────

def test_marking_article_featured_unsets_previous(make_article, editor_user):
"""Padrão NYT/Substack — só 1 hero. Marcar um novo artigo como featured
desmarca o anterior automaticamente (model.save)."""
first = make_article(editor_user, title='Primeiro destaque', is_featured=True)
assert first.is_featured is True

second = make_article(editor_user, title='Segundo destaque', is_featured=True)

first.refresh_from_db()
assert first.is_featured is False, 'destaque antigo deveria ter sido desmarcado'
assert second.is_featured is True


def test_only_one_featured_after_multiple_marks(make_article, editor_user):
"""Invariante dura: nunca mais de 1 featured no banco, mesmo após N marcações."""
for i in range(5):
make_article(editor_user, title=f'Art {i}', is_featured=True)
assert Article.objects.filter(is_featured=True).count() == 1


# ── Update + Delete (object-level: dono ou admin) ─────────────────────────────

def test_editor_can_update_own_article(
Expand All @@ -150,15 +262,11 @@ def test_editor_can_update_own_article(
def test_editor_cannot_update_other_editors_article(
make_article, editor_user, db, authed_client_factory,
):
"""Editor B não pode mexer no artigo de Editor A. Só dono ou admin.

NOTA: a permission class IsPublisherOrReadOnly autoriza apenas a NÍVEL
DE VIEW (qualquer publisher pode PATCH); a restrição owner-only para
edição de outros é APENAS no frontend (ArticleAdminActions). Backend
hoje permite editor mexer no artigo de outro editor — débito conhecido
(A6/A9 §11.2 — refactor de permissões granulares). Este teste captura
o COMPORTAMENTO ATUAL: passa 200, NÃO 403. Quando IsOwnerOrAdmin entrar
no detail view, ajustar pra 403."""
"""SEGURANÇA: Editor B NÃO pode editar artigo de Editor A — só dono ou
admin/dev. Antes, IsPublisherOrReadOnly só restringia a nível de view
(qualquer publisher fazia PATCH) e a proteção owner-only existia APENAS
no frontend — trivial de burlar via curl. Fix: IsOwnerOrAdmin no
ArticleDetailView (object-level). Este teste é a regression do escalonamento."""
from apps.users.models import User
other_editor = User.objects.create_user(
username='outro.editor', email='outro@interpop.test',
Expand All @@ -173,9 +281,29 @@ def test_editor_cannot_update_other_editors_article(
data={'title': 'Edit by other editor'},
format='json',
)
# Comportamento atual: 200 (sem IsOwnerOrAdmin no detail). Quando o
# refactor entrar, virar 403 (e este teste passa a ser regression).
assert resp.status_code in (200, 403)
assert resp.status_code == 403, (
'ESCALONAMENTO: editor conseguiu editar artigo de outro editor. '
'IsOwnerOrAdmin deveria bloquear (403).'
)
art.refresh_from_db()
assert art.title == 'Not Mine', 'título não deveria ter mudado'


def test_editor_cannot_delete_other_editors_article(
make_article, editor_user, db, authed_client_factory,
):
"""Mesma proteção no DELETE — editor não apaga artigo alheio."""
from apps.users.models import User
other = User.objects.create_user(
username='outro2.editor', email='outro2@interpop.test',
password='SenhaForte!2026', first_name='Outro2', last_name='Editor',
role=User.Role.EDITOR,
)
art = make_article(other, title='Keep Mine')
client = authed_client_factory(editor_user)
resp = client.delete(f'/api/v1/articles/{art.slug}/')
assert resp.status_code == 403
assert Article.objects.filter(pk=art.pk).exists()


def test_admin_can_update_any_article(
Expand Down
5 changes: 1 addition & 4 deletions backend/apps/articles/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
from django.urls import path, register_converter
from .converters import UnicodeSlugConverter
from django.urls import path
from .views import ArticleDetailView, ArticleListView, ArticleViewCountView, CategoryListView

register_converter(UnicodeSlugConverter, 'uslug')

urlpatterns = [
path('categories/', CategoryListView.as_view(), name='category-list'),
path('articles/', ArticleListView.as_view(), name='article-list'),
Expand Down
15 changes: 12 additions & 3 deletions backend/apps/articles/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from rest_framework.views import APIView

from apps.audit.utils import get_client_ip
from apps.users.permissions import IsPublisherOrReadOnly
from apps.users.permissions import IsOwnerOrAdmin, IsPublisherOrReadOnly
from .models import Article, Category
from .serializers import (
ArticleDetailSerializer,
Expand Down Expand Up @@ -36,9 +36,14 @@ class ArticleListView(generics.ListCreateAPIView):
ordering_fields = ['published_at', 'view_count', 'created_at']

def get_queryset(self):
# .annotate(Count(...)) injeta GROUP BY → Django marca o queryset como
# "unordered" (QuerySet.ordered=False) mesmo com Meta.ordering, pois
# ordem default de query agregada é considerada não-confiável. Sem um
# order_by EXPLÍCITO o paginador do DRF dispara UnorderedObjectListWarning
# e pode paginar inconsistente. Repete a ordem do Meta de Article.
qs = Article.objects.select_related('author', 'category').annotate(
comment_count=Count('comments', filter=Q(comments__is_deleted=False))
)
).order_by('-published_at', '-created_at')
# Editorial team (admin + editor) enxerga drafts — convenção CMS
# (WordPress/Ghost): toda equipe vê o estado editorial. Edição/exclusão
# continua restrita ao próprio autor ou admin (regra no frontend +
Expand All @@ -61,7 +66,11 @@ def perform_create(self, serializer):


class ArticleDetailView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [IsPublisherOrReadOnly]
# IsPublisherOrReadOnly: anon lê (GET), publisher escreve (nível de view).
# IsOwnerOrAdmin (object-level): só o AUTOR ou admin/dev pode PATCH/DELETE.
# Sem o segundo, qualquer editor editava/deletava artigo de QUALQUER outro
# editor via API (a restrição existia só no frontend — trivial de burlar).
permission_classes = [IsPublisherOrReadOnly, IsOwnerOrAdmin]
lookup_field = 'slug'
queryset = Article.objects.select_related('author', 'category')

Expand Down
25 changes: 25 additions & 0 deletions backend/apps/audit/tests/test_admin_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,31 @@ def test_per_article_only_published(
assert 'Visible Pub' in titles


def test_per_article_like_count_excludes_likes_on_deleted_comments(
admin_user, editor_user, reader_user, make_article, authed_client_factory,
):
"""REGRESSÃO: like_count NÃO conta curtidas de comentários soft-deleted.

comment_count já exclui deletados (filter is_deleted=False); like_count
precisa ser consistente, senão engagement_rate infla com curtidas em
conteúdo OCULTO (comment deletado some da tela mas seu like contava)."""
article = make_article(editor_user, title='DelLikes', view_count=100)

alive = Comment.objects.create(article=article, author=reader_user, content='vivo')
CommentLike.objects.create(comment=alive, user=reader_user) # like válido

dead = Comment.objects.create(
article=article, author=reader_user, content='morto', is_deleted=True,
)
CommentLike.objects.create(comment=dead, user=editor_user) # like em comment oculto

api = authed_client_factory(admin_user)
body = api.get(METRICS_URL).json()
row = next(a for a in body['per_article'] if a['title'] == 'DelLikes')
assert row['comment_count'] == 1 # só o "vivo"
assert row['like_count'] == 1 # like do comment deletado NÃO conta


# ── category_breakdown ───────────────────────────────────────────────────────


Expand Down
10 changes: 9 additions & 1 deletion backend/apps/audit/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,15 @@ def get(self, request):
filter=Q(comments__is_deleted=False),
distinct=True,
),
like_count=Count('comments__likes', distinct=True),
# Mesmo filtro de is_deleted que comment_count: curtidas em
# comentários soft-deleted (ocultos da tela) não devem entrar
# no engagement. distinct evita a inflação do JOIN cartesiano
# comments × likes.
like_count=Count(
'comments__likes',
filter=Q(comments__is_deleted=False),
distinct=True,
),
)
.order_by('-view_count')
.values('slug', 'title', 'view_count', 'comment_count', 'like_count', 'published_at')
Expand Down
Loading
Loading