Edoruin commited on
Commit
6aee8f5
·
1 Parent(s): 17712e3

sustitude flet by flask

Browse files
Dockerfile CHANGED
@@ -1,20 +1,23 @@
1
  FROM python:3.9-slim
2
 
3
- # Instalar dependencias de sistema para Flet
4
  RUN apt-get update && apt-get install -y \
5
  git \
6
  && rm -rf /var/lib/apt/lists/*
7
 
8
  WORKDIR /app
9
 
 
10
  COPY app/requirements.txt .
11
  RUN pip install --no-cache-dir -r requirements.txt
12
 
 
13
  COPY app/ .
14
- COPY assets/ ./assets/
15
 
16
- # Exponer el puerto que definimos en main.py
17
- EXPOSE 8080
18
 
19
- # Comando para ejecutar la app en modo PWA
20
- CMD ["python", "main.py", "--port", "7860", "--host", "0.0.0.0"]
 
 
 
1
  FROM python:3.9-slim
2
 
3
+ # Instalar dependencias de sistema
4
  RUN apt-get update && apt-get install -y \
5
  git \
6
  && rm -rf /var/lib/apt/lists/*
7
 
8
  WORKDIR /app
9
 
10
+ # Copiar requerimientos e instalar
11
  COPY app/requirements.txt .
12
  RUN pip install --no-cache-dir -r requirements.txt
13
 
14
+ # Copiar la aplicación
15
  COPY app/ .
 
16
 
17
+ # Exponer el puerto de Hugging Face
18
+ EXPOSE 7860
19
 
20
+ # Ejecutar con Gunicorn y worker de eventlet para soportar Socket.IO
21
+ # Usamos -w 1 porque el bot de Telegram corre en un hilo dentro del proceso
22
+ # y no queremos múltiples instancias del bot (409 Conflict)
23
+ CMD ["gunicorn", "--worker-class", "eventlet", "-w", "1", "-b", "0.0.0.0:7860", "main:app"]
app/main.py CHANGED
@@ -1,16 +1,20 @@
1
- import flet as ft
2
- import gitlab
3
  import os
 
 
 
 
4
  import base64
5
  import requests
6
  import datetime
7
- import socket
8
- import json
9
- import threading
10
- import uuid
11
  import telebot
12
- import time
13
  from telebot import types
 
 
 
 
14
 
15
  # --- CLASE PARA PERSISTENCIA ---
16
  class LoanManager:
@@ -48,6 +52,11 @@ class LoanManager:
48
 
49
  loan_mgr = LoanManager()
50
 
 
 
 
 
 
51
  # --- CONFIGURACIÓN DE VARIABLES GLOBALES ---
52
  TG_TOKEN = os.getenv("TELEGRAM_TOKEN")
53
  TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
@@ -55,26 +64,24 @@ try:
55
  if TG_CHAT_ID: TG_CHAT_ID = int(TG_CHAT_ID)
56
  except:
57
  pass
58
- GOOGLE_PROXY_URL = "https://script.google.com/macros/s/AKfycbz7z1Jb0vsur42GmmqrL3PVXeRkN2WxSojFDIleEDoLOg6MnrmJjb_uuPcQ15CTwyzD/exec"
59
 
60
- # --- CONFIGURACIÓN DE PROXY PARA TODO EL BOT ---
 
 
61
  if TG_TOKEN and GOOGLE_PROXY_URL:
62
  base_url = GOOGLE_PROXY_URL.split('?')[0]
63
  telebot.apihelper.API_URL = base_url + "?path={1}&token={0}"
64
- telebot.apihelper.CONNECT_TIMEOUT = 60
65
- telebot.apihelper.READ_TIMEOUT = 60
66
 
67
- # Inicializar bot (GLOBAL)
68
  bot = telebot.TeleBot(TG_TOKEN) if TG_TOKEN else None
69
 
70
- # --- AYUDANTE PARA ESCAPAR MARKDOWN (GLOBAL) ---
71
  def escape_md(text):
72
  if not text: return ""
73
  for char in ['_', '*', '[', '`']:
74
  text = text.replace(char, f"\\{char}")
75
  return text
76
 
77
- # --- BOT HANDLERS (GLOBALS) ---
78
  if bot:
79
  @bot.callback_query_handler(func=lambda call: True)
80
  def handle_query(call):
@@ -85,15 +92,14 @@ if bot:
85
  bot.answer_callback_query(call.id, "Préstamo Aceptado")
86
  nuevo_texto = f"✅ *ACEPTADO*\n{escape_md(call.message.text)}"
87
  bot.edit_message_text(nuevo_texto, call.message.chat.id, call.message.message_id, parse_mode="Markdown")
88
- # Notificar a todas las sesiones activas vía PubSub
89
- bot_broadcast({"text": f"Préstamo {loan_id[:8]} ACEPTADO", "color": ft.Colors.GREEN})
90
  elif call.data.startswith("decline_"):
91
  loan_id = call.data.replace("decline_", "")
92
  if loan_mgr.update_status(loan_id, "DECLINED"):
93
  bot.answer_callback_query(call.id, "Préstamo Declinado")
94
  nuevo_texto = f"❌ *DECLINADO*\n{escape_md(call.message.text)}"
95
  bot.edit_message_text(nuevo_texto, call.message.chat.id, call.message.message_id, parse_mode="Markdown")
96
- bot_broadcast({"text": f"Préstamo {loan_id[:8]} DECLINADO", "color": "red"})
97
  except Exception as e:
98
  print(f"Callback Error: {e}")
99
 
@@ -109,7 +115,7 @@ if bot:
109
  if loan_mgr.update_status(loan_id, status):
110
  emoji = "✅" if status == "ACCEPTED" else "❌"
111
  bot.reply_to(message, f"{emoji} Préstamo {loan_id} actualizado a {status}")
112
- bot_broadcast({"text": f"Préstamo {loan_id[:8]} {status}", "color": "green" if status == "ACCEPTED" else "red"})
113
  else:
114
  bot.reply_to(message, "ID de préstamo no encontrado.")
115
  except Exception as e:
@@ -122,388 +128,129 @@ if bot:
122
  except Exception as e:
123
  print(f"GetID Error: {e}")
124
 
125
- # --- BROADCAST PARA NOTIFICACIONES (GLOBAL) ---
126
- registered_pages = []
127
- def bot_broadcast(msg):
128
- for p in registered_pages:
129
- try:
130
- p.pubsub.send_all(msg)
131
- except: pass
132
-
133
  def start_bot_thread():
134
  if bot:
135
- # Retraso inicial para Hugging Face: permite que las instancias viejas se apaguen
136
- # y suelten el polling antes de que esta nueva intente conectar (Evita 409 Conflict)
137
- print("INFO: Esperando 15s para estabilizar conexión con Telegram...")
138
- time.sleep(15)
139
-
140
  try: bot.delete_webhook()
141
  except: pass
142
-
143
  while True:
144
  try:
145
  print("DEBUG: Iniciando polling de Telegram...")
146
  bot.infinity_polling(timeout=20, long_polling_timeout=10)
147
  except Exception as e:
148
- print(f"Error DNS/Red/Conflicto: {e}. Reintento en 30s...")
149
  time.sleep(30)
150
 
151
  if bot:
152
  threading.Thread(target=start_bot_thread, daemon=True).start()
153
 
154
- def main(page: ft.Page):
155
- page.title = "makerspacepage"
156
- page.favicon = "favicon.png"
157
- # Solicitar permiso para notificaciones de navegador/sistema (vía JS)
158
- # Solicitar permiso para notificaciones de navegador/sistema (vía JS seguro)
159
- def pedir_permiso_notif(e=None):
160
- if hasattr(page, "run_javascript"):
161
- page.run_javascript("""
162
- if ("Notification" in window) {
163
- Notification.requestPermission();
164
- }
165
- """)
166
- else:
167
- mostrar_notificacion("⚠️ Tu navegador no soporta alertas nativas")
168
-
169
- def abrir_link_externo(url):
170
- if hasattr(page, "run_javascript"):
171
- # Script para forzar apertura en navegador externo (mejor para PWA)
172
- page.run_javascript(f"window.open('{url}', '_blank');")
173
- else:
174
- page.launch_url(url)
175
-
176
- def mostrar_notificacion(texto, color="blue"):
177
- # Notificación en la app (SnackBar)
178
- snack = ft.SnackBar(ft.Text(texto), bgcolor=color)
179
- page.overlay.append(snack)
180
- snack.open = True
181
-
182
- # Notificación de Sistema/Navegador (vía JS seguro)
183
- if hasattr(page, "run_javascript"):
184
- page.run_javascript(f"""
185
- if ("Notification" in window && Notification.permission === "granted") {{
186
- new Notification("MAKER STATION", {{
187
- body: "{texto}",
188
- icon: "/icon192x192.png"
189
- }});
190
- }}
191
- """)
192
-
193
- page.update()
194
-
195
- def enviar_telegram_con_botones(loan_id, mensaje):
196
- if not bot or not TG_CHAT_ID:
197
- print("Error: Bot o Chat ID no configurados")
198
- return "ERR_CONFIG"
199
-
200
- markup = types.InlineKeyboardMarkup()
201
- btn_aceptar = types.InlineKeyboardButton("✅ Aceptar", callback_data=f"accept_{loan_id}")
202
- btn_declinar = types.InlineKeyboardButton("❌ Declinar", callback_data=f"decline_{loan_id}")
203
- markup.add(btn_aceptar, btn_declinar)
204
-
205
  try:
206
- print(f"DEBUG: Enviando mensaje a Telegram (Chat: {TG_CHAT_ID}). Len: {len(mensaje)}")
207
- bot.send_message(TG_CHAT_ID, mensaje, reply_markup=markup, parse_mode="Markdown")
208
- return "OK"
 
 
 
209
  except Exception as e:
210
- error_msg = str(e)
211
- print(f"DEBUG Error Telegram: {error_msg}")
212
- if "Max retries" in error_msg:
213
- return f"ERR_DNS: {error_msg[:40]}"
214
- return f"ERR_CONN: {error_msg[:100]}"
215
-
216
- # Registrar esta página para recibir notificaciones del bot
217
- registered_pages.append(page)
218
- def on_broadcast(msg):
219
- mostrar_notificacion(msg["text"], msg["color"])
220
- # Si estamos en /prestamos, forzar refresco (esto es un poco complejo sin una ref global)
221
- # Por ahora solo la notificación
222
- page.pubsub.subscribe(on_broadcast)
223
 
224
- # --- CONFIGURACIÓN DE VARIABLES LOCALES ---
 
 
 
 
225
  GITLAB_URL = "https://gitlab.com"
226
  GIT_TOKEN = os.getenv("GITLAB_TOKEN")
227
  GIT_GROUP = os.getenv("GITLAB_GROUP_ID")
 
 
 
 
 
 
 
 
228
 
229
- def mostrar_factura(i):
230
- status_color = {
231
- "PENDING": ft.Colors.ORANGE,
232
- "ACCEPTED": ft.Colors.GREEN,
233
- "DECLINED": ft.Colors.RED
234
- }.get(i.get('status_loan', 'PENDING'), ft.Colors.GREY)
235
-
236
- dialog = ft.AlertDialog(
237
- title=ft.Text("RECIBO DE PRÉSTAMO", weight="bold"),
238
- content=ft.Column([
239
- ft.Text(f"ESTADO: {i.get('status_loan', 'PENDING')}", color=status_color, weight="bold"),
240
- ft.Divider(),
241
- ft.Text(f"SOLICITANTE: {i.get('Solicitante', i.get('solicitante', '---'))}", size=16),
242
- ft.Text(f"ITEM: {i['item']}", weight="bold"),
243
- ft.Text(f"CANTIDAD: {i['cantidad']}"),
244
- ft.Divider(),
245
- ft.Text(f"SALIDA: {i['hora']}"),
246
- ft.Text(f"RETORNO EST.: {i['devolucion']}"),
247
- ft.Text(f"ID: {i['id']}", size=10, color="grey"),
248
- ], tight=True, spacing=5),
249
- actions=[ft.TextButton("Cerrar", on_click=lambda _: page.close_dialog())]
250
- )
251
- page.dialog = dialog
252
- dialog.open = True
253
- page.update()
254
-
255
- def route_change(route):
256
- page.views.clear()
257
-
258
- if page.route == "/":
259
- page.views.append(
260
- ft.View("/", [
261
- ft.AppBar(title=ft.Text("MAKERSPACE"), bgcolor="#1a1c1e"),
262
- ft.Container(
263
- content=ft.Column([
264
- ft.Text("PANEL DE CONTROL", size=24, weight="bold", text_align=ft.TextAlign.CENTER),
265
- ft.ResponsiveRow([
266
- ft.ElevatedButton(
267
- "GITLAB REPOS",
268
- icon=ft.Icons.FOLDER,
269
- on_click=lambda _: page.go("/repos"),
270
- height=80,
271
- col={"sm": 12, "md": 6}
272
- ),
273
- ft.ElevatedButton(
274
- "SISTEMA PRÉSTAMOS",
275
- icon=ft.Icons.BUILD,
276
- on_click=lambda _: page.go("/prestamos"),
277
- height=80,
278
- bgcolor=ft.Colors.BLUE_800,
279
- col={"sm": 12, "md": 6}
280
- ),
281
- ], spacing=20, alignment=ft.MainAxisAlignment.CENTER),
282
- ], spacing=30, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
283
- padding=20,
284
- alignment=ft.alignment.top_center,
285
- expand=True
286
- )
287
- ])
288
- )
289
 
290
- elif page.route == "/prestamos":
291
- # --- CONFIGURACIÓN DE TIEMPO (8:00 - 15:00) ---
292
- opciones_hora = [ft.dropdown.Option(f"{h:02d}:00") for h in range(8, 16)]
293
-
294
- nombre = ft.TextField(
295
- label="Nombre del solicitante",
296
- prefix_icon=ft.Icons.PERSON,
297
- text_size=18,
298
- border_color=ft.Colors.BLUE_400,
299
- capitalization=ft.TextCapitalization.WORDS
300
- )
301
- h_ext = ft.Dropdown(label="Hora Salida", options=opciones_hora, value="08:00", expand=True)
302
- h_dev = ft.Dropdown(label="Devolución", options=opciones_hora, value="15:00", expand=True)
303
-
304
- st_txt = ft.Text("", weight="bold")
305
- lista_historial = ft.ListView(expand=True, spacing=10)
306
 
307
- # --- GESTIÓN DE FILAS DINÁMICAS ---
308
- container_items = ft.Column(spacing=10)
 
 
 
 
 
309
 
310
- def crear_fila_item():
311
- return ft.ResponsiveRow([
312
- ft.Dropdown(
313
- label="Categoría",
314
- options=[
315
- ft.dropdown.Option("Herramientas"),
316
- ft.dropdown.Option("Dispositivos Eléctricos")
317
- ],
318
- value="Herramientas",
319
- col={"sm": 12, "md": 3}
320
- ),
321
- ft.TextField(label="Descripción", col={"sm": 12, "md": 6}),
322
- ft.Row([
323
- ft.TextField(label="Cant.", value="1", width=70, text_align=ft.TextAlign.CENTER),
324
- ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_color="red", on_click=lambda e: eliminar_fila(e))
325
- ], col={"sm": 12, "md": 3}, alignment=ft.MainAxisAlignment.END)
326
- ], alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.CrossAxisAlignment.CENTER)
327
-
328
- def eliminar_fila(e):
329
- if len(container_items.controls) > 1:
330
- container_items.controls.remove(e.control.parent.parent)
331
- page.update()
332
-
333
- def agregar_fila(e):
334
- container_items.controls.append(crear_fila_item())
335
- page.update()
336
-
337
- container_items.controls.append(crear_fila_item())
338
-
339
- def crear_card(d):
340
- icon = ft.Icons.HOURGLASS_EMPTY
341
- color = ft.Colors.ORANGE
342
- if d.get('status_loan') == "ACCEPTED":
343
- icon = ft.Icons.CHECK_CIRCLE
344
- color = ft.Colors.GREEN
345
- elif d.get('status_loan') == "DECLINED":
346
- icon = ft.Icons.CANCEL
347
- color = ft.Colors.RED
348
-
349
- return ft.Container(
350
- content=ft.ListTile(
351
- title=ft.Text(d.get('Solicitante', d.get('solicitante', '---')).upper(), weight="bold", size=16),
352
- subtitle=ft.Text(f"{d['item']} (x{d['cantidad']})", size=13, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS),
353
- trailing=ft.Icon(icon, color=color, size=30),
354
- is_three_line=True,
355
- on_click=lambda _: mostrar_factura(d)
356
- ),
357
- bgcolor="#2d3238",
358
- border_radius=12,
359
- padding=ft.padding.symmetric(vertical=5, horizontal=0)
360
- )
361
-
362
- def refactor_list():
363
- lista_historial.controls.clear()
364
- for d in reversed(loan_mgr.get_all()):
365
- lista_historial.controls.append(crear_card(d))
366
- page.update()
367
-
368
- refactor_list()
369
-
370
- def registrar(e):
371
- if not nombre.value:
372
- st_txt.value = "⚠️ Nombre de solicitante requerido"; st_txt.color="red"; page.update(); return
373
-
374
- items_solicitados = []
375
- for fila in container_items.controls:
376
- cat = fila.controls[0].value
377
- txt = fila.controls[1].value
378
- ct = fila.controls[2].controls[0].value
379
- if txt:
380
- items_solicitados.append(f"• [{cat}] {txt} (x{ct})")
381
-
382
- if not items_solicitados:
383
- st_txt.value = "⚠️ Agrega al menos una herramienta"; st_txt.color="red"; page.update(); return
384
-
385
- loan_id = str(uuid.uuid4())
386
- lista_items_str = "\n".join(items_solicitados)
387
-
388
- mensaje_tg = (
389
- "🛠 *NUEVA SOLICITUD DE PRÉSTAMO*\n"
390
- f"👤 *Solicitante:* {escape_md(nombre.value)}\n"
391
- f" *Horario:* {h_ext.value} a {h_dev.value}\n"
392
- f" *Herramientas:*\n{escape_md(lista_items_str)}"
393
- )
394
-
395
- res_tg = enviar_telegram_con_botones(loan_id, mensaje_tg)
396
-
397
- nuevo = {
398
- "id": loan_id,
399
- "Solicitante": nombre.value,
400
- "item": lista_items_str,
401
- "cantidad": "Multiple",
402
- "hora": h_ext.value,
403
- "devolucion": h_dev.value,
404
- "status_loan": "PENDING",
405
- "status_tg": res_tg
406
- }
407
-
408
- loan_mgr.add_loan(nuevo)
409
- refactor_list()
410
-
411
- st_txt.value = "✅ Solicitud Enviada" if res_tg == "OK" else f"⚠️ Error Telegram: {res_tg}"
412
- st_txt.color = "green" if res_tg == "OK" else "orange"
413
- nombre.value = ""
414
- container_items.controls.clear()
415
- container_items.controls.append(crear_fila_item())
416
- page.update()
417
-
418
- page.views.append(
419
- ft.View("/prestamos", [
420
- ft.AppBar(title=ft.Text("Préstamos"), bgcolor="#1a1c1e"),
421
- ft.Container(
422
- content=ft.Column([
423
- ft.Row([
424
- ft.Text("SOLICITAR PRÉSTAMO", size=18, weight="bold"),
425
- ft.ElevatedButton("🔔 ALERTAS", icon=ft.Icons.NOTIFICATIONS_ACTIVE,
426
- on_click=pedir_permiso_notif, bgcolor=ft.Colors.BLUE_GREY_900)
427
- ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
428
-
429
- nombre,
430
- ft.ResponsiveRow([
431
- ft.Container(h_ext, col={"sm": 6, "md": 6}),
432
- ft.Container(h_dev, col={"sm": 6, "md": 6}),
433
- ], spacing=10),
434
-
435
- ft.Divider(),
436
- ft.Row([
437
- ft.Text("LISTA DE HERRAMIENTAS", size=14, weight="bold"),
438
- ft.IconButton(ft.Icons.ADD_CIRCLE, icon_color="green", on_click=agregar_fila)
439
- ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
440
-
441
- container_items,
442
-
443
- ft.ElevatedButton("ENVIAR SOLICITUD TOTAL", icon=ft.Icons.SEND, on_click=registrar,
444
- bgcolor=ft.Colors.GREEN_800, color="white", height=50, width=float("inf")),
445
-
446
- st_txt,
447
- ft.Divider(),
448
- ft.Text("HISTORIAL RECIENTE", size=20, weight="bold"),
449
- ft.Container(lista_historial, expand=True),
450
- ft.TextButton("Volver al Inicio", on_click=lambda _: page.go("/"), icon=ft.Icons.ARROW_BACK)
451
- ], spacing=20, scroll=ft.ScrollMode.ADAPTIVE),
452
- padding=20,
453
- # Para compatibilidad con versiones antiguas, usamos width dinámico o restringido
454
- width=800 if page.width > 800 else None,
455
- alignment=ft.alignment.top_center,
456
- expand=True
457
- )
458
- ])
459
- )
460
-
461
- elif page.route == "/repos":
462
- repo_list = ft.ListView(expand=True, spacing=10)
463
- page.views.append(ft.View("/repos", [ft.AppBar(title=ft.Text("Proyectos")), repo_list, ft.TextButton("Volver", on_click=lambda _: page.go("/"))]))
464
- try:
465
- gl = gitlab.Gitlab(GITLAB_URL, private_token=GIT_TOKEN)
466
- for p in gl.groups.get(GIT_GROUP).projects.list(all=True):
467
- repo_list.controls.append(ft.ListTile(title=ft.Text(p.name), on_click=lambda _, pid=p.id, pname=p.name: page.go(f"/ver/{pid}/{pname}")))
468
- page.update()
469
- except: pass
470
-
471
- elif page.route.startswith("/ver/"):
472
- parts = page.route.split("/")
473
- pid, pname = parts[2], parts[3]
474
- c_area = ft.Column(scroll=ft.ScrollMode.ADAPTIVE, expand=True)
475
- page.views.append(ft.View(f"/ver/{pid}", [ft.AppBar(title=ft.Text(pname)), c_area, ft.TextButton("Volver", on_click=lambda _: page.go("/repos"))]))
476
- try:
477
- gl = gitlab.Gitlab(GITLAB_URL, private_token=GIT_TOKEN)
478
- project = gl.projects.get(pid)
479
- readme_text = "README.md no encontrado."
480
- for branch in ["main", "master"]:
481
- try:
482
- f = project.files.get(file_path='README.md', ref=branch)
483
- readme_text = base64.b64decode(f.content).decode("utf-8")
484
- break
485
- except: continue
486
- c_area.controls.append(ft.Markdown(readme_text, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB))
487
- c_area.controls.append(ft.Divider())
488
- c_area.controls.append(ft.Row([
489
- ft.ElevatedButton("ZIP", icon=ft.Icons.DOWNLOAD, on_click=lambda _: abrir_link_externo(f"{GITLAB_URL}/api/v4/projects/{pid}/repository/archive.zip?private_token={GIT_TOKEN}")),
490
- ft.OutlinedButton("WEB", icon=ft.Icons.OPEN_IN_NEW, on_click=lambda _: abrir_link_externo(project.web_url))
491
- ], alignment=ft.MainAxisAlignment.CENTER))
492
- page.update()
493
- except: pass
494
- page.update()
495
-
496
- page.on_route_change = route_change
497
- page.go("/")
498
-
499
- if __name__ == "__main__":
500
  port = int(os.getenv("PORT", 7860))
501
-
502
- # Buscar la carpeta de assets (local: ../assets, docker: ./assets)
503
- current_dir = os.path.dirname(os.path.abspath(__file__))
504
- assets_path = os.path.join(current_dir, "assets")
505
- if not os.path.exists(assets_path):
506
- assets_path = os.path.join(current_dir, "../assets")
507
-
508
- print(f"DEBUG: assets_dir set to: {assets_path}")
509
- ft.app(target=main, view=ft.AppView.WEB_BROWSER, host="0.0.0.0", port=port, assets_dir=assets_path)
 
 
 
1
  import os
2
+ import json
3
+ import uuid
4
+ import threading
5
+ import time
6
  import base64
7
  import requests
8
  import datetime
9
+ from flask import Flask, render_template, request, jsonify, redirect, url_for
10
+ from flask_socketio import SocketIO, emit
11
+ import gitlab
 
12
  import telebot
 
13
  from telebot import types
14
+ import markdown
15
+ from dotenv import load_dotenv
16
+
17
+ load_dotenv()
18
 
19
  # --- CLASE PARA PERSISTENCIA ---
20
  class LoanManager:
 
52
 
53
  loan_mgr = LoanManager()
54
 
55
+ # --- APP CONFIG ---
56
+ app = Flask(__name__)
57
+ app.config['SECRET_KEY'] = 'maker-secret-key'
58
+ socketio = SocketIO(app, cors_allowed_origins="*")
59
+
60
  # --- CONFIGURACIÓN DE VARIABLES GLOBALES ---
61
  TG_TOKEN = os.getenv("TELEGRAM_TOKEN")
62
  TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
 
64
  if TG_CHAT_ID: TG_CHAT_ID = int(TG_CHAT_ID)
65
  except:
66
  pass
 
67
 
68
+ GOOGLE_PROXY_URL = os.getenv("GOOGLE_PROXY_URL") # Can be empty
69
+
70
+ # --- CONFIGURACIÓN DE PROXY PARA EL BOT ---
71
  if TG_TOKEN and GOOGLE_PROXY_URL:
72
  base_url = GOOGLE_PROXY_URL.split('?')[0]
73
  telebot.apihelper.API_URL = base_url + "?path={1}&token={0}"
 
 
74
 
75
+ # Inicializar bot
76
  bot = telebot.TeleBot(TG_TOKEN) if TG_TOKEN else None
77
 
 
78
  def escape_md(text):
79
  if not text: return ""
80
  for char in ['_', '*', '[', '`']:
81
  text = text.replace(char, f"\\{char}")
82
  return text
83
 
84
+ # --- BOT HANDLERS ---
85
  if bot:
86
  @bot.callback_query_handler(func=lambda call: True)
87
  def handle_query(call):
 
92
  bot.answer_callback_query(call.id, "Préstamo Aceptado")
93
  nuevo_texto = f"✅ *ACEPTADO*\n{escape_md(call.message.text)}"
94
  bot.edit_message_text(nuevo_texto, call.message.chat.id, call.message.message_id, parse_mode="Markdown")
95
+ socketio.emit('notification', {"text": f"Préstamo {loan_id[:8]} ACEPTADO", "color": "green"})
 
96
  elif call.data.startswith("decline_"):
97
  loan_id = call.data.replace("decline_", "")
98
  if loan_mgr.update_status(loan_id, "DECLINED"):
99
  bot.answer_callback_query(call.id, "Préstamo Declinado")
100
  nuevo_texto = f"❌ *DECLINADO*\n{escape_md(call.message.text)}"
101
  bot.edit_message_text(nuevo_texto, call.message.chat.id, call.message.message_id, parse_mode="Markdown")
102
+ socketio.emit('notification', {"text": f"Préstamo {loan_id[:8]} DECLINADO", "color": "red"})
103
  except Exception as e:
104
  print(f"Callback Error: {e}")
105
 
 
115
  if loan_mgr.update_status(loan_id, status):
116
  emoji = "✅" if status == "ACCEPTED" else "❌"
117
  bot.reply_to(message, f"{emoji} Préstamo {loan_id} actualizado a {status}")
118
+ socketio.emit('notification', {"text": f"Préstamo {loan_id[:8]} {status}", "color": "green" if status == "ACCEPTED" else "red"})
119
  else:
120
  bot.reply_to(message, "ID de préstamo no encontrado.")
121
  except Exception as e:
 
128
  except Exception as e:
129
  print(f"GetID Error: {e}")
130
 
 
 
 
 
 
 
 
 
131
  def start_bot_thread():
132
  if bot:
133
+ print("INFO: Esperando 5s para estabilizar conexión con Telegram...")
134
+ time.sleep(5)
 
 
 
135
  try: bot.delete_webhook()
136
  except: pass
 
137
  while True:
138
  try:
139
  print("DEBUG: Iniciando polling de Telegram...")
140
  bot.infinity_polling(timeout=20, long_polling_timeout=10)
141
  except Exception as e:
142
+ print(f"Error Polling: {e}. Reintento en 30s...")
143
  time.sleep(30)
144
 
145
  if bot:
146
  threading.Thread(target=start_bot_thread, daemon=True).start()
147
 
148
+ # --- WEB ROUTES ---
149
+ @app.route('/')
150
+ def index():
151
+ return render_template('index.html', title="MAKER STATION")
152
+
153
+ @app.route('/prestamos')
154
+ def prestamos():
155
+ loans = loan_mgr.get_all()
156
+ return render_template('prestamos.html', title="Préstamos", loans=loans)
157
+
158
+ @app.route('/api/prestamo', methods=['POST'])
159
+ def api_prestamo():
160
+ data = request.json
161
+ solicitante = data.get('solicitante')
162
+ h_salida = data.get('hora_salida')
163
+ h_retorno = data.get('hora_retorno')
164
+ items = data.get('items', [])
165
+
166
+ if not solicitante or not items:
167
+ return jsonify({"status": "error", "message": "Datos incompletos"}), 400
168
+
169
+ loan_id = str(uuid.uuid4())
170
+ lista_items_str = "\n".join([f"• [{i['categoria']}] {i['descripcion']} (x{i['cantidad']})" for i in items])
171
+
172
+ mensaje_tg = (
173
+ "🛠 *NUEVA SOLICITUD DE PRÉSTAMO*\n"
174
+ f"👤 *Solicitante:* {escape_md(solicitante)}\n"
175
+ f" *Horario:* {h_salida} a {h_retorno}\n"
176
+ f" *Herramientas:*\n{escape_md(lista_items_str)}"
177
+ )
178
+
179
+ res_tg = "OK"
180
+ if bot and TG_CHAT_ID:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  try:
182
+ markup = types.InlineKeyboardMarkup()
183
+ markup.add(
184
+ types.InlineKeyboardButton("✅ Aceptar", callback_data=f"accept_{loan_id}"),
185
+ types.InlineKeyboardButton("❌ Declinar", callback_data=f"decline_{loan_id}")
186
+ )
187
+ bot.send_message(TG_CHAT_ID, mensaje_tg, reply_markup=markup, parse_mode="Markdown")
188
  except Exception as e:
189
+ res_tg = str(e)
190
+
191
+ nuevo = {
192
+ "id": loan_id,
193
+ "Solicitante": solicitante,
194
+ "item": lista_items_str,
195
+ "cantidad": "Multiple",
196
+ "hora": h_salida,
197
+ "devolucion": h_retorno,
198
+ "status_loan": "PENDING",
199
+ "status_tg": res_tg
200
+ }
 
201
 
202
+ loan_mgr.add_loan(nuevo)
203
+ return jsonify({"status": "success", "id": loan_id})
204
+
205
+ @app.route('/repos')
206
+ def repos():
207
  GITLAB_URL = "https://gitlab.com"
208
  GIT_TOKEN = os.getenv("GITLAB_TOKEN")
209
  GIT_GROUP = os.getenv("GITLAB_GROUP_ID")
210
+
211
+ projects = []
212
+ if GIT_TOKEN and GIT_GROUP:
213
+ try:
214
+ gl = gitlab.Gitlab(GIT_URL, private_token=GIT_TOKEN)
215
+ projects = gl.groups.get(GIT_GROUP).projects.list(all=True)
216
+ except: pass
217
+ return render_template('repos.html', title="Proyectos", projects=projects)
218
 
219
+ @app.route('/ver/<pid>/<pname>')
220
+ def ver_repo(pid, pname):
221
+ GIT_TOKEN = os.getenv("GITLAB_TOKEN")
222
+ GITLAB_URL = "https://gitlab.com"
223
+
224
+ readme_html = "<p>README.md no encontrado.</p>"
225
+ web_url = "#"
226
+ download_url = "#"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
+ if GIT_TOKEN:
229
+ try:
230
+ gl = gitlab.Gitlab(GITLAB_URL, private_token=GIT_TOKEN)
231
+ project = gl.projects.get(pid)
232
+ web_url = project.web_url
233
+ download_url = f"{GITLAB_URL}/api/v4/projects/{pid}/repository/archive.zip?private_token={GIT_TOKEN}"
 
 
 
 
 
 
 
 
 
 
234
 
235
+ readme_text = ""
236
+ for branch in ["main", "master"]:
237
+ try:
238
+ f = project.files.get(file_path='README.md', ref=branch)
239
+ readme_text = base64.b64decode(f.content).decode("utf-8")
240
+ break
241
+ except: continue
242
 
243
+ if readme_text:
244
+ readme_html = markdown.markdown(readme_text, extensions=['fenced_code', 'tables'])
245
+ except: pass
246
+
247
+ return render_template('ver_repo.html',
248
+ title=pname,
249
+ project_name=pname,
250
+ readme_html=readme_html,
251
+ web_url=web_url,
252
+ download_url=download_url)
253
+
254
+ if __name__ == '__main__':
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  port = int(os.getenv("PORT", 7860))
256
+ socketio.run(app, host="0.0.0.0", port=port, debug=True)
 
 
 
 
 
 
 
 
app/requirements.txt CHANGED
@@ -1,4 +1,9 @@
1
- flet
 
2
  python-gitlab
3
  requests
4
  pyTelegramBotAPI
 
 
 
 
 
1
+ flask
2
+ flask-socketio
3
  python-gitlab
4
  requests
5
  pyTelegramBotAPI
6
+ python-dotenv
7
+ gunicorn
8
+ eventlet
9
+ markdown
app/static/assets/apple-touch-icon.png ADDED

Git LFS Details

  • SHA256: 60eb09fa1009122be732e8169712282001be2ce9ddd60a136d4b7a51831e80c8
  • Pointer size: 130 Bytes
  • Size of remote file: 51.1 kB
app/static/assets/favicon.png ADDED

Git LFS Details

  • SHA256: de720c92c1dfe96bd6e23efbf521532a89f34fafb551c7143c3b63af1a9e0386
  • Pointer size: 130 Bytes
  • Size of remote file: 57 kB
app/static/assets/icon-512x512.png ADDED

Git LFS Details

  • SHA256: 6d17e3f65e86dcb35617a9c3fd0ac4ff308a62837c24cc5eada11cc86fc05f51
  • Pointer size: 131 Bytes
  • Size of remote file: 267 kB
app/static/assets/icon.png ADDED

Git LFS Details

  • SHA256: 6d17e3f65e86dcb35617a9c3fd0ac4ff308a62837c24cc5eada11cc86fc05f51
  • Pointer size: 131 Bytes
  • Size of remote file: 267 kB
app/static/assets/icon192x192.png ADDED

Git LFS Details

  • SHA256: de720c92c1dfe96bd6e23efbf521532a89f34fafb551c7143c3b63af1a9e0386
  • Pointer size: 130 Bytes
  • Size of remote file: 57 kB
app/static/assets/splash.png ADDED

Git LFS Details

  • SHA256: 6d17e3f65e86dcb35617a9c3fd0ac4ff308a62837c24cc5eada11cc86fc05f51
  • Pointer size: 131 Bytes
  • Size of remote file: 267 kB
app/static/css/style.css ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg-dark: #0f172a;
3
+ --bg-card: rgba(30, 41, 59, 0.7);
4
+ --accent: #38bdf8;
5
+ --accent-hover: #0ea5e9;
6
+ --text-main: #f8fafc;
7
+ --text-dim: #94a3b8;
8
+ --glass-border: rgba(255, 255, 255, 0.1);
9
+ --shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
10
+ }
11
+
12
+ * {
13
+ margin: 0;
14
+ padding: 0;
15
+ box-sizing: border-box;
16
+ }
17
+
18
+ body {
19
+ font-family: 'Inter', sans-serif;
20
+ background: radial-gradient(circle at top right, #1e293b, #0f172a);
21
+ color: var(--text-main);
22
+ min-height: 100vh;
23
+ line-height: 1.6;
24
+ }
25
+
26
+ .glass {
27
+ background: var(--bg-card);
28
+ backdrop-filter: blur(12px);
29
+ -webkit-backdrop-filter: blur(12px);
30
+ border: 1px solid var(--glass-border);
31
+ box-shadow: var(--shadow);
32
+ }
33
+
34
+ .navbar {
35
+ position: sticky;
36
+ top: 0;
37
+ z-index: 100;
38
+ padding: 1rem 2rem;
39
+ margin-bottom: 2rem;
40
+ }
41
+
42
+ .nav-container {
43
+ max-width: 1200px;
44
+ margin: 0 auto;
45
+ display: flex;
46
+ justify-content: space-between;
47
+ align-items: center;
48
+ }
49
+
50
+ .nav-logo {
51
+ font-family: 'Outfit', sans-serif;
52
+ font-size: 1.5rem;
53
+ font-weight: 700;
54
+ color: var(--accent);
55
+ text-decoration: none;
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 0.5rem;
59
+ }
60
+
61
+ .nav-links {
62
+ display: flex;
63
+ gap: 2rem;
64
+ }
65
+
66
+ .nav-item {
67
+ color: var(--text-dim);
68
+ text-decoration: none;
69
+ font-weight: 600;
70
+ transition: color 0.3s;
71
+ font-size: 0.9rem;
72
+ letter-spacing: 1px;
73
+ }
74
+
75
+ .nav-item:hover {
76
+ color: var(--accent);
77
+ }
78
+
79
+ .content-wrapper {
80
+ max-width: 1000px;
81
+ margin: 0 auto;
82
+ padding: 0 2rem 4rem;
83
+ }
84
+
85
+ /* CARDS */
86
+ .card-grid {
87
+ display: grid;
88
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
89
+ gap: 2rem;
90
+ margin-top: 2rem;
91
+ }
92
+
93
+ .card {
94
+ padding: 2.5rem;
95
+ border-radius: 24px;
96
+ text-decoration: none;
97
+ color: inherit;
98
+ transition: transform 0.3s, border-color 0.3s;
99
+ display: flex;
100
+ flex-direction: column;
101
+ align-items: center;
102
+ text-align: center;
103
+ }
104
+
105
+ .card:hover {
106
+ transform: translateY(-10px);
107
+ border-color: var(--accent);
108
+ }
109
+
110
+ .card i {
111
+ font-size: 3rem;
112
+ color: var(--accent);
113
+ margin-bottom: 1.5rem;
114
+ }
115
+
116
+ .card h2 {
117
+ font-family: 'Outfit', sans-serif;
118
+ margin-bottom: 1rem;
119
+ }
120
+
121
+ /* BUTTONS */
122
+ .btn {
123
+ padding: 0.75rem 1.5rem;
124
+ border-radius: 12px;
125
+ border: none;
126
+ cursor: pointer;
127
+ font-weight: 600;
128
+ transition: all 0.3s;
129
+ }
130
+
131
+ .btn-primary {
132
+ background: var(--accent);
133
+ color: var(--bg-dark);
134
+ }
135
+
136
+ .btn-primary:hover {
137
+ background: var(--accent-hover);
138
+ transform: scale(1.02);
139
+ }
140
+
141
+ .btn-icon {
142
+ background: none;
143
+ border: none;
144
+ color: var(--text-dim);
145
+ font-size: 1.3rem;
146
+ cursor: pointer;
147
+ transition: color 0.3s;
148
+ }
149
+
150
+ .btn-icon:hover {
151
+ color: var(--accent);
152
+ }
153
+
154
+ /* FORMS */
155
+ .form-group {
156
+ margin-bottom: 1.5rem;
157
+ }
158
+
159
+ label {
160
+ display: block;
161
+ margin-bottom: 0.5rem;
162
+ color: var(--text-dim);
163
+ font-size: 0.9rem;
164
+ }
165
+
166
+ input, select, textarea {
167
+ width: 100%;
168
+ padding: 1rem;
169
+ background: rgba(15, 23, 42, 0.5);
170
+ border: 1px solid var(--glass-border);
171
+ border-radius: 12px;
172
+ color: white;
173
+ font-family: inherit;
174
+ }
175
+
176
+ input:focus {
177
+ outline: none;
178
+ border-color: var(--accent);
179
+ }
180
+
181
+ /* NOTIFICATIONS */
182
+ #notification-container {
183
+ position: fixed;
184
+ bottom: 2rem;
185
+ right: 2rem;
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: 1rem;
189
+ z-index: 1000;
190
+ }
191
+
192
+ .notification {
193
+ padding: 1rem 2rem;
194
+ border-radius: 12px;
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 1rem;
198
+ animation: slideIn 0.3s ease-out;
199
+ transition: opacity 0.5s;
200
+ }
201
+
202
+ @keyframes slideIn {
203
+ from { transform: translateX(100%); opacity: 0; }
204
+ to { transform: translateX(0); opacity: 1; }
205
+ }
206
+
207
+ .green { border-left: 4px solid #10b981; }
208
+ .red { border-left: 4px solid #ef4444; }
209
+ .blue { border-left: 4px solid #3b82f6; }
210
+
211
+ /* REPO LIST */
212
+ .repo-item {
213
+ padding: 1.5rem;
214
+ border-radius: 16px;
215
+ margin-bottom: 1rem;
216
+ display: flex;
217
+ justify-content: space-between;
218
+ align-items: center;
219
+ transition: background 0.3s;
220
+ }
221
+
222
+ .repo-item:hover {
223
+ background: rgba(255, 255, 255, 0.05);
224
+ }
225
+
226
+ /* MARKDOWN */
227
+ .markdown-body {
228
+ background: transparent !important;
229
+ color: var(--text-main) !important;
230
+ }
231
+
232
+ /* LOAN ITEMS */
233
+ .item-row {
234
+ display: grid;
235
+ grid-template-columns: 1fr 2fr 100px auto;
236
+ gap: 1rem;
237
+ align-items: flex-end;
238
+ margin-bottom: 1rem;
239
+ }
240
+
241
+ @media (max-width: 768px) {
242
+ .item-row {
243
+ grid-template-columns: 1fr;
244
+ }
245
+ }
app/static/js/script.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ // Global scripts for Maker Station
2
+ console.log("Maker Station UI Loaded");
3
+
4
+ // Handle any global interactions here
app/templates/base.html ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ title if title else "MAKERSPACE" }}</title>
7
+ <link rel="icon" href="{{ url_for('static', filename='assets/favicon.png') }}">
8
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Outfit:wght@500;700&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
11
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
12
+ </head>
13
+ <body class="dark-theme">
14
+ <nav class="navbar glass">
15
+ <div class="nav-container">
16
+ <a href="/" class="nav-logo">
17
+ <i class="fas fa-cube"></i>
18
+ <span>MAKER STATION</span>
19
+ </a>
20
+ <div class="nav-links">
21
+ <a href="/repos" class="nav-item">PROYECTOS</a>
22
+ <a href="/prestamos" class="nav-item">PRÉSTAMOS</a>
23
+ </div>
24
+ <button id="notif-btn" class="btn-icon">
25
+ <i class="fas fa-bell"></i>
26
+ </button>
27
+ </div>
28
+ </nav>
29
+
30
+ <main class="content-wrapper">
31
+ {% block content %}{% endblock %}
32
+ </main>
33
+
34
+ <div id="notification-container"></div>
35
+
36
+ <script src="{{ url_for('static', filename='js/script.js') }}"></script>
37
+ <script>
38
+ const socket = io();
39
+
40
+ socket.on('connect', () => {
41
+ console.log('Static Connection established');
42
+ });
43
+
44
+ socket.on('notification', (data) => {
45
+ showNotification(data.text, data.color || 'blue');
46
+ });
47
+
48
+ function showNotification(text, color) {
49
+ const container = document.getElementById('notification-container');
50
+ const notif = document.createElement('div');
51
+ notif.className = `notification glass ${color}`;
52
+ notif.innerHTML = `<i class="fas fa-info-circle"></i> <span>${text}</span>`;
53
+ container.appendChild(notif);
54
+
55
+ if ("Notification" in window && Notification.permission === "granted") {
56
+ new Notification("MAKER STATION", { body: text });
57
+ }
58
+
59
+ setTimeout(() => {
60
+ notif.style.opacity = '0';
61
+ setTimeout(() => notif.remove(), 500);
62
+ }, 5000);
63
+ }
64
+
65
+ document.getElementById('notif-btn').addEventListener('click', () => {
66
+ if ("Notification" in window) {
67
+ Notification.requestPermission();
68
+ }
69
+ });
70
+ </script>
71
+ </body>
72
+ </html>
app/templates/index.html ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="hero section">
5
+ <h1>PANEL DE CONTROL</h1>
6
+ <p class="text-dim">Gestiona los recursos de Maker Station de forma eficiente.</p>
7
+ </div>
8
+
9
+ <div class="card-grid">
10
+ <a href="/repos" class="card glass">
11
+ <i class="fas fa-folder-open"></i>
12
+ <h2>GITLAB REPOS</h2>
13
+ <p class="text-dim">Explora y descarga proyectos del grupo Maker Station.</p>
14
+ </a>
15
+
16
+ <a href="/prestamos" class="card glass" style="border-bottom: 4px solid #3b82f6;">
17
+ <i class="fas fa-tools"></i>
18
+ <h2>SISTEMA PRÉSTAMOS</h2>
19
+ <p class="text-dim">Solicita herramientas y dispositivos para tus proyectos.</p>
20
+ </a>
21
+ </div>
22
+ {% endblock %}
app/templates/prestamos.html ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="section-header"
5
+ style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
6
+ <h1>SOLICITAR PRÉSTAMO</h1>
7
+ <a href="/" class="btn-icon"><i class="fas fa-arrow-left"></i></a>
8
+ </div>
9
+
10
+ <div class="glass" style="padding: 2rem; border-radius: 24px; margin-bottom: 3rem;">
11
+ <form id="loan-form">
12
+ <div class="form-group">
13
+ <label for="solicitante">Nombre del solicitante</label>
14
+ <input type="text" id="solicitante" name="solicitante" placeholder="Tu nombre completo" required>
15
+ </div>
16
+
17
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
18
+ <div class="form-group">
19
+ <label for="hora_salida">Hora Salida</label>
20
+ <select id="hora_salida" name="hora_salida">
21
+ {% for h in range(8, 16) %}
22
+ <option value="{{ '%02d' % h }:00">{{ '%02d' % h }:00</option>
23
+ {% endfor %}
24
+ </select>
25
+ </div>
26
+ <div class="form-group">
27
+ <label for="hora_retorno">Devolución Estimada</label>
28
+ <select id="hora_retorno" name="hora_retorno">
29
+ {% for h in range(8, 16) %}
30
+ <option value="{{ '%02d' % h }:00" {{ 'selected' if h==15 }}>{{ '%02d' % h }:00</option>
31
+ {% endfor %}
32
+ </select>
33
+ </div>
34
+ </div>
35
+
36
+ <hr style="opacity: 0.1; margin: 2rem 0;">
37
+
38
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
39
+ <h3>LISTA DE HERRAMIENTAS</h3>
40
+ <button type="button" class="btn-icon" onclick="addItemRow()" style="color: #10b981;">
41
+ <i class="fas fa-plus-circle"></i>
42
+ </button>
43
+ </div>
44
+
45
+ <div id="items-container">
46
+ <div class="item-row">
47
+ <div class="form-group">
48
+ <label>Categoría</label>
49
+ <select name="categoria[]">
50
+ <option value="Herramientas">Herramientas</option>
51
+ <option value="Dispositivos Eléctricos">Dispositivos Eléctricos</option>
52
+ </select>
53
+ </div>
54
+ <div class="form-group">
55
+ <label>Descripción</label>
56
+ <input type="text" name="descripcion[]" placeholder="Ej: Martillo, Multímetro..." required>
57
+ </div>
58
+ <div class="form-group">
59
+ <label>Cant.</label>
60
+ <input type="number" name="cantidad[]" value="1" min="1">
61
+ </div>
62
+ <button type="button" class="btn-icon" onclick="this.parentElement.remove()"
63
+ style="color: #ef4444; margin-bottom: 1rem;">
64
+ <i class="fas fa-trash"></i>
65
+ </button>
66
+ </div>
67
+ </div>
68
+
69
+ <button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 2rem;">
70
+ ENVIAR SOLICITUD TOTAL <i class="fas fa-paper-plane" style="margin-left: 0.5rem;"></i>
71
+ </button>
72
+ </form>
73
+ </div>
74
+
75
+ <div class="history-section">
76
+ <h2 style="margin-bottom: 2rem; font-family: 'Outfit';">HISTORIAL RECIENTE</h2>
77
+ <div id="history-list">
78
+ {% for loan in loans|reverse %}
79
+ <div class="repo-item glass" onclick="showReceipt({{ loop.index0 }})">
80
+ <div>
81
+ <strong style="font-size: 1.1rem;">{{ loan.Solicitante|upper }}</strong>
82
+ <p class="text-dim" style="font-size: 0.9rem;">{{ loan.item }}</p>
83
+ </div>
84
+ <div style="text-align: right;">
85
+ {% if loan.status_loan == 'ACCEPTED' %}
86
+ <i class="fas fa-check-circle" style="color: #10b981; font-size: 1.5rem;"></i>
87
+ {% elif loan.status_loan == 'DECLINED' %}
88
+ <i class="fas fa-times-circle" style="color: #ef4444; font-size: 1.5rem;"></i>
89
+ {% else %}
90
+ <i class="fas fa-hourglass-half" style="color: #f59e0b; font-size: 1.5rem;"></i>
91
+ {% endif %}
92
+ </div>
93
+ </div>
94
+ {% endfor %}
95
+ </div>
96
+ </div>
97
+
98
+ <style>
99
+ /* Inline helper for specific page logic */
100
+ .receipt-modal {
101
+ position: fixed;
102
+ top: 50%;
103
+ left: 50%;
104
+ transform: translate(-50%, -50%);
105
+ width: 90%;
106
+ max-width: 400px;
107
+ z-index: 1001;
108
+ padding: 2rem;
109
+ border-radius: 20px;
110
+ }
111
+ </style>
112
+
113
+ <div id="modal-overlay"
114
+ style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:1000;"
115
+ onclick="closeModal()"></div>
116
+ <div id="modal-content" class="receipt-modal glass" style="display:none;"></div>
117
+
118
+ <script>
119
+ function addItemRow() {
120
+ const container = document.getElementById('items-container');
121
+ const div = document.createElement('div');
122
+ div.className = 'item-row';
123
+ div.innerHTML = `
124
+ <div class="form-group">
125
+ <select name="categoria[]">
126
+ <option value="Herramientas">Herramientas</option>
127
+ <option value="Dispositivos Eléctricos">Dispositivos Eléctricos</option>
128
+ </select>
129
+ </div>
130
+ <div class="form-group">
131
+ <input type="text" name="descripcion[]" placeholder="Ej: Martillo, Multímetro..." required>
132
+ </div>
133
+ <div class="form-group">
134
+ <input type="number" name="cantidad[]" value="1" min="1">
135
+ </div>
136
+ <button type="button" class="btn-icon" onclick="this.parentElement.remove()" style="color: #ef4444; margin-bottom: 1rem;">
137
+ <i class="fas fa-trash"></i>
138
+ </button>
139
+ `;
140
+ container.appendChild(div);
141
+ }
142
+
143
+ document.getElementById('loan-form').onsubmit = async (e) => {
144
+ e.preventDefault();
145
+ const formData = new FormData(e.target);
146
+ const data = {
147
+ solicitante: formData.get('solicitante'),
148
+ hora_salida: formData.get('hora_salida'),
149
+ hora_retorno: formData.get('hora_retorno'),
150
+ items: []
151
+ };
152
+
153
+ const cats = formData.getAll('categoria[]');
154
+ const descs = formData.getAll('descripcion[]');
155
+ const cants = formData.getAll('cantidad[]');
156
+
157
+ for (let i = 0; i < descs.length; i++) {
158
+ if (descs[i].trim()) {
159
+ data.items.push({
160
+ categoria: cats[i],
161
+ descripcion: descs[i],
162
+ cantidad: cants[i]
163
+ });
164
+ }
165
+ }
166
+
167
+ const response = await fetch('/api/prestamo', {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify(data)
171
+ });
172
+
173
+ if (response.ok) {
174
+ location.reload();
175
+ } else {
176
+ alert('Error al enviar la solicitud');
177
+ }
178
+ };
179
+
180
+ const rawLoans = {{ loans| tojson }};
181
+ function showReceipt(index) {
182
+ const loan = rawLoans.slice().reverse()[index];
183
+ const modal = document.getElementById('modal-content');
184
+ const overlay = document.getElementById('modal-overlay');
185
+
186
+ const statusColor = loan.status_loan === 'ACCEPTED' ? '#10b981' : (loan.status_loan === 'DECLINED' ? '#ef4444' : '#f59e0b');
187
+
188
+ modal.innerHTML = `
189
+ <h2 style="font-family:'Outfit'; text-align:center; margin-bottom:1.5rem;">RECIBO DE PRÉSTAMO</h2>
190
+ <div style="background:${statusColor}; color:white; padding:0.5rem; border-radius:8px; text-align:center; font-weight:bold; margin-bottom:1.5rem;">
191
+ ESTADO: ${loan.status_loan}
192
+ </div>
193
+ <p><strong>SOLICITANTE:</strong> ${loan.Solicitante}</p>
194
+ <p><strong>SALIDA:</strong> ${loan.hora}</p>
195
+ <p><strong>RETORNO:</strong> ${loan.devolucion}</p>
196
+ <hr style="opacity:0.1; margin:1rem 0;">
197
+ <p><strong>HERRAMIENTAS:</strong></p>
198
+ <p style="white-space:pre-wrap; font-size:0.9rem;">${loan.item}</p>
199
+ <button class="btn btn-primary" style="width:100%; margin-top:1.5rem;" onclick="closeModal()">Cerrar</button>
200
+ `;
201
+ modal.style.display = 'block';
202
+ overlay.style.display = 'block';
203
+ }
204
+
205
+ function closeModal() {
206
+ document.getElementById('modal-content').style.display = 'none';
207
+ document.getElementById('modal-overlay').style.display = 'none';
208
+ }
209
+ </script>
210
+ {% endblock %}
app/templates/repos.html ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="section-header"
5
+ style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
6
+ <h1>PROYECTOS</h1>
7
+ <a href="/" class="btn-icon"><i class="fas fa-arrow-left"></i></a>
8
+ </div>
9
+
10
+ <div class="project-list">
11
+ {% for project in projects %}
12
+ <a href="/ver/{{ project.id }}/{{ project.name }}" class="repo-item glass"
13
+ style="text-decoration: none; color: inherit;">
14
+ <div>
15
+ <strong style="font-size: 1.1rem;">{{ project.name }}</strong>
16
+ <p class="text-dim" style="font-size: 0.9rem;">{{ project.description if project.description else 'Sin
17
+ descripción' }}</p>
18
+ </div>
19
+ <i class="fas fa-chevron-right text-dim"></i>
20
+ </a>
21
+ {% endfor %}
22
+ </div>
23
+ {% endblock %}
app/templates/ver_repo.html ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="section-header"
5
+ style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
6
+ <h1>{{ project_name }}</h1>
7
+ <a href="/repos" class="btn-icon"><i class="fas fa-arrow-left"></i></a>
8
+ </div>
9
+
10
+ <div class="glass" style="padding: 2rem; border-radius: 24px; margin-bottom: 2rem;">
11
+ <div class="markdown-body">
12
+ {{ readme_html|safe }}
13
+ </div>
14
+ </div>
15
+
16
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
17
+ <a href="{{ download_url }}" class="btn glass"
18
+ style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; text-decoration: none; color: inherit;">
19
+ <i class="fas fa-download" style="color: var(--accent);"></i> DESCARGAR ZIP
20
+ </a>
21
+ <a href="{{ web_url }}" target="_blank" class="btn glass"
22
+ style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; text-decoration: none; color: inherit;">
23
+ <i class="fas fa-external-link-alt" style="color: var(--accent);"></i> VER EN GITLAB
24
+ </a>
25
+ </div>
26
+
27
+ <!-- Add markdown styling -->
28
+ <link rel="stylesheet"
29
+ href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown-dark.min.css">
30
+ {% endblock %}