Design — Analytics de downloads de arquivos
Status: implementado — ADR 0016 (decisões) + fases 1–9 concluídas. Desvios da implementação em relação a este design estão marcados com ⚠️ abaixo. Objetivo: dar aos downloads de arquivos a mesma paridade analítica que os vídeos já têm hoje (
apps.streaming—MediaPlaybackSession/MediaPlaybackHeartbeat, heatmap, watchtime; ver ADR 0009).
1. Contexto: o que existe hoje
1.1 Analytics de vídeo (paridade alvo)
| Camada | Onde | O quê |
|---|---|---|
| Models | backend/apps/streaming/models.py | MediaPlaybackSession (user, device, IP, total_watch_time, completion_rate, is_completed) + MediaPlaybackHeartbeat (log por ping: current_time, watch_time_delta, quality, rebuffer_events) |
| Endpoints | backend/apps/streaming/urls.py | POST /api/v1/media/<uuid>/heartbeat/ (aceita sendBeacon), GET /api/v1/media/<uuid>/analytics/ (total_views, total_watch_time, average_watch_time, completion_rate, heatmap 5s) |
| Frontend | @repo/ui video-player.tsx | VideoAnalyticsConfig (analyticsUrl, mediaId, sessionId, heartbeatIntervalMs) — heartbeats a cada 5s + pause/unmount |
| Admin | backend/apps/streaming/admin.py | Listagem de sessões/heartbeats |
✅ Gap de vídeo relacionado — resolvido pelo ADR 0016: os players de aula e de produto do SPA agora passam a prop analytics ao VideoPlayer (heartbeat em /api/v1/media/<id>/heartbeat/), e MediaAnalyticsSummaryView/GET /media/<id>/analytics/ foram restritos a admin/moderador.
1.2 Fluxos de download existentes (sem nenhum tracking)
| Fluxo | Endpoint | Como entrega o arquivo | Passa pelo backend? |
|---|---|---|---|
| Drive (admin) | GET /api/v1/files/{uid}/download/ (apps/drive/views.py::DriveFileViewSet.download) | Responde {download_url} (presigned S3/MinIO) ou FileResponse local | ✅ sim (gera a URL) |
| Anexo de aula | GET /api/headless/v1/courses/{course}/lessons/{lesson}/attachments/{media}/download (apps/headless/views.py::LessonAttachmentDownloadView) | Valida paywall/acesso, marca d'água em PDFs, 302 → presigned URL | ✅ sim |
| Produto digital | GET /api/v1/product-items/{uid}/file/ (ProductItemFileView, apps/spaces) — o file_url do detail já apontava para ele ⚠️ | Backend serve o arquivo via FileResponse com gate de acesso (visible_to + is_accessible_to/is_free_preview) | ✅ sim (já passava pelo backend — só faltava registrar o evento) |
Fatos verificados:
DriveFileeProductItemnão têm campo de contagem de downloads (zero ocorrências dedownload_count/downloadsno codebase).- Nenhum endpoint de download chama
AuditLogger.log(oAuditLogEvent/AuditLoggerdeapps/utils/services/audit.pyé genérico e não é usado em nenhum fluxo real hoje). _internal/apps/trackingé infra de marketing (ingest de eventos → pixels GA4/Meta/TikTok, Neon separado, sem conceitos de domínio) — não deve ser usado para analytics de produto.
2. Decisões de design
D1. Um app novo e pequeno: apps.downloads
Download analytics é transversal a três donos (DriveFile, Media — anexos de aula, ProductItem). Não cabe em apps.drive (só cobre Drive), nem em apps.streaming (que é "métricas de reprodução", ADR 0009), nem em apps.media (é pipeline de processamento). Um app dedicado segue o padrão já consolidado de desacoplar domínio por app (streaming, payments).
D2. Um modelo único de evento: FileDownloadEvent com GenericForeignKey
Espelha o padrão já usado por Media.target/Reaction (GFK) — um único modelo serve os três alvos sem tabelas paralelas:
FileDownloadEvent(BaseModel)
├── uid (BaseModel)
├── community FK → Community (indexado)
├── target_content_type FK + target_object_id (GFK) → DriveFile | Media | ProductItem
├── membership FK → Membership (nullable — futuros downloads anônimos/públicos)
├── ip_address GenericIPAddressField (null=True)
├── user_agent TextField (blank)
├── referrer URLField (blank) # request.META HTTP_REFERER
├── content_type CharField # snapshot MIME (ex.: application/pdf)
├── file_size PositiveBigIntegerField # snapshot de bytes
└── created_at (BaseModel)Por que evento por download e não heartbeat/sessão? Download é um evento discreto e de volume baixo-médio (diferente do heartbeat de vídeo, que pinge a cada 5s). Um log por download resolve. Sem contador denormalizado no dono: contadores são deriváveis por COUNT/agregação e ficam sempre consistentes — se no futuro o volume exigir, adiciona-se um downloads_count denormalizado com backfill (mesmo padrão de Post.reaction_count_agg no sort popular).
Dedup: não deduplicar. Re-download do mesmo membro é um evento legítimo (e informação útil: "quantos baixaram de novo"). Anti-abuso via throttle (seção 6).
Privacidade: IP/UA guardados igual MediaPlaybackSession. membership identifica o baixador (attribution via Membership, nunca via CustomUser — regra do projeto).
D3. Registro = "pediu o link de download"
Em Drive e anexos de aula o backend já é o ponto único onde a presigned URL nasce — o registro acontece aí. Para produtos digitais isso exige parar de entregar file_url direto (ver D5). Trade-off assumido (já implícito em LessonAttachmentDownloadView): gerar a URL ≈ contar o download; um clique que não completa o download conta igual.
D4. Analytics restrito a admin/moderador
O atual MediaAnalyticsSummaryView exige apenas RequiresActiveMembership (qualquer membro vê métricas de qualquer mídia) — não repetir isso. Endpoints de analytics de downloads usam IsAdminOrModerator (mesmo gate do AdminAnalyticsView).
D5. Produto digital: registrar no endpoint que já serve o arquivo ⚠️
Desvio da implementação: o design previa um novo ProductItemDownloadView no headless porque assumia que o frontend baixava direto do storage via file_url presigned. Na verdade ProductPackageItemDetailView já embutia file_url = reverse("spaces:product-item-file") — ou seja, o download de produto já passava pelo backend (ProductItemFileView, GET /api/v1/product-items/{uid}/file/, que valida visible_to + is_accessible_to/is_free_preview e serve FileResponse).
- Backend (feito):
ProductItemFileViewganhourecord_download(request, target=item, ...)antes de servir o arquivo — mesmo padrão dos outros fluxos. Nenhum endpoint novo, nenhuma mudança de schema/contrato. - Frontend (não precisou de mudança):
$itemId.tsxjá apontava para o backend; oTablatureViewercontinua usandofile_urlpara preview inline (que agora também conta como download — decisão D3 assumida).
3. Endpoints
3.1 Registro (implementado)
| Superfície | Endpoint | Ação |
|---|---|---|
| Drive | GET /api/v1/files/{uid}/download/ (existente) | + criar FileDownloadEvent antes de responder a URL |
| Anexo de aula | GET .../attachments/{media_id}/download (existente) | + criar FileDownloadEvent antes do 302 (e no fallback de PDF com marca d'água) |
| Produto digital | GET /api/v1/product-items/{uid}/file/ (já existente ⚠️) | + criar FileDownloadEvent antes do FileResponse |
3.2 Sumário (novos, apps.downloads, gate admin/moderador)
Formato espelhando MediaAnalyticsSummaryView, simplificado (sem heatmap — não faz sentido para download):
{
"total_downloads": 42,
"downloads_30d": 17,
"unique_downloaders": 12,
"average_per_day_30d": 0.6,
"timeline": [{"day": "2026-07-01", "count": 3}, "..."],
"top_downloaders": [{"membership_id": "...", "name": "João", "count": 9}, "..."],
"last_downloads": [{"at": "...", "membership_id": "...", "name": "Maria", "ip": "..."}, "..."]
}Uma única rota genérica por GFK (mais simples de manter do que 3 endpoints espelhados):
GET /api/v1/downloads/analytics/?object_type=drive|media|product&object_id=<uid>→ sumário acima.object_idé sempre ouid(UUID) do alvo; resolução via GFK no app.drive→DriveFile,media→Media,product→ProductItem.- Alternativa (se preferir explícito por superfície): 3 endpoints
GET /api/v1/files/{uid}/analytics/,GET /api/v1/media/{uid}/downloads/,GET /api/v1/products/{pid}/items/{iid}/downloads/. Recomendo o genérico — uma rota, um serializer, uma página no admin.
3.3 Comunidade (dashboard admin)
Estender AdminAnalyticsView (apps/communities/views_analytics.py) com seção files:
"files": {
"total_downloads": 128,
"top_files": [{"label": "tab.pdf", "type": "drive|media|product", "id": "...", "downloads": 34}, "..."],
"timeline": [{"day": "...", "count": 5}, "..."]
}Os endpoints de download (todos autenticados) resolvem request.community via TenantResolutionMiddleware — o community do evento vem de request.community.
4. Mudanças de frontend
| App | Mudança |
|---|---|
apps/web produtos ($itemId.tsx) | Nenhuma ⚠️ — o file_url já apontava para o backend (ProductItemFileView); o download passou a ser rastreado sem tocar no frontend |
apps/web aulas ($lessonId.tsx) | Nenhuma — já passa pelo backend; tracking transparente |
apps/admin /analytics | Nova seção "Downloads" (top arquivos + timeline), consumindo AdminAnalyticsView.files — feito (DownloadStats.tsx) |
apps/admin Drive/Media (telas de listagem) | Coluna opcional com contagem de downloads por arquivo (usa o endpoint genérico por objeto) — não feito, fica como opportunity |
Regra do Orval: qualquer mudança de view/serializer/schema exige make generate-api antes de codar o frontend.
5. Reuso de infra existente
- Presigned URL:
apps.media.security.get_presigned_download_url(já usada em anexos) e o padrão doDriveFileViewSet.download— reaproveitar emProductItemDownloadView. - Gates de acesso:
_resolve_accessible_space+visible_content_qs/resolve_accessible_object(contrato do headless, regra #0 do CLAUDE.md) para produto digital e anexos. - Throttling:
with_default_throttles(...)e classes deapps/communities/throttling.py— aplicar aos endpoints de download e analytics. - AuditLogEvent: opcionalmente registrar "admin_viewed_download_analytics" (não substitui o
FileDownloadEvent, que é o dado de produto). BaseModel: novo modelo estendeapps.utils.models.BaseModel(uid + timestamps).
6. Considerações de segurança/privacidade
- Throttle dedicado nos endpoints de download (ex.:
DownloadRateThrottle) — um crawler gerando presigned URLs inflaria os números. membershipnullable: se futuramente houver conteúdo público baixável, o evento continua válido sem identidade.- IP/UA guardados; exposição no
last_downloadsdo sumário só para admins (já garantido pelo gate). - Retenção: como os playback analytics, sem política de expurgo hoje — registrar no doc técnico quando existir.
7. Fases de implementação (executadas — ADR 0016)
- Fase 1 —
video_media_idno detail de aula (schema + respostas full/stub locked). - Fase 2 — Telemetria de vídeo no SPA (
analyticsprop nos players de aula e produto). - Fase 3 — Permissões:
MediaAnalyticsSummaryView→IsAdminOrModerator(heartbeat permanece permissivo). - Fase 4 — Base
apps.downloads:FileDownloadEvent+ migração + admin +record_download. - Fase 5 — Registro nos fluxos existentes: Drive + anexos de aula.
- Fase 6 — Produto digital: registro no
ProductItemFileViewjá existente ⚠️ (sem endpoint novo). - Fase 7 — Sumários:
GET /api/v1/downloads/analytics/+ seçãofilesnoAdminAnalyticsView(incluiu fix de bug pré-existente:[-12:]em QuerySet 500ava o dashboard). - Fase 8 — UI admin: cards "Downloads" no dashboard (
DownloadStats.tsx). - Fase 9 — Docs:
BACKEND_TECHNICAL.md,CONTEXT.mde este design doc.
Itens fora de escopo (opportunities, não bloqueiam)
- ✅
Ligar a prop— feito (Fases 1–2).analyticsdoVideoPlayerno SPA - Contador denormalizado
downloads_countnos donos (só se o volume exigir). - Coluna de contagem por arquivo nas telas de listagem do admin (Drive/Media) — opcional, usa o endpoint genérico por objeto.
- Analytics de "visualização" de arquivos (abrir/preview vs baixar).
DownloadRateThrottlededicado nos endpoints de download (seção 6) — não implementado no primeiro momento.
8. Perguntas em aberto — resolvidas (ADR 0016)
- Endpoint genérico vs por superfície → genérico (
object_type+object_id): uma rota, um serializer, uma página no admin. file_urlpara oTablatureViewer? → mantido para preview inline; o download viaProductItemFileViewtambém conta como evento (D3).- Exibir contagem publicamente? → não: contagens só no admin (gate
IsAdminOrModerator). Revisitar se o produto exigir exposição pública. - PR único ou por fase? → implementação por fases (1–9), agora consolidadas no ADR 0016.