first commit
This commit is contained in:
commit
ca8848a9c8
81
.gitignore
vendored
Normal file
81
.gitignore
vendored
Normal 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
17
.markdownlint.json
Normal 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
5
README.md
Normal 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
0
api/__init__.py
Normal file
58
api/dependencies.py
Normal file
58
api/dependencies.py
Normal 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
0
api/routers/__init__.py
Normal file
35
api/routers/invoices.py
Normal file
35
api/routers/invoices.py
Normal 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
62
api/routers/users.py
Normal 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
31
api/schemas.py
Normal 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
|
||||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
34
core/config.py
Normal file
34
core/config.py
Normal 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
50
core/security.py
Normal 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
0
db/__init__.py
Normal file
57
db/crud.py
Normal file
57
db/crud.py
Normal 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
30
db/database.py
Normal 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
12
db/models.py
Normal 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
41
main.py
Normal 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
29
pyproject.toml
Normal 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
46
requirements.txt
Normal 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
0
services/__init__.py
Normal file
36
services/gcp_document_ai_client.py
Normal file
36
services/gcp_document_ai_client.py
Normal 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
|
||||||
55
services/invoice_processor_service.py
Normal file
55
services/invoice_processor_service.py
Normal 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
|
||||||
0
services/utils/__init__.py
Normal file
0
services/utils/__init__.py
Normal file
87
services/utils/data_cleaner.py
Normal file
87
services/utils/data_cleaner.py
Normal 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
64
templates/dashboard.html
Normal 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
91
templates/login.html
Normal 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
103
templates/register.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user