| |
| |
| |
|
|
| import os |
| import uvicorn |
| import mercadopago |
| from datetime import datetime |
| from typing import List, Optional, Dict, Any |
|
|
| |
| 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 |
|
|
| |
| from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Enum as DBEnum |
| from sqlalchemy.orm import sessionmaker, relationship, Session, declarative_base |
|
|
| |
| from pydantic import BaseModel |
|
|
| |
| from passlib.context import CryptContext |
| import secrets |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| ADMIN_USER = os.environ.get("ADMIN_USER", "gran.maestro@arcanum.sal") |
| ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "gadU_357") |
| SESSION_SECRET_KEY = os.environ.get("SESSION_SECRET_KEY", secrets.token_hex(32)) |
|
|
| |
| |
| |
| MP_ACCESS_TOKEN = os.environ.get("MP_ACCESS_TOKEN", "TEST-SAMPLE-TOKEN") |
| 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) |
|
|
| |
| |
| 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() |
|
|
| |
| pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") |
|
|
| |
| app = FastAPI(title="Arcanum Salis API", description="El secreto de las cajas saladas.") |
|
|
| |
| mp_sdk = mercadopago.SDK(MP_ACCESS_TOKEN) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| ingredients = Column(String, nullable=False) |
|
|
| 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) |
| |
| order = relationship("Order", back_populates="items") |
| product = relationship("Product") |
|
|
|
|
| |
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| 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> |
| """ |
|
|
| |
| 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>© {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> |
| """ |
|
|
| |
| 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> |
| """ |
|
|
| |
| 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> |
| """ |
|
|
| |
| 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> |
| """ |
|
|
| |
| 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">×</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> |
| """ |
|
|
| |
| 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> |
| """ |
|
|
| |
| 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> |
| """ |
|
|
| |
| 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> |
| """ |
|
|
| |
| 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> |
| """ |
|
|
| |
| 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> |
| """ |
|
|
| |
| |
| |
|
|
| |
| def get_db(): |
| """Funci贸n de dependencia para obtener una sesi贸n de DB.""" |
| db = SessionLocal() |
| try: |
| yield db |
| finally: |
| db.close() |
|
|
| |
| 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: |
| |
| 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 |
|
|
| |
| 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_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())} |
|
|
| |
| 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. |
| """ |
| |
| context["current_year"] = datetime.utcnow().year |
| context["MASONIC_SYMBOL_SVG"] = MASONIC_SYMBOL_SVG |
| |
| |
| cart_count = 0 |
| if db: |
| 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>' |
| |
| |
| content_html = template.format(**context) |
| |
| |
| 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) |
|
|
| |
| |
| |
|
|
| @app.on_event("startup") |
| def on_startup(): |
| """Se ejecuta al iniciar la aplicaci贸n.""" |
| print("Iniciando Arcanum Salis...") |
| |
| |
| Base.metadata.create_all(bind=engine) |
| print("Tablas de la base de datos verificadas/creadas.") |
| |
| |
| 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.") |
| |
| |
| if db.query(Product).count() == 0: |
| print("Poblando la base de datos con los 3 Grados...") |
| |
| |
| 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" |
|
|
| |
| 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() |
|
|
| |
| |
| |
|
|
| |
| app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY) |
|
|
|
|
| |
| |
| |
|
|
| @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: |
| |
| 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 |
| } |
| 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", {}) |
| |
| |
| product_id_str = str(product_id) |
| |
| cart[product_id_str] = cart.get(product_id_str, 0) + 1 |
| request.session["cart"] = cart |
| |
| |
| 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] |
| |
| request.session["cart"] = cart |
| return RedirectResponse(url="/cart", status_code=303) |
|
|
| |
| |
| |
|
|
| @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) |
|
|
| |
| 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() |
| |
| mp_items = [] |
| for item in cart_data["items"]: |
| |
| 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) |
| |
| |
| mp_items.append({ |
| "id": str(item["product_id"]), |
| "title": item["name"], |
| "quantity": item["quantity"], |
| "unit_price": item["price"], |
| "currency_id": "ARS" |
| }) |
| |
| db.commit() |
|
|
| |
| |
| 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" |
| }, |
| "auto_return": "approved", |
| "notification_url": f"{base_url}payment/webhook", |
| "external_reference": str(new_order.id), |
| "metadata": { |
| "order_id": new_order.id |
| } |
| } |
|
|
| try: |
| preference_response = mp_sdk.preference().create(preference_data) |
| preference = preference_response["response"] |
| |
| |
| new_order.mp_preference_id = preference["id"] |
| db.commit() |
| |
| |
| request.session["cart"] = {} |
| |
| |
| request.session["last_order_id"] = new_order.id |
| |
| |
| |
| 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}") |
| |
| new_order.status = "failed" |
| db.commit() |
| |
| 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.""" |
| |
| |
| 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="/") |
| |
| |
| payment_status = request.query_params.get("status") |
| external_reference = request.query_params.get("external_reference") |
|
|
| |
| if payment_status == "approved" and external_reference == str(order.id): |
| |
| if order.status == "pending_payment": |
| order.status = "paid" |
| db.commit() |
| else: |
| |
| return RedirectResponse(url="/") |
|
|
| context = { |
| "title": "Ritual Completado", |
| "order_id": order.id, |
| "email": order.customer_email |
| } |
| |
| |
| 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() |
| |
| |
| 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) |
|
|
| |
| 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) |
| |
| except Exception as e: |
| print(f"Error en Webhook: {e}") |
| return Response(status_code=500) |
|
|
|
|
| |
| |
| |
|
|
| @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 |
| } |
| |
| |
| 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): |
| |
| return RedirectResponse(url="/admin/login?error=1", status_code=303) |
| |
| |
| 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) |
| ): |
| """Muestra el dashboard de admin con todas las 贸rdenes.""" |
| |
| |
| orders = db.query(Order).order_by(Order.created_at.desc()).all() |
| |
| orders_by_status = { |
| "paid": [], |
| "preparing": [], |
| "shipped": [], |
| "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: |
| orders_by_status["failed"].append(order) |
|
|
| |
| 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 |
| |
|
|
| 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), |
| 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: |
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|