Salade / app.py
Lukeetah's picture
Update app.py
919213a verified
# ARCANUM SALIS (El Secreto de la Sal)
# Una aplicaci贸n de E-commerce completa en un solo archivo, dise帽ada por IA.
# Backend: FastAPI | DB: SQLAlchemy (SQLite) | Frontend: HTML/TailwindCSS | Pagos: MercadoPago
import os
import uvicorn
import mercadopago
from datetime import datetime
from typing import List, Optional, Dict, Any
# --- Imports de FastAPI y relacionados ---
from fastapi import FastAPI, Request, Depends, HTTPException, Form, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from starlette.middleware.sessions import SessionMiddleware
from starlette.datastructures import URL
# --- Imports de SQLAlchemy (Base de Datos) ---
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Enum as DBEnum
from sqlalchemy.orm import sessionmaker, relationship, Session, declarative_base
# --- Imports de Pydantic (Validaci贸n de Modelos) ---
from pydantic import BaseModel
# --- Imports de Seguridad (Login de Admin) ---
from passlib.context import CryptContext
import secrets
# ======================================================================================
# 1. CONFIGURACI脫N INICIAL
# ======================================================================================
# --- Configuraci贸n de Seguridad ---
# En un entorno de producci贸n real, estos valores DEBEN venir de variables de entorno.
# Para Hugging Face Spaces, puedes setearlos en los "Secrets" del Space.
# (Usamos valores default para la demo)
ADMIN_USER = os.environ.get("ADMIN_USER", "gran.maestro@arcanum.sal")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "gadU_357") # GADU = Gran Arquitecto del Universo
SESSION_SECRET_KEY = os.environ.get("SESSION_SECRET_KEY", secrets.token_hex(32))
# --- Configuraci贸n de MercadoPago ---
# 隆隆隆IMPORTANTE!!! Reemplaza esto con tu ACCESS TOKEN de Producci贸n o Test.
# Lo puedes obtener de tu cuenta de MercadoPago.
MP_ACCESS_TOKEN = os.environ.get("MP_ACCESS_TOKEN", "TEST-SAMPLE-TOKEN") # <--- 隆CAMBIAR ESTO!
if MP_ACCESS_TOKEN == "TEST-SAMPLE-TOKEN":
print("="*50)
print("ADVERTENCIA: Usando Access Token de MercadoPago de PRUEBA.")
print("El checkout NO funcionar谩. Debes setear tu propio MP_ACCESS_TOKEN.")
print("="*50)
# --- Configuraci贸n de la Base de Datos (SQLite) ---
# Se crear谩 un archivo 'arcanum.db' en la ra铆z de tu Space.
DATABASE_URL = "sqlite:///./arcanum.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# --- Configuraci贸n de Hashing de Contrase帽as ---
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# --- Inicializaci贸n de FastAPI ---
app = FastAPI(title="Arcanum Salis API", description="El secreto de las cajas saladas.")
# --- Inicializaci贸n del SDK de MercadoPago ---
mp_sdk = mercadopago.SDK(MP_ACCESS_TOKEN)
# ======================================================================================
# 2. MODELOS DE BASE DE DATOS (SQLAlchemy)
# ======================================================================================
class Admin(Base):
"""Modelo para el Administrador de 'La Logia'."""
__tablename__ = "admins"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
class Product(Base):
"""Modelo para nuestros 'Grados' (las cajas)."""
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(String, nullable=False)
price = Column(Float, nullable=False)
image_url = Column(String, nullable=False) # Usaremos placeholders por ahora
ingredients = Column(String, nullable=False) # JSON-like string o separado por comas
class Order(Base):
"""Modelo para un pedido 'Ritual'."""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
customer_name = Column(String, nullable=False)
customer_email = Column(String, nullable=False)
customer_phone = Column(String)
total_amount = Column(Float, nullable=False)
status = Column(DBEnum("pending_payment", "paid", "preparing", "shipped", "failed", name="order_status_enum"), default="pending_payment")
created_at = Column(DateTime, default=datetime.utcnow)
mp_preference_id = Column(String, index=True)
items = relationship("OrderItem", back_populates="order")
class OrderItem(Base):
"""Modelo para un item dentro de un pedido."""
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"))
product_id = Column(Integer, ForeignKey("products.id"))
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False) # Precio al momento de la compra
order = relationship("Order", back_populates="items")
product = relationship("Product")
# ======================================================================================
# 3. MODELOS DE DATOS (Pydantic) - Para validaci贸n
# ======================================================================================
# (No los usamos mucho en este enfoque de "templates", pero son buenas pr谩cticas)
class ProductSchema(BaseModel):
id: int
name: str
description: str
price: float
image_url: str
ingredients: str
class Config:
orm_mode = True
class CartItem(BaseModel):
product: ProductSchema
quantity: int
class CartSchema(BaseModel):
items: List[CartItem]
total: float
# ======================================================================================
# 4. PLANTILLAS HTML (El Frontend completo como Strings de Python)
# ======================================================================================
# Esto es lo que hace que sea un solo archivo. Es una locura, pero cumple el requisito.
# Usamos TailwindCSS para el estilo.
# --- S铆mbolo Mas贸nico Secreto (SVG) ---
# Un "Nivel de Plomada" estilizado. Sutil.
MASONIC_SYMBOL_SVG = """
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7V21H22V7L12 2Z" stroke="#D4AF37" stroke-width="2" stroke-linejoin="round"/>
<path d="M12 22V12" stroke="#D4AF37" stroke-width="2"/>
<path d="M7 17H17" stroke="#D4AF37" stroke-width="2"/>
<path d="M12 12L7 17" stroke="#D4AF37" stroke-width="2"/>
<path d="M12 12L17 17" stroke="#D4AF37" stroke-width="2"/>
</svg>
"""
# --- Plantilla Base (Header y Footer) ---
HTML_BASE_TEMPLATE = """
<!DOCTYPE html>
<html lang="es" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} | Arcanum Salis</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body {{ font-family: 'Inter', sans-serif; background-color: #111827; color: #E5E7EB; }}
h1, h2, h3, .font-serif {{ font-family: 'Playfair Display', serif; }}
.masonic-gold {{ color: #D4AF37; }}
.masonic-gold-bg {{ background-color: #D4AF37; }}
.masonic-gold-border {{ border-color: #D4AF37; }}
.masonic-bg-dark {{ background-color: #1F2937; }} /* Un gris oscuro, no negro puro */
.masonic-bg-light {{ background-color: #F9FAFB; color: #111827; }} /* Para contraste */
/* Efecto de 'revelaci贸n' sutil */
.reveal-on-hover .reveal-content {{
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}}
.reveal-on-hover:hover .reveal-content {{
opacity: 1;
transform: translateY(0);
}}
/* El "Ojo que todo lo ve" sutil como 铆cono de admin */
.admin-eye-icon {{
width: 16px;
height: 16px;
border: 1px solid #9CA3AF;
border-radius: 50%;
position: relative;
display: inline-block;
margin-right: 4px;
}}
.admin-eye-icon::before {{
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
background: #9CA3AF;
border-radius: 50%;
transform: translate(-50%, -50%);
}}
</style>
</head>
<body class="antialiased">
<header class="masonic-bg-dark shadow-lg sticky top-0 z-50">
<nav class="container mx-auto px-6 py-4 flex justify-between items-center">
<a href="/" class="flex items-center space-x-2">
<!-- <span class="masonic-gold text-2xl font-bold">{MASONIC_SYMBOL_SVG}</span> -->
{MASONIC_SYMBOL_SVG}
<span class="text-2xl font-serif masonic-gold font-bold">Arcanum Salis</span>
</a>
<div class="flex items-center space-x-4">
<a href="/#grados" class="text-gray-300 hover:masonic-gold transition-colors">Los Grados</a>
<a href="/cart" class="relative text-gray-300 hover:masonic-gold transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{cart_badge}
</a>
</div>
</nav>
</header>
<main>
{content}
</main>
<footer class="masonic-bg-dark text-gray-400 p-10 mt-16">
<div class="container mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h3 class="font-serif text-xl masonic-gold mb-3">Arcanum Salis</h3>
<p class="text-sm">Elevando el 谩gape, un ritual a la vez. Cajas saladas para paladares iniciados.</p>
<a href="/admin/login" class="text-xs text-gray-600 hover:text-gray-500 mt-2 block"><span class="admin-eye-icon"></span>Logia</a>
</div>
<div>
<h3 class="font-serif text-lg text-gray-200 mb-3">Grados de Conocimiento</h3>
<ul class="text-sm space-y-1">
<li><a href="/#grados" class="hover:masonic-gold">Grado Aprendiz</a></li>
<li><a href="/#grados" class="hover:masonic-gold">Grado Compa帽ero</a></li>
<li><a href="/#grados" class="hover:masonic-gold">Grado Maestro</a></li>
</ul>
</div>
<div>
<h3 class="font-serif text-lg text-gray-200 mb-3">El Secreto</h3>
<p class="text-sm">Somos dos amigos, dos hermanos. Creemos que compartir es el verdadero secreto. <br> Argentina, 2025.</p>
</div>
</div>
<div class="border-t border-gray-700 mt-8 pt-6 text-center text-xs">
<p>&copy; {current_year} Arcanum Salis. Todos los derechos reservados.</p>
<p class="text-gray-600 mt-1">A:. L:. G:. D:. G:. A:. D:. U:.</p>
</div>
</footer>
</body>
</html>
"""
# --- Contenido para la P谩gina Principal (Home) ---
HTML_HOME_CONTENT = """
<!-- Secci贸n H茅roe -->
<div class="relative h-[60vh] md:h-[80vh] flex items-center justify-center text-center px-6 overflow-hidden">
<img src="https://images.unsplash.com/photo-1559851876-883b54c86E04?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1770&q=80"
alt="Una picada abundante y elegante"
class="absolute inset-0 w-full h-full object-cover z-0"
style="filter: brightness(0.4) grayscale(0.2);">
<div class="relative z-10">
<h1 class="text-4xl md:text-6xl lg:text-7xl font-serif masonic-gold font-bold text-shadow-lg" style="text-shadow: 2px 2px 4px rgba(0,0,0,0.7);">
El Ritual de la "Salade帽a"
</h1>
<p class="text-lg md:text-2xl text-white mt-4 max-w-2xl mx-auto" style="text-shadow: 1px 1px 2px rgba(0,0,0,0.7);">
M谩s que una caja. Es el fin del pan dulce y el comienzo de un nuevo 谩gape.
El secreto mejor guardado de las fiestas argentinas.
</p>
<a href="#grados" class="mt-8 inline-block masonic-gold-bg text-gray-900 font-bold py-3 px-8 rounded-full text-lg
transition-transform transform hover:scale-105 shadow-lg hover:shadow-xl">
Descubre los 3 Grados
</a>
</div>
</div>
<!-- Secci贸n de Productos ("Los Grados") -->
<div id="grados" class="container mx-auto px-6 py-16">
<h2 class="text-4xl font-serif text-center masonic-gold mb-12">Los Grados del Sabor</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{product_cards}
</div>
</div>
"""
# --- Plantilla para una Card de Producto ---
HTML_PRODUCT_CARD = """
<div class="masonic-bg-dark rounded-lg shadow-xl overflow-hidden flex flex-col reveal-on-hover">
<img src="{image_url}" alt="{name}" class="w-full h-56 object-cover">
<div class="p-6 flex flex-col flex-grow">
<h3 class="text-2xl font-serif masonic-gold mb-2">{name}</h3>
<p class="text-gray-300 text-sm mb-4 flex-grow">{description}</p>
<!-- Contenido Secreto (Ingredientes) -->
<div class="reveal-content mb-4">
<h4 class="text-sm font-semibold text-gray-400 mb-2">Contenido del Arcano:</h4>
<ul class="text-xs text-gray-300 list-disc list-inside space-y-1">
{ingredients_list}
</ul>
</div>
<div class="mt-auto pt-4 border-t border-gray-700">
<div class="flex justify-between items-center">
<span class="text-3xl font-bold masonic-gold">${price:,.0f}</span>
<form action="/cart/add" method="post">
<input type="hidden" name="product_id" value="{id}">
<button type="submit" class="masonic-gold-bg text-gray-900 font-bold py-2 px-4 rounded-full
transition-transform transform hover:scale-105 shadow-md">
Iniciar
</button>
</form>
</div>
</div>
</div>
</div>
"""
# --- Contenido para la P谩gina del Carrito ---
HTML_CART_CONTENT = """
<div class="masonic-bg-light rounded-lg shadow-xl max-w-4xl mx-auto p-8 my-16">
<h1 class="text-4xl font-serif masonic-gold text-center mb-10" style="color: #111827;">Tu Ritual de Compra</h1>
{cart_items_html}
<div class="border-t border-gray-300 mt-8 pt-6">
<div class="flex justify-between items-center text-2xl font-bold text-gray-900">
<span>Total:</span>
<span>${total:,.0f}</span>
</div>
<p class_="text-sm text-gray-600 mt-4">
Al continuar, ser谩s redirigido a MercadoPago para completar el pago de forma segura.
Prepara tus datos, el ritual est谩 por comenzar.
</p>
{checkout_button}
</div>
</div>
"""
# --- Plantilla para un Item del Carrito ---
HTML_CART_ITEM_ROW = """
<div class="flex items-center justify-between py-4 border-b border-gray-300">
<div class="flex items-center space-x-4">
<img src="{image_url}" alt="{name}" class="w-16 h-16 object-cover rounded-md">
<div>
<h3 class="text-lg font-semibold text-gray-900">{name}</h3>
<span class="text-sm text-gray-600">Cantidad: {quantity}</span>
</div>
</div>
<div class="flex items-center space-x-4">
<span class="text-lg font-semibold text-gray-900">${subtotal:,.0f}</span>
<form action="/cart/remove" method="post">
<input type="hidden" name="product_id" value="{product_id}">
<button type="submit" class="text-red-600 hover:text-red-800">&times;</button>
</form>
</div>
</div>
"""
HTML_CART_EMPTY = """
<div class="masonic-bg-light rounded-lg shadow-xl max-w-4xl mx-auto p-8 my-16 text-center">
<h1 class="text-4xl font-serif masonic-gold text-center mb-4" style="color: #111827;">Tu 脕gape est谩 vac铆o</h1>
<p class="text-lg text-gray-700 mb-8">El primer paso del ritual es elegir un grado.</p>
<a href="/#grados" class="masonic-gold-bg text-gray-900 font-bold py-3 px-8 rounded-full text-lg
transition-transform transform hover:scale-105 shadow-lg hover:shadow-xl"
style="background-color: #1F2937; color: #D4AF37;">
Ver los Grados
</a>
</div>
"""
HTML_CHECKOUT_BUTTON = """
<form action="/checkout" method="post" class="mt-6">
<h3 class="text-xl font-serif text-gray-900 mb-4">Datos del Iniciado</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="text" name="customer_name" placeholder="Nombre Completo" required
class="w-full p-3 rounded-md border border-gray-300 text-gray-900 bg-white">
<input type="email" name="customer_email" placeholder="Email de Contacto" required
class="w-full p-3 rounded-md border border-gray-300 text-gray-900 bg-white">
</div>
<input type="text" name="customer_phone" placeholder="Tel茅fono (Opcional)"
class="w-full p-3 rounded-md border border-gray-300 text-gray-900 bg-white mt-4">
<button type="submit" class="w-full mt-8 bg-blue-600 text-white font-bold py-4 px-8 rounded-full text-lg
transition-transform transform hover:scale-105 shadow-lg hover:shadow-xl">
Pagar con MercadoPago y Completar Ritual
</button>
</form>
"""
# --- P谩gina de 脡xito del Pedido ---
HTML_ORDER_SUCCESS_CONTENT = """
<div class="masonic-bg-light rounded-lg shadow-xl max-w-2xl mx-auto p-8 my-16 text-center">
<div class="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 class="text-4xl font-serif text-gray-900 mb-4">隆Ritual Completado!</h1>
<p class="text-lg text-gray-700 mb-2">Tu pago ha sido aprobado. El 谩gape est谩 en marcha.</p>
<p class="text-gray-600 mb-8">Recibir谩s un email (a {email}) con los detalles de tu orden (ID: {order_id}). Estamos preparando tu Arcanum Salis.</p>
<a href="/" class="text-blue-600 hover:underline">Volver al inicio</a>
</div>
"""
# --- P谩gina de Fallo del Pedido ---
HTML_ORDER_FAILURE_CONTENT = """
<div class="masonic-bg-light rounded-lg shadow-xl max-w-2xl mx-auto p-8 my-16 text-center">
<div class="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 class="text-4xl font-serif text-gray-900 mb-4">Ritual Interrumpido</h1>
<p class="text-lg text-gray-700 mb-8">Hubo un problema con tu pago y no pudo ser procesado. No se te ha cobrado nada.</p>
<a href="/cart" class="text-blue-600 hover:underline">Intentar nuevamente desde el carrito</a>
</div>
"""
# --- P谩gina de Login de Admin ("La Logia") ---
HTML_ADMIN_LOGIN_CONTENT = """
<div class="max-w-md mx-auto my-24 masonic-bg-dark p-8 rounded-lg shadow-2xl">
<div class="text-center mb-8">
{MASONIC_SYMBOL_SVG}
<h1 class="text-3xl font-serif masonic-gold mt-2">Acceso a La Logia</h1>
<p class="text-gray-400">Solo para Maestros del Arcanum.</p>
</div>
{error_message}
<form action="/admin/login" method="post">
<div class="mb-4">
<label for="username" class="block text-sm font-medium text-gray-300 mb-1">Email del Taller</label>
<input type="email" id="username" name="username" required
class="w-full p-3 rounded-md border border-gray-600 text-gray-100 bg-gray-900 focus:border-masonic-gold focus:ring-masonic-gold">
</div>
<div class="mb-6">
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Palabra de Paso</label>
<input type="password" id="password" name="password" required
class="w-full p-3 rounded-md border border-gray-600 text-gray-100 bg-gray-900 focus:border-masonic-gold focus:ring-masonic-gold">
</div>
<button type="submit" class="w-full masonic-gold-bg text-gray-900 font-bold py-3 px-6 rounded-full
transition-transform transform hover:scale-105 shadow-lg">
Ingresar
</button>
</form>
</div>
"""
# --- P谩gina del Dashboard de Admin ---
HTML_ADMIN_DASHBOARD_CONTENT = """
<div class="container mx-auto px-6 py-12">
<div class="flex justify-between items-center mb-8">
<h1 class="text-4xl font-serif masonic-gold">Tablero de la Logia</h1>
<form action="/admin/logout" method="post">
<button type="submit" class="text-gray-400 hover:text-white">Cerrar Sesi贸n</button>
</form>
</div>
<p class="text-lg text-gray-300 mb-8">Bienvenido, Gran Maestro. Aqu铆 est谩n los rituales pendientes y completados.</p>
<!-- "IA Redise帽ando": Un flujo de trabajo simple basado en Kanban -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<!-- Columna: Pagado (Listo para preparar) -->
<div class="masonic-bg-dark p-4 rounded-lg">
<h2 class="text-xl font-serif text-yellow-400 mb-4">Pagado (En Cola)</h2>
<div class="space-y-4">
{orders_paid}
</div>
</div>
<!-- Columna: En Preparaci贸n -->
<div class="masonic-bg-dark p-4 rounded-lg">
<h2 class="text-xl font-serif text-blue-400 mb-4">En Preparaci贸n</h2>
<div class="space-y-4">
{orders_preparing}
</div>
</div>
<!-- Columna: Enviado -->
<div class="masonic-bg-dark p-4 rounded-lg">
<h2 class="text-xl font-serif text-green-400 mb-4">Enviado</h2>
<div class="space-y-4">
{orders_shipped}
</div>
</div>
<!-- Columna: Pendiente / Fallido -->
<div class="masonic-bg-dark p-4 rounded-lg">
<h2 class="text-xl font-serif text-red-400 mb-4">Pendiente / Fallido</h2>
<div class="space-y-4">
{orders_failed}
</div>
</div>
</div>
</div>
"""
# --- Plantilla para una Card de Pedido en Admin ---
HTML_ADMIN_ORDER_CARD = """
<div class="masonic-bg-dark border border-gray-700 p-4 rounded-lg shadow-lg">
<div class="flex justify-between items-center mb-2">
<h3 class="font-bold text-lg text-white">Orden #{id}</h3>
<span class="text-lg font-bold masonic-gold">${total_amount:,.0f}</span>
</div>
<p class="text-sm text-gray-300">{customer_name}</p>
<p class="text-xs text-gray-400">{customer_email}</p>
<p class="text-xs text-gray-500">{created_at}</p>
<ul class="text-sm text-gray-300 mt-3 border-t border-gray-700 pt-2">
{items_list}
</ul>
<form action="/admin/order/update" method="post" class="mt-4">
<input type="hidden" name="order_id" value="{id}">
<select name="new_status" class="w-full p-2 rounded-md bg-gray-800 text-white border border-gray-600">
<option value="paid" {selected_paid}>Pagado</option>
<option value="preparing" {selected_preparing}>En Preparaci贸n</option>
<option value="shipped" {selected_shipped}>Enviado</option>
<option value="failed" {selected_failed}>Fallido</option>
</select>
<button type="submit" class="w-full mt-2 masonic-gold-bg text-gray-900 text-sm font-bold py-1 px-3 rounded-full">
Actualizar
</button>
</form>
</div>
"""
# ======================================================================================
# 5. L脫GICA DE NEGOCIO Y HELPERS
# ======================================================================================
# --- Dependencia de Base de Datos ---
def get_db():
"""Funci贸n de dependencia para obtener una sesi贸n de DB."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# --- Funciones de Seguridad ---
def verify_password(plain_password, hashed_password):
"""Verifica una contrase帽a contra su hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
"""Genera un hash para una contrase帽a."""
return pwd_context.hash(password)
def get_admin_user(request: Request, db: Session = Depends(get_db)):
"""
Dependencia de FastAPI para proteger rutas de admin.
Verifica si 'admin_user' est谩 en la sesi贸n.
"""
username = request.session.get("admin_user")
if not username:
# Usamos una excepci贸n que redirige al login
raise HTTPException(status_code=307, detail="Not authenticated", headers={"Location": "/admin/login"})
user = db.query(Admin).filter(Admin.username == username).first()
if user is None:
raise HTTPException(status_code=307, detail="User not found", headers={"Location": "/admin/login"})
return user
# --- Funciones del Carrito ---
def get_cart_data(request: Request, db: Session) -> Dict[str, Any]:
"""Obtiene y procesa los datos del carrito desde la sesi贸n."""
cart_session = request.session.get("cart", {}) # cart_session = {product_id: quantity}
cart_items = []
total = 0.0
if not cart_session:
return {"items": [], "total": 0.0, "count": 0}
product_ids = cart_session.keys()
products = db.query(Product).filter(Product.id.in_(product_ids)).all()
products_dict = {str(p.id): p for p in products}
for product_id_str, quantity in cart_session.items():
product = products_dict.get(product_id_str)
if product:
subtotal = product.price * quantity
total += subtotal
cart_items.append({
"product_id": product.id,
"name": product.name,
"price": product.price,
"quantity": quantity,
"subtotal": subtotal,
"image_url": product.image_url
})
return {"items": cart_items, "total": total, "count": sum(cart_session.values())}
# --- Funci贸n de Renderizado ---
def render_template(
template: str,
context: dict,
request: Request,
db: Session = None
) -> HTMLResponse:
"""
Nuestro "motor de plantillas" s煤per simple.
Inserta el contenido en la base y a帽ade valores globales.
"""
# A帽adir valores globales
context["current_year"] = datetime.utcnow().year
context["MASONIC_SYMBOL_SVG"] = MASONIC_SYMBOL_SVG
# A帽adir el contador del carrito
cart_count = 0
if db: # Si la DB est谩 disponible, calcula el carrito real
cart_data = get_cart_data(request, db)
cart_count = cart_data["count"]
cart_badge = ""
if cart_count > 0:
cart_badge = f'<span class="absolute -top-2 -right-2 bg-red-600 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">{cart_count}</span>'
# Formatea el contenido principal
content_html = template.format(**context)
# Inserta el contenido en la plantilla base
full_html = HTML_BASE_TEMPLATE.format(
title=context.get("title", "El Secreto de la Sal"),
cart_badge=cart_badge,
content=content_html,
current_year=datetime.utcnow().year
)
return HTMLResponse(content=full_html)
# ======================================================================================
# 6. EVENTOS DE STARTUP (Crear DB y Admin)
# ======================================================================================
@app.on_event("startup")
def on_startup():
"""Se ejecuta al iniciar la aplicaci贸n."""
print("Iniciando Arcanum Salis...")
# 1. Crear todas las tablas de la base de datos
Base.metadata.create_all(bind=engine)
print("Tablas de la base de datos verificadas/creadas.")
# 2. Crear el Admin por defecto (si no existe)
db = SessionLocal()
try:
admin = db.query(Admin).filter(Admin.username == ADMIN_USER).first()
if not admin:
hashed_pass = get_password_hash(ADMIN_PASSWORD)
new_admin = Admin(username=ADMIN_USER, hashed_password=hashed_pass)
db.add(new_admin)
db.commit()
print(f"Usuario admin '{ADMIN_USER}' creado.")
else:
print(f"Usuario admin '{ADMIN_USER}' ya existe.")
# 3. Crear los Productos (Cajas "Grados") si no existen
if db.query(Product).count() == 0:
print("Poblando la base de datos con los 3 Grados...")
# Placeholders de im谩genes (Usando placehold.co, m谩s sutil)
img1 = "https://placehold.co/600x400/374151/D4AF37?text=Grado+Aprendiz"
img2 = "https://placehold.co/600x400/374151/D4AF37?text=Grado+Compa%C3%B1ero"
img3 = "https://placehold.co/600x400/374151/D4AF37?text=Grado+Maestro"
# Ingredientes (como string separado por comas)
ing1 = "Salam铆n Picado Fino,Queso Mar del Plata,Man铆 Salado,Papas Pay,Grisines"
ing2 = "Jam贸n Crudo (Estilo Parma),Queso Brie,Aceitunas Rellenas,Almendras Ahumadas,Mini Focaccias con Romero"
ing3 = "Lomo Ahumado,Queso Azul,Pistachos Pelados,Tomates Secos en Oliva,Pan de Masa Madre,Cerveza Artesanal (Porr贸n)"
p1 = Product(
name="Grado Aprendiz",
description="El primer paso hacia el sabor. Cl谩sicos infalibles para una picada que cumple. Simple, contundente y honesto.",
price=15000,
image_url=img1,
ingredients=ing1
)
p2 = Product(
name="Grado Compa帽ero",
description="El equilibrio perfecto. Un ascenso en complejidad y texturas. Para paladares que buscan algo m谩s.",
price=25000,
image_url=img2,
ingredients=ing2
)
p3 = Product(
name="Grado Maestro",
description="El conocimiento total del sabor. Lujo, sofisticaci贸n y combinaciones audaces. La c煤spide del 谩gape.",
price=40000,
image_url=img3,
ingredients=ing3
)
db.add_all([p1, p2, p3])
db.commit()
print("Los 3 Grados han sido creados.")
finally:
db.close()
# ======================================================================================
# 7. MIDDLEWARE (Para manejar sesiones)
# ======================================================================================
# A帽adimos el middleware de sesi贸n a FastAPI
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY)
# ======================================================================================
# 8. ENDPOINTS DEL FRONTEND (Lado Usuario)
# ======================================================================================
@app.get("/", response_class=HTMLResponse)
async def get_home(request: Request, db: Session = Depends(get_db)):
"""Muestra la p谩gina principal con los productos."""
products = db.query(Product).all()
product_cards_html = ""
for prod in products:
# Formatear lista de ingredientes
ingredients_list_html = "".join([f"<li>{item}</li>" for item in prod.ingredients.split(',')])
product_cards_html += HTML_PRODUCT_CARD.format(
id=prod.id,
name=prod.name,
description=prod.description,
price=prod.price,
image_url=prod.image_url,
ingredients_list=ingredients_list_html
)
context = {
"title": "El Ritual de la Salade帽a",
"product_cards": product_cards_html
}
return render_template(HTML_HOME_CONTENT, context, request, db)
@app.get("/cart", response_class=HTMLResponse)
async def get_cart(request: Request, db: Session = Depends(get_db)):
"""Muestra la p谩gina del carrito de compras."""
cart_data = get_cart_data(request, db)
if not cart_data["items"]:
return render_template(HTML_CART_EMPTY, {"title": "Carrito Vac铆o"}, request, db)
cart_items_html = ""
for item in cart_data["items"]:
cart_items_html += HTML_CART_ITEM_ROW.format(**item)
context = {
"title": "Tu Ritual de Compra",
"cart_items_html": cart_items_html,
"total": cart_data["total"],
"checkout_button": HTML_CHECKOUT_BUTTON # A帽adimos el formulario de checkout
}
return render_template(HTML_CART_CONTENT, context, request, db)
@app.post("/cart/add")
async def post_add_to_cart(request: Request, product_id: int = Form(...)):
"""A帽ade un producto al carrito en la sesi贸n."""
cart = request.session.get("cart", {}) # { "1": 1, "2": 3 }
# Usamos strings para las claves de sesi贸n, es m谩s seguro
product_id_str = str(product_id)
cart[product_id_str] = cart.get(product_id_str, 0) + 1
request.session["cart"] = cart
# Redirigimos de vuelta al carrito
return RedirectResponse(url="/cart", status_code=303)
@app.post("/cart/remove")
async def post_remove_from_cart(request: Request, product_id: int = Form(...)):
"""Reduce o elimina un producto del carrito en la sesi贸n."""
cart = request.session.get("cart", {})
product_id_str = str(product_id)
if product_id_str in cart:
cart[product_id_str] -= 1
if cart[product_id_str] <= 0:
del cart[product_id_str] # Elimina si la cantidad es 0 o menos
request.session["cart"] = cart
return RedirectResponse(url="/cart", status_code=303)
# ======================================================================================
# 9. ENDPOINTS DE PAGO (Checkout y MercadoPago)
# ======================================================================================
@app.post("/checkout")
async def post_checkout(
request: Request,
db: Session = Depends(get_db),
customer_name: str = Form(...),
customer_email: str = Form(...)
):
"""
1. Obtiene el carrito.
2. Crea una Orden en la DB como 'pending_payment'.
3. Crea una Preferencia de Pago en MercadoPago.
4. Redirige al usuario al checkout de MP.
"""
cart_data = get_cart_data(request, db)
if not cart_data["items"]:
return RedirectResponse(url="/cart", status_code=303)
# 1. Crear la Orden en la DB
new_order = Order(
customer_name=customer_name,
customer_email=customer_email,
total_amount=cart_data["total"],
status="pending_payment"
)
db.add(new_order)
db.commit() # Hacemos commit para obtener el new_order.id
mp_items = []
for item in cart_data["items"]:
# A帽adir items a la Orden
order_item_db = OrderItem(
order_id=new_order.id,
product_id=item["product_id"],
quantity=item["quantity"],
unit_price=item["price"]
)
db.add(order_item_db)
# Preparar items para MP
mp_items.append({
"id": str(item["product_id"]),
"title": item["name"],
"quantity": item["quantity"],
"unit_price": item["price"],
"currency_id": "ARS" # Moneda Argentina
})
db.commit() # Guardamos los OrderItems
# 2. Crear Preferencia de MercadoPago
# Usamos str(request.base_url) para construir URLs absolutas (requerido por MP)
base_url = str(request.base_url)
preference_data = {
"items": mp_items,
"payer": {
"name": customer_name,
"email": customer_email,
},
"back_urls": {
"success": f"{base_url}payment/success",
"failure": f"{base_url}payment/failure",
"pending": f"{base_url}payment/failure" # Tratamos pendiente como fallo por simpleza
},
"auto_return": "approved", # Redirige autom谩ticamente al aprobar
"notification_url": f"{base_url}payment/webhook",
"external_reference": str(new_order.id), # 隆Clave! Vinculamos la Orden con MP
"metadata": {
"order_id": new_order.id # Metadatos extra
}
}
try:
preference_response = mp_sdk.preference().create(preference_data)
preference = preference_response["response"]
# Guardamos el ID de la preferencia en nuestra orden
new_order.mp_preference_id = preference["id"]
db.commit()
# Limpiamos el carrito
request.session["cart"] = {}
# Guardamos la orden en la sesi贸n para mostrarla en la p谩gina de 茅xito
request.session["last_order_id"] = new_order.id
# 3. Redirigir al checkout
# Usamos el init_point del sandbox si estamos en modo test
checkout_url = preference["init_point"]
if "sandbox_init_point" in preference:
checkout_url = preference["sandbox_init_point"]
return RedirectResponse(url=checkout_url, status_code=303)
except Exception as e:
print(f"Error creando preferencia de MercadoPago: {e}")
# Revertir la orden
new_order.status = "failed"
db.commit()
# Idealmente, mostrar un mensaje de error al usuario
return RedirectResponse(url="/cart?error=mp_failed", status_code=303)
@app.get("/payment/success", response_class=HTMLResponse)
async def get_payment_success(request: Request, db: Session = Depends(get_db)):
"""P谩gina de 茅xito. Se muestra despu茅s de un pago aprobado."""
# Obtenemos la orden de la sesi贸n (no del query param, por seguridad)
order_id = request.session.get("last_order_id")
if not order_id:
return RedirectResponse(url="/")
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
return RedirectResponse(url="/")
# Verificamos los par谩metros que S脥 env铆a MP en la URL
payment_status = request.query_params.get("status")
external_reference = request.query_params.get("external_reference")
# Doble chequeo
if payment_status == "approved" and external_reference == str(order.id):
# Marcamos la orden como pagada (si el webhook no lo hizo ya)
if order.status == "pending_payment":
order.status = "paid"
db.commit()
else:
# Algo raro pas贸, el usuario no deber铆a estar aqu铆
return RedirectResponse(url="/")
context = {
"title": "Ritual Completado",
"order_id": order.id,
"email": order.customer_email
}
# Limpiamos el ID de la sesi贸n
request.session["last_order_id"] = None
return render_template(HTML_ORDER_SUCCESS_CONTENT, context, request, db)
@app.get("/payment/failure", response_class=HTMLResponse)
async def get_payment_failure(request: Request, db: Session = Depends(get_db)):
"""P谩gina de fallo. Se muestra si el pago es rechazado."""
order_id = request.session.get("last_order_id")
if order_id:
order = db.query(Order).filter(Order.id == order_id).first()
if order and order.status == "pending_payment":
order.status = "failed"
db.commit()
# Limpiamos el ID de la sesi贸n
request.session["last_order_id"] = None
context = {"title": "Ritual Interrumpido"}
return render_template(HTML_ORDER_FAILURE_CONTENT, context, request)
@app.post("/payment/webhook")
async def post_payment_webhook(request: Request, db: Session = Depends(get_db)):
"""
Webhook de MercadoPago. Se ejecuta en segundo plano.
Es la forma M脕S SEGURA de confirmar un pago.
"""
try:
data = await request.json()
if data.get("type") == "payment":
payment_id = data.get("data", {}).get("id")
if not payment_id:
return Response(status_code=400)
# Obtenemos la info del pago desde MP
payment_info_response = mp_sdk.payment().get(payment_id)
payment_info = payment_info_response["response"]
order_id = payment_info.get("external_reference")
status = payment_info.get("status")
if order_id:
order = db.query(Order).filter(Order.id == int(order_id)).first()
if order:
if status == "approved" and order.status != "paid":
order.status = "paid"
print(f"[Webhook] Orden {order_id} marcada como PAGADA.")
elif status in ["rejected", "cancelled"] and order.status == "pending_payment":
order.status = "failed"
print(f"[Webhook] Orden {order_id} marcada como FALLIDA.")
db.commit()
return Response(status_code=200) # Siempre responder 200 a MP
except Exception as e:
print(f"Error en Webhook: {e}")
return Response(status_code=500)
# ======================================================================================
# 10. ENDPOINTS DE ADMINISTRACI脫N ("La Logia")
# ======================================================================================
@app.get("/admin", response_class=RedirectResponse)
@app.get("/admin/login", response_class=HTMLResponse)
async def get_admin_login(request: Request, error: Optional[str] = None):
"""Muestra la p谩gina de login de admin."""
error_message = ""
if error:
error_message = '<div class="bg-red-800 border border-red-600 text-red-100 px-4 py-3 rounded-md mb-4">Palabra de Paso o Email incorrectos.</div>'
context = {
"title": "Acceso a La Logia",
"error_message": error_message
}
# Usamos la plantilla base, pero el contenido de login es "full page"
# as铆 que el {content} ser谩 todo el HTML.
return HTMLResponse(content=HTML_ADMIN_LOGIN_CONTENT.format(**context))
@app.post("/admin/login", response_class=RedirectResponse)
async def post_admin_login(
request: Request,
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
):
"""Procesa el formulario de login de admin."""
user = db.query(Admin).filter(Admin.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
# Redirige a la misma p谩gina con un mensaje de error
return RedirectResponse(url="/admin/login?error=1", status_code=303)
# 脡xito: Guardamos al admin en la sesi贸n
request.session["admin_user"] = user.username
return RedirectResponse(url="/admin/dashboard", status_code=303)
@app.post("/admin/logout", response_class=RedirectResponse)
async def post_admin_logout(request: Request):
"""Cierra la sesi贸n del admin."""
request.session.pop("admin_user", None)
return RedirectResponse(url="/admin/login", status_code=303)
@app.get("/admin/dashboard", response_class=HTMLResponse)
async def get_admin_dashboard(
request: Request,
db: Session = Depends(get_db),
admin: Admin = Depends(get_admin_user) # 隆Ruta Protegida!
):
"""Muestra el dashboard de admin con todas las 贸rdenes."""
# Obtenemos 贸rdenes y las separamos por estado
orders = db.query(Order).order_by(Order.created_at.desc()).all()
orders_by_status = {
"paid": [],
"preparing": [],
"shipped": [],
"failed": [] # Incluye pending_payment y failed
}
for order in orders:
if order.status == "paid":
orders_by_status["paid"].append(order)
elif order.status == "preparing":
orders_by_status["preparing"].append(order)
elif order.status == "shipped":
orders_by_status["shipped"].append(order)
else: # "pending_payment" o "failed"
orders_by_status["failed"].append(order)
# --- Funci贸n interna para renderizar las cards de 贸rdenes ---
def render_order_cards(order_list: List[Order]) -> str:
html = ""
for order in order_list:
items_html = ""
for item in order.items:
product_name = db.query(Product.name).filter(Product.id == item.product_id).scalar() or "Producto Eliminado"
items_html += f"<li>{item.quantity}x {product_name}</li>"
html += HTML_ADMIN_ORDER_CARD.format(
id=order.id,
total_amount=order.total_amount,
customer_name=order.customer_name,
customer_email=order.customer_email,
created_at=order.created_at.strftime("%d/%m/%y %H:%M"),
items_list=items_html,
selected_paid='selected' if order.status == 'paid' else '',
selected_preparing='selected' if order.status == 'preparing' else '',
selected_shipped='selected' if order.status == 'shipped' else '',
selected_failed='selected' if order.status in ['failed', 'pending_payment'] else '',
)
if not html:
return '<p class="text-sm text-gray-500 p-4 text-center">Nada por aqu铆.</p>'
return html
# --- Fin de la funci贸n interna ---
context = {
"title": "Tablero de la Logia",
"orders_paid": render_order_cards(orders_by_status["paid"]),
"orders_preparing": render_order_cards(orders_by_status["preparing"]),
"orders_shipped": render_order_cards(orders_by_status["shipped"]),
"orders_failed": render_order_cards(orders_by_status["failed"]),
}
return render_template(HTML_ADMIN_DASHBOARD_CONTENT, context, request, db)
@app.post("/admin/order/update", response_class=RedirectResponse)
async def post_update_order_status(
request: Request,
db: Session = Depends(get_db),
admin: Admin = Depends(get_admin_user), # 隆Ruta Protegida!
order_id: int = Form(...),
new_status: str = Form(...)
):
"""Actualiza el estado de una orden."""
order = db.query(Order).filter(Order.id == order_id).first()
if order:
# Validar que el estado sea uno de los permitidos
allowed_statuses = [s.value for s in Order.status.type.enums]
if new_status in allowed_statuses:
order.status = new_status
db.commit()
print(f"[Admin] Orden {order_id} actualizada a {new_status} por {admin.username}")
else:
print(f"[Admin] Intento de actualizar a estado inv谩lido: {new_status}")
return RedirectResponse(url="/admin/dashboard", status_code=303)
# ======================================================================================
# 11. INICIAR LA APLICACI脫N (Para correr localmente)
# ======================================================================================
if __name__ == "__main__":
print("Iniciando servidor Uvicorn localmente en http://127.0.0.1:8000")
print(f"Admin User: {ADMIN_USER}")
print(f"Admin Pass: {ADMIN_PASSWORD}")
uvicorn.run(app, host="127.0.0.1", port=8000)