Functional Update + Docker deployment

This commit is contained in:
Pedro Romero 2025-05-13 01:32:51 +02:00
parent 69b0fa02a4
commit 19080419a1
12 changed files with 223 additions and 39 deletions

View File

@ -1,6 +1,7 @@
DJANGO_SECRET_KEY=
DJANGO_SECRET_KEY=tu_super_secreta_key
DB_ENGINE=postgres
DB_NAME=trafoking
DB_USER=postgres
DB_PASSWORD=postgres
DB_HOST=db
DB_PORT=5432
DB_PORT=5432

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# Dockerfile
# 1. Imagen base
FROM python:3.11-slim
# 2. Variables de entorno para hacer Python más silencioso
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# 3. Workdir en el contenedor
WORKDIR /app
# 4. Copiamos requirements e instalamos
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 5. Copiamos el proyecto
COPY . .
# 6. Recolectar estáticos y preparar migraciones al arrancar
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "trafoking.wsgi:application", "--bind", "0.0.0.0:8000"]

View File

@ -1,4 +1,5 @@
# apps/participants/services.py
from django.db import transaction
from .models import Participant
from apps.settingsapp.models import GradingSettings
@ -11,29 +12,72 @@ def _factor(diff: float) -> float:
return 0.6
def calculate_scores():
"""Recalcula y persiste 'score' para todos los participantes"""
"""Recalcula y persiste sólo el campo 'score' (base + similitud + bonus objetivo)."""
settings = GradingSettings.get()
participants = list(Participant.objects.all())
# 1) Base Entregable + Presentación
# 1) Base + similitud
for p in participants:
p.score = settings.report_weight + settings.presentation_weight
base = settings.report_weight + settings.presentation_weight
sim_total = sum(
_factor(getattr(p, f"{fld}_diff")) * getattr(settings, f"{fld}_weight")
for fld in ("R", "L", "Ph", "Pcu")
)
p.score = base + sim_total
# 2) Similitud
for diff, weight in [
(p.R_diff, settings.R_weight),
(p.L_diff, settings.L_weight),
(p.Ph_diff, settings.Ph_weight),
(p.Pcu_diff,settings.Pcu_weight),
]:
p.score += _factor(diff) * weight
# 2) Calculamos bonus de objetivo según ranking de s_vol_over_eta (mayor es mejor)
sorted_obj = sorted(participants, key=lambda x: x.s_vol_over_eta, reverse=True)
obj_bonus_map = {}
for idx, p in enumerate(sorted_obj, start=1):
bonus = max(settings.objective_weight - (idx - 1), 0)
obj_bonus_map[p.pk] = bonus
# 3) Objetivo (rank por métrica)
sorted_by_obj = sorted(participants, key=lambda x: x.s_vol_over_eta, reverse=True)
for idx, p in enumerate(sorted_by_obj, start=1):
p.score += max(settings.objective_weight - (idx - 1), 0)
# 4) Persistimos en batch
# 3) Persistimos sumando el bonus a score
with transaction.atomic():
for p in participants:
p.score += obj_bonus_map[p.pk]
p.save(update_fields=["score"])
def calculate_scores_details():
"""
Devuelve una lista de dicts con desgloses:
[{
participant: <Participant>,
base: float,
sim_total: float,
obj_bonus: float,
total: float,
objective_metric: float
}, ...]
ordenada exclusivamente por 'objective_metric' (s_vol_over_eta) descendente.
"""
settings = GradingSettings.get()
participants = list(Participant.objects.all())
# 1) Calculamos base y similitud
details = []
for p in participants:
base = settings.report_weight + settings.presentation_weight
sim_total = sum(
_factor(getattr(p, f"{fld}_diff")) * getattr(settings, f"{fld}_weight")
for fld in ("R", "L", "Ph", "Pcu")
)
details.append({
"participant": p,
"base": base,
"sim_total": sim_total,
"obj_bonus": 0, # se asignará más abajo
"total": base + sim_total, # se incrementará con obj_bonus
"objective_metric": p.s_vol_over_eta,
})
# 2) Asignamos bonus de objetivo según posición en objective_metric
sorted_by_obj = sorted(details, key=lambda d: d["objective_metric"], reverse=True)
for idx, entry in enumerate(sorted_by_obj, start=1):
bonus = max(settings.objective_weight - (idx - 1), 0)
entry["obj_bonus"] = bonus
entry["total"] += bonus
# 3) Orden final sólo por objective_metric descendente
return sorted(details, key=lambda d: d["objective_metric"], reverse=True)

View File

@ -5,6 +5,8 @@ from django.shortcuts import redirect
from .models import Participant
from .forms import ParticipantForm
from .services import calculate_scores
from django.views.generic import TemplateView
from .services import calculate_scores_details
class ParticipantCreateView(CreateView):
model = Participant
@ -21,3 +23,12 @@ class ResultsView(ListView):
model = Participant
template_name = "participants/results.html"
context_object_name = "participants"
class ResultsView(TemplateView):
template_name = "participants/results.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# recalcula en memoria y entrega detalles
ctx["rankings"] = calculate_scores_details()
return ctx

Binary file not shown.

29
docker-compose.yml Normal file
View File

@ -0,0 +1,29 @@
services:
db:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-trafoking}
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
volumes:
- ./pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
trafokingweb:
build: .
command: ["gunicorn", "trafoking.wsgi:application", "--bind", "0.0.0.0:8000"]
volumes:
- ./:/app
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
volumes:
pgdata:

11
entrypoint.sh Normal file
View File

@ -0,0 +1,11 @@
#!/bin/bash
set -e
# Migraciones
python manage.py migrate --noinput
# Recolectar estáticos
python manage.py collectstatic --noinput
# Ejecutar el comando que se pase al contenedor
exec "$@"

View File

@ -101,3 +101,13 @@ label sup {
position: relative;
top: 0.25em;
}
/* Medallas */
.text-gold { color: #FFD700 !important; }
.text-silver { color: #C0C0C0 !important; }
.text-bronze { color: #CD7F32 !important; }
/* Tabla animada */
#ranking-table tbody tr {
transition: transform .4s ease, opacity .4s ease;
}

13
static/js/animations.js Normal file
View File

@ -0,0 +1,13 @@
// static/js/animations.js
document.addEventListener("DOMContentLoaded", () => {
const rows = Array.from(document.querySelectorAll("#ranking-table tbody tr"));
// Ordenamos por rank y delay incremental
rows.sort((a, b) =>
parseInt(a.dataset.rank) - parseInt(b.dataset.rank)
).forEach((row, i) => {
setTimeout(() => {
row.style.opacity = 1;
row.style.transform = "scale(1)";
}, 150 * i);
});
});

View File

@ -1,27 +1,70 @@
<!-- templates/participants/results.html -->
{% extends "base.html" %}
{% load static%}
{% block title %}Resultados · Trafoking{% endblock %}
{% block content %}
<h2 class="text-3xl font-bold mb-8 text-[color:var(--brand-yellow)]">
Ranking
</h2>
<h2 class="text-3xl font-bold mb-8 text-[color:var(--brand-yellow)]">Ranking</h2>
{% if rankings %}
<table id="ranking-table"
class="w-full max-w-2xl mx-auto border-separate border-spacing-y-2">
<thead>
<tr>
<th class="text-left px-4">#</th>
<th class="text-left px-4">Grupo</th>
<th class="text-right px-4">Total</th>
</tr>
</thead>
<tbody>
{% for entry in rankings %}
{% with forloop.counter as rank %}
<tr class="transform transition duration-500 opacity-0 scale-95"
data-rank="{{ rank }}">
<td class="px-4 py-2">
<span class="font-bold
{% if rank == 1 %}text-2xl text-[#FFD700]
{% elif rank == 2 %}text-xl text-[#C0C0C0]
{% elif rank == 3 %}text-xl text-[#CD7F32]
{% else %}text-base{% endif %}">
{{ rank }}
</span>
</td>
<td class="px-4 py-2 font-semibold
{% if rank == 1 %}text-2xl text-[#FFD700]
{% elif rank == 2 %}text-xl text-[#C0C0C0]
{% elif rank == 3 %}text-xl text-[#CD7F32]{% endif %}">
{{ entry.participant.group_name }}
</td>
<td class="px-4 py-2 text-right">
<span class="font-mono">
{{ entry.total|floatformat:2 }}
</span>
</td>
</tr>
<tr class="bg-white bg-opacity-10 transform transition duration-500 opacity-0 scale-95"
data-rank="{{ rank }}">
<td colspan="3" class="px-4 py-2 text-sm">
Base: {{ entry.base|floatformat:2 }}
|Similitud: {{ entry.sim_total|floatformat:2 }}
|Objetivo: {{ entry.obj_bonus|floatformat:2 }}
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-center text-red-500 text-lg">
No hay datos para mostrar el ranking.
</p>
{% endif %}
<table class="hero-appear">
<thead><tr><th>#</th><th>Grupo</th><th>Puntuación</th></tr></thead>
<tbody>
{% for p in participants %}
<tr class="text-center">
<td>{{forloop.counter}}</td>
<td class="font-semibold">{{p.group_name}}</td>
<td>{{p.score|floatformat:2}}</td>
</tr>
{% empty %}
<tr><td colspan="3">Sin participantes todavía.</td></tr>
{% endfor %}
</tbody>
</table>
<a href="{% url 'participant_new' %}" class="btn btn-primary mt-10">
Añadir más grupos
</a>
<a href="{% url 'participant_new' %}"
class="btn btn-primary mt-10 inline-block">
Añadir más grupos
</a>
<script src="{% static 'js/animations.js' %}"></script>
{% endblock %}