Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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