diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a958c97 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Escritorio_Boxes/diseño.py b/Escritorio_Boxes/diseño.py new file mode 100644 index 0000000..3b47a9d --- /dev/null +++ b/Escritorio_Boxes/diseño.py @@ -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 diff --git a/Escritorio_Boxes/logo_gades.png b/Escritorio_Boxes/logo_gades.png new file mode 100644 index 0000000..36e0782 Binary files /dev/null and b/Escritorio_Boxes/logo_gades.png differ diff --git a/Escritorio_Boxes/monitor.py b/Escritorio_Boxes/monitor.py new file mode 100644 index 0000000..9db8b99 --- /dev/null +++ b/Escritorio_Boxes/monitor.py @@ -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() diff --git a/Firmware/G26-Telemetria/G26-Telemetria.ino b/Firmware/G26-Telemetria/G26-Telemetria.ino new file mode 100644 index 0000000..2dabd64 --- /dev/null +++ b/Firmware/G26-Telemetria/G26-Telemetria.ino @@ -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); +} diff --git a/include/can.hpp b/Firmware/G26-Telemetria/include/can.hpp similarity index 93% rename from include/can.hpp rename to Firmware/G26-Telemetria/include/can.hpp index 7a03905..f790f73 100644 --- a/include/can.hpp +++ b/Firmware/G26-Telemetria/include/can.hpp @@ -1,13 +1,13 @@ #ifndef CAN_HPP #define CAN_HPP -#define RX_PIN 13 -#define TX_PIN 38 +#define RX_PIN 23 +#define TX_PIN 22 #define POLLING_RATE_MS 1000 #define TRANSMIT_RATE_MS 1000 #include "driver/twai.h" -#include "common/common_libraries.hpp" +#include "common_libraries.hpp" #include "data_processor.hpp" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" diff --git a/Firmware/G26-Telemetria/include/common_libraries.hpp b/Firmware/G26-Telemetria/include/common_libraries.hpp new file mode 100644 index 0000000..7cfab84 --- /dev/null +++ b/Firmware/G26-Telemetria/include/common_libraries.hpp @@ -0,0 +1,30 @@ +#ifndef COMMON_LIBRARIES_HPP +#define COMMON_LIBRARIES_HPP + +#include +#include "time.h" +#include +#include + +// --- WiFi y UDP --- +#include +#include + +// --- SD --- +#include +#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 diff --git a/Firmware/G26-Telemetria/include/data_processor.hpp b/Firmware/G26-Telemetria/include/data_processor.hpp new file mode 100644 index 0000000..64c1fce --- /dev/null +++ b/Firmware/G26-Telemetria/include/data_processor.hpp @@ -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 data); + +private: + SdFat* _sd = nullptr; + SdFile* _logFile = nullptr; + + uint32_t _last_sync_time = 0; + const uint32_t _sync_interval_ms = 1000; + + void flushToSD(); +}; + +#endif diff --git a/src/can.cpp b/Firmware/G26-Telemetria/src/can.cpp similarity index 79% rename from src/can.cpp rename to Firmware/G26-Telemetria/src/can.cpp index f4ef323..4bf860e 100644 --- a/src/can.cpp +++ b/Firmware/G26-Telemetria/src/can.cpp @@ -1,17 +1,22 @@ /** * @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. */ #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; void CAN::start() { 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_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(); esp_err_t install_status = twai_driver_install(&g_config, &t_config, &f_config); @@ -41,7 +46,6 @@ void CAN::start() { return; } - // TWAI driver is now successfully installed and started driver_installed = true; } @@ -63,20 +67,10 @@ void CAN::start_listening_task() { "CAN_Listen_Task", // Task name 4096, // Stack size (words) this, // Task parameter (this CAN instance) - 1, // Priority (lowered from 5 to 1) + 1, // Priority &_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) { Serial.println("CAN listening task created successfully"); } else { @@ -92,7 +86,6 @@ void CAN::stop_listening_task() { if (_listen_task_handle != NULL) { _should_stop_listening = true; - // Wait for task to finish (max 1 second) for (int i = 0; i < 100; i++) { if (_listen_task_handle == NULL) { break; @@ -100,7 +93,6 @@ void CAN::stop_listening_task() { vTaskDelay(pdMS_TO_TICKS(10)); } - // Force delete if still running if (_listen_task_handle != NULL) { vTaskDelete(_listen_task_handle); _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() { Serial.println("CAN listening task started"); - // Continuous loop for the thread while (!_should_stop_listening) { if (!driver_installed) { - // Driver not installed vTaskDelay(pdMS_TO_TICKS(1000)); continue; } - // Check if alert happened 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_get_status_info(&twaistatus); - // Handle alerts if (alerts_triggered & TWAI_ALERT_ERR_PASS) { Serial.println("Alert: TWAI controller has become error passive."); } @@ -166,7 +154,7 @@ void CAN::listen() { int message_count = 0; while (twai_receive(&message, 0) == ESP_OK && !_should_stop_listening) { 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) { all_zeros = false; break; @@ -174,24 +162,26 @@ void CAN::listen() { } if (all_zeros) { +#if VOLCADO_CRUDO_CAN Serial.println("Ignoring message with all zero data"); +#endif taskYIELD(); continue; } - + +#if VOLCADO_CRUDO_CAN if (message.extd) { Serial.println("Extended Format"); } else { Serial.println("Standard Format"); } Serial.printf("ID: %lx\nByte:", message.identifier); + for (int i = 0; i < message.data_length_code && !(message.rtr); i++) { + Serial.printf(" %d = %02x,", i, message.data[i]); + } + Serial.println(""); +#endif if (!(message.rtr)) { - for (int i = 0; i < message.data_length_code; i++) { - Serial.printf(" %d = %02x,", i, message.data[i]); - } - Serial.println(""); - - // Send to data processor based on first byte (maintaining original logic) switch (message.data[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]); @@ -200,10 +190,14 @@ void CAN::listen() { _data_processor->send_serial_frame_1(message.data[1], message.data[2], message.data[3], message.data[4], message.data[5], message.data[6], message.data[7]); break; case 2: - _data_processor->send_serial_frame_2(message.data[1], message.data[2], message.data[3], message.data[4], message.data[5], message.data[6], message.data[7]); - break; - case 3: + _data_processor->send_serial_frame_2(message.data[1], message.data[2], message.data[3], message.data[4], message.data[5], message.data[6], message.data[7]); + break; + 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]); + 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: break; } @@ -219,5 +213,5 @@ void CAN::listen() { Serial.println("CAN listening task ending"); _listen_task_handle = NULL; - vTaskDelete(NULL); // Delete this task + vTaskDelete(NULL); } diff --git a/Firmware/G26-Telemetria/src/data_processor.cpp b/Firmware/G26-Telemetria/src/data_processor.cpp new file mode 100644 index 0000000..a271eaf --- /dev/null +++ b/Firmware/G26-Telemetria/src/data_processor.cpp @@ -0,0 +1,90 @@ +#include "../include/data_processor.hpp" + +char* DataProcessor::process(std::vector 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(); + } + } +} diff --git a/G26-Telemetria.ino b/G26-Telemetria.ino deleted file mode 100644 index 271da27..0000000 --- a/G26-Telemetria.ino +++ /dev/null @@ -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 -#include - -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); -} \ No newline at end of file diff --git a/Plataform_Web/documentos/__init__.py b/Plataform_Web/documentos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/documentos/admin.py b/Plataform_Web/documentos/admin.py new file mode 100644 index 0000000..d14f08f --- /dev/null +++ b/Plataform_Web/documentos/admin.py @@ -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) \ No newline at end of file diff --git a/Plataform_Web/documentos/apps.py b/Plataform_Web/documentos/apps.py new file mode 100644 index 0000000..b42b59d --- /dev/null +++ b/Plataform_Web/documentos/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DocumentosConfig(AppConfig): + name = 'documentos' diff --git a/Plataform_Web/documentos/migrations/0001_initial.py b/Plataform_Web/documentos/migrations/0001_initial.py new file mode 100644 index 0000000..3598421 --- /dev/null +++ b/Plataform_Web/documentos/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/Plataform_Web/documentos/migrations/0002_factura_alter_documento_options_and_more.py b/Plataform_Web/documentos/migrations/0002_factura_alter_documento_options_and_more.py new file mode 100644 index 0000000..d29ff00 --- /dev/null +++ b/Plataform_Web/documentos/migrations/0002_factura_alter_documento_options_and_more.py @@ -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'), + ), + ] diff --git a/Plataform_Web/documentos/migrations/0003_factura_estado.py b/Plataform_Web/documentos/migrations/0003_factura_estado.py new file mode 100644 index 0000000..0d89bea --- /dev/null +++ b/Plataform_Web/documentos/migrations/0003_factura_estado.py @@ -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), + ), + ] diff --git a/Plataform_Web/documentos/migrations/__init__.py b/Plataform_Web/documentos/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/documentos/models.py b/Plataform_Web/documentos/models.py new file mode 100644 index 0000000..2703e84 --- /dev/null +++ b/Plataform_Web/documentos/models.py @@ -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}€" \ No newline at end of file diff --git a/Plataform_Web/documentos/tests.py b/Plataform_Web/documentos/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Plataform_Web/documentos/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Plataform_Web/documentos/views.py b/Plataform_Web/documentos/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/Plataform_Web/documentos/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/Plataform_Web/gades_manager/__init__.py b/Plataform_Web/gades_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/gades_manager/asgi.py b/Plataform_Web/gades_manager/asgi.py new file mode 100644 index 0000000..153ca09 --- /dev/null +++ b/Plataform_Web/gades_manager/asgi.py @@ -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() diff --git a/Plataform_Web/gades_manager/settings.py b/Plataform_Web/gades_manager/settings.py new file mode 100644 index 0000000..a0536f4 --- /dev/null +++ b/Plataform_Web/gades_manager/settings.py @@ -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' diff --git a/Plataform_Web/gades_manager/urls.py b/Plataform_Web/gades_manager/urls.py new file mode 100644 index 0000000..6d53cf2 --- /dev/null +++ b/Plataform_Web/gades_manager/urls.py @@ -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//editar/', users_views.editar_usuario, name='editar_usuario'), + path('gestion/usuarios//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//editar/', temporadas_views.editar_temporada, name='editar_temporada'), + path('gestion/temporadas//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//aceptar/', views.aceptar_factura, name='aceptar_factura'), + path('gestion/contabilidad/factura//rechazar/', views.rechazar_factura, name='rechazar_factura'), + path('patrocinios/', views.patrocinios, name='patrocinios'), + path('patrocinios/proponer/', views.proponer_patrocinio, name='proponer_patrocinio'), + path('patrocinios//editar/', views.editar_patrocinio, name='editar_patrocinio'), + path('patrocinios//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//', pruebas_views.detalle_prueba, name='detalle_prueba'), + path('pruebas//editar/', pruebas_views.editar_prueba, name='editar_prueba'), + path('pruebas//eliminar/', pruebas_views.eliminar_prueba, name='eliminar_prueba'), + path('pruebas//csv/subir/', pruebas_views.subir_csv, name='subir_csv'), +] \ No newline at end of file diff --git a/Plataform_Web/gades_manager/wsgi.py b/Plataform_Web/gades_manager/wsgi.py new file mode 100644 index 0000000..ef3583b --- /dev/null +++ b/Plataform_Web/gades_manager/wsgi.py @@ -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() diff --git a/Plataform_Web/gestion/__init__.py b/Plataform_Web/gestion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/gestion/admin.py b/Plataform_Web/gestion/admin.py new file mode 100644 index 0000000..0d47392 --- /dev/null +++ b/Plataform_Web/gestion/admin.py @@ -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' \ No newline at end of file diff --git a/Plataform_Web/gestion/apps.py b/Plataform_Web/gestion/apps.py new file mode 100644 index 0000000..48586ea --- /dev/null +++ b/Plataform_Web/gestion/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GestionConfig(AppConfig): + name = 'gestion' diff --git a/Plataform_Web/gestion/forms.py b/Plataform_Web/gestion/forms.py new file mode 100644 index 0000000..aecfd67 --- /dev/null +++ b/Plataform_Web/gestion/forms.py @@ -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' diff --git a/Plataform_Web/gestion/migrations/0001_initial.py b/Plataform_Web/gestion/migrations/0001_initial.py new file mode 100644 index 0000000..7c34fb8 --- /dev/null +++ b/Plataform_Web/gestion/migrations/0001_initial.py @@ -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')), + ], + ), + ] diff --git a/Plataform_Web/gestion/migrations/0002_remove_patrocinador_contacto_equipo_and_more.py b/Plataform_Web/gestion/migrations/0002_remove_patrocinador_contacto_equipo_and_more.py new file mode 100644 index 0000000..84fb76c --- /dev/null +++ b/Plataform_Web/gestion/migrations/0002_remove_patrocinador_contacto_equipo_and_more.py @@ -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', + ), + ] diff --git a/Plataform_Web/gestion/migrations/__init__.py b/Plataform_Web/gestion/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/gestion/models.py b/Plataform_Web/gestion/models.py new file mode 100644 index 0000000..5155e65 --- /dev/null +++ b/Plataform_Web/gestion/models.py @@ -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})" diff --git a/Plataform_Web/gestion/tests.py b/Plataform_Web/gestion/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Plataform_Web/gestion/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Plataform_Web/gestion/views.py b/Plataform_Web/gestion/views.py new file mode 100644 index 0000000..8da0530 --- /dev/null +++ b/Plataform_Web/gestion/views.py @@ -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') diff --git a/Plataform_Web/manage.py b/Plataform_Web/manage.py new file mode 100644 index 0000000..d8daff8 --- /dev/null +++ b/Plataform_Web/manage.py @@ -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() diff --git a/Plataform_Web/media/ingenieria_docs/PruebaTest1.png b/Plataform_Web/media/ingenieria_docs/PruebaTest1.png new file mode 100644 index 0000000..0c824e6 Binary files /dev/null and b/Plataform_Web/media/ingenieria_docs/PruebaTest1.png differ diff --git a/Plataform_Web/pruebas/__init__.py b/Plataform_Web/pruebas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/pruebas/admin.py b/Plataform_Web/pruebas/admin.py new file mode 100644 index 0000000..c2bb061 --- /dev/null +++ b/Plataform_Web/pruebas/admin.py @@ -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') \ No newline at end of file diff --git a/Plataform_Web/pruebas/apps.py b/Plataform_Web/pruebas/apps.py new file mode 100644 index 0000000..8cbb027 --- /dev/null +++ b/Plataform_Web/pruebas/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PruebasConfig(AppConfig): + name = 'pruebas' diff --git a/Plataform_Web/pruebas/forms.py b/Plataform_Web/pruebas/forms.py new file mode 100644 index 0000000..60029e9 --- /dev/null +++ b/Plataform_Web/pruebas/forms.py @@ -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'}), + } diff --git a/Plataform_Web/pruebas/migrations/0001_initial.py b/Plataform_Web/pruebas/migrations/0001_initial.py new file mode 100644 index 0000000..d531753 --- /dev/null +++ b/Plataform_Web/pruebas/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/Plataform_Web/pruebas/migrations/0002_prueba_telemetria_variable_delete_testgeneral.py b/Plataform_Web/pruebas/migrations/0002_prueba_telemetria_variable_delete_testgeneral.py new file mode 100644 index 0000000..8bc4e3b --- /dev/null +++ b/Plataform_Web/pruebas/migrations/0002_prueba_telemetria_variable_delete_testgeneral.py @@ -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', + ), + ] diff --git a/Plataform_Web/pruebas/migrations/__init__.py b/Plataform_Web/pruebas/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/pruebas/models.py b/Plataform_Web/pruebas/models.py new file mode 100644 index 0000000..257563e --- /dev/null +++ b/Plataform_Web/pruebas/models.py @@ -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})" \ No newline at end of file diff --git a/Plataform_Web/pruebas/tests.py b/Plataform_Web/pruebas/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Plataform_Web/pruebas/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Plataform_Web/pruebas/views.py b/Plataform_Web/pruebas/views.py new file mode 100644 index 0000000..38fcb78 --- /dev/null +++ b/Plataform_Web/pruebas/views.py @@ -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) diff --git a/Plataform_Web/static/dossiers/DossierEN_2025_2026.pdf b/Plataform_Web/static/dossiers/DossierEN_2025_2026.pdf new file mode 100644 index 0000000..e77e215 Binary files /dev/null and b/Plataform_Web/static/dossiers/DossierEN_2025_2026.pdf differ diff --git a/Plataform_Web/static/dossiers/Dossier_2025_2026.pdf b/Plataform_Web/static/dossiers/Dossier_2025_2026.pdf new file mode 100644 index 0000000..cc1b2f1 Binary files /dev/null and b/Plataform_Web/static/dossiers/Dossier_2025_2026.pdf differ diff --git a/Plataform_Web/static/images/logo_gades.png b/Plataform_Web/static/images/logo_gades.png new file mode 100644 index 0000000..36e0782 Binary files /dev/null and b/Plataform_Web/static/images/logo_gades.png differ diff --git a/Plataform_Web/static/images/monoplaza_gades.jpg b/Plataform_Web/static/images/monoplaza_gades.jpg new file mode 100644 index 0000000..fe02155 Binary files /dev/null and b/Plataform_Web/static/images/monoplaza_gades.jpg differ diff --git a/Plataform_Web/templates/base.html b/Plataform_Web/templates/base.html new file mode 100644 index 0000000..fd7817d --- /dev/null +++ b/Plataform_Web/templates/base.html @@ -0,0 +1,111 @@ +{% load static %} + + + + + + {% block title %}Intranet | Formula Gades{% endblock title %} + + + + + + {% block extra_head %}{% endblock extra_head %} + + + + + + +
+ {% block content %} + {% endblock content %} +
+ +
+

© 2026 Formula Gades - Universidad de Cádiz. Todos los derechos reservados.

+
+ + + + {% block extra_js %}{% endblock extra_js %} + + \ No newline at end of file diff --git a/Plataform_Web/templates/contabilidad.html b/Plataform_Web/templates/contabilidad.html new file mode 100644 index 0000000..9beeaf4 --- /dev/null +++ b/Plataform_Web/templates/contabilidad.html @@ -0,0 +1,247 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Contabilidad | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+

Contabilidad

+

+ {% if temporada_actual %}{{ temporada_actual.nombre }}{% else %}Sin temporada activa{% endif %} +

+
+
+
+ +{% if messages %} +
+
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+
+{% endif %} + +{% if not temporada_actual %} +
+
+
No hay ninguna temporada activa. Activa una temporada desde Gestión de Temporadas.
+
+
+{% else %} + +{# ── Bloque 1: 4 tarjetas resumen ── #} +
+
+
+
+

Presupuesto Inicial

+

{{ presupuesto_inicial|floatformat:2 }} €

+
+
+
+
+
+
+

Total Ingresos

+

+{{ total_ingresos|floatformat:2 }} €

+
+
+
+
+
+
+

Total Gastos

+

-{{ total_gastos|floatformat:2 }} €

+
+
+
+
+
+
+

Presupuesto Actual

+

+ {{ presupuesto_actual|floatformat:2 }} € +

+
+
+
+
+ +{# ── Bloque 2: Gastos | Ingresos ── #} +
+ + {# Columna Gastos #} +
+
+
+
+
Gastos
+ +
+
+ {% if gastos %} +
    + {% for gasto in gastos %} +
  • +
    +
    {{ gasto.concepto }}
    +
    {{ gasto.get_categoria_display }} · {{ gasto.fecha|date:"d/m/Y" }}
    +
    + -{{ gasto.importe|floatformat:2 }} € +
  • + {% endfor %} +
+ {% else %} +

Sin gastos registrados.

+ {% endif %} +
+
+
+
+ + {# Columna Ingresos #} +
+
+
+
+
Ingresos
+ +
+
+ {% if ingresos %} +
    + {% for ingreso in ingresos %} +
  • +
    +
    {{ ingreso.concepto }}
    +
    {{ ingreso.get_categoria_display }} · {{ ingreso.fecha|date:"d/m/Y" }}
    +
    + +{{ ingreso.importe|floatformat:2 }} € +
  • + {% endfor %} +
+ {% else %} +

Sin ingresos registrados.

+ {% endif %} +
+
+
+
+
+ +{# ── Bloque 3: Facturas pendientes ── #} +
+
+
+
+
Facturas Pendientes
+
+ + + + + + + + + + + + {% for factura in facturas_pendientes %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
NombreEmpresaImporteCategoríaAcciones
{{ factura.nombre }}{{ factura.empresa }}{{ factura.importe|floatformat:2 }} €{{ factura.get_categoria_display }} +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
No hay facturas pendientes.
+
+
+
+
+
+ +{% endif %}{# end if temporada_actual #} + +{# ── Modal Añadir Gasto ── #} + + +{# ── Modal Añadir Ingreso ── #} + + +{% endblock content %} diff --git a/Plataform_Web/templates/detalle_prueba.html b/Plataform_Web/templates/detalle_prueba.html new file mode 100644 index 0000000..1a8d889 --- /dev/null +++ b/Plataform_Web/templates/detalle_prueba.html @@ -0,0 +1,112 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ prueba.nombre }} | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+
+

{{ prueba.nombre }}

+

{{ prueba.get_categoria_display }} · Temporada {{ prueba.temporada.nombre }}

+
+
+ {% if puede_editar %} + Editar + {% endif %} + {% if user.rol == 'directiva' %} + + {% endif %} +
+
+
+
+ +
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+
+
+
Objetivo / Descripción
+

{{ prueba.descripcion }}

+ +
+
Fecha inicio: {{ prueba.fecha_inicio|date:"d/m/Y" }}
+
Fecha fin: {{ prueba.fecha_fin|date:"d/m/Y" }}
+
+

Realizado por: {{ prueba.realizado_por.first_name|default:"—" }}

+ + {% if prueba.resultados %} +
Conclusiones y Resultados
+

{{ prueba.resultados }}

+ {% endif %} +
+
+
+ +
+
+
+
Archivos de telemetría
+
    + {% for telemetria in prueba.archivos_telemetria.all %} +
  • + {{ telemetria.nombre }} + {{ telemetria.fecha_subida|date:"d/m/Y" }} +
  • + {% empty %} +
  • No hay archivos subidos todavía.
  • + {% endfor %} +
+ + {% if puede_subir_csv %} +
+ {% csrf_token %} +
+ + {{ form_csv.nombre }} +
+
+ + {{ form_csv.archivo_csv }} +
+ +
+ {% endif %} +
+
+
+
+ +{% if user.rol == 'directiva' %} + +{% endif %} +{% endblock content %} diff --git a/Plataform_Web/templates/editar_patrocinio.html b/Plataform_Web/templates/editar_patrocinio.html new file mode 100644 index 0000000..0b7d0bc --- /dev/null +++ b/Plataform_Web/templates/editar_patrocinio.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Editar Patrocinio | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+

Editar Patrocinio

+

{{ patrocinio.empresa }}

+
+
+
+ +
+
+
+
+
Datos del Patrocinio
+ +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + +
+ + Cancelar +
+
+
+
+
+
+{% endblock content %} diff --git a/Plataform_Web/templates/editar_prueba.html b/Plataform_Web/templates/editar_prueba.html new file mode 100644 index 0000000..1e7e012 --- /dev/null +++ b/Plataform_Web/templates/editar_prueba.html @@ -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 %} +
+
+
+

{% if prueba %}Editar Test{% else %}Nuevo Test{% endif %}

+

{% if prueba %}{{ prueba.nombre }}{% else %}Rellena los datos del nuevo test.{% endif %}

+
+
+
+ +
+
+
+
+ +
+ {% csrf_token %} + +
+ + + {% for error in form.nombre.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ + + {% for error in form.descripcion.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+
+ + {{ form.fecha_inicio }} + {% for error in form.fecha_inicio.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {{ form.fecha_fin }} + {% for error in form.fecha_fin.errors %} +
{{ error }}
+ {% endfor %} +
+
+ +
+
+ + +
+
+ + + {% for error in form.temporada.errors %} +
{{ error }}
+ {% endfor %} +
+
+ +
+ + + {% for error in form.resultados.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ + Cancelar +
+
+ +
+
+
+
+{% endblock content %} diff --git a/Plataform_Web/templates/editar_temporada.html b/Plataform_Web/templates/editar_temporada.html new file mode 100644 index 0000000..cd7d320 --- /dev/null +++ b/Plataform_Web/templates/editar_temporada.html @@ -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 %} +
+
+
+

{% if temporada %}Editar Temporada{% else %}Nueva Temporada{% endif %}

+

{% if temporada %}{{ temporada.nombre }}{% else %}Rellena los datos de la nueva temporada.{% endif %}

+
+
+
+ +
+
+
+
+ +
+ {% csrf_token %} + +
+ + + {% for error in form.nombre.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+
+ + {{ form.fecha_inicio }} + {% for error in form.fecha_inicio.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {{ form.fecha_fin }} + {% for error in form.fecha_fin.errors %} +
{{ error }}
+ {% endfor %} +
+
+ +
+ + + {% for error in form.presupuesto.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ + +
Solo puede haber una temporada activa. Al marcar esta se desactivarán las demás automáticamente.
+
+ +
+ +
+ {% for checkbox in form.miembros %} +
+ {{ checkbox.tag }} + +
+ {% empty %} + No hay usuarios en el sistema. + {% endfor %} +
+
+ +
+ + Cancelar +
+
+ +
+
+
+
+{% endblock content %} diff --git a/Plataform_Web/templates/editar_usuario.html b/Plataform_Web/templates/editar_usuario.html new file mode 100644 index 0000000..9e107d8 --- /dev/null +++ b/Plataform_Web/templates/editar_usuario.html @@ -0,0 +1,70 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Editar Usuario | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+

Editar Usuario

+

{{ usuario.username }}

+
+
+
+ +
+
+
+
+
Datos del Usuario
+ +
+ {% csrf_token %} + +
+ + {{ form.username }} + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+
+ + {{ form.first_name }} +
+
+ + {{ form.last_name }} +
+
+ +
+ + {{ form.email }} + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+
+ + {{ form.rol }} +
+
+ + {{ form.especialidad }} +
+
+ + + Cancelar +
+
+
+
+
+{% endblock content %} diff --git a/Plataform_Web/templates/gestion_temporadas.html b/Plataform_Web/templates/gestion_temporadas.html new file mode 100644 index 0000000..404f3b9 --- /dev/null +++ b/Plataform_Web/templates/gestion_temporadas.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Gestión de Temporadas | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+
+

Gestión de Temporadas

+

Crea, edita y elimina temporadas del equipo.

+
+ + Nueva Temporada +
+
+
+ +
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+
+
+ +
+ + + + + + + + + + + + + + {% for temporada in temporadas %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
NombreFecha inicioFecha finPresupuestoMiembrosEstadoAcciones
{{ temporada.nombre }}{{ temporada.fecha_inicio|date:"d/m/Y" }}{{ temporada.fecha_fin|date:"d/m/Y" }}{{ temporada.presupuesto|floatformat:2 }} €{{ temporada.miembros.count }} + {% if temporada.actual %} + Activa + {% else %} + Anterior + {% endif %} + + Editar + +
No hay temporadas creadas todavía.
+
+ +
+
+
+
+{% endblock content %} diff --git a/Plataform_Web/templates/gestion_usuarios.html b/Plataform_Web/templates/gestion_usuarios.html new file mode 100644 index 0000000..312d10e --- /dev/null +++ b/Plataform_Web/templates/gestion_usuarios.html @@ -0,0 +1,125 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Gestión de Usuarios | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+

Gestión de Usuarios

+

Administra las cuentas del sistema: edita o elimina usuarios.

+
+
+
+ +
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {% if filtro_rol or filtro_especialidad or filtro_apellido %} + × + {% endif %} +
+
+ +
+ + + + + + + + + + + + + + {% for usuario in usuarios %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
UsuarioNombreApellidosEmailÁrea TécnicaRolAcciones
{{ usuario.username }}{{ usuario.first_name }}{{ usuario.last_name }}{{ usuario.email }}{{ usuario.get_especialidad_display|default:"—" }}{{ usuario.get_rol_display }} + Editar + +
No se han encontrado usuarios con los filtros seleccionados.
+
+ +
+
+
+
+{% endblock content %} diff --git a/Plataform_Web/templates/index.html b/Plataform_Web/templates/index.html new file mode 100644 index 0000000..a3a931a --- /dev/null +++ b/Plataform_Web/templates/index.html @@ -0,0 +1,115 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Inicio | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+
+

Bienvenido, {{ user.first_name }} {{ user.last_name }}

+

Panel de control de la plataforma de gestión interna.

+
+
+ + Temporada Activa: {% if temporada_actual %}{{ temporada_actual.nombre }}{% else %}Ninguna activa{% endif %} + +
+
+
+
+ +
+ +
+
+ Monoplaza Formula Gades +
+
+ +
+
+ + {% if user.rol != 'directiva' %} + +
+
+
+
Mi Área Técnica
+

Acceso a documentación, planos y buzón de compras de tu departamento.

+ Acceder a mi Área +
+
+
+ +
+
+
+
Pruebas y Telemetría
+

Carga de archivos de registros CSV y procesamiento analítico de telemetría.

+ Ver Telemetría +
+
+
+ +
+
+
+
Miembros del Equipo
+

Directorio completo de integrantes, correos corporativos y roles.

+ Ver Directorio +
+
+
+ + {% else %} + +
+
+
+
Pruebas y Telemetría
+

Carga de archivos de registros CSV y procesamiento analítico de telemetría.

+ Ver Telemetría +
+
+
+ +
+
+
+
Miembros del Equipo
+

Directorio completo de integrantes, correos corporativos y roles.

+ Ver Directorio +
+
+
+ +
+
+
+
Contabilidad Global
+

Supervisión del presupuesto anual y control del buzón financiero de facturas.

+ Gestionar Finanzas +
+
+
+ +
+
+
+
Administración del Sistema
+

Módulo avanzado de configuración de permisos, usuarios y temporadas.

+ Panel de Control +
+
+
+ + {% endif %} + +
+
+ +
+{% endblock content %} \ No newline at end of file diff --git a/Plataform_Web/templates/listado_miembros.html b/Plataform_Web/templates/listado_miembros.html new file mode 100644 index 0000000..5746c3a --- /dev/null +++ b/Plataform_Web/templates/listado_miembros.html @@ -0,0 +1,90 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Miembros | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+

Miembros del Equipo

+

Directorio completo de integrantes de Formula Gades.

+
+
+
+ +
+
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {% if filtro_rol or filtro_especialidad or filtro_apellido %} + × + {% endif %} +
+
+ +
+ + + + + + + + + + + + + {% for miembro in miembros %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
UsuarioNombreApellidosEmailÁrea TécnicaRol
{{ miembro.username }}{{ miembro.first_name }}{{ miembro.last_name }}{{ miembro.email }}{{ miembro.get_especialidad_display|default:"—" }}{{ miembro.get_rol_display }}
No se han encontrado miembros con los filtros seleccionados.
+
+ +
+
+
+
+{% endblock content %} diff --git a/Plataform_Web/templates/listado_pruebas.html b/Plataform_Web/templates/listado_pruebas.html new file mode 100644 index 0000000..7741beb --- /dev/null +++ b/Plataform_Web/templates/listado_pruebas.html @@ -0,0 +1,91 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Pruebas | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+
+

Pruebas

+

Tests realizados durante la temporada.

+
+ {% if user.rol == 'directiva' or user.rol == 'jefe_area' %} + + Nuevo Test + {% endif %} +
+
+
+ +
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + + {% for prueba in pruebas %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
NombreFecha inicioFecha finCategoríaRealizado por
{{ prueba.nombre }}{{ prueba.fecha_inicio|date:"d/m/Y" }}{{ prueba.fecha_fin|date:"d/m/Y" }}{{ prueba.get_categoria_display }}{{ prueba.realizado_por.first_name|default:"—" }}
No hay tests todavía.
+
+ +
+
+
+
+{% endblock content %} diff --git a/Plataform_Web/templates/login.html b/Plataform_Web/templates/login.html new file mode 100644 index 0000000..11950dd --- /dev/null +++ b/Plataform_Web/templates/login.html @@ -0,0 +1,97 @@ +{% load static %} + + + + + + Iniciar Sesión | Formula Gades + + + + + + + + + + + + diff --git a/Plataform_Web/templates/mi_perfil.html b/Plataform_Web/templates/mi_perfil.html new file mode 100644 index 0000000..2b73724 --- /dev/null +++ b/Plataform_Web/templates/mi_perfil.html @@ -0,0 +1,121 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Mi Perfil | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+

Mi Perfil

+

Consulta y edita tus datos personales.

+
+
+
+ +
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+
+
+
Datos Personales
+ +
+
+ + +
+
+ + +
+
+ +
+ {% csrf_token %} + +
+ + {{ perfil_form.username }} + {% for error in perfil_form.username.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+
+ + {{ perfil_form.first_name }} +
+
+ + {{ perfil_form.last_name }} +
+
+ +
+ + {{ perfil_form.email }} + {% for error in perfil_form.email.errors %} +
{{ error }}
+ {% endfor %} +
+ + +
+
+
+
+ +
+
+
+
Cambiar Contraseña
+ +
+ {% csrf_token %} + +
+ + + {% for error in password_form.old_password.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ + + {% for error in password_form.new_password1.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ + + {% for error in password_form.new_password2.errors %} +
{{ error }}
+ {% endfor %} +
+ + {% if password_form.non_field_errors %} +
{{ password_form.non_field_errors }}
+ {% endif %} + + +
+
+
+
+ +
+{% endblock content %} diff --git a/Plataform_Web/templates/patrocinios.html b/Plataform_Web/templates/patrocinios.html new file mode 100644 index 0000000..189321b --- /dev/null +++ b/Plataform_Web/templates/patrocinios.html @@ -0,0 +1,269 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Patrocinios | Gades Manager{% endblock title %} + +{% block content %} +
+
+
+
+

Patrocinios

+

Dossiers y gestión de patrocinadores del equipo.

+
+ +
+
+
+ +{% if messages %} +
+
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+
+{% endif %} + +
+ + {# ── Bloque 1: Dossiers PDF ── #} +
+
+
+
+ + + + +
+
Dossier de Patrocinio
+

Versión en español · Temporada 2025/2026

+ +
+
+
+ +
+
+
+
+ + + + +
+
Sponsorship Dossier
+

English version · Season 2025/2026

+
+ Open + Download +
+
+
+
+ + {# ── Bloque 2: Patrocinios agrupados ── #} +
+ + {% if not temporada_actual %} +
No hay temporada activa. Activa una desde Gestión de Temporadas.
+ {% else %} + + {# Pendientes #} +
+
+ {{ pendientes.count }} +
Pendientes / En contacto
+
+
+
+ + + + + + + + + + {% if user.rol == 'directiva' %}{% endif %} + + + + {% for p in pendientes %} + + + + + + + + {% if user.rol == 'directiva' %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
EmpresaTipoPersona contactoEmailPropuesto porFechaAcciones
{{ p.empresa }}{{ p.get_tipo_patrocinio_display }}{{ p.persona_contacto|default:"—" }}{{ p.email_contacto }}{{ p.contacto_equipo.get_full_name|default:p.contacto_equipo }}{{ p.fecha_contacto|date:"d/m/Y" }} +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+ Editar +
+
No hay patrocinios pendientes.
+
+
+
+ + {# Aceptados #} +
+
+ {{ aceptados.count }} +
Aceptados
+
+
+
+ + + + + + + + + + {% if user.rol == 'directiva' %}{% endif %} + + + + {% for p in aceptados %} + + + + + + + + {% if user.rol == 'directiva' %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
EmpresaTipoImportePersona contactoEmailFechaAcciones
{{ p.empresa }}{{ p.get_tipo_patrocinio_display }}{{ p.importe_economico|floatformat:2 }} €{{ p.persona_contacto|default:"—" }}{{ p.email_contacto }}{{ p.fecha_contacto|date:"d/m/Y" }} +
+
+ {% csrf_token %} + + +
+ Editar +
+
No hay patrocinios aceptados.
+
+
+
+ + {# Rechazados — colapsado por defecto #} +
+
+ {{ denegados.count }} +
Rechazados
+ +
+
+
+ + + + + + + + + {% if user.rol == 'directiva' %}{% endif %} + + + + {% for p in denegados %} + + + + + + + {% if user.rol == 'directiva' %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
EmpresaTipoPersona contactoEmailFechaAcciones
{{ p.empresa }}{{ p.get_tipo_patrocinio_display }}{{ p.persona_contacto|default:"—" }}{{ p.email_contacto }}{{ p.fecha_contacto|date:"d/m/Y" }} +
+
+ {% csrf_token %} + + +
+ Editar +
+
No hay patrocinios rechazados.
+
+
+
+ + {% endif %}{# end if temporada_actual #} +
+
+ +{# ── Modal Proponer Patrocinio ── #} + + +{% endblock content %} diff --git a/Plataform_Web/temporadas/__init__.py b/Plataform_Web/temporadas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/temporadas/admin.py b/Plataform_Web/temporadas/admin.py new file mode 100644 index 0000000..8420191 --- /dev/null +++ b/Plataform_Web/temporadas/admin.py @@ -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',) \ No newline at end of file diff --git a/Plataform_Web/temporadas/apps.py b/Plataform_Web/temporadas/apps.py new file mode 100644 index 0000000..1a7e5b5 --- /dev/null +++ b/Plataform_Web/temporadas/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TemporadasConfig(AppConfig): + name = 'temporadas' diff --git a/Plataform_Web/temporadas/forms.py b/Plataform_Web/temporadas/forms.py new file mode 100644 index 0000000..4788a5d --- /dev/null +++ b/Plataform_Web/temporadas/forms.py @@ -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(), + } diff --git a/Plataform_Web/temporadas/migrations/0001_initial.py b/Plataform_Web/temporadas/migrations/0001_initial.py new file mode 100644 index 0000000..ba154fb --- /dev/null +++ b/Plataform_Web/temporadas/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/Plataform_Web/temporadas/migrations/0002_temporada_miembros.py b/Plataform_Web/temporadas/migrations/0002_temporada_miembros.py new file mode 100644 index 0000000..b32fb3d --- /dev/null +++ b/Plataform_Web/temporadas/migrations/0002_temporada_miembros.py @@ -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'), + ), + ] diff --git a/Plataform_Web/temporadas/migrations/__init__.py b/Plataform_Web/temporadas/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/temporadas/models.py b/Plataform_Web/temporadas/models.py new file mode 100644 index 0000000..7d0e19c --- /dev/null +++ b/Plataform_Web/temporadas/models.py @@ -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) \ No newline at end of file diff --git a/Plataform_Web/temporadas/tests.py b/Plataform_Web/temporadas/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Plataform_Web/temporadas/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Plataform_Web/temporadas/views.py b/Plataform_Web/temporadas/views.py new file mode 100644 index 0000000..1db3713 --- /dev/null +++ b/Plataform_Web/temporadas/views.py @@ -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') diff --git a/Plataform_Web/users/__init__.py b/Plataform_Web/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/users/admin.py b/Plataform_Web/users/admin.py new file mode 100644 index 0000000..81671c8 --- /dev/null +++ b/Plataform_Web/users/admin.py @@ -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'), + }), + ) \ No newline at end of file diff --git a/Plataform_Web/users/apps.py b/Plataform_Web/users/apps.py new file mode 100644 index 0000000..4ce1fab --- /dev/null +++ b/Plataform_Web/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' diff --git a/Plataform_Web/users/decorators.py b/Plataform_Web/users/decorators.py new file mode 100644 index 0000000..28cd93a --- /dev/null +++ b/Plataform_Web/users/decorators.py @@ -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 diff --git a/Plataform_Web/users/forms.py b/Plataform_Web/users/forms.py new file mode 100644 index 0000000..0d096c3 --- /dev/null +++ b/Plataform_Web/users/forms.py @@ -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', + } diff --git a/Plataform_Web/users/migrations/0001_initial.py b/Plataform_Web/users/migrations/0001_initial.py new file mode 100644 index 0000000..fedfc57 --- /dev/null +++ b/Plataform_Web/users/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/Plataform_Web/users/migrations/0002_alter_customuser_especialidad_alter_customuser_rol.py b/Plataform_Web/users/migrations/0002_alter_customuser_especialidad_alter_customuser_rol.py new file mode 100644 index 0000000..9c991e1 --- /dev/null +++ b/Plataform_Web/users/migrations/0002_alter_customuser_especialidad_alter_customuser_rol.py @@ -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'), + ), + ] diff --git a/Plataform_Web/users/migrations/0003_alter_customuser_especialidad_alter_customuser_rol.py b/Plataform_Web/users/migrations/0003_alter_customuser_especialidad_alter_customuser_rol.py new file mode 100644 index 0000000..68dc9a3 --- /dev/null +++ b/Plataform_Web/users/migrations/0003_alter_customuser_especialidad_alter_customuser_rol.py @@ -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'), + ), + ] diff --git a/Plataform_Web/users/migrations/__init__.py b/Plataform_Web/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Plataform_Web/users/models.py b/Plataform_Web/users/models.py new file mode 100644 index 0000000..2661b57 --- /dev/null +++ b/Plataform_Web/users/models.py @@ -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()}" \ No newline at end of file diff --git a/Plataform_Web/users/tests.py b/Plataform_Web/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Plataform_Web/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Plataform_Web/users/views.py b/Plataform_Web/users/views.py new file mode 100644 index 0000000..4a7fc52 --- /dev/null +++ b/Plataform_Web/users/views.py @@ -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') diff --git a/include/common/common_libraries.hpp b/include/common/common_libraries.hpp deleted file mode 100644 index f03e7c4..0000000 --- a/include/common/common_libraries.hpp +++ /dev/null @@ -1,9 +0,0 @@ -#ifndef COMMON_LIBRARIES_HPP -#define COMMON_LIBRARIES_HPP - -#include -#include "time.h" -#include -#include - -#endif \ No newline at end of file diff --git a/include/common/telemetry_status.hpp b/include/common/telemetry_status.hpp deleted file mode 100644 index 74e2971..0000000 --- a/include/common/telemetry_status.hpp +++ /dev/null @@ -1,9 +0,0 @@ -#ifndef TELEMETRY_STATUS_HPP -#define TELEMETRY_STATUS_HPP - -enum class TelemetryStatus { - CONNECTED, -}; - - -#endif \ No newline at end of file diff --git a/include/data_processor.hpp b/include/data_processor.hpp deleted file mode 100644 index 0523555..0000000 --- a/include/data_processor.hpp +++ /dev/null @@ -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 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 \ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index 886c04d..0000000 --- a/index.html +++ /dev/null @@ -1,254 +0,0 @@ - - - - - Telemetría IoT - Sesiones - - - - -
-

🔬 Telemetría IoT

-
❌ Sesión Inactiva
-
--- kg
-
Socket: desconocido
- -
-
Ventanas recibidas
0
-
Muestras totales
0
-
Última actualización
---
-
- -
- - - -
- -
-
- - - - - - - diff --git a/src/data_processor.cpp b/src/data_processor.cpp deleted file mode 100644 index fcf6e74..0000000 --- a/src/data_processor.cpp +++ /dev/null @@ -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); -} \ No newline at end of file