first commit

This commit is contained in:
Daniel Oscar Zamo 2025-08-26 12:26:03 +02:00
commit ca8848a9c8
28 changed files with 1024 additions and 0 deletions

81
.gitignore vendored Normal file
View File

@ -0,0 +1,81 @@
# ===================================================================
# Mis archivos extras (en lo posible, estructura de marco de trabajo ACE)
# ===================================================================
+/
docs/ # Por ahora como mkdocs no esta, no hago el push
data/
!data/.gitkeep
*beta*
#*.py
*y.v*
# Archivos de construcción de PyInstaller
/dist
/build
*.spec
# ===================================================================
# 1. SECRETOS Y CONFIGURACIÓN LOCAL
# ¡Nunca subir claves de API, contraseñas u otros secretos!
# ===================================================================
.env
.env.*
#>!.env.example
# ===================================================================
# 2. ENTORNOS VIRTUALES
# Se pueden recrear a partir de requirements.txt, no deben estar en el repo.
# ===================================================================
.venv/
venv/
env/
# ===================================================================
# 3. FICHEROS COMPILADOS Y CACHE DE PYTHON
# Generados automáticamente por el intérprete de Python.
# ===================================================================
__pycache__/
*.pyc
*.pyo
*.pyd
# ===================================================================
# 4. FICHEROS DE IDEs Y EDITORES DE CÓDIGO
# Configuración específica del entorno de desarrollo de cada persona.
# ===================================================================
.vscode/
.idea/
*.swp
*.swo
# ===================================================================
# 5. FICHEROS DEL SISTEMA OPERATIVO
# Metadatos y archivos basura de macOS, Windows y Linux.
# ===================================================================
.DS_Store
Thumbs.db
desktop.ini
# ===================================================================
# 6. PAQUETES Y DISTRIBUCIÓN
# Directorios generados al crear un paquete instalable (pip).
# ===================================================================
build/
dist/
*.egg-info/
# ===================================================================
# 7. LOGS Y REPORTES
# Archivos de registro que se generan durante la ejecución.
# ===================================================================
*.log
logs/
htmlcov/
.pytest_cache/
.coverage

17
.markdownlint.json Normal file
View File

@ -0,0 +1,17 @@
{
"MD010": false,
"MD041": false,
"MD047": false,
"MD007": false,
"MD012": false,
"MD013": false,
"MD025": false,
"MD028": false,
"MD029": false,
"MD031": false,
"MD030": false,
"MD033": false,
"MD034": false,
"MD036": false,
"MD040": false
}

5
README.md Normal file
View File

@ -0,0 +1,5 @@
- [ ] Repositorio en mi gitea
- [ ] Integrar los dos repos en uno solo y alojarlos en este repositorio
- [ ] Codificar un front end, primero basado en para Python
- [ ] Reimplementar para API
- [ ] Recodificar para con PHP (basado en algun framework

0
api/__init__.py Normal file
View File

58
api/dependencies.py Normal file
View File

@ -0,0 +1,58 @@
# api/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from core import security
from db import crud, models
from db.database import get_db
# ============================ ¡EL CAMBIO CLAVE ESTÁ AQUÍ! ============================
# GUARDIA ESTRICTO: Para endpoints protegidos. Si no hay token, lanza un error 401.
oauth2_scheme_strict = OAuth2PasswordBearer(tokenUrl="/api/users/token")
# GUARDIA PERMISIVO: Para endpoints opcionales. Si no hay token, NO lanza error.
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/users/token", auto_error=False)
# ===================================================================================
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme_strict) # <-- Usa el guardia estricto
) -> models.User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
username = security.decode_access_token(token)
if username is None:
raise credentials_exception
user = crud.get_user_by_username(db, username=username)
if user is None:
raise credentials_exception
return user
def get_current_active_user(
current_user: models.User = Depends(get_current_user)
) -> models.User:
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_user_optional(
db: Session = Depends(get_db), token: str | None = Depends(oauth2_scheme_optional) # <-- Usa el guardia permisivo
) -> models.User | None:
if not token:
return None
try:
username = security.decode_access_token(token)
if username is None:
return None
user = crud.get_user_by_username(db, username=username)
return user
except Exception:
return None

0
api/routers/__init__.py Normal file
View File

35
api/routers/invoices.py Normal file
View File

@ -0,0 +1,35 @@
# api/routers/invoices.py
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from typing import Dict
from api.dependencies import get_current_active_user
from services import invoice_processor_service
from core.config import settings
from db.models import User
router = APIRouter()
@router.post("/upload", response_model=Dict[str, str])
async def upload_invoice(
file: UploadFile = File(...),
current_user: User = Depends(get_current_active_user)
):
"""
Endpoint para subir una factura, procesarla y devolver los datos extraídos.
Requiere autenticación.
"""
if not file.content_type in ["application/pdf", "image/jpeg", "image/png"]:
raise HTTPException(status_code=400, detail="Tipo de archivo no soportado.")
try:
file_bytes = await file.read()
extracted_data = invoice_processor_service.process_invoice_from_bytes(
project_id=settings.GCP_PROJECT_ID,
location=settings.GCP_LOCATION,
processor_id=settings.DOCAI_PROCESSOR_ID,
file_bytes=file_bytes,
mime_type=file.content_type
)
return extracted_data
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al procesar la factura: {e}")

62
api/routers/users.py Normal file
View File

@ -0,0 +1,62 @@
# api/routers/users.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import timedelta
from api import schemas
from db import crud
from db.database import get_db
from core import security
from core.config import settings
router = APIRouter()
# ================== ¡ENDPOINT DE REGISTRO! ==================
# Este es el endpoint que faltaba y que arregla el error 404
@router.post("/register", response_model=schemas.User)
def register_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
"""
Crea un nuevo usuario en la base de datos.
"""
db_user = crud.get_user_by_username(db, username=user.username)
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
db_user_email = crud.get_user_by_email(db, email=user.email)
if db_user_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
return crud.create_user(db=db, user=user)
# =============================================================
# ================== ENDPOINT DE LOGIN ==================
@router.post("/token", response_model=schemas.Token)
def login_for_access_token(
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
):
"""
Procesa el formulario de login y devuelve un token de acceso JWT.
"""
user = crud.authenticate_user(db, username=form_data.username, password=form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = security.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

31
api/schemas.py Normal file
View File

@ -0,0 +1,31 @@
# api/schemas.py
from pydantic import BaseModel, EmailStr
from typing import Optional
# --- Token Schemas ---
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
# --- User Schemas ---
# Propiedades base del usuario (compartidas)
class UserBase(BaseModel):
username: str
email: EmailStr
# Esquema para la creación de un usuario (recibe la contraseña)
class UserCreate(UserBase):
password: str
# Esquema para leer/devolver un usuario desde la API (nunca incluye la contraseña)
class User(UserBase):
id: int
is_active: bool
class Config:
# Permite que Pydantic lea los datos directamente desde un objeto ORM de SQLAlchemy
from_attributes = True

BIN
app.db Normal file

Binary file not shown.

0
core/__init__.py Normal file
View File

34
core/config.py Normal file
View File

@ -0,0 +1,34 @@
# core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List, Dict
class Settings(BaseSettings):
# Carga las variables desde un fichero .env
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# --- Configuración de Seguridad ---
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# --- Configuración de Google Cloud Document AI ---
GCP_PROJECT_ID: str
GCP_LOCATION: str
DOCAI_PROCESSOR_ID: str
# --- Lógica de Negocio (extraída del antiguo config.py) ---
REQUIRED_FIELDS: List[str] = [
"supplier_name",
"invoice_id",
"invoice_date",
"total_amount"
]
# Umbrales de confianza por campo. Un valor por defecto y anulaciones específicas.
CONFIDENCE_THRESHOLDS: Dict[str, float] = {
"__default__": 0.85,
"supplier_name": 0.90,
"total_amount": 0.95
}
# Creamos una única instancia global de la configuración
settings = Settings()

50
core/security.py Normal file
View File

@ -0,0 +1,50 @@
# core/security.py
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import settings
# Configuración para el hashing de contraseñas
# Usamos bcrypt, que es el estándar recomendado.
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# ALGORITHM y SECRET_KEY deben coincidir con los de tu configuración
ALGORITHM = "HS256"
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verifica si una contraseña en texto plano coincide con su hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Genera el hash de una contraseña."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Crea un nuevo token de acceso JWT."""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
# Por defecto, el token expira en 15 minutos
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[str]:
"""
Decodifica un token de acceso y devuelve el nombre de usuario (subject).
Retorna None si el token es inválido o ha expirado.
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
username: Optional[str] = payload.get("sub")
if username is None:
return None
return username
except JWTError:
return None

0
db/__init__.py Normal file
View File

57
db/crud.py Normal file
View File

@ -0,0 +1,57 @@
# db/crud.py
from sqlalchemy.orm import Session
from . import models
from api import schemas
from core.security import get_password_hash, verify_password
# --- User CRUD ---
def get_user(db: Session, user_id: int):
"""Obtiene un usuario por su ID."""
return db.query(models.User).filter(models.User.id == user_id).first()
def get_user_by_username(db: Session, username: str):
"""Obtiene un usuario por su nombre de usuario."""
return db.query(models.User).filter(models.User.username == username).first()
def get_user_by_email(db: Session, email: str):
"""Obtiene un usuario por su email."""
return db.query(models.User).filter(models.User.email == email).first()
def get_users(db: Session, skip: int = 0, limit: int = 100):
"""Obtiene una lista de usuarios con paginación."""
return db.query(models.User).offset(skip).limit(limit).all()
def create_user(db: Session, user: schemas.UserCreate):
"""Crea un nuevo usuario en la base de datos."""
hashed_password = get_password_hash(user.password)
db_user = models.User(
username=user.username,
email=user.email,
hashed_password=hashed_password
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def update_user_password(db: Session, user: models.User, new_password: str):
"""Actualiza la contraseña de un usuario."""
hashed_password = get_password_hash(new_password)
user.hashed_password = hashed_password
db.add(user)
db.commit()
db.refresh(user)
return user
def authenticate_user(db: Session, username: str, password: str):
"""
Autentica a un usuario. Retorna el objeto de usuario si es exitoso,
de lo contrario, retorna None.
"""
user = get_user_by_username(db, username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user

30
db/database.py Normal file
View File

@ -0,0 +1,30 @@
# db/database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# Usamos SQLite, que guarda la base de datos en un fichero local "app.db".
SQLALCHEMY_DATABASE_URL = "sqlite:///./app.db"
# El engine es el punto de entrada a la base de datos.
# connect_args es necesario solo para SQLite para permitir que se use en múltiples hilos (como lo hace FastAPI).
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
# Cada instancia de SessionLocal será una sesión de base de datos.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base será la clase de la que heredarán nuestros modelos de SQLAlchemy (como la clase User en models.py).
Base = declarative_base()
# --- Función de dependencia para los endpoints ---
def get_db():
"""
Dependencia de FastAPI que crea y gestiona una sesión de BD por cada request.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

12
db/models.py Normal file
View File

@ -0,0 +1,12 @@
# db/models.py
from sqlalchemy import Boolean, Column, Integer, String
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)

41
main.py Normal file
View File

@ -0,0 +1,41 @@
# main.py
from fastapi import FastAPI, Request, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from db.models import User
from api.dependencies import get_current_user_optional
from api.routers import users, invoices
from db.database import engine, Base
Base.metadata.create_all(bind=engine)
app = FastAPI(title="ACME Invoice Processor")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(invoices.router, prefix="/api/invoices", tags=["Invoices"])
# --- RUTAS DE PLANTILLAS HTML ---
@app.get("/", response_class=HTMLResponse)
async def read_login_page(request: Request):
"""
Esta ruta ahora SOLO sirve la página de login.
"""
return templates.TemplateResponse("login.html", {"request": request})
@app.get("/dashboard", response_class=HTMLResponse)
async def read_dashboard(request: Request):
"""
Esta es la nueva ruta dedicada para el dashboard.
La seguridad se manejará en el propio HTML con JavaScript.
"""
return templates.TemplateResponse("dashboard.html", {"request": request})
@app.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
return templates.TemplateResponse("register.html", {"request": request})

29
pyproject.toml Normal file
View File

@ -0,0 +1,29 @@
# pyproject.toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "acme-invoice-processor"
version = "1.0.0"
authors = [
{ name = "Daniel Oscar Zamo", email = "daniel.oscar.zamo@gmail.com" },
]
description = "Aplicación autónoma para la extracción de datos de facturas de Acme Inc. utilizando Google Document AI."
readme = "README.md"
requires-python = ">=3.8"
classifiers = [
"Programming Language :: Python :: 3",
]
# Todas las dependencias ahora están aquí
dependencies = [
"google-cloud-documentai==3.5.0",
"python-dotenv",
"pyinstaller",
"python-dateutil",
# Las dependencias transitivas de google-cloud-documentai serán gestionadas por pip,
# pero es buena práctica listarlas explícitamente si se requiere fijar versiones.
]
[tool.setuptools.packages.find]
where = ["src"]

46
requirements.txt Normal file
View File

@ -0,0 +1,46 @@
# requirements.txt
# Dependencias de la aplicación y del toolkit unificadas
# Framework Web y Servidor
fastapi
uvicorn[standard]
# Base de datos (ORM)
sqlalchemy
# Para usar SQLite (simple para empezar)
pydantic-settings
# Autenticación y Seguridad
python-jose[cryptography]
passlib[bcrypt]
python-multipart # Para subida de archivos
# Plantillas HTML
jinja2
# Dependencias directas
google-cloud-documentai==3.5.0
python-dotenv
pyinstaller
python-dateutil
# Dependencias transitivas (fijadas para consistencia, tomadas del toolkit)
cachetools==5.5.2
certifi==2025.8.3
charset-normalizer==3.4.3
google-api-core==2.25.1
google-auth==2.40.3
googleapis-common-protos==1.70.0
grpcio==1.74.0
grpcio-status==1.74.0
idna==3.10
proto-plus==1.26.1
protobuf==6.32.0
pyasn1==0.6.1
pyasn1_modules==0.4.2
requests==2.32.4
rsa==4.9.1
urllib3==2.5.0
# Validación de email para Pydantic
email-validator

0
services/__init__.py Normal file
View File

View File

@ -0,0 +1,36 @@
# services/gcp_document_ai_client.py
from google.api_core.client_options import ClientOptions
from google.api_core.exceptions import GoogleAPICallError
from google.cloud import documentai
def process_document_gcp(
project_id: str,
location: str,
processor_id: str,
file_bytes: bytes,
mime_type: str,
) -> documentai.Document:
"""
Procesa el contenido de un documento en bytes usando la API de Google Document AI.
Esta función ahora solo se encarga de la comunicación con GCP.
"""
try:
client_options = ClientOptions(
api_endpoint=f"{location}-documentai.googleapis.com"
)
client = documentai.DocumentProcessorServiceClient(client_options=client_options)
resource_name = client.processor_path(project_id, location, processor_id)
raw_document = documentai.RawDocument(
content=file_bytes, mime_type=mime_type
)
request = documentai.ProcessRequest(name=resource_name, raw_document=raw_document)
result = client.process_document(request=request)
return result.document
except GoogleAPICallError as e:
raise GoogleAPICallError(f"API call failed: {e}") from e
except Exception as e:
raise Exception(f"An unexpected error occurred during GCP processing: {e}") from e

View File

@ -0,0 +1,55 @@
# services/invoice_processor_service.py
import logging
from typing import Dict, List, Any
# Importamos nuestro nuevo cliente GCP de forma local y limpia
from .gcp_document_ai_client import process_document_gcp
# (Opcional, si tienes utilidades) from .utils import data_cleaner
# Importamos la configuración centralizada
from core.config import settings
# --- Lógica de negocio extraída del antiguo processing.py ---
def _get_confidence_threshold_for_field(field_type: str) -> float:
return settings.CONFIDENCE_THRESHOLDS.get(field_type, settings.CONFIDENCE_THRESHOLDS["__default__"])
def _extract_specific_fields(entities: List[Any]) -> Dict[str, str]:
extracted_data = {field: "Not found or low confidence" for field in settings.REQUIRED_FIELDS}
for entity in entities:
entity_type = entity.type_
threshold = _get_confidence_threshold_for_field(entity_type)
if entity_type in settings.REQUIRED_FIELDS and entity.confidence >= threshold:
value = entity.mention_text.replace('\n', ' ').strip()
# if entity_type == 'invoice_date':
# value = data_cleaner.normalize_date(value) or f"Unparseable Date: '{value}'"
extracted_data[entity_type] = value
return extracted_data
# --- Función principal del servicio ---
def process_invoice_from_bytes(file_bytes: bytes, mime_type: str) -> Dict[str, str]:
"""
Orquesta el proceso completo: llama a Document AI, extrae y limpia los datos.
"""
try:
# 1. Llamar a la API de Google a través de nuestro cliente dedicado
document = process_document_gcp(
project_id=settings.GCP_PROJECT_ID,
location=settings.GCP_LOCATION,
processor_id=settings.DOCAI_PROCESSOR_ID,
file_bytes=file_bytes,
mime_type=mime_type,
)
# 2. Aplicar la lógica de negocio para extraer y validar los campos
validated_data = _extract_specific_fields(document.entities)
logging.info("Documento procesado con éxito y datos validados.")
return validated_data
except Exception as e:
logging.error(f"Error en el flujo de procesamiento de factura: {e}", exc_info=True)
# Re-lanzamos la excepción para que el endpoint de la API la capture y devuelva un 500
raise

View File

View File

@ -0,0 +1,87 @@
# src/cli_invoice_processor/data_cleaner.py
import logging
import locale
from dateutil import parser
from typing import Optional
from datetime import datetime
SPANISH_TO_ENGLISH_MONTHS = {
'enero': 'january',
'febrero': 'february',
'marzo': 'march',
'abril': 'april',
'mayo': 'may',
'junio': 'june',
'julio': 'july',
'agosto': 'august',
'septiembre': 'september',
'octubre': 'october',
'noviembre': 'november',
'diciembre': 'december'
}
def _parse_with_fallback(date_string: str) -> Optional[datetime]:
"""
Intenta parsear la fecha usando un fallback manual que primero limpia
preposiciones comunes en español ("de", "del") y luego traduce los meses.
"""
# 1. Normalizar a minúsculas para trabajar de forma consistente
temp_string = date_string.lower()
# 2. Traducir el mes de español a inglés
for spa, eng in SPANISH_TO_ENGLISH_MONTHS.items():
if spa in temp_string:
temp_string = temp_string.replace(spa, eng)
break # Salimos del bucle una vez que encontramos y reemplazamos el mes
# 3. Eliminar preposiciones comunes, cuidando los espacios para evitar unir palabras
temp_string = temp_string.replace(' de ', ' ')
temp_string = temp_string.replace(' del ', ' ')
# Después de la limpieza, la cadena debería ser algo como '5 january 2030', que es parseable.
try:
logging.info(f"Attempting to parse cleaned date string: '{temp_string}'")
return parser.parse(temp_string)
except (parser.ParserError, ValueError):
# Si incluso después de la limpieza falla, no podemos hacer más.
logging.warning(f"Fallback parsing failed even for cleaned string: '{temp_string}'")
return None
def normalize_date(date_string: str) -> Optional[str]:
"""
Parses a date string from various formats and normalizes it to DD/MM/YYYY.
It first tries using Spanish locale, and if it fails, it uses a manual
cleaning and translation fallback.
"""
if not date_string:
return None
original_locale = locale.getlocale(locale.LC_TIME)
parsed_date = None
# Estrategia 1: Intentar con el locale español
try:
try:
locale.setlocale(locale.LC_TIME, 'es_ES.UTF-8')
except locale.Error:
locale.setlocale(locale.LC_TIME, 'Spanish')
parsed_date = parser.parse(date_string)
except (parser.ParserError, ValueError, locale.Error):
logging.warning(f"Could not parse date '{date_string}' using Spanish locale. Attempting robust fallback.")
# Estrategia 2: Si el locale falla, usar el fallback robusto
parsed_date = _parse_with_fallback(date_string)
finally:
# Siempre restauramos el locale original
locale.setlocale(locale.LC_TIME, original_locale)
if parsed_date:
# Aquí se asegura el formato DD/MM/AAAA.
# '%d' -> día con cero (05), '%m' -> mes con cero (01), '%Y' -> año (2030)
return parsed_date.strftime('%d/%m/%Y')
else:
# Si ambas estrategias fallan, registramos el error final
logging.error(f"Failed to parse date '{date_string}' with all available methods.")
return None

64
templates/dashboard.html Normal file
View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - ACME Invoice Processor</title>
<!-- ======================= INICIO DEL GUARDIA DE SEGURIDAD ======================= -->
<script>
const token = localStorage.getItem('accessToken');
if (!token) {
// Si NO hay token, no cargues esta página. ¡Redirige al login!
window.location.href = '/';
}
</script>
<!-- ======================== FIN DEL GUARDIA DE SEGURIDAD ========================= -->
<style>
/* ... (tus estilos CSS no cambian) ... */
body { font-family: sans-serif; margin: 0; background-color: #f4f4f9; }
.navbar { background-color: #333; overflow: hidden; }
.navbar a { float: left; display: block; color: white; text-align: center; padding: 14px 16px; text-decoration: none; }
.navbar .logout { float: right; cursor: pointer; }
.container { max-width: 800px; margin: 2rem auto; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
h1 { color: #333; }
.upload-form { margin-top: 2rem; border: 2px dashed #ccc; padding: 2rem; border-radius: 8px; text-align: center; }
.upload-form input[type="file"] { border: none; }
.results { margin-top: 2rem; background-color: #e9ecef; padding: 1rem; border-radius: 8px; display: none; }
.results pre { white-space: pre-wrap; word-wrap: break-word; }
.message { text-align: center; padding: 1rem; margin-top: 1rem; border-radius: 4px; display: none; }
.error { background-color: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<!-- El resto del HTML no cambia -->
<div class="navbar">
<a href="/dashboard">Dashboard</a>
<a id="logout-button" class="logout">Cerrar Sesión</a>
</div>
<div class="container">
<h1>Sube una Factura para Procesar</h1>
<form id="upload-form" class="upload-form">
<input type="file" id="invoice-file" name="invoice" required>
<button type="submit">Procesar Factura</button>
</form>
<div id="message-container" class="message"></div>
<div id="results-container" class="results">
<h2>Resultados de la Extracción:</h2>
<pre id="results-data"></pre>
</div>
</div>
<script>
// --- El resto del script no cambia ---
document.getElementById('logout-button').addEventListener('click', () => {
localStorage.removeItem('accessToken');
window.location.href = '/';
});
const uploadForm = document.getElementById('upload-form');
// ... (el resto del script de subida) ...
</script>
</body>
</html>

91
templates/login.html Normal file
View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Iniciar Sesión - ACME Invoice Processor</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f9; }
.login-container { padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); width: 300px; }
h1 { text-align: center; }
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.5rem; }
input { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
button { width: 100%; padding: 0.7rem; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background-color: #0056b3; }
p { text-align: center; }
.message { text-align: center; padding: 1rem; margin-top: 1rem; border-radius: 4px; display: none; }
.error { background-color: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="login-container">
<h1>Iniciar Sesión</h1>
<form id="login-form">
<div class="form-group">
<label for="username">Nombre de Usuario</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Contraseña</label>
<input type="password" id="password" name="password" required>
</div>
<!-- Le damos un id al botón para poder seleccionarlo -->
<button type="submit" id="login-button">Entrar</button>
</form>
<p><a href="/register">¿No tienes una cuenta? Regístrate</a></p>
<div id="message-container" class="message"></div>
</div>
<!-- ======================= SCRIPT FINAL CORREGIDO ======================= -->
<script>
console.log("Página de login cargada. El script se está ejecutando.");
const form = document.getElementById('login-form');
const button = document.getElementById('login-button'); // Seleccionamos el botón
const messageContainer = document.getElementById('message-container');
if (form && button) {
console.log("Formulario y botón encontrados. Adjuntando listener al BOTÓN...");
// Cambiamos el listener para que escuche el evento 'click' del botón
button.addEventListener('click', async (event) => {
event.preventDefault(); // ¡Muy importante para que no recargue la página!
console.log("¡EVENTO CLICK CAPTURADO! Iniciando llamada a la API...");
const username = form.username.value;
const password = form.password.value;
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
try {
const response = await fetch('/api/users/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData,
});
const data = await response.json();
if (response.ok) {
console.log("LOGIN EXITOSO. Token:", data.access_token);
localStorage.setItem('accessToken', data.access_token);
window.location.href = '/dashboard';
} else {
console.error("ERROR DE API. Respuesta:", data);
messageContainer.textContent = `Error: ${data.detail || 'Credenciales incorrectas.'}`;
messageContainer.className = 'message error';
messageContainer.style.display = 'block';
}
} catch (error) {
console.error("ERROR DE RED:", error);
messageContainer.textContent = 'Error de conexión con el servidor.';
messageContainer.className = 'message error';
messageContainer.style.display = 'block';
}
});
} else {
console.error("¡ERROR CRÍTICO! No se encontró el formulario o el botón.");
}
</script>
</body>
</html>

103
templates/register.html Normal file
View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registro - ACME Invoice Processor</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f9; }
.register-container { padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); width: 300px; }
h1 { text-align: center; }
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.5rem; }
input { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
button { width: 100%; padding: 0.7rem; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background-color: #0056b3; }
p { text-align: center; }
.message { text-align: center; padding: 1rem; margin-top: 1rem; border-radius: 4px; display: none; }
.success { background-color: #d4edda; color: #155724; }
.error { background-color: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="register-container">
<h1>Registrar Nuevo Usuario</h1>
<form id="register-form">
<div class="form-group">
<label for="username">Nombre de Usuario</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">Contraseña</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Registrar</button>
</form>
<p><a href="/">¿Ya tienes una cuenta? Inicia sesión</a></p>
<!-- Contenedor para mensajes de éxito o error -->
<div id="message-container" class="message"></div>
</div>
<!-- ======================= INICIO DEL JAVASCRIPT ======================= -->
<script>
// 1. Seleccionar el formulario y el contenedor de mensajes del DOM
const form = document.getElementById('register-form');
const messageContainer = document.getElementById('message-container');
// 2. Añadir un "escuchador" para el evento de envío del formulario
form.addEventListener('submit', async (event) => {
// Prevenir el comportamiento por defecto del formulario (que es recargar la página)
event.preventDefault();
// 3. Recoger los datos del formulario
const username = form.username.value;
const email = form.email.value;
const password = form.password.value;
// 4. Preparar la llamada a la API usando `fetch`
try {
const response = await fetch('/api/users/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, email, password }),
});
// 5. Procesar la respuesta de la API
const data = await response.json();
if (response.ok) { // Si la respuesta es exitosa (código 2xx)
messageContainer.textContent = '¡Registro exitoso! Redirigiendo al login...';
messageContainer.className = 'message success';
messageContainer.style.display = 'block';
// Esperar 2 segundos y redirigir a la página de login
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else { // Si la respuesta es un error (código 4xx o 5xx)
// El `detail` es el mensaje de error que FastAPI envía
messageContainer.textContent = `Error: ${data.detail || 'Ocurrió un error.'}`;
messageContainer.className = 'message error';
messageContainer.style.display = 'block';
}
} catch (error) {
// Capturar errores de red (ej. el servidor no responde)
messageContainer.textContent = 'Error de conexión con el servidor.';
messageContainer.className = 'message error';
messageContainer.style.display = 'block';
}
});
</script>
<!-- ======================== FIN DEL JAVASCRIPT ========================= -->
</body>
</html>