mirror of
https://github.com/adrigongv23/G26---Telemetry-Software.git
synced 2026-08-25 11:33:17 +02:00
Compare commits
No commits in common. "5ca44845c87b40bc36eeface0bc29ff39372d10d" and "4b58b1ec97a2e34798763556a12ff013ee99b2fd" have entirely different histories.
5ca44845c8
...
4b58b1ec97
94 changed files with 5070 additions and 684 deletions
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Django
|
||||||
|
*.sqlite3
|
||||||
|
/media/
|
||||||
|
/staticfiles/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Claude Code workspace
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Grabaciones y capturas del monitor de boxes
|
||||||
|
Escritorio_Boxes/sesiones/
|
||||||
|
Escritorio_Boxes/capturas/
|
||||||
80
Escritorio_Boxes/diseño.py
Normal file
80
Escritorio_Boxes/diseño.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
|
||||||
|
# --- CONFIGURACIÓN DE RED ---
|
||||||
|
UDP_IP = "127.0.0.1" # Enviamos a nuestro propio PC (Localhost)
|
||||||
|
UDP_PORT = 4210
|
||||||
|
|
||||||
|
# Formato del paquete: "clave=valor" separados por ';'. El monitor tolera que
|
||||||
|
# falte cualquier canal, así que se pueden comentar líneas del diccionario de
|
||||||
|
# abajo para simular canales que el firmware todavía no envía.
|
||||||
|
FORMATO_CLAVE_VALOR = True
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
|
||||||
|
print("--- SIMULADOR G26 INICIADO ---")
|
||||||
|
print(f"Enviando a {UDP_IP}:{UDP_PORT}")
|
||||||
|
print("Formato:", "clave=valor" if FORMATO_CLAVE_VALOR else "posicional (antiguo)")
|
||||||
|
|
||||||
|
t = 0.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# El acelerador manda: marca el régimen, la carga y la mezcla
|
||||||
|
tps = max(0.0, 100 * abs(math.sin(t / 3.0)) - 5)
|
||||||
|
carga = tps / 100.0
|
||||||
|
|
||||||
|
# Régimen siguiendo al acelerador, con algo de inercia
|
||||||
|
rpm = 1200 + 12800 * (carga ** 0.7) + random.uniform(-120, 120)
|
||||||
|
|
||||||
|
# Velocidad: correlada con el régimen (modelo simple para el simulador)
|
||||||
|
velocidad = 15 + 105 * (carga ** 0.8) + random.uniform(-1.5, 1.5)
|
||||||
|
|
||||||
|
# Freno delantero: presión (bar). Frena al levantar el pie del acelerador
|
||||||
|
freno_del = max(0.0, 45 * max(0.0, min(1.0, (6 - tps) / 6)) + random.uniform(-0.4, 0.4))
|
||||||
|
|
||||||
|
# Temperaturas: suben poco a poco y responden a la carga
|
||||||
|
ect = 88 + 6 * math.sin(t / 20.0) + 4 * carga + random.uniform(-0.3, 0.3)
|
||||||
|
taceite = 98 + 8 * math.sin(t / 25.0) + 6 * carga + random.uniform(-0.4, 0.4)
|
||||||
|
|
||||||
|
# MAP: motor atmosférico -> depresión en ralentí, cerca de atmosférica a fondo
|
||||||
|
mapa = 28 + 72 * carga + random.uniform(-1.5, 1.5)
|
||||||
|
|
||||||
|
# Presión de combustible: cae ligeramente al pedir caudal
|
||||||
|
pcomb = 3.8 - 0.35 * carga + random.uniform(-0.04, 0.04)
|
||||||
|
|
||||||
|
# Presión de aceite: sube con el régimen (regla aproximada de 1 bar/1000 rpm)
|
||||||
|
paceite = max(0.8, min(6.5, rpm / 2200.0)) + random.uniform(-0.05, 0.05)
|
||||||
|
|
||||||
|
# Lambda: la ECU enriquece en carga (objetivo ~0,88) y ronda 1,00 en crucero
|
||||||
|
lambda_obj = 1.00 - 0.13 * carga
|
||||||
|
lambda_val = lambda_obj + random.uniform(-0.025, 0.025)
|
||||||
|
|
||||||
|
# Batería: cargando con el motor en marcha
|
||||||
|
vbatt = 13.9 + 0.25 * math.sin(t / 7.0) - 0.3 * carga
|
||||||
|
|
||||||
|
if FORMATO_CLAVE_VALOR:
|
||||||
|
campos = {
|
||||||
|
'ect': f'{ect:.1f}',
|
||||||
|
'rpm': f'{int(rpm)}',
|
||||||
|
'vbatt': f'{vbatt:.2f}',
|
||||||
|
'pcomb': f'{pcomb:.2f}',
|
||||||
|
'taceite': f'{taceite:.1f}',
|
||||||
|
'paceite': f'{paceite:.2f}',
|
||||||
|
'map': f'{mapa:.0f}',
|
||||||
|
'lambda': f'{lambda_val:.3f}',
|
||||||
|
'lambda_obj': f'{lambda_obj:.3f}',
|
||||||
|
'tps': f'{tps:.0f}',
|
||||||
|
'velocidad': f'{velocidad:.0f}',
|
||||||
|
'freno_del': f'{freno_del:.1f}',
|
||||||
|
}
|
||||||
|
mensaje = ';'.join(f'{clave}={valor}' for clave, valor in campos.items())
|
||||||
|
else:
|
||||||
|
# Formato antiguo de tres campos, el que emite el firmware actual
|
||||||
|
mensaje = f"{ect:.1f}|{int(rpm)}|{vbatt:.1f}"
|
||||||
|
|
||||||
|
sock.sendto(mensaje.encode('utf-8'), (UDP_IP, UDP_PORT))
|
||||||
|
|
||||||
|
t += 0.1
|
||||||
|
time.sleep(0.05) # 20 paquetes por segundo, como la ESP32
|
||||||
BIN
Escritorio_Boxes/logo_gades.png
Normal file
BIN
Escritorio_Boxes/logo_gades.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
898
Escritorio_Boxes/monitor.py
Normal file
898
Escritorio_Boxes/monitor.py
Normal file
|
|
@ -0,0 +1,898 @@
|
||||||
|
import socket
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.animation as animation
|
||||||
|
from matplotlib.gridspec import GridSpec
|
||||||
|
from matplotlib.patches import Rectangle, Polygon
|
||||||
|
from matplotlib.lines import Line2D
|
||||||
|
from matplotlib.colors import to_rgb
|
||||||
|
from collections import deque
|
||||||
|
import time
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# --- CONFIGURACIÓN DE RED ---
|
||||||
|
UDP_IP = "0.0.0.0" # Escuchamos en Todas las interfaces posibles (WiFi, Ethernet...)
|
||||||
|
UDP_PORT = 4210 # Mismo puerto que usamos para la ESP32
|
||||||
|
TIMEOUT_SEG = 1.5 # Para ver si existe desconexión
|
||||||
|
|
||||||
|
# --- CONFIGURACIÓN DE DATOS ---
|
||||||
|
FRECUENCIA_HZ = 20 # Frecuencia a la que emite la ESP32 (un paquete cada 50 ms)
|
||||||
|
VENTANA_SEG = 10 # Segundos de historia visibles en las gráficas
|
||||||
|
MAX_PUNTOS = FRECUENCIA_HZ * VENTANA_SEG
|
||||||
|
|
||||||
|
# --- FORMATO DEL PAQUETE UDP ---
|
||||||
|
# Se admiten dos formatos y se distinguen solos:
|
||||||
|
#
|
||||||
|
# 1. "87.3|9200|14.2" -> ECT | RPM | BATERÍA
|
||||||
|
#
|
||||||
|
# 2. Nuevo (clave-valor, para cuando el firmware envíe el resto de canales):
|
||||||
|
# "ect=87.3;rpm=9200;vbatt=14.2;velocidad=64;tps=45;freno_del=8.5;
|
||||||
|
# pcomb=3.6;taceite=104;paceite=4.2;map=98;lambda=0.88;lambda_obj=0.88"
|
||||||
|
CLAVES_LEGADO = ('ect', 'rpm', 'vbatt')
|
||||||
|
|
||||||
|
# --- PALETA (tomada del logo oficial del equipo) ---
|
||||||
|
FONDO = '#080D1A' # Navy del logo llevado casi a negro
|
||||||
|
PANEL = '#0E1526' # Fondo de los paneles
|
||||||
|
GRID = '#1C2946' # Rejilla y separadores
|
||||||
|
TXT = '#E9EEF8' # Texto principal
|
||||||
|
TXT_DIM = '#7C8AA8' # Etiquetas secundarias
|
||||||
|
AZUL = '#6E9BE0' # Azul del logo, aclarado para fondo oscuro
|
||||||
|
VERDE = '#35D07F' # Semáforo: correcto
|
||||||
|
AMBAR = '#FFB627' # Semáforo: precaución
|
||||||
|
ROJO = '#FF4D4D' # Semáforo: crítico
|
||||||
|
CIAN = '#4FC3F7' # Motor frío
|
||||||
|
APAGADO = '#18213A' # Segmento / relleno inactivo
|
||||||
|
|
||||||
|
# --- RÉGIMEN DE MOTOR ---
|
||||||
|
MAX_RPM = 13000 # Fondo de escala del indicador
|
||||||
|
RPM_CORTE = 12000 # Zona roja / corte de inyección
|
||||||
|
|
||||||
|
# --- PEDALES ---
|
||||||
|
VENTANA_PEDALES_SEG = 7 # Segundos visibles en la traza de pedales
|
||||||
|
PUNTOS_PEDALES = FRECUENCIA_HZ * VENTANA_PEDALES_SEG
|
||||||
|
# El sensor de freno es de PRESIÓN (bar). Este es el valor que se dibuja como el
|
||||||
|
# 100 % de la traza. AJUSTAR cuando se mida en pista la frenada más fuerte.
|
||||||
|
FRENO_PRESION_MAX = 50.0
|
||||||
|
|
||||||
|
# --- DEFINICIÓN DE CANALES ---
|
||||||
|
# Cada canal declara su rango visible, sus zonas de color y una referencia
|
||||||
|
# opcional que se dibuja como línea en la gráfica. Las 'zonas' son pares
|
||||||
|
# (límite superior, color): se recorre en orden y gana la primera que supera
|
||||||
|
# al valor. AJUSTAR ESTOS UMBRALES A VUESTRO MOTOR.
|
||||||
|
CANALES = {
|
||||||
|
'ect': dict(
|
||||||
|
etiqueta='ECT', descripcion='TEMP. REFRIGERANTE', unidad='°C',
|
||||||
|
vmin=0, vmax=130, decimales=1, referencia=None,
|
||||||
|
zonas=[(65, CIAN), (95, VERDE), (105, AMBAR), (float('inf'), ROJO)]),
|
||||||
|
'taceite': dict(
|
||||||
|
etiqueta='T. ACEITE', descripcion='TEMP. DE ACEITE', unidad='°C',
|
||||||
|
vmin=0, vmax=160, decimales=1, referencia=None,
|
||||||
|
zonas=[(60, CIAN), (125, VERDE), (135, AMBAR), (float('inf'), ROJO)]),
|
||||||
|
'pcomb': dict(
|
||||||
|
etiqueta='P. COMBUSTIBLE', descripcion='PRESIÓN COMBUSTIBLE', unidad='bar',
|
||||||
|
vmin=0, vmax=6, decimales=2, referencia=3.5,
|
||||||
|
zonas=[(2.5, ROJO), (3.0, AMBAR), (4.5, VERDE), (float('inf'), AMBAR)]),
|
||||||
|
'paceite': dict(
|
||||||
|
etiqueta='P. ACEITE', descripcion='PRESIÓN DE ACEITE', unidad='bar',
|
||||||
|
vmin=0, vmax=8, decimales=2, referencia=None,
|
||||||
|
zonas=[(1.0, ROJO), (2.0, AMBAR), (6.5, VERDE), (float('inf'), AMBAR)]),
|
||||||
|
'lambda': dict(
|
||||||
|
etiqueta='LAMBDA', descripcion='MEZCLA (λ)', unidad='',
|
||||||
|
vmin=0.70, vmax=1.30, decimales=2, referencia=1.00,
|
||||||
|
zonas=[(0.75, ROJO), (0.80, AMBAR), (1.02, VERDE),
|
||||||
|
(1.08, AMBAR), (float('inf'), ROJO)]),
|
||||||
|
'map': dict(
|
||||||
|
etiqueta='MAP', descripcion='PRESIÓN DE ADMISIÓN', unidad='kPa',
|
||||||
|
vmin=0, vmax=120, decimales=0, referencia=101.3, # Presión atmosférica
|
||||||
|
zonas=[(float('inf'), AZUL)]), # Es carga, no alarma
|
||||||
|
'vbatt': dict(
|
||||||
|
etiqueta='BATERÍA', descripcion='TENSIÓN DE BATERÍA', unidad='V',
|
||||||
|
vmin=0, vmax=16, decimales=1, referencia=None,
|
||||||
|
zonas=[(11.8, ROJO), (12.4, AMBAR), (14.8, VERDE), (float('inf'), ROJO)]),
|
||||||
|
'tps': dict(
|
||||||
|
etiqueta='TPS', descripcion='ACELERADOR', unidad='%',
|
||||||
|
vmin=0, vmax=100, decimales=0, referencia=None,
|
||||||
|
zonas=[(float('inf'), AZUL)]),
|
||||||
|
# Frenos: sensores de PRESIÓN. Se guardan en bar (real) y en la traza se
|
||||||
|
# normalizan a 0-100 % contra FRENO_PRESION_MAX. Solo el delantero está
|
||||||
|
# instalado; el trasero queda declarado a la espera de montarse.
|
||||||
|
'freno_del': dict(
|
||||||
|
etiqueta='FRENO DEL.', descripcion='PRESIÓN FRENO DELANTERO', unidad='bar',
|
||||||
|
vmin=0, vmax=FRENO_PRESION_MAX, decimales=1, referencia=None,
|
||||||
|
zonas=[(float('inf'), ROJO)]),
|
||||||
|
'freno_tra': dict(
|
||||||
|
etiqueta='FRENO TRA.', descripcion='PRESIÓN FRENO TRASERO', unidad='bar',
|
||||||
|
vmin=0, vmax=FRENO_PRESION_MAX, decimales=1, referencia=None,
|
||||||
|
zonas=[(float('inf'), ROJO)]),
|
||||||
|
'velocidad': dict(
|
||||||
|
etiqueta='VELOCIDAD', descripcion='VELOCIDAD', unidad='km/h',
|
||||||
|
vmin=0, vmax=160, decimales=0, referencia=None,
|
||||||
|
zonas=[(float('inf'), AZUL)]),
|
||||||
|
'rpm': dict(
|
||||||
|
etiqueta='RPM', descripcion='RÉGIMEN DE MOTOR', unidad='',
|
||||||
|
vmin=0, vmax=MAX_RPM, decimales=0, referencia=None,
|
||||||
|
zonas=[(RPM_CORTE, VERDE), (float('inf'), ROJO)]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Canales que ocupan las tarjetas inferiores, en orden. Su posición es además la
|
||||||
|
# tecla que lleva ese canal a la gráfica grande (1 = primera tarjeta, etc.).
|
||||||
|
TARJETAS = ['ect', 'taceite', 'paceite', 'pcomb', 'lambda', 'map', 'vbatt']
|
||||||
|
CANAL_FOCO_INICIAL = 'ect'
|
||||||
|
|
||||||
|
# Lambda se colorea por desviación respecto al objetivo que manda la ECU, no
|
||||||
|
# por umbrales fijos: mezcla pobre funde pistones, rica solo pierde potencia
|
||||||
|
LAMBDA_POBRE_CRITICO = 0.06
|
||||||
|
LAMBDA_POBRE_AVISO = 0.03
|
||||||
|
LAMBDA_RICA_AVISO = -0.08
|
||||||
|
|
||||||
|
RUTA_BASE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RUTA_LOGO = os.path.join(RUTA_BASE, 'logo_gades.png')
|
||||||
|
RUTA_SESIONES = os.path.join(RUTA_BASE, 'sesiones')
|
||||||
|
RUTA_CAPTURAS = os.path.join(RUTA_BASE, 'capturas')
|
||||||
|
|
||||||
|
# --- ESTADO ---
|
||||||
|
historial = {clave: deque([np.nan] * MAX_PUNTOS, maxlen=MAX_PUNTOS) for clave in CANALES}
|
||||||
|
ultima_lectura = {} # Último valor recibido de cada canal
|
||||||
|
maximos = {} # Máximo de sesión por canal
|
||||||
|
minimos = {} # Mínimo de sesión por canal
|
||||||
|
canal_foco = CANAL_FOCO_INICIAL
|
||||||
|
|
||||||
|
ultimo_tiempo_dato = 0.0
|
||||||
|
conectado = False
|
||||||
|
inicio_sesion = None
|
||||||
|
paquetes_ok = 0
|
||||||
|
paquetes_error = 0
|
||||||
|
sellos_tiempo = deque(maxlen=FRECUENCIA_HZ * 3)
|
||||||
|
|
||||||
|
# Grabación a CSV
|
||||||
|
grabando = False
|
||||||
|
inicio_grabacion = None
|
||||||
|
fichero_csv = None
|
||||||
|
escritor_csv = None
|
||||||
|
nombre_grabacion = ''
|
||||||
|
muestras_grabadas = 0
|
||||||
|
|
||||||
|
# Pie de pantalla: estado permanente + avisos temporales que lo tapan unos segundos
|
||||||
|
texto_pie = ''
|
||||||
|
aviso_pie = ''
|
||||||
|
aviso_hasta = 0.0
|
||||||
|
|
||||||
|
# Orden de las columnas del CSV de sesión
|
||||||
|
COLUMNAS_CSV = ['ect', 'rpm', 'velocidad', 'vbatt', 'tps', 'freno_del', 'freno_tra',
|
||||||
|
'pcomb', 'taceite', 'paceite', 'map', 'lambda']
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.bind((UDP_IP, UDP_PORT))
|
||||||
|
sock.setblocking(False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- LÓGICA DE DATOS ---
|
||||||
|
def parsear_mensaje(msg):
|
||||||
|
"""Convierte el paquete UDP en un diccionario canal -> valor.
|
||||||
|
|
||||||
|
Acepta el formato clave-valor y el posicional antiguo, para que el monitor
|
||||||
|
siga funcionando con el firmware que ya está flasheado en el coche."""
|
||||||
|
if '=' in msg:
|
||||||
|
lectura = {}
|
||||||
|
for par in msg.replace(';', '|').split('|'):
|
||||||
|
clave, sep, valor = par.partition('=')
|
||||||
|
if not sep:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lectura[clave.strip().lower()] = float(valor)
|
||||||
|
except ValueError:
|
||||||
|
continue # Una clave ilegible no invalida el resto del paquete
|
||||||
|
if not lectura:
|
||||||
|
raise ValueError('paquete clave-valor sin ningún campo legible')
|
||||||
|
return lectura
|
||||||
|
|
||||||
|
partes = msg.split('|')
|
||||||
|
if len(partes) != len(CLAVES_LEGADO):
|
||||||
|
raise ValueError(f'se esperaban {len(CLAVES_LEGADO)} campos, llegaron {len(partes)}')
|
||||||
|
return {clave: float(valor) for clave, valor in zip(CLAVES_LEGADO, partes)}
|
||||||
|
|
||||||
|
|
||||||
|
def color_de(clave, valor):
|
||||||
|
"""Color semáforo de un valor según las zonas declaradas para su canal."""
|
||||||
|
if valor is None or (isinstance(valor, float) and np.isnan(valor)):
|
||||||
|
return TXT_DIM
|
||||||
|
if clave == 'lambda':
|
||||||
|
return color_lambda(valor, ultima_lectura.get('lambda_obj'))
|
||||||
|
for limite, color in CANALES[clave]['zonas']:
|
||||||
|
if valor < limite:
|
||||||
|
return color
|
||||||
|
return TXT
|
||||||
|
|
||||||
|
|
||||||
|
def color_lambda(valor, objetivo):
|
||||||
|
"""Mezcla pobre es peligrosa (funde pistones); rica solo desperdicia."""
|
||||||
|
if not objetivo or objetivo <= 0:
|
||||||
|
objetivo = CANALES['lambda']['referencia']
|
||||||
|
desviacion = valor - objetivo
|
||||||
|
if desviacion >= LAMBDA_POBRE_CRITICO:
|
||||||
|
return ROJO
|
||||||
|
if desviacion >= LAMBDA_POBRE_AVISO:
|
||||||
|
return AMBAR
|
||||||
|
if desviacion <= LAMBDA_RICA_AVISO:
|
||||||
|
return AMBAR
|
||||||
|
return VERDE
|
||||||
|
|
||||||
|
|
||||||
|
def referencia_de(clave):
|
||||||
|
"""Valor de referencia del canal. En lambda lo manda la propia ECU."""
|
||||||
|
if clave == 'lambda':
|
||||||
|
return ultima_lectura.get('lambda_obj') or CANALES['lambda']['referencia']
|
||||||
|
return CANALES[clave]['referencia']
|
||||||
|
|
||||||
|
|
||||||
|
def formatear(clave, valor):
|
||||||
|
if valor is None or (isinstance(valor, float) and np.isnan(valor)):
|
||||||
|
return '--'
|
||||||
|
decimales = CANALES[clave]['decimales']
|
||||||
|
if decimales == 0:
|
||||||
|
return f'{int(round(valor)):,}'.replace(',', '.')
|
||||||
|
return f'{valor:.{decimales}f}'
|
||||||
|
|
||||||
|
|
||||||
|
def valor_csv(clave):
|
||||||
|
"""Valor para el fichero de sesión: sin separador de millares, que rompería
|
||||||
|
el CSV, y con los decimales propios del canal (RPM y velocidad son enteros)."""
|
||||||
|
valor = ultima_lectura.get(clave)
|
||||||
|
if valor is None:
|
||||||
|
return ''
|
||||||
|
decimales = CANALES[clave]['decimales'] if clave in CANALES else 0
|
||||||
|
return f'{valor:.{decimales}f}'
|
||||||
|
|
||||||
|
|
||||||
|
def formato_tiempo(segundos):
|
||||||
|
if segundos is None:
|
||||||
|
return '--:--'
|
||||||
|
segundos = int(segundos)
|
||||||
|
return f'{segundos // 60:02d}:{segundos % 60:02d}'
|
||||||
|
|
||||||
|
|
||||||
|
def cargar_logo(ruta):
|
||||||
|
"""Devuelve el logo con los azules oscuros aclarados para que se lea sobre
|
||||||
|
fondo oscuro. El naranja corporativo se mantiene intacto."""
|
||||||
|
if not os.path.isfile(ruta):
|
||||||
|
return None
|
||||||
|
img = plt.imread(ruta)
|
||||||
|
if img.dtype == np.uint8:
|
||||||
|
img = img.astype(float) / 255.0
|
||||||
|
else:
|
||||||
|
img = img.astype(float).copy()
|
||||||
|
if img.ndim != 3 or img.shape[2] < 3:
|
||||||
|
return None
|
||||||
|
if img.shape[2] == 3:
|
||||||
|
img = np.dstack([img, np.ones(img.shape[:2])])
|
||||||
|
|
||||||
|
rgb = img[:, :, :3]
|
||||||
|
luminancia = rgb @ np.array([0.299, 0.587, 0.114])
|
||||||
|
oscuro = luminancia < 0.30
|
||||||
|
medio = (luminancia >= 0.30) & (luminancia < 0.58) & (rgb[:, :, 2] > rgb[:, :, 0])
|
||||||
|
img[oscuro, :3] = to_rgb(TXT)
|
||||||
|
img[medio, :3] = to_rgb(AZUL)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# --- ESTILO GLOBAL ---
|
||||||
|
plt.rcParams['toolbar'] = 'None'
|
||||||
|
# Sin barra de herramientas matplotlib ignora su propio atajo de guardado, así que
|
||||||
|
# la captura la gestionamos nosotros. Y 'r' viene asignada de fábrica a "reiniciar
|
||||||
|
# vista": se la quitamos para que sea inequívocamente la tecla de grabar.
|
||||||
|
plt.rcParams['keymap.save'] = []
|
||||||
|
plt.rcParams['keymap.home'] = [t for t in plt.rcParams['keymap.home'] if t != 'r']
|
||||||
|
plt.rcParams['font.family'] = 'sans-serif'
|
||||||
|
plt.rcParams['font.sans-serif'] = ['Bahnschrift', 'Segoe UI', 'Franklin Gothic Medium', 'DejaVu Sans']
|
||||||
|
plt.rcParams['figure.facecolor'] = FONDO
|
||||||
|
plt.rcParams['text.color'] = TXT
|
||||||
|
plt.rcParams['axes.edgecolor'] = GRID
|
||||||
|
plt.rcParams['xtick.color'] = TXT_DIM
|
||||||
|
plt.rcParams['ytick.color'] = TXT_DIM
|
||||||
|
|
||||||
|
fig = plt.figure(figsize=(16, 9))
|
||||||
|
fig.canvas.manager.set_window_title('G26 Telemetry - Formula Gades')
|
||||||
|
|
||||||
|
|
||||||
|
def preparar_panel(ax, color_acento=None, fondo=PANEL):
|
||||||
|
"""Fondo de panel + barra de acento a la izquierda, el mismo lenguaje visual
|
||||||
|
que las tarjetas de la plataforma web."""
|
||||||
|
ax.set_facecolor(fondo)
|
||||||
|
for spine in ax.spines.values():
|
||||||
|
spine.set_visible(False)
|
||||||
|
ax.set_xticks([])
|
||||||
|
ax.set_yticks([])
|
||||||
|
ax.set_xlim(0, 1)
|
||||||
|
ax.set_ylim(0, 1)
|
||||||
|
if color_acento is None:
|
||||||
|
return None
|
||||||
|
acento = Rectangle((0, 0), 0.011, 1, transform=ax.transAxes,
|
||||||
|
facecolor=color_acento, zorder=5, clip_on=False)
|
||||||
|
ax.add_patch(acento)
|
||||||
|
return acento
|
||||||
|
|
||||||
|
|
||||||
|
# --- CABECERA ---
|
||||||
|
logo = cargar_logo(RUTA_LOGO)
|
||||||
|
if logo is not None:
|
||||||
|
alto_logo = 0.42 / 9.0
|
||||||
|
ancho_logo = (0.42 * (logo.shape[1] / logo.shape[0])) / 16.0
|
||||||
|
ax_logo = fig.add_axes([0.035, 0.9315, ancho_logo, alto_logo])
|
||||||
|
ax_logo.imshow(logo)
|
||||||
|
ax_logo.set_axis_off()
|
||||||
|
x_titulo = 0.035 + ancho_logo + 0.018
|
||||||
|
else:
|
||||||
|
x_titulo = 0.035
|
||||||
|
|
||||||
|
fig.text(x_titulo, 0.962, 'TELEMETRÍA G26', fontsize=15, fontweight='bold', color=TXT, va='center')
|
||||||
|
fig.text(x_titulo, 0.937, 'MURO DE BOXES · UNIVERSIDAD DE CÁDIZ', fontsize=8, color=TXT_DIM, va='center')
|
||||||
|
|
||||||
|
txt_reloj = fig.text(0.975, 0.962, '--:--:--', fontsize=19, color=TXT, ha='right', va='center')
|
||||||
|
|
||||||
|
|
||||||
|
def crear_led(x, y, color):
|
||||||
|
"""Los pilotos de estado van como marcadores, no como carácter: la tipografía
|
||||||
|
condensada no incluye el glifo del círculo."""
|
||||||
|
led = Line2D([x], [y], marker='o', markersize=8, color=color,
|
||||||
|
transform=fig.transFigure, figure=fig)
|
||||||
|
fig.add_artist(led)
|
||||||
|
return led
|
||||||
|
|
||||||
|
|
||||||
|
led_estado = crear_led(0.7035, 0.937, TXT_DIM)
|
||||||
|
txt_estado = fig.text(0.713, 0.937, 'SIN SEÑAL', fontsize=10, color=TXT_DIM, va='center')
|
||||||
|
txt_hz = fig.text(0.830, 0.937, '-- Hz', fontsize=10, color=TXT_DIM, va='center')
|
||||||
|
led_rec = crear_led(0.9015, 0.937, APAGADO)
|
||||||
|
txt_rec = fig.text(0.911, 0.937, 'SIN GRABAR', fontsize=10, color=TXT_DIM, va='center')
|
||||||
|
|
||||||
|
# Banda de alarma: con tantos canales ya no se pueden vigilar todos a la vez,
|
||||||
|
# así que la alarma viene a buscarte en lugar de esperar a que mires la casilla
|
||||||
|
txt_alarma = fig.text(0.50, 0.952, '', fontsize=12, fontweight='bold', color=TXT,
|
||||||
|
ha='center', va='center', zorder=10,
|
||||||
|
bbox=dict(facecolor=ROJO, edgecolor='none', boxstyle='square,pad=0.45'))
|
||||||
|
txt_alarma.set_visible(False)
|
||||||
|
|
||||||
|
fig.add_artist(Line2D([0.035, 0.975], [0.916, 0.916], color=GRID, lw=1.2,
|
||||||
|
transform=fig.transFigure))
|
||||||
|
|
||||||
|
txt_sesion = fig.text(0.035, 0.017, '', fontsize=8.5, color=TXT_DIM, va='center')
|
||||||
|
fig.text(0.50, 0.017, 'R grabar S captura 1-7 gráfica F pantalla completa Q salir',
|
||||||
|
fontsize=8.5, color=TXT_DIM, ha='center', va='center')
|
||||||
|
txt_fichero = fig.text(0.975, 0.017, '', fontsize=8.5, color=TXT_DIM, ha='right', va='center')
|
||||||
|
|
||||||
|
# --- BANDA A: LO QUE HACE EL PILOTO (RPM + VELOCIDAD + PEDALES) ---
|
||||||
|
gs_a = GridSpec(1, 3, width_ratios=[2.6, 0.8, 1.7],
|
||||||
|
left=0.035, right=0.975, top=0.893, bottom=0.712, wspace=0.030)
|
||||||
|
|
||||||
|
# A1. Luces de cambio
|
||||||
|
ax_rpm = fig.add_subplot(gs_a[0, 0])
|
||||||
|
preparar_panel(ax_rpm)
|
||||||
|
ax_rpm.set_xlim(0, 100)
|
||||||
|
|
||||||
|
N_SEGMENTOS = 22
|
||||||
|
ANCHO_TIRA = 78.0
|
||||||
|
paso = ANCHO_TIRA / N_SEGMENTOS
|
||||||
|
segmentos = []
|
||||||
|
colores_segmento = []
|
||||||
|
for i in range(N_SEGMENTOS):
|
||||||
|
fraccion = (i + 1) / N_SEGMENTOS
|
||||||
|
if fraccion <= 0.55:
|
||||||
|
color = VERDE
|
||||||
|
elif fraccion <= RPM_CORTE / MAX_RPM:
|
||||||
|
color = AMBAR
|
||||||
|
else:
|
||||||
|
color = ROJO
|
||||||
|
colores_segmento.append(color)
|
||||||
|
seg = Rectangle((2 + i * paso, 0.34), paso * 0.76, 0.40, facecolor=APAGADO)
|
||||||
|
ax_rpm.add_patch(seg)
|
||||||
|
segmentos.append(seg)
|
||||||
|
|
||||||
|
ax_rpm.text(2, 0.26, '0', fontsize=7.5, color=TXT_DIM, va='top')
|
||||||
|
ax_rpm.text(2 + ANCHO_TIRA * (RPM_CORTE / MAX_RPM), 0.26, f'{RPM_CORTE // 1000}.000',
|
||||||
|
fontsize=7.5, color=ROJO, va='top', ha='center')
|
||||||
|
ax_rpm.text(2 + ANCHO_TIRA, 0.26, f'{MAX_RPM // 1000}.000', fontsize=7.5, color=TXT_DIM,
|
||||||
|
va='top', ha='right')
|
||||||
|
ax_rpm.text(2, 0.88, 'RPM', fontsize=9, color=TXT_DIM, va='center')
|
||||||
|
txt_rpm = ax_rpm.text(98, 0.55, '--', fontsize=30, fontweight='bold', color=TXT_DIM,
|
||||||
|
ha='right', va='center')
|
||||||
|
|
||||||
|
# A2. Velocidad: número grande, hereda el hueco glanceable que dejó la marcha
|
||||||
|
ax_vel = fig.add_subplot(gs_a[0, 1])
|
||||||
|
preparar_panel(ax_vel, AZUL)
|
||||||
|
ax_vel.text(0.5, 0.86, 'VELOCIDAD', fontsize=9, color=TXT_DIM, ha='center', va='center')
|
||||||
|
txt_vel = ax_vel.text(0.5, 0.45, '--', fontsize=54, fontweight='bold', color=TXT_DIM,
|
||||||
|
ha='center', va='center')
|
||||||
|
ax_vel.text(0.5, 0.13, 'km/h', fontsize=12, color=TXT_DIM, ha='center', va='center')
|
||||||
|
|
||||||
|
# A3. Traza de pedales: acelerador (verde) y freno (rojo) oscilando de 0 a 100 %,
|
||||||
|
# como en la telemetría real. El freno es presión (bar) normalizada a % en pantalla.
|
||||||
|
ax_ped = fig.add_subplot(gs_a[0, 2])
|
||||||
|
ax_ped.set_facecolor(PANEL)
|
||||||
|
for lado in ('top', 'right'):
|
||||||
|
ax_ped.spines[lado].set_visible(False)
|
||||||
|
for lado in ('bottom', 'left'):
|
||||||
|
ax_ped.spines[lado].set_color(GRID)
|
||||||
|
ax_ped.set_xlim(0, PUNTOS_PEDALES)
|
||||||
|
ax_ped.set_ylim(0, 105)
|
||||||
|
ax_ped.set_yticks([0, 50, 100])
|
||||||
|
ax_ped.set_yticklabels(['0', '50', '100'], fontsize=7)
|
||||||
|
ax_ped.set_xticks([0, PUNTOS_PEDALES / 2, PUNTOS_PEDALES])
|
||||||
|
ax_ped.set_xticklabels([f'-{VENTANA_PEDALES_SEG:g} s'.replace('.', ','),
|
||||||
|
f'-{VENTANA_PEDALES_SEG / 2:g} s'.replace('.', ','), 'ahora'], fontsize=7)
|
||||||
|
ax_ped.tick_params(length=0, labelsize=7.5)
|
||||||
|
ax_ped.grid(True, color=GRID, alpha=0.5, linestyle='--', lw=0.6, zorder=1)
|
||||||
|
ax_ped.text(PUNTOS_PEDALES * 0.015, 99, 'PEDALES', fontsize=9, color=TXT_DIM, va='top', zorder=5)
|
||||||
|
|
||||||
|
fill_tps = Polygon([[0, 0], [0, 0]], closed=True, facecolor=VERDE, alpha=0.22,
|
||||||
|
edgecolor='none', zorder=2)
|
||||||
|
fill_freno = Polygon([[0, 0], [0, 0]], closed=True, facecolor=ROJO, alpha=0.20,
|
||||||
|
edgecolor='none', zorder=2)
|
||||||
|
ax_ped.add_patch(fill_tps)
|
||||||
|
ax_ped.add_patch(fill_freno)
|
||||||
|
line_tps, = ax_ped.plot([], [], color=VERDE, lw=2, zorder=4, solid_capstyle='round')
|
||||||
|
line_freno, = ax_ped.plot([], [], color=ROJO, lw=2, zorder=3, solid_capstyle='round')
|
||||||
|
_caja_ped = dict(facecolor=PANEL, alpha=0.65, edgecolor='none', boxstyle='square,pad=0.2')
|
||||||
|
txt_ped_tps = ax_ped.text(PUNTOS_PEDALES * 0.985, 80, 'ACEL --', fontsize=10.5,
|
||||||
|
fontweight='bold', color=VERDE, ha='right', va='center',
|
||||||
|
zorder=6, bbox=_caja_ped)
|
||||||
|
txt_ped_freno = ax_ped.text(PUNTOS_PEDALES * 0.985, 62, 'FRENO --', fontsize=10.5,
|
||||||
|
fontweight='bold', color=ROJO, ha='right', va='center',
|
||||||
|
zorder=6, bbox=_caja_ped)
|
||||||
|
|
||||||
|
|
||||||
|
def area_pedales(xs, ys):
|
||||||
|
"""Vértices del polígono relleno bajo una curva de pedal, saltándose los NaN
|
||||||
|
(que aparecen al desconectar), para que el relleno no se rompa."""
|
||||||
|
m = np.isfinite(ys)
|
||||||
|
if not m.any():
|
||||||
|
return [[0, 0], [0, 0]]
|
||||||
|
xf, yf = xs[m], ys[m]
|
||||||
|
return [(xf[0], 0)] + list(zip(xf, yf)) + [(xf[-1], 0)]
|
||||||
|
|
||||||
|
# --- BANDA B: CANAL CON FOCO (número grande + gráfica) ---
|
||||||
|
gs_b = GridSpec(1, 2, width_ratios=[1, 3.3],
|
||||||
|
left=0.035, right=0.975, top=0.678, bottom=0.318, wspace=0.09)
|
||||||
|
|
||||||
|
ax_foco_num = fig.add_subplot(gs_b[0, 0])
|
||||||
|
acento_foco = preparar_panel(ax_foco_num, TXT_DIM)
|
||||||
|
txt_foco_etiqueta = ax_foco_num.text(0.5, 0.90, '', fontsize=13, fontweight='bold',
|
||||||
|
color=TXT_DIM, ha='center', va='center')
|
||||||
|
txt_foco_desc = ax_foco_num.text(0.5, 0.835, '', fontsize=7.5, color=TXT_DIM,
|
||||||
|
ha='center', va='center')
|
||||||
|
txt_foco_valor = ax_foco_num.text(0.5, 0.55, '--', fontsize=76, fontweight='bold',
|
||||||
|
color=TXT_DIM, ha='center', va='center')
|
||||||
|
txt_foco_unidad = ax_foco_num.text(0.5, 0.345, '', fontsize=18, color=TXT_DIM,
|
||||||
|
ha='center', va='center')
|
||||||
|
txt_foco_estado = ax_foco_num.text(0.5, 0.16, 'SIN SEÑAL', fontsize=11, fontweight='bold',
|
||||||
|
color=TXT_DIM, ha='center', va='center')
|
||||||
|
|
||||||
|
ax_foco = fig.add_subplot(gs_b[0, 1])
|
||||||
|
ax_foco.set_facecolor(PANEL)
|
||||||
|
for lado in ('top', 'right'):
|
||||||
|
ax_foco.spines[lado].set_visible(False)
|
||||||
|
for lado in ('bottom', 'left'):
|
||||||
|
ax_foco.spines[lado].set_color(GRID)
|
||||||
|
ax_foco.set_xlim(0, MAX_PUNTOS)
|
||||||
|
ax_foco.set_xticks([0, MAX_PUNTOS * 0.25, MAX_PUNTOS * 0.5, MAX_PUNTOS * 0.75, MAX_PUNTOS])
|
||||||
|
ax_foco.set_xticklabels([f'-{VENTANA_SEG:g} s'.replace('.', ','),
|
||||||
|
f'-{VENTANA_SEG * 0.75:g} s'.replace('.', ','),
|
||||||
|
f'-{VENTANA_SEG * 0.5:g} s'.replace('.', ','),
|
||||||
|
f'-{VENTANA_SEG * 0.25:g} s'.replace('.', ','), 'ahora'],
|
||||||
|
fontsize=8)
|
||||||
|
ax_foco.grid(True, color=GRID, alpha=0.55, linestyle='--', lw=0.7, zorder=1)
|
||||||
|
ax_foco.tick_params(length=0, labelsize=9)
|
||||||
|
line_foco, = ax_foco.plot([], [], color=TXT, lw=2.2, zorder=3, solid_capstyle='round')
|
||||||
|
punto_foco, = ax_foco.plot([], [], marker='o', markersize=7, color=TXT, zorder=4)
|
||||||
|
adornos_foco = [] # Bandas, líneas de umbral y etiquetas: se rehacen al cambiar de canal
|
||||||
|
|
||||||
|
|
||||||
|
def ticks_bonitos(vmin, vmax, objetivo=6):
|
||||||
|
"""Escalones redondos para el eje Y del canal que tenga el foco.
|
||||||
|
|
||||||
|
Se descarta el tick del borde inferior porque se solaparía con la etiqueta
|
||||||
|
del eje de tiempos en la esquina."""
|
||||||
|
rango = vmax - vmin
|
||||||
|
if rango <= 0:
|
||||||
|
return []
|
||||||
|
mejor_paso, mejor_error = None, None
|
||||||
|
for exponente in range(-4, 6):
|
||||||
|
for multiplo in (1, 2, 5):
|
||||||
|
paso = multiplo * (10.0 ** exponente)
|
||||||
|
error = abs(rango / paso - objetivo)
|
||||||
|
if mejor_error is None or error < mejor_error:
|
||||||
|
mejor_paso, mejor_error = paso, error
|
||||||
|
primero = np.ceil(vmin / mejor_paso) * mejor_paso
|
||||||
|
ticks = np.arange(primero, vmax + mejor_paso * 0.5, mejor_paso)
|
||||||
|
return [t for t in ticks if t > vmin + rango * 0.001 and t <= vmax]
|
||||||
|
|
||||||
|
|
||||||
|
def aplicar_foco(clave):
|
||||||
|
"""Reconfigura la gráfica grande para el canal indicado."""
|
||||||
|
global canal_foco, adornos_foco
|
||||||
|
canal_foco = clave
|
||||||
|
cfg = CANALES[clave]
|
||||||
|
|
||||||
|
for adorno in adornos_foco:
|
||||||
|
adorno.remove()
|
||||||
|
adornos_foco = []
|
||||||
|
|
||||||
|
ax_foco.set_ylim(cfg['vmin'], cfg['vmax'])
|
||||||
|
ax_foco.set_yticks(ticks_bonitos(cfg['vmin'], cfg['vmax']))
|
||||||
|
ax_foco.set_ylabel(cfg['unidad'] or cfg['etiqueta'], fontsize=10, color=TXT_DIM)
|
||||||
|
|
||||||
|
# Bandas de zona: se ve de un vistazo en qué régimen está el canal
|
||||||
|
inferior = cfg['vmin']
|
||||||
|
for limite, color in cfg['zonas']:
|
||||||
|
superior = min(limite, cfg['vmax'])
|
||||||
|
if superior <= inferior:
|
||||||
|
continue
|
||||||
|
alpha = 0.05 if color in (AZUL, CIAN) else (0.14 if color == ROJO else 0.08)
|
||||||
|
adornos_foco.append(ax_foco.axhspan(inferior, superior, color=color, alpha=alpha, zorder=0))
|
||||||
|
if color in (AMBAR, ROJO) and inferior > cfg['vmin']:
|
||||||
|
adornos_foco.append(ax_foco.axhline(inferior, color=color, lw=1, ls='--',
|
||||||
|
alpha=0.55, zorder=1))
|
||||||
|
adornos_foco.append(ax_foco.text(
|
||||||
|
MAX_PUNTOS * 0.005, inferior + (cfg['vmax'] - cfg['vmin']) * 0.012,
|
||||||
|
f'{inferior:g} {cfg["unidad"]}'.strip(), fontsize=7.5, color=color,
|
||||||
|
ha='left', va='bottom', zorder=5,
|
||||||
|
bbox=dict(facecolor=PANEL, edgecolor='none', boxstyle='square,pad=0.25')))
|
||||||
|
inferior = superior
|
||||||
|
|
||||||
|
referencia = referencia_de(clave)
|
||||||
|
if referencia is not None:
|
||||||
|
adornos_foco.append(ax_foco.axhline(referencia, color=TXT_DIM, lw=1, ls=':',
|
||||||
|
alpha=0.7, zorder=2))
|
||||||
|
|
||||||
|
txt_foco_etiqueta.set_text(cfg['etiqueta'])
|
||||||
|
txt_foco_desc.set_text(cfg['descripcion'])
|
||||||
|
txt_foco_unidad.set_text(cfg['unidad'])
|
||||||
|
for tarjeta in TARJETAS:
|
||||||
|
tarjetas[tarjeta]['marco'].set_visible(tarjeta == clave)
|
||||||
|
|
||||||
|
|
||||||
|
# --- BANDA C: TARJETAS DE VIGILANCIA ---
|
||||||
|
gs_c = GridSpec(1, len(TARJETAS), left=0.035, right=0.975, top=0.282, bottom=0.052,
|
||||||
|
wspace=0.030)
|
||||||
|
|
||||||
|
tarjetas = {}
|
||||||
|
for indice, clave in enumerate(TARJETAS):
|
||||||
|
cfg = CANALES[clave]
|
||||||
|
ax = fig.add_subplot(gs_c[0, indice])
|
||||||
|
acento = preparar_panel(ax, TXT_DIM)
|
||||||
|
|
||||||
|
# Marco que señala qué tarjeta está en la gráfica grande
|
||||||
|
marco = Rectangle((0.004, 0.01), 0.992, 0.98, transform=ax.transAxes, fill=False,
|
||||||
|
edgecolor=AZUL, lw=1.6, zorder=6)
|
||||||
|
marco.set_visible(False)
|
||||||
|
ax.add_patch(marco)
|
||||||
|
|
||||||
|
ax.text(0.09, 0.86, cfg['etiqueta'], fontsize=10, fontweight='bold', color=TXT_DIM, va='center')
|
||||||
|
ax.text(0.955, 0.86, str(indice + 1), fontsize=8, color=TXT_DIM, ha='right', va='center')
|
||||||
|
txt_valor = ax.text(0.09, 0.60, '--', fontsize=26, fontweight='bold', color=TXT_DIM, va='center')
|
||||||
|
txt_unidad = ax.text(0.955, 0.55, cfg['unidad'], fontsize=10, color=TXT_DIM,
|
||||||
|
ha='right', va='center')
|
||||||
|
txt_extremos = ax.text(0.09, 0.36, '', fontsize=7, color=TXT_DIM, va='center')
|
||||||
|
|
||||||
|
# Sparkline: cada canal lleva su propia historia, no solo el que tiene el foco.
|
||||||
|
# Se normaliza contra el rango fijo del canal, nunca autoescalado: si no, una
|
||||||
|
# señal plana con ruido de milésimas parecería una montaña rusa.
|
||||||
|
X0_SPARK, X1_SPARK, Y0_SPARK, Y1_SPARK = 0.09, 0.955, 0.10, 0.30
|
||||||
|
linea_spark, = ax.plot([], [], color=TXT_DIM, lw=1.3, zorder=3, solid_capstyle='round')
|
||||||
|
linea_ref = Line2D([X0_SPARK, X1_SPARK], [0, 0], color=TXT_DIM, lw=0.8, ls=':',
|
||||||
|
alpha=0.6, zorder=2)
|
||||||
|
ax.add_line(linea_ref)
|
||||||
|
linea_ref.set_visible(cfg['referencia'] is not None)
|
||||||
|
|
||||||
|
tarjetas[clave] = dict(ax=ax, acento=acento, marco=marco, valor=txt_valor,
|
||||||
|
unidad=txt_unidad, extremos=txt_extremos, spark=linea_spark,
|
||||||
|
ref=linea_ref,
|
||||||
|
caja=(X0_SPARK, X1_SPARK, Y0_SPARK, Y1_SPARK))
|
||||||
|
|
||||||
|
aplicar_foco(CANAL_FOCO_INICIAL)
|
||||||
|
|
||||||
|
|
||||||
|
def puntos_sparkline(clave, caja):
|
||||||
|
"""Proyecta la historia del canal dentro del recuadro de su tarjeta."""
|
||||||
|
x0, x1, y0, y1 = caja
|
||||||
|
cfg = CANALES[clave]
|
||||||
|
datos = np.array(historial[clave], dtype=float)
|
||||||
|
recorrido = cfg['vmax'] - cfg['vmin']
|
||||||
|
if recorrido <= 0:
|
||||||
|
return [], []
|
||||||
|
normalizado = (datos - cfg['vmin']) / recorrido
|
||||||
|
normalizado = np.clip(normalizado, 0.0, 1.0)
|
||||||
|
xs = np.linspace(x0, x1, len(datos))
|
||||||
|
return xs, y0 + normalizado * (y1 - y0)
|
||||||
|
|
||||||
|
|
||||||
|
def y_sparkline(clave, valor, caja):
|
||||||
|
"""Altura, dentro de la tarjeta, que corresponde a un valor del canal."""
|
||||||
|
_, _, y0, y1 = caja
|
||||||
|
cfg = CANALES[clave]
|
||||||
|
recorrido = cfg['vmax'] - cfg['vmin']
|
||||||
|
if recorrido <= 0:
|
||||||
|
return y0
|
||||||
|
fraccion = min(max((valor - cfg['vmin']) / recorrido, 0.0), 1.0)
|
||||||
|
return y0 + fraccion * (y1 - y0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- GRABACIÓN Y CAPTURA ---
|
||||||
|
def alternar_grabacion():
|
||||||
|
"""Arranca o detiene el volcado de la sesión a CSV. El fichero resultante es
|
||||||
|
el que se sube después a la plataforma web como registro de telemetría."""
|
||||||
|
global grabando, fichero_csv, escritor_csv, inicio_grabacion, nombre_grabacion
|
||||||
|
global muestras_grabadas, texto_pie
|
||||||
|
|
||||||
|
if grabando:
|
||||||
|
fichero_csv.close()
|
||||||
|
fichero_csv = None
|
||||||
|
escritor_csv = None
|
||||||
|
grabando = False
|
||||||
|
texto_pie = f'Guardado: sesiones/{nombre_grabacion} ({muestras_grabadas} muestras)'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Descartamos lo que estuviera encolado: son muestras anteriores a pulsar REC
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
sock.recvfrom(1024)
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
os.makedirs(RUTA_SESIONES, exist_ok=True)
|
||||||
|
nombre_grabacion = f"sesion_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
fichero_csv = open(os.path.join(RUTA_SESIONES, nombre_grabacion), 'w', newline='', encoding='utf-8')
|
||||||
|
escritor_csv = csv.writer(fichero_csv)
|
||||||
|
escritor_csv.writerow(['n_muestra', 'tiempo_s', 'hora'] + COLUMNAS_CSV)
|
||||||
|
inicio_grabacion = time.time()
|
||||||
|
muestras_grabadas = 0
|
||||||
|
grabando = True
|
||||||
|
texto_pie = f'Grabando en sesiones/{nombre_grabacion}'
|
||||||
|
|
||||||
|
|
||||||
|
def mostrar_aviso(texto, segundos=4.0):
|
||||||
|
"""Mensaje temporal en el pie, que después deja ver de nuevo el estado fijo."""
|
||||||
|
global aviso_pie, aviso_hasta
|
||||||
|
aviso_pie = texto
|
||||||
|
aviso_hasta = time.time() + segundos
|
||||||
|
|
||||||
|
|
||||||
|
def capturar_pantalla():
|
||||||
|
"""Guarda la pantalla tal cual se ve. Sin diálogo de fichero: en boxes no hay
|
||||||
|
tiempo ni ratón cómodo para navegar por carpetas."""
|
||||||
|
os.makedirs(RUTA_CAPTURAS, exist_ok=True)
|
||||||
|
nombre = f"captura_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||||
|
fig.savefig(os.path.join(RUTA_CAPTURAS, nombre), dpi=110, facecolor=fig.get_facecolor())
|
||||||
|
mostrar_aviso(f'Captura guardada: capturas/{nombre}')
|
||||||
|
|
||||||
|
|
||||||
|
def al_pulsar_tecla(event):
|
||||||
|
if event.key == 'r':
|
||||||
|
alternar_grabacion()
|
||||||
|
elif event.key == 's':
|
||||||
|
capturar_pantalla()
|
||||||
|
elif event.key and event.key.isdigit():
|
||||||
|
indice = int(event.key) - 1
|
||||||
|
if 0 <= indice < len(TARJETAS):
|
||||||
|
aplicar_foco(TARJETAS[indice])
|
||||||
|
mostrar_aviso(f'Gráfica: {CANALES[TARJETAS[indice]]["descripcion"]}', 2.5)
|
||||||
|
|
||||||
|
|
||||||
|
fig.canvas.mpl_connect('key_press_event', al_pulsar_tecla)
|
||||||
|
|
||||||
|
|
||||||
|
def update(frame):
|
||||||
|
global ultimo_tiempo_dato, conectado, inicio_sesion
|
||||||
|
global paquetes_ok, paquetes_error, muestras_grabadas
|
||||||
|
|
||||||
|
txt_reloj.set_text(datetime.now().strftime('%H:%M:%S'))
|
||||||
|
ahora = time.time()
|
||||||
|
hubo_dato = False
|
||||||
|
|
||||||
|
# --- LECTURA DEL SOCKET (vaciamos todo lo pendiente) ---
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data, _ = sock.recvfrom(1024)
|
||||||
|
try:
|
||||||
|
lectura = parsear_mensaje(data.decode('utf-8'))
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
paquetes_error += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sello propio de cada paquete: en una misma pasada se vacían varios
|
||||||
|
# y compartir el instante del frame duplicaría marcas en el CSV
|
||||||
|
t_paquete = time.time()
|
||||||
|
ultimo_tiempo_dato = t_paquete
|
||||||
|
paquetes_ok += 1
|
||||||
|
hubo_dato = True
|
||||||
|
sellos_tiempo.append(t_paquete)
|
||||||
|
if inicio_sesion is None:
|
||||||
|
inicio_sesion = t_paquete
|
||||||
|
|
||||||
|
ultima_lectura.update(lectura)
|
||||||
|
for clave, valor in lectura.items():
|
||||||
|
if clave in historial:
|
||||||
|
historial[clave].append(valor)
|
||||||
|
maximos[clave] = valor if clave not in maximos else max(maximos[clave], valor)
|
||||||
|
minimos[clave] = valor if clave not in minimos else min(minimos[clave], valor)
|
||||||
|
|
||||||
|
if grabando:
|
||||||
|
muestras_grabadas += 1
|
||||||
|
escritor_csv.writerow(
|
||||||
|
[muestras_grabadas, f'{t_paquete - inicio_grabacion:.3f}',
|
||||||
|
datetime.now().strftime('%H:%M:%S.%f')[:-3]]
|
||||||
|
+ [valor_csv(col) for col in COLUMNAS_CSV])
|
||||||
|
fichero_csv.flush()
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conectado = (ahora - ultimo_tiempo_dato) <= TIMEOUT_SEG
|
||||||
|
|
||||||
|
# --- ESTADO DEL ENLACE ---
|
||||||
|
if conectado:
|
||||||
|
hz = len([t for t in sellos_tiempo if ahora - t <= 1.0])
|
||||||
|
txt_estado.set_text('EN LÍNEA')
|
||||||
|
txt_estado.set_color(VERDE)
|
||||||
|
led_estado.set_color(VERDE)
|
||||||
|
txt_hz.set_text(f'{hz} Hz')
|
||||||
|
txt_hz.set_color(TXT_DIM if hz >= FRECUENCIA_HZ * 0.7 else AMBAR)
|
||||||
|
else:
|
||||||
|
if not hubo_dato:
|
||||||
|
# Cortamos las líneas en vez de dibujar ceros falsos
|
||||||
|
for cola in historial.values():
|
||||||
|
cola.append(np.nan)
|
||||||
|
ultima_lectura.clear()
|
||||||
|
txt_estado.set_text('SIN SEÑAL')
|
||||||
|
txt_estado.set_color(ROJO)
|
||||||
|
led_estado.set_color(ROJO)
|
||||||
|
txt_hz.set_text('-- Hz')
|
||||||
|
txt_hz.set_color(TXT_DIM)
|
||||||
|
|
||||||
|
# --- BANDA A: RPM, VELOCIDAD, PEDALES ---
|
||||||
|
valor_rpm = ultima_lectura.get('rpm') if conectado else None
|
||||||
|
if valor_rpm is not None:
|
||||||
|
valor_rpm = max(0, min(MAX_RPM, valor_rpm))
|
||||||
|
encendidos = int(round((valor_rpm / MAX_RPM) * N_SEGMENTOS))
|
||||||
|
en_corte = valor_rpm >= RPM_CORTE
|
||||||
|
destello = en_corte and (frame // 4) % 2 == 0
|
||||||
|
for i, seg in enumerate(segmentos):
|
||||||
|
if destello:
|
||||||
|
seg.set_facecolor(ROJO)
|
||||||
|
elif i < encendidos:
|
||||||
|
seg.set_facecolor(colores_segmento[i])
|
||||||
|
else:
|
||||||
|
seg.set_facecolor(APAGADO)
|
||||||
|
txt_rpm.set_text(formatear('rpm', valor_rpm))
|
||||||
|
txt_rpm.set_color(ROJO if en_corte else TXT)
|
||||||
|
else:
|
||||||
|
for seg in segmentos:
|
||||||
|
seg.set_facecolor(APAGADO)
|
||||||
|
txt_rpm.set_text('--')
|
||||||
|
txt_rpm.set_color(TXT_DIM)
|
||||||
|
|
||||||
|
# Velocidad: número grande
|
||||||
|
valor_vel = ultima_lectura.get('velocidad') if conectado else None
|
||||||
|
txt_vel.set_text(formatear('velocidad', valor_vel))
|
||||||
|
txt_vel.set_color(TXT if valor_vel is not None else TXT_DIM)
|
||||||
|
|
||||||
|
# Traza de pedales: acelerador (0-100 %) y freno (presión -> % de FRENO_PRESION_MAX)
|
||||||
|
serie_tps = np.array(historial['tps'], dtype=float)[-PUNTOS_PEDALES:]
|
||||||
|
serie_freno = np.array(historial['freno_del'], dtype=float)[-PUNTOS_PEDALES:]
|
||||||
|
freno_pct = np.clip(serie_freno / FRENO_PRESION_MAX * 100.0, 0, 100)
|
||||||
|
xs_ped = np.arange(len(serie_tps), dtype=float)
|
||||||
|
line_tps.set_data(xs_ped, serie_tps)
|
||||||
|
line_freno.set_data(xs_ped, freno_pct)
|
||||||
|
fill_tps.set_xy(area_pedales(xs_ped, serie_tps))
|
||||||
|
fill_freno.set_xy(area_pedales(xs_ped, freno_pct))
|
||||||
|
|
||||||
|
val_tps = ultima_lectura.get('tps') if conectado else None
|
||||||
|
val_freno = ultima_lectura.get('freno_del') if conectado else None
|
||||||
|
txt_ped_tps.set_text(f'ACEL {int(round(val_tps))}%' if val_tps is not None else 'ACEL --')
|
||||||
|
if val_freno is not None:
|
||||||
|
txt_ped_freno.set_text(f'FRENO {int(round(min(100.0, val_freno / FRENO_PRESION_MAX * 100)))}%')
|
||||||
|
else:
|
||||||
|
txt_ped_freno.set_text('FRENO --')
|
||||||
|
|
||||||
|
# --- BANDA C: TARJETAS ---
|
||||||
|
alarmas = []
|
||||||
|
for clave, widgets in tarjetas.items():
|
||||||
|
valor = ultima_lectura.get(clave) if conectado else None
|
||||||
|
color = color_de(clave, valor)
|
||||||
|
widgets['valor'].set_text(formatear(clave, valor))
|
||||||
|
widgets['valor'].set_color(color)
|
||||||
|
widgets['unidad'].set_color(TXT_DIM)
|
||||||
|
widgets['acento'].set_facecolor(color)
|
||||||
|
widgets['spark'].set_color(color if valor is not None else TXT_DIM)
|
||||||
|
|
||||||
|
xs, ys = puntos_sparkline(clave, widgets['caja'])
|
||||||
|
widgets['spark'].set_data(xs, ys)
|
||||||
|
|
||||||
|
referencia = referencia_de(clave)
|
||||||
|
if referencia is not None:
|
||||||
|
y_ref = y_sparkline(clave, referencia, widgets['caja'])
|
||||||
|
widgets['ref'].set_ydata([y_ref, y_ref])
|
||||||
|
widgets['ref'].set_visible(True)
|
||||||
|
else:
|
||||||
|
widgets['ref'].set_visible(False)
|
||||||
|
|
||||||
|
if clave in maximos:
|
||||||
|
widgets['extremos'].set_text(
|
||||||
|
f'máx {formatear(clave, maximos[clave])} mín {formatear(clave, minimos[clave])}')
|
||||||
|
else:
|
||||||
|
widgets['extremos'].set_text('')
|
||||||
|
|
||||||
|
if color == ROJO and valor is not None:
|
||||||
|
alarmas.append(CANALES[clave]['etiqueta'])
|
||||||
|
|
||||||
|
# --- BANDA B: CANAL CON FOCO ---
|
||||||
|
valor_foco = ultima_lectura.get(canal_foco) if conectado else None
|
||||||
|
color_foco = color_de(canal_foco, valor_foco)
|
||||||
|
txt_foco_valor.set_text(formatear(canal_foco, valor_foco))
|
||||||
|
txt_foco_valor.set_color(color_foco)
|
||||||
|
acento_foco.set_facecolor(color_foco)
|
||||||
|
line_foco.set_color(color_foco if valor_foco is not None else TXT_DIM)
|
||||||
|
line_foco.set_data(range(MAX_PUNTOS), historial[canal_foco])
|
||||||
|
|
||||||
|
if valor_foco is None:
|
||||||
|
txt_foco_estado.set_text('SIN SEÑAL' if not conectado else 'SIN DATO')
|
||||||
|
txt_foco_estado.set_color(TXT_DIM)
|
||||||
|
punto_foco.set_data([], [])
|
||||||
|
else:
|
||||||
|
if color_foco == ROJO:
|
||||||
|
estado = 'CRÍTICO'
|
||||||
|
elif color_foco == AMBAR:
|
||||||
|
estado = 'PRECAUCIÓN'
|
||||||
|
elif color_foco == CIAN:
|
||||||
|
estado = 'EN CALENTAMIENTO'
|
||||||
|
else:
|
||||||
|
estado = 'NORMAL'
|
||||||
|
txt_foco_estado.set_text(estado)
|
||||||
|
txt_foco_estado.set_color(color_foco)
|
||||||
|
punto_foco.set_color(color_foco)
|
||||||
|
punto_foco.set_data([MAX_PUNTOS - 1], [valor_foco])
|
||||||
|
|
||||||
|
# --- ALARMA ---
|
||||||
|
if alarmas:
|
||||||
|
txt_alarma.set_text(' ALARMA: ' + ' · '.join(alarmas) + ' ')
|
||||||
|
txt_alarma.set_visible(True)
|
||||||
|
encendida = (frame // 5) % 2 == 0
|
||||||
|
txt_alarma.get_bbox_patch().set_facecolor(ROJO if encendida else '#6E1414')
|
||||||
|
else:
|
||||||
|
txt_alarma.set_visible(False)
|
||||||
|
|
||||||
|
# --- INDICADOR DE GRABACIÓN Y PIE ---
|
||||||
|
if grabando:
|
||||||
|
parpadeo = (frame // 6) % 2 == 0
|
||||||
|
txt_rec.set_text(f'REC {formato_tiempo(ahora - inicio_grabacion)}')
|
||||||
|
txt_rec.set_color(ROJO)
|
||||||
|
led_rec.set_color(ROJO if parpadeo else '#4A1414')
|
||||||
|
else:
|
||||||
|
txt_rec.set_text('SIN GRABAR')
|
||||||
|
txt_rec.set_color(TXT_DIM)
|
||||||
|
led_rec.set_color(APAGADO)
|
||||||
|
|
||||||
|
sesion = formato_tiempo(None if inicio_sesion is None else ahora - inicio_sesion)
|
||||||
|
errores = f' · {paquetes_error} con error' if paquetes_error else ''
|
||||||
|
txt_sesion.set_text(f'SESIÓN {sesion} · {paquetes_ok:,}'.replace(',', '.')
|
||||||
|
+ f' paquetes{errores}')
|
||||||
|
txt_fichero.set_text(aviso_pie if ahora < aviso_hasta else texto_pie)
|
||||||
|
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
ani = animation.FuncAnimation(fig, update, interval=60, blit=False, cache_frame_data=False)
|
||||||
|
|
||||||
|
# Arrancamos maximizado y sin barra de herramientas (pantalla de boxes)
|
||||||
|
try:
|
||||||
|
fig.canvas.manager.window.state('zoomed')
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
fig.canvas.manager.full_screen_toggle()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
if fichero_csv is not None:
|
||||||
|
fichero_csv.close()
|
||||||
136
Firmware/G26-Telemetria/G26-Telemetria.ino
Normal file
136
Firmware/G26-Telemetria/G26-Telemetria.ino
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
#include "include/data_processor.hpp"
|
||||||
|
#include "include/can.hpp"
|
||||||
|
#include "include/common_libraries.hpp"
|
||||||
|
|
||||||
|
DataProcessor dataProcessor;
|
||||||
|
CAN canController;
|
||||||
|
|
||||||
|
// SD
|
||||||
|
SPIClass spiSD(HSPI);
|
||||||
|
SdFat sd;
|
||||||
|
SdFile logFile;
|
||||||
|
|
||||||
|
// UDP
|
||||||
|
WiFiUDP udp;
|
||||||
|
|
||||||
|
// --- Tarea UDP (Nucleo 0) ---
|
||||||
|
void TaskUdpSender(void *pvParameters) {
|
||||||
|
Serial.println("Iniciando tarea de envio UDP...");
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
int tempActual = dataProcessor.current_ect_value;
|
||||||
|
int rpmActual = dataProcessor.current_rpm_value;
|
||||||
|
float battActual = dataProcessor.current_vbatt_value;
|
||||||
|
float tpsActual = dataProcessor.current_tps_value;
|
||||||
|
float frenoDelActual = dataProcessor.current_freno_del_value;
|
||||||
|
float pcombActual = dataProcessor.current_pcomb_value;
|
||||||
|
float taceiteActual = dataProcessor.current_taceite_value;
|
||||||
|
float paceiteActual = dataProcessor.current_paceite_value;
|
||||||
|
float mapActual = dataProcessor.current_map_value;
|
||||||
|
float lambdaActual = dataProcessor.current_lambda_value;
|
||||||
|
float lambdaObjActual = dataProcessor.current_lambda_obj_value;
|
||||||
|
|
||||||
|
char mensaje[256];
|
||||||
|
snprintf(mensaje, sizeof(mensaje),
|
||||||
|
"ect=%d;rpm=%d;vbatt=%.2f;tps=%.1f;freno_del=%.1f;"
|
||||||
|
"pcomb=%.2f;taceite=%.1f;paceite=%.2f;map=%.1f;lambda=%.3f;lambda_obj=%.3f",
|
||||||
|
tempActual, rpmActual, battActual, tpsActual, frenoDelActual,
|
||||||
|
pcombActual, taceiteActual, paceiteActual, mapActual, lambdaActual, lambdaObjActual);
|
||||||
|
|
||||||
|
udp.beginPacket(IPAddress(255, 255, 255, 255), UDP_PORT);
|
||||||
|
udp.print(mensaje);
|
||||||
|
udp.endPacket();
|
||||||
|
} else {
|
||||||
|
Serial.println("[WIFI] Desconectado...");
|
||||||
|
WiFi.disconnect();
|
||||||
|
WiFi.reconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
vTaskDelay(50 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(1000);
|
||||||
|
Serial.println("\n--- G26 TELEMETRY: INICIO DE SISTEMA ---");
|
||||||
|
|
||||||
|
// 1. INICIALIZACION SD
|
||||||
|
spiSD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
|
||||||
|
|
||||||
|
if (!sd.begin(SdSpiConfig(SD_CS, DEDICATED_SPI, SD_SCK_MHZ(1), &spiSD))) {
|
||||||
|
Serial.println("[FALLO] SD no detectada. El sistema continuara sin Datalogging.");
|
||||||
|
} else {
|
||||||
|
char filename[24];
|
||||||
|
int session = 1;
|
||||||
|
bool opened = false;
|
||||||
|
|
||||||
|
while (!opened && session < 100000) {
|
||||||
|
snprintf(filename, sizeof(filename), "G26-%d.csv", session);
|
||||||
|
if (logFile.open(filename, O_RDWR | O_CREAT | O_EXCL)) {
|
||||||
|
opened = true;
|
||||||
|
Serial.printf("[OK] Nueva sesion: %s\n", filename);
|
||||||
|
} else {
|
||||||
|
session++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opened) {
|
||||||
|
logFile.println("Time,ECT,RPM,TPS,VBATT,FRENO_DEL,PCOMB,TACEITE,PACEITE,MAP,LAMBDA,LAMBDA_OBJ");
|
||||||
|
logFile.sync();
|
||||||
|
} else {
|
||||||
|
Serial.println("[ERROR] No se pudo abrir archivo de sesion");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataProcessor.setLogSystem(&sd, &logFile);
|
||||||
|
|
||||||
|
// 2. INICIAR CAN
|
||||||
|
canController.set_data_proccessor(&dataProcessor);
|
||||||
|
canController.start();
|
||||||
|
canController.start_listening_task();
|
||||||
|
|
||||||
|
// 3. INICIAR WIFI
|
||||||
|
Serial.println("--- CONECTANDO WIFI ---");
|
||||||
|
|
||||||
|
IPAddress local_IP(192, 168, 0, 50);
|
||||||
|
IPAddress gateway(192, 168, 0, 254);
|
||||||
|
IPAddress subnet(255, 255, 255, 0);
|
||||||
|
|
||||||
|
if (!WiFi.config(local_IP, gateway, subnet)) {
|
||||||
|
Serial.println("[ERR] Fallo al configurar IP estatica");
|
||||||
|
}
|
||||||
|
|
||||||
|
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||||
|
|
||||||
|
int intentos = 0;
|
||||||
|
while (WiFi.status() != WL_CONNECTED && intentos < 20) {
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
intentos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
Serial.println("\n[OK] WiFi Conectado.");
|
||||||
|
} else {
|
||||||
|
Serial.println("\n[ERR] No se pudo conectar WiFi (Continuando offline).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. TAREA UDP
|
||||||
|
xTaskCreatePinnedToCore(
|
||||||
|
TaskUdpSender,
|
||||||
|
"UdpSender",
|
||||||
|
4096,
|
||||||
|
NULL,
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
Serial.println("[OK] Sistema ONLINE (CAN + SD + WiFi)");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
vTaskDelay(5 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
#ifndef CAN_HPP
|
#ifndef CAN_HPP
|
||||||
#define CAN_HPP
|
#define CAN_HPP
|
||||||
|
|
||||||
#define RX_PIN 13
|
#define RX_PIN 23
|
||||||
#define TX_PIN 38
|
#define TX_PIN 22
|
||||||
#define POLLING_RATE_MS 1000
|
#define POLLING_RATE_MS 1000
|
||||||
#define TRANSMIT_RATE_MS 1000
|
#define TRANSMIT_RATE_MS 1000
|
||||||
|
|
||||||
#include "driver/twai.h"
|
#include "driver/twai.h"
|
||||||
#include "common/common_libraries.hpp"
|
#include "common_libraries.hpp"
|
||||||
#include "data_processor.hpp"
|
#include "data_processor.hpp"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/semphr.h"
|
#include "freertos/semphr.h"
|
||||||
30
Firmware/G26-Telemetria/include/common_libraries.hpp
Normal file
30
Firmware/G26-Telemetria/include/common_libraries.hpp
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#ifndef COMMON_LIBRARIES_HPP
|
||||||
|
#define COMMON_LIBRARIES_HPP
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "time.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// --- WiFi y UDP ---
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <WiFiUdp.h>
|
||||||
|
|
||||||
|
// --- SD ---
|
||||||
|
#include <SPI.h>
|
||||||
|
#include "SdFat.h"
|
||||||
|
|
||||||
|
// CONFIGURACION WIFI
|
||||||
|
#define WIFI_SSID "FGades"
|
||||||
|
#define WIFI_PASSWORD "GadesCPE"
|
||||||
|
|
||||||
|
// CONFIGURACION UDP
|
||||||
|
#define UDP_PORT 4210
|
||||||
|
|
||||||
|
// CONFIGURACION SD (HSPI)
|
||||||
|
#define SD_CS 25
|
||||||
|
#define SD_MOSI 26
|
||||||
|
#define SD_SCK 27
|
||||||
|
#define SD_MISO 14
|
||||||
|
|
||||||
|
#endif
|
||||||
57
Firmware/G26-Telemetria/include/data_processor.hpp
Normal file
57
Firmware/G26-Telemetria/include/data_processor.hpp
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#ifndef DATAPROCESSOR_HPP
|
||||||
|
#define DATAPROCESSOR_HPP
|
||||||
|
|
||||||
|
#include "common_libraries.hpp"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
|
|
||||||
|
class DataProcessor {
|
||||||
|
public:
|
||||||
|
DataProcessor() = default;
|
||||||
|
|
||||||
|
// Variables CAN (actualizadas por los frames)
|
||||||
|
volatile int current_ect_value = 0;
|
||||||
|
volatile int current_rpm_value = 0;
|
||||||
|
volatile float current_vbatt_value = 0.0;
|
||||||
|
|
||||||
|
volatile float current_tps_value = 0.0;
|
||||||
|
volatile float current_freno_del_value = 0.0;
|
||||||
|
volatile float current_pcomb_value = 0.0;
|
||||||
|
volatile float current_taceite_value = 0.0;
|
||||||
|
volatile float current_paceite_value = 0.0;
|
||||||
|
volatile float current_map_value = 0.0;
|
||||||
|
volatile float current_lambda_value = 0.0;
|
||||||
|
volatile float current_lambda_obj_value = 0.0;
|
||||||
|
|
||||||
|
// Pendientes de instalar
|
||||||
|
volatile float current_freno_tra_value = 0.0;
|
||||||
|
volatile float current_velocidad_value = 0.0;
|
||||||
|
|
||||||
|
// Configuracion SD
|
||||||
|
void setLogSystem(SdFat* sd_inst, SdFile* file_inst) {
|
||||||
|
_sd = sd_inst;
|
||||||
|
_logFile = file_inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metodos CAN
|
||||||
|
void send_serial_frame_0(int rpmh, int rpml, int tpsh, int tpsl, int vbatth, int vbattl, int ect);
|
||||||
|
void send_serial_frame_1(int lmbh, int lmbl, int lmbth, int lmbtl, int fuelh, int fuell, int gear);
|
||||||
|
void send_serial_frame_2(int shut, int fan, int lmbch, int lmbcl, int brakeh, int brakel, int aux1);
|
||||||
|
void send_serial_frame_3(int oilth, int oiltl, int oilph, int oilpl, int maph, int mapl, int dig1);
|
||||||
|
void send_serial_frame_4(int dig3, int dig4, int dig5, int dig6, int dig7, int dig8, int dig9);
|
||||||
|
|
||||||
|
void send_serial(byte type, unsigned int value);
|
||||||
|
|
||||||
|
char* process(std::vector<float> data);
|
||||||
|
|
||||||
|
private:
|
||||||
|
SdFat* _sd = nullptr;
|
||||||
|
SdFile* _logFile = nullptr;
|
||||||
|
|
||||||
|
uint32_t _last_sync_time = 0;
|
||||||
|
const uint32_t _sync_interval_ms = 1000;
|
||||||
|
|
||||||
|
void flushToSD();
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
/**
|
/**
|
||||||
* @file can.cpp
|
* @file can.cpp
|
||||||
* @author Raúl Arcos Herrera
|
* @author Raul Arcos Herrera
|
||||||
* @brief This file contains the implementation of the CAN Controller class for Link G4+ ECU.
|
* @brief This file contains the implementation of the CAN Controller class for Link G4+ ECU.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "../include/can.hpp"
|
#include "../include/can.hpp"
|
||||||
|
|
||||||
|
// Volcado en crudo de TODOS los mensajes del bus, a la velocidad a la que
|
||||||
|
// llegan. Satura los 115200 baudios y frena la tarea de escucha, asi que solo
|
||||||
|
// debe activarse para depurar el bus.
|
||||||
|
#define VOLCADO_CRUDO_CAN 0
|
||||||
|
|
||||||
static bool driver_installed = false;
|
static bool driver_installed = false;
|
||||||
|
|
||||||
void CAN::start() {
|
void CAN::start() {
|
||||||
Serial.println("Starting CAN Controller...");
|
Serial.println("Starting CAN Controller...");
|
||||||
twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t)TX_PIN, (gpio_num_t)RX_PIN, TWAI_MODE_NORMAL);
|
twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t)TX_PIN, (gpio_num_t)RX_PIN, TWAI_MODE_NORMAL);
|
||||||
twai_timing_config_t t_config = TWAI_TIMING_CONFIG_125KBITS();
|
twai_timing_config_t t_config = TWAI_TIMING_CONFIG_500KBITS();
|
||||||
twai_filter_config_t f_config = TWAI_FILTER_CONFIG_ACCEPT_ALL();
|
twai_filter_config_t f_config = TWAI_FILTER_CONFIG_ACCEPT_ALL();
|
||||||
|
|
||||||
esp_err_t install_status = twai_driver_install(&g_config, &t_config, &f_config);
|
esp_err_t install_status = twai_driver_install(&g_config, &t_config, &f_config);
|
||||||
|
|
@ -41,7 +46,6 @@ void CAN::start() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TWAI driver is now successfully installed and started
|
|
||||||
driver_installed = true;
|
driver_installed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,20 +67,10 @@ void CAN::start_listening_task() {
|
||||||
"CAN_Listen_Task", // Task name
|
"CAN_Listen_Task", // Task name
|
||||||
4096, // Stack size (words)
|
4096, // Stack size (words)
|
||||||
this, // Task parameter (this CAN instance)
|
this, // Task parameter (this CAN instance)
|
||||||
1, // Priority (lowered from 5 to 1)
|
1, // Priority
|
||||||
&_listen_task_handle // Task handle
|
&_listen_task_handle // Task handle
|
||||||
);
|
);
|
||||||
|
|
||||||
// BaseType_t result = xTaskCreatePinnedToCore(
|
|
||||||
// listenTask, // Task function
|
|
||||||
// "CAN_Listen_Task", // Task name
|
|
||||||
// 4096, // Stack size (words)
|
|
||||||
// this, // Task parameter (this CAN instance)
|
|
||||||
// 1, // Priority
|
|
||||||
// &_listen_task_handle, // Task handle
|
|
||||||
// 0 // Core 0 (main loop typically runs on Core 1)
|
|
||||||
// );
|
|
||||||
|
|
||||||
if (result == pdPASS) {
|
if (result == pdPASS) {
|
||||||
Serial.println("CAN listening task created successfully");
|
Serial.println("CAN listening task created successfully");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -92,7 +86,6 @@ void CAN::stop_listening_task() {
|
||||||
if (_listen_task_handle != NULL) {
|
if (_listen_task_handle != NULL) {
|
||||||
_should_stop_listening = true;
|
_should_stop_listening = true;
|
||||||
|
|
||||||
// Wait for task to finish (max 1 second)
|
|
||||||
for (int i = 0; i < 100; i++) {
|
for (int i = 0; i < 100; i++) {
|
||||||
if (_listen_task_handle == NULL) {
|
if (_listen_task_handle == NULL) {
|
||||||
break;
|
break;
|
||||||
|
|
@ -100,7 +93,6 @@ void CAN::stop_listening_task() {
|
||||||
vTaskDelay(pdMS_TO_TICKS(10));
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force delete if still running
|
|
||||||
if (_listen_task_handle != NULL) {
|
if (_listen_task_handle != NULL) {
|
||||||
vTaskDelete(_listen_task_handle);
|
vTaskDelete(_listen_task_handle);
|
||||||
_listen_task_handle = NULL;
|
_listen_task_handle = NULL;
|
||||||
|
|
@ -132,21 +124,17 @@ twai_message_t CAN::createBoolMessage(bool b0, bool b1, bool b2, bool b3, bool b
|
||||||
void CAN::listen() {
|
void CAN::listen() {
|
||||||
Serial.println("CAN listening task started");
|
Serial.println("CAN listening task started");
|
||||||
|
|
||||||
// Continuous loop for the thread
|
|
||||||
while (!_should_stop_listening) {
|
while (!_should_stop_listening) {
|
||||||
if (!driver_installed) {
|
if (!driver_installed) {
|
||||||
// Driver not installed
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if alert happened
|
|
||||||
uint32_t alerts_triggered;
|
uint32_t alerts_triggered;
|
||||||
twai_read_alerts(&alerts_triggered, pdMS_TO_TICKS(1000)); // Reduced timeout for more responsiveness
|
twai_read_alerts(&alerts_triggered, 0);
|
||||||
twai_status_info_t twaistatus;
|
twai_status_info_t twaistatus;
|
||||||
twai_get_status_info(&twaistatus);
|
twai_get_status_info(&twaistatus);
|
||||||
|
|
||||||
// Handle alerts
|
|
||||||
if (alerts_triggered & TWAI_ALERT_ERR_PASS) {
|
if (alerts_triggered & TWAI_ALERT_ERR_PASS) {
|
||||||
Serial.println("Alert: TWAI controller has become error passive.");
|
Serial.println("Alert: TWAI controller has become error passive.");
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +154,7 @@ void CAN::listen() {
|
||||||
int message_count = 0;
|
int message_count = 0;
|
||||||
while (twai_receive(&message, 0) == ESP_OK && !_should_stop_listening) {
|
while (twai_receive(&message, 0) == ESP_OK && !_should_stop_listening) {
|
||||||
bool all_zeros = true;
|
bool all_zeros = true;
|
||||||
for (int i = 0; i < message.data_length_code; i++) {
|
for (int i = 1; i < message.data_length_code; i++) {
|
||||||
if (message.data[i] != 0) {
|
if (message.data[i] != 0) {
|
||||||
all_zeros = false;
|
all_zeros = false;
|
||||||
break;
|
break;
|
||||||
|
|
@ -174,24 +162,26 @@ void CAN::listen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (all_zeros) {
|
if (all_zeros) {
|
||||||
|
#if VOLCADO_CRUDO_CAN
|
||||||
Serial.println("Ignoring message with all zero data");
|
Serial.println("Ignoring message with all zero data");
|
||||||
|
#endif
|
||||||
taskYIELD();
|
taskYIELD();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if VOLCADO_CRUDO_CAN
|
||||||
if (message.extd) {
|
if (message.extd) {
|
||||||
Serial.println("Extended Format");
|
Serial.println("Extended Format");
|
||||||
} else {
|
} else {
|
||||||
Serial.println("Standard Format");
|
Serial.println("Standard Format");
|
||||||
}
|
}
|
||||||
Serial.printf("ID: %lx\nByte:", message.identifier);
|
Serial.printf("ID: %lx\nByte:", message.identifier);
|
||||||
if (!(message.rtr)) {
|
for (int i = 0; i < message.data_length_code && !(message.rtr); i++) {
|
||||||
for (int i = 0; i < message.data_length_code; i++) {
|
|
||||||
Serial.printf(" %d = %02x,", i, message.data[i]);
|
Serial.printf(" %d = %02x,", i, message.data[i]);
|
||||||
}
|
}
|
||||||
Serial.println("");
|
Serial.println("");
|
||||||
|
#endif
|
||||||
// Send to data processor based on first byte (maintaining original logic)
|
if (!(message.rtr)) {
|
||||||
switch (message.data[0]) {
|
switch (message.data[0]) {
|
||||||
case 0:
|
case 0:
|
||||||
_data_processor->send_serial_frame_0(message.data[1], message.data[2], message.data[3], message.data[4], message.data[5], message.data[6], message.data[7]);
|
_data_processor->send_serial_frame_0(message.data[1], message.data[2], message.data[3], message.data[4], message.data[5], message.data[6], message.data[7]);
|
||||||
|
|
@ -204,6 +194,10 @@ void CAN::listen() {
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
_data_processor->send_serial_frame_3(message.data[1], message.data[2], message.data[3], message.data[4], message.data[5], message.data[6], message.data[7]);
|
_data_processor->send_serial_frame_3(message.data[1], message.data[2], message.data[3], message.data[4], message.data[5], message.data[6], message.data[7]);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
_data_processor->send_serial_frame_4(message.data[1], message.data[2], message.data[3], message.data[4], message.data[5], message.data[6], message.data[7]);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -219,5 +213,5 @@ void CAN::listen() {
|
||||||
|
|
||||||
Serial.println("CAN listening task ending");
|
Serial.println("CAN listening task ending");
|
||||||
_listen_task_handle = NULL;
|
_listen_task_handle = NULL;
|
||||||
vTaskDelete(NULL); // Delete this task
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
90
Firmware/G26-Telemetria/src/data_processor.cpp
Normal file
90
Firmware/G26-Telemetria/src/data_processor.cpp
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
#include "../include/data_processor.hpp"
|
||||||
|
|
||||||
|
char* DataProcessor::process(std::vector<float> data) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DataProcessor::send_serial(byte type, unsigned int value) {
|
||||||
|
byte dato[8] = { 0x5A, 0xA5, 0x05, 0x82, 0x00, 0x00, 0x00, 0x00 };
|
||||||
|
dato[4] = type;
|
||||||
|
dato[6] = (value >> 8) & 0xFF;
|
||||||
|
dato[7] = value & 0xFF;
|
||||||
|
Serial.write(dato, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPM + TPS + vBatt + ECT
|
||||||
|
void DataProcessor::send_serial_frame_0(int rpmh, int rpml, int tpsh, int tpsl, int vbatth, int vbattl, int ect) {
|
||||||
|
int rpm = (rpmh * 256) + rpml;
|
||||||
|
double vbatt = ((vbatth * 256) + vbattl) / 100.0;
|
||||||
|
int tps = (tpsh * 256) + tpsl;
|
||||||
|
|
||||||
|
this->current_ect_value = ect;
|
||||||
|
this->current_rpm_value = rpm;
|
||||||
|
this->current_vbatt_value = vbatt;
|
||||||
|
this->current_tps_value = tps;
|
||||||
|
|
||||||
|
flushToSD();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DataProcessor::send_serial_frame_1(int lmbh, int lmbl, int lmbth, int lmbtl, int fuelh, int fuell, int gear) {
|
||||||
|
float lambda = ((lmbh * 256) + lmbl) / 100.0;
|
||||||
|
float lambdaTarget = ((lmbth * 256) + lmbtl) / 100.0;
|
||||||
|
float presionComb = ((fuelh * 256) + fuell) / 100.0;
|
||||||
|
|
||||||
|
this->current_lambda_value = lambda;
|
||||||
|
this->current_lambda_obj_value = lambdaTarget;
|
||||||
|
this->current_pcomb_value = presionComb;
|
||||||
|
flushToSD();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void DataProcessor::send_serial_frame_2(int shut, int fan, int lmbch, int lmbcl, int brakeh, int brakel, int aux1) {
|
||||||
|
float freno = (brakeh * 256) + brakel;
|
||||||
|
this->current_freno_del_value = freno;
|
||||||
|
flushToSD();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void DataProcessor::send_serial_frame_3(int oilth, int oiltl, int oilph, int oilpl, int maph, int mapl, int dig1) {
|
||||||
|
float tempOil = ((oilth * 256) + oiltl) / 100.0;
|
||||||
|
float presionOil = ((oilph * 256) + oilpl) / 100.0;
|
||||||
|
float map = ((maph * 256) + mapl) / 100.0;
|
||||||
|
|
||||||
|
this->current_taceite_value = tempOil;
|
||||||
|
this->current_paceite_value = presionOil;
|
||||||
|
this->current_map_value = map;
|
||||||
|
flushToSD();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void DataProcessor::send_serial_frame_4(int dig3, int dig4, int dig5, int dig6, int dig7, int dig8, int dig9) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ESCRITURA SD ---
|
||||||
|
|
||||||
|
void DataProcessor::flushToSD() {
|
||||||
|
if (_sd && _logFile && _logFile->isOpen()) {
|
||||||
|
char buffer[256];
|
||||||
|
int len = snprintf(buffer, sizeof(buffer),
|
||||||
|
"%lu,%d,%d,%.1f,%.2f,%.1f,%.2f,%.1f,%.2f,%.1f,%.3f,%.3f\n",
|
||||||
|
millis(),
|
||||||
|
current_ect_value,
|
||||||
|
current_rpm_value,
|
||||||
|
current_tps_value,
|
||||||
|
current_vbatt_value,
|
||||||
|
current_freno_del_value,
|
||||||
|
current_pcomb_value,
|
||||||
|
current_taceite_value,
|
||||||
|
current_paceite_value,
|
||||||
|
current_map_value,
|
||||||
|
current_lambda_value,
|
||||||
|
current_lambda_obj_value);
|
||||||
|
|
||||||
|
_logFile->write(buffer, len);
|
||||||
|
|
||||||
|
if (millis() - _last_sync_time > _sync_interval_ms) {
|
||||||
|
_logFile->sync();
|
||||||
|
_last_sync_time = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
#include "include/data_processor.hpp"
|
|
||||||
#include "include/can.hpp"
|
|
||||||
#include "include/g24_wheel_buttons.hpp"
|
|
||||||
#include "include/led_strip.hpp"
|
|
||||||
#include "include/crowpanel_controller.hpp"
|
|
||||||
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/task.h>
|
|
||||||
|
|
||||||
DataProcessor dataProcessor;
|
|
||||||
CAN canController;
|
|
||||||
G24WheelButtons wheelButtons;
|
|
||||||
LedStrip ledStrip;
|
|
||||||
CrowPanelController crowPanelController;
|
|
||||||
|
|
||||||
// Screen rotation variables
|
|
||||||
unsigned long lastScreenChange = 0;
|
|
||||||
int currentScreen = 1;
|
|
||||||
int screenCycle = 0; // 0 = screen1, 1 = screen2, 2 = screen3, 3 = screen4
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
while (!Serial) { delay(10); }
|
|
||||||
Serial.println("Starting setup...");
|
|
||||||
canController.set_data_proccessor(&dataProcessor);
|
|
||||||
dataProcessor.set_led_strip(&ledStrip);
|
|
||||||
dataProcessor.set_crow_panel_controller(&crowPanelController);
|
|
||||||
// wheelButtons.set_led_strip(&ledStrip);
|
|
||||||
// wheelButtons.set_can_controller(&canController);
|
|
||||||
// wheelButtons.set_data_processor(&dataProcessor);
|
|
||||||
// ledStrip.set_mutex(canController.get_mutex());
|
|
||||||
|
|
||||||
canController.start();
|
|
||||||
canController.start_listening_task();
|
|
||||||
|
|
||||||
|
|
||||||
// wheelButtons.begin();
|
|
||||||
|
|
||||||
// xTaskCreate(wheelButtons.updateTask, "updateTask", 4096, &wheelButtons, 1, NULL);
|
|
||||||
|
|
||||||
// Initialize with screen 1
|
|
||||||
lastScreenChange = millis();
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop(){
|
|
||||||
lv_timer_handler();
|
|
||||||
vTaskDelay(5);
|
|
||||||
}
|
|
||||||
0
Plataform_Web/documentos/__init__.py
Normal file
0
Plataform_Web/documentos/__init__.py
Normal file
39
Plataform_Web/documentos/admin.py
Normal file
39
Plataform_Web/documentos/admin.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Documento, Factura
|
||||||
|
|
||||||
|
@admin.register(Documento)
|
||||||
|
class DocumentoAdmin(admin.ModelAdmin):
|
||||||
|
# Columnas principales
|
||||||
|
list_display = ('nombre', 'categoria', 'tipo', 'subido_por', 'fecha_subida')
|
||||||
|
|
||||||
|
# Filtros por área técnica, tipo de documento y temporada del documento
|
||||||
|
list_filter = ('categoria', 'tipo', 'temporada')
|
||||||
|
|
||||||
|
# Buscador de documentos por su nombre o descripción
|
||||||
|
search_fields = ('nombre', 'descripcion')
|
||||||
|
|
||||||
|
# Para asignar a que temporada pertenece el documento
|
||||||
|
filter_horizontal = ('temporada',)
|
||||||
|
|
||||||
|
# Automatización: para rellenar el creador del nuevo documento de manera automática
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if not obj.subido_por:
|
||||||
|
obj.subido_por = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Factura)
|
||||||
|
class FacturaAdmin(admin.ModelAdmin):
|
||||||
|
# Herncia de Documento
|
||||||
|
list_display = ('nombre', 'empresa', 'importe', 'categoria', 'subido_por', 'fecha_subida')
|
||||||
|
|
||||||
|
list_filter = ('categoria', 'temporada')
|
||||||
|
search_fields = ('nombre', 'empresa', 'descripcion')
|
||||||
|
|
||||||
|
filter_horizontal = ('temporada',)
|
||||||
|
|
||||||
|
# Mantenemos la misma automatización
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if not obj.subido_por:
|
||||||
|
obj.subido_por = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
5
Plataform_Web/documentos/apps.py
Normal file
5
Plataform_Web/documentos/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentosConfig(AppConfig):
|
||||||
|
name = 'documentos'
|
||||||
37
Plataform_Web/documentos/migrations/0001_initial.py
Normal file
37
Plataform_Web/documentos/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-02-17 11:36
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('temporadas', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Documento',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('titulo', models.CharField(max_length=100, verbose_name='Título del documento')),
|
||||||
|
('archivo', models.FileField(upload_to='ingenieria_docs/')),
|
||||||
|
('categoria', models.CharField(choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('suspension', 'Suspensión'), ('motor', 'Motor/Powertrain'), ('electronica', 'Electrónica'), ('general', 'General / Normativa')], max_length=20)),
|
||||||
|
('tipo', models.CharField(choices=[('diseno', 'Diseño / CAD'), ('simulacion', 'Simulación'), ('informe', 'Informe Técnico'), ('factura', 'Factura / Presupuesto'), ('otro', 'Otro')], default='informe', max_length=20)),
|
||||||
|
('descripcion', models.TextField(blank=True, null=True, verbose_name='Descripción o notas')),
|
||||||
|
('fecha_subida', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('subido_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documentos_subidos', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('temporada', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='temporadas.temporada')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Documento Técnico',
|
||||||
|
'verbose_name_plural': 'Documentos de Ingeniería',
|
||||||
|
'ordering': ['-fecha_subida'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-03-06 19:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('documentos', '0001_initial'),
|
||||||
|
('temporadas', '0002_temporada_miembros'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Factura',
|
||||||
|
fields=[
|
||||||
|
('documento_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='documentos.documento')),
|
||||||
|
('empresa', models.CharField(max_length=100, verbose_name='Nombre de la empresa')),
|
||||||
|
('importe', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Importe (€)')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Factura',
|
||||||
|
'verbose_name_plural': 'Facturas',
|
||||||
|
},
|
||||||
|
bases=('documentos.documento',),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='documento',
|
||||||
|
options={'ordering': ['-fecha_subida'], 'verbose_name': 'Documento', 'verbose_name_plural': 'Documentos'},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='documento',
|
||||||
|
name='titulo',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='documento',
|
||||||
|
name='nombre',
|
||||||
|
field=models.CharField(default='Documento antiguo', max_length=100, verbose_name='Nombre del documento'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='documento',
|
||||||
|
name='categoria',
|
||||||
|
field=models.CharField(choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('business', 'Business & Operations'), ('e_powertrain', 'E-Powertrain'), ('electronica', 'Electrónica'), ('sdf', 'SDF'), ('motor_transmision', 'Motor & Transmisión'), ('software', 'Software'), ('normativa', 'General / Normativa')], max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='documento',
|
||||||
|
name='descripcion',
|
||||||
|
field=models.TextField(blank=True, null=True, verbose_name='Descripción del documento'),
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='documento',
|
||||||
|
name='temporada',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='documento',
|
||||||
|
name='tipo',
|
||||||
|
field=models.CharField(choices=[('fabricacion', 'Fabricación'), ('diseno', 'Diseño / CAD'), ('concepto', 'Concepto'), ('simulacion', 'Simulación'), ('dossier_patrocinado', 'Dossier Patrocinio'), ('informe', 'Informe'), ('tutorial', 'Tutorial'), ('otro', 'Otro')], default='informe', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='documento',
|
||||||
|
name='temporada',
|
||||||
|
field=models.ManyToManyField(related_name='documentos_asociados', to='temporadas.temporada'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
Plataform_Web/documentos/migrations/0003_factura_estado.py
Normal file
18
Plataform_Web/documentos/migrations/0003_factura_estado.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-06-30 16:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('documentos', '0002_factura_alter_documento_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='factura',
|
||||||
|
name='estado',
|
||||||
|
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('aceptada', 'Aceptada'), ('rechazada', 'Rechazada')], default='pendiente', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
Plataform_Web/documentos/migrations/__init__.py
Normal file
0
Plataform_Web/documentos/migrations/__init__.py
Normal file
83
Plataform_Web/documentos/models.py
Normal file
83
Plataform_Web/documentos/models.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
from django.db import models
|
||||||
|
from users.models import CustomUser
|
||||||
|
from temporadas.models import Temporada
|
||||||
|
import os
|
||||||
|
|
||||||
|
class Documento(models.Model):
|
||||||
|
# Opciones de categorías (puedes añadir más)
|
||||||
|
CATEGORIAS = (
|
||||||
|
('aerodinamica', 'Aerodinámica'),
|
||||||
|
('chasis', 'Chasis'),
|
||||||
|
('business', 'Business & Operations'),
|
||||||
|
('e_powertrain', 'E-Powertrain'),
|
||||||
|
('electronica', 'Electrónica'),
|
||||||
|
('sdf', 'SDF'),
|
||||||
|
('motor_transmision', 'Motor & Transmisión'),
|
||||||
|
('software', 'Software'),
|
||||||
|
('normativa', 'General / Normativa'),
|
||||||
|
)
|
||||||
|
|
||||||
|
TIPO_DOC = (
|
||||||
|
('fabricacion', 'Fabricación'),
|
||||||
|
('diseno', 'Diseño / CAD'),
|
||||||
|
('concepto', 'Concepto'),
|
||||||
|
('simulacion', 'Simulación'),
|
||||||
|
('dossier_patrocinado', 'Dossier Patrocinio'),
|
||||||
|
('informe', 'Informe'),
|
||||||
|
('tutorial', 'Tutorial'),
|
||||||
|
('otro', 'Otro'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# CAMPOS
|
||||||
|
nombre = models.CharField(max_length=100, verbose_name="Nombre del documento")
|
||||||
|
|
||||||
|
# Aquí definimos dónde se guardan los archivos.
|
||||||
|
# upload_to='ingenieria/' creará esa carpeta automáticamente.
|
||||||
|
archivo = models.FileField(upload_to='ingenieria_docs/')
|
||||||
|
|
||||||
|
categoria = models.CharField(max_length=20, choices=CATEGORIAS)
|
||||||
|
tipo = models.CharField(max_length=20, choices=TIPO_DOC, default='informe')
|
||||||
|
descripcion = models.TextField(blank=True, null=True, verbose_name="Descripción del documento")
|
||||||
|
fecha_subida = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# RELACIONES
|
||||||
|
# Relacion Documento - Temporadas (Relación 1..* a 1..* )
|
||||||
|
temporada = models.ManyToManyField(Temporada, related_name="documentos_asociados")
|
||||||
|
|
||||||
|
# 2. Relación 1 a N con el Usuario (Miembros)
|
||||||
|
subido_por = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, null=True, related_name="documentos_subidos")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Documento"
|
||||||
|
verbose_name_plural = "Documentos"
|
||||||
|
ordering = ['-fecha_subida']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.nombre} ({self.get_categoria_display()})"
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
# Esto borra el archivo físico del disco duro cuando borras la entrada en la base de datos
|
||||||
|
if self.archivo:
|
||||||
|
if os.path.isfile(self.archivo.path):
|
||||||
|
os.remove(self.archivo.path)
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
ESTADO_FACTURA = [
|
||||||
|
('pendiente', 'Pendiente'),
|
||||||
|
('aceptada', 'Aceptada'),
|
||||||
|
('rechazada', 'Rechazada'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Herencia de la clase Factura: Factura hereda de Documento
|
||||||
|
class Factura(Documento):
|
||||||
|
# Al heredar de Documento, ya tiene nombre, archivo, categoria, etc.
|
||||||
|
empresa = models.CharField(max_length=100, verbose_name="Nombre de la empresa")
|
||||||
|
importe = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Importe (€)")
|
||||||
|
estado = models.CharField(max_length=20, choices=ESTADO_FACTURA, default='pendiente')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Factura"
|
||||||
|
verbose_name_plural = "Facturas"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Factura {self.empresa} - {self.importe}€"
|
||||||
3
Plataform_Web/documentos/tests.py
Normal file
3
Plataform_Web/documentos/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
Plataform_Web/documentos/views.py
Normal file
3
Plataform_Web/documentos/views.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
Plataform_Web/gades_manager/__init__.py
Normal file
0
Plataform_Web/gades_manager/__init__.py
Normal file
16
Plataform_Web/gades_manager/asgi.py
Normal file
16
Plataform_Web/gades_manager/asgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""
|
||||||
|
ASGI config for gades_manager project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gades_manager.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
141
Plataform_Web/gades_manager/settings.py
Normal file
141
Plataform_Web/gades_manager/settings.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"""
|
||||||
|
Django settings for gades_manager project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 6.0.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = 'django-insecure-mwlyo^0ax9u9ts5(hw(e&vlgmr$nbla-3az0d%s(lymmp5^)$-'
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'users',
|
||||||
|
'temporadas',
|
||||||
|
'documentos',
|
||||||
|
'gestion',
|
||||||
|
'pruebas',
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'gades_manager.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'gades_manager.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.mysql',
|
||||||
|
'NAME': 'formula_gades_bd', # El nombre exacto que pusiste en Workbench
|
||||||
|
'USER': 'root',
|
||||||
|
'PASSWORD': 'admin1234', # ¡La que apuntaste en el papel!
|
||||||
|
'HOST': 'localhost', # Significa "este ordenador"
|
||||||
|
'PORT': '3306',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
|
STATICFILES_DIRS = [
|
||||||
|
BASE_DIR / "static",
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.CustomUser'
|
||||||
|
|
||||||
|
MEDIA_URL = '/media/' # La URL pública (ej: tudominio.com/media/archivo.pdf)
|
||||||
|
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # La carpeta física en tu ordenador
|
||||||
|
|
||||||
|
LOGIN_URL = 'login'
|
||||||
|
LOGIN_REDIRECT_URL = 'inicio'
|
||||||
|
LOGOUT_REDIRECT_URL = 'login'
|
||||||
55
Plataform_Web/gades_manager/urls.py
Normal file
55
Plataform_Web/gades_manager/urls.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""
|
||||||
|
URL configuration for gades_manager project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from gestion import views
|
||||||
|
from users import views as users_views
|
||||||
|
from temporadas import views as temporadas_views
|
||||||
|
from pruebas import views as pruebas_views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('', views.inicio, name='inicio'),
|
||||||
|
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
|
||||||
|
path('logout/', auth_views.LogoutView.as_view(next_page='login'), name='logout'),
|
||||||
|
path('mi-perfil/', users_views.mi_perfil, name='mi_perfil'),
|
||||||
|
path('miembros/', users_views.listado_miembros, name='listado_miembros'),
|
||||||
|
path('gestion/usuarios/', users_views.gestion_usuarios, name='gestion_usuarios'),
|
||||||
|
path('gestion/usuarios/<int:pk>/editar/', users_views.editar_usuario, name='editar_usuario'),
|
||||||
|
path('gestion/usuarios/<int:pk>/eliminar/', users_views.eliminar_usuario, name='eliminar_usuario'),
|
||||||
|
path('gestion/temporadas/', temporadas_views.gestion_temporadas, name='gestion_temporadas'),
|
||||||
|
path('gestion/temporadas/crear/', temporadas_views.crear_temporada, name='crear_temporada'),
|
||||||
|
path('gestion/temporadas/<int:pk>/editar/', temporadas_views.editar_temporada, name='editar_temporada'),
|
||||||
|
path('gestion/temporadas/<int:pk>/eliminar/', temporadas_views.eliminar_temporada, name='eliminar_temporada'),
|
||||||
|
path('gestion/contabilidad/', views.contabilidad, name='contabilidad'),
|
||||||
|
path('gestion/contabilidad/gasto/anadir/', views.anadir_gasto, name='anadir_gasto'),
|
||||||
|
path('gestion/contabilidad/ingreso/anadir/', views.anadir_ingreso, name='anadir_ingreso'),
|
||||||
|
path('gestion/contabilidad/factura/<int:pk>/aceptar/', views.aceptar_factura, name='aceptar_factura'),
|
||||||
|
path('gestion/contabilidad/factura/<int:pk>/rechazar/', views.rechazar_factura, name='rechazar_factura'),
|
||||||
|
path('patrocinios/', views.patrocinios, name='patrocinios'),
|
||||||
|
path('patrocinios/proponer/', views.proponer_patrocinio, name='proponer_patrocinio'),
|
||||||
|
path('patrocinios/<int:pk>/editar/', views.editar_patrocinio, name='editar_patrocinio'),
|
||||||
|
path('patrocinios/<int:pk>/estado/', views.cambiar_estado_patrocinio, name='cambiar_estado_patrocinio'),
|
||||||
|
path('pruebas/', pruebas_views.listado_pruebas, name='listado_pruebas'),
|
||||||
|
path('pruebas/nueva/', pruebas_views.crear_prueba, name='crear_prueba'),
|
||||||
|
path('pruebas/<int:pk>/', pruebas_views.detalle_prueba, name='detalle_prueba'),
|
||||||
|
path('pruebas/<int:pk>/editar/', pruebas_views.editar_prueba, name='editar_prueba'),
|
||||||
|
path('pruebas/<int:pk>/eliminar/', pruebas_views.eliminar_prueba, name='eliminar_prueba'),
|
||||||
|
path('pruebas/<int:pk>/csv/subir/', pruebas_views.subir_csv, name='subir_csv'),
|
||||||
|
]
|
||||||
16
Plataform_Web/gades_manager/wsgi.py
Normal file
16
Plataform_Web/gades_manager/wsgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""
|
||||||
|
WSGI config for gades_manager project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gades_manager.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
0
Plataform_Web/gestion/__init__.py
Normal file
0
Plataform_Web/gestion/__init__.py
Normal file
46
Plataform_Web/gestion/admin.py
Normal file
46
Plataform_Web/gestion/admin.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Patrocinio, Pieza, Gasto, Ingreso
|
||||||
|
|
||||||
|
# INLINES
|
||||||
|
class PiezaInline(admin.TabularInline):
|
||||||
|
model = Pieza
|
||||||
|
extra = 1
|
||||||
|
|
||||||
|
# PANEL PARA ADMINISTRACIÓN
|
||||||
|
@admin.register(Patrocinio)
|
||||||
|
class PatrocinioAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('empresa', 'estado', 'tipo_patrocinio', 'importe_economico', 'temporada', 'contacto_equipo')
|
||||||
|
|
||||||
|
# Para editar el estado y pasar de En contacto a Aceptado o Denagado desde la lista, sin tener que hacerlo manual
|
||||||
|
list_editable = ('estado',)
|
||||||
|
|
||||||
|
list_filter = ('estado', 'tipo_patrocinio', 'temporada')
|
||||||
|
search_fields = ('empresa', 'persona_contacto', 'email_contacto')
|
||||||
|
|
||||||
|
# Metemos las piezas en el patrocinio, por si hiciera falta
|
||||||
|
inlines = [PiezaInline]
|
||||||
|
|
||||||
|
@admin.register(Pieza)
|
||||||
|
class PiezaAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('nombre', 'cantidad', 'patrocinio')
|
||||||
|
search_fields = ('nombre', 'patrocinio__empresa')
|
||||||
|
|
||||||
|
# CONTABILIDAD (Gastos e Ingresos)
|
||||||
|
@admin.register(Gasto)
|
||||||
|
class GastoAdmin(admin.ModelAdmin):
|
||||||
|
# Heredamos los campos de Contabilidad
|
||||||
|
list_display = ('concepto', 'importe', 'categoria', 'fecha', 'temporada')
|
||||||
|
list_filter = ('categoria', 'temporada')
|
||||||
|
search_fields = ('concepto', 'observaciones')
|
||||||
|
|
||||||
|
# Barra de navegación por fecha de gasto
|
||||||
|
date_hierarchy = 'fecha'
|
||||||
|
|
||||||
|
@admin.register(Ingreso)
|
||||||
|
class IngresoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('concepto', 'importe', 'categoria', 'fecha', 'temporada')
|
||||||
|
list_filter = ('categoria', 'temporada')
|
||||||
|
search_fields = ('concepto', 'observaciones')
|
||||||
|
|
||||||
|
# Barra de navegación por fecha de ingreso
|
||||||
|
date_hierarchy = 'fecha'
|
||||||
5
Plataform_Web/gestion/apps.py
Normal file
5
Plataform_Web/gestion/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GestionConfig(AppConfig):
|
||||||
|
name = 'gestion'
|
||||||
46
Plataform_Web/gestion/forms.py
Normal file
46
Plataform_Web/gestion/forms.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
from django import forms
|
||||||
|
from .models import Gasto, Ingreso, Patrocinio
|
||||||
|
|
||||||
|
|
||||||
|
class GastoForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Gasto
|
||||||
|
fields = ['concepto', 'importe', 'categoria', 'observaciones']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs['class'] = 'form-control'
|
||||||
|
|
||||||
|
|
||||||
|
class IngresoForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Ingreso
|
||||||
|
fields = ['concepto', 'importe', 'categoria', 'observaciones']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs['class'] = 'form-control'
|
||||||
|
|
||||||
|
|
||||||
|
class PatrocinioForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Patrocinio
|
||||||
|
fields = ['empresa', 'email_contacto', 'persona_contacto', 'tipo_patrocinio']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs['class'] = 'form-control'
|
||||||
|
|
||||||
|
|
||||||
|
class PatrocinioEditForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Patrocinio
|
||||||
|
fields = ['empresa', 'email_contacto', 'persona_contacto', 'tipo_patrocinio', 'estado', 'importe_economico']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs['class'] = 'form-control'
|
||||||
45
Plataform_Web/gestion/migrations/0001_initial.py
Normal file
45
Plataform_Web/gestion/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-02-19 10:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('temporadas', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Factura',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('nombre', models.CharField(max_length=100, verbose_name='Concepto de la factura')),
|
||||||
|
('empresa', models.CharField(max_length=100, verbose_name='Empresa / Proveedor')),
|
||||||
|
('categoria', models.CharField(choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('business', 'Business & Operations'), ('epowertrain', 'E-Powertrain'), ('electronica', 'Electrónica'), ('sdf', 'SDF'), ('motor_transmision', 'Motor & Transmisión'), ('software', 'Software'), ('general', 'General')], max_length=30)),
|
||||||
|
('descripcion', models.TextField(blank=True, null=True, verbose_name='Breve descripción')),
|
||||||
|
('archivo', models.FileField(upload_to='facturas/', verbose_name='Foto o PDF de la factura')),
|
||||||
|
('subido_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Usuario que la sube')),
|
||||||
|
('temporada', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='temporadas.temporada')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Patrocinador',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('empresa', models.CharField(max_length=100, verbose_name='Nombre de la Empresa')),
|
||||||
|
('email_contacto', models.EmailField(max_length=254, verbose_name='Correo de contacto')),
|
||||||
|
('tipo_empresa', models.CharField(max_length=30, verbose_name='Tipo de la Empresa')),
|
||||||
|
('tipo_patrocinio', models.CharField(choices=[('economico', 'Económico'), ('piezas/materiales', 'Piezas/Materiales'), ('mixto', 'Mixto (Dinero y Piezas)')], max_length=30)),
|
||||||
|
('detalle_piezas', models.TextField(blank=True, help_text='Detallar qué se ofrece si es en especies', null=True)),
|
||||||
|
('estado', models.CharField(choices=[('en contacto', 'En contacto'), ('aceptado', 'Aceptado / Activo'), ('denegado', 'Denegado')], default='contacto', max_length=20)),
|
||||||
|
('contacto_equipo', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Miembro al cargo')),
|
||||||
|
('temporada', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='temporadas.temporada')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-03-06 19:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('gestion', '0001_initial'),
|
||||||
|
('temporadas', '0002_temporada_miembros'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='patrocinador',
|
||||||
|
name='contacto_equipo',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='patrocinador',
|
||||||
|
name='temporada',
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Gasto',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('concepto', models.CharField(max_length=150)),
|
||||||
|
('importe', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('fecha', models.DateField()),
|
||||||
|
('observaciones', models.TextField(blank=True, null=True)),
|
||||||
|
('categoria', models.CharField(choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('business', 'Business & Operations'), ('e_powertrain', 'E-Powertrain'), ('electronica', 'Electrónica'), ('sdf', 'SDF'), ('motor_transmision', 'Motor & Transmisión'), ('software', 'Software'), ('general', 'General')], max_length=30, verbose_name='Área del gasto')),
|
||||||
|
('doc_justificativo', models.FileField(blank=True, null=True, upload_to='contabilidad/gastos/', verbose_name='Ticket o Factura (PDF/IMG)')),
|
||||||
|
('temporada', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_registrados', to='temporadas.temporada')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Gasto',
|
||||||
|
'verbose_name_plural': 'Gastos',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Ingreso',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('concepto', models.CharField(max_length=150)),
|
||||||
|
('importe', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('fecha', models.DateField()),
|
||||||
|
('observaciones', models.TextField(blank=True, null=True)),
|
||||||
|
('categoria', models.CharField(choices=[('patrocinador', 'Patrocinador'), ('donación', 'Donación'), ('premio', 'Premio'), ('recaudación', 'Recaudación'), ('Cuotas', 'Cuotas'), ('Ventas', 'ventas')], max_length=30, verbose_name='Categoria del Ingreso')),
|
||||||
|
('temporada', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_registrados', to='temporadas.temporada')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Ingreso',
|
||||||
|
'verbose_name_plural': 'Ingresos',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Patrocinio',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('empresa', models.CharField(max_length=100, verbose_name='Nombre de la Empresa')),
|
||||||
|
('persona_contacto', models.CharField(blank=True, max_length=100, null=True, verbose_name='Persona de contacto')),
|
||||||
|
('email_contacto', models.EmailField(max_length=254, verbose_name='Correo de contacto')),
|
||||||
|
('tipo_patrocinio', models.CharField(choices=[('economico', 'Económico'), ('piezas/materiales', 'Piezas/Materiales'), ('mixto', 'Mixto (Dinero y Piezas)')], max_length=30)),
|
||||||
|
('estado', models.CharField(choices=[('en_contacto', 'En contacto'), ('aceptado', 'Aceptado / Activo'), ('denegado', 'Denegado')], default='en_contacto', max_length=20)),
|
||||||
|
('importe_economico', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Importe (€)')),
|
||||||
|
('fecha_contacto', models.DateField(auto_now_add=True)),
|
||||||
|
('contacto_equipo', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='patrocinios_gestionados', to=settings.AUTH_USER_MODEL, verbose_name='Persona al cargo del Patrocinio')),
|
||||||
|
('temporada', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='patrocinios', to='temporadas.temporada', verbose_name='Temporada del patrocinio')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Patrocinio',
|
||||||
|
'verbose_name_plural': 'Patrocinios',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Pieza',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('nombre', models.CharField(max_length=100, verbose_name='Nombre de la pieza/material')),
|
||||||
|
('cantidad', models.PositiveIntegerField(default=1)),
|
||||||
|
('patrocinio', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='piezas', to='gestion.patrocinio')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Factura',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Patrocinador',
|
||||||
|
),
|
||||||
|
]
|
||||||
0
Plataform_Web/gestion/migrations/__init__.py
Normal file
0
Plataform_Web/gestion/migrations/__init__.py
Normal file
106
Plataform_Web/gestion/models.py
Normal file
106
Plataform_Web/gestion/models.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from temporadas.models import Temporada
|
||||||
|
|
||||||
|
|
||||||
|
class Patrocinio(models.Model):
|
||||||
|
|
||||||
|
TIPO_PATROCINIO = (
|
||||||
|
('economico', 'Económico'),
|
||||||
|
('piezas/materiales', 'Piezas/Materiales'),
|
||||||
|
('mixto', 'Mixto (Dinero y Piezas)'),
|
||||||
|
)
|
||||||
|
|
||||||
|
ESTADOS = (
|
||||||
|
('en_contacto', 'En contacto'),
|
||||||
|
('aceptado', 'Aceptado / Activo'),
|
||||||
|
('denegado', 'Denegado'),
|
||||||
|
)
|
||||||
|
|
||||||
|
empresa = models.CharField(max_length=100, verbose_name="Nombre de la Empresa")
|
||||||
|
persona_contacto = models.CharField(max_length=100, blank=True, null=True, verbose_name="Persona de contacto")
|
||||||
|
email_contacto = models.EmailField(verbose_name="Correo de contacto")
|
||||||
|
tipo_patrocinio = models.CharField(max_length=30, choices=TIPO_PATROCINIO)
|
||||||
|
estado = models.CharField(max_length=20, choices=ESTADOS, default='en_contacto')
|
||||||
|
|
||||||
|
# Si el patrocinio es únicamente material, se quedará en 0. Si es económico o mixto, se rellena
|
||||||
|
importe_economico = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name="Importe (€)")
|
||||||
|
|
||||||
|
fecha_contacto = models.DateField(auto_now_add=True)
|
||||||
|
# Relaciones
|
||||||
|
temporada = models.ForeignKey(Temporada, on_delete=models.CASCADE, related_name="patrocinios", verbose_name="Temporada del patrocinio")
|
||||||
|
contacto_equipo = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="patrocinios_gestionados", verbose_name="Persona al cargo del Patrocinio")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Patrocinio"
|
||||||
|
verbose_name_plural = "Patrocinios"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.empresa} ({self.get_estado_display()})"
|
||||||
|
|
||||||
|
class Pieza(models.Model):
|
||||||
|
nombre = models.CharField(max_length=100, verbose_name="Nombre de la pieza/material")
|
||||||
|
cantidad = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
|
# COMPOSICIÓN ESTRICTA: Si se borra el Patrocinio, se borran las Piezas irremediablemente (CASCADE)
|
||||||
|
patrocinio = models.ForeignKey(Patrocinio, on_delete=models.CASCADE, related_name="piezas")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.cantidad}x {self.nombre} (De: {self.patrocinio.empresa})"
|
||||||
|
|
||||||
|
class Contabilidad(models.Model):
|
||||||
|
concepto = models.CharField(max_length=150)
|
||||||
|
importe = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
fecha = models.DateField()
|
||||||
|
observaciones = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Relación con Temporada. PROTECT evita que borres una temporada si tiene contabilidad asociada (por temas legales)
|
||||||
|
temporada = models.ForeignKey(Temporada, on_delete=models.PROTECT, related_name="%(class)s_registrados")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True #marcamos la clase como abstracta
|
||||||
|
ordering = ['-fecha']
|
||||||
|
|
||||||
|
|
||||||
|
class Gasto(Contabilidad):
|
||||||
|
|
||||||
|
CATEGORIAS_GASTOS = (
|
||||||
|
('aerodinamica', 'Aerodinámica'),
|
||||||
|
('chasis', 'Chasis'),
|
||||||
|
('business', 'Business & Operations'),
|
||||||
|
('e_powertrain', 'E-Powertrain'),
|
||||||
|
('electronica', 'Electrónica'),
|
||||||
|
('sdf', 'SDF'),
|
||||||
|
('motor_transmision', 'Motor & Transmisión'),
|
||||||
|
('software', 'Software'),
|
||||||
|
('general', 'General'),
|
||||||
|
)
|
||||||
|
# Hereda concepto, importe, fecha, observaciones y temporada automáticamente
|
||||||
|
categoria = models.CharField(max_length=30, choices=CATEGORIAS_GASTOS, verbose_name="Área del gasto")
|
||||||
|
doc_justificativo = models.FileField(upload_to='contabilidad/gastos/', blank=True, null=True, verbose_name="Ticket o Factura (PDF/IMG)")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Gasto"
|
||||||
|
verbose_name_plural = "Gastos"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"[-{self.importe}€] {self.concepto} ({self.temporada})"
|
||||||
|
|
||||||
|
class Ingreso(Contabilidad):
|
||||||
|
CATEGORIAS_INGRESO = (
|
||||||
|
('patrocinador', 'Patrocinador'),
|
||||||
|
('donación', 'Donación'),
|
||||||
|
('premio', 'Premio'),
|
||||||
|
('recaudación', 'Recaudación'),
|
||||||
|
('Cuotas', 'Cuotas'),
|
||||||
|
('Ventas', 'ventas'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hereda concepto, importe, fecha, observaciones y temporada automáticamente
|
||||||
|
categoria = models.CharField(max_length=30, choices=CATEGORIAS_INGRESO, verbose_name="Categoria del Ingreso")
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Ingreso"
|
||||||
|
verbose_name_plural = "Ingresos"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"[+{self.importe}€] {self.concepto} ({self.temporada})"
|
||||||
3
Plataform_Web/gestion/tests.py
Normal file
3
Plataform_Web/gestion/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
202
Plataform_Web/gestion/views.py
Normal file
202
Plataform_Web/gestion/views.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from django.db.models import Sum
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from temporadas.models import Temporada
|
||||||
|
from documentos.models import Factura
|
||||||
|
from users.decorators import require_rol
|
||||||
|
from .models import Gasto, Ingreso, Patrocinio
|
||||||
|
from .forms import GastoForm, IngresoForm, PatrocinioForm, PatrocinioEditForm
|
||||||
|
|
||||||
|
# Categorías válidas en Gasto (Documento usa 'normativa' en su lugar de 'general')
|
||||||
|
_CATEGORIAS_GASTO_VALIDAS = {c[0] for c in Gasto.CATEGORIAS_GASTOS}
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def inicio(request):
|
||||||
|
temporada_activa = Temporada.objects.filter(actual=True).first()
|
||||||
|
return render(request, 'index.html', {'temporada_actual': temporada_activa})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
def contabilidad(request):
|
||||||
|
temporada_actual = Temporada.objects.filter(actual=True).first()
|
||||||
|
|
||||||
|
gastos = []
|
||||||
|
ingresos = []
|
||||||
|
facturas_pendientes = []
|
||||||
|
total_gastos = 0
|
||||||
|
total_ingresos = 0
|
||||||
|
presupuesto_inicial = 0
|
||||||
|
presupuesto_actual = 0
|
||||||
|
|
||||||
|
if temporada_actual:
|
||||||
|
gastos = Gasto.objects.filter(temporada=temporada_actual)
|
||||||
|
ingresos = Ingreso.objects.filter(temporada=temporada_actual)
|
||||||
|
facturas_pendientes = Factura.objects.filter(
|
||||||
|
temporada=temporada_actual, estado='pendiente'
|
||||||
|
)
|
||||||
|
|
||||||
|
total_gastos = gastos.aggregate(total=Sum('importe'))['total'] or 0
|
||||||
|
total_ingresos = ingresos.aggregate(total=Sum('importe'))['total'] or 0
|
||||||
|
presupuesto_inicial = temporada_actual.presupuesto
|
||||||
|
presupuesto_actual = presupuesto_inicial - total_gastos + total_ingresos
|
||||||
|
|
||||||
|
return render(request, 'contabilidad.html', {
|
||||||
|
'temporada_actual': temporada_actual,
|
||||||
|
'gastos': gastos,
|
||||||
|
'ingresos': ingresos,
|
||||||
|
'facturas_pendientes': facturas_pendientes,
|
||||||
|
'total_gastos': total_gastos,
|
||||||
|
'total_ingresos': total_ingresos,
|
||||||
|
'presupuesto_inicial': presupuesto_inicial,
|
||||||
|
'presupuesto_actual': presupuesto_actual,
|
||||||
|
'gasto_form': GastoForm(),
|
||||||
|
'ingreso_form': IngresoForm(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
@require_POST
|
||||||
|
def anadir_gasto(request):
|
||||||
|
temporada_actual = Temporada.objects.filter(actual=True).first()
|
||||||
|
if not temporada_actual:
|
||||||
|
messages.error(request, 'No hay temporada activa.')
|
||||||
|
return redirect('contabilidad')
|
||||||
|
|
||||||
|
form = GastoForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
gasto = form.save(commit=False)
|
||||||
|
gasto.fecha = date.today()
|
||||||
|
gasto.temporada = temporada_actual
|
||||||
|
gasto.save()
|
||||||
|
messages.success(request, 'Gasto añadido correctamente.')
|
||||||
|
else:
|
||||||
|
messages.error(request, 'Error al añadir el gasto. Revisa los datos.')
|
||||||
|
return redirect('contabilidad')
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
@require_POST
|
||||||
|
def anadir_ingreso(request):
|
||||||
|
temporada_actual = Temporada.objects.filter(actual=True).first()
|
||||||
|
if not temporada_actual:
|
||||||
|
messages.error(request, 'No hay temporada activa.')
|
||||||
|
return redirect('contabilidad')
|
||||||
|
|
||||||
|
form = IngresoForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
ingreso = form.save(commit=False)
|
||||||
|
ingreso.fecha = date.today()
|
||||||
|
ingreso.temporada = temporada_actual
|
||||||
|
ingreso.save()
|
||||||
|
messages.success(request, 'Ingreso añadido correctamente.')
|
||||||
|
else:
|
||||||
|
messages.error(request, 'Error al añadir el ingreso. Revisa los datos.')
|
||||||
|
return redirect('contabilidad')
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
@require_POST
|
||||||
|
def aceptar_factura(request, pk):
|
||||||
|
factura = get_object_or_404(Factura, pk=pk)
|
||||||
|
temporada_actual = Temporada.objects.filter(actual=True).first()
|
||||||
|
if not temporada_actual:
|
||||||
|
messages.error(request, 'No hay temporada activa.')
|
||||||
|
return redirect('contabilidad')
|
||||||
|
|
||||||
|
categoria = factura.categoria if factura.categoria in _CATEGORIAS_GASTO_VALIDAS else 'general'
|
||||||
|
Gasto.objects.create(
|
||||||
|
concepto=factura.nombre,
|
||||||
|
importe=factura.importe,
|
||||||
|
fecha=date.today(),
|
||||||
|
categoria=categoria,
|
||||||
|
temporada=temporada_actual,
|
||||||
|
observaciones=f'Factura de {factura.empresa}',
|
||||||
|
doc_justificativo=factura.archivo,
|
||||||
|
)
|
||||||
|
factura.estado = 'aceptada'
|
||||||
|
factura.save()
|
||||||
|
messages.success(request, f'Factura de {factura.empresa} aceptada y registrada como gasto.')
|
||||||
|
return redirect('contabilidad')
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
@require_POST
|
||||||
|
def rechazar_factura(request, pk):
|
||||||
|
factura = get_object_or_404(Factura, pk=pk)
|
||||||
|
factura.estado = 'rechazada'
|
||||||
|
factura.save()
|
||||||
|
messages.success(request, f'Factura de {factura.empresa} rechazada.')
|
||||||
|
return redirect('contabilidad')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def patrocinios(request):
|
||||||
|
temporada_actual = Temporada.objects.filter(actual=True).first()
|
||||||
|
pendientes = []
|
||||||
|
aceptados = []
|
||||||
|
denegados = []
|
||||||
|
if temporada_actual:
|
||||||
|
qs = Patrocinio.objects.filter(temporada=temporada_actual).select_related('contacto_equipo')
|
||||||
|
pendientes = qs.filter(estado='en_contacto')
|
||||||
|
aceptados = qs.filter(estado='aceptado')
|
||||||
|
denegados = qs.filter(estado='denegado')
|
||||||
|
return render(request, 'patrocinios.html', {
|
||||||
|
'temporada_actual': temporada_actual,
|
||||||
|
'pendientes': pendientes,
|
||||||
|
'aceptados': aceptados,
|
||||||
|
'denegados': denegados,
|
||||||
|
'form': PatrocinioForm(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def proponer_patrocinio(request):
|
||||||
|
temporada_actual = Temporada.objects.filter(actual=True).first()
|
||||||
|
if not temporada_actual:
|
||||||
|
messages.error(request, 'No hay temporada activa. No se puede proponer un patrocinio.')
|
||||||
|
return redirect('patrocinios')
|
||||||
|
|
||||||
|
form = PatrocinioForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
empresa = form.cleaned_data['empresa'].strip()
|
||||||
|
if Patrocinio.objects.filter(empresa__iexact=empresa, temporada=temporada_actual).exists():
|
||||||
|
messages.error(request, f'Ya existe un patrocinio con "{empresa}" en la temporada actual.')
|
||||||
|
else:
|
||||||
|
patrocinio = form.save(commit=False)
|
||||||
|
patrocinio.estado = 'en_contacto'
|
||||||
|
patrocinio.temporada = temporada_actual
|
||||||
|
patrocinio.contacto_equipo = request.user
|
||||||
|
patrocinio.save()
|
||||||
|
messages.success(request, f'Patrocinio de "{empresa}" propuesto correctamente.')
|
||||||
|
else:
|
||||||
|
messages.error(request, 'Error en el formulario. Revisa los datos.')
|
||||||
|
return redirect('patrocinios')
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
def editar_patrocinio(request, pk):
|
||||||
|
patrocinio = get_object_or_404(Patrocinio, pk=pk)
|
||||||
|
form = PatrocinioEditForm(request.POST or None, instance=patrocinio)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Patrocinio actualizado correctamente.')
|
||||||
|
return redirect('patrocinios')
|
||||||
|
return render(request, 'editar_patrocinio.html', {'form': form, 'patrocinio': patrocinio})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
@require_POST
|
||||||
|
def cambiar_estado_patrocinio(request, pk):
|
||||||
|
patrocinio = get_object_or_404(Patrocinio, pk=pk)
|
||||||
|
nuevo_estado = request.POST.get('estado')
|
||||||
|
if nuevo_estado in ('aceptado', 'denegado', 'en_contacto'):
|
||||||
|
patrocinio.estado = nuevo_estado
|
||||||
|
patrocinio.save()
|
||||||
|
messages.success(request, f'Estado de "{patrocinio.empresa}" actualizado.')
|
||||||
|
return redirect('patrocinios')
|
||||||
22
Plataform_Web/manage.py
Normal file
22
Plataform_Web/manage.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gades_manager.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
BIN
Plataform_Web/media/ingenieria_docs/PruebaTest1.png
Normal file
BIN
Plataform_Web/media/ingenieria_docs/PruebaTest1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
0
Plataform_Web/pruebas/__init__.py
Normal file
0
Plataform_Web/pruebas/__init__.py
Normal file
50
Plataform_Web/pruebas/admin.py
Normal file
50
Plataform_Web/pruebas/admin.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Prueba, Telemetria, Variable
|
||||||
|
|
||||||
|
# INLINES
|
||||||
|
# Usamos inlines que nos sirve para cuando se este creando un registro de Telemetría, podremos añadir sus Variables en la misma pantalla sin tener que ir a otro menú
|
||||||
|
class VariableInline(admin.TabularInline):
|
||||||
|
model = Variable
|
||||||
|
extra = 1
|
||||||
|
|
||||||
|
class TelemetriaInline(admin.TabularInline):
|
||||||
|
model = Telemetria
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
# PANELES DE ADMINISTRACIÓN
|
||||||
|
@admin.register(Prueba)
|
||||||
|
class PruebaAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('nombre', 'categoria', 'fecha_inicio', 'temporada', 'realizado_por')
|
||||||
|
|
||||||
|
# Filtros para encontrar los test dado una temporada y una área técnica
|
||||||
|
list_filter = ('categoria', 'temporada')
|
||||||
|
|
||||||
|
# Buscador de texto
|
||||||
|
search_fields = ('nombre', 'descripcion', 'resultados')
|
||||||
|
|
||||||
|
# Una barra de navegación en la parte superior, para poder hacer clic en un año y en un mes y mostrar todos los datos de ese me
|
||||||
|
date_hierarchy = 'fecha_inicio'
|
||||||
|
|
||||||
|
# Mostramos los archivos de telemetría directamente dentro del test
|
||||||
|
inlines = [TelemetriaInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Telemetria)
|
||||||
|
class TelemetriaAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('nombre', 'prueba', 'fecha_subida')
|
||||||
|
|
||||||
|
# Filtra test de telemetría basandose en la temporada o área tecnica de su test correspondiente
|
||||||
|
list_filter = ('prueba__temporada', 'prueba__categoria')
|
||||||
|
|
||||||
|
# Buscador del nombre de la telemetría o por el nombre de la prueba
|
||||||
|
search_fields = ('nombre', 'prueba__nombre')
|
||||||
|
|
||||||
|
# Mostramos las variables de su telemetría
|
||||||
|
inlines = [VariableInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Variable)
|
||||||
|
class VariableAdmin(admin.ModelAdmin):
|
||||||
|
# Aunque se pueden crear desde el Inline, dejamos su tabla propia por si fuese necesario, por prevenir
|
||||||
|
list_display = ('nombre', 'unidad_medida', 'telemetria')
|
||||||
|
search_fields = ('nombre', 'telemetria__nombre')
|
||||||
5
Plataform_Web/pruebas/apps.py
Normal file
5
Plataform_Web/pruebas/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PruebasConfig(AppConfig):
|
||||||
|
name = 'pruebas'
|
||||||
22
Plataform_Web/pruebas/forms.py
Normal file
22
Plataform_Web/pruebas/forms.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from django import forms
|
||||||
|
from .models import Prueba, Telemetria
|
||||||
|
|
||||||
|
|
||||||
|
class PruebaForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Prueba
|
||||||
|
fields = ['nombre', 'descripcion', 'fecha_inicio', 'fecha_fin', 'categoria', 'resultados', 'temporada']
|
||||||
|
widgets = {
|
||||||
|
'fecha_inicio': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||||
|
'fecha_fin': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetriaForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Telemetria
|
||||||
|
fields = ['nombre', 'archivo_csv']
|
||||||
|
widgets = {
|
||||||
|
'nombre': forms.TextInput(attrs={'class': 'form-control form-control-sm'}),
|
||||||
|
'archivo_csv': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}),
|
||||||
|
}
|
||||||
34
Plataform_Web/pruebas/migrations/0001_initial.py
Normal file
34
Plataform_Web/pruebas/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-02-19 10:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('temporadas', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TestGeneral',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('nombre', models.CharField(max_length=150, verbose_name='Nombre del Test')),
|
||||||
|
('descripcion', models.TextField(verbose_name='Objetivo / Descripción del test')),
|
||||||
|
('fecha_inicio', models.DateField()),
|
||||||
|
('fecha_fin', models.DateField()),
|
||||||
|
('categoria', models.CharField(choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('business', 'Business & Operations'), ('epowertrain', 'E-Powertrain'), ('electronica', 'Electrónica'), ('sdf', 'SDF'), ('motor_transmision', 'Motor & Transmisión'), ('general', 'General')], default='general', max_length=30)),
|
||||||
|
('resultados', models.TextField(blank=True, null=True, verbose_name='Conclusiones y Resultados')),
|
||||||
|
('temporada', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='temporadas.temporada')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Test General',
|
||||||
|
'verbose_name_plural': 'Tests Generales',
|
||||||
|
'ordering': ['-fecha_inicio'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-03-06 19:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pruebas', '0001_initial'),
|
||||||
|
('temporadas', '0002_temporada_miembros'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Prueba',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('nombre', models.CharField(max_length=150, verbose_name='Nombre del Test')),
|
||||||
|
('descripcion', models.TextField(verbose_name='Objetivo / Descripción de la prueba')),
|
||||||
|
('fecha_inicio', models.DateField()),
|
||||||
|
('fecha_fin', models.DateField()),
|
||||||
|
('categoria', models.CharField(choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('epowertrain', 'E-Powertrain'), ('electronica', 'Electrónica'), ('sdf', 'SDF'), ('motor_transmision', 'Motor & Transmisión'), ('software', 'Software'), ('general', 'General')], default='general', max_length=30)),
|
||||||
|
('resultados', models.TextField(blank=True, null=True, verbose_name='Conclusiones y Resultados')),
|
||||||
|
('realizado_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pruebas_realizadas', to=settings.AUTH_USER_MODEL, verbose_name='Realizado por')),
|
||||||
|
('temporada', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pruebas', to='temporadas.temporada')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Prueba',
|
||||||
|
'verbose_name_plural': 'Pruebas',
|
||||||
|
'ordering': ['-fecha_inicio'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Telemetria',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('nombre', models.CharField(max_length=100, verbose_name='Nombre del registro')),
|
||||||
|
('archivo_csv', models.FileField(upload_to='telemetria/archivos_csv/', verbose_name='Archivo de Datos (CSV)')),
|
||||||
|
('fecha_subida', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('prueba', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='archivos_telemetria', to='pruebas.prueba')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Registro de Telemetría',
|
||||||
|
'verbose_name_plural': 'Registros de Telemetría',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Variable',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('nombre', models.CharField(max_length=50, verbose_name='Nombre ')),
|
||||||
|
('unidad_medida', models.CharField(blank=True, max_length=20, null=True, verbose_name='Unidad de medida')),
|
||||||
|
('descripcion', models.TextField(blank=True, null=True)),
|
||||||
|
('telemetria', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variables', to='pruebas.telemetria')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Variable de Telemetría',
|
||||||
|
'verbose_name_plural': 'Variables de Telemetría',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='TestGeneral',
|
||||||
|
),
|
||||||
|
]
|
||||||
0
Plataform_Web/pruebas/migrations/__init__.py
Normal file
0
Plataform_Web/pruebas/migrations/__init__.py
Normal file
78
Plataform_Web/pruebas/models.py
Normal file
78
Plataform_Web/pruebas/models.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from temporadas.models import Temporada
|
||||||
|
import os
|
||||||
|
|
||||||
|
class Prueba(models.Model):
|
||||||
|
CATEGORIAS_TEST = (
|
||||||
|
('aerodinamica', 'Aerodinámica'),
|
||||||
|
('chasis', 'Chasis'),
|
||||||
|
('epowertrain', 'E-Powertrain'),
|
||||||
|
('electronica', 'Electrónica'),
|
||||||
|
('sdf', 'SDF'),
|
||||||
|
('motor_transmision', 'Motor & Transmisión'),
|
||||||
|
('software', 'Software'),
|
||||||
|
('general', 'General'),
|
||||||
|
)
|
||||||
|
|
||||||
|
nombre = models.CharField(max_length=150, verbose_name="Nombre del Test")
|
||||||
|
descripcion = models.TextField(verbose_name="Objetivo / Descripción de la prueba")
|
||||||
|
|
||||||
|
fecha_inicio = models.DateField()
|
||||||
|
fecha_fin = models.DateField()
|
||||||
|
|
||||||
|
categoria = models.CharField(max_length=30, choices=CATEGORIAS_TEST, default='general')
|
||||||
|
resultados = models.TextField(blank=True, null=True, verbose_name="Conclusiones y Resultados")
|
||||||
|
|
||||||
|
# Relaciones
|
||||||
|
# Relacion con Temporada
|
||||||
|
temporada = models.ForeignKey(Temporada, on_delete=models.CASCADE, related_name="pruebas")
|
||||||
|
#Relacion con Usuario
|
||||||
|
realizado_por = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="pruebas_realizadas", verbose_name="Realizado por")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Prueba"
|
||||||
|
verbose_name_plural = "Pruebas"
|
||||||
|
ordering = ['-fecha_inicio']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.nombre} ({self.fecha_inicio})"
|
||||||
|
|
||||||
|
class Telemetria(models.Model):
|
||||||
|
nombre = models.CharField(max_length=100, verbose_name="Nombre del registro")
|
||||||
|
archivo_csv = models.FileField(upload_to='telemetria/archivos_csv/', verbose_name="Archivo de Datos (CSV)")
|
||||||
|
fecha_subida = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# La telemetría se obtiene durante una Prueba en pista
|
||||||
|
prueba = models.ForeignKey(Prueba, on_delete=models.CASCADE, related_name="archivos_telemetria")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Registro de Telemetría"
|
||||||
|
verbose_name_plural = "Registros de Telemetría"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Telemetría: {self.nombre} (De: {self.prueba.nombre})"
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
# Borra el archivo físico del disco duro cuando borras la entrada en la base de datos
|
||||||
|
if self.archivo_csv:
|
||||||
|
if os.path.isfile(self.archivo_csv.path):
|
||||||
|
os.remove(self.archivo_csv.path)
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Variable(models.Model):
|
||||||
|
nombre = models.CharField(max_length=50, verbose_name="Nombre ")
|
||||||
|
unidad_medida = models.CharField(max_length=20, verbose_name="Unidad de medida", blank=True, null=True)
|
||||||
|
descripcion = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# COMPOSICIÓN
|
||||||
|
telemetria = models.ForeignKey(Telemetria, on_delete=models.CASCADE, related_name="variables")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Variable de Telemetría"
|
||||||
|
verbose_name_plural = "Variables de Telemetría"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
unidad = f" [{self.unidad_medida}]" if self.unidad_medida else ""
|
||||||
|
return f"{self.nombre}{unidad} (De: {self.telemetria.nombre})"
|
||||||
3
Plataform_Web/pruebas/tests.py
Normal file
3
Plataform_Web/pruebas/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
102
Plataform_Web/pruebas/views.py
Normal file
102
Plataform_Web/pruebas/views.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from users.decorators import require_rol
|
||||||
|
from temporadas.models import Temporada
|
||||||
|
from .models import Prueba
|
||||||
|
from .forms import PruebaForm, TelemetriaForm
|
||||||
|
|
||||||
|
|
||||||
|
def puede_subir_csv(user):
|
||||||
|
return user.rol == 'directiva' or user.especialidad == 'software'
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def listado_pruebas(request):
|
||||||
|
temporada_actual = Temporada.objects.filter(actual=True).first()
|
||||||
|
temporada_id = request.GET.get('temporada') or (temporada_actual.pk if temporada_actual else None)
|
||||||
|
categoria = request.GET.get('categoria')
|
||||||
|
|
||||||
|
pruebas = Prueba.objects.all()
|
||||||
|
if temporada_id:
|
||||||
|
pruebas = pruebas.filter(temporada_id=temporada_id)
|
||||||
|
if categoria:
|
||||||
|
pruebas = pruebas.filter(categoria=categoria)
|
||||||
|
|
||||||
|
return render(request, 'listado_pruebas.html', {
|
||||||
|
'pruebas': pruebas,
|
||||||
|
'temporadas': Temporada.objects.all(),
|
||||||
|
'categorias': Prueba.CATEGORIAS_TEST,
|
||||||
|
'temporada_seleccionada': str(temporada_id) if temporada_id else '',
|
||||||
|
'categoria_seleccionada': categoria or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def detalle_prueba(request, pk):
|
||||||
|
prueba = get_object_or_404(Prueba, pk=pk)
|
||||||
|
puede_editar = request.user.rol == 'directiva' or (
|
||||||
|
request.user.rol == 'jefe_area' and prueba.realizado_por_id == request.user.id
|
||||||
|
)
|
||||||
|
return render(request, 'detalle_prueba.html', {
|
||||||
|
'prueba': prueba,
|
||||||
|
'puede_editar': puede_editar,
|
||||||
|
'puede_subir_csv': puede_subir_csv(request.user),
|
||||||
|
'form_csv': TelemetriaForm(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva', 'jefe_area')
|
||||||
|
def crear_prueba(request):
|
||||||
|
form = PruebaForm(request.POST or None)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
prueba = form.save(commit=False)
|
||||||
|
prueba.realizado_por = request.user
|
||||||
|
prueba.save()
|
||||||
|
messages.success(request, 'Test creado correctamente.')
|
||||||
|
return redirect('listado_pruebas')
|
||||||
|
return render(request, 'editar_prueba.html', {'form': form, 'prueba': None})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva', 'jefe_area')
|
||||||
|
def editar_prueba(request, pk):
|
||||||
|
prueba = get_object_or_404(Prueba, pk=pk)
|
||||||
|
if request.user.rol == 'jefe_area' and prueba.realizado_por_id != request.user.id:
|
||||||
|
messages.error(request, 'Solo puedes editar los tests que tú has creado.')
|
||||||
|
return redirect('detalle_prueba', pk=prueba.pk)
|
||||||
|
|
||||||
|
form = PruebaForm(request.POST or None, instance=prueba)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Test actualizado correctamente.')
|
||||||
|
return redirect('detalle_prueba', pk=prueba.pk)
|
||||||
|
return render(request, 'editar_prueba.html', {'form': form, 'prueba': prueba})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
@require_POST
|
||||||
|
def eliminar_prueba(request, pk):
|
||||||
|
prueba = get_object_or_404(Prueba, pk=pk)
|
||||||
|
prueba.delete()
|
||||||
|
messages.success(request, f'Test "{prueba.nombre}" eliminado.')
|
||||||
|
return redirect('listado_pruebas')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def subir_csv(request, pk):
|
||||||
|
prueba = get_object_or_404(Prueba, pk=pk)
|
||||||
|
if not puede_subir_csv(request.user):
|
||||||
|
messages.error(request, 'No tienes permiso para subir archivos de telemetría.')
|
||||||
|
return redirect('detalle_prueba', pk=prueba.pk)
|
||||||
|
|
||||||
|
form = TelemetriaForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
telemetria = form.save(commit=False)
|
||||||
|
telemetria.prueba = prueba
|
||||||
|
telemetria.save()
|
||||||
|
messages.success(request, 'Archivo de telemetría subido correctamente.')
|
||||||
|
else:
|
||||||
|
messages.error(request, 'No se pudo subir el archivo. Revisa el formulario.')
|
||||||
|
return redirect('detalle_prueba', pk=prueba.pk)
|
||||||
BIN
Plataform_Web/static/dossiers/DossierEN_2025_2026.pdf
Normal file
BIN
Plataform_Web/static/dossiers/DossierEN_2025_2026.pdf
Normal file
Binary file not shown.
BIN
Plataform_Web/static/dossiers/Dossier_2025_2026.pdf
Normal file
BIN
Plataform_Web/static/dossiers/Dossier_2025_2026.pdf
Normal file
Binary file not shown.
BIN
Plataform_Web/static/images/logo_gades.png
Normal file
BIN
Plataform_Web/static/images/logo_gades.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
Plataform_Web/static/images/monoplaza_gades.jpg
Normal file
BIN
Plataform_Web/static/images/monoplaza_gades.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 MiB |
111
Plataform_Web/templates/base.html
Normal file
111
Plataform_Web/templates/base.html
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Intranet | Formula Gades{% endblock title %}</title>
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{% static 'css/estilos.css' %}">
|
||||||
|
|
||||||
|
{% block extra_head %}{% endblock extra_head %}
|
||||||
|
<style>
|
||||||
|
html, body { overflow: hidden; height: 100%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-light text-dark d-flex flex-column min-vh-100">
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-xl navbar-dark bg-dark sticky-top shadow-sm">
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<a class="navbar-brand d-flex align-items-center" href="{% url 'inicio' %}">
|
||||||
|
<img src="{% static 'images/logo_gades.png' %}" alt="Logo Gades" height="40" class="me-2">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-center">
|
||||||
|
|
||||||
|
<li class="nav-item dropdown px-2">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="areasDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Áreas Técnicas
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-dark" aria-labelledby="areasDropdown">
|
||||||
|
<li><a class="dropdown-menu-item dropdown-item" href="#">Aerodinámica</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#">Chasis</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#">Bussiness & Operation</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#">E_Powertrain</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#">Electrónica</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#">SDF</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#">Motor & Transmisión</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#">Software</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item px-2">
|
||||||
|
<a class="nav-link" href="{% url 'listado_pruebas' %}">Pruebas</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item px-2">
|
||||||
|
<a class="nav-link" href="{% url 'patrocinios' %}">Patrocinios</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item px-2">
|
||||||
|
<a class="nav-link" href="{% url 'listado_miembros' %}">Miembros</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if user.is_authenticated and user.rol == 'directiva' %}
|
||||||
|
<li class="nav-item px-2">
|
||||||
|
<a class="nav-link text-warning" href="{% url 'contabilidad' %}">Contabilidad</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item dropdown px-2">
|
||||||
|
<a class="nav-link dropdown-toggle text-warning" href="#" id="gestionDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Gestión
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-dark" aria-labelledby="gestionDropdown">
|
||||||
|
<li><a class="dropdown-item" href="{% url 'gestion_usuarios' %}">Gestión de Usuarios</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'gestion_temporadas' %}">Gestión de Temporadas</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="nav-item dropdown ms-3 border-start ps-3">
|
||||||
|
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<div class="bg-secondary rounded-circle d-flex align-items-center justify-content-center text-white fw-bold shadow-sm" style="width: 35px; height: 35px;">
|
||||||
|
{{ user.first_name|slice:":1"|upper }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
|
||||||
|
<li><span class="dropdown-item-text fw-bold text-muted">Hola, {{ user.first_name }}</span></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'mi_perfil' %}">Mi Perfil</a></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="{% url 'logout' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="dropdown-item text-danger">Cerrar Sesión</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container-fluid py-3 px-4 flex-grow-1 d-flex flex-column" style="min-height: 0;">
|
||||||
|
{% block content %}
|
||||||
|
{% endblock content %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-dark text-white text-center py-2 mt-auto border-top border-secondary">
|
||||||
|
<p class="mb-0 small">© 2026 Formula Gades - Universidad de Cádiz. Todos los derechos reservados.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock extra_js %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
247
Plataform_Web/templates/contabilidad.html
Normal file
247
Plataform_Web/templates/contabilidad.html
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Contabilidad | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm" style="border-left: 5px solid #ffc107;">
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Contabilidad</h1>
|
||||||
|
<p class="text-white-50 mb-0">
|
||||||
|
{% if temporada_actual %}{{ temporada_actual.nombre }}{% else %}Sin temporada activa{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="row mb-2 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}success{% endif %} py-2 small mb-1">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not temporada_actual %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-warning">No hay ninguna temporada activa. Activa una temporada desde <a href="{% url 'gestion_temporadas' %}">Gestión de Temporadas</a>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# ── Bloque 1: 4 tarjetas resumen ── #}
|
||||||
|
<div class="row g-3 mb-3 flex-shrink-0">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm text-center h-100" style="border-top: 4px solid #0d6efd !important;">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<p class="text-secondary small mb-1">Presupuesto Inicial</p>
|
||||||
|
<h4 class="fw-bold text-primary mb-0">{{ presupuesto_inicial|floatformat:2 }} €</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm text-center h-100" style="border-top: 4px solid #198754 !important;">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<p class="text-secondary small mb-1">Total Ingresos</p>
|
||||||
|
<h4 class="fw-bold text-success mb-0">+{{ total_ingresos|floatformat:2 }} €</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm text-center h-100" style="border-top: 4px solid #dc3545 !important;">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<p class="text-secondary small mb-1">Total Gastos</p>
|
||||||
|
<h4 class="fw-bold text-danger mb-0">-{{ total_gastos|floatformat:2 }} €</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm text-center h-100"
|
||||||
|
style="border-top: 4px solid {% if presupuesto_actual >= 0 %}#198754{% else %}#dc3545{% endif %} !important;">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<p class="text-secondary small mb-1">Presupuesto Actual</p>
|
||||||
|
<h4 class="fw-bold {% if presupuesto_actual >= 0 %}text-success{% else %}text-danger{% endif %} mb-0">
|
||||||
|
{{ presupuesto_actual|floatformat:2 }} €
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Bloque 2: Gastos | Ingresos ── #}
|
||||||
|
<div class="row g-3 mb-3" style="overflow-y: auto; max-height: 340px;">
|
||||||
|
|
||||||
|
{# Columna Gastos #}
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-bold mb-0 text-danger">Gastos</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#modalGasto">+ Añadir</button>
|
||||||
|
</div>
|
||||||
|
<div style="max-height: 220px; overflow-y: auto;">
|
||||||
|
{% if gastos %}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for gasto in gastos %}
|
||||||
|
<li class="list-group-item px-0 py-2 d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold small">{{ gasto.concepto }}</div>
|
||||||
|
<div class="text-secondary" style="font-size:0.75rem;">{{ gasto.get_categoria_display }} · {{ gasto.fecha|date:"d/m/Y" }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-danger rounded-pill ms-2">-{{ gasto.importe|floatformat:2 }} €</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-secondary small text-center py-3 mb-0">Sin gastos registrados.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Columna Ingresos #}
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-bold mb-0 text-success">Ingresos</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-success" data-bs-toggle="modal" data-bs-target="#modalIngreso">+ Añadir</button>
|
||||||
|
</div>
|
||||||
|
<div style="max-height: 220px; overflow-y: auto;">
|
||||||
|
{% if ingresos %}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for ingreso in ingresos %}
|
||||||
|
<li class="list-group-item px-0 py-2 d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold small">{{ ingreso.concepto }}</div>
|
||||||
|
<div class="text-secondary" style="font-size:0.75rem;">{{ ingreso.get_categoria_display }} · {{ ingreso.fecha|date:"d/m/Y" }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-success rounded-pill ms-2">+{{ ingreso.importe|floatformat:2 }} €</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-secondary small text-center py-3 mb-0">Sin ingresos registrados.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Bloque 3: Facturas pendientes ── #}
|
||||||
|
<div class="row flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h6 class="fw-bold mb-3">Facturas Pendientes</h6>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Empresa</th>
|
||||||
|
<th>Importe</th>
|
||||||
|
<th>Categoría</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for factura in facturas_pendientes %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ factura.nombre }}</td>
|
||||||
|
<td>{{ factura.empresa }}</td>
|
||||||
|
<td class="fw-semibold">{{ factura.importe|floatformat:2 }} €</td>
|
||||||
|
<td>{{ factura.get_categoria_display }}</td>
|
||||||
|
<td class="d-flex gap-2">
|
||||||
|
<form method="post" action="{% url 'aceptar_factura' factura.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-success fw-bold">Aceptar</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'rechazar_factura' factura.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Rechazar</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-secondary py-3">No hay facturas pendientes.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}{# end if temporada_actual #}
|
||||||
|
|
||||||
|
{# ── Modal Añadir Gasto ── #}
|
||||||
|
<div class="modal fade" id="modalGasto" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Añadir Gasto</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'anadir_gasto' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body">
|
||||||
|
{% for field in gasto_form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-danger fw-bold">Guardar Gasto</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Modal Añadir Ingreso ── #}
|
||||||
|
<div class="modal fade" id="modalIngreso" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Añadir Ingreso</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'anadir_ingreso' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body">
|
||||||
|
{% for field in ingreso_form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-success fw-bold">Guardar Ingreso</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
112
Plataform_Web/templates/detalle_prueba.html
Normal file
112
Plataform_Web/templates/detalle_prueba.html
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ prueba.nombre }} | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm d-flex justify-content-between align-items-center" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-6 fw-bold mb-1">{{ prueba.nombre }}</h1>
|
||||||
|
<p class="text-white-50 mb-0">{{ prueba.get_categoria_display }} · Temporada {{ prueba.temporada.nombre }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if puede_editar %}
|
||||||
|
<a href="{% url 'editar_prueba' prueba.pk %}" class="btn btn-outline-light fw-bold">Editar</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if user.rol == 'directiva' %}
|
||||||
|
<button type="button" class="btn btn-outline-danger fw-bold" data-bs-toggle="modal" data-bs-target="#eliminarModal">Eliminar</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="col-12">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-success py-2 small mb-0">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="fw-semibold">Objetivo / Descripción</h5>
|
||||||
|
<p>{{ prueba.descripcion }}</p>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6"><strong>Fecha inicio:</strong> {{ prueba.fecha_inicio|date:"d/m/Y" }}</div>
|
||||||
|
<div class="col-6"><strong>Fecha fin:</strong> {{ prueba.fecha_fin|date:"d/m/Y" }}</div>
|
||||||
|
</div>
|
||||||
|
<p><strong>Realizado por:</strong> {{ prueba.realizado_por.first_name|default:"—" }}</p>
|
||||||
|
|
||||||
|
{% if prueba.resultados %}
|
||||||
|
<h5 class="fw-semibold mt-4">Conclusiones y Resultados</h5>
|
||||||
|
<p>{{ prueba.resultados }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="fw-semibold mb-3">Archivos de telemetría</h5>
|
||||||
|
<ul class="list-group list-group-flush mb-3">
|
||||||
|
{% for telemetria in prueba.archivos_telemetria.all %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<a href="{{ telemetria.archivo_csv.url }}">{{ telemetria.nombre }}</a>
|
||||||
|
<span class="text-secondary small">{{ telemetria.fecha_subida|date:"d/m/Y" }}</span>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item px-0 text-secondary small">No hay archivos subidos todavía.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if puede_subir_csv %}
|
||||||
|
<form method="post" action="{% url 'subir_csv' prueba.pk %}" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small fw-semibold">Nombre</label>
|
||||||
|
{{ form_csv.nombre }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small fw-semibold">Archivo CSV</label>
|
||||||
|
{{ form_csv.archivo_csv }}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary fw-bold">Subir CSV</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user.rol == 'directiva' %}
|
||||||
|
<div class="modal fade" id="eliminarModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirmar eliminación</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
¿Seguro que quieres eliminar el test <strong>{{ prueba.nombre }}</strong>? Esta acción no se puede deshacer.
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<form method="post" action="{% url 'eliminar_prueba' prueba.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger fw-bold">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
44
Plataform_Web/templates/editar_patrocinio.html
Normal file
44
Plataform_Web/templates/editar_patrocinio.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Editar Patrocinio | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Editar Patrocinio</h1>
|
||||||
|
<p class="text-white-50 mb-0">{{ patrocinio.empresa }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="card-title fw-bold mb-3">Datos del Patrocinio</h5>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ field.id_for_label }}" class="form-label fw-semibold">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary fw-bold px-4">Guardar Cambios</button>
|
||||||
|
<a href="{% url 'patrocinios' %}" class="btn btn-outline-secondary px-4">Cancelar</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
102
Plataform_Web/templates/editar_prueba.html
Normal file
102
Plataform_Web/templates/editar_prueba.html
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% if prueba %}Editar Test{% else %}Nuevo Test{% endif %} | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<h1 class="display-6 fw-bold mb-1">{% if prueba %}Editar Test{% else %}Nuevo Test{% endif %}</h1>
|
||||||
|
<p class="text-white-50 mb-0">{% if prueba %}{{ prueba.nombre }}{% else %}Rellena los datos del nuevo test.{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.nombre.id_for_label }}" class="form-label fw-semibold">Nombre del test</label>
|
||||||
|
<input type="text" name="nombre" id="{{ form.nombre.id_for_label }}"
|
||||||
|
class="form-control {% if form.nombre.errors %}is-invalid{% endif %}"
|
||||||
|
value="{{ form.nombre.value|default:'' }}" placeholder="Ej: Test de frenada">
|
||||||
|
{% for error in form.nombre.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.descripcion.id_for_label }}" class="form-label fw-semibold">Objetivo / Descripción</label>
|
||||||
|
<textarea name="descripcion" id="{{ form.descripcion.id_for_label }}" rows="3"
|
||||||
|
class="form-control {% if form.descripcion.errors %}is-invalid{% endif %}">{{ form.descripcion.value|default:'' }}</textarea>
|
||||||
|
{% for error in form.descripcion.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="{{ form.fecha_inicio.id_for_label }}" class="form-label fw-semibold">Fecha de inicio</label>
|
||||||
|
{{ form.fecha_inicio }}
|
||||||
|
{% for error in form.fecha_inicio.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="{{ form.fecha_fin.id_for_label }}" class="form-label fw-semibold">Fecha de fin</label>
|
||||||
|
{{ form.fecha_fin }}
|
||||||
|
{% for error in form.fecha_fin.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="{{ form.categoria.id_for_label }}" class="form-label fw-semibold">Categoría</label>
|
||||||
|
<select name="categoria" id="{{ form.categoria.id_for_label }}" class="form-select">
|
||||||
|
{% for valor, etiqueta in form.fields.categoria.choices %}
|
||||||
|
<option value="{{ valor }}" {% if form.categoria.value == valor %}selected{% endif %}>{{ etiqueta }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="{{ form.temporada.id_for_label }}" class="form-label fw-semibold">Temporada</label>
|
||||||
|
<select name="temporada" id="{{ form.temporada.id_for_label }}"
|
||||||
|
class="form-select {% if form.temporada.errors %}is-invalid{% endif %}">
|
||||||
|
{% for temporada in form.fields.temporada.queryset %}
|
||||||
|
<option value="{{ temporada.pk }}" {% if form.temporada.value|stringformat:"s" == temporada.pk|stringformat:"s" %}selected{% endif %}>{{ temporada.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% for error in form.temporada.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="{{ form.resultados.id_for_label }}" class="form-label fw-semibold">Conclusiones y Resultados</label>
|
||||||
|
<textarea name="resultados" id="{{ form.resultados.id_for_label }}" rows="3"
|
||||||
|
class="form-control {% if form.resultados.errors %}is-invalid{% endif %}">{{ form.resultados.value|default:'' }}</textarea>
|
||||||
|
{% for error in form.resultados.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary fw-bold px-4">Guardar</button>
|
||||||
|
<a href="{% if prueba %}{% url 'detalle_prueba' prueba.pk %}{% else %}{% url 'listado_pruebas' %}{% endif %}" class="btn btn-outline-secondary px-4">Cancelar</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
96
Plataform_Web/templates/editar_temporada.html
Normal file
96
Plataform_Web/templates/editar_temporada.html
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% if temporada %}Editar Temporada{% else %}Nueva Temporada{% endif %} | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<h1 class="display-6 fw-bold mb-1">{% if temporada %}Editar Temporada{% else %}Nueva Temporada{% endif %}</h1>
|
||||||
|
<p class="text-white-50 mb-0">{% if temporada %}{{ temporada.nombre }}{% else %}Rellena los datos de la nueva temporada.{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.nombre.id_for_label }}" class="form-label fw-semibold">Nombre</label>
|
||||||
|
<input type="text" name="nombre" id="{{ form.nombre.id_for_label }}"
|
||||||
|
class="form-control {% if form.nombre.errors %}is-invalid{% endif %}"
|
||||||
|
value="{{ form.nombre.value|default:'' }}" placeholder="Ej: Gades 2025-26">
|
||||||
|
{% for error in form.nombre.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="{{ form.fecha_inicio.id_for_label }}" class="form-label fw-semibold">Fecha de inicio</label>
|
||||||
|
{{ form.fecha_inicio }}
|
||||||
|
{% for error in form.fecha_inicio.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="{{ form.fecha_fin.id_for_label }}" class="form-label fw-semibold">Fecha de fin</label>
|
||||||
|
{{ form.fecha_fin }}
|
||||||
|
{% for error in form.fecha_fin.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.presupuesto.id_for_label }}" class="form-label fw-semibold">Presupuesto (€)</label>
|
||||||
|
<input type="number" name="presupuesto" id="{{ form.presupuesto.id_for_label }}"
|
||||||
|
class="form-control {% if form.presupuesto.errors %}is-invalid{% endif %}"
|
||||||
|
value="{{ form.presupuesto.value|default:'0.00' }}" step="0.01" min="0">
|
||||||
|
{% for error in form.presupuesto.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 form-check">
|
||||||
|
<input type="checkbox" name="actual" id="{{ form.actual.id_for_label }}"
|
||||||
|
class="form-check-input" {% if form.actual.value %}checked{% endif %}>
|
||||||
|
<label for="{{ form.actual.id_for_label }}" class="form-check-label fw-semibold">
|
||||||
|
Marcar como temporada activa
|
||||||
|
</label>
|
||||||
|
<div class="form-text">Solo puede haber una temporada activa. Al marcar esta se desactivarán las demás automáticamente.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-semibold">Miembros</label>
|
||||||
|
<div class="border rounded p-3" style="max-height: 220px; overflow-y: auto;">
|
||||||
|
{% for checkbox in form.miembros %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ checkbox.tag }}
|
||||||
|
<label class="form-check-label" for="{{ checkbox.id_for_label }}">
|
||||||
|
{{ checkbox.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<span class="text-secondary small">No hay usuarios en el sistema.</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary fw-bold px-4">Guardar</button>
|
||||||
|
<a href="{% url 'gestion_temporadas' %}" class="btn btn-outline-secondary px-4">Cancelar</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
70
Plataform_Web/templates/editar_usuario.html
Normal file
70
Plataform_Web/templates/editar_usuario.html
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Editar Usuario | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Editar Usuario</h1>
|
||||||
|
<p class="text-white-50 mb-0">{{ usuario.username }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="card-title fw-bold mb-3">Datos del Usuario</h5>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.username.id_for_label }}" class="form-label fw-semibold">{{ form.username.label }}</label>
|
||||||
|
{{ form.username }}
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<label for="{{ form.first_name.id_for_label }}" class="form-label fw-semibold">{{ form.first_name.label }}</label>
|
||||||
|
{{ form.first_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<label for="{{ form.last_name.id_for_label }}" class="form-label fw-semibold">{{ form.last_name.label }}</label>
|
||||||
|
{{ form.last_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.email.id_for_label }}" class="form-label fw-semibold">{{ form.email.label }}</label>
|
||||||
|
{{ form.email }}
|
||||||
|
{% for error in form.email.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<label for="{{ form.rol.id_for_label }}" class="form-label fw-semibold">{{ form.rol.label }}</label>
|
||||||
|
{{ form.rol }}
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<label for="{{ form.especialidad.id_for_label }}" class="form-label fw-semibold">{{ form.especialidad.label }}</label>
|
||||||
|
{{ form.especialidad }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary fw-bold px-4">Guardar Cambios</button>
|
||||||
|
<a href="{% url 'gestion_usuarios' %}" class="btn btn-outline-secondary px-4">Cancelar</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
100
Plataform_Web/templates/gestion_temporadas.html
Normal file
100
Plataform_Web/templates/gestion_temporadas.html
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Gestión de Temporadas | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm d-flex justify-content-between align-items-center" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Gestión de Temporadas</h1>
|
||||||
|
<p class="text-white-50 mb-0">Crea, edita y elimina temporadas del equipo.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'crear_temporada' %}" class="btn btn-primary fw-bold">+ Nueva Temporada</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="col-12">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-success py-2 small mb-0">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Fecha inicio</th>
|
||||||
|
<th>Fecha fin</th>
|
||||||
|
<th>Presupuesto</th>
|
||||||
|
<th>Miembros</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for temporada in temporadas %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ temporada.nombre }}</td>
|
||||||
|
<td>{{ temporada.fecha_inicio|date:"d/m/Y" }}</td>
|
||||||
|
<td>{{ temporada.fecha_fin|date:"d/m/Y" }}</td>
|
||||||
|
<td>{{ temporada.presupuesto|floatformat:2 }} €</td>
|
||||||
|
<td>{{ temporada.miembros.count }}</td>
|
||||||
|
<td>
|
||||||
|
{% if temporada.actual %}
|
||||||
|
<span class="badge bg-success">Activa</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Anterior</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="d-flex gap-2">
|
||||||
|
<a href="{% url 'editar_temporada' temporada.pk %}" class="btn btn-sm btn-outline-primary">Editar</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#eliminarModal{{ temporada.pk }}">Eliminar</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<div class="modal fade" id="eliminarModal{{ temporada.pk }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirmar eliminación</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
¿Seguro que quieres eliminar la temporada <strong>{{ temporada.nombre }}</strong>? Esta acción no se puede deshacer.
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<form method="post" action="{% url 'eliminar_temporada' temporada.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger fw-bold">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-secondary py-4">No hay temporadas creadas todavía.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
125
Plataform_Web/templates/gestion_usuarios.html
Normal file
125
Plataform_Web/templates/gestion_usuarios.html
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Gestión de Usuarios | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Gestión de Usuarios</h1>
|
||||||
|
<p class="text-white-50 mb-0">Administra las cuentas del sistema: edita o elimina usuarios.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="col-12">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-success py-2 small mb-0">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<form method="get" class="row g-2 align-items-end mb-4">
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label small text-secondary mb-1">Rol</label>
|
||||||
|
<select name="rol" class="form-select">
|
||||||
|
<option value="">Todos los roles</option>
|
||||||
|
{% for value, label in rol_choices %}
|
||||||
|
<option value="{{ value }}" {% if filtro_rol == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label small text-secondary mb-1">Área Técnica</label>
|
||||||
|
<select name="especialidad" class="form-select">
|
||||||
|
<option value="">Todas las áreas</option>
|
||||||
|
{% for value, label in especialidad_choices %}
|
||||||
|
<option value="{{ value }}" {% if filtro_especialidad == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label small text-secondary mb-1">Apellido</label>
|
||||||
|
<input type="text" name="apellido" class="form-control" placeholder="Buscar por apellido..." value="{{ filtro_apellido }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-2 d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary fw-bold flex-grow-1">Buscar</button>
|
||||||
|
{% if filtro_rol or filtro_especialidad or filtro_apellido %}
|
||||||
|
<a href="{% url 'gestion_usuarios' %}" class="btn btn-outline-secondary" title="Limpiar filtros">×</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Apellidos</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Área Técnica</th>
|
||||||
|
<th>Rol</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for usuario in usuarios %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ usuario.username }}</td>
|
||||||
|
<td>{{ usuario.first_name }}</td>
|
||||||
|
<td>{{ usuario.last_name }}</td>
|
||||||
|
<td>{{ usuario.email }}</td>
|
||||||
|
<td>{{ usuario.get_especialidad_display|default:"—" }}</td>
|
||||||
|
<td>{{ usuario.get_rol_display }}</td>
|
||||||
|
<td class="d-flex gap-2">
|
||||||
|
<a href="{% url 'editar_usuario' usuario.pk %}" class="btn btn-sm btn-outline-primary">Editar</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#eliminarModal{{ usuario.pk }}">Eliminar</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<div class="modal fade" id="eliminarModal{{ usuario.pk }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirmar eliminación</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
¿Seguro que quieres eliminar al usuario <strong>{{ usuario.username }}</strong>? Esta acción no se puede deshacer.
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<form method="post" action="{% url 'eliminar_usuario' usuario.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger fw-bold">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-secondary py-4">No se han encontrado usuarios con los filtros seleccionados.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
115
Plataform_Web/templates/index.html
Normal file
115
Plataform_Web/templates/index.html
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Inicio | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm d-flex justify-content-between align-items-center flex-wrap" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Bienvenido, {{ user.first_name }} {{ user.last_name }}</h1>
|
||||||
|
<p class="text-white-50 mb-0">Panel de control de la plataforma de gestión interna.</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-end mt-2 mt-md-0">
|
||||||
|
<span class="badge bg-primary fs-6 px-3 py-2 shadow-sm">
|
||||||
|
Temporada Activa: {% if temporada_actual %}{{ temporada_actual.nombre }}{% else %}Ninguna activa{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 flex-grow-1" style="min-height: 0;">
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-8 d-flex flex-column" style="min-height: 0;">
|
||||||
|
<div class="shadow rounded-3 overflow-hidden flex-grow-1" style="min-height: 0;">
|
||||||
|
<img src="{% static 'images/monoplaza_gades.jpg' %}" alt="Monoplaza Formula Gades"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover; display: block;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="row g-3">
|
||||||
|
|
||||||
|
{% if user.rol != 'directiva' %}
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title fw-bold text-dark mb-1">Mi Área Técnica</h5>
|
||||||
|
<p class="text-secondary small mb-2">Acceso a documentación, planos y buzón de compras de tu departamento.</p>
|
||||||
|
<a href="#" class="btn btn-outline-primary btn-sm fw-bold px-3">Acceder a mi Área</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title fw-bold text-dark mb-1">Pruebas y Telemetría</h5>
|
||||||
|
<p class="text-secondary small mb-2">Carga de archivos de registros CSV y procesamiento analítico de telemetría.</p>
|
||||||
|
<a href="#" class="btn btn-outline-primary btn-sm fw-bold px-3">Ver Telemetría</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title fw-bold text-dark mb-1">Miembros del Equipo</h5>
|
||||||
|
<p class="text-secondary small mb-2">Directorio completo de integrantes, correos corporativos y roles.</p>
|
||||||
|
<a href="{% url 'listado_miembros' %}" class="btn btn-outline-primary btn-sm fw-bold px-3">Ver Directorio</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title fw-bold text-dark mb-1">Pruebas y Telemetría</h5>
|
||||||
|
<p class="text-secondary small mb-2">Carga de archivos de registros CSV y procesamiento analítico de telemetría.</p>
|
||||||
|
<a href="#" class="btn btn-outline-primary btn-sm fw-bold px-3">Ver Telemetría</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title fw-bold text-dark mb-1">Miembros del Equipo</h5>
|
||||||
|
<p class="text-secondary small mb-2">Directorio completo de integrantes, correos corporativos y roles.</p>
|
||||||
|
<a href="{% url 'listado_miembros' %}" class="btn btn-outline-primary btn-sm fw-bold px-3">Ver Directorio</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm" style="border-left: 4px solid #ffc107;">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title fw-bold text-dark mb-1">Contabilidad Global</h5>
|
||||||
|
<p class="text-secondary small mb-2">Supervisión del presupuesto anual y control del buzón financiero de facturas.</p>
|
||||||
|
<a href="#" class="btn btn-warning btn-sm fw-bold text-dark px-3">Gestionar Finanzas</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm" style="border-left: 4px solid #ffc107;">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title fw-bold text-dark mb-1">Administración del Sistema</h5>
|
||||||
|
<p class="text-secondary small mb-2">Módulo avanzado de configuración de permisos, usuarios y temporadas.</p>
|
||||||
|
<a href="#" class="btn btn-warning btn-sm fw-bold text-dark px-3">Panel de Control</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
90
Plataform_Web/templates/listado_miembros.html
Normal file
90
Plataform_Web/templates/listado_miembros.html
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Miembros | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Miembros del Equipo</h1>
|
||||||
|
<p class="text-white-50 mb-0">Directorio completo de integrantes de Formula Gades.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<form method="get" class="row g-2 align-items-end mb-4">
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label small text-secondary mb-1">Rol</label>
|
||||||
|
<select name="rol" class="form-select">
|
||||||
|
<option value="">Todos los roles</option>
|
||||||
|
{% for value, label in rol_choices %}
|
||||||
|
<option value="{{ value }}" {% if filtro_rol == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label small text-secondary mb-1">Área Técnica</label>
|
||||||
|
<select name="especialidad" class="form-select">
|
||||||
|
<option value="">Todas las áreas</option>
|
||||||
|
{% for value, label in especialidad_choices %}
|
||||||
|
<option value="{{ value }}" {% if filtro_especialidad == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label small text-secondary mb-1">Apellido</label>
|
||||||
|
<input type="text" name="apellido" class="form-control" placeholder="Buscar por apellido..." value="{{ filtro_apellido }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-2 d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary fw-bold flex-grow-1">Buscar</button>
|
||||||
|
{% if filtro_rol or filtro_especialidad or filtro_apellido %}
|
||||||
|
<a href="{% url 'listado_miembros' %}" class="btn btn-outline-secondary" title="Limpiar filtros">×</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Apellidos</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Área Técnica</th>
|
||||||
|
<th>Rol</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for miembro in miembros %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ miembro.username }}</td>
|
||||||
|
<td>{{ miembro.first_name }}</td>
|
||||||
|
<td>{{ miembro.last_name }}</td>
|
||||||
|
<td>{{ miembro.email }}</td>
|
||||||
|
<td>{{ miembro.get_especialidad_display|default:"—" }}</td>
|
||||||
|
<td>{{ miembro.get_rol_display }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-secondary py-4">No se han encontrado miembros con los filtros seleccionados.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
91
Plataform_Web/templates/listado_pruebas.html
Normal file
91
Plataform_Web/templates/listado_pruebas.html
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Pruebas | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm d-flex justify-content-between align-items-center" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Pruebas</h1>
|
||||||
|
<p class="text-white-50 mb-0">Tests realizados durante la temporada.</p>
|
||||||
|
</div>
|
||||||
|
{% if user.rol == 'directiva' or user.rol == 'jefe_area' %}
|
||||||
|
<a href="{% url 'crear_prueba' %}" class="btn btn-primary fw-bold">+ Nuevo Test</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="col-12">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-success py-2 small mb-0">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<form method="get" class="row g-2 align-items-end mb-4">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label fw-semibold small mb-1">Temporada</label>
|
||||||
|
<select name="temporada" class="form-select form-select-sm">
|
||||||
|
{% for temporada in temporadas %}
|
||||||
|
<option value="{{ temporada.pk }}" {% if temporada_seleccionada == temporada.pk|stringformat:"s" %}selected{% endif %}>{{ temporada.nombre }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label fw-semibold small mb-1">Categoría</label>
|
||||||
|
<select name="categoria" class="form-select form-select-sm">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{% for valor, etiqueta in categorias %}
|
||||||
|
<option value="{{ valor }}" {% if categoria_seleccionada == valor %}selected{% endif %}>{{ etiqueta }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">Buscar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Fecha inicio</th>
|
||||||
|
<th>Fecha fin</th>
|
||||||
|
<th>Categoría</th>
|
||||||
|
<th>Realizado por</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for prueba in pruebas %}
|
||||||
|
<tr role="button" onclick="window.location='{% url 'detalle_prueba' prueba.pk %}'">
|
||||||
|
<td class="fw-semibold">{{ prueba.nombre }}</td>
|
||||||
|
<td>{{ prueba.fecha_inicio|date:"d/m/Y" }}</td>
|
||||||
|
<td>{{ prueba.fecha_fin|date:"d/m/Y" }}</td>
|
||||||
|
<td>{{ prueba.get_categoria_display }}</td>
|
||||||
|
<td>{{ prueba.realizado_por.first_name|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-secondary py-4">No hay tests todavía.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
97
Plataform_Web/templates/login.html
Normal file
97
Plataform_Web/templates/login.html
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Iniciar Sesión | Formula Gades</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #111;
|
||||||
|
}
|
||||||
|
.login-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("{% static 'images/monoplaza_gades.jpg' %}");
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
filter: brightness(0.35);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(20, 20, 20, 0.88);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border-color: #444;
|
||||||
|
color: #f0f0f0;
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
color: #f0f0f0;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(13,110,253,.25);
|
||||||
|
}
|
||||||
|
.form-control::placeholder { color: #888; }
|
||||||
|
.form-label { color: #ccc; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="d-flex align-items-center justify-content-center min-vh-100">
|
||||||
|
|
||||||
|
<div class="login-bg"></div>
|
||||||
|
|
||||||
|
<div class="login-card rounded-4 shadow-lg p-4 p-md-5" style="width: 100%; max-width: 420px;">
|
||||||
|
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<img src="{% static 'images/logo_gades.png' %}" alt="Formula Gades" height="60" class="mb-3">
|
||||||
|
<h4 class="text-white fw-bold mb-1">Plataforma de gestión interna</h4>
|
||||||
|
<p class="text-secondary small">Bienvenido al sistema interno del equipo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger py-2 small" role="alert">
|
||||||
|
Usuario o contraseña incorrectos. Inténtalo de nuevo.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_username" class="form-label fw-semibold">Usuario</label>
|
||||||
|
<input type="text" name="username" id="id_username"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Introduce tu usuario"
|
||||||
|
autofocus required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="id_password" class="form-label fw-semibold">Contraseña</label>
|
||||||
|
<input type="password" name="password" id="id_password"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Introduce tu contraseña"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary fw-bold py-2">
|
||||||
|
Iniciar Sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
Plataform_Web/templates/mi_perfil.html
Normal file
121
Plataform_Web/templates/mi_perfil.html
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Mi Perfil | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Mi Perfil</h1>
|
||||||
|
<p class="text-white-50 mb-0">Consulta y edita tus datos personales.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="col-12">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-success py-2 small mb-0">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="card-title fw-bold mb-3">Datos Personales</h5>
|
||||||
|
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label small text-secondary mb-1">Rol en el equipo</label>
|
||||||
|
<input type="text" class="form-control" value="{{ user.get_rol_display }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label small text-secondary mb-1">Área técnica</label>
|
||||||
|
<input type="text" class="form-control" value="{{ user.get_especialidad_display|default:'—' }}" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ perfil_form.username.id_for_label }}" class="form-label fw-semibold">{{ perfil_form.username.label }}</label>
|
||||||
|
{{ perfil_form.username }}
|
||||||
|
{% for error in perfil_form.username.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<label for="{{ perfil_form.first_name.id_for_label }}" class="form-label fw-semibold">{{ perfil_form.first_name.label }}</label>
|
||||||
|
{{ perfil_form.first_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<label for="{{ perfil_form.last_name.id_for_label }}" class="form-label fw-semibold">{{ perfil_form.last_name.label }}</label>
|
||||||
|
{{ perfil_form.last_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ perfil_form.email.id_for_label }}" class="form-label fw-semibold">{{ perfil_form.email.label }}</label>
|
||||||
|
{{ perfil_form.email }}
|
||||||
|
{% for error in perfil_form.email.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" name="guardar_perfil" class="btn btn-primary fw-bold px-4">Guardar Cambios</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="card-title fw-bold mb-3">Cambiar Contraseña</h5>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ password_form.old_password.id_for_label }}" class="form-label fw-semibold">Contraseña actual</label>
|
||||||
|
<input type="password" name="{{ password_form.old_password.html_name }}" id="{{ password_form.old_password.id_for_label }}" class="form-control">
|
||||||
|
{% for error in password_form.old_password.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ password_form.new_password1.id_for_label }}" class="form-label fw-semibold">Nueva contraseña</label>
|
||||||
|
<input type="password" name="{{ password_form.new_password1.html_name }}" id="{{ password_form.new_password1.id_for_label }}" class="form-control">
|
||||||
|
{% for error in password_form.new_password1.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ password_form.new_password2.id_for_label }}" class="form-label fw-semibold">Confirmar nueva contraseña</label>
|
||||||
|
<input type="password" name="{{ password_form.new_password2.html_name }}" id="{{ password_form.new_password2.id_for_label }}" class="form-control">
|
||||||
|
{% for error in password_form.new_password2.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if password_form.non_field_errors %}
|
||||||
|
<div class="text-danger small mb-3">{{ password_form.non_field_errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="submit" name="cambiar_password" class="btn btn-outline-danger fw-bold px-4">Cambiar Contraseña</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
269
Plataform_Web/templates/patrocinios.html
Normal file
269
Plataform_Web/templates/patrocinios.html
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Patrocinios | Gades Manager{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-3 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="bg-dark text-white p-3 rounded-3 shadow-sm d-flex justify-content-between align-items-center" style="border-left: 5px solid #0d6efd;">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-6 fw-bold mb-1">Patrocinios</h1>
|
||||||
|
<p class="text-white-50 mb-0">Dossiers y gestión de patrocinadores del equipo.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary fw-bold" data-bs-toggle="modal" data-bs-target="#modalProponer">+ Proponer Patrocinio</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="row mb-2 flex-shrink-0">
|
||||||
|
<div class="col-12">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}success{% endif %} py-2 small mb-1">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-4" style="overflow-y: auto;">
|
||||||
|
|
||||||
|
{# ── Bloque 1: Dossiers PDF ── #}
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4 d-flex flex-column align-items-center text-center">
|
||||||
|
<div class="mb-3" style="font-size: 3rem; color: #dc3545;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
|
||||||
|
<path d="M4.603 14.087a.81.81 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.68 7.68 0 0 1 1.482-.645 19.697 19.697 0 0 0 1.062-2.227 7.269 7.269 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a10.954 10.954 0 0 0 .98 1.686 5.753 5.753 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.856.856 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.712 5.712 0 0 1-.911-.95 11.651 11.651 0 0 0-1.997.406 11.307 11.307 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.793.793 0 0 1-.58.029z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold mb-1">Dossier de Patrocinio</h5>
|
||||||
|
<p class="text-secondary small mb-3">Versión en español · Temporada 2025/2026</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% static 'dossiers/Dossier_2025_2026.pdf' %}" target="_blank" class="btn btn-outline-primary fw-bold">Abrir</a>
|
||||||
|
<a href="{% static 'dossiers/Dossier_2025_2026.pdf' %}" download class="btn btn-primary fw-bold">Descargar</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4 d-flex flex-column align-items-center text-center">
|
||||||
|
<div class="mb-3" style="font-size: 3rem; color: #dc3545;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
|
||||||
|
<path d="M4.603 14.087a.81.81 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.68 7.68 0 0 1 1.482-.645 19.697 19.697 0 0 0 1.062-2.227 7.269 7.269 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a10.954 10.954 0 0 0 .98 1.686 5.753 5.753 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.856.856 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.712 5.712 0 0 1-.911-.95 11.651 11.651 0 0 0-1.997.406 11.307 11.307 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.793.793 0 0 1-.58.029z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold mb-1">Sponsorship Dossier</h5>
|
||||||
|
<p class="text-secondary small mb-3">English version · Season 2025/2026</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% static 'dossiers/DossierEN_2025_2026.pdf' %}" target="_blank" class="btn btn-outline-primary fw-bold">Open</a>
|
||||||
|
<a href="{% static 'dossiers/DossierEN_2025_2026.pdf' %}" download class="btn btn-primary fw-bold">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Bloque 2: Patrocinios agrupados ── #}
|
||||||
|
<div class="col-12">
|
||||||
|
|
||||||
|
{% if not temporada_actual %}
|
||||||
|
<div class="alert alert-warning">No hay temporada activa. Activa una desde <a href="{% url 'gestion_temporadas' %}">Gestión de Temporadas</a>.</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# Pendientes #}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white border-bottom d-flex align-items-center gap-2 py-3">
|
||||||
|
<span class="badge bg-warning text-dark fs-6">{{ pendientes.count }}</span>
|
||||||
|
<h6 class="fw-bold mb-0">Pendientes / En contacto</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Empresa</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Persona contacto</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Propuesto por</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
{% if user.rol == 'directiva' %}<th>Acciones</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in pendientes %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ p.empresa }}</td>
|
||||||
|
<td>{{ p.get_tipo_patrocinio_display }}</td>
|
||||||
|
<td>{{ p.persona_contacto|default:"—" }}</td>
|
||||||
|
<td>{{ p.email_contacto }}</td>
|
||||||
|
<td>{{ p.contacto_equipo.get_full_name|default:p.contacto_equipo }}</td>
|
||||||
|
<td>{{ p.fecha_contacto|date:"d/m/Y" }}</td>
|
||||||
|
{% if user.rol == 'directiva' %}
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<form method="post" action="{% url 'cambiar_estado_patrocinio' p.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="estado" value="aceptado">
|
||||||
|
<button type="submit" class="btn btn-sm btn-success fw-bold">Aceptar</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'cambiar_estado_patrocinio' p.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="estado" value="denegado">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Rechazar</button>
|
||||||
|
</form>
|
||||||
|
<a href="{% url 'editar_patrocinio' p.pk %}" class="btn btn-sm btn-outline-secondary">Editar</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="{% if user.rol == 'directiva' %}7{% else %}6{% endif %}" class="text-center text-secondary py-3">No hay patrocinios pendientes.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Aceptados #}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white border-bottom d-flex align-items-center gap-2 py-3">
|
||||||
|
<span class="badge bg-success fs-6">{{ aceptados.count }}</span>
|
||||||
|
<h6 class="fw-bold mb-0">Aceptados</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Empresa</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Importe</th>
|
||||||
|
<th>Persona contacto</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
{% if user.rol == 'directiva' %}<th>Acciones</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in aceptados %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ p.empresa }}</td>
|
||||||
|
<td>{{ p.get_tipo_patrocinio_display }}</td>
|
||||||
|
<td>{{ p.importe_economico|floatformat:2 }} €</td>
|
||||||
|
<td>{{ p.persona_contacto|default:"—" }}</td>
|
||||||
|
<td>{{ p.email_contacto }}</td>
|
||||||
|
<td>{{ p.fecha_contacto|date:"d/m/Y" }}</td>
|
||||||
|
{% if user.rol == 'directiva' %}
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<form method="post" action="{% url 'cambiar_estado_patrocinio' p.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="estado" value="en_contacto">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning">Revertir</button>
|
||||||
|
</form>
|
||||||
|
<a href="{% url 'editar_patrocinio' p.pk %}" class="btn btn-sm btn-outline-secondary">Editar</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="{% if user.rol == 'directiva' %}7{% else %}6{% endif %}" class="text-center text-secondary py-3">No hay patrocinios aceptados.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Rechazados — colapsado por defecto #}
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom d-flex align-items-center gap-2 py-3" style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#collapseRechazados">
|
||||||
|
<span class="badge bg-danger fs-6">{{ denegados.count }}</span>
|
||||||
|
<h6 class="fw-bold mb-0 text-secondary">Rechazados</h6>
|
||||||
|
<span class="ms-auto text-secondary small">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="collapse" id="collapseRechazados">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Empresa</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Persona contacto</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
{% if user.rol == 'directiva' %}<th>Acciones</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in denegados %}
|
||||||
|
<tr class="text-secondary">
|
||||||
|
<td class="fw-semibold">{{ p.empresa }}</td>
|
||||||
|
<td>{{ p.get_tipo_patrocinio_display }}</td>
|
||||||
|
<td>{{ p.persona_contacto|default:"—" }}</td>
|
||||||
|
<td>{{ p.email_contacto }}</td>
|
||||||
|
<td>{{ p.fecha_contacto|date:"d/m/Y" }}</td>
|
||||||
|
{% if user.rol == 'directiva' %}
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<form method="post" action="{% url 'cambiar_estado_patrocinio' p.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="estado" value="en_contacto">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning">Reabrir</button>
|
||||||
|
</form>
|
||||||
|
<a href="{% url 'editar_patrocinio' p.pk %}" class="btn btn-sm btn-outline-secondary">Editar</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="{% if user.rol == 'directiva' %}6{% else %}5{% endif %}" class="text-center text-secondary py-3">No hay patrocinios rechazados.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}{# end if temporada_actual #}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Modal Proponer Patrocinio ── #}
|
||||||
|
<div class="modal fade" id="modalProponer" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Proponer Patrocinio</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'proponer_patrocinio' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body">
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<p class="text-secondary small mb-0">El estado quedará como <strong>Pendiente</strong> hasta que la directiva lo revise. Tu nombre quedará registrado como proponente.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-primary fw-bold">Proponer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
0
Plataform_Web/temporadas/__init__.py
Normal file
0
Plataform_Web/temporadas/__init__.py
Normal file
19
Plataform_Web/temporadas/admin.py
Normal file
19
Plataform_Web/temporadas/admin.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Temporada
|
||||||
|
|
||||||
|
@admin.register(Temporada)
|
||||||
|
class TemporadaAdmin(admin.ModelAdmin):
|
||||||
|
# Columnas principales.
|
||||||
|
list_display = ('nombre', 'fecha_inicio', 'fecha_fin', 'presupuesto', 'actual')
|
||||||
|
|
||||||
|
# Permite marcar o desmarcar el check de "actual" directamente desde la tabla, sin entrar al detalle
|
||||||
|
list_editable = ('actual',)
|
||||||
|
|
||||||
|
# Filtro para filtrar por temporadas históricas o actual
|
||||||
|
list_filter = ('actual',)
|
||||||
|
|
||||||
|
# Buscador de temporadas por nombre
|
||||||
|
search_fields = ('nombre',)
|
||||||
|
|
||||||
|
# Filtro para ver los miembros de una temporada
|
||||||
|
filter_horizontal = ('miembros',)
|
||||||
5
Plataform_Web/temporadas/apps.py
Normal file
5
Plataform_Web/temporadas/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TemporadasConfig(AppConfig):
|
||||||
|
name = 'temporadas'
|
||||||
13
Plataform_Web/temporadas/forms.py
Normal file
13
Plataform_Web/temporadas/forms.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from django import forms
|
||||||
|
from .models import Temporada
|
||||||
|
|
||||||
|
|
||||||
|
class TemporadaForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Temporada
|
||||||
|
fields = ['nombre', 'fecha_inicio', 'fecha_fin', 'presupuesto', 'actual', 'miembros']
|
||||||
|
widgets = {
|
||||||
|
'fecha_inicio': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||||
|
'fecha_fin': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||||
|
'miembros': forms.CheckboxSelectMultiple(),
|
||||||
|
}
|
||||||
30
Plataform_Web/temporadas/migrations/0001_initial.py
Normal file
30
Plataform_Web/temporadas/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-02-17 11:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Temporada',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('nombre', models.CharField(max_length=50, unique=True)),
|
||||||
|
('fecha_inicio', models.DateField()),
|
||||||
|
('fecha_fin', models.DateField()),
|
||||||
|
('presupuesto', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)),
|
||||||
|
('actual', models.BooleanField(default=False, verbose_name='¿Es la temporada actual?')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Temporada',
|
||||||
|
'verbose_name_plural': 'Temporadas',
|
||||||
|
'ordering': ['-fecha_inicio'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-03-06 19:29
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('temporadas', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='temporada',
|
||||||
|
name='miembros',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='temporadas_participadas', to=settings.AUTH_USER_MODEL, verbose_name='Miembros del equipo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
Plataform_Web/temporadas/migrations/__init__.py
Normal file
0
Plataform_Web/temporadas/migrations/__init__.py
Normal file
39
Plataform_Web/temporadas/models.py
Normal file
39
Plataform_Web/temporadas/models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class Temporada(models.Model):
|
||||||
|
# El nombre será algo como "Gades 2024-25"
|
||||||
|
nombre = models.CharField(max_length=50, unique=True)
|
||||||
|
|
||||||
|
fecha_inicio = models.DateField()
|
||||||
|
fecha_fin = models.DateField()
|
||||||
|
|
||||||
|
# Presupuesto total para ese año
|
||||||
|
presupuesto = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
|
||||||
|
|
||||||
|
# Checkbox para marcar cuál es la temporada que estamos viviendo ahora
|
||||||
|
actual = models.BooleanField(default=False, verbose_name="¿Es la temporada actual?")
|
||||||
|
|
||||||
|
# Relaciones
|
||||||
|
# Relacion N a N con miembros
|
||||||
|
miembros = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
related_name="temporadas_participadas",
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Miembros del equipo"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Temporada"
|
||||||
|
verbose_name_plural = "Temporadas"
|
||||||
|
ordering = ['-fecha_inicio'] # Ordena las más nuevas primero
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.nombre
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# TRUCO PRO: Si marco esta temporada como "actual", desmarco todas las demás
|
||||||
|
# Así evitamos que haya dos temporadas activas a la vez.
|
||||||
|
if self.actual:
|
||||||
|
Temporada.objects.filter(actual=True).exclude(pk=self.pk).update(actual=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
3
Plataform_Web/temporadas/tests.py
Normal file
3
Plataform_Web/temporadas/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
42
Plataform_Web/temporadas/views.py
Normal file
42
Plataform_Web/temporadas/views.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from users.decorators import require_rol
|
||||||
|
from .models import Temporada
|
||||||
|
from .forms import TemporadaForm
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
def gestion_temporadas(request):
|
||||||
|
temporadas = Temporada.objects.all()
|
||||||
|
return render(request, 'gestion_temporadas.html', {'temporadas': temporadas})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
def crear_temporada(request):
|
||||||
|
form = TemporadaForm(request.POST or None)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Temporada creada correctamente.')
|
||||||
|
return redirect('gestion_temporadas')
|
||||||
|
return render(request, 'editar_temporada.html', {'form': form, 'temporada': None})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
def editar_temporada(request, pk):
|
||||||
|
temporada = get_object_or_404(Temporada, pk=pk)
|
||||||
|
form = TemporadaForm(request.POST or None, instance=temporada)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Temporada actualizada correctamente.')
|
||||||
|
return redirect('gestion_temporadas')
|
||||||
|
return render(request, 'editar_temporada.html', {'form': form, 'temporada': temporada})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
@require_POST
|
||||||
|
def eliminar_temporada(request, pk):
|
||||||
|
temporada = get_object_or_404(Temporada, pk=pk)
|
||||||
|
temporada.delete()
|
||||||
|
messages.success(request, f'Temporada "{temporada.nombre}" eliminada.')
|
||||||
|
return redirect('gestion_temporadas')
|
||||||
0
Plataform_Web/users/__init__.py
Normal file
0
Plataform_Web/users/__init__.py
Normal file
27
Plataform_Web/users/admin.py
Normal file
27
Plataform_Web/users/admin.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
@admin.register(CustomUser)
|
||||||
|
class CustomUserAdmin(UserAdmin):
|
||||||
|
list_display = ('username', 'first_name', 'last_name', 'rol', 'especialidad', 'is_staff')
|
||||||
|
|
||||||
|
# Filtramos por rol y área técnica
|
||||||
|
list_filter = ('rol', 'especialidad', 'is_staff', 'is_active')
|
||||||
|
|
||||||
|
# Buscador de usuarios
|
||||||
|
search_fields = ('username', 'first_name', 'last_name', 'email')
|
||||||
|
|
||||||
|
# Para editar al usuario con los datos insertados
|
||||||
|
fieldsets = UserAdmin.fieldsets + (
|
||||||
|
('Información del Equipo Gades', {
|
||||||
|
'fields': ('rol', 'especialidad'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Para crear al usuario con los datos insertados
|
||||||
|
add_fieldsets = UserAdmin.add_fieldsets + (
|
||||||
|
('Información del Equipo Gades', {
|
||||||
|
'fields': ('rol', 'especialidad'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
5
Plataform_Web/users/apps.py
Normal file
5
Plataform_Web/users/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UsersConfig(AppConfig):
|
||||||
|
name = 'users'
|
||||||
7
Plataform_Web/users/decorators.py
Normal file
7
Plataform_Web/users/decorators.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
|
|
||||||
|
|
||||||
|
def require_rol(*roles):
|
||||||
|
def decorator(view_func):
|
||||||
|
return login_required(user_passes_test(lambda u: u.rol in roles)(view_func))
|
||||||
|
return decorator
|
||||||
42
Plataform_Web/users/forms.py
Normal file
42
Plataform_Web/users/forms.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from django import forms
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
class PerfilForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = CustomUser
|
||||||
|
fields = ['username', 'first_name', 'last_name', 'email']
|
||||||
|
widgets = {
|
||||||
|
'username': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'username': 'Usuario',
|
||||||
|
'first_name': 'Nombre',
|
||||||
|
'last_name': 'Apellidos',
|
||||||
|
'email': 'Correo electrónico',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EditarUsuarioForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = CustomUser
|
||||||
|
fields = ['username', 'first_name', 'last_name', 'email', 'rol', 'especialidad']
|
||||||
|
widgets = {
|
||||||
|
'username': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||||
|
'rol': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'especialidad': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'username': 'Usuario',
|
||||||
|
'first_name': 'Nombre',
|
||||||
|
'last_name': 'Apellidos',
|
||||||
|
'email': 'Correo electrónico',
|
||||||
|
'rol': 'Rol en el equipo',
|
||||||
|
'especialidad': 'Área técnica',
|
||||||
|
}
|
||||||
46
Plataform_Web/users/migrations/0001_initial.py
Normal file
46
Plataform_Web/users/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-02-17 10:36
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomUser',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('rol', models.CharField(choices=[('directiva', 'Directiva'), ('jefe_area', 'Jefe de Área'), ('empleado', 'Empleado')], default='empleado', max_length=20)),
|
||||||
|
('especialidad', models.CharField(blank=True, choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('business', 'Business & Operations'), ('epowertrain', 'E-Powertrain'), ('electronica', 'Electrónica'), ('sdf', 'SDF'), ('motor_transmision', 'Motor & Transmisión'), ('software', 'Software')], max_length=30, null=True)),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'user',
|
||||||
|
'verbose_name_plural': 'users',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-02-17 11:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='especialidad',
|
||||||
|
field=models.CharField(blank=True, choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('business', 'Business & Operations'), ('epowertrain', 'E-Powertrain'), ('electronica', 'Electrónica'), ('sdf', 'SDF'), ('motor_transmision', 'Motor & Transmisión'), ('software', 'Software')], max_length=30, null=True, verbose_name='Área Técnica'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='rol',
|
||||||
|
field=models.CharField(choices=[('directiva', 'Directiva'), ('jefe_area', 'Jefe de Área'), ('empleado', 'Empleado')], default='empleado', max_length=20, verbose_name='Rol en el equipo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-03-06 19:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0002_alter_customuser_especialidad_alter_customuser_rol'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='especialidad',
|
||||||
|
field=models.CharField(blank=True, choices=[('aerodinamica', 'Aerodinámica'), ('chasis', 'Chasis'), ('business_operations', 'Business & Operations'), ('epowertrain', 'E-Powertrain'), ('electronica', 'Electrónica'), ('sdf', 'SDF'), ('motor_transmision', 'Motor & Transmisión'), ('software', 'Software')], max_length=30, null=True, verbose_name='Área Técnica'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='rol',
|
||||||
|
field=models.CharField(choices=[('directiva', 'Directiva'), ('jefe_area', 'Jefe de Área'), ('miembro', 'Miembro')], default='miembro', max_length=20, verbose_name='Rol en el equipo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
Plataform_Web/users/migrations/__init__.py
Normal file
0
Plataform_Web/users/migrations/__init__.py
Normal file
43
Plataform_Web/users/models.py
Normal file
43
Plataform_Web/users/models.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
class CustomUser(AbstractUser):
|
||||||
|
# --- 1. OPCIONES (El Menú) ---
|
||||||
|
ROL_CHOICES = (
|
||||||
|
('directiva', 'Directiva'),
|
||||||
|
('jefe_area', 'Jefe de Área'),
|
||||||
|
('miembro', 'Miembro'),
|
||||||
|
)
|
||||||
|
|
||||||
|
ESPECIALIDAD_CHOICES = (
|
||||||
|
('aerodinamica', 'Aerodinámica'),
|
||||||
|
('chasis', 'Chasis'),
|
||||||
|
('business_operations', 'Business & Operations'),
|
||||||
|
('epowertrain', 'E-Powertrain'),
|
||||||
|
('electronica', 'Electrónica'),
|
||||||
|
('sdf', 'SDF'),
|
||||||
|
('motor_transmision', 'Motor & Transmisión'),
|
||||||
|
('software', 'Software'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 2. CAMPOS (Las Columnas en la BD) ---
|
||||||
|
# Usamos la versión con 'verbose_name' porque queda mejor en la web
|
||||||
|
rol = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ROL_CHOICES,
|
||||||
|
default='miembro',
|
||||||
|
verbose_name="Rol en el equipo"
|
||||||
|
)
|
||||||
|
|
||||||
|
especialidad = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=ESPECIALIDAD_CHOICES,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Área Técnica"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 3. MÉTODOS ---
|
||||||
|
def __str__(self):
|
||||||
|
# Muestra: "Nombre Apellido (usuario) - Rol"
|
||||||
|
return f"{self.first_name} {self.last_name} ({self.username}) - {self.get_rol_display()}"
|
||||||
3
Plataform_Web/users/tests.py
Normal file
3
Plataform_Web/users/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
119
Plataform_Web/users/views.py
Normal file
119
Plataform_Web/users/views.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth.forms import PasswordChangeForm
|
||||||
|
from django.contrib.auth import update_session_auth_hash
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
from .decorators import require_rol
|
||||||
|
from .forms import PerfilForm, EditarUsuarioForm
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def index(request):
|
||||||
|
return render(request, 'index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def mi_perfil(request):
|
||||||
|
perfil_form = PerfilForm(instance=request.user)
|
||||||
|
password_form = PasswordChangeForm(user=request.user)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
if 'guardar_perfil' in request.POST:
|
||||||
|
perfil_form = PerfilForm(request.POST, instance=request.user)
|
||||||
|
if perfil_form.is_valid():
|
||||||
|
perfil_form.save()
|
||||||
|
messages.success(request, 'Tus datos se han actualizado correctamente.')
|
||||||
|
return redirect('mi_perfil')
|
||||||
|
|
||||||
|
elif 'cambiar_password' in request.POST:
|
||||||
|
password_form = PasswordChangeForm(user=request.user, data=request.POST)
|
||||||
|
if password_form.is_valid():
|
||||||
|
user = password_form.save()
|
||||||
|
update_session_auth_hash(request, user)
|
||||||
|
messages.success(request, 'Tu contraseña se ha cambiado correctamente.')
|
||||||
|
return redirect('mi_perfil')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'perfil_form': perfil_form,
|
||||||
|
'password_form': password_form,
|
||||||
|
}
|
||||||
|
return render(request, 'mi_perfil.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def listado_miembros(request):
|
||||||
|
miembros = CustomUser.objects.all().order_by('last_name', 'first_name')
|
||||||
|
|
||||||
|
rol = request.GET.get('rol', '')
|
||||||
|
especialidad = request.GET.get('especialidad', '')
|
||||||
|
apellido = request.GET.get('apellido', '')
|
||||||
|
|
||||||
|
if rol:
|
||||||
|
miembros = miembros.filter(rol=rol)
|
||||||
|
if especialidad:
|
||||||
|
miembros = miembros.filter(especialidad=especialidad)
|
||||||
|
if apellido:
|
||||||
|
miembros = miembros.filter(last_name__icontains=apellido)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'miembros': miembros,
|
||||||
|
'rol_choices': CustomUser.ROL_CHOICES,
|
||||||
|
'especialidad_choices': CustomUser.ESPECIALIDAD_CHOICES,
|
||||||
|
'filtro_rol': rol,
|
||||||
|
'filtro_especialidad': especialidad,
|
||||||
|
'filtro_apellido': apellido,
|
||||||
|
}
|
||||||
|
return render(request, 'listado_miembros.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
def gestion_usuarios(request):
|
||||||
|
usuarios = CustomUser.objects.all().order_by('last_name', 'first_name')
|
||||||
|
|
||||||
|
rol = request.GET.get('rol', '')
|
||||||
|
especialidad = request.GET.get('especialidad', '')
|
||||||
|
apellido = request.GET.get('apellido', '')
|
||||||
|
|
||||||
|
if rol:
|
||||||
|
usuarios = usuarios.filter(rol=rol)
|
||||||
|
if especialidad:
|
||||||
|
usuarios = usuarios.filter(especialidad=especialidad)
|
||||||
|
if apellido:
|
||||||
|
usuarios = usuarios.filter(last_name__icontains=apellido)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'usuarios': usuarios,
|
||||||
|
'rol_choices': CustomUser.ROL_CHOICES,
|
||||||
|
'especialidad_choices': CustomUser.ESPECIALIDAD_CHOICES,
|
||||||
|
'filtro_rol': rol,
|
||||||
|
'filtro_especialidad': especialidad,
|
||||||
|
'filtro_apellido': apellido,
|
||||||
|
}
|
||||||
|
return render(request, 'gestion_usuarios.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
def editar_usuario(request, pk):
|
||||||
|
usuario = get_object_or_404(CustomUser, pk=pk)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = EditarUsuarioForm(request.POST, instance=usuario)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'El usuario se ha actualizado correctamente.')
|
||||||
|
return redirect('gestion_usuarios')
|
||||||
|
else:
|
||||||
|
form = EditarUsuarioForm(instance=usuario)
|
||||||
|
|
||||||
|
return render(request, 'editar_usuario.html', {'form': form, 'usuario': usuario})
|
||||||
|
|
||||||
|
|
||||||
|
@require_rol('directiva')
|
||||||
|
@require_POST
|
||||||
|
def eliminar_usuario(request, pk):
|
||||||
|
usuario = get_object_or_404(CustomUser, pk=pk)
|
||||||
|
usuario.delete()
|
||||||
|
messages.success(request, 'El usuario se ha eliminado correctamente.')
|
||||||
|
return redirect('gestion_usuarios')
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
#ifndef COMMON_LIBRARIES_HPP
|
|
||||||
#define COMMON_LIBRARIES_HPP
|
|
||||||
|
|
||||||
#include <Arduino.h>
|
|
||||||
#include "time.h"
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
#ifndef TELEMETRY_STATUS_HPP
|
|
||||||
#define TELEMETRY_STATUS_HPP
|
|
||||||
|
|
||||||
enum class TelemetryStatus {
|
|
||||||
CONNECTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
#ifndef DATAPROCESSOR_HPP
|
|
||||||
#define DATAPROCESSOR_HPP
|
|
||||||
|
|
||||||
#include "common/common_libraries.hpp"
|
|
||||||
#include "common/display_id.hpp"
|
|
||||||
#include "led_strip.hpp"
|
|
||||||
#include "crowpanel_controller.hpp"
|
|
||||||
|
|
||||||
#include "freertos/FreeRTOS.h"
|
|
||||||
#include "freertos/semphr.h"
|
|
||||||
|
|
||||||
class DataProcessor {
|
|
||||||
public:
|
|
||||||
DataProcessor() = default;
|
|
||||||
char* process(std::vector<float> data);
|
|
||||||
void send_serial(byte type, unsigned int value);
|
|
||||||
void send_serial_frame_0(int rpmh, int rpml, int tpsh, int tpsl, int ecth, int ectl, int gear);
|
|
||||||
void send_serial_frame_1(int lfws, int rfws, int lrws, int rrws, int maph, int mapl, int ect);
|
|
||||||
void send_serial_frame_2(int lambh, int lambl, int lamth, int lamtl, int bvolth, int bvoltl, int iat);
|
|
||||||
void send_serial_frame_3(int aux1, int aux2, int aux3, int aux4, int aux5, int aux6, int aux7);
|
|
||||||
void send_serial_frame_4(int aux1, int aux2, int aux3, int aux4, int aux5, int aux6, int aux7);
|
|
||||||
void send_serial_change_display(int display);
|
|
||||||
void send_serial_screen_test(int test);
|
|
||||||
void set_led_strip(LedStrip *led_strip){
|
|
||||||
_led_strip = led_strip;
|
|
||||||
}
|
|
||||||
void set_crow_panel_controller(CrowPanelController *crow_panel_controller) {
|
|
||||||
_crow_panel_controller = crow_panel_controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
LedStrip *_led_strip;
|
|
||||||
CrowPanelController *_crow_panel_controller;
|
|
||||||
int current_display=0;
|
|
||||||
bool change_screen_requested=false;
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif
|
|
||||||
254
index.html
254
index.html
|
|
@ -1,254 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Telemetría IoT - Sesiones</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin:0; font-family: Arial, sans-serif; background: linear-gradient(135deg,#667eea,#764ba2); min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px; }
|
|
||||||
#app { background:#fff; width:100%; max-width:980px; border-radius:16px; padding:28px; box-shadow:0 20px 60px rgba(0,0,0,.25); }
|
|
||||||
h1 { margin:0 0 8px; color:#333; text-align:center; }
|
|
||||||
#estado { text-align:center; font-weight:700; padding:10px; border-radius:8px; margin:8px 0 14px; }
|
|
||||||
.activa { color:#155724; background:#d4edda; }
|
|
||||||
.inactiva { color:#721c24; background:#f8d7da; }
|
|
||||||
#peso { text-align:center; font-size:3.2rem; font-weight:800; color:#007bff; margin:6px 0 16px; letter-spacing:-1px; }
|
|
||||||
.grid { display:grid; grid-template-columns: repeat(auto-fit,minmax(180px,1fr)); gap:10px; margin-bottom:16px; }
|
|
||||||
.card { background:#f7f7fb; border-radius:10px; padding:12px; text-align:center; }
|
|
||||||
.label { font-size:.85rem; color:#6c757d; }
|
|
||||||
.value { font-size:1.4rem; font-weight:700; color:#333; }
|
|
||||||
.controls { display:flex; gap:10px; flex-wrap:wrap; justify-content:center; margin-top:8px; }
|
|
||||||
button { background:#007bff; color:#fff; border:0; padding:12px 18px; border-radius:8px; font-weight:700; cursor:pointer; transition:.2s; }
|
|
||||||
button.stop { background:#dc3545; }
|
|
||||||
button.download { background:#28a745; }
|
|
||||||
button:hover { filter:brightness(.95); transform: translateY(-1px); }
|
|
||||||
#log { margin-top:16px; background:#f8f9fa; padding:12px; border-radius:8px; max-height:260px; overflow:auto; font-family: Consolas, monospace; font-size:.9rem; color:#495057; }
|
|
||||||
#conn { text-align:center; margin-top:6px; font-size:.9rem; color:#6c757d; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<h1>🔬 Telemetría IoT</h1>
|
|
||||||
<div id="estado" class="inactiva">❌ Sesión Inactiva</div>
|
|
||||||
<div id="peso">--- kg</div>
|
|
||||||
<div id="conn">Socket: desconocido</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card"><div class="label">Ventanas recibidas</div><div class="value" id="ventanas-count">0</div></div>
|
|
||||||
<div class="card"><div class="label">Muestras totales</div><div class="value" id="muestras-count">0</div></div>
|
|
||||||
<div class="card"><div class="label">Última actualización</div><div class="value" id="ultima-actualizacion">---</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button onclick="iniciarSesion()">▶️ Iniciar sesión</button>
|
|
||||||
<button class="stop" onclick="finalizarSesion()">⏸️ Finalizar sesión</button>
|
|
||||||
<button class="download" onclick="descargarCSV()">📥 Descargar CSV</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="log"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-app.js"></script>
|
|
||||||
<script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-database.js"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const firebaseConfig = {
|
|
||||||
apiKey: "AIzaSyB0JaH3ZPXdj-fw2LmKq1DGCEjriJ8hmgc",
|
|
||||||
authDomain: "iot-formula-gades.firebaseapp.com",
|
|
||||||
databaseURL: "https://iot-formula-gades-default-rtdb.europe-west1.firebasedatabase.app",
|
|
||||||
projectId: "iot-formula-gades",
|
|
||||||
storageBucket: "iot-formula-gades.firebasestorage.app",
|
|
||||||
messagingSenderId: "930129619937",
|
|
||||||
appId: "1:930129619937:web:473b802794abe593da40a0",
|
|
||||||
measurementId: "G-CWXQWQQE3R"
|
|
||||||
};
|
|
||||||
firebase.initializeApp(firebaseConfig);
|
|
||||||
const db = firebase.database();
|
|
||||||
|
|
||||||
const controlRef = db.ref('/control/sesion_activa');
|
|
||||||
const sesionesRef = db.ref('/sesiones');
|
|
||||||
const infoConnRef = db.ref('.info/connected'); // [web:263]
|
|
||||||
|
|
||||||
const estadoEl = document.getElementById('estado');
|
|
||||||
const connEl = document.getElementById('conn');
|
|
||||||
const pesoEl = document.getElementById('peso');
|
|
||||||
const ventanasCountEl = document.getElementById('ventanas-count');
|
|
||||||
const muestrasCountEl = document.getElementById('muestras-count');
|
|
||||||
const ultimaActualizacionEl = document.getElementById('ultima-actualizacion');
|
|
||||||
const logEl = document.getElementById('log');
|
|
||||||
|
|
||||||
let ultimaSesionID = null; // clave actual de sesión
|
|
||||||
let windowsRef = null; // referencia sin query
|
|
||||||
let windowsQuery = null; // query activa
|
|
||||||
let lastWindowId = null;
|
|
||||||
let prevLogId = null;
|
|
||||||
let prevSampleTs = null;
|
|
||||||
let ventanasRecibidas = 0;
|
|
||||||
let muestrasTotales = 0;
|
|
||||||
let todasLasMuestras = [];
|
|
||||||
|
|
||||||
function log(msg) {
|
|
||||||
const line = document.createElement('div');
|
|
||||||
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
|
||||||
logEl.prepend(line);
|
|
||||||
while (logEl.children.length > 300) logEl.removeChild(logEl.lastChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
infoConnRef.on('value', s => {
|
|
||||||
connEl.textContent = s.val() ? 'Socket: conectado' : 'Socket: desconectado';
|
|
||||||
});
|
|
||||||
|
|
||||||
function iniciarSesion() { controlRef.set(true); log('▶️ Iniciar sesión'); }
|
|
||||||
function finalizarSesion(){ controlRef.set(false); log('⏸️ Finalizar sesión'); }
|
|
||||||
|
|
||||||
function iniciarSesionUI() {
|
|
||||||
estadoEl.className = 'activa';
|
|
||||||
estadoEl.textContent = '✅ Sesión Activa';
|
|
||||||
pesoEl.style.color = '#28a745';
|
|
||||||
actualizarMetricas();
|
|
||||||
}
|
|
||||||
function finalizarSesionUI() {
|
|
||||||
estadoEl.className = 'inactiva';
|
|
||||||
estadoEl.textContent = '❌ Sesión Inactiva';
|
|
||||||
pesoEl.style.color = '#6c757d';
|
|
||||||
pesoEl.textContent = '--- kg';
|
|
||||||
actualizarMetricas();
|
|
||||||
}
|
|
||||||
function actualizarMetricas() {
|
|
||||||
ventanasCountEl.textContent = ventanasRecibidas;
|
|
||||||
muestrasCountEl.textContent = muestrasTotales;
|
|
||||||
ultimaActualizacionEl.textContent = new Date().toLocaleTimeString();
|
|
||||||
}
|
|
||||||
function resetEstadoSesion() {
|
|
||||||
ventanasRecibidas = 0; muestrasTotales = 0; todasLasMuestras = [];
|
|
||||||
lastWindowId = null; prevLogId = null; prevSampleTs = null;
|
|
||||||
actualizarMetricas();
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI de sesión (no afecta a tracking)
|
|
||||||
controlRef.on('value', snap => {
|
|
||||||
const activo = !!snap.val();
|
|
||||||
if (activo) iniciarSesionUI(); else finalizarSesionUI();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1) Al cargar, engancharse a la última
|
|
||||||
sesionesRef.orderByKey().limitToLast(1).on('value', snap => {
|
|
||||||
let key = null; snap.forEach(ch => key = ch.key);
|
|
||||||
if (!key) return;
|
|
||||||
const keyNum = Number(key);
|
|
||||||
const curNum = ultimaSesionID ? Number(ultimaSesionID) : -1;
|
|
||||||
if (keyNum > curNum) attachToSession(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) Y además detectar nuevas que aparezcan después
|
|
||||||
sesionesRef.on('child_added', snap => {
|
|
||||||
const key = snap.key;
|
|
||||||
const keyNum = Number(key);
|
|
||||||
const curNum = ultimaSesionID ? Number(ultimaSesionID) : -1;
|
|
||||||
if (keyNum > curNum) attachToSession(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
function detachWindows() {
|
|
||||||
if (windowsQuery) { windowsQuery.off(); windowsQuery = null; }
|
|
||||||
if (windowsRef) { windowsRef.off(); windowsRef = null; }
|
|
||||||
resetEstadoSesion();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjunta una sesión: hace un prefetch inicial y luego activa child_added
|
|
||||||
function attachToSession(sid) {
|
|
||||||
// Detach y reset
|
|
||||||
detachWindows();
|
|
||||||
ultimaSesionID = sid;
|
|
||||||
log(`📂 Sesión activa: ${sid}`);
|
|
||||||
|
|
||||||
windowsRef = db.ref(`/sesiones/${sid}/windows`);
|
|
||||||
|
|
||||||
// Prefetch: traer lo que ya exista ahora mismo (por si se escribió antes de adjuntar)
|
|
||||||
windowsRef.orderByKey().once('value', (snap) => {
|
|
||||||
const batch = [];
|
|
||||||
snap.forEach(ch => {
|
|
||||||
batch.push({ id: Number(ch.key), v: ch.val() });
|
|
||||||
});
|
|
||||||
batch.sort((a,b) => a.id - b.id);
|
|
||||||
for (const it of batch) {
|
|
||||||
if (lastWindowId === null || it.id > lastWindowId) {
|
|
||||||
processVentana(it.v, it.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ahora activar child_added para lo que venga a partir de aquí
|
|
||||||
windowsQuery = windowsRef.orderByKey().startAt(String((lastWindowId || 0) + 1)); // [web:263]
|
|
||||||
windowsQuery.on('child_added', (snap2) => {
|
|
||||||
const id = Number(snap2.key);
|
|
||||||
if (Number.isFinite(lastWindowId) && id <= lastWindowId) return;
|
|
||||||
processVentana(snap2.val(), id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function processVentana(w, id) {
|
|
||||||
if (!w || !Array.isArray(w.muestras)) return;
|
|
||||||
|
|
||||||
if (prevLogId !== null && id > prevLogId + 1) {
|
|
||||||
log(`⚠️ Faltan ${id - prevLogId - 1} ventanas entre ${prevLogId} y ${id}`);
|
|
||||||
}
|
|
||||||
prevLogId = id;
|
|
||||||
|
|
||||||
const t0 = typeof w.t0_ms === 'number' ? w.t0_ms : 0;
|
|
||||||
const dt = typeof w.dt_ms === 'number' ? w.dt_ms : 500;
|
|
||||||
const numMuestras = w.muestras.length;
|
|
||||||
|
|
||||||
w.muestras.forEach((m, i) => {
|
|
||||||
const ts_ms = (typeof m.ts_ms === 'number') ? m.ts_ms : (t0 + i*dt);
|
|
||||||
const iso = new Date(ts_ms).toISOString();
|
|
||||||
const peso = (typeof m.peso === 'number') ? m.peso : parseFloat(m.peso);
|
|
||||||
todasLasMuestras.push([iso, ts_ms, id, peso]);
|
|
||||||
|
|
||||||
if (prevSampleTs !== null && ts_ms - prevSampleTs > 2000) {
|
|
||||||
const gap = ((ts_ms - prevSampleTs)/1000).toFixed(1);
|
|
||||||
log(`⚠️ Hueco temporal de ${gap}s antes de ventana ${id}`);
|
|
||||||
}
|
|
||||||
prevSampleTs = ts_ms;
|
|
||||||
});
|
|
||||||
|
|
||||||
// UI
|
|
||||||
const last = w.muestras[numMuestras - 1];
|
|
||||||
if (last && last.peso != null) {
|
|
||||||
const v = parseFloat(last.peso);
|
|
||||||
if (!Number.isNaN(v)) pesoEl.textContent = v.toFixed(3) + ' kg';
|
|
||||||
}
|
|
||||||
ventanasRecibidas++;
|
|
||||||
muestrasTotales = todasLasMuestras.length;
|
|
||||||
actualizarMetricas();
|
|
||||||
|
|
||||||
const lastTs = (typeof last?.ts_ms === 'number') ? last.ts_ms : (t0 + (numMuestras-1)*dt);
|
|
||||||
log(`📦 Ventana ${id} (+${numMuestras} muestras) @ ${new Date(lastTs).toISOString()}`);
|
|
||||||
|
|
||||||
lastWindowId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function descargarCSV() {
|
|
||||||
if (!ultimaSesionID) { alert('⚠️ No hay sesión.'); return; }
|
|
||||||
if (todasLasMuestras.length === 0) { alert('⚠️ Sin datos.'); return; }
|
|
||||||
todasLasMuestras.sort((a,b) => a[1] - b[1]);
|
|
||||||
const BOM = "\uFEFF";
|
|
||||||
let csv = "sep=,\r\n";
|
|
||||||
csv += "Timestamp,Timestamp_ms,Window_ID,Peso_kg\r\n";
|
|
||||||
for (const r of todasLasMuestras) {
|
|
||||||
csv += [r[0], String(r[1]), String(r[2]), String(r[3])].join(',') + "\r\n";
|
|
||||||
}
|
|
||||||
const blob = new Blob([BOM + csv], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url; a.download = `sesion_${ultimaSesionID}.csv`;
|
|
||||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
log(`📥 CSV exportado (${todasLasMuestras.length} filas)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.iniciarSesion = iniciarSesion;
|
|
||||||
window.finalizarSesion = finalizarSesion;
|
|
||||||
window.descargarCSV = descargarCSV;
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
/**
|
|
||||||
* @file data_processor.cpp
|
|
||||||
* @author Raúl Arcos Herrera
|
|
||||||
* @brief This file contains the implementation of the Data Processor class for Link G4+ ECU.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "../include/data_processor.hpp"
|
|
||||||
|
|
||||||
void DataProcessor::send_serial(byte type, unsigned int value) { //Como parámetros se pasan el ID (type), que es el ID establecido al inicio del código para el dato que se quiera enviar. Ej: RPM_ID -> 0x51; y se envía el valor de dicho dato.
|
|
||||||
byte dato[8] = { 0x5A, 0xA5, 0x05, 0x82, 0x00, 0x00, 0x00, 0x00 }; //Se establece un arreglo de bytes con los primeros datos necesarios para que la pantalla lo interprete como mensaje (En la Wiki hay tutoriales que lo explican a fondo), como ser la longitud y el tipo de mensaje.
|
|
||||||
dato[4] = type; //Se configura en el mensaje el ID correspondiente al dato a enviar.
|
|
||||||
dato[6] = (value >> 8) & 0xFF; //Se configura el dato en los últimos 2 bytes.
|
|
||||||
dato[7] = value & 0xFF;
|
|
||||||
|
|
||||||
Serial.write(dato, 8); //Se envía serialmente el mensaje, indicando su longituden bytes para ello.
|
|
||||||
}
|
|
||||||
|
|
||||||
//RPM + TPS + vBatt + ECT
|
|
||||||
void DataProcessor::send_serial_frame_0(int rpmh, int rpml, int tpsh, int tpsl, int vbatth, int vbattl, int ect){
|
|
||||||
Serial.println("send_serial_frame_0");
|
|
||||||
|
|
||||||
int rpm = (rpmh * 256) + rpml;
|
|
||||||
int tps = (tpsh * 256) + tpsl;
|
|
||||||
double vbatt = ((vbatth * 256) + vbattl) / 100.0;
|
|
||||||
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_rpm, rpm);
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_battvolt, vbatt);
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_ect, ect);
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_ect2, ect);
|
|
||||||
|
|
||||||
// Update RPM LED bar (8000-12500 RPM range)
|
|
||||||
_crow_panel_controller->update_rpm_bar(rpm);
|
|
||||||
|
|
||||||
// Battery voltage color (typical car battery: 12.6V resting, 13.2-14.4V running)
|
|
||||||
if (vbatt < 11.5) {
|
|
||||||
_crow_panel_controller->set_label_color(ui_battvolt, CrowPanelController::COLOR_CRITICAL); // Red for low
|
|
||||||
} else if (vbatt < 12.0) {
|
|
||||||
_crow_panel_controller->set_label_color(ui_battvolt, CrowPanelController::COLOR_WARNING); // Yellow for warning
|
|
||||||
} else if (vbatt > 15.0) {
|
|
||||||
_crow_panel_controller->set_label_color(ui_battvolt, CrowPanelController::COLOR_WARNING); // Yellow for overcharge
|
|
||||||
} else {
|
|
||||||
_crow_panel_controller->set_label_color(ui_battvolt, CrowPanelController::COLOR_NORMAL); // Green for good
|
|
||||||
}
|
|
||||||
|
|
||||||
//El numero que muestra la temperatura siempre será blanco
|
|
||||||
_crow_panel_controller->set_label_color(ui_ect, CrowPanelController::COLOR_PANEL_DEFAULT);
|
|
||||||
_crow_panel_controller->set_label_color(ui_ect2, CrowPanelController::COLOR_PANEL_DEFAULT);
|
|
||||||
|
|
||||||
// Engine coolant temperature (typical range: 80-105°C normal operating temp)
|
|
||||||
if (ect > 105) {
|
|
||||||
// Crítico: Rojo
|
|
||||||
_crow_panel_controller->set_panel_color(ui_PanelETC, CrowPanelController::COLOR_CRITICAL);
|
|
||||||
} else if (ect >= 95) {
|
|
||||||
// Advertencia: Amarillo (95 a 105)
|
|
||||||
_crow_panel_controller->set_panel_color(ui_PanelETC, CrowPanelController::COLOR_WARNING);
|
|
||||||
} else if (ect >= 65) {
|
|
||||||
//Temperatura Ideal: Verde (65 a 94)
|
|
||||||
_crow_panel_controller->set_panel_color(ui_PanelETC, CrowPanelController::COLOR_GOOD);
|
|
||||||
} else { // etc <= 60 Azul
|
|
||||||
_crow_panel_controller->set_panel_color(ui_PanelETC, CrowPanelController::COLOR_BLUE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//LAMB + LAMBTRG + FUEL + GEAR
|
|
||||||
void DataProcessor::send_serial_frame_1(int lmbh, int lmbl, int lmbth, int lmbtl, int fuelh, int fuell, int gear){
|
|
||||||
Serial.println("send_serial_frame_1");
|
|
||||||
int lmb = (lmbh * 256) + lmbl;
|
|
||||||
int lmbtrg = (lmbth * 256) + lmbtl;
|
|
||||||
int fuel = (fuelh * 256) + fuell;
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_lambda, lmb);
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_lambdatarget, lmbtrg);
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_fuel, fuel);
|
|
||||||
// _crow_panel_controller->set_value_to_label(ui_gear, gear);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void DataProcessor::send_serial_frame_2(int shut, int fan, int lmbch, int lmbcl, int brakeh, int brakel, int aux1){
|
|
||||||
Serial.println("send_serial_frame_2");
|
|
||||||
int lmbcorrect = (lmbch * 256) + lmbcl;
|
|
||||||
int brake = (brakeh * 256) + brakel;
|
|
||||||
|
|
||||||
char shut_str[10];
|
|
||||||
char fan_str[10];
|
|
||||||
char aux1_str[10];
|
|
||||||
|
|
||||||
if (shut == 3){
|
|
||||||
strcpy(shut_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(shut_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fan == 1){
|
|
||||||
strcpy(fan_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(fan_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aux1 == 1){
|
|
||||||
strcpy(aux1_str, "N");
|
|
||||||
_crow_panel_controller->set_label_color(ui_PanelGear, CrowPanelController::COLOR_GOOD);
|
|
||||||
} else {
|
|
||||||
strcpy(aux1_str, "D");
|
|
||||||
_crow_panel_controller->set_label_color(ui_PanelGear, CrowPanelController::COLOR_PANEL_DEFAULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_crow_panel_controller->set_string_to_label(ui_shutdown, shut_str);
|
|
||||||
_crow_panel_controller->set_string_to_label(ui_fan, fan_str);
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_correctionlambda, lmbcorrect);
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_auxstatus9, brake);
|
|
||||||
_crow_panel_controller->set_string_to_label(ui_gear, aux1_str);
|
|
||||||
|
|
||||||
// Shutdown status color
|
|
||||||
if (shut == 3) {
|
|
||||||
_crow_panel_controller->set_label_color(ui_shutdown, CrowPanelController::COLOR_CRITICAL); // Red when shutdown is ON (emergency)
|
|
||||||
} else {
|
|
||||||
_crow_panel_controller->set_label_color(ui_shutdown, CrowPanelController::COLOR_GOOD); // Green when shutdown is OFF (normal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fan status color
|
|
||||||
if (fan == 1) {
|
|
||||||
_crow_panel_controller->set_label_color(ui_fan, CrowPanelController::COLOR_BLUE); // Blue when fan is ON (cooling)
|
|
||||||
} else {
|
|
||||||
_crow_panel_controller->set_label_color(ui_fan, CrowPanelController::COLOR_NORMAL); // White when fan is OFF
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brake pressure color (assuming brake > 0 means brakes applied)
|
|
||||||
if (brake > 100) { // Adjust threshold as needed
|
|
||||||
_crow_panel_controller->set_label_color(ui_auxstatus9, CrowPanelController::COLOR_WARNING); // Yellow for heavy braking
|
|
||||||
} else if (brake > 0) {
|
|
||||||
_crow_panel_controller->set_label_color(ui_auxstatus9, CrowPanelController::COLOR_NORMAL); // White for light braking
|
|
||||||
} else {
|
|
||||||
_crow_panel_controller->set_label_color(ui_auxstatus9, CrowPanelController::COLOR_GOOD); // Green for no braking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void DataProcessor::send_serial_frame_3(int aux3, int aux4, int aux5, int aux6, int aux7, int aux8, int dig1){
|
|
||||||
Serial.println("send_serial_frame_3");
|
|
||||||
|
|
||||||
char aux3_str[10];
|
|
||||||
char aux4_str[10];
|
|
||||||
char aux5_str[10];
|
|
||||||
char aux6_str[10];
|
|
||||||
char aux7_str[10];
|
|
||||||
char aux8_str[10];
|
|
||||||
char dig1_str[10];
|
|
||||||
|
|
||||||
if (aux3 == 1){
|
|
||||||
strcpy(aux3_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(aux3_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aux4 == 1){
|
|
||||||
|
|
||||||
strcpy(aux4_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(aux4_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aux5 == 1){
|
|
||||||
strcpy(aux5_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(aux5_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aux6 == 1){
|
|
||||||
strcpy(aux6_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(aux6_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aux7 == 1){
|
|
||||||
strcpy(aux7_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(aux7_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aux8 == 1){
|
|
||||||
strcpy(aux8_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(aux8_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dig1 == 1){
|
|
||||||
strcpy(dig1_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(dig1_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_auxstatus3, aux3_str);
|
|
||||||
if(aux3 == 1 && change_screen_requested == false){
|
|
||||||
switch(current_display){
|
|
||||||
case 0:
|
|
||||||
_crow_panel_controller->change_screen(ui_Screen1);
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
_crow_panel_controller->change_screen(ui_Screen2);
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
_crow_panel_controller->change_screen(ui_Screen3);
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
_crow_panel_controller->change_screen(ui_Screen4);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current_display++;
|
|
||||||
change_screen_requested = true;
|
|
||||||
if(current_display > 3){
|
|
||||||
current_display = 0;
|
|
||||||
}
|
|
||||||
}else if(aux3 == 0 && change_screen_requested == true){
|
|
||||||
change_screen_requested = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_auxstatus4, aux4_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_auxstatus5, aux5_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_auxstatus6, aux6_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_auxstatus7, aux7_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_auxstatus8, aux8_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_digitalstatus1, dig1_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DataProcessor::send_serial_frame_4(int dig3, int dig4, int dig5, int dig6, int dig7, int dig8, int dig9){
|
|
||||||
Serial.println("send_serial_frame_4");
|
|
||||||
|
|
||||||
char dig3_str[10];
|
|
||||||
char dig4_str[10];
|
|
||||||
char dig5_str[10];
|
|
||||||
char dig6_str[10];
|
|
||||||
char dig7_str[10];
|
|
||||||
char dig8_str[10];
|
|
||||||
char dig9_str[10];
|
|
||||||
|
|
||||||
if (dig3 == 1){
|
|
||||||
strcpy(dig3_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(dig3_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dig4 == 1){
|
|
||||||
strcpy(dig4_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(dig4_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dig5 == 1){
|
|
||||||
strcpy(dig5_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(dig5_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dig6 == 1){
|
|
||||||
strcpy(dig6_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(dig6_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dig7 == 1){
|
|
||||||
strcpy(dig7_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(dig7_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dig8 == 1){
|
|
||||||
strcpy(dig8_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(dig8_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dig9 == 1){
|
|
||||||
strcpy(dig9_str, "ON");
|
|
||||||
} else {
|
|
||||||
strcpy(dig9_str, "OFF");
|
|
||||||
}
|
|
||||||
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_digitalstatus3, dig3_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_digitalstatus4, dig4_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_digitalstatus5, dig5_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_digitalstatus6, dig6_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_digitalstatus7, dig7_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_digitalstatus8, dig8_str);
|
|
||||||
_crow_panel_controller -> set_string_to_label(ui_digitalstatus9, dig9_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DataProcessor::send_serial_screen_test(int test) {
|
|
||||||
_crow_panel_controller->set_value_to_label(ui_rpm, test);
|
|
||||||
Serial.println(test);
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue