|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|