Versión: 2.1 Fecha: 2025-10-26 Arquitectura: Híbrida (Django Landing + React SPA + REST API + StageRunner)
- Visión General del Proyecto
- Arquitectura del Sistema
- Estado Actual de Implementación
- Nuevas Funcionalidades Requeridas
- Modelo de Datos
- Roles y Permisos
- Especificación de APIs
- Frontend - Componentes y Páginas
- Plan de Implementación por Fases
- Consideraciones Técnicas
DPMS (Demo Party Management System) es un sistema integral para gestionar fiestas de demoscene (demo parties). Permite la organización de eventos, gestión de competiciones, envío de producciones, votaciones, y difusión de información pública sobre el evento.
- Gestión de eventos: Crear y administrar ediciones de demo parties
- Competiciones: Organizar compos (competiciones) con reglas y plazos configurables
- Participación: Permitir a usuarios registrarse, enviar producciones y votar
- Difusión pública: Página de landing SEO-friendly con información del evento
- Administración: Panel de control para organizadores del evento
- Presentación: StageRunner para mostrar producciones en proyector durante el evento
| Rol | Descripción | Permisos |
|---|---|---|
| Visitante Anónimo | Usuario no registrado | Ver landing page, noticias, galería |
| Usuario Participante | Usuario registrado y verificado | Enviar producciones, votar, gestionar perfil |
| Administrador DPMS | Organizador del evento | Gestión completa del sistema |
El sistema utiliza una arquitectura híbrida que combina:
┌─────────────────────────────────────────────────────────────────────────────┐
│ DPMS System Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────────┐
│ Django Frontend │ │ React SPA │ │ StageRunner (React) │
│ (Landing Page) │ │ (App Panel) │ │ (Presentation Display) │
├──────────────────┤ ├──────────────────┤ ├──────────────────────────────┤
│ URL: / │ │ URL: /app/* │ │ URL: /stage/* (standalone) │
│ │ │ │ │ │
│ - Noticias │ │ - Login/Signup │ │ - Countdown screen │
│ - Próximo evento │ │ - Dashboard user │ │ - Production list │
│ - Contador │ │ - Dashboard admin│ │ - Production showcase │
│ - Galería │ │ - Producciones │ │ - Results display │
│ - Información │ │ - Votaciones │ │ - Announcements │
│ │ │ │ │ │
│ SEO: ✓ Indexable │ │ SEO: ✗ No index │ │ Purpose: Projector/Big Screen│
│ Render: SSR │ │ Render: CSR │ │ Control: Keyboard shortcuts │
└────────┬─────────┘ └────────┬─────────┘ └──────────────┬───────────────┘
│ │ │
│ │ │
└─────────────────────┴────────────────────────────┘
│
▼
┌────────────────────────┐
│ Django REST API │
│ URL: /api/* │
├────────────────────────┤
│ - Users API │
│ - Editions API │
│ - Compos API │
│ - Productions API │
│ - News API │
│ - Gallery API │
│ - Votes API │
│ - StageControl API │
└────────────┬───────────┘
│
▼
┌────────────────────────┐
│ PostgreSQL Database │
└────────────────────────┘
- Framework: Django 5.0.x + Django REST Framework
- Base de datos: PostgreSQL 16
- Autenticación: DRF Token + JWT
- Servidor: Gunicorn + Nginx (producción)
- Containerización: Docker + Docker Compose
- Motor de templates: Django Templates
- CSS Framework: Bootstrap 5 o Tailwind CSS
- JavaScript: Vanilla JS (mínimo, solo interactividad)
- Framework: React 18.3
- UI Library: Material-UI 6.3
- Routing: React Router 6
- HTTP Client: Axios 1.7
- Gestión de estado: React Context API
- i18n: i18next
- Desarrollo: Docker Compose (local.yml)
- Producción: Docker Compose (production.yml) + Caddy
- CI/CD: (Por definir)
/ → Django landing (SEO)
/static/ → Archivos estáticos (CSS, JS, imágenes)
/media/ → Uploads de usuarios
/app/ → React SPA
/app/login → Login de usuarios
/app/signup → Registro
/app/verify-account/:token → Verificación de email
/app/dashboard → Dashboard principal
/app/admin/dashboard → Panel administrativo
/api/ → REST API
/api/users/ → Gestión de usuarios
/api/editions/ → Ediciones/eventos
/api/compos/ → Competiciones
/api/productions/ → Producciones enviadas
/api/news/ → Noticias (nueva)
/api/gallery/ → Galería de fotos (nueva)
/api/votes/ → Sistema de votación (nueva)
/api/site-settings/ → Configuración del sitio (nueva)
/admin/ → Django Admin
/docs/ → Swagger API docs
Estado: Completamente implementado
Modelos:
- ✓
User- Usuario con email como USERNAME_FIELD - ✓
Profile- Perfil extendido (nickname, grupo, avatar)
API Endpoints:
- ✓
POST /api/users/signup/- Registro de usuario - ✓
POST /api/users/login/- Login con email/password - ✓
GET /api/users/verify/?token=<jwt>- Verificación de email - ✓
GET /api/users/<email>/- Obtener datos de usuario - ✓
PUT/PATCH /api/users/<email>/profile/- Actualizar perfil
Características:
- ✓ Email verification con JWT (3 días de validez)
- ✓ Dual-token authentication (DRF Token + JWT)
- ✓ Control de concurrencia de sesiones
- ✓ Grupos automáticos: "DPMS Admins" y "DPMS Users"
- ✓ Permisos basados en grupos
- ✓ Django Admin configurado
Estado: Modelos definidos, API NO implementada
Modelos:
- ✓
Edition- Evento/edición de la party - ✓
Compo- Tipo de competición - ✓
HasCompo- Relación M2M con configuración temporal - ✓
Production- Producción enviada - ✓
File- Archivos subidos (con UUID naming) - ⚠
Image- Modelo stub vacío
API Endpoints:
- ✗ No hay serializers
- ✗ No hay viewsets
- ✗ No hay rutas en urls.py
Django Admin:
- ✓ Interfaces admin configuradas para todos los modelos
- ✓
Login- Formulario de login completo - ✓
Signup- Formulario de registro - ✓
VerifyAccount- Verificación de email - ✓
ForgotPassword- Modal de recuperación (no funcional) - ✓
MainBar- Barra de navegación principal - ✓
Content- Contenedor de páginas (placeholder) - ✓
DemoPartyDashboard- Dashboard usuario (vacío) - ✓
AdminDashboard- Dashboard admin (vacío) - ✓
Error404- Página de error
- ✓ Sistema de autenticación completo
- ✓ Context API para estado global
- ✓ Rutas privadas/públicas
- ✓ Navegación responsive
- ✓ i18n (español/inglés)
- ✓ Tema oscuro Material-UI
- ✗ Gestión de competiciones
- ✗ Envío de producciones
- ✗ Sistema de votaciones
- ✗ Galería
- ✗ Páginas de información
- ✗ Gestión de usuarios (admin)
- ✗ Perfil de usuario
Estado: NO implementado
- ✗ No hay templates Django
- ✗ No hay vistas Django para landing
- ✗ No hay modelos para contenido (News, Gallery, etc.)
Página Principal (/)
-
Encabezado/Hero Section:
- Logo del evento
- Nombre y eslogan de la demo party
- Fechas de la próxima edición
- Ubicación del evento
- Contador regresivo hasta el evento
- Call-to-action: "Regístrate ahora" / "Envía tu producción"
-
Sección de Noticias:
- Listado de últimas 5 noticias
- Imagen destacada por noticia
- Título, resumen y fecha
- Enlace a noticia completa
- Paginación para ver más
-
Próximo Evento:
- Información destacada de la próxima edición
- Fecha y hora
- Lugar/venue con mapa embebido
- Competiciones disponibles
- Plazos de envío
-
Cómo Llegar:
- Dirección completa
- Mapa interactivo (Google Maps/OpenStreetMap)
- Indicaciones en transporte público
- Información de parking
- Alojamientos cercanos
-
Galería de Ediciones Anteriores:
- Grid de fotos de eventos pasados
- Filtrado por edición/año
- Lightbox para ver fotos en grande
- Información de la edición en cada foto
-
Footer:
- Enlaces a redes sociales
- Contacto
- Créditos
- Enlace a login/registro de la app
- Title y description dinámicos
- Open Graph tags para redes sociales
- Schema.org markup para eventos
- Sitemap.xml automático
- robots.txt
Gestión (Admin):
- Crear, editar, eliminar noticias
- Título, contenido (rich text), imagen destacada
- Fecha de publicación, autor
- Estado: borrador/publicado
- Categorías/tags
Visualización:
- Lista de noticias en landing
- Página detalle de noticia
- Filtrado por fecha/categoría
- RSS feed
Gestión (Admin):
- Subir múltiples fotos
- Asociar fotos a ediciones específicas
- Título, descripción, fotógrafo
- Orden/destacadas
Visualización:
- Grid responsive en landing
- Lightbox/modal para ver fotos
- Filtro por edición
- Slideshow automático
Settings editables desde Admin:
- Información general del evento
- Fechas de próxima edición
- Ubicación/venue
- Enlaces a redes sociales
- Información de contacto
- Banner/anuncios importantes
- Activar/desactivar secciones
Backend API (falta implementar):
- CRUD completo de Editions
- CRUD completo de Compos
- Gestión de HasCompo (configuración temporal)
- Endpoints públicos (solo lectura) para landing
- Endpoints privados (escritura) para admin
Frontend React:
- Lista de competiciones disponibles
- Reglas de cada competición
- Plazos de envío
- Formulario de envío de producción
- Lista de mis producciones enviadas
Frontend Django Landing:
- Mostrar competiciones del próximo evento
- Información básica y fechas
Backend:
- Modelo Vote
- Restricciones: un voto por usuario por producción
- Cálculo de resultados
- Período de votación configurable
Frontend React:
- Interfaz de votación durante el evento
- Ver producciones por competición
- Emitir votos (puntuación)
- Ver mis votos
- Resultados finales
Backend: Ya existe el modelo
Frontend React - Panel Admin:
- Lista de usuarios registrados
- Búsqueda y filtros
- Ver perfil de usuario
- Activar/desactivar usuarios
- Asignar roles/grupos
- Ver producciones de un usuario
class User(BaseModel, AbstractUser):
email = EmailField(unique=True) # USERNAME_FIELD
username = CharField(max_length=150)
first_name = CharField(max_length=150)
last_name = CharField(max_length=150)
is_verified = BooleanField(default=False)
allow_concurrence = BooleanField(default=False)
# created, modified (from BaseModel)class Profile(BaseModel):
user = OneToOneField(User, on_delete=CASCADE)
extra_information = TextField(max_length=500)
avatar = ImageField(upload_to='users/pictures/')
nickname = CharField(max_length=128)
group = CharField(max_length=128) # Demoscene group
visit_listing = BooleanField(default=False)
# created, modifiedclass Edition(BaseModel):
title = CharField(max_length=255)
description = TextField()
uploaded_by = ForeignKey(User, on_delete=CASCADE)
public = BooleanField(default=False)
open_to_upload = BooleanField(default=False)
open_to_update = BooleanField(default=False)
compos = ManyToManyField(Compo, through='HasCompo')
# created, modifiedclass Compo(BaseModel):
name = CharField(max_length=255)
description = TextField()
created_by = ForeignKey(User, on_delete=CASCADE)
# created, modifiedclass HasCompo(BaseModel):
edition = ForeignKey(Edition, on_delete=CASCADE)
compo = ForeignKey(Compo, on_delete=CASCADE)
start = DateTimeField()
show_authors_on_slide = BooleanField(default=True)
open_to_upload = BooleanField(default=False)
open_to_update = BooleanField(default=False)
created_by = ForeignKey(User, on_delete=CASCADE)
# created, modifiedclass Production(BaseModel):
title = CharField(max_length=255)
authors = CharField(max_length=255)
description = TextField()
uploaded_by = ForeignKey(User, on_delete=CASCADE)
edition = ForeignKey(Edition, on_delete=CASCADE)
compo = ForeignKey(Compo, on_delete=CASCADE)
files = ManyToManyField(File)
# created, modifiedclass File(BaseModel):
title = CharField(max_length=255)
description = TextField()
uploaded_by = ForeignKey(User, on_delete=CASCADE)
original_filename = CharField(max_length=255, editable=False)
file = FileField(upload_to=production_file_upload_to)
public = BooleanField(default=False)
is_active = BooleanField(default=True)
is_deleted = BooleanField(default=False)
# created, modifiedclass News(BaseModel):
"""Noticias sobre el evento para la landing page"""
title = CharField(max_length=255, verbose_name="Título")
slug = SlugField(unique=True, blank=True)
summary = TextField(max_length=500, verbose_name="Resumen")
content = TextField(verbose_name="Contenido completo")
featured_image = ImageField(
upload_to='news/images/',
blank=True,
null=True,
verbose_name="Imagen destacada"
)
author = ForeignKey(User, on_delete=SET_NULL, null=True)
published_at = DateTimeField(null=True, blank=True)
is_published = BooleanField(default=False)
is_featured = BooleanField(default=False) # Destacar en portada
views = PositiveIntegerField(default=0)
category = CharField(
max_length=50,
choices=[
('general', 'General'),
('competition', 'Competición'),
('artist', 'Artista'),
('venue', 'Venue'),
],
default='general'
)
# created, modified (from BaseModel)
class Meta:
verbose_name = "Noticia"
verbose_name_plural = "Noticias"
ordering = ['-published_at', '-created']
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
if self.is_published and not self.published_at:
self.published_at = timezone.now()
super().save(*args, **kwargs)class GalleryImage(BaseModel):
"""Fotos de ediciones anteriores para mostrar en landing"""
title = CharField(max_length=255, verbose_name="Título")
description = TextField(blank=True, verbose_name="Descripción")
image = ImageField(
upload_to='gallery/images/%Y/',
verbose_name="Imagen"
)
thumbnail = ImageField(
upload_to='gallery/thumbnails/%Y/',
blank=True,
null=True,
editable=False,
verbose_name="Miniatura"
)
edition = ForeignKey(
Edition,
on_delete=CASCADE,
related_name='gallery_images',
verbose_name="Edición"
)
photographer = CharField(
max_length=255,
blank=True,
verbose_name="Fotógrafo"
)
uploaded_by = ForeignKey(User, on_delete=SET_NULL, null=True)
is_featured = BooleanField(default=False) # Destacar en portada
display_order = PositiveIntegerField(default=0)
# created, modified
class Meta:
verbose_name = "Foto de Galería"
verbose_name_plural = "Galería de Fotos"
ordering = ['edition', 'display_order', '-created']
def __str__(self):
return f"{self.title} - {self.edition}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# TODO: Generar thumbnail automáticoclass SiteSettings(models.Model):
"""Configuración única del sitio (Singleton pattern)"""
# Información general
site_title = CharField(
max_length=255,
default="Demo Party Management System",
verbose_name="Título del sitio"
)
site_description = TextField(
default="Sistema de gestión de demo parties",
verbose_name="Descripción"
)
site_logo = ImageField(
upload_to='site/',
blank=True,
null=True,
verbose_name="Logo"
)
# Próximo evento
next_edition = ForeignKey(
Edition,
on_delete=SET_NULL,
null=True,
blank=True,
related_name='as_next_edition',
verbose_name="Próxima edición"
)
event_date_start = DateTimeField(
null=True,
blank=True,
verbose_name="Fecha inicio evento"
)
event_date_end = DateTimeField(
null=True,
blank=True,
verbose_name="Fecha fin evento"
)
# Ubicación
venue_name = CharField(max_length=255, blank=True, verbose_name="Nombre del venue")
venue_address = TextField(blank=True, verbose_name="Dirección completa")
venue_city = CharField(max_length=100, blank=True, verbose_name="Ciudad")
venue_country = CharField(max_length=100, blank=True, verbose_name="País")
venue_map_embed = TextField(
blank=True,
verbose_name="Código embed del mapa",
help_text="Código iframe de Google Maps u otro"
)
venue_latitude = DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True
)
venue_longitude = DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True
)
# Información de cómo llegar
transport_info = TextField(
blank=True,
verbose_name="Información de transporte"
)
parking_info = TextField(
blank=True,
verbose_name="Información de parking"
)
accommodation_info = TextField(
blank=True,
verbose_name="Información de alojamiento"
)
# Redes sociales
facebook_url = URLField(blank=True)
twitter_url = URLField(blank=True)
instagram_url = URLField(blank=True)
youtube_url = URLField(blank=True)
discord_url = URLField(blank=True)
# Contacto
contact_email = EmailField(blank=True, verbose_name="Email de contacto")
contact_phone = CharField(max_length=50, blank=True, verbose_name="Teléfono")
# Banner/anuncio
announcement_enabled = BooleanField(default=False)
announcement_text = TextField(blank=True, verbose_name="Texto del anuncio")
announcement_type = CharField(
max_length=20,
choices=[
('info', 'Información'),
('warning', 'Advertencia'),
('success', 'Éxito'),
('danger', 'Peligro'),
],
default='info'
)
# Secciones visibles
show_news = BooleanField(default=True, verbose_name="Mostrar noticias")
show_gallery = BooleanField(default=True, verbose_name="Mostrar galería")
show_countdown = BooleanField(default=True, verbose_name="Mostrar contador")
# Timestamps
updated_at = DateTimeField(auto_now=True)
updated_by = ForeignKey(
User,
on_delete=SET_NULL,
null=True,
blank=True
)
class Meta:
verbose_name = "Configuración del Sitio"
verbose_name_plural = "Configuración del Sitio"
def __str__(self):
return "Configuración del Sitio"
def save(self, *args, **kwargs):
# Singleton pattern - solo una instancia
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def load(cls):
"""Obtener o crear la única instancia"""
obj, created = cls.objects.get_or_create(pk=1)
return objclass VotingConfiguration(BaseModel):
"""
Configuración de votación para una edición.
Permite configurar modalidades mixtas (público + jurado) y control de acceso.
"""
edition = OneToOneField(
Edition,
on_delete=CASCADE,
related_name='voting_config',
verbose_name="Edición"
)
# Modalidad de votación
VOTING_MODE_CHOICES = [
('public', 'Votación Pública 100%'),
('jury', 'Votación por Jurado 100%'),
('mixed', 'Modalidad Mixta (Público + Jurado)'),
]
voting_mode = CharField(
max_length=10,
choices=VOTING_MODE_CHOICES,
default='public',
verbose_name="Modalidad de votación"
)
public_weight = PositiveIntegerField(
default=100,
validators=[MinValueValidator(0), MaxValueValidator(100)],
verbose_name="Peso votación pública (%)",
help_text="Porcentaje de peso de la votación pública (0-100%)"
)
jury_weight = PositiveIntegerField(
default=0,
validators=[MinValueValidator(0), MaxValueValidator(100)],
verbose_name="Peso votación jurado (%)",
help_text="Porcentaje de peso de la votación del jurado (0-100%)"
)
# Control de acceso a votación
ACCESS_MODE_CHOICES = [
('open', 'Abierta por Tiempo'),
('code', 'Código de Asistencia'),
('manual', 'Verificación Manual'),
('checkin', 'Check-in Físico (QR)'),
]
access_mode = CharField(
max_length=10,
choices=ACCESS_MODE_CHOICES,
default='open',
verbose_name="Modo de acceso",
help_text="Cómo se controla quién puede votar"
)
# Configuración de resultados
results_published = BooleanField(
default=False,
verbose_name="Resultados publicados"
)
results_published_at = DateTimeField(
null=True,
blank=True,
verbose_name="Fecha de publicación"
)
show_partial_results = BooleanField(
default=False,
verbose_name="Mostrar resultados parciales",
help_text="Permitir ver resultados antes del cierre de votación"
)
# created, modified
class Meta:
verbose_name = "Configuración de Votación"
verbose_name_plural = "Configuraciones de Votación"
def __str__(self):
return f"Votación - {self.edition.title}"
def clean(self):
"""Validar que los pesos sumen 100% en modo mixto"""
if self.voting_mode == 'mixed':
if self.public_weight + self.jury_weight != 100:
raise ValidationError(
"Los pesos público y jurado deben sumar 100% en modo mixto"
)
def calculate_final_score(self, production):
"""
Calcula el score final de una producción según la modalidad.
Returns:
float: Puntuación final calculada
"""
public_votes = production.votes.filter(is_jury_vote=False)
jury_votes = production.votes.filter(is_jury_vote=True)
public_avg = public_votes.aggregate(Avg('score'))['score__avg'] or 0
jury_avg = jury_votes.aggregate(Avg('score'))['score__avg'] or 0
if self.voting_mode == 'public':
return public_avg
elif self.voting_mode == 'jury':
return jury_avg
else: # mixed
return (
(public_avg * self.public_weight / 100) +
(jury_avg * self.jury_weight / 100)
)
#### AttendanceCode (Código de Asistencia)
```python
class AttendanceCode(BaseModel):
"""
Códigos únicos para verificar asistencia física a la party.
Se generan en lotes y se distribuyen a asistentes.
"""
code = CharField(
max_length=50,
unique=True,
verbose_name="Código"
)
edition = ForeignKey(
Edition,
on_delete=CASCADE,
related_name='attendance_codes',
verbose_name="Edición"
)
is_used = BooleanField(
default=False,
verbose_name="Usado"
)
used_by = ForeignKey(
User,
on_delete=SET_NULL,
null=True,
blank=True,
related_name='used_codes',
verbose_name="Usado por"
)
used_at = DateTimeField(
null=True,
blank=True,
verbose_name="Fecha de uso"
)
notes = TextField(
blank=True,
verbose_name="Notas"
)
# created, modified
class Meta:
verbose_name = "Código de Asistencia"
verbose_name_plural = "Códigos de Asistencia"
ordering = ['code']
def __str__(self):
status = "Usado" if self.is_used else "Disponible"
return f"{self.code} - {self.edition.title} ({status})"
def use_code(self, user):
"""Marca el código como usado por un usuario"""
if self.is_used:
raise ValidationError("Este código ya ha sido utilizado")
self.is_used = True
self.used_by = user
self.used_at = timezone.now()
self.save()
# Crear verificación de asistente
AttendeeVerification.objects.create(
user=user,
edition=self.edition,
is_verified=True,
verification_method='code',
notes=f"Código: {self.code}"
)
#### AttendeeVerification (Verificación de Asistente)
```python
class AttendeeVerification(BaseModel):
"""
Registro de asistentes verificados para una edición.
Controla quién puede votar según el modo de acceso configurado.
"""
user = ForeignKey(
User,
on_delete=CASCADE,
related_name='attendee_verifications',
verbose_name="Usuario"
)
edition = ForeignKey(
Edition,
on_delete=CASCADE,
related_name='verified_attendees',
verbose_name="Edición"
)
is_verified = BooleanField(
default=False,
verbose_name="Verificado"
)
verified_by = ForeignKey(
User,
on_delete=SET_NULL,
null=True,
blank=True,
related_name='verifications_made',
verbose_name="Verificado por"
)
verified_at = DateTimeField(
null=True,
blank=True,
verbose_name="Fecha de verificación"
)
VERIFICATION_METHOD_CHOICES = [
('manual', 'Verificación Manual'),
('code', 'Código de Asistencia'),
('checkin', 'Check-in QR'),
]
verification_method = CharField(
max_length=20,
choices=VERIFICATION_METHOD_CHOICES,
default='manual',
verbose_name="Método de verificación"
)
notes = TextField(
blank=True,
verbose_name="Notas"
)
# created, modified
class Meta:
verbose_name = "Verificación de Asistente"
verbose_name_plural = "Verificaciones de Asistentes"
unique_together = ['user', 'edition']
def __str__(self):
status = "Verificado" if self.is_verified else "Pendiente"
return f"{self.user.email} - {self.edition.title} ({status})"
def can_vote(self):
"""Verifica si el asistente puede votar"""
config = self.edition.voting_config
if config.access_mode == 'open':
# En modo abierto, todos los verificados pueden votar
# durante el período de votación
return True
# En otros modos, debe estar verificado
return self.is_verified
#### JuryMember (Miembro del Jurado)
```python
class JuryMember(BaseModel):
"""
Miembro del jurado para una edición específica.
Puede votar en compos específicas o en todas.
"""
user = ForeignKey(
User,
on_delete=CASCADE,
related_name='jury_memberships',
verbose_name="Usuario"
)
edition = ForeignKey(
Edition,
on_delete=CASCADE,
related_name='jury_members',
verbose_name="Edición"
)
compos = ManyToManyField(
Compo,
blank=True,
related_name='jury_members',
verbose_name="Competiciones",
help_text="Compos en las que puede votar. Vacío = todas las compos"
)
notes = TextField(
blank=True,
verbose_name="Notas",
help_text="Información adicional sobre el miembro del jurado"
)
# created, modified
class Meta:
unique_together = ['user', 'edition']
verbose_name = "Miembro del Jurado"
verbose_name_plural = "Miembros del Jurado"
def __str__(self):
return f"{self.user.email} - Jurado {self.edition.title}"
def can_vote_in_compo(self, compo):
"""
Verifica si este miembro del jurado puede votar en una compo específica.
Args:
compo: Instancia de Compo
Returns:
bool: True si puede votar en esta compo
"""
# Si no tiene compos asignadas, puede votar en todas
if not self.compos.exists():
return True
# Verificar si la compo está en su lista
return self.compos.filter(id=compo.id).exists()
def get_voting_progress(self):
"""
Obtiene el progreso de votación del jurado.
Returns:
dict: Estadísticas de votación
"""
# Obtener compos en las que puede votar
if self.compos.exists():
compos = self.compos.all()
else:
compos = Compo.objects.filter(
hascompo__edition=self.edition
).distinct()
# Contar producciones y votos
total_productions = Production.objects.filter(
edition=self.edition,
compo__in=compos
).count()
votes_cast = Vote.objects.filter(
user=self.user,
production__edition=self.edition,
production__compo__in=compos,
is_jury_vote=True
).count()
return {
'total_productions': total_productions,
'votes_cast': votes_cast,
'pending': total_productions - votes_cast,
'progress_percentage': (votes_cast / total_productions * 100) if total_productions > 0 else 0
}
#### Vote (Sistema de Votación)
```python
class Vote(BaseModel):
"""
Votos de usuarios o jurados en producciones.
Soporta votación pública y por jurado.
"""
user = ForeignKey(
User,
on_delete=CASCADE,
related_name='votes',
verbose_name="Usuario"
)
production = ForeignKey(
Production,
on_delete=CASCADE,
related_name='votes',
verbose_name="Producción"
)
score = PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)],
verbose_name="Puntuación"
)
comment = TextField(
blank=True,
max_length=500,
verbose_name="Comentario"
)
is_jury_vote = BooleanField(
default=False,
verbose_name="Voto de jurado",
help_text="Indica si este voto es de un miembro del jurado"
)
# created, modified
class Meta:
verbose_name = "Voto"
verbose_name_plural = "Votos"
unique_together = [['user', 'production']]
ordering = ['-created']
indexes = [
models.Index(fields=['production', 'is_jury_vote']),
]
def __str__(self):
vote_type = "Jurado" if self.is_jury_vote else "Público"
return f"{self.user.email} → {self.production.title}: {self.score}/10 ({vote_type})"
def clean(self):
"""Validaciones de negocio"""
edition = self.production.edition
# Verificar que existe configuración de votación
if not hasattr(edition, 'voting_config'):
raise ValidationError("La edición no tiene configuración de votación")
config = edition.voting_config
# Verificar período de votación
voting_period = VotingPeriod.objects.filter(
edition=edition,
is_active=True
).first()
if not voting_period or not voting_period.is_open():
raise ValidationError("El período de votación no está abierto")
# Verificar modo de votación
if config.voting_mode == 'jury' and not self.is_jury_vote:
raise ValidationError(
"Esta edición solo acepta votos del jurado"
)
# Si es voto de jurado, verificar que el usuario es miembro del jurado
if self.is_jury_vote:
jury_member = JuryMember.objects.filter(
user=self.user,
edition=edition
).first()
if not jury_member:
raise ValidationError(
"El usuario no es miembro del jurado de esta edición"
)
if not jury_member.can_vote_in_compo(self.production.compo):
raise ValidationError(
f"El jurado no está asignado a la compo {self.production.compo.name}"
)
# Si es voto público, verificar acceso según modalidad
if not self.is_jury_vote and config.access_mode != 'open':
verification = AttendeeVerification.objects.filter(
user=self.user,
edition=edition,
is_verified=True
).first()
if not verification:
raise ValidationError(
"El usuario no está verificado como asistente"
)
def save(self, *args, **kwargs):
"""Auto-detectar si es voto de jurado"""
if not self.pk: # Solo en creación
is_jury = JuryMember.objects.filter(
user=self.user,
edition=self.production.edition
).exists()
if is_jury:
self.is_jury_vote = True
super().save(*args, **kwargs)
#### VotingPeriod (Período de Votación)
```python
class VotingPeriod(BaseModel):
"""
Define el período de tiempo en que se puede votar para una edición o compo.
"""
edition = ForeignKey(
Edition,
on_delete=CASCADE,
related_name='voting_periods',
verbose_name="Edición"
)
compo = ForeignKey(
Compo,
on_delete=CASCADE,
null=True,
blank=True,
related_name='voting_periods',
verbose_name="Competición",
help_text="Dejar vacío para aplicar a todas las compos"
)
start_date = DateTimeField(verbose_name="Inicio de votación")
end_date = DateTimeField(verbose_name="Fin de votación")
is_active = BooleanField(default=True)
# created, modified
class Meta:
verbose_name = "Período de Votación"
verbose_name_plural = "Períodos de Votación"
ordering = ['-start_date']
def __str__(self):
compo_name = self.compo.name if self.compo else "Todas las compos"
return f"{self.edition.title} - {compo_name}"
def is_open(self):
"""Verifica si la votación está abierta ahora"""
now = timezone.now()
return self.is_active and self.start_date <= now <= self.end_date
def clean(self):
"""Validar fechas"""
if self.end_date <= self.start_date:
raise ValidationError(
"La fecha de fin debe ser posterior a la fecha de inicio"
)class Edition(BaseModel):
# ... campos existentes ...
# NUEVOS CAMPOS para landing page
logo = ImageField(
upload_to='editions/logos/',
blank=True,
null=True,
verbose_name="Logo de la edición"
)
banner_image = ImageField(
upload_to='editions/banners/',
blank=True,
null=True,
verbose_name="Imagen de banner"
)
start_date = DateTimeField(
null=True,
blank=True,
verbose_name="Fecha de inicio"
)
end_date = DateTimeField(
null=True,
blank=True,
verbose_name="Fecha de fin"
)
location = CharField(
max_length=255,
blank=True,
verbose_name="Ubicación"
)
is_featured = BooleanField(
default=False,
verbose_name="Edición destacada"
)
slug = SlugField(unique=True, blank=True)class Compo(BaseModel):
# ... campos existentes ...
# NUEVOS CAMPOS
rules = TextField(
blank=True,
verbose_name="Reglas de la competición"
)
icon = CharField(
max_length=50,
blank=True,
help_text="Nombre del icono Material-UI"
)
display_order = PositiveIntegerField(default=0)Puede hacer:
- Ver landing page completa
- Leer noticias
- Ver galería de fotos
- Ver información de la próxima edición
- Ver competiciones y reglas
- Ver producciones públicas (si están configuradas)
- Acceder a login/registro
NO puede hacer:
- Enviar producciones
- Votar
- Acceder a la aplicación React
- Ver datos privados
Puede hacer:
- Todo lo de Visitante Anónimo
- Acceder a
/app/*(React SPA) - Ver su dashboard personal
- Gestionar su perfil
- Enviar producciones a competiciones abiertas
- Editar/eliminar sus propias producciones
- Votar en producciones (durante período de votación)
- Ver resultados de votaciones
- Subir archivos asociados a sus producciones
- Ver historial de sus participaciones
NO puede hacer:
- Crear/editar ediciones
- Crear/editar competiciones
- Gestionar otros usuarios
- Ver votos de otros usuarios
- Modificar producciones de otros
- Acceder al panel de administración
- Publicar noticias
- Gestionar galería
Puede hacer:
- Todo lo de Usuario Participante
- Acceder a
/app/admin/* - Gestión de Ediciones:
- Crear, editar, eliminar ediciones
- Configurar fechas y ubicación
- Activar/desactivar ediciones
- Configurar períodos de envío
- Gestión de Competiciones:
- Crear, editar, eliminar compos
- Configurar reglas y restricciones
- Asociar compos a ediciones (HasCompo)
- Configurar plazos de envío por compo
- Configurar visibilidad de autores
- Gestión de Producciones:
- Ver todas las producciones
- Editar cualquier producción
- Eliminar producciones
- Aprobar/rechazar producciones
- Gestionar archivos asociados
- Gestión de Usuarios:
- Ver lista de usuarios
- Buscar y filtrar usuarios
- Ver perfiles completos
- Activar/desactivar usuarios
- Asignar roles y grupos
- Ver producciones de cada usuario
- Ver votos de cada usuario
- Gestión de Contenido (Landing):
- Crear, editar, eliminar noticias
- Publicar/despublicar noticias
- Gestionar galería de fotos
- Subir múltiples fotos
- Asociar fotos a ediciones
- Configurar fotos destacadas
- Configuración del Sitio:
- Editar configuración general
- Configurar próxima edición
- Gestionar información de venue
- Configurar redes sociales
- Activar/desactivar secciones
- Configurar anuncios/banners
- Gestión de Votaciones:
- Configurar períodos de votación
- Ver resultados en tiempo real
- Exportar resultados
- Validar votos
- Ver estadísticas
- Acceso al Django Admin:
- Acceso completo a
/admin/ - Gestión directa de la base de datos
- Acceso completo a
Permisos específicos en Django:
# Group: DPMS Admins
permissions = [
'add_edition', 'change_edition', 'delete_edition', 'view_edition',
'add_compo', 'change_compo', 'delete_compo', 'view_compo',
'add_hascompo', 'change_hascompo', 'delete_hascompo', 'view_hascompo',
'add_production', 'change_production', 'delete_production', 'view_production',
'add_file', 'change_file', 'delete_file', 'view_file',
'add_news', 'change_news', 'delete_news', 'view_news',
'add_galleryimage', 'change_galleryimage', 'delete_galleryimage', 'view_galleryimage',
'add_vote', 'change_vote', 'delete_vote', 'view_vote',
'change_sitesettings', 'view_sitesettings',
'view_user', 'change_user',
'view_profile', 'change_profile',
]POST /api/users/signup/
- Permisos: AllowAny
- Body:
{ "email": "user@example.com", "username": "username", "password": "securepass123", "password_confirmation": "securepass123", "first_name": "John", "last_name": "Doe", "nickname": "JD", "group": "DemoGroup" } - Response:
201 Created{ "email": "user@example.com", "first_name": "John", "last_name": "Doe", "profile": { "nickname": "JD", "group": "DemoGroup", "extra_information": "", "avatar": null, "visit_listing": false }, "groups": ["DPMS Users"] }
POST /api/users/login/
- Permisos: AllowAny
- Body:
{ "email": "user@example.com", "password": "securepass123" } - Response:
202 Accepted{ "user": { ... }, "access_token": "drf_token_here", "jwt_access_token": "jwt_token_here", "groups": ["DPMS Users"] }
GET /api/users/verify/?token=<jwt>
- Permisos: AllowAny
- Response:
200 OK
GET /api/users/<email>/
- Permisos: IsAuthenticated + IsAccountOwner
- Response:
200 OK+ user data + fresh JWT
PUT/PATCH /api/users/<email>/profile/
- Permisos: IsAuthenticated + IsAccountOwner
- Body: Campos de Profile
- Response:
200 OK+ updated user data
GET /api/editions/
- Permisos: AllowAny (solo públicas), IsAuthenticated (todas)
- Query params:
public=true- Solo ediciones públicasupcoming=true- Solo próximas edicionespast=true- Solo ediciones pasadas
- Response: Lista de ediciones
GET /api/editions/<id>/
- Permisos: AllowAny (si pública), IsAuthenticated
- Response: Detalle de edición + compos asociadas
POST /api/editions/
- Permisos: IsAdminUser
- Body: Campos de Edition
- Response:
201 Created
PUT/PATCH /api/editions/<id>/
- Permisos: IsAdminUser
- Response:
200 OK
DELETE /api/editions/<id>/
- Permisos: IsAdminUser
- Response:
204 No Content
GET /api/editions/<id>/compos/
- Permisos: AllowAny
- Response: Lista de compos de la edición
GET /api/editions/<id>/productions/
- Permisos: AllowAny (si públicas), IsAuthenticated
- Response: Lista de producciones de la edición
GET /api/editions/<id>/results/
- Permisos: AllowAny (si votación cerrada)
- Response: Resultados de votación por compo
GET /api/compos/
- Permisos: AllowAny
- Response: Lista de tipos de competiciones
GET /api/compos/<id>/
- Permisos: AllowAny
- Response: Detalle de compo + reglas
POST /api/compos/
- Permisos: IsAdminUser
- Body: Campos de Compo
- Response:
201 Created
PUT/PATCH /api/compos/<id>/
- Permisos: IsAdminUser
- Response:
200 OK
DELETE /api/compos/<id>/
- Permisos: IsAdminUser
- Response:
204 No Content
GET /api/productions/
- Permisos: IsAuthenticated
- Query params:
edition=<id>compo=<id>my_productions=true- Solo del usuario actual
- Response: Lista de producciones
GET /api/productions/<id>/
- Permisos: IsAuthenticated
- Response: Detalle de producción + archivos
POST /api/productions/
- Permisos: IsAuthenticated
- Body:
{ "title": "My Demo", "authors": "DemoGroup", "description": "Description here", "edition": 1, "compo": 2, "files": [1, 2] // IDs de archivos ya subidos } - Response:
201 Created
PUT/PATCH /api/productions/<id>/
- Permisos: IsOwner OR IsAdminUser
- Response:
200 OK
DELETE /api/productions/<id>/
- Permisos: IsOwner OR IsAdminUser
- Response:
204 No Content
POST /api/files/upload/
- Permisos: IsAuthenticated
- Body:
multipart/form-datacon archivo - Response:
201 Created{ "id": 1, "title": "demo.zip", "original_filename": "demo.zip", "file": "/media/files/edition/compo/demo_uuid.zip", "uploaded_by": "user@example.com" }
GET /api/files/<id>/download/
- Permisos: IsAuthenticated (si privado), AllowAny (si público)
- Response: File download
DELETE /api/files/<id>/
- Permisos: IsOwner OR IsAdminUser
- Response:
204 No Content
GET /api/news/
- Permisos: AllowAny
- Query params:
is_published=trueis_featured=truecategory=<category>page=<number>page_size=<number>
- Response: Lista paginada de noticias
GET /api/news/<slug>/
- Permisos: AllowAny
- Response: Detalle de noticia
- Side effect: Incrementa views
POST /api/news/
- Permisos: IsAdminUser
- Body: Campos de News
- Response:
201 Created
PUT/PATCH /api/news/<slug>/
- Permisos: IsAdminUser
- Response:
200 OK
DELETE /api/news/<slug>/
- Permisos: IsAdminUser
- Response:
204 No Content
GET /api/gallery/
- Permisos: AllowAny
- Query params:
edition=<id>is_featured=true
- Response: Lista de imágenes
POST /api/gallery/upload/
- Permisos: IsAdminUser
- Body:
multipart/form-datacon imagen(es) - Response:
201 Created
PUT/PATCH /api/gallery/<id>/
- Permisos: IsAdminUser
- Response:
200 OK
DELETE /api/gallery/<id>/
- Permisos: IsAdminUser
- Response:
204 No Content
GET /api/votes/
- Permisos: IsAuthenticated
- Query params:
my_votes=true- Solo votos del usuarioproduction=<id>
- Response: Lista de votos (solo propios si no es admin)
POST /api/votes/
- Permisos: IsAuthenticated
- Body:
{ "production": 1, "score": 8, "comment": "Great work!" } - Validations:
- Verificar que votación esté abierta
- Verificar que no haya votado ya
- Verificar que score esté entre 1-10
- Response:
201 Created
PUT/PATCH /api/votes/<id>/
- Permisos: IsOwner (si votación aún abierta)
- Response:
200 OK
DELETE /api/votes/<id>/
- Permisos: IsOwner (si votación aún abierta)
- Response:
204 No Content
GET /api/votes/results/<edition_id>/
- Permisos: AllowAny (si votación cerrada)
- Response: Resultados agrupados por compo
{ "edition": "Posadas Party 2025", "compos": [ { "compo": "Demo", "productions": [ { "position": 1, "production": "Best Demo", "authors": "DemoGroup", "score": 9.2, "votes_count": 45 } ] } ] }
GET /api/site-settings/
- Permisos: AllowAny
- Response: Configuración completa del sitio
PUT/PATCH /api/site-settings/
- Permisos: IsAdminUser
- Body: Campos de SiteSettings
- Response:
200 OK
backend/dpms/landing/
├── templates/
│ ├── landing/
│ │ ├── base.html # Base template con SEO
│ │ ├── index.html # Página principal
│ │ ├── news_list.html # Lista de noticias
│ │ ├── news_detail.html # Detalle de noticia
│ │ ├── gallery.html # Galería completa
│ │ └── partials/
│ │ ├── header.html
│ │ ├── hero.html
│ │ ├── news_section.html
│ │ ├── gallery_section.html
│ │ ├── countdown.html
│ │ └── footer.html
├── static/
│ ├── landing/
│ │ ├── css/
│ │ │ ├── main.css
│ │ │ └── responsive.css
│ │ ├── js/
│ │ │ ├── countdown.js
│ │ │ ├── gallery.js
│ │ │ └── main.js
│ │ └── images/
│ │ └── (logos, backgrounds, etc.)
├── views.py
├── urls.py
└── context_processors.py
# views.py
from django.views.generic import TemplateView, ListView, DetailView
from .models import News, GalleryImage, Edition, SiteSettings
class LandingPageView(TemplateView):
template_name = 'landing/index.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
settings = SiteSettings.load()
context.update({
'settings': settings,
'latest_news': News.objects.filter(
is_published=True
).order_by('-published_at')[:5],
'featured_images': GalleryImage.objects.filter(
is_featured=True
)[:6],
'next_edition': settings.next_edition,
})
return context
class NewsListView(ListView):
model = News
template_name = 'landing/news_list.html'
context_object_name = 'news_list'
paginate_by = 10
queryset = News.objects.filter(is_published=True)
class NewsDetailView(DetailView):
model = News
template_name = 'landing/news_detail.html'
context_object_name = 'news'
slug_field = 'slug'
def get_queryset(self):
return News.objects.filter(is_published=True)
def get_object(self, queryset=None):
obj = super().get_object(queryset)
# Incrementar vistas
obj.views += 1
obj.save(update_fields=['views'])
return obj
class GalleryView(ListView):
model = GalleryImage
template_name = 'landing/gallery.html'
context_object_name = 'images'
def get_queryset(self):
qs = GalleryImage.objects.all()
edition_id = self.request.GET.get('edition')
if edition_id:
qs = qs.filter(edition_id=edition_id)
return qsbase.html - Template base con SEO
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block seo %}
<title>{% block title %}{{ settings.site_title }}{% endblock %}</title>
<meta name="description" content="{% block description %}{{ settings.site_description }}{% endblock %}">
<!-- Open Graph -->
<meta property="og:title" content="{% block og_title %}{{ settings.site_title }}{% endblock %}">
<meta property="og:description" content="{% block og_description %}{{ settings.site_description }}{% endblock %}">
<meta property="og:image" content="{% block og_image %}{{ settings.site_logo.url }}{% endblock %}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ request.build_absolute_uri }}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{% block twitter_title %}{{ settings.site_title }}{% endblock %}">
<meta name="twitter:description" content="{% block twitter_description %}{{ settings.site_description }}{% endblock %}">
<meta name="twitter:image" content="{% block twitter_image %}{{ settings.site_logo.url }}{% endblock %}">
<!-- Schema.org markup for Google -->
{% block schema %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Event",
"name": "{{ settings.site_title }}",
"description": "{{ settings.site_description }}",
"startDate": "{{ settings.event_date_start|date:'c' }}",
"endDate": "{{ settings.event_date_end|date:'c' }}",
"location": {
"@type": "Place",
"name": "{{ settings.venue_name }}",
"address": {
"@type": "PostalAddress",
"streetAddress": "{{ settings.venue_address }}",
"addressLocality": "{{ settings.venue_city }}",
"addressCountry": "{{ settings.venue_country }}"
}
}
}
</script>
{% endblock %}
{% endblock seo %}
<!-- CSS -->
<link rel="stylesheet" href="{% static 'landing/css/main.css' %}">
{% block extra_css %}{% endblock %}
</head>
<body>
{% include 'landing/partials/header.html' %}
{% if settings.announcement_enabled %}
<div class="announcement announcement-{{ settings.announcement_type }}">
{{ settings.announcement_text }}
</div>
{% endif %}
<main>
{% block content %}{% endblock %}
</main>
{% include 'landing/partials/footer.html' %}
<!-- JS -->
<script src="{% static 'landing/js/main.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>index.html - Página principal
{% extends 'landing/base.html' %}
{% load static %}
{% block content %}
<!-- Hero Section -->
{% include 'landing/partials/hero.html' %}
<!-- Countdown -->
{% if settings.show_countdown and settings.event_date_start %}
{% include 'landing/partials/countdown.html' %}
{% endif %}
<!-- News Section -->
{% if settings.show_news %}
{% include 'landing/partials/news_section.html' %}
{% endif %}
<!-- Next Edition Info -->
{% if next_edition %}
<section class="next-edition">
<div class="container">
<h2>Próxima Edición</h2>
<div class="edition-info">
<h3>{{ next_edition.title }}</h3>
<p>{{ next_edition.description }}</p>
<div class="edition-details">
<div class="detail">
<strong>Fecha:</strong>
{{ settings.event_date_start|date:"d/m/Y" }} -
{{ settings.event_date_end|date:"d/m/Y" }}
</div>
<div class="detail">
<strong>Lugar:</strong>
{{ settings.venue_name }}, {{ settings.venue_city }}
</div>
</div>
<!-- Compos disponibles -->
<h4>Competiciones</h4>
<div class="compos-grid">
{% for hascompo in next_edition.hascompo_set.all %}
<div class="compo-card">
<h5>{{ hascompo.compo.name }}</h5>
<p>{{ hascompo.compo.description|truncatewords:20 }}</p>
</div>
{% endfor %}
</div>
<a href="/app/signup" class="btn btn-primary">¡Participa ahora!</a>
</div>
</div>
</section>
{% endif %}
<!-- Como Llegar -->
<section class="venue-info">
<div class="container">
<h2>¿Cómo llegar?</h2>
<div class="venue-grid">
<div class="map">
{{ settings.venue_map_embed|safe }}
</div>
<div class="info">
<div class="info-block">
<h4>Dirección</h4>
<p>{{ settings.venue_address }}</p>
</div>
{% if settings.transport_info %}
<div class="info-block">
<h4>Transporte Público</h4>
{{ settings.transport_info|linebreaks }}
</div>
{% endif %}
{% if settings.parking_info %}
<div class="info-block">
<h4>Parking</h4>
{{ settings.parking_info|linebreaks }}
</div>
{% endif %}
{% if settings.accommodation_info %}
<div class="info-block">
<h4>Alojamiento</h4>
{{ settings.accommodation_info|linebreaks }}
</div>
{% endif %}
</div>
</div>
</div>
</section>
<!-- Gallery Section -->
{% if settings.show_gallery %}
{% include 'landing/partials/gallery_section.html' %}
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'landing/js/countdown.js' %}"></script>
<script>
// Inicializar countdown
initCountdown('{{ settings.event_date_start|date:"c" }}');
</script>
{% endblock %}partials/countdown.html
<section class="countdown-section">
<div class="container">
<h2>Cuenta Atrás</h2>
<div id="countdown" class="countdown">
<div class="countdown-item">
<span class="countdown-value" id="days">0</span>
<span class="countdown-label">Días</span>
</div>
<div class="countdown-item">
<span class="countdown-value" id="hours">0</span>
<span class="countdown-label">Horas</span>
</div>
<div class="countdown-item">
<span class="countdown-value" id="minutes">0</span>
<span class="countdown-label">Minutos</span>
</div>
<div class="countdown-item">
<span class="countdown-value" id="seconds">0</span>
<span class="countdown-label">Segundos</span>
</div>
</div>
</div>
</section>static/landing/js/countdown.js
function initCountdown(targetDateISO) {
const targetDate = new Date(targetDateISO);
function updateCountdown() {
const now = new Date();
const diff = targetDate - now;
if (diff <= 0) {
document.getElementById('countdown').innerHTML =
'<p class="countdown-ended">¡El evento ha comenzado!</p>';
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
document.getElementById('days').textContent = days;
document.getElementById('hours').textContent = hours.toString().padStart(2, '0');
document.getElementById('minutes').textContent = minutes.toString().padStart(2, '0');
document.getElementById('seconds').textContent = seconds.toString().padStart(2, '0');
}
updateCountdown();
setInterval(updateCountdown, 1000);
}frontend/src/
├── components/
│ ├── user/ # (Ya existe)
│ │ ├── Login.js
│ │ ├── Signup.js
│ │ └── VerifyAccount.js
│ ├── admin/ # NUEVO
│ │ ├── UsersManagement.js
│ │ ├── EditionsManagement.js
│ │ ├── ComposManagement.js
│ │ ├── ProductionsManagement.js
│ │ ├── NewsManagement.js
│ │ ├── GalleryManagement.js
│ │ ├── SiteSettingsManagement.js
│ │ └── VotingResults.js
│ ├── productions/ # NUEVO
│ │ ├── ProductionsList.js
│ │ ├── ProductionDetail.js
│ │ ├── ProductionForm.js
│ │ ├── MyProductions.js
│ │ └── FileUpload.js
│ ├── voting/ # NUEVO
│ │ ├── VotingPanel.js
│ │ ├── CompoVoting.js
│ │ ├── ProductionVoteCard.js
│ │ └── VotingResults.js
│ ├── profile/ # NUEVO
│ │ ├── UserProfile.js
│ │ ├── EditProfile.js
│ │ └── MyVotes.js
│ └── common/ # NUEVO
│ ├── DataTable.js
│ ├── ConfirmDialog.js
│ ├── FileUploader.js
│ └── ImageGallery.js
└── pages/ # NUEVO
├── user/
│ ├── DashboardPage.js
│ ├── ProfilePage.js
│ └── MyProductionsPage.js
├── admin/
│ ├── AdminDashboardPage.js
│ ├── UsersPage.js
│ ├── EditionsPage.js
│ ├── ComposPage.js
│ ├── NewsPage.js
│ ├── GalleryPage.js
│ └── SettingsPage.js
└── voting/
├── VotingPage.js
└── ResultsPage.js
// routes.js
const routes = [
// Existentes
{ path: '/app/login', component: Login, isPrivate: false },
{ path: '/app/signup', component: Signup, isPrivate: false },
{ path: '/app/verify-account/:token', component: VerifyAccount, isPrivate: false },
// Usuario
{ path: '/app/dashboard', component: DashboardPage, isPrivate: true },
{ path: '/app/profile', component: ProfilePage, isPrivate: true },
{ path: '/app/my-productions', component: MyProductionsPage, isPrivate: true },
{ path: '/app/productions', component: ProductionsListPage, isPrivate: true },
{ path: '/app/productions/:id', component: ProductionDetailPage, isPrivate: true },
{ path: '/app/productions/new', component: ProductionFormPage, isPrivate: true },
// Votación
{ path: '/app/voting', component: VotingPage, isPrivate: true },
{ path: '/app/voting/:editionId', component: VotingPage, isPrivate: true },
{ path: '/app/results/:editionId', component: ResultsPage, isPrivate: true },
// Admin
{ path: '/app/admin/dashboard', component: AdminDashboardPage, isPrivate: true, adminOnly: true },
{ path: '/app/admin/users', component: UsersPage, isPrivate: true, adminOnly: true },
{ path: '/app/admin/editions', component: EditionsPage, isPrivate: true, adminOnly: true },
{ path: '/app/admin/compos', component: ComposPage, isPrivate: true, adminOnly: true },
{ path: '/app/admin/news', component: NewsPage, isPrivate: true, adminOnly: true },
{ path: '/app/admin/gallery', component: GalleryPage, isPrivate: true, adminOnly: true },
{ path: '/app/admin/settings', component: SettingsPage, isPrivate: true, adminOnly: true },
// Redirect
{ path: '/app', redirect: '/app/dashboard' },
{ path: '*', component: Error404 }
];// AdminRoute.js
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthContext';
const AdminRoute = ({ children }) => {
const { isAuthenticated, groups, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/app/login" replace />;
}
if (!groups.includes('DPMS Admins')) {
return <Navigate to="/app/dashboard" replace />;
}
return children;
};
export default AdminRoute;import React, { useState, useEffect } from 'react';
import {
Box,
TextField,
Button,
Select,
MenuItem,
FormControl,
InputLabel,
Typography,
Alert,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import axios from '../utils/AxiosWrapper';
import FileUpload from './FileUpload';
const ProductionForm = ({ productionId = null }) => {
const navigate = useNavigate();
const [formData, setFormData] = useState({
title: '',
authors: '',
description: '',
edition: '',
compo: '',
files: [],
});
const [editions, setEditions] = useState([]);
const [compos, setCompos] = useState([]);
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchEditions();
}, []);
useEffect(() => {
if (formData.edition) {
fetchCompos(formData.edition);
}
}, [formData.edition]);
useEffect(() => {
if (productionId) {
fetchProduction(productionId);
}
}, [productionId]);
const fetchEditions = async () => {
try {
const response = await axios.get('/api/editions/?open_to_upload=true');
setEditions(response.data.results || response.data);
} catch (error) {
console.error('Error fetching editions:', error);
}
};
const fetchCompos = async (editionId) => {
try {
const response = await axios.get(`/api/editions/${editionId}/compos/`);
setCompos(response.data);
} catch (error) {
console.error('Error fetching compos:', error);
}
};
const fetchProduction = async (id) => {
try {
const response = await axios.get(`/api/productions/${id}/`);
setFormData(response.data);
} catch (error) {
console.error('Error fetching production:', error);
}
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleFilesUploaded = (fileIds) => {
setFormData({
...formData,
files: fileIds,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setErrors({});
try {
if (productionId) {
await axios.put(`/api/productions/${productionId}/`, formData);
} else {
await axios.post('/api/productions/', formData);
}
navigate('/app/my-productions');
} catch (error) {
if (error.response?.data) {
setErrors(error.response.data);
}
} finally {
setLoading(false);
}
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ maxWidth: 800, mx: 'auto', p: 3 }}>
<Typography variant="h4" gutterBottom>
{productionId ? 'Editar Producción' : 'Enviar Producción'}
</Typography>
{errors.non_field_errors && (
<Alert severity="error" sx={{ mb: 2 }}>
{errors.non_field_errors}
</Alert>
)}
<FormControl fullWidth margin="normal">
<InputLabel>Edición</InputLabel>
<Select
name="edition"
value={formData.edition}
onChange={handleChange}
required
>
{editions.map((edition) => (
<MenuItem key={edition.id} value={edition.id}>
{edition.title}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="normal">
<InputLabel>Competición</InputLabel>
<Select
name="compo"
value={formData.compo}
onChange={handleChange}
disabled={!formData.edition}
required
>
{compos.map((compo) => (
<MenuItem key={compo.id} value={compo.id}>
{compo.name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
margin="normal"
name="title"
label="Título"
value={formData.title}
onChange={handleChange}
error={!!errors.title}
helperText={errors.title}
required
/>
<TextField
fullWidth
margin="normal"
name="authors"
label="Autores / Grupo"
value={formData.authors}
onChange={handleChange}
error={!!errors.authors}
helperText={errors.authors}
required
/>
<TextField
fullWidth
margin="normal"
name="description"
label="Descripción"
value={formData.description}
onChange={handleChange}
multiline
rows={4}
error={!!errors.description}
helperText={errors.description}
/>
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Archivos
</Typography>
<FileUpload
onFilesUploaded={handleFilesUploaded}
initialFiles={formData.files}
/>
</Box>
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
<Button
type="submit"
variant="contained"
color="primary"
disabled={loading}
>
{loading ? 'Guardando...' : 'Guardar'}
</Button>
<Button
variant="outlined"
onClick={() => navigate('/app/my-productions')}
>
Cancelar
</Button>
</Box>
</Box>
);
};
export default ProductionForm;Duración estimada: 2-3 semanas
Backend:
- Crear app Django
landing - Implementar modelos:
News,GalleryImage,SiteSettings - Modificar modelo
Edition(añadir campos para landing) - Crear serializers para API pública (News, Gallery, Settings)
- Implementar ViewSets con permisos adecuados
- Crear vistas Django para templates
- Configurar URLs
- Añadir permisos a grupos
- Configurar Django Admin para nuevos modelos
Frontend Django:
- Crear templates base con SEO
- Implementar página principal (index)
- Sección de noticias
- Sección de galería
- Contador regresivo
- Información de venue
- Footer con redes sociales
- CSS responsive
- JavaScript para interactividad (countdown, lightbox)
Testing:
- Verificar renderizado server-side
- Probar SEO con herramientas (Google Search Console)
- Validar meta tags
- Probar responsividad
Duración estimada: 2-3 semanas
Backend:
- Crear serializers para
Edition,Compo,HasCompo,Production,File - Implementar ViewSets completos (CRUD)
- Añadir endpoints específicos:
/api/editions/<id>/compos//api/editions/<id>/productions//api/files/upload//api/files/<id>/download/
- Implementar permisos personalizados (IsOwner)
- Validaciones de negocio:
- Verificar plazos de envío
- Verificar edición abierta
- Limitar archivos por tamaño
- Configurar URLs
Testing:
- Tests unitarios de serializers
- Tests de endpoints (pytest)
- Tests de permisos
- Tests de validaciones
Duración estimada: 2-3 semanas
Frontend:
- Implementar
ProductionsList.js - Implementar
ProductionForm.js(crear/editar) - Implementar
MyProductions.js - Implementar
FileUpload.js(componente reutilizable) - Implementar
ProductionDetail.js - Añadir rutas
- Actualizar navegación
- Integrar con API
Testing:
- Tests de componentes (Jest + RTL)
- Tests de integración con API (mock)
- Tests E2E (Cypress o Playwright)
Duración estimada: 2-3 semanas
Backend:
- Crear modelos
Vote,VotingPeriod - Crear serializers
- Implementar ViewSet de votos con validaciones:
- Un voto por usuario por producción
- Verificar período de votación abierto
- Score entre 1-10
- Endpoint de resultados con agregación
- Configurar permisos
Frontend:
- Implementar
VotingPanel.js - Implementar
CompoVoting.js(listar producciones por compo) - Implementar
ProductionVoteCard.js(tarjeta con slider de voto) - Implementar
VotingResults.js(resultados finales) - Añadir rutas y navegación
Testing:
- Tests de validaciones de voto
- Tests de cálculo de resultados
- Tests de UI de votación
Duración estimada: 3-4 semanas
Frontend Admin:
- Implementar
UsersManagement.js:- Tabla con búsqueda y filtros
- Ver detalle de usuario
- Activar/desactivar usuarios
- Asignar grupos
- Implementar
EditionsManagement.js:- CRUD completo de ediciones
- Configurar compos asociadas
- Configurar plazos
- Implementar
ComposManagement.js:- CRUD de tipos de competiciones
- Editar reglas
- Implementar
ProductionsManagement.js:- Ver todas las producciones
- Filtrar por edición/compo
- Aprobar/rechazar (si se implementa moderación)
- Implementar
NewsManagement.js:- CRUD de noticias
- Editor rich text (TinyMCE o similar)
- Vista previa
- Implementar
GalleryManagement.js:- Upload masivo de fotos
- Arrastrar y soltar orden
- Asociar a ediciones
- Implementar
SiteSettingsManagement.js:- Formulario de configuración general
- Vista previa de landing
- Implementar
VotingResults.js:- Dashboard de resultados en tiempo real
- Gráficos (Chart.js o similar)
- Exportar a CSV/PDF
Componentes Reutilizables:
-
DataTable.js(tabla genérica con paginación, ordenación, filtros) -
ConfirmDialog.js(diálogo de confirmación) -
ImageGallery.js(gestión de galería de imágenes) -
RichTextEditor.js(editor WYSIWYG)
Testing:
- Tests de cada página admin
- Tests de permisos (solo admin puede acceder)
Duración estimada: 1 semana
Frontend:
- Implementar
UserProfile.js(vista del perfil) - Implementar
EditProfile.js(editar perfil, avatar, etc.) - Implementar
MyVotes.js(historial de votos) - Añadir rutas y navegación
Testing:
- Tests de actualización de perfil
- Tests de subida de avatar
Duración estimada: 3-4 semanas
Descripción: Aplicación React independiente para mostrar producciones en proyector durante el evento.
Backend (Extensiones API):
- Añadir
currentaction a EditionViewSet - Añadir filtros
statusyshow_on_beamera HasCompoViewSet - Añadir campos
positionytotal_in_compoa ProductionSerializer - Crear modelo
StageControl(opcional Phase 2) - Crear endpoint
/api/stage-control/current/(opcional Phase 2)
Frontend StageRunner (Aplicación independiente):
- Setup proyecto React en
stagerunner/ - Implementar API service layer con caching
- Implementar StageContext (estado global)
- Implementar keyboard controls hook
- Implementar screens:
- IdleScreen (logo, próximo compo)
- CountdownScreen (cuenta regresiva)
- ProductionListScreen (lista numerada)
- ProductionShowScreen (showcase individual)
- ResultsScreen (tabla de resultados)
- AnnouncementScreen (anuncios)
- Implementar transiciones entre pantallas
- Implementar modo slideshow/rotación
- Implementar fullscreen API
- Implementar caché local con fallback offline
- Implementar theme customization
Testing:
- Tests de keyboard shortcuts
- Tests de API caching
- Tests de offline mode
- Test en proyector 1920x1080
- Test de transiciones suaves
Documentación:
- STAGERUNNER_SPEC.md completo
Duración estimada: 1-2 semanas
- Optimización de queries (select_related, prefetch_related)
- Añadir caché (Redis) para landing page
- Optimizar imágenes (thumbnails automáticos)
- Añadir paginación donde falte
- Mejorar mensajes de error
- Añadir loading states
- Añadir animaciones
- Revisar accesibilidad (a11y)
- Auditoría de seguridad
- Documentación API (Swagger completo)
- README actualizado
Duración estimada: 1-2 semanas
- Tests de integración completos
- Tests E2E de flujos críticos:
- Registro → Verificación → Login → Enviar producción → Votar
- Admin: Crear edición → Configurar compos → Gestionar producciones
- Performance testing
- Security testing (OWASP Top 10)
- Configurar CI/CD
- Preparar producción:
- Configurar Caddy
- Configurar backups automáticos
- Monitoreo (Sentry, logs)
- Deploy a producción
- Smoke tests en producción
Total estimado: 18-24 semanas (~4.5-6 meses)
Landing Page:
- HTML semántico correcto
- Meta tags completos (title, description, OG, Twitter Card)
- Schema.org markup para eventos
- Sitemap.xml automático
- Robots.txt
- Lazy loading de imágenes
- Minificación de CSS/JS
- Caché de páginas estáticas (Redis/Varnish)
Imágenes:
- Generar thumbnails automáticamente (Pillow)
- Formatos optimizados (WebP con fallback)
- Lazy loading
- CDN para media files (opcional)
Autenticación:
- HTTPS obligatorio en producción
- Tokens con expiración
- Rate limiting en endpoints de login
- CORS configurado correctamente
- CSRF protection habilitado
Uploads:
- Validación de tipo de archivo
- Límite de tamaño por archivo
- Sanitización de nombres de archivo
- Escaneo de virus (opcional - ClamAV)
- Almacenamiento fuera de webroot o con permisos restringidos
API:
- Permisos granulares
- Validación de entrada en serializers
- Protección contra SQL injection (ORM Django)
- Protección contra XSS (React)
- Rate limiting (Django Ratelimit)
Base de datos:
- Índices en campos de búsqueda frecuente
- Paginación en todos los listados
- select_related / prefetch_related para optimizar queries
- Connection pooling (PgBouncer)
Media Files:
- Considerar S3 o similar para uploads en producción
- CDN para servir archivos estáticos
Caché:
- Redis para caché de landing page
- Caché de resultados de votación
- Caché de queries pesadas
Background Tasks:
- Celery para tareas asíncronas:
- Envío de emails
- Generación de thumbnails
- Cálculo de resultados
- Exportación de datos
Logging:
- Logs estructurados (JSON)
- Niveles adecuados (DEBUG en dev, INFO/ERROR en prod)
- Rotación de logs
- Centralización (ELK, Graylog, o similar)
Monitoreo:
- Sentry para errores
- Métricas de performance (Django Debug Toolbar en dev)
- Uptime monitoring
- Alertas automáticas
Analytics:
- Google Analytics en landing
- Tracking de conversiones (registro, envíos, votos)
- Heatmaps (opcional - Hotjar)
Base de datos:
- Backups automáticos diarios (PostgreSQL dump)
- Retención de 30 días
- Backups offsite
- Procedimiento de restore documentado
Media files:
- Backup sincronizado con BD
- Versionado (opcional)
Configuración:
- Variables de entorno en .env (no en repo)
- Secrets management (Docker secrets, Vault, etc.)
Backend:
- Django i18n configurado
- Traducciones en español (primario) e inglés
Frontend:
- i18next ya configurado
- Completar traducciones faltantes
- Selector de idioma visible
Landing:
- Contenido en español por defecto
- Considerar versión en inglés (subdominio o /en/)
- Cumplir WCAG 2.1 AA
- Navegación por teclado
- Screen reader friendly
- Contraste de colores adecuado
- Alt text en imágenes
- ARIA labels donde sea necesario
Backend:
- Coverage mínimo: 80%
- Tests de modelos, serializers, views, permisos
- Fixtures para datos de prueba
- pytest + pytest-django
Frontend:
- Tests de componentes (Jest + RTL)
- Tests de integración con API mock
- Tests E2E de flujos críticos (Cypress/Playwright)
- Coverage mínimo: 70%
API:
- Swagger/OpenAPI completo
- Ejemplos de requests/responses
- Códigos de error documentados
Código:
- Docstrings en funciones complejas
- Comentarios donde sea necesario
- Type hints en Python (opcional)
Proyecto:
- README actualizado
- Guía de instalación
- Guía de despliegue
- Arquitectura documentada (este documento)
- Changelog
- ✅ Sistema de autenticación completo
- ✅ Modelos de competiciones definidos
- ✅ Frontend React básico funcional
- ⚠ API de competiciones NO implementada
- ❌ Landing page NO implementada
- ❌ Sistema de votación NO implementado
- ❌ Panel de administración NO implementado
- Fase 1 (Crítica): Landing page Django con SEO
- Fase 2-3: API y frontend de competiciones/producciones
- Fase 4: Sistema de votación
- Fase 5: Panel de administración completo
- Fases 6-8: Perfil, optimización, testing, deployment
- Tiempo: 15-20 semanas (~4-5 meses)
- Complejidad: Media-Alta
- Riesgo técnico: Bajo (tecnologías probadas)
- Landing page (SEO y difusión pública)
- API de competiciones y producciones (funcionalidad core)
- Sistema de votación (diferenciador clave)
- Panel de administración (operatividad)
Fin del documento técnico