First commit

This commit is contained in:
unknown
2026-06-09 21:18:13 -03:00
commit 5bff6b938b
66 changed files with 10922 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Exportar los módulos principales
from .database import Base, get_db
from . import models
from . import schemas
__all__ = ["Base", "get_db", "models", "schemas"]
+38
View File
@@ -0,0 +1,38 @@
import sys
from database import SessionLocal
from models import User
from services import AuthService
from os import getenv
def create_admin():
email = getenv("ADMIN_EMAIL")
password = getenv("ADMIN_PASSWORD")
if not email or not password:
raise RuntimeError("ADMIN_EMAIL y ADMIN_PASSWORD is required.")
if len(password) < 8:
raise RuntimeError("ADMIN_PASSWORD need almost 8 caracters.")
db = SessionLocal()
try:
existing = db.query(User).filter(User.email == email).first()
if existing:
print(f"[INFO] Admin '{email}' already exists, skipping creation.")
return
admin = User(
name='Admin',
email=email,
password=AuthService.hash_password(password),
rol='admin',
active=True
)
db.add(admin)
db.commit()
print(f"[INFO] Admin '{email}' created successfully.")
except Exception as e:
db.rollback()
print(f"[ERROR] Failed to create admin: {e}", file=sys.stderr)
raise
finally:
db.close()
+231
View File
@@ -0,0 +1,231 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from typing import List
from slowapi import Limiter
from slowapi.util import get_remote_address
import re
from database import get_db
from models import User
from schemas import (
UserCreate, UserUpdate, UserResponse, UserWithModifications,
RoleChangeRequest, LoginRequest, Token, ModificationResponse
)
from services import UserService, AuthService
from dependencies import get_current_user, require_admin, require_admin_or_owner, oauth2_scheme
router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
# Rutas de Autenticación
@router.post("/login", response_model=Token)
def login(
login_data: LoginRequest,
db: Session = Depends(get_db)
):
user_service = UserService(db)
user = AuthService.authenticate_user(db, login_data.email, login_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
access_token = AuthService.create_access_token(data={"user_id": user.id})
return {
"access_token": access_token,
"token_type": "bearer",
"user": user
}
# Rutas de Usuarios
@router.get("/users/", response_model=List[UserResponse])
def get_all_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
user_service = UserService(db)
return user_service.get_all_users(skip, limit)
@router.get("/users/pending", response_model=List[UserResponse])
def get_pending_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
user_service = UserService(db)
return user_service.get_pending_users(skip, limit)
@router.get("/users/me", response_model=UserResponse)
def get_current_user_info(current_user: User = Depends(get_current_user)):
return current_user
@router.get("/users/{user_id}", response_model=UserWithModifications)
def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
user_service = UserService(db)
user = user_service.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verificar permisos (admin o el propio usuario)
if current_user.rol != 'admin' and current_user.id != user_id:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
@router.post("/users/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
user_data: UserCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
user_service = UserService(db)
ip_address = request.client.host if request.client else None
return user_service.create_user(user_data, current_user.id, ip_address)
@router.post("/users/common", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
def create_common_user(
user_data: UserCreate,
request: Request,
db: Session = Depends(get_db),
):
"""Endpoint público para que cualquier usuario (no autenticado) pueda registrarse.
The created user will always have `rol='operator'` and `active=False`.
For audit purposes, when no authenticated updater is available we record the
created user itself as the `updater` (self-created).
"""
user_service = UserService(db)
ip_address = request.client.host if request.client else None
# Ensure role and active defaults regardless of input
user_data = user_data.copy(update={"rol": "operator", "active": False})
# Pass updater_id=None so the service will mark the new user as the updater
return user_service.create_user(user_data, updater_id=None, ip_address=ip_address)
@router.get("/logs/users", response_model=List[ModificationResponse])
def get_users_modifications(
skip: int = 0,
limit: int = 200,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Verificar permisos
if current_user.rol != 'admin':
raise HTTPException(status_code=403, detail="Insufficient permissions")
user_service = UserService(db)
return user_service.get_users_modifications(skip, limit)
@router.put("/users/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user_data: UserUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
user_service = UserService(db)
# Verificar permisos
if current_user.rol != 'admin' and current_user.id != user_id:
raise HTTPException(status_code=403, detail="Insufficient permissions")
edited_user = user_service.get_user_by_id(user_id)
"""Verify is the desactivated used is and admin, in this case check if there is at least another active admin before allowing the activation."""
if user_data.active is not None and edited_user.rol == 'admin' and user_data.active == False:
active_admins = user_service.count_active_admins()
if active_admins <= 1:
raise HTTPException(
status_code=400,
detail="Cannot deactivate this user because it is the only active admin. Please activate another admin first."
)
ip_address = request.client.host if request.client else None
return user_service.update_user(user_id, user_data, current_user.id, ip_address)
@router.patch("/users/{user_id}/role", response_model=UserResponse)
def change_user_role(
user_id: int,
role_data: RoleChangeRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
user_service = UserService(db)
ip_address = request.client.host if request.client else None
return user_service.change_user_role(user_id, role_data.new_role, current_user.id, ip_address)
@router.post("/users/{user_id}/deactivate", response_model=UserResponse)
def deactivate_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
user_service = UserService(db)
ip_address = request.client.host if request.client else None
"""Verify is the desactivated used is and admin, in this case check if there is at least another active admin before allowing the activation."""
if current_user.rol == 'admin':
desactivated_user = user_service.get_user_by_id(user_id)
if desactivated_user and desactivated_user.rol == 'admin':
active_admins = user_service.count_active_admins()
if active_admins <= 1:
raise HTTPException(
status_code=400,
detail="Cannot activate this user because it is the only active admin. Please activate another admin first."
)
return user_service.deactivate_user(user_id, current_user.id, ip_address)
@router.post("/users/{user_id}/activate", response_model=UserResponse)
def activate_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
user_service = UserService(db)
ip_address = request.client.host if request.client else None
return user_service.activate_user(user_id, current_user.id, ip_address)
@router.get("/users/{user_id}/modifications", response_model=List[ModificationResponse])
def get_user_modifications(
user_id: int,
skip: int = 0,
limit: int = 200,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Verificar permisos
if current_user.rol != 'admin' and current_user.id != user_id:
raise HTTPException(status_code=403, detail="Insufficient permissions")
user_service = UserService(db)
return user_service.get_user_modifications(user_id, skip, limit)
@router.delete("/users/{user_id}", response_model=UserResponse)
def delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
user_service = UserService(db)
ip_address = request.client.host if request.client else None
if current_user.rol != 'admin' and current_user.id != user_id:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user_service.delete_user(user_id, current_user.id, ip_address)
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
# Configuración de la base de datos
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./users.db")
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} if SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependencia para obtener la sesión de la base de datos
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+49
View File
@@ -0,0 +1,49 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from database import get_db
from services import AuthService, UserService
from models import User
# Configuración OAuth2
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login")
# Dependencias de seguridad
def get_current_user(
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)
) -> User:
user_id = AuthService.verify_token(token)
user_service = UserService(db)
user = user_service.get_user_by_id(user_id)
if not user or not user.active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token or inactive user",
)
return user
def get_current_active_user(current_user: User = Depends(get_current_user)):
if not current_user.active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def require_admin(current_user: User = Depends(get_current_active_user)):
if current_user.rol != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
def require_admin_or_owner(
user_id: int,
current_user: User = Depends(get_current_active_user)
):
if current_user.rol != 'admin' and current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
+44
View File
@@ -0,0 +1,44 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import crud
from database import engine
from models import Base
from create_admin import create_admin
from os import getenv
# Crear tablas
Base.metadata.create_all(bind=engine)
app = FastAPI(title="User Management API", version="1.0.0")
# Configurar CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[getenv("FRONT_END_URL")],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["Authorization", "Content-Type"],
)
# Incluir rutas
app.include_router(crud.router, prefix="/api/v1", tags=["api"])
@app.get("/")
def root():
return {"message": "User Management API"}
@app.get("/health")
def health_check():
return {"status": "healthy"}
##Eliminar en producción
@app.on_event("startup")
async def startup_event():
"""Ejecutar scraper síncrono en thread separado"""
# Usar asyncio.to_thread para ejecutar código síncrono
create_admin()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+40
View File
@@ -0,0 +1,40 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
from database import Base
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
email = Column(String(100), unique=True, nullable=False)
password = Column(String(255), nullable=False)
rol = Column(String(50), default='operator')
active = Column(Boolean, default=True)
creation_time = Column(DateTime, default=datetime.utcnow)
update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
modifications = relationship('UserAudit', back_populates='user',
foreign_keys='UserAudit.user_id')
modifications_made = relationship('UserAudit', back_populates='updater',
foreign_keys='UserAudit.updater_id')
class UserAudit(Base):
__tablename__ = 'users_audit'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
updater_id = Column(Integer, ForeignKey('users.id'), nullable=False)
action = Column(String(50), nullable=False)
updated_attribute = Column(String(100))
before_value = Column(Text)
after_value = Column(Text)
snapshot = Column(JSON)
modification_date = Column(DateTime, default=datetime.utcnow)
ip_address = Column(String(45))
user = relationship('User', foreign_keys=[user_id], back_populates='modifications')
updater = relationship('User', foreign_keys=[updater_id], back_populates='modifications_made')
+67
View File
@@ -0,0 +1,67 @@
from pydantic import BaseModel, EmailStr
from typing import Optional, List
from datetime import datetime
# User Schemas
class UserBase(BaseModel):
name: str
email: EmailStr
rol: str = "operator"
active: bool = True
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = None
rol: Optional[str] = None
active: Optional[bool] = None
class UserResponse(UserBase):
id: int
creation_time: datetime
update_time: datetime
class Config:
from_attributes = True
class UserWithModifications(UserResponse):
modifications: List["ModificationSimple"] = []
# Audit Schemas
class ModificationSimple(BaseModel):
id: int
action: str
updated_attribute: Optional[str] = None
before_value: Optional[str] = None
after_value: Optional[str] = None
modification_date: datetime
class Config:
from_attributes = True
class ModificationResponse(ModificationSimple):
user: Optional[UserResponse] = None
updater: Optional[UserResponse] = None
# Auth Schemas
class Token(BaseModel):
access_token: str
token_type: str
user: UserResponse
class TokenData(BaseModel):
user_id: Optional[int] = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RoleChangeRequest(BaseModel):
new_role: str
# Resolver referencias circulares
UserWithModifications.model_rebuild()
ModificationResponse.model_rebuild()
+333
View File
@@ -0,0 +1,333 @@
from sqlalchemy.orm import Session
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
from datetime import datetime, timedelta
import os
from typing import Optional
from models import User, UserAudit
from schemas import UserCreate, UserUpdate
# Configuración de seguridad
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise RuntimeError("SECRET_KEY no configurada. Abortando inicio.")
if len(SECRET_KEY) < 20:
raise RuntimeError("SECRET_KEY demasiado corta")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class AuthService:
@staticmethod
def hash_password(password: str) -> str:
return pwd_context.hash(password)
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
@staticmethod
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
user = db.query(User).filter(User.email == email, User.active == True).first()
if not user:
return None
if not AuthService.verify_password(password, user.password):
return None
return user
@staticmethod
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
@staticmethod
def verify_token(token: str):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int = payload.get("user_id")
if user_id is None:
raise credentials_exception
return user_id
except JWTError:
raise credentials_exception
class UserService:
def __init__(self, db: Session):
self.db = db
def get_user_by_id(self, user_id: int) -> Optional[User]:
return self.db.query(User).filter(User.id == user_id).first()
def get_user_by_email(self, email: str) -> Optional[User]:
return self.db.query(User).filter(User.email == email).first()
def get_all_users(self, skip: int = 0, limit: int = 100):
return self.db.query(User).offset(skip).limit(limit).all()
def get_pending_users(self, skip: int = 0, limit: int = 100):
return self.db.query(User).filter(User.active == False).offset(skip).limit(limit).all()
def count_active_admins(self) -> int:
"""Helper function to count the number of active admin users."""
return self.db.query(User).filter(User.rol == 'admin', User.active == True).count()
def create_user(self, user_data: UserCreate, updater_id: Optional[int] = None, ip_address: str = None) -> User:
if self.get_user_by_email(user_data.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to create user"
)
if user_data.rol not in ['admin', 'operator']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid role specified"
)
if user_data.password and len(user_data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters long"
)
hashed_password = AuthService.hash_password(user_data.password)
user = User(
name=user_data.name,
email=user_data.email,
password=hashed_password,
rol=user_data.rol,
active=user_data.active
)
self.db.add(user)
self.db.flush()
# If no updater_id provided (unauthenticated creation), mark the user as the updater (self-created)
audit_updater = updater_id if updater_id is not None else user.id
audit = UserAudit(
user_id=user.id,
updater_id=audit_updater,
action="create",
after_value=f"User created with role: {user.rol}",
snapshot={
"name": user.name,
"email": user.email,
"rol": user.rol,
"active": user.active
},
ip_address=ip_address
)
self.db.add(audit)
self.db.commit()
self.db.refresh(user)
return user
def update_user(self, user_id: int, user_data: UserUpdate, updater_id: int, ip_address: str = None) -> User:
user = self.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if user_data.rol not in ['admin', 'operator']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid role specified"
)
if user_data.password and len(user_data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters long"
)
changes = []
snapshot = {
"name": user.name,
"email": user.email,
"rol": user.rol,
"active": user.active
}
for field, value in user_data.dict(exclude_unset=True).items():
if hasattr(user, field):
old_value = getattr(user, field)
if field == "password":
value = AuthService.hash_password(value)
old_value = "***"
if old_value != value:
setattr(user, field, value)
audit = UserAudit(
user_id=user_id,
updater_id=updater_id,
action="update",
updated_attribute=field,
before_value=str(old_value),
after_value=str(value) if field != "password" else "***",
snapshot=snapshot,
ip_address=ip_address
)
self.db.add(audit)
changes.append(field)
if changes:
self.db.commit()
self.db.refresh(user)
return user
def change_user_role(self, user_id: int, new_role: str, updater_id: int, ip_address: str = None) -> User:
user = self.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
old_role = user.rol
snapshot = {
"name": user.name,
"email": user.email,
"rol": user.rol,
"active": user.active
}
user.rol = new_role
audit = UserAudit(
user_id=user_id,
updater_id=updater_id,
action="role_change",
updated_attribute="rol",
before_value=old_role,
after_value=new_role,
snapshot=snapshot,
ip_address=ip_address
)
self.db.add(audit)
self.db.commit()
self.db.refresh(user)
return user
def deactivate_user(self, user_id: int, updater_id: int, ip_address: str = None) -> User:
user = self.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.active = False
audit = UserAudit(
user_id=user_id,
updater_id=updater_id,
action="deactivate",
updated_attribute="active",
before_value="True",
after_value="False",
snapshot={
"name": user.name,
"email": user.email,
"rol": user.rol,
"active": True
},
ip_address=ip_address
)
self.db.add(audit)
self.db.commit()
self.db.refresh(user)
return user
def activate_user(self, user_id: int, updater_id: int, ip_address: str = None) -> User:
user = self.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.active = True
audit = UserAudit(
user_id=user_id,
updater_id=updater_id,
action="activate",
updated_attribute="active",
before_value="False",
after_value="True",
snapshot={
"name": user.name,
"email": user.email,
"rol": user.rol,
"active": False
},
ip_address=ip_address
)
self.db.add(audit)
self.db.commit()
self.db.refresh(user)
return user
def get_user_modifications(self, user_id: int, skip: int = 0, limit: int = 50):
user = self.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return (self.db.query(UserAudit)
.filter(UserAudit.user_id == user_id)
.order_by(UserAudit.modification_date.desc())
.offset(skip)
.limit(limit)
.all())
def get_users_modifications(self, skip: int = 0, limit: int = 100):
return (self.db.query(UserAudit)
.order_by(UserAudit.modification_date.desc())
.offset(skip)
.limit(limit)
.all())
def delete_user(self, user_id: int, updater_id: int, ip_address: str = None):
user = self.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
snapshot = {
"name": user.name,
"email": user.email,
"rol": user.rol,
"active": user.active
}
self.db.delete(user)
audit = UserAudit(
user_id=user_id,
updater_id=updater_id,
action="delete",
after_value="User deleted",
snapshot=snapshot,
ip_address=ip_address
)
self.db.add(audit)
self.db.commit()