commit 61d386a7c73e324234bc7926fa108d66b71739c1 Author: gerardoe Date: Wed Apr 1 13:48:40 2026 -0700 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..383bdef --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +# .env + + +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres \ No newline at end of file diff --git a/.envexample b/.envexample new file mode 100644 index 0000000..e69de29 diff --git a/.gitingnore b/.gitingnore new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..8683d36 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,16 @@ +# Dockerfile.dev +FROM python:3.11-alpine + +WORKDIR /app + +RUN apk add --no-cache \ + gcc \ + musl-dev \ + postgresql-dev + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..6d200d8 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql+psycopg2://postgres:postgres@postgres:5432/postgres + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__pycache__/__init__.cpython-311.pyc b/app/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d5737c5 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/core/__pycache__/auth.cpython-311.pyc b/app/core/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000..d971fa1 Binary files /dev/null and b/app/core/__pycache__/auth.cpython-311.pyc differ diff --git a/app/core/__pycache__/base.cpython-311.pyc b/app/core/__pycache__/base.cpython-311.pyc new file mode 100644 index 0000000..dca0685 Binary files /dev/null and b/app/core/__pycache__/base.cpython-311.pyc differ diff --git a/app/core/__pycache__/baseRepository.cpython-311.pyc b/app/core/__pycache__/baseRepository.cpython-311.pyc new file mode 100644 index 0000000..1ab5fd9 Binary files /dev/null and b/app/core/__pycache__/baseRepository.cpython-311.pyc differ diff --git a/app/core/__pycache__/security.cpython-311.pyc b/app/core/__pycache__/security.cpython-311.pyc new file mode 100644 index 0000000..66a8c8c Binary files /dev/null and b/app/core/__pycache__/security.cpython-311.pyc differ diff --git a/app/core/__pycache__/servo.cpython-311.pyc b/app/core/__pycache__/servo.cpython-311.pyc new file mode 100644 index 0000000..b2eeb08 Binary files /dev/null and b/app/core/__pycache__/servo.cpython-311.pyc differ diff --git a/app/core/affidavit.py b/app/core/affidavit.py new file mode 100644 index 0000000..910dd02 --- /dev/null +++ b/app/core/affidavit.py @@ -0,0 +1,184 @@ +# core/affidavit.py +from datetime import datetime +from typing import Any, Dict, Optional, List +import hashlib +import json +from sqlalchemy.orm import Session + +from app.modules.affidavit_logs.models import Operation, StatusOP, AffidavitRecord + + +class LegalAffidavit: + """ + Certificador Legal Digital + Cada operación genera un acta notarial inmutable + """ + + def __init__(self, db: Session, user, operation: str, client_id: int = None): + self.db = db + self.user = user + self.operation = operation + self.client_id = client_id + self.timestamp = datetime.utcnow() + self.entities_affected: List[Dict] = [] + self.rules_validated: List[Dict] = [] + self.evidence: List[Dict] = [] + self._record = None + + def _get_operation_enum(self) -> Operation: + """Convierte string de operación a Enum Operation""" + operation_map = { + "create": Operation.CREATED, + "CREATE": Operation.CREATED, + "update": Operation.UPDATED, + "UPDATE": Operation.UPDATED, + "delete": Operation.DELETED, + "DELETE": Operation.DELETED, + "cancel": Operation.CANCELED, + "CANCEL": Operation.CANCELED, + "tried": Operation.TRIED, + "TRIED": Operation.TRIED, + } + return operation_map.get(self.operation.lower(), Operation.TRIED) + + def _get_status_enum(self, success: bool) -> StatusOP: + """Convierte resultado a StatusOP""" + if success: + return StatusOP.COMPLETED + return StatusOP.INVALID + + def _hash_data(self, data: Any) -> str: + """Genera hash para integridad de datos""" + data_str = json.dumps(data, sort_keys=True, default=str) + return hashlib.sha256(data_str.encode()).hexdigest() + + def certify_entity(self, entity_type: str, entity_id: int, entity_data: Dict) -> 'LegalAffidavit': + """Certifica que una entidad fue afectada""" + self.entities_affected.append({ + "type": entity_type, + "id": entity_id, + "data_hash": self._hash_data(entity_data), + "timestamp": self.timestamp.isoformat() + }) + return self + + def validate_rule(self, rule_name: str, rule_result: bool, rule_details: str = "") -> 'LegalAffidavit': + """Valida una regla de negocio""" + self.rules_validated.append({ + "rule": rule_name, + "passed": rule_result, + "details": rule_details, + "validated_at": self.timestamp.isoformat() + }) + + if not rule_result: + raise AffidavitValidationError(f"Regla '{rule_name}' no cumplida: {rule_details}") + + return self + + def add_evidence(self, evidence_type: str, evidence_data: Any) -> 'LegalAffidavit': + """Agrega evidencia digital""" + self.evidence.append({ + "type": evidence_type, + "data": evidence_data, + "hash": self._hash_data(evidence_data), + "timestamp": self.timestamp.isoformat() + }) + return self + + def _generate_signature(self) -> str: + """Genera firma digital del acta""" + data_to_sign = f"{self.user.id}{self.timestamp.isoformat()}{self.operation}{json.dumps(self.entities_affected, sort_keys=True)}" + return hashlib.sha512(data_to_sign.encode()).hexdigest() + + def _build_description(self) -> str: + """Construye la descripción del acta""" + entities = ", ".join([f"{e['type']}:{e['id']}" for e in self.entities_affected]) + rules = ", ".join([r['rule'] for r in self.rules_validated if r['passed']]) + + desc = f"Operación {self.operation} por usuario {self.user.email}" + if entities: + desc += f" | Entidades: {entities}" + if rules: + desc += f" | Reglas: {rules}" + return desc + + def sign(self, success: bool = True) -> Dict: + """ + Firma y guarda el acta notarial en la base de datos + """ + description = self._build_description() + signature = self._generate_signature() + status = self._get_status_enum(success) + operation_enum = self._get_operation_enum() + + # Crear registro en BD + self._record = AffidavitRecord( + operation=operation_enum, + user_id=self.user.id, + client_id=self.client_id, + description=description, + adjustment=json.dumps(self.evidence) if self.evidence else None, + status=status, + sign=signature, + created_at=self.timestamp, + created_by=self.user.id + ) + + self.db.add(self._record) + self.db.commit() + self.db.refresh(self._record) + + return { + "affidavit_id": self._record.id, + "operation": self.operation, + "user": { + "id": self.user.id, + "email": self.user.email, + "user_type": self.user.user_type, + "rol_operativo": self.user.rol_operativo + }, + "timestamp": self.timestamp.isoformat(), + "description": description, + "status": status.value, + "signature": signature, + "entities_affected": self.entities_affected, + "rules_validated": self.rules_validated + } + + def get_record_id(self) -> Optional[int]: + """Retorna el ID del registro creado""" + return self._record.id if self._record else None + + +class AffidavitValidationError(Exception): + """Error de validación notarial""" + pass + + +def certify_operation(operation_name: str): + """Decorador para certificar operaciones automáticamente""" + def decorator(func): + async def wrapper(*args, **kwargs): + db = kwargs.get('db') + user = kwargs.get('current_user') + client_id = kwargs.get('client_id') + + if not db and args and hasattr(args[0], 'db'): + db = args[0].db + + if db and user: + affidavit = LegalAffidavit(db, user, operation_name, client_id) + kwargs['affidavit'] = affidavit + + try: + result = await func(*args, **kwargs) + affidavit.sign(success=True) + return result + except Exception as e: + affidavit.sign(success=False) + raise + + return await func(*args, **kwargs) + return wrapper + return decorator \ No newline at end of file diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..0e2d790 --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,160 @@ +from datetime import datetime, timedelta +from typing import Optional, List +import os +#======================== +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + + +security = HTTPBearer() + +SECRET_KEY = os.getenv("SECRET_KEY", "xma-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "1")) + + +class UserType: + """ User type with permisions""" + + ROOT ="root" + ADMIN ="admin" + PRIVILEGED ="privileged" + ADMIN_LICENCIAS ="admin_licencias" + UPDATER = "updater" + USER ="user" + + + HIERARCHY = { + ROOT :6, + ADMIN :5, + ADMIN_LICENCIAS : 4, + PRIVILEGED : 3, + UPDATER : 2, + USER : 1, + } + + @classmethod + def has_permission(cls, user_type: str, required_type: str) -> bool: + """ VERIFY if user_type has permition equal o plus required_type""" + return cls.HIERARCHY.get(user_type, 0) >= cls.HIERARCHY.get(required_type, 0) + +class OperationalRole: + """ Role's""" + COMPRAS ="compras" + VENTAS ="ventas" + LOGISTICA ="logistica" + ADUANA_SOFT ="aduana_soft" + CLIENTE ="cliente" + OPERATIVO ="operativo" + +class AuthService: + """ Service auth""" + + def __init__(self): + self.secret_key = SECRET_KEY + self.algorithm = ALGORITHM + + + def create_token(self, user_id: int, email: str, user_type: str, rol_operativo: str) -> str: + + expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + payload = { + "sub": str(user_id), + "email": email, + "user_type": user_type, + "rol_operativo": rol_operativo, + "exp": expire, + "iat": datetime.utcnow() + } + return jwt.encode(payload, self.secret_key, algorithm=self.algorithm) + + def verify_token(self, token: str) -> dict: + + try: + payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expirado") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Token inválido") + + def decode_token(self, token: str) -> dict: + + try: + return jwt.decode(token, self.secret_key, algorithms=[self.algorithm], options={"verify_exp": False}) + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Token inválido") + +class CurrentUser: + """Modelo del usuario autenticado""" + + def __init__(self, id: int, email: str, user_type: str, rol_operativo: str): + self.id = id + self.email = email + self.user_type = user_type + self.rol_operativo = rol_operativo + self.ip_address = None + + def is_root(self) -> bool: + return self.user_type == UserType.ROOT + + def is_admin(self) -> bool: + return self.user_type == UserType.ADMIN + + def is_admin_licencias(self) -> bool: + return self.user_type == UserType.ADMIN_LICENCIAS + + def has_permission(self, required_type: str) -> bool: + """Verifica si el usuario tiene el tipo requerido o superior""" + return UserType.has_permission(self.user_type, required_type) + + def can_access_enterprise(self, enterprise_id: int, user_enterprise_id: int = None) -> bool: + """Verifica si puede acceder a una empresa""" + if self.is_root(): + return True + if user_enterprise_id and self.user_type == UserType.ADMIN: + return enterprise_id == user_enterprise_id + return False + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + auth_service: AuthService = Depends(lambda: AuthService()) +) -> CurrentUser: + """Dependencia para obtener el usuario actual desde el token""" + token = credentials.credentials + payload = auth_service.verify_token(token) + + user = CurrentUser( + id=int(payload.get("sub")), + email=payload.get("email"), + user_type=payload.get("user_type", UserType.USER), + rol_operativo=payload.get("rol_operativo", OperationalRole.OPERATIVO) + ) + + return user + + +def require_user_type(required_type: str): + """Dependencia para requerir un tipo de usuario específico""" + async def dependency(current_user: CurrentUser = Depends(get_current_user)): + if not current_user.has_permission(required_type): + raise HTTPException( + status_code=403, + detail=f"Se requiere tipo de usuario: {required_type}" + ) + return current_user + return dependency + + +def require_rol(required_rol: str): + """Dependencia para requerir un rol operativo específico""" + async def dependency(current_user: CurrentUser = Depends(get_current_user)): + if current_user.rol_operativo != required_rol and not current_user.is_root(): + raise HTTPException( + status_code=403, + detail=f"Se requiere rol operativo: {required_rol}" + ) + return current_user + return dependency \ No newline at end of file diff --git a/app/core/base.py b/app/core/base.py new file mode 100644 index 0000000..831956f --- /dev/null +++ b/app/core/base.py @@ -0,0 +1,14 @@ +"""UNIFICACION DE LOS MODELOS XMA + Todos los modelos deben importarse para que alembic los detecte +""" + +from database import Base + +# Export target_metadata for Alembic +target_metadata = Base.metadata + +# Lista explicita de modelos +__all__ = [ + "Base", + "target_metadata", +] \ No newline at end of file diff --git a/app/core/baseRepository.py b/app/core/baseRepository.py new file mode 100644 index 0000000..f107a89 --- /dev/null +++ b/app/core/baseRepository.py @@ -0,0 +1,224 @@ +# core/base_repository.py +from typing import Generic, TypeVar, Type, Optional, List, Dict, Any +from datetime import datetime +from sqlalchemy.orm import Session, Query +from sqlalchemy import and_, desc + +from database import Base + +ModelType = TypeVar("ModelType", bound= Base) + + +class BaseRepository(Generic[ModelType]): + """ + Repositorio base con: + - CRUD completo + - Soft delete integrado + - Trazabilidad (created_by, updated_by, deleted_by) + - Métodos para root, admin, admin_licencias + """ + + def __init__(self, db: Session, model: Type[ModelType]): + self.db = db + self.model = model + + # ============================================ + # QUERY BASE + # ============================================ + + def _base_query(self, include_deleted: bool = False) -> Query: + """ + Query base con filtro de soft delete + include_deleted=True: incluye registros eliminados + """ + query = self.db.query(self.model) + if not include_deleted: + query = query.filter( + self.model.deleted_at.is_(None), + self.model.is_active == True + ) + return query + + # ============================================ + # CRUD BÁSICO + # ============================================ + + def get_by_id(self, id: int, include_deleted: bool = False) -> Optional[ModelType]: + """Obtiene por ID""" + return self._base_query(include_deleted).filter(self.model.id == id).first() + + def get_all( + self, + skip: int = 0, + limit: int = 100, + filters: Dict[str, Any] = None, + order_by: str = None, + descending: bool = False, + include_deleted: bool = False + ) -> List[ModelType]: + """Lista con paginación y filtros""" + query = self._base_query(include_deleted) + + # Aplicar filtros + if filters: + for key, value in filters.items(): + if hasattr(self.model, key) and value is not None: + query = query.filter(getattr(self.model, key) == value) + + # Ordenamiento + if order_by and hasattr(self.model, order_by): + order_col = getattr(self.model, order_by) + query = query.order_by(desc(order_col) if descending else order_col) + + return query.offset(skip).limit(limit).all() + + def count(self, filters: Dict[str, Any] = None, include_deleted: bool = False) -> int: + """Cuenta registros""" + query = self._base_query(include_deleted) + if filters: + for key, value in filters.items(): + if hasattr(self.model, key) and value is not None: + query = query.filter(getattr(self.model, key) == value) + return query.count() + + def create(self, data: Dict[str, Any], created_by: int = None) -> ModelType: + """Crea un nuevo registro""" + import logging + logger = logging.getLogger(__name__) + + try: + # Eliminar campos que deben ser automáticos + data.pop("created_at", None) + data.pop("updated_at", None) + data.pop("deleted_at", None) + data.pop("last_login", None) + data.pop("id", None) + + if created_by: + data["created_by"] = created_by + + logger.info(f"CREANDO ENTIDAD {self.model.__name__} CON DATOS: {data}") + + entity = self.model(**data) + self.db.add(entity) + self.db.commit() + self.db.refresh(entity) + return entity + + except Exception as e: + logger.error(f"ERROR EN CREATE: {str(e)}") + self.db.rollback() + raise e + + def update( + self, + id: int, + data: Dict[str, Any], + updated_by: int = None, + include_deleted: bool = False + ) -> Optional[ModelType]: + """Actualiza un registro""" + entity = self.get_by_id(id, include_deleted) + if not entity: + return None + + # No permitir actualizar si está eliminado + if entity.deleted_at is not None and not include_deleted: + return None + + # Actualizar campos + for key, value in data.items(): + if hasattr(entity, key) and value is not None: + setattr(entity, key, value) + + if updated_by: + entity.updated_by = updated_by + + self.db.commit() + self.db.refresh(entity) + return entity + + def soft_delete(self, id: int, deleted_by: int = None) -> bool: + """Soft delete: marca is_active=False y deleted_at""" + entity = self.get_by_id(id) + if not entity: + return False + + if entity.deleted_at is not None: + return False + + entity.is_active = False + entity.deleted_at = datetime.utcnow() + if deleted_by: + entity.deleted_by = deleted_by + + self.db.commit() + return True + + def hard_delete(self, id: int) -> bool: + """Eliminación física""" + entity = self.get_by_id(id, include_deleted=True) + if not entity: + return False + + self.db.delete(entity) + self.db.commit() + return True + + def restore(self, id: int, updated_by: int = None) -> Optional[ModelType]: + """Restaura un registro soft-deleted""" + entity = self.get_by_id(id, include_deleted=True) + if not entity: + return None + + if entity.deleted_at is None: + return entity + + entity.is_active = True + entity.deleted_at = None + if updated_by: + entity.updated_by = updated_by + + self.db.commit() + self.db.refresh(entity) + return entity + + # ============================================ + # MÉTODOS PARA DIFERENCIACIÓN DE USUARIOS + # ============================================ + # Estos métodos serán sobrescritos en repositorios específicos + # si el modelo tiene los campos correspondientes + + def get_by_user_type(self, user_type: str) -> List[ModelType]: + """Obtiene por tipo de usuario (root, admin, admin_licencias, user)""" + if hasattr(self.model, "tipo_usuario"): + return self._base_query().filter(self.model.tipo_usuario == user_type).all() + return [] + + def get_root(self) -> List[ModelType]: + """Obtiene usuarios tipo root""" + return self.get_by_user_type("root") + + def get_admin(self) -> List[ModelType]: + """Obtiene usuarios tipo admin""" + return self.get_by_user_type("admin") + + def get_admin_licencias(self) -> List[ModelType]: + """Obtiene usuarios tipo admin_licencias""" + return self.get_by_user_type("admin_licencias") + + #=============================================== + # el tipo de usuario "PRIVILEGED" un esta por definir que clase de permisos tendras + #================================= + + def get_by_role(self, role: str) -> List[ModelType]: + """Obtiene por rol operativo""" + if hasattr(self.model, "rol_operativo"): + return self._base_query().filter(self.model.rol_operativo == role).all() + return [] + + def get_by_enterprise(self, enterprise_id: int) -> List[ModelType]: + """Obtiene por empresa""" + if hasattr(self.model, "enterprise_id"): + return self._base_query().filter(self.model.enterprise_id == enterprise_id).all() + return [] \ No newline at end of file diff --git a/app/core/dependencies.py b/app/core/dependencies.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/intefaces.py b/app/core/intefaces.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..dd464e5 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,64 @@ +# core/security.py +from passlib.context import CryptContext +from datetime import datetime, timedelta +from typing import Optional +# +import os +import jwt +import logging + + +logger = logging.getLogger(__name__) + +# Configuración de hashing de contraseñas +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# Configuración de JWT +SECRET_KEY = os.getenv("SECRET_KEY", "xma-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "1")) + + +def get_password_hash(password: str) -> str: + """Genera hash de contraseña con bcrypt""" + + if password.startswith("$2b$") or password.startswith("$2a$"): + raise ValueError("La contrasena ya esta hasheada") + + password_bytes = password.encode('utf-8') + if len(password_bytes) > 72: + password = password[:72] + + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + try: + return pwd_context.verify(plain_password, hashed_password) + except Exception as e: + logger.error(f"Error verifying password: {e}") + return False + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Crea token JWT""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + + to_encode.update({"exp": expire, "iat": datetime.utcnow()}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> dict: + """Decodifica token JWT""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + raise ValueError("Token expirado") + except jwt.InvalidTokenError: + raise ValueError("Token inválido") \ No newline at end of file diff --git a/app/core/servo.py b/app/core/servo.py new file mode 100644 index 0000000..0e6555c --- /dev/null +++ b/app/core/servo.py @@ -0,0 +1,91 @@ +# core/servo.py +""" +XMA Servomotor - Gestor simple del ciclo de vida +""" + +import asyncio +import signal +import sys +from enum import Enum +from typing import Optional +from datetime import datetime +import logging + +from database import test_connection, dispose_engine + +logger = logging.getLogger(__name__) + + +class ServoState(str, Enum): + BOOT = "boot" + RUN = "run" + STOP = "stop" + + +class XMAServo: + """ + Servomotor simple que: + - Verifica BD al arrancar + - Ejecuta tareas en segundo plano si es necesario + - Maneja señales de apagado + """ + + def __init__(self, mode: str = "dev"): + self.mode = mode + self.state = ServoState.BOOT + self.boot_time = datetime.utcnow() + self._tasks = [] + self._running = False + + async def start(self): + """Arranca el servomotor""" + logger.info(f" XMA Servomotor iniciando (modo: {self.mode})") + + # Verificar BD + if not test_connection(): + logger.error(" Base de datos no disponible") + sys.exit(1) + + logger.info(" Base de datos conectada") + + self.state = ServoState.RUN + self._running = True + logger.info(f" XMA Servomotor en estado RUN") + + # Si necesitas tareas en segundo plano, las agregas aquí + # await self._start_background_tasks() + + async def stop(self): + """Detiene el servomotor""" + logger.info(" Deteniendo servomotor...") + self.state = ServoState.STOP + self._running = False + + # Cancelar tareas + for task in self._tasks: + task.cancel() + + # Cerrar conexiones + dispose_engine() + + logger.info(" Servomotor detenido") + + def get_status(self) -> dict: + return { + "state": self.state.value, + "mode": self.mode, + "uptime": (datetime.utcnow() - self.boot_time).total_seconds(), + "database": "connected" if test_connection() else "disconnected" + } + + +# Instancia global +_servo = None + +def get_servo(mode: str = None) -> XMAServo: + global _servo + if _servo is None: + import os + mode = mode or os.getenv("XMA_MODE", "dev") + _servo = XMAServo(mode=mode) + return _servo \ No newline at end of file diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e33b26b --- /dev/null +++ b/app/modules/__init__.py @@ -0,0 +1,52 @@ +""" +UNIFICACION DE LOS MODELOS XMA +Todos los modelos deben importarse para que alembic los detecte +""" + +# Importar Base directamente desde database para evitar circular imports +from database import Base + +# Importar todos los modelos +from app.modules.clients.models import Client +from app.modules.users.models import Users +from app.modules.affidavit_logs.models import AffidavitRecord +from app.modules.branches.models import Branches +from app.modules.coments.models import Coments +from app.modules.configuration.models import Configuration +from app.modules.credits.models import Credits +from app.modules.edos.models import EDOS +from app.modules.efos.models import EFOS +from app.modules.feed.models import Feed +from app.modules.files.models import Files +from app.modules.interactions.models import Interacction +from app.modules.invoices.models import Invoices +from app.modules.license.models import License +from app.modules.location.models import Locations +from app.modules.moves.models import Moves +from app.modules.suppliers.models import Suppliers + +# Metadata para Alembic +target_metadata = Base.metadata + +# Lista explícita de modelos XMA +__all__ = [ + "Base", + "target_metadata", + "Users", + "Client", + "Locations", + "AffidavitRecord", + "Branches", + "Coments", + "Configuration", + "Credits", + "EDOS", + "EFOS", + "Feed", + "Interacction", + "Files", + "Invoices", + "License", + "Moves", + "Suppliers", +] diff --git a/app/modules/__pycache__/__init__.cpython-311.pyc b/app/modules/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..945413e Binary files /dev/null and b/app/modules/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/modules/affidavit_logs/__pycache__/models.cpython-311.pyc b/app/modules/affidavit_logs/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..1a40a8e Binary files /dev/null and b/app/modules/affidavit_logs/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/affidavit_logs/models.py b/app/modules/affidavit_logs/models.py new file mode 100644 index 0000000..a3da56f --- /dev/null +++ b/app/modules/affidavit_logs/models.py @@ -0,0 +1,58 @@ +from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum, Index +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +class StatusOP(str, enum.Enum): + COMPLETED = "completed" + PARTIAL = "partial" + INITIAL = "initial" + NO_PROCECED = "no_proceced" + INVALID = "invalid" + +class Operation(str, enum.Enum): + DELETED ="deleted" + CREATED ="created" + UPDATED ="updated" + TRIED ="tried" + MOST ="mosted" + LESS ="less" + MINUS ="minus" + CANCELED ="canceled" + + +class AffidavitRecord(Base): + __tablename__ = "affidavit_logs" + + id = Column(Integer, primary_key=True, nullable=False, autoincrement=True) + operation = Column(SQLEnum(Operation, name="operation", create_type=False), default=Operation.TRIED, nullable=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + client_id = Column(Integer, ForeignKey("clients.id"), nullable=True) + description = Column(Text,nullable=False) + adjustment = Column(String(100), nullable=True) + status = Column(SQLEnum(StatusOP, name="status", create_type=False), default=StatusOP.INITIAL, nullable=True) + sign = Column(String, nullable=False) + + #timestamsp + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + #trace + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + updated_by = Column(Integer, ForeignKey("users.id"), nullable=True) + deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True) + + #relationships + user = relationship("Users", foreign_keys=[user_id], back_populates="affidavit_logs") + client = relationship("Client", foreign_keys=[client_id]) + + __table_args__ = ( + Index('idx_affidavit_user', 'user_id'), + Index('idx_affidavit_client', 'client_id'), + Index('idx_affidavit_operation', 'operation'), + Index('idx_affidavit_status', 'status'), + Index('idx_affidavit_created', 'created_at'), + ) \ No newline at end of file diff --git a/app/modules/affidavit_logs/repository.py b/app/modules/affidavit_logs/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/affidavit_logs/route.py b/app/modules/affidavit_logs/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/affidavit_logs/schema.py b/app/modules/affidavit_logs/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/branches/__pycache__/models.cpython-311.pyc b/app/modules/branches/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..81925fd Binary files /dev/null and b/app/modules/branches/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/branches/models.py b/app/modules/branches/models.py new file mode 100644 index 0000000..4413795 --- /dev/null +++ b/app/modules/branches/models.py @@ -0,0 +1,47 @@ +from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean,Index, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + + +class Branches(Base): + __tablename__ = "branches" + + id = Column(Integer,primary_key=True, nullable=False, autoincrement=True) + direccion = Column(String(255), nullable=False) + cp = Column(Integer, nullable=False) + #physical + is_physical = Column(Boolean, nullable=False) + location_id = Column(Integer, ForeignKey("location.id"), nullable=False) + #validation + is_active = Column(Boolean, nullable=False, default=True) + #cliente_id + client_id = Column(Integer, ForeignKey("clients.id"), nullable=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + #timestamsp + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + #trace + created_by = Column(Integer, nullable=True) + updated_by = Column(Integer, nullable=True) + deleted_by = Column(Integer, nullable=True) + + #indices + __table_args__ = ( + Index('idx_branches_client', 'client_id'), + Index('idx_branches_location', 'location_id'), + Index('idx_branches_user', 'user_id'), + Index('idx_branches_active', 'is_active') + ) + + #relaciones + + client = relationship("Client", foreign_keys=[client_id], back_populates="branches") + location = relationship("Locations", foreign_keys=[location_id], back_populates="branches") + user = relationship("Users", back_populates="branch") + diff --git a/app/modules/branches/repository.py b/app/modules/branches/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/branches/route.py b/app/modules/branches/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/branches/schema.py b/app/modules/branches/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/clients/__pycache__/models.cpython-311.pyc b/app/modules/clients/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..3087ae7 Binary files /dev/null and b/app/modules/clients/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py new file mode 100644 index 0000000..467c9a9 --- /dev/null +++ b/app/modules/clients/models.py @@ -0,0 +1,76 @@ +from sqlalchemy import Column, Integer, String, DateTime, Float, Index, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +class Medio(str, enum.Enum): + MANUAL = "manual" + DIOT = "diot" + EXCEL = "excel" + FACTURA = "factura" + +class Tercero(str, enum.Enum): + NACIONAL = "nacional" + EXTRANJERO = "extranjero" + GLOBAL = "global" + +class Operacion(str, enum.Enum): + PRESTACIONSERVICIOSPROFECIONALES = "prestacion" + ARRENDAMIENTOSINMUENBLES = "arrendamientos" + OTROS = "otros" + + + +class Client(Base): + __tablename__ = "clients" + + #Identificacion + id = Column(Integer, primary_key=True, index=True) + rfc = Column(String(60), nullable=False, unique=True) + email = Column(String(150), nullable=False, unique=True) + + #Datos fiscales + short_name = Column(String(255), nullable=True) + razon_social = Column(String(255), nullable=False, index=True) + fiscal_number = Column(String(15), nullable=False, unique=True) + cellphone = Column(String(20), nullable=False) + + + #clasififcacion + medio = Column(SQLEnum(Medio, name="medio_enum", create_type=False), default=Medio.MANUAL, nullable=False) + third_type = Column(SQLEnum(Tercero, name="tercero_enum", create_type=False), default=Tercero.GLOBAL, nullable=False) + operation_type = Column(SQLEnum(Operacion, name="operacion_enum", create_type=False), default=Operacion.OTROS, nullable=False) + is_foreign = Column(Boolean, nullable=True, default=False) + + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + location_id = Column(Integer, ForeignKey("location.id"), nullable=True) + + #timestamps + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + #trazabilidad + created_by = Column(Integer, nullable=True) + updated_by = Column(Integer, nullable=True) + deleted_by = Column(Integer, nullable=True) + + __table_args = ( + Index('idx_clients_user', 'user_id'), + Index('idx_clients_location', 'location_id'), + ) + + #relationships + + user = relationship("Users", foreign_keys=[user_id], back_populates="clients") + location = relationship("Locations", foreign_keys=[location_id]) + license = relationship("License", back_populates="client") + branches = relationship("Branches", back_populates="client") + + + def __repr__(self): + return f"" + + diff --git a/app/modules/clients/repository.py b/app/modules/clients/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/clients/route.py b/app/modules/clients/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/clients/schema.py b/app/modules/clients/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/coments/__pycache__/models.cpython-311.pyc b/app/modules/coments/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..82cac0d Binary files /dev/null and b/app/modules/coments/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/coments/models.py b/app/modules/coments/models.py new file mode 100644 index 0000000..05a94f7 --- /dev/null +++ b/app/modules/coments/models.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, Integer, String, DateTime, Index, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + + +class Coments(Base): + __tablename__= "coments" + + id = Column(Integer, primary_key=True, nullable=False) + texto = Column(Text, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + feed_id= Column( Integer, ForeignKey("feed.id"), nullable=True) + + is_active = Column(Boolean, nullable=False, default=True) + + created_at = Column( DateTime(timezone=True), server_default=func.now(), nullable=False) + deactivate_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + deactivated_by = Column(Integer, ForeignKey("users.id"), nullable=True) + + #index + __table_args__ = ( + Index('idx_coments_feed', 'feed_id'), + Index('idx_coments_user', 'user_id'), + Index('idx_coments_created', 'created_at'), + Index('idx_coments_active', 'is_active') + ) + + #Relaciones + user = relationship("Users", foreign_keys=[user_id], back_populates="coments") + feed = relationship("Feed", foreign_keys=[feed_id]) + creator = relationship("Users", foreign_keys=[created_by]) + diff --git a/app/modules/coments/repository.py b/app/modules/coments/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/coments/route.py b/app/modules/coments/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/coments/schema.py b/app/modules/coments/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/coments/services.py b/app/modules/coments/services.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/configuration/__pycache__/models.cpython-311.pyc b/app/modules/configuration/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..b414f7b Binary files /dev/null and b/app/modules/configuration/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/configuration/models.py b/app/modules/configuration/models.py new file mode 100644 index 0000000..1bb4298 --- /dev/null +++ b/app/modules/configuration/models.py @@ -0,0 +1,34 @@ +from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Index, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + + +class Configuration(Base): + __tablename__ = "configuration" + + id = Column(Integer, primary_key=True, autoincrement=True, nullable=False) + + #SMTP data + smtp_host = Column(String(100), nullable=True) + smtp_port = Column(Integer, nullable=True) + smtp_user = Column(String(100), nullable=True) + smtp_password = Column(String(100), nullable=True) + + is_active = Column(Boolean, nullable=False, default=True) + + + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True) + last_send = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + created_by = Column(Integer, nullable=False) + updtated_by = Column(Integer, nullable=True) + deleted_by = Column(Integer, nullable=True) + + __table_args__ = ( + Index('idx_cofig_active', 'is_active'), + ) \ No newline at end of file diff --git a/app/modules/configuration/repository.py b/app/modules/configuration/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/configuration/route.py b/app/modules/configuration/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/configuration/schema.py b/app/modules/configuration/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/credits/__pycache__/models.cpython-311.pyc b/app/modules/credits/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..b551fea Binary files /dev/null and b/app/modules/credits/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/credits/models.py b/app/modules/credits/models.py new file mode 100644 index 0000000..eb5d1ed --- /dev/null +++ b/app/modules/credits/models.py @@ -0,0 +1,50 @@ +from sqlalchemy import Column, Integer, String, Date, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +# Articulo 69 de codigo fiscal de la federacion!!! +# 69 article of fiscal code of federation, mexican united states. + +class Supuestos(str, enum.Enum): + CANCELADOS ="cancelados" + CONDONADOS ="condonados" + FIRMES ="firmes" + SENTENCIAS ="sentencias" + EXIGIBLES ="exigibles" + RETORNO_INVERSIONES ="retorno_inversiones" + FRACCION_X ="fraccion_x" + FRACCION_VII ="fraccion_vii" + NO_LOCALIZADOS ="no_localizados" + + +class Credits(Base): + __tablename__="credits" + + + id = Column(Integer, primary_key=True, autoincrement=True, nullable=False) + title = Column(String(30), nullable=False) + rfc = Column(String(25), nullable=False) + razon_social = Column(String(100), nullable=False) + tipo_persona = Column(String(100), nullable=False) + supuesto = Column(SQLEnum(Supuestos, name="supuestos", create_type=False), nullable=False) + fecha_primera_publicacion = Column(Date, nullable=False) + fecha_publicacion_ley_transparencia = Column(Date, nullable=True) + fecha_cancelacion = Column(Date, nullable=True) + fecha_cancelacion_csd = Column(Date, nullable=True) + entidad_federativa = Column(String, nullable=False) + monto = Column(Integer, nullable=True) + motivo = Column(Text, nullable=True) + location_id = Column(Integer, ForeignKey("location.id"), nullable=False) + + + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=True) + + uploaded_by = Column(Integer, nullable=True) + + # relationships + location = relationship("Locations", back_populates="credits") + \ No newline at end of file diff --git a/app/modules/credits/repository.py b/app/modules/credits/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/credits/route.py b/app/modules/credits/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/credits/schema.py b/app/modules/credits/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/diot/__pycache__/models.cpython-311.pyc b/app/modules/diot/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..7e61d9e Binary files /dev/null and b/app/modules/diot/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/diot/models.py b/app/modules/diot/models.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/diot/repository.py b/app/modules/diot/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/diot/route.py b/app/modules/diot/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/diot/schema.py b/app/modules/diot/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/edos/__pycache__/models.cpython-311.pyc b/app/modules/edos/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..1efdb9c Binary files /dev/null and b/app/modules/edos/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/edos/models.py b/app/modules/edos/models.py new file mode 100644 index 0000000..642e9da --- /dev/null +++ b/app/modules/edos/models.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, Date, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +#articulo 69-B-Bis del CFF +# 69 article -B - Bis, of fiscal code of fedration of mexican united estates + +class Situacion(str, enum.Enum): + SENTENCIA_FAVORABLE ="sentencia_favorable" + DEFINITIVO="definitivo" + +class EDOS(Base): + __tablename__="edos" + + id = Column(Integer, primary_key=True, nullable=False) + numero = Column(Integer, nullable=False) + razon_social = Column(String(100), nullable=False) + situacion = Column(SQLEnum(Situacion, name="situcion", create_type=False), nullable=False) + numero_definitivo = Column(String(60), nullable=False) + fecha_definitivo = Column(Date, nullable=False ) + publicaccion_sat = Column(Date, nullable=True) + numero_def_dof = Column(String(100), nullable=True) + fecha_def_dof = Column(Date, nullable=True) + publicacion_dof = Column(Date, nullable=True) + numero_fav_sat = Column(String(100), nullable=True) + + diff --git a/app/modules/edos/repository.py b/app/modules/edos/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/edos/route.py b/app/modules/edos/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/edos/schema.py b/app/modules/edos/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/efos/__pycache__/models.cpython-311.pyc b/app/modules/efos/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..e4a1a7f Binary files /dev/null and b/app/modules/efos/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/efos/models.py b/app/modules/efos/models.py new file mode 100644 index 0000000..51e0aef --- /dev/null +++ b/app/modules/efos/models.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, Integer, String, Date, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +#Articulo 69-B del codigo fiscal de la federacion +#69-B article of fiscal code of federation of mexican united states + +class Situacion(str, enum.Enum): + DEFINITIVO ="definitivo" + DESVIRTUADO ="desvituado" + PRESUNTO ="presunto" + SENTENCIA_FAVORABLE ="sentencia_favorable" + +class EFOS(Base): + __tablename__ = "efos" + + id = Column(Integer, primary_key=True, nullable=False) + numero = Column(Integer, nullable=False) + rfc = Column(String(30), nullable=False) + nombre_contribuyente = Column(String(100), nullable=False) + situacion = Column(String(60), SQLEnum(Situacion, name="situacion", create_type=False), nullable=False) + publi_presuntos_sat = Column(Date, nullable=True) + publi_desvirtuados_sat = Column(Date, nullable=True) + publi_definitivos_sat = Column(Date, nullable=True) + publi_favorables_sat = Column(Date, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=False) + + loaded_by = Column(Integer, nullable=False) diff --git a/app/modules/efos/repository.py b/app/modules/efos/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/efos/route.py b/app/modules/efos/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/efos/schema.py b/app/modules/efos/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/entreprise/models.py b/app/modules/entreprise/models.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/entreprise/repository.py b/app/modules/entreprise/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/entreprise/route.py b/app/modules/entreprise/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/entreprise/schema.py b/app/modules/entreprise/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/feed/__pycache__/models.cpython-311.pyc b/app/modules/feed/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..fb1320a Binary files /dev/null and b/app/modules/feed/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/feed/models.py b/app/modules/feed/models.py new file mode 100644 index 0000000..5ec9c6f --- /dev/null +++ b/app/modules/feed/models.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, Integer, String, Date, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +class Feed(Base): + __tablename__ = "feed" + + id = Column(Integer, primary_key=True, nullable=False) + title = Column(String(50), nullable=False ) + body = Column(Text, nullable=True) + document_url = Column(String, nullable=True) + publication_date = Column(Date, nullable=True) + is_important = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + updated_by = Column(Integer, ForeignKey("users.id"), nullable=True) + file_id = Column(Integer, nullable=True) + + #relationships + user = relationship("Users", foreign_keys=[user_id], back_populates="feed") \ No newline at end of file diff --git a/app/modules/feed/repository.py b/app/modules/feed/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/feed/route.py b/app/modules/feed/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/feed/schema.py b/app/modules/feed/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/files/__pycache__/models.cpython-311.pyc b/app/modules/files/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..99fe146 Binary files /dev/null and b/app/modules/files/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/files/models.py b/app/modules/files/models.py new file mode 100644 index 0000000..af0ceab --- /dev/null +++ b/app/modules/files/models.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, Date, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +class Files(Base): + __tablename__ = "files" + + id = Column(Integer, primary_key=True, nullable=False) + title = Column(String(50), nullable=True) + summary = Column(Text, nullable=False) + document_url = Column(String(100), nullable=False) + file_type = Column(String(50), nullable=False) + file_size = Column(Integer, nullable=False) + + feed_id = Column(Integer, nullable=False) + + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=False ) + + uploaded_by = Column(Integer, nullable=False) + updated_by = Column(Integer, nullable=True) diff --git a/app/modules/files/repository.py b/app/modules/files/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/files/route.py b/app/modules/files/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/files/schema.py b/app/modules/files/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/interactions/__pycache__/models.cpython-311.pyc b/app/modules/interactions/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..5c33293 Binary files /dev/null and b/app/modules/interactions/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/interactions/models.py b/app/modules/interactions/models.py new file mode 100644 index 0000000..8fcdebb --- /dev/null +++ b/app/modules/interactions/models.py @@ -0,0 +1,34 @@ +from sqlalchemy import Column, Integer, String, Date, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +class Interaccion(str, enum.Enum): + LIKE = "like" + DONT_LIKE = "dont_like" + APPROVED = "approved" + DISAPRROVE = "disapproved" + NONE = "none" + + +class Interacction(Base): + __tablename__="interacctions" + + id = Column(Integer, primary_key=True, nullable=False) + feed_id = Column(Integer, ForeignKey("feed.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + type_iteractions = Column(SQLEnum(Interaccion, name="type_interactios", create_type=False), default=Interaccion.NONE, nullable=False) + + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + #relationships + user = relationship("Users", back_populates="interactions") + + + + + + diff --git a/app/modules/interactions/repository.py b/app/modules/interactions/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/interactions/route.py b/app/modules/interactions/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/interactions/schema.py b/app/modules/interactions/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/invoices/__pycache__/models.cpython-311.pyc b/app/modules/invoices/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..7846241 Binary files /dev/null and b/app/modules/invoices/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/invoices/models.py b/app/modules/invoices/models.py new file mode 100644 index 0000000..5f520a2 --- /dev/null +++ b/app/modules/invoices/models.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String, Date, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + + +class Invoices(Base): + __tablename__ = "invoices" + + id = Column(Integer, primary_key=True, nullable=False, autoincrement=True) + emisor = Column(String(100), nullable=False) + receptor = Column(String(110), nullable=False) + uuid = Column(String(255), nullable=False) + total = Column(Integer, nullable=False) + tipo = Column(String(50), nullable=True) + date = Column(DateTime, nullable=True) + + verified_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + verified_by = Column(Integer, nullable=True) + + diff --git a/app/modules/invoices/repository.py b/app/modules/invoices/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/invoices/route.py b/app/modules/invoices/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/invoices/schema.py b/app/modules/invoices/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/license/__pycache__/models.cpython-311.pyc b/app/modules/license/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..f28e181 Binary files /dev/null and b/app/modules/license/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/license/models.py b/app/modules/license/models.py new file mode 100644 index 0000000..f6f8896 --- /dev/null +++ b/app/modules/license/models.py @@ -0,0 +1,45 @@ +from sqlalchemy import Column, Integer, String, DateTime, Index, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + + + +class License(Base): + __tablename__ = "license" + + id = Column(Integer, primary_key=True, nullable=False, autoincrement=True) + titular = Column(Integer, ForeignKey("users.id"), nullable=False) + + begins_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + ends_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + token_license = Column(String(30), nullable=True) + location_id = Column(Integer, ForeignKey("location.id"), nullable=False) + client_id = Column(Integer, ForeignKey("clients.id"), nullable=False) + + + + #timestamsp + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=False) + + #trace + created_by = Column(Integer, nullable=True) + updated_by = Column(Integer, nullable=True) + deleted_by = Column(Integer, nullable=True) + + __table_arg__ = ( + Index('idx_license_token', 'token_license'), + Index('idx_license_client', 'client_id'), + Index('idx_license_active', 'is_active'), + Index('idx_license_ends_at', 'ends_at'), + ) + + #Relations + client = relationship("Client", foreign_keys=[client_id], back_populates="license") + location = relationship("Locations", foreign_keys=[location_id], back_populates="licenses") + user = relationship("Users", foreign_keys=[titular], back_populates="licenses") \ No newline at end of file diff --git a/app/modules/license/repository.py b/app/modules/license/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/license/route.py b/app/modules/license/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/license/schema.py b/app/modules/license/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/location/__pycache__/models.cpython-311.pyc b/app/modules/location/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..c9f5cc2 Binary files /dev/null and b/app/modules/location/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/location/models.py b/app/modules/location/models.py new file mode 100644 index 0000000..78ef27c --- /dev/null +++ b/app/modules/location/models.py @@ -0,0 +1,50 @@ +from sqlalchemy import Column, Integer, String, DateTime, Index, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +class Locations(Base): + __tablename__ = "location" + id = Column(Integer, primary_key=True, nullable=False) + + country = Column(String(120), nullable=False) + country_id = Column(Integer, nullable=False) + state = Column(String(100), nullable=False) + state_id = Column(Integer, nullable=False) + city = Column(String(100), nullable=False) + city_id = Column(Integer, nullable=False) + cp_zp = Column(Integer, nullable=True) + street = Column(String(120), nullable=True) + is_department = Column(Boolean, nullable=False, default=False) + number_ext = Column(Integer, nullable=True) + number_int = Column(Integer, nullable=True) + + is_active = Column(Boolean, default=True, nullable=False) + + #timestamsp + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_at = Column(DateTime, nullable=True) + + #trace + created_by = Column(Integer, nullable=True) + updated_by = Column(Integer, nullable=True) + deleted_by = Column(Integer, nullable=True) + + #index + __table_args__ = ( + Index('idx_location_country','country'), + Index('idx_location_state','state'), + Index('idx_location_city','city'), + Index('idx_location_cp','cp_zp'), + Index('idx_location_active','is_active'), + ) + + #relationships + + clients = relationship("Client",back_populates="location") + branches = relationship("Branches",back_populates="location") + credits = relationship("Credits",back_populates="location") + licenses = relationship("License",back_populates="location") \ No newline at end of file diff --git a/app/modules/location/repository.py b/app/modules/location/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/location/route.py b/app/modules/location/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/location/schema.py b/app/modules/location/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/location/service.py b/app/modules/location/service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/moves/__pycache__/models.cpython-311.pyc b/app/modules/moves/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..f14dca3 Binary files /dev/null and b/app/modules/moves/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/moves/models.py b/app/modules/moves/models.py new file mode 100644 index 0000000..2696ef5 --- /dev/null +++ b/app/modules/moves/models.py @@ -0,0 +1,71 @@ +from sqlalchemy import Column, Integer, String, Date, Index, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship + +from database import Base +from sqlalchemy.dialects.postgresql import JSONB + +import enum + +class Action(str, enum.Enum): + UPLOAD = "upload" + MATCH = "match" + QUERY = "query" + CONSUMPTION = "consumption" + CREATE = "create" + DELETE = "delete" + SOLD = "sold" + INTERACTION = "interaction" + COMMENT = "comment" + CONFIG = "config" + EMAIL = "email" + +class Target(str, enum.Enum): + CLIENT = "client" + INVOICES = "invoices" + FEED = "feed" + EFOS = "efos" + CREDITS = "credits" + USERS = "users" + EMAIL = "email" + + +class Moves(Base): + __tablename__ = "moves" + + id = Column(Integer, primary_key=True, nullable=False) + + action_type = Column(SQLEnum(Action, name="action_type", create_type=False), nullable=False) + target_type = Column(SQLEnum(Target, name="target_type", create_type=False), nullable=False) + + description = Column(Text, nullable=True) + move_metadata = Column(JSONB, nullable=True) + + ip_address = Column(String(45), nullable=True) + user_agent = Column(String(255), nullable=True) + + client_id = Column(Integer, ForeignKey("clients.id"), nullable=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + + #relationships + user = relationship("Users", foreign_keys=[user_id], back_populates="moves") + + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True) + + __table_args_ = ( + Index('idx_moves_client','client_id'), + Index('idx_moves_user','user_id'), + Index('idx_moves_action','action_type'), + Index('idx_moves_target','target_type'), + Index('idx_moves_created','created_at'), + Index('idx_moves_active','is_active'), + ) + + #Relaciones + client = relationship("Client", foreign_keys=[client_id]) + creator = relationship("Users", foreign_keys=[created_by]) \ No newline at end of file diff --git a/app/modules/moves/repository.py b/app/modules/moves/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/moves/route.py b/app/modules/moves/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/moves/schema.py b/app/modules/moves/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/reports/__pycache__/models.cpython-311.pyc b/app/modules/reports/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..00b54a5 Binary files /dev/null and b/app/modules/reports/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/reports/models.py b/app/modules/reports/models.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/reports/repository.py b/app/modules/reports/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/reports/route.py b/app/modules/reports/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/reports/schema.py b/app/modules/reports/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/suppliers/__pycache__/models.cpython-311.pyc b/app/modules/suppliers/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..8d09a96 Binary files /dev/null and b/app/modules/suppliers/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/suppliers/models.py b/app/modules/suppliers/models.py new file mode 100644 index 0000000..c422c2c --- /dev/null +++ b/app/modules/suppliers/models.py @@ -0,0 +1,55 @@ +from sqlalchemy import Column, Integer, String, Index, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +class SupplierType(str, enum.Enum): + NACIONAL = "nacional" + EXTRANJERO = "exttranjero" + GLOBAL = "global" + + +class Suppliers(Base): + __tablename__ = "suppliers" + id = Column(Integer, primary_key=True, nullable=False) + + #Identificacion + rfc = Column(String(60), nullable=False, unique=True) + email = Column(String(150), nullable=False, unique=True) + + #Datos fiscales + short_name = Column(String(255), nullable=True) + razon_social = Column(String(255), nullable=False, index=True) + fiscal_number = Column(String(15), nullable=False, unique=True) + cellphone = Column(String(20), nullable=False) + + supplier_type = Column(SQLEnum(SupplierType, name="suppliertype", create_type=True), + nullable=False, default=SupplierType.NACIONAL) + + location_id = Column(Integer, ForeignKey("location.id"), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + + client_id = Column(Integer, ForeignKey("clients.id"), nullable=False) + + #timestamsp + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + #trace + created_by = Column(Integer, nullable=True) + updated_by = Column(Integer, nullable=True) + deleted_by = Column(Integer, nullable=True) + + #index + __table_args__ = ( + Index('idx_suppliers_client', 'client_id'), + Index('idx_suppliers_location', 'location_id'), + Index('idx_suppliers_type', 'supplier_type'), + Index('idx_suppliers_active', 'is_active'), + ) + + client = relationship("Client", foreign_keys=[client_id]) + location = relationship("Locations", foreign_keys=[location_id]) \ No newline at end of file diff --git a/app/modules/suppliers/repository.py b/app/modules/suppliers/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/suppliers/route.py b/app/modules/suppliers/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/suppliers/schema.py b/app/modules/suppliers/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/suppliers/service.py b/app/modules/suppliers/service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/taxes/models.py b/app/modules/taxes/models.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/taxes/repository.py b/app/modules/taxes/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/taxes/route.py b/app/modules/taxes/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/taxes/schema.py b/app/modules/taxes/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/taxpayers/models.py b/app/modules/taxpayers/models.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/taxpayers/repository.py b/app/modules/taxpayers/repository.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/taxpayers/route.py b/app/modules/taxpayers/route.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/taxpayers/schema.py b/app/modules/taxpayers/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/users/__pycache__/models.cpython-311.pyc b/app/modules/users/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..cf59302 Binary files /dev/null and b/app/modules/users/__pycache__/models.cpython-311.pyc differ diff --git a/app/modules/users/__pycache__/repository.cpython-311.pyc b/app/modules/users/__pycache__/repository.cpython-311.pyc new file mode 100644 index 0000000..9341258 Binary files /dev/null and b/app/modules/users/__pycache__/repository.cpython-311.pyc differ diff --git a/app/modules/users/__pycache__/route.cpython-311.pyc b/app/modules/users/__pycache__/route.cpython-311.pyc new file mode 100644 index 0000000..b1615dd Binary files /dev/null and b/app/modules/users/__pycache__/route.cpython-311.pyc differ diff --git a/app/modules/users/__pycache__/schema.cpython-311.pyc b/app/modules/users/__pycache__/schema.cpython-311.pyc new file mode 100644 index 0000000..7c23477 Binary files /dev/null and b/app/modules/users/__pycache__/schema.cpython-311.pyc differ diff --git a/app/modules/users/__pycache__/services.cpython-311.pyc b/app/modules/users/__pycache__/services.cpython-311.pyc new file mode 100644 index 0000000..d0e9ef6 Binary files /dev/null and b/app/modules/users/__pycache__/services.cpython-311.pyc differ diff --git a/app/modules/users/models.py b/app/modules/users/models.py new file mode 100644 index 0000000..9049efe --- /dev/null +++ b/app/modules/users/models.py @@ -0,0 +1,79 @@ +from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, ForeignKey, Enum as SQLEnum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from database import Base + +import enum + +class Rol(str, enum.Enum): + COMPRAS ="compras" + VENTAS ="ventas" + LOGISTICA ="logistica" + ADUANA_SOFT ="aduana_soft" + CLIENTE ="cliente" + OPERATIVO ="operativo" + +class TipoUsuario(str, enum.Enum): + ROOT ="root" + ADMIN ="admin" + PRIVILEGED ="privileged" + ADMIN_LICENCIAS ="admin_licencias" + UPDATER = "updater" + USER ="user" + + +class Users(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, nullable=False, index=True, autoincrement=True) + #identity + name = Column(String(100), nullable=False) + middle_Name = Column(String(100), nullable=True) + last_Name = Column(String(120), nullable=False) + rfc = Column(String(60), nullable=False, unique=True) + + #==== CONEXIONS ====# + email = Column(String(120), nullable=False, unique=True) + password = Column(String(255), nullable=False) + + #=== credentials + rol_operativo = Column(SQLEnum(Rol), default="operativo", nullable=False) + tipo_usuario = Column(SQLEnum(TipoUsuario), default="user", nullable=False) + permite_diot = Column(Boolean, default=False, nullable=False) + + #==== WHERES + enterprise_id = Column(Integer, nullable=True) + firma = Column(String(110), nullable=True, unique=True) + is_active = Column(Boolean, nullable=False, default=True) + + + #timestamsp + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True) + last_login = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + #trace + created_by = Column(Integer, nullable=True) + updated_by = Column(Integer, nullable=True) + deleted_by = Column(Integer, nullable=True) + + + #===== + # PASSWORD RESET + #===== + + password_reset_token = Column(String, nullable=True) + password_reset_expires = Column(String, nullable=True) + + + #relationships + clients = relationship("Client", back_populates="user") + branch = relationship("Branches", back_populates="user") + interactions = relationship("Interacction", back_populates="user") + moves = relationship("Moves", back_populates="user", primaryjoin="Users.id == Moves.user_id") + licenses = relationship("License", back_populates="user") + feed = relationship("Feed", back_populates="user", primaryjoin="Users.id == Feed.user_id") + coments = relationship("Coments", back_populates="user", primaryjoin="Users.id == Coments.user_id") + affidavit_logs = relationship("AffidavitRecord", back_populates="user", primaryjoin="Users.id == AffidavitRecord.user_id") + diff --git a/app/modules/users/repository.py b/app/modules/users/repository.py new file mode 100644 index 0000000..a75e05a --- /dev/null +++ b/app/modules/users/repository.py @@ -0,0 +1,212 @@ +# modules/users/repositories.py +from typing import Optional, List, Dict, Any +from datetime import datetime +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from app.core.baseRepository import BaseRepository +from app.modules.users.models import Users, TipoUsuario, Rol +from app.core.security import get_password_hash, verify_password + + +class UserRepository(BaseRepository[Users]): + """Repositorio específico para Users""" + + def __init__(self, db: Session): + super().__init__(db, Users) + + # ============================================ + # MÉTODOS DE AUTENTICACIÓN + # ============================================ + + def get_by_email(self, email: str, include_deleted: bool = False) -> Optional[Users]: + """Obtiene usuario por email""" + return self._base_query(include_deleted).filter(Users.email == email).first() + + def get_by_firma(self, firma: str, include_deleted: bool = False) -> Optional[Users]: + """Obtiene usuario por firma digital""" + return self._base_query(include_deleted).filter(Users.firma == firma).first() + + def authenticate(self, email: str, password: str) -> Optional[Users]: + """Autentica usuario por email y contraseña""" + user = self.get_by_email(email) + if not user: + return None + if not user.is_active or user.deleted_at is not None: + return None + if not verify_password(password, user.password): + return None + return user + + def update_last_login(self, user_id: int, ip_address: str) -> Optional[Users]: + """Actualiza timestamp e IP del último login""" + user = self.get_by_id(user_id) + if user: + user.last_login = datetime.utcnow() + user.last_ip = ip_address + self.db.commit() + self.db.refresh(user) + return user + + def change_password(self, user_id: int, new_password: str, updated_by: int = None) -> bool: + """Cambia la contraseña del usuario""" + user = self.get_by_id(user_id) + if not user: + return False + + user.password = get_password_hash(new_password) + if updated_by: + user.updated_by = updated_by + + self.db.commit() + return True + + # ============================================ + # MÉTODOS POR TIPO DE USUARIO + # ============================================ + + def get_by_tipo_usuario(self, tipo_usuario: str, include_deleted: bool = False) -> List[Users]: + """Obtiene usuarios por tipo (root, admin, admin_licencias, user)""" + return self._base_query(include_deleted).filter(Users.tipo_usuario == tipo_usuario).all() + + def get_root(self, include_deleted: bool = False) -> List[Users]: + """Usuarios tipo root""" + return self.get_by_tipo_usuario(TipoUsuario.ROOT, include_deleted) + + def get_admin(self, include_deleted: bool = False) -> List[Users]: + """Usuarios tipo admin""" + return self.get_by_tipo_usuario(TipoUsuario.ADMIN, include_deleted) + + def get_admin_licencias(self, include_deleted: bool = False) -> List[Users]: + """Usuarios tipo admin_licencias""" + return self.get_by_tipo_usuario(TipoUsuario.ADMIN_LICENCIAS, include_deleted) + + def get_regular_users(self, include_deleted: bool = False) -> List[Users]: + """Usuarios tipo user (regulares)""" + return self.get_by_tipo_usuario(TipoUsuario.USER, include_deleted) + + # ============================================ + # MÉTODOS POR ROL OPERATIVO + # ============================================ + + def get_by_rol_operativo(self, rol: str, include_deleted: bool = False) -> List[Users]: + """Obtiene usuarios por rol operativo""" + return self._base_query(include_deleted).filter(Users.rol_operativo == rol).all() + + def get_by_permiso_diot(self, include_deleted: bool = False) -> List[Users]: + """Obtiene usuarios que tienen permiso DIOT""" + return self._base_query(include_deleted).filter(Users.permite_diot == True).all() + + # ============================================ + # MÉTODOS POR ASOCIACIONES + # ============================================ + + def get_by_enterprise(self, enterprise_id: int, include_deleted: bool = False) -> List[Users]: + """Obtiene usuarios de una empresa específica""" + return self._base_query(include_deleted).filter(Users.enterprise_id == enterprise_id).all() + + def get_by_sucursal(self, sucursal_id: int, include_deleted: bool = False) -> List[Users]: + """Obtiene usuarios de una sucursal específica""" + return self._base_query(include_deleted).filter(Users.sucursales_id == sucursal_id).all() + + def get_by_licencia(self, licencia_id: int, include_deleted: bool = False) -> List[Users]: + """Obtiene usuarios asociados a una licencia""" + return self._base_query(include_deleted).filter(Users.licencia_id == licencia_id).all() + + def get_by_perfil(self, perfil_id: int, include_deleted: bool = False) -> List[Users]: + """Obtiene usuarios por perfil""" + return self._base_query(include_deleted).filter(Users.perfil_id == perfil_id).all() + + # ============================================ + # MÉTODOS DE ESTADO + # ============================================ + + def get_active(self, skip: int = 0, limit: int = 100) -> List[Users]: + """Obtiene usuarios activos (no eliminados)""" + return self.get_all(skip=skip, limit=limit, include_deleted=False) + + def get_inactive(self, skip: int = 0, limit: int = 100) -> List[Users]: + """Obtiene usuarios inactivos o eliminados""" + return self._base_query(include_deleted=True).filter( + or_( + Users.is_active == False, + Users.deleted_at.isnot(None) + ) + ).offset(skip).limit(limit).all() + + def activate(self, user_id: int, updated_by: int = None) -> Optional[Users]: + """Activa un usuario inactivo""" + user = self.get_by_id(user_id, include_deleted=True) + if not user: + return None + + user.is_active = True + if user.deleted_at: + user.deleted_at = None + if updated_by: + user.updated_by = updated_by + + self.db.commit() + self.db.refresh(user) + return user + + def deactivate(self, user_id: int, updated_by: int = None) -> Optional[Users]: + """Desactiva un usuario (sin soft delete)""" + user = self.get_by_id(user_id) + if not user: + return None + + user.is_active = False + if updated_by: + user.updated_by = updated_by + + self.db.commit() + self.db.refresh(user) + return user + + # ============================================ + # MÉTODOS DE BÚSQUEDA + # ============================================ + + def search(self, term: str, skip: int = 0, limit: int = 100) -> List[Users]: + """Búsqueda por nombre, email o RFC""" + search_term = f"%{term}%" + return self._base_query().filter( + or_( + Users.name.ilike(search_term), + Users.last_name.ilike(search_term), + Users.email.ilike(search_term), + Users.rfc.ilike(search_term) + ) + ).offset(skip).limit(limit).all() + + def count_by_enterprise(self, enterprise_id: int) -> int: + """Cuenta usuarios por empresa""" + return self._base_query().filter(Users.enterprise_id == enterprise_id).count() + + def count_by_tipo(self, tipo_usuario: str) -> int: + """Cuenta usuarios por tipo""" + return self._base_query().filter(Users.tipo_usuario == tipo_usuario).count() + + # ============================================ + # SOBRESCRITURA DE MÉTODOS BASE CON TRAZABILIDAD + # ============================================ + + def create(self, data: Dict[str, Any], created_by: int = None) -> Users: + """Crea usuario con hash de contraseña""" + create_data = data.copy() + + # Hashear contraseña si existe + if "password" in create_data: + raw_password = create_data["password"] + print(f"HASHEANDO PASSWORD: {raw_password} (longitud: {len(raw_password)})") + create_data["password"] = get_password_hash(raw_password) + print(f"HASH GENERADO: {create_data['password'][:20]}...") + + return super().create(create_data, created_by) + + def update(self, id: int, data: Dict[str, Any], updated_by: int = None, include_deleted: bool = False) -> Optional[Users]: + """Actualiza usuario, hashea contraseña si viene""" + if "password" in data: + data["password"] = get_password_hash(data["password"]) + return super().update(id, data, updated_by, include_deleted) \ No newline at end of file diff --git a/app/modules/users/route.py b/app/modules/users/route.py new file mode 100644 index 0000000..13ad8cd --- /dev/null +++ b/app/modules/users/route.py @@ -0,0 +1,188 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Header, status +# +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session +from typing import Optional, List +# +from database import get_db +from app.core.auth import get_current_user +from app.modules.users.models import Users +# +from app.modules.users.schema import UserCreate, UserResponse, UserLogin, LoginResponse, DeleteResponse, UpdateUser +from app.modules.users.services import UserCrudService +from app.core.security import create_access_token, verify_password + +router = APIRouter(prefix="/users", tags=["users"]) +security = HTTPBearer() + +#================ Login ==================== +@router.post("/login", response_model=LoginResponse) +async def login( + login_data: UserLogin, + db: Session = Depends(get_db) +): + """Authenticate user and return JWT token""" + + user = db.query(Users).filter(Users.email == login_data.email).first() + + #check email + if not user: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail="Credentials invalid - user not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + + #check password ... + if not verify_password(login_data.password, user.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials = worng password", + headers={"WWW-Athenticate": "Bearer"} + ) + + #check is active + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This user is ianctive, contact with support" + ) + + #update last login + from datetime import datetime + user.last_login = datetime.utcnow() + db.commit() + + #create token + token_data = { + "sub": str(user.id), + "email": user.email, + "user_type": user.tipo_usuario, + "rol_operativo": user.rol_operativo + } + access_token = create_access_token(token_data) + + return LoginResponse(access_token=access_token, token_type="bearer") + + + +#================ Register ==================== + +@router.post("/register", response_model=UserResponse) +async def register(data: UserCreate, db: Session = Depends(get_db)): + + user_count = db.query(Users).count() + + if user_count == 0: + service = UserCrudService() + return UserCrudService.create_user(db = db, user_create=data, current_user=None) + else: + raise HTTPException( + status_code=403, + detail="Root user already exists, use create_users enpoint" + ) + +#================ Create User ==================== +@router.post("/create_users", response_model=UserResponse) +async def new_user( + data: UserCreate, + db: Session = Depends(get_db), + credentials: HTTPAuthorizationCredentials = Depends(security) +): + + """Create a new user - Requeres authentication """ + + from app.core.auth import AuthService + + token = credentials.credentials + auth_service = AuthService() + payload = auth_service.decode_token(token) + user_id = int(payload.get("sub")) + current_user = db.query(Users).filter(Users.id == user_id).first() + + + if not current_user: + raise HTTPException( + status_code=401, + detail="User not found - invalid token" + ) + + if current_user.tipo_usuario not in ["root", "admin"]: + raise HTTPException(status_code=403, detail="you dont have permission to create users") + + if current_user.tipo_usuario == "admin" and data.tipo_usuario != "user": + raise HTTPException(status_code=403, detail="Admin can only create users type user") + + return UserCrudService.create_user( + db = db, user_create = data, current_user = current_user + ) + +#================ Get Users ==================== +@router.get("/get", response_model=List[UserResponse]) +def obtain_users( + db: Session = Depends(get_db), + current_user: Users = Depends(get_current_user), + skip: int = Query(0, ge=0, description="Number of records to skip for pagination"), + limit: int = Query(100, ge=1, le= 1000, description="Maximun number of records to return") +): + """Get a list of user with pagination - requeres authentication""" + return UserCrudService.get_users(db = db, skip=skip, limit=limit) + +#================ Delete Users ==================== +@router.delete("/delete/{user_id}", response_model=DeleteResponse) +async def delete( + db:Session = Depends(get_db), + user_id= int, + current_user: Users = Depends(get_current_user) +): + """Delete a user by id""" + + current_user = db.query(Users).filter(Users.id == user_id).first() + + if current_user.tipo_usuario not in ["root", "admin"]: + raise HTTPException(status_code=403, + detail="You dont have permitions to delete ") + try: + UserCrudService.delete_user(db = db, user_id=user_id, current_user=current_user) + return {"message": "ok"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +#================ Update Users ==================== + +@router.patch("/update/{user_id}", response_model=UserResponse) +async def user_update( + user_id: int, + data: UpdateUser, + db: Session = Depends(get_db), + current_user: Users = Depends(get_current_user) +): + """ Update existing user""" + + user = db.query(Users).filter(Users.id == user_id).first() + + current_user = db.query(Users).filter(Users.id == user_id).first() + + if not user: + raise ValueError("Usuario no encontrado") + + if current_user.id != user_id: + if current_user.tipo_usuario not in ["root", "admin"]: + raise PermissionError("Admin only can update user ur enterprise") + try: + updated_user = await UserCrudService.Update_user(db=db, user_id=user_id, user_data=data, current_user=current_user) + print(f"TIPO DE updated_user: {type(updated_user)}") + print(f"VALOR: {updated_user}") + print(f"ID: {updated_user.id if updated_user else 'None'}") + return UserResponse.model_validate(updated_user) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + + + + \ No newline at end of file diff --git a/app/modules/users/schema.py b/app/modules/users/schema.py new file mode 100644 index 0000000..c2a9088 --- /dev/null +++ b/app/modules/users/schema.py @@ -0,0 +1,96 @@ +# modules/users/schemas.py +from pydantic import BaseModel, EmailStr, Field, validator +from datetime import datetime +from typing import Optional +# +import re +from enum import Enum + +class TipoUsuario(str, Enum): + ROOT = "root" + ADMIN = "admin" + ADMIN_LICENCIAS = "admin_licencias" + USER = "user" + +class RolOperativo(str, Enum): + COMPRAS ="compras" + VENTAS ="ventas" + LOGISTICA ="logistica" + ADUANA_SOFT ="aduana_soft" + CLIENTE ="cliente" + OPERATIVO ="operativo" + +# ============================================ +# BASE SCHEMAS +# ============================================ +class UserBase(BaseModel): + name: str = Field(...) + middle_Name: Optional[str] = Field(None) + last_Name: str = Field(...) + rfc: str = Field(..., example="PORF8513JH3") + email: EmailStr + tipo_usuario: TipoUsuario + rol_operativo: RolOperativo + permite_diot: bool = False + +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + enterprise_id: Optional[int] = Field(None) + firma: Optional[str] = Field(None) + + + @validator("password") + def validate_password(cls, v): + if len(v) < 8: + raise ValueError("La contrasena debe tener al menos 8 caracteres") + if not re.search(r"[A-Z]", v): + raise ValueError("La contrasena debe contenera la emnos una mayuscula") + return v + + +class UserResponse(UserBase): + id: int + is_active: bool + last_login: datetime + created_by: Optional[int] + updated_by: Optional[int] + deleted_by: Optional[int] + created_at: datetime + updated_at: Optional[datetime] + deleted_at: Optional[datetime] + + class Config: + from_attributes = True + + +class UpdateUser(UserBase): + name: Optional[str] = None + middle_Name: Optional[str] = None + last_Name: Optional[str] = None + rfc: Optional[str] = None + email: Optional[EmailStr] = None + permite_diot: bool = None + rol_operativo: Optional[RolOperativo] = None + tipo_usuario: Optional[TipoUsuario] = None + firma: Optional[str] = Field(None, max_length=110) + brnaches_id: Optional[int] = None + license_id: Optional[int] = None + is_active: Optional[bool] = None + + class Config: + from_attributes = True + +#============================================ +class UserLogin(BaseModel): + email: EmailStr = Field(..., examples=["User@example.com"]) + password: str = Field(..., min_length=8) + + + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + +class DeleteResponse(BaseModel): + message: str diff --git a/app/modules/users/services.py b/app/modules/users/services.py new file mode 100644 index 0000000..cc43f6f --- /dev/null +++ b/app/modules/users/services.py @@ -0,0 +1,158 @@ +from datetime import datetime +from typing import List, Optional +# +from sqlalchemy.orm import Session +import logging +from app.modules.users.models import Users, TipoUsuario, Rol +# +from app.modules.users.schema import UserCreate, UserResponse, UpdateUser +from app.modules.users.repository import UserRepository +from app.core.security import get_password_hash +# +from app.core.auth import get_current_user +from app.core.baseRepository import BaseRepository +from app.pipelines.crud import CreatePipe + +class UserCrudService: + + @staticmethod + def create_user(db:Session, user_create: UserCreate, current_user: Optional[Users] = None) -> UserResponse: + + """ Create a new user in the database and approved if is root user""" + first = db.query(Users).count() + is_first_user = (first == 0) + + user_data = user_create.dict() + # + # + model_data = { + "name": user_data.get("name"), + "middle_Name": user_data.get("middle_Name"), + "last_Name": user_data.get("last_Name"), + "rfc": user_data.get("rfc"), + "email": user_data.get("email"), + "rol_operativo": user_data.get("rol_operativo"), + "tipo_usuario": user_data.get("tipo_usuario"), + "permite_diot": user_data.get("permite_diot", False), + "enterprise_id": user_data.get("enterprise_id"), + "firma": user_data.get("firma"), + "is_active": True, + } + + #Password comes raw since schema + plain_pass = user_data.get("password") + + + # this step is for hash the pasword + hashed_pass = get_password_hash(plain_pass) + model_data["password"] = hashed_pass + + # first user or not that, this is a joke :) + + + if is_first_user: + # if first user is being created, it must be root and approved by default + model_data["tipo_usuario"] = TipoUsuario.ROOT.value + model_data["created_by"] = None + + #this point is for make one instance + new_user = Users(**model_data) + db.add(new_user) + db.commit() + db.refresh(new_user) + + else: + # Case B: aditional User + if not current_user: + raise ValueError("requeries authentication for create user") + + #validate who is creating or if intent create a root user + if model_data["tipo_usuario"] == TipoUsuario.ROOT.value: + if current_user.tipo_usuario != TipoUsuario.ROOT.value: + raise ValueError(" only ROOT user can create another Root user") + + model_data["created_by"] = current_user.id + new_user = Users(**model_data) + db.add(new_user) + db.commit() + db.refresh(new_user) + + #to this point validate the next users if can be used the app, admin, license_admin and users + + + + + + return UserResponse.model_validate(new_user) + # if first is registered allthem users only can set admin or user role. + + + @staticmethod + def get_users(db: Session, skip: int =0, limit: int = 100) -> List[UserResponse]: + """"Get a list of users whith pagination""" + userRes = db.query(Users).offset(skip).limit(limit).all() + + return [UserResponse.model_validate(user) for user in userRes] + + @staticmethod + def delete_user(db: Session, user_id: int, current_user: Users) -> None: + """ this is soft delete, for traceability""" + user = db.query(Users).filter(Users.id == user_id).first() + + if not user: + raise ValueError( + "User not found") + if user.tipo_usuario == TipoUsuario.ROOT.value: + raise ValueError("Root user cannot be deleted") + user.is_active = False + user.deleted_by = current_user.id + user.deleted_at = datetime.utcnow() + + db.commit() + return { + "message": f"User has been deleted" + } + + #=========== UPDATE ===================== + @staticmethod + async def Update_user(db: Session, user_id: int, user_data: UpdateUser, current_user: Users) -> Users: + """Function to updates users, only personal data""" + user = db.query(Users).filter(Users.id == user_id).first() + + if not user: + raise ValueError( + "User not found") + + if current_user.id != user_id: + + if current_user.tipo_usuario not in ["root", "admin"]: + raise PermissionError("You don't have permition to updated") + + if current_user.tipo_usuario == "admin": + if user.enterprise_id != current_user.enterprise_id: + raise PermissionError(" Admin only can't update user enterprise ") + + update_data = user_data.dict(exclude_unset=True) + + if user.tipo_usuario == "root" and update_data["tipo_usuario"] != "root": + root_count = db.query(Users).filter( + Users.tipo_usuario == "root", + Users.deleted_at.is_(None) + ).count + if root_count <= 1: + raise ValueError("Can't downgrade ROOT user") + + for field, value in update_data.items(): + if hasattr(user, field): + setattr(user, field, value) + + user.updated_by = current_user.id + user.updated_at = datetime.utcnow() + + db.commit() + db.refresh(user) + + return user + + + \ No newline at end of file diff --git a/app/pipelines/__pycache__/base.cpython-311.pyc b/app/pipelines/__pycache__/base.cpython-311.pyc new file mode 100644 index 0000000..577faa8 Binary files /dev/null and b/app/pipelines/__pycache__/base.cpython-311.pyc differ diff --git a/app/pipelines/__pycache__/crud.cpython-311.pyc b/app/pipelines/__pycache__/crud.cpython-311.pyc new file mode 100644 index 0000000..2420b27 Binary files /dev/null and b/app/pipelines/__pycache__/crud.cpython-311.pyc differ diff --git a/app/pipelines/base.py b/app/pipelines/base.py new file mode 100644 index 0000000..7bb8b41 --- /dev/null +++ b/app/pipelines/base.py @@ -0,0 +1,79 @@ +from typing import Any, Dict, Optional, Callable, List, TypeVar, Generic +from sqlalchemy.orm import Session +from app.core.auth import CurrentUser + +from app.serviceInput.base import ServiceInput +from app.serviceOutput.base import ServiceOutput + +InputT = TypeVar("InputT") +OutputT = TypeVar("OutputT") + +class Pipeline(Generic[InputT, OutputT]): + """ + Pipeline base que unifica entrada, procesamiento y salida. + """ + + def __init__(self, db: Session, user: CurrentUser, operation: str): + self.db = db + self.user = user + self.operation = operation + self._steps: List[Callable] = [] + self._input: Optional[ServiceInput] = None + self._output: Optional[ServiceOutput] = None + self._input_data: Any = None + self._schema = None + self._permission: Optional[str] = None + self._affidavit: Optional[Any] = None + + def set_input_data(self, data: Any) -> 'Pipeline': + """Define los datos de entrada""" + self._input_data = data + return self + + def with_schema(self, schema: Any) -> 'Pipeline': + """Define el schema de validación""" + self._schema = schema + return self + + def with_permission(self, permission: Optional[str]) -> 'Pipeline': + """Define el permiso requerido""" + self._permission = permission + return self + + def with_affidavit(self, affidavit: Any) -> 'Pipeline': + """Define el certificador legal""" + self._affidavit = affidavit + return self + + def add_step(self, name: str, func: Callable) -> 'Pipeline': + """Agrega un paso al pipeline""" + self._steps.append({"name": name, "func": func}) + return self + + async def execute(self, context: Dict = None) -> Dict: + """ + Ejecuta el pipeline completo: + 1. Validación de entrada + 2. Verificación de permisos + 3. Ejecución de pasos + 4. Retorna resultado + """ + if context is None: + context = {} + + context["db"] = self.db + context["user"] = self.user + context["operation"] = self.operation + context["input_data"] = self._input_data + context["schema"] = self._schema + context["permission"] = self._permission + context["affidavit"] = self._affidavit + + result = context + + for step in self._steps: + result = await step["func"](result) + if result.get("error"): + return {"success": False, "error": result["error"]} + + return {"success": True, "data": result.get("output")} \ No newline at end of file diff --git a/app/pipelines/crud.py b/app/pipelines/crud.py new file mode 100644 index 0000000..2d4d889 --- /dev/null +++ b/app/pipelines/crud.py @@ -0,0 +1,289 @@ +from typing import Type, Dict, Any, List +from sqlalchemy.orm import Session +from app.pipelines.base import Pipeline +# # +from app.serviceInput.base import ServiceInput +from app.core.auth import CurrentUser + + +class CreatePipe(Pipeline): + """Pipe prefab to create enititys""" + + def __init__(self, db: Session, user: CurrentUser, model: Type, repository): + super().__init__(db, user, f"CREATE_{model.__tablename__.upper()}") + self.model = model + self.repository = repository + self._build() + + def _build(self): + """Build steps to Pipe""" + self.add_step("Validate", self._validate) + self.add_step("check_permissions", self._check_permissions) + self.add_step("execute", self._execute) + self.add_step("format_output", self._format) + + async def _validate(self, ctx): + data = ctx.get("input_data", {}) + if ctx.get("schema"): + validated = ctx["schema"](**data) + ctx["validated_data"] = validated.dict() + else: + ctx["validated_data"] = data + return ctx + + + async def _check_permissions(self, ctx): + if ctx.get("permission"): + if not ctx.get("user"): + ctx["error"] = "Usuario no autenticado" + return ctx + + + async def _execute(self, ctx): + if ctx.get("error"): + return ctx + + try: + created_by = ctx["user"].id if ctx.get("user") else None + entity = self.repository.create(ctx["validated_data"], created_by) + ctx["entity"] = entity + except Exception as e: + ctx["error"] = str(e) + return ctx + + + async def _format(self, ctx): + if ctx.get("error"): + ctx["output"] = None + return ctx + + if ctx.get("entity"): + ctx["output"] = {c.name: getattr(ctx["entity"], c.name) + for c in self.model.__table__.columns} + print(f"FORMAT: output creado con {len(ctx['output'])} campos") + else: + print("FORMAT: No hay entity para formatear") + ctx["output"] = None + return ctx + + +class ReadPipe(Pipeline): + """ Read prefab """ + + def __init__(self, db: Session, user: CurrentUser, model: Type, repository): + super().__init__(db, user, f"READ_{model.__tablename__.upper()}") + self.model = model + self.repository = repository + self._build() + + def _build(self): + self.add_step("validate_id", self._validate_id) + self.add_step("check_permissions", self._check_permissions) + self.add_step("execurte", self._execute) + self.add_step("format_output", self._format) + + async def _validate_id(self, ctx): + input_data = ctx["input"].raw_data + entity_id = input_data.get("id") + if not entity_id: + raise ValueError("se requiere ID") + ctx["entity_id"] = entity_id + return ctx + + async def _check_permissions(self, ctx): + return ctx + + async def _execute(self, ctx): + entity = self.repository.get_by_id(ctx["entity_id"]) + if not entity: + raise ValueError(f"{self.model.__name__} no encontrado") + ctx["entity"] = entity + ctx["output"] = entity + return ctx + async def _format(self, ctx): + ctx["output"] = {c.name: getattr(ctx["entity"], c.name) + for c in self.model.__table__.columns} + return ctx + + +class UpdatePipe(Pipeline): + + """Pipe prefab to Update""" + + def __init__(self, db:Session, user:CurrentUser, model: Type, repository): + super().__init__(db, user, f"UPDATE_{model.__tablename__.upper()}") + self.model = model + self.repository = repository + self._build() + + def _build(self): + self.add_step("validate", self._validate) + self.add_step("validate_id", self._validate_id) + self.add_step("check_permissions", self._check_permissions) + self.add_step("execute", self._execute) + self.add_step("certify", self._certify) + self.add_step("format_output", self._format) + + async def _validate(self, ctx): + data = ctx["input"].get_validated() + ctx["validated_data"] = data.dict() if data else {} + return ctx + + async def _validate_id(self, ctx): + entity_id = ctx["validated_data"].get("id") + if not entity_id: + raise ValueError("Se rqeuiere ID") + ctx["entity_id"] = entity_id + return ctx + + async def _check_permissions(self, ctx): + return ctx + async def _execute(self, ctx): + entity = self.repository.update(ctx["entity_id"], ctx["validated_data"]) + if not entity: + raise ValueError(f"{self.model.__name__} no encontrado") + ctx["entity"] = entity + ctx["output"] = entity + return ctx + + async def _certify(self, ctx): + return ctx + + + async def _format(self, ctx): + ctx["output"] = { + "id": ctx["entity"].id, + "message": f"{self.model.__name__} actualizado exitosamente" + } + return ctx + +class SoftDelete(Pipeline): + + """Pipe prefab to SoftDelete""" + + def __init__(self, db:Session, user:CurrentUser, model: Type, repository): + super().__init__(db, user, f"DELETE_{model.__tablename__.upper()}") + self.model = model + self.repository = repository + self._build() + + def _build(self): + self.add_step("validate", self._validate) + self.add_step("validate_id", self._validate_id) + self.add_step("check_permissions", self._check_permissions) + self.add_step("check_already_deleted", self._check_already_deleted) + self.add_step("execute", self._execute) + self.add_step("certify", self._certify) + self.add_step("format_output", self._format) + + async def _validate_id(self, ctx): + input_data = ctx["input"].raw_data + entity_id = input_data.get("id") + if not entity_id: + raise ValueError("se requiere ID") + ctx["entity_id"] = entity_id + return ctx + + async def _check_permissions(self, ctx): + return ctx + + async def _check_already_deleted(self, ctx): + """Verify entity has no deleted""" + entity = self.repository.get_by_id(ctx["entity_id"]) + if not entity: + raise ValueError(f"{self.model.__name__} no encontrado") + + if not entity.is_active or entity.deleted_at is not None: + raise ValueError(f"{self.model.__name__} ya esta eliminado") + + ctx["entity"] = entity + return ctx + + async def _execute(self, ctx): + """ SoftDelete implemented""" + + entity = ctx["entity"] + from datetime import datetime + + entity.is_active = False + entity.deleted_at = datetime.utcnow() + entity.deleted_by = ctx["user"].id + + self.db.commit() + self.db.refresh(entity) + + ctx["deleted"] = True + ctx["output"] = {"id": entity.id, "deleted": True} + return ctx + + + async def _certify(self, ctx): + return ctx + + + async def _format(self, ctx): + + ctx["output"]["message"] = f"{self.model.__name__} eliminado corectamente" + ctx["output"]["deleted_at"] = ctx["entity"].deleted_at.isoformat() + ctx["output"]["deleted_by"] = ctx["entity"].delted_by + return ctx + +class ListPipe(Pipeline): + """Pipeline prefab to list entitys""" + + def __init__(self, db: Session, user: CurrentUser, model: Type, repository): + super().__init__(db, user, f"LIST_{model.__tablename__.upper()}") + self.model = model + self.repository = repository + self._skip = 0 + self._limit = 100 + self._filters = {} + self._include_inactive = False + self._build() + + def with_pagination(self, skip: int, limit: int) -> 'ListPipe': + self._skip = skip + self._limit = limit + return self + + def with_filters(self, filters: Dict) -> 'ListPipe': + self._filters = filters + return self + + def include_inactive(self, value: bool = True) -> 'ListPipe': + self._include_inactive = value + return self + + def _build(self): + self.add_step("parse_params", self._parse_params) + self.add_step("check_permissions", self._check_permissions) + self.add_step("execute", self._execute) + self.add_step("format_output", self._format) + + async def _parse_params(self, ctx): + ctx["skip"] = self._skip + ctx["limit"] = self._limit + ctx["filters"] = self._filters + ctx["include_inactive"] = self._include_inactive + return ctx + + async def _check_permissions(self, ctx): + return ctx + + async def _execute(self, ctx): + entities = self.repository.get_all( + skip=ctx["skip"], + limit=ctx["limit"], + filters=ctx["filters"] + ) + ctx["entities"] = entities + ctx["output"] = entities + return ctx + + async def _format(self, ctx): + ctx["output"] = [ + {c.name: getattr(e, c.name) for c in self.model.__table__.columns} + for e in ctx["entities"] + ] + return ctx + \ No newline at end of file diff --git a/app/serviceInput/__init__.py b/app/serviceInput/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/serviceInput/__pycache__/__init__.cpython-311.pyc b/app/serviceInput/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..12a0304 Binary files /dev/null and b/app/serviceInput/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/serviceInput/__pycache__/base.cpython-311.pyc b/app/serviceInput/__pycache__/base.cpython-311.pyc new file mode 100644 index 0000000..d5133a2 Binary files /dev/null and b/app/serviceInput/__pycache__/base.cpython-311.pyc differ diff --git a/app/serviceInput/base.py b/app/serviceInput/base.py new file mode 100644 index 0000000..e979aa8 --- /dev/null +++ b/app/serviceInput/base.py @@ -0,0 +1,29 @@ +from typing import Any, Dict, Optional, Generic, TypeVar +from pydantic import BaseModel +from datetime import datetime + +InputType = TypeVar("InputType", bound=BaseModel) + +class ServiceInput(Generic[InputType]): + """ Base unificada de entradas """ + + def __init__(self, raw_data: Any, source: str = "api"): + self.raw_data = raw_data + self.source = source + self.timestamp = datetime.utcnow() + self._validated = None + + def validate(self, schema: type[InputType]) -> InputType: + """Validacion de datos en contra de un schema""" + self._validated = schema(**self.raw_data) + return self._validated + + def get_validated(self) -> Optional[InputType]: + return self._validated + + def to_dict(self) -> Dict: + return { + "source": self.source, + "timestamp": self.timestamp.isoformat(), + "data": self.raw_data, + } \ No newline at end of file diff --git a/app/serviceOutput/__init__.py b/app/serviceOutput/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/serviceOutput/__pycache__/__init__.cpython-311.pyc b/app/serviceOutput/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..7f38cd7 Binary files /dev/null and b/app/serviceOutput/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/serviceOutput/__pycache__/base.cpython-311.pyc b/app/serviceOutput/__pycache__/base.cpython-311.pyc new file mode 100644 index 0000000..c0b940f Binary files /dev/null and b/app/serviceOutput/__pycache__/base.cpython-311.pyc differ diff --git a/app/serviceOutput/base.py b/app/serviceOutput/base.py new file mode 100644 index 0000000..aa45358 --- /dev/null +++ b/app/serviceOutput/base.py @@ -0,0 +1,37 @@ +from typing import Any, Dict, Optional, Generic, TypeVar +from datetime import datetime + +OutputType = TypeVar("OutputType") + +class ServiceOutput(Generic[OutputType]): + """ Base para cualquier salida de los pipelines""" + + def __init__(self, data: OutputType, operation: str, status: str = "success"): + self.data = data + self.operation = operation + self.status = status + self.timestamp = datetime.utcnow() + self.metadata = {} + + def add_metadata(self, key: str, value: Any) -> 'ServiceOutput': + self.metadata[key] = value + return self + + def to_dict(self) -> Dict: + result = { + "operation": self.operation, + "status": self.status, + "timestamp": self.timestamp.isoformat(), + "data": self.data + } + if self.metadata: + result["metadata"] = self.metadata + return result + + def to_json(self) -> str: + import json + return json.dumps(self.to_dict(), default=str) + + def to_print(self) -> str: + """Para salida en consola""" + return f"[{self.status.upper()}] {self.operation}: {self.data}" \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..8eb8586 --- /dev/null +++ b/database.py @@ -0,0 +1,104 @@ + +# Database.py + +""" +Configuracion de la base de datos para XMA en PostgresSQL + +""" +""" +Configuracion de la base de datos para XMA en PostgresSQL +""" + +import os +from sqlalchemy import create_engine, event, text +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.pool import QueuePool + +from sqlalchemy.ext.declarative import declarative_base +from typing import Generator +import logging + + + +logger = logging.getLogger(__name__) + +# URL +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+psycopg2://postgres:postgres@localhost:5432/postgres", +) + +# Configuracion de las conexiones +POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20")) +MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10")) +POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30")) +POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCKE", "3600")) +ECHO = os.getenv("DB_ECHO", "False").lower() == "true" + +# Create Engine +engine = create_engine( + DATABASE_URL, + poolclass=QueuePool, + pool_size=POOL_SIZE, + max_overflow=MAX_OVERFLOW, + pool_timeout=POOL_TIMEOUT, + pool_recycle=POOL_RECYCLE, + pool_pre_ping=True, + echo=ECHO, + connect_args={ + "connect_timeout": 10, + "keepalives": 1, + "keepalives_idle": 30, + "keepalives_interval": 10, + "keepalives_count": 5 + } +) +Base = declarative_base() + +def test_connection(): + """Prueba de conexion a la bd""" + try: + with engine.connect() as conn: + result = conn.execute(text("SELECT 1")) + result.fetchone() + logger.info("Conexion a postgres establecida") + return True + except Exception as e: + logger.error(f"Error al conectar a postgres: {e}") + return False + +# Session Local +sessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + expire_on_commit=False +) + +def get_db() -> Generator[Session, None, None]: + """Dependencia para FastAPI""" + db = sessionLocal() + try: + yield db + except Exception as e: + logger.error(f"Error en session de base de datos: {e}") + db.rollback() + raise + finally: + db.close() + +@event.listens_for(engine, "connect") +def receive_connect(dbapi_connection, connection_record): + logger.debug("Nueva conexión a PostgreSQL establecida") + +@event.listens_for(engine, "checkout") +def receive_checkout(dbapi_connection, connection_record, connection_proxy): + logger.debug("Conexión tomada del pool") + +@event.listens_for(engine, "checkin") +def receive_checkin(dbapi_connection, connection_record): + logger.debug("Conexión devuelta al pool") + +def dispose_engine(): + engine.dispose() + logger.info("Pool de conexiones cerrado") \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..98d304d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,50 @@ +# docker-compose.dev.yml +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: verificaEfos_postgres_1 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: + context: . + dockerfile: Dockerfile.dev + container_name: verificaEfos_app-1 + environment: + DATABASE_URL: postgresql+psycopg2://postgres:postgres@postgres:5432/postgres + ports: + - "8000:8000" + volumes: + - .:/app + - /app/__pycache__ + working_dir: /app + depends_on: + postgres: + condition: service_healthy + command: > + sh -c " + echo 'Esperando a que PostgreSQL esté listo...' && + sleep 3 && + echo 'Ejecutando migraciones...' && + alembic upgrade head && + echo 'Iniciando aplicación...' && + uvicorn main:app --host 0.0.0.0 --port 8000 --reload + " + +volumes: + postgres_data: \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..88ea359 --- /dev/null +++ b/main.py @@ -0,0 +1,58 @@ +import os +import logging +from contextlib import asynccontextmanager +# +from fastapi import FastAPI +from app.core.servo import get_servo +from database import test_connection + +from app.modules.users.route import router as user_router + +logging.basicConfig(level=logging.INFO, + format= "%(asctime)s - %(levelname)s - %(message)s", + datefmt= "%H:%M:%S") + +logger = logging.getLogger(__name__) + + + +@asynccontextmanager +async def lifespan(app: FastAPI): +# STARTUP + logger.info("=" * 50) + logger.info(" XMA - Xtended Module Architecture") + logger.info("=" * 50) + + servo = get_servo() + await servo.start() + + logger.info(f" Sistema listo | Estado: {servo.state.value.upper()} | Modo: {servo.mode.upper()}") + logger.info("=" * 50) + + yield + + # SHUTDOWN + logger.info(" Apagando XMA...") + await servo.stop() + logger.info(" XMA detenido correctamente") + + +app = FastAPI( + title="XMA - Xtended Module Architecture", + version="1.0.0", + lifespan=lifespan +) + + + +@app.get("/healt") +async def health(): + servo = get_servo() + return servo.get_status() + +@app.get("/") +async def root(): + return{"message": "XMA running", "status": get_servo().state.value} + + +app.include_router(user_router) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/__pycache__/env.cpython-311.pyc b/migrations/__pycache__/env.cpython-311.pyc new file mode 100644 index 0000000..e7f1698 Binary files /dev/null and b/migrations/__pycache__/env.cpython-311.pyc differ diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..89c7805 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,55 @@ + + +import sys +from pathlib import Path +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Agregar el directorio raíz al path +sys.path.append(str(Path(__file__).parent.parent)) + +# Importar target_metadata desde modules +from app.modules import target_metadata + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + + diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/03baecbcb5d9_adjustmen_modules.py b/migrations/versions/03baecbcb5d9_adjustmen_modules.py new file mode 100644 index 0000000..c4a7d9f --- /dev/null +++ b/migrations/versions/03baecbcb5d9_adjustmen_modules.py @@ -0,0 +1,407 @@ +"""adjustmen_modules + +Revision ID: 03baecbcb5d9 +Revises: d41d0457f936 +Create Date: 2026-04-01 18:46:35.084090 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '03baecbcb5d9' +down_revision: Union[str, None] = 'd41d0457f936' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('configuration', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('smtp_host', sa.String(length=100), nullable=True), + sa.Column('smtp_port', sa.Integer(), nullable=True), + sa.Column('smtp_user', sa.String(length=100), nullable=True), + sa.Column('smtp_password', sa.String(length=100), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_send', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('updtated_by', sa.Integer(), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_cofig_active', 'configuration', ['is_active'], unique=False) + op.create_table('credits', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=30), nullable=False), + sa.Column('rfc', sa.String(length=25), nullable=False), + sa.Column('razon_social', sa.String(length=100), nullable=False), + sa.Column('tipo_persona', sa.String(length=100), nullable=False), + sa.Column('supuesto', sa.Enum('CANCELADOS', 'CONDONADOS', 'FIRMES', 'SENTENCIAS', 'EXIGIBLES', 'RETORNO_INVERSIONES', 'FRACCION_X', 'FRACCION_VII', 'NO_LOCALIZADOS', name='supuestos'), nullable=False), + sa.Column('fecha_primera_publicacion', sa.Date(), nullable=False), + sa.Column('fecha_publicacion_ley_transparencia', sa.Date(), nullable=True), + sa.Column('fecha_cancelacion', sa.Date(), nullable=True), + sa.Column('fecha_cancelacion_csd', sa.Date(), nullable=True), + sa.Column('entidad_federativa', sa.String(), nullable=False), + sa.Column('monto', sa.Integer(), nullable=True), + sa.Column('motivo', sa.Text(), nullable=True), + sa.Column('location_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('uploaded_by', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('edos', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('numero', sa.Integer(), nullable=False), + sa.Column('razon_social', sa.String(length=100), nullable=False), + sa.Column('situacion', sa.Enum('SENTENCIA_FAVORABLE', 'DEFINITIVO', name='situcion'), nullable=False), + sa.Column('numero_definitivo', sa.String(length=60), nullable=False), + sa.Column('fecha_definitivo', sa.Date(), nullable=False), + sa.Column('publicaccion_sat', sa.Date(), nullable=True), + sa.Column('numero_def_dof', sa.String(length=100), nullable=True), + sa.Column('fecha_def_dof', sa.Date(), nullable=True), + sa.Column('publicacion_dof', sa.Date(), nullable=True), + sa.Column('numero_fav_sat', sa.String(length=100), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('efos', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('numero', sa.Integer(), nullable=False), + sa.Column('rfc', sa.String(length=30), nullable=False), + sa.Column('nombre_contribuyente', sa.String(length=100), nullable=False), + sa.Column('situacion', sa.String(length=60), nullable=False), + sa.Column('publi_presuntos_sat', sa.Date(), nullable=True), + sa.Column('publi_desvirtuados_sat', sa.Date(), nullable=True), + sa.Column('publi_definitivos_sat', sa.Date(), nullable=True), + sa.Column('publi_favorables_sat', sa.Date(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('loaded_by', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('files', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=True), + sa.Column('summary', sa.Text(), nullable=False), + sa.Column('document_url', sa.String(length=100), nullable=False), + sa.Column('file_type', sa.String(length=50), nullable=False), + sa.Column('file_size', sa.Integer(), nullable=False), + sa.Column('feed_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('uploaded_by', sa.Integer(), nullable=False), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('invoices', + sa.Column('id', sa.Integer(), nullable=True), + sa.Column('emisor', sa.String(length=100), nullable=False), + sa.Column('receptor', sa.String(length=110), nullable=False), + sa.Column('uuid', sa.String(length=255), nullable=False), + sa.Column('total', sa.Integer(), nullable=False), + sa.Column('tipo', sa.String(length=50), nullable=True), + sa.Column('date', sa.DateTime(), nullable=True), + sa.Column('verified_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('verified_by', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('location', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('country', sa.String(length=120), nullable=False), + sa.Column('country_id', sa.Integer(), nullable=False), + sa.Column('state', sa.String(length=100), nullable=False), + sa.Column('state_id', sa.Integer(), nullable=False), + sa.Column('city', sa.String(length=100), nullable=False), + sa.Column('city_id', sa.Integer(), nullable=False), + sa.Column('cp_zp', sa.Integer(), nullable=True), + sa.Column('street', sa.String(length=120), nullable=True), + sa.Column('is_department', sa.Boolean(), nullable=False), + sa.Column('number_ext', sa.Integer(), nullable=True), + sa.Column('number_int', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_location_active', 'location', ['is_active'], unique=False) + op.create_index('idx_location_city', 'location', ['city'], unique=False) + op.create_index('idx_location_country', 'location', ['country'], unique=False) + op.create_index('idx_location_cp', 'location', ['cp_zp'], unique=False) + op.create_index('idx_location_state', 'location', ['state'], unique=False) + op.create_table('users', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('middle_Name', sa.String(length=100), nullable=True), + sa.Column('last_Name', sa.String(length=120), nullable=False), + sa.Column('rfc', sa.String(length=60), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password', sa.String(length=255), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.Column('rol_operativo', sa.Enum('COMPRAS', 'VENTAS', 'LOGISTICA', 'ADUANA_SOFT', 'CLIENTE', 'OPERATIVO', name='rol'), nullable=False), + sa.Column('tipo_usuario', sa.Enum('ROOT', 'ADMIN', 'PRIVILEGED', 'ADMIN_LICENCIAS', 'UPDATER', 'USER', name='tipousuario'), nullable=False), + sa.Column('permite_diot', sa.Boolean(), nullable=False), + sa.Column('enterprise_id', sa.Integer(), nullable=True), + sa.Column('firma', sa.String(length=110), nullable=True), + sa.Column('branches_id', sa.Integer(), nullable=True), + sa.Column('license_id', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_login', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.Column('password_reset_token', sa.String(), nullable=True), + sa.Column('password_reset_expires', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('firma'), + sa.UniqueConstraint('rfc') + ) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_table('clients', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('rfc', sa.String(length=60), nullable=False), + sa.Column('email', sa.String(length=150), nullable=False), + sa.Column('short_name', sa.String(length=255), nullable=True), + sa.Column('razon_social', sa.String(length=255), nullable=False), + sa.Column('fiscal_number', sa.String(length=15), nullable=False), + sa.Column('cellphone', sa.String(length=20), nullable=False), + sa.Column('medio', sa.Enum('MANUAL', 'DIOT', 'EXCEL', 'FACTURA', name='medio_enum'), nullable=False), + sa.Column('third_type', sa.Enum('NACIONAL', 'EXTRANJERO', 'GLOBAL', name='tercero_enum'), nullable=False), + sa.Column('operation_type', sa.Enum('PRESTACIONSERVICIOSPROFECIONALES', 'ARRENDAMIENTOSINMUENBLES', 'OTROS', name='operacion_enum'), nullable=False), + sa.Column('is_foreign', sa.Boolean(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('location_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['location_id'], ['location.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('fiscal_number'), + sa.UniqueConstraint('rfc') + ) + op.create_index(op.f('ix_clients_id'), 'clients', ['id'], unique=False) + op.create_index(op.f('ix_clients_razon_social'), 'clients', ['razon_social'], unique=False) + op.create_table('feed', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=False), + sa.Column('body', sa.Text(), nullable=True), + sa.Column('document_url', sa.String(), nullable=True), + sa.Column('publication_date', sa.Date(), nullable=True), + sa.Column('is_important', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('file_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('interacctions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('feed_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('type_iteractions', sa.Enum('LIKE', 'DONT_LIKE', 'APPROVED', 'DISAPRROVE', 'NONE', name='type_interactios'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('affidavit_logs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('operation', sa.Enum('DELETED', 'CREATED', 'UPDATED', 'TRIED', 'MOST', 'LESS', 'MINUS', 'CANCELED', name='operation'), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('adjustment', sa.String(length=100), nullable=True), + sa.Column('status', sa.Enum('COMPLETED', 'PARTIAL', 'INITIAL', 'NO_PROCECED', 'INVALID', name='status'), nullable=True), + sa.Column('sign', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_affidavit_client', 'affidavit_logs', ['client_id'], unique=False) + op.create_index('idx_affidavit_created', 'affidavit_logs', ['created_at'], unique=False) + op.create_index('idx_affidavit_operation', 'affidavit_logs', ['operation'], unique=False) + op.create_index('idx_affidavit_status', 'affidavit_logs', ['status'], unique=False) + op.create_index('idx_affidavit_user', 'affidavit_logs', ['user_id'], unique=False) + op.create_table('branches', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('direccion', sa.String(length=255), nullable=False), + sa.Column('cp', sa.Integer(), nullable=False), + sa.Column('is_physical', sa.Boolean(), nullable=False), + sa.Column('location_id', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['location_id'], ['location.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_branches_active', 'branches', ['is_active'], unique=False) + op.create_index('idx_branches_client', 'branches', ['client_id'], unique=False) + op.create_index('idx_branches_location', 'branches', ['location_id'], unique=False) + op.create_table('coments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('texto', sa.Text(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('feed_id', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deactivate_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('deactivated_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['feed_id'], ['feed.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_coments_active', 'coments', ['is_active'], unique=False) + op.create_index('idx_coments_created', 'coments', ['created_at'], unique=False) + op.create_index('idx_coments_feed', 'coments', ['feed_id'], unique=False) + op.create_index('idx_coments_user', 'coments', ['user_id'], unique=False) + op.create_table('license', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('titular', sa.Integer(), nullable=False), + sa.Column('begins_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('ends_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('token_license', sa.String(length=30), nullable=True), + sa.Column('location_id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['titular'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('moves', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('action_type', sa.Enum('UPLOAD', 'MATCH', 'QUERY', 'CONSUMPTION', 'CREATE', 'DELETE', 'SOLD', 'INTERACTION', 'COMMENT', 'CONFIG', 'EMAIL', name='action_type'), nullable=False), + sa.Column('target_type', sa.Enum('CLIENT', 'INVOICES', 'FEED', 'EFOS', 'CREDITS', 'USERS', 'EMAIL', name='target_type'), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('move_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.String(length=255), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('suppliers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('rfc', sa.String(length=60), nullable=False), + sa.Column('email', sa.String(length=150), nullable=False), + sa.Column('short_name', sa.String(length=255), nullable=True), + sa.Column('razon_social', sa.String(length=255), nullable=False), + sa.Column('fiscal_number', sa.String(length=15), nullable=False), + sa.Column('cellphone', sa.String(length=20), nullable=False), + sa.Column('supplier_type', sa.Enum('NACIONAL', 'EXTRANJERO', 'GLOBAL', name='suppliertype'), nullable=False), + sa.Column('location_id', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('deleted_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['location_id'], ['location.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('fiscal_number'), + sa.UniqueConstraint('rfc') + ) + op.create_index('idx_suppliers_active', 'suppliers', ['is_active'], unique=False) + op.create_index('idx_suppliers_client', 'suppliers', ['client_id'], unique=False) + op.create_index('idx_suppliers_location', 'suppliers', ['location_id'], unique=False) + op.create_index('idx_suppliers_type', 'suppliers', ['supplier_type'], unique=False) + op.create_index(op.f('ix_suppliers_razon_social'), 'suppliers', ['razon_social'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_suppliers_razon_social'), table_name='suppliers') + op.drop_index('idx_suppliers_type', table_name='suppliers') + op.drop_index('idx_suppliers_location', table_name='suppliers') + op.drop_index('idx_suppliers_client', table_name='suppliers') + op.drop_index('idx_suppliers_active', table_name='suppliers') + op.drop_table('suppliers') + op.drop_table('moves') + op.drop_table('license') + op.drop_index('idx_coments_user', table_name='coments') + op.drop_index('idx_coments_feed', table_name='coments') + op.drop_index('idx_coments_created', table_name='coments') + op.drop_index('idx_coments_active', table_name='coments') + op.drop_table('coments') + op.drop_index('idx_branches_location', table_name='branches') + op.drop_index('idx_branches_client', table_name='branches') + op.drop_index('idx_branches_active', table_name='branches') + op.drop_table('branches') + op.drop_index('idx_affidavit_user', table_name='affidavit_logs') + op.drop_index('idx_affidavit_status', table_name='affidavit_logs') + op.drop_index('idx_affidavit_operation', table_name='affidavit_logs') + op.drop_index('idx_affidavit_created', table_name='affidavit_logs') + op.drop_index('idx_affidavit_client', table_name='affidavit_logs') + op.drop_table('affidavit_logs') + op.drop_table('interacctions') + op.drop_table('feed') + op.drop_index(op.f('ix_clients_razon_social'), table_name='clients') + op.drop_index(op.f('ix_clients_id'), table_name='clients') + op.drop_table('clients') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_table('users') + op.drop_index('idx_location_state', table_name='location') + op.drop_index('idx_location_cp', table_name='location') + op.drop_index('idx_location_country', table_name='location') + op.drop_index('idx_location_city', table_name='location') + op.drop_index('idx_location_active', table_name='location') + op.drop_table('location') + op.drop_table('invoices') + op.drop_table('files') + op.drop_table('efos') + op.drop_table('edos') + op.drop_table('credits') + op.drop_index('idx_cofig_active', table_name='configuration') + op.drop_table('configuration') + # ### end Alembic commands ### diff --git a/migrations/versions/37e3df20754e_xma_architecture_all_modules_with_.py b/migrations/versions/37e3df20754e_xma_architecture_all_modules_with_.py new file mode 100644 index 0000000..8e483e0 --- /dev/null +++ b/migrations/versions/37e3df20754e_xma_architecture_all_modules_with_.py @@ -0,0 +1,36 @@ +"""XMA architecture - all modules with bidirectional relationships + +Revision ID: 37e3df20754e +Revises: 8e2919155783 +Create Date: 2026-04-01 19:35:18.366513 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '37e3df20754e' +down_revision: Union[str, None] = '8e2919155783' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key(None, 'credits', 'location', ['location_id'], ['id']) + op.drop_column('users', 'license_id') + op.drop_column('users', 'branches_id') + op.drop_column('users', 'client_id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('client_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('users', sa.Column('branches_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('license_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'credits', type_='foreignkey') + # ### end Alembic commands ### diff --git a/migrations/versions/84e064c18a0f_adjustmen_modules.py b/migrations/versions/84e064c18a0f_adjustmen_modules.py new file mode 100644 index 0000000..a3b029f --- /dev/null +++ b/migrations/versions/84e064c18a0f_adjustmen_modules.py @@ -0,0 +1,42 @@ +"""adjustmen_modules + +Revision ID: 84e064c18a0f +Revises: 03baecbcb5d9 +Create Date: 2026-04-01 19:09:19.420667 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '84e064c18a0f' +down_revision: Union[str, None] = '03baecbcb5d9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('branches', sa.Column('user_id', sa.Integer(), nullable=True)) + op.create_index('idx_branches_user', 'branches', ['user_id'], unique=False) + op.create_foreign_key(None, 'branches', 'users', ['user_id'], ['id']) + op.alter_column('invoices', 'id', + existing_type=sa.INTEGER(), + nullable=True, + autoincrement=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('invoices', 'id', + existing_type=sa.INTEGER(), + nullable=False, + autoincrement=True) + op.drop_constraint(None, 'branches', type_='foreignkey') + op.drop_index('idx_branches_user', table_name='branches') + op.drop_column('branches', 'user_id') + # ### end Alembic commands ### diff --git a/migrations/versions/8e2919155783_base_adjustment_1_5.py b/migrations/versions/8e2919155783_base_adjustment_1_5.py new file mode 100644 index 0000000..f5e38f1 --- /dev/null +++ b/migrations/versions/8e2919155783_base_adjustment_1_5.py @@ -0,0 +1,30 @@ +"""base_adjustment_1.5 + +Revision ID: 8e2919155783 +Revises: b7441b91f586 +Create Date: 2026-04-01 19:14:53.327618 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8e2919155783' +down_revision: Union[str, None] = 'b7441b91f586' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/__pycache__/03baecbcb5d9_adjustmen_modules.cpython-311.pyc b/migrations/versions/__pycache__/03baecbcb5d9_adjustmen_modules.cpython-311.pyc new file mode 100644 index 0000000..00f2e95 Binary files /dev/null and b/migrations/versions/__pycache__/03baecbcb5d9_adjustmen_modules.cpython-311.pyc differ diff --git a/migrations/versions/__pycache__/37e3df20754e_xma_architecture_all_modules_with_.cpython-311.pyc b/migrations/versions/__pycache__/37e3df20754e_xma_architecture_all_modules_with_.cpython-311.pyc new file mode 100644 index 0000000..0b993ba Binary files /dev/null and b/migrations/versions/__pycache__/37e3df20754e_xma_architecture_all_modules_with_.cpython-311.pyc differ diff --git a/migrations/versions/__pycache__/84e064c18a0f_adjustmen_modules.cpython-311.pyc b/migrations/versions/__pycache__/84e064c18a0f_adjustmen_modules.cpython-311.pyc new file mode 100644 index 0000000..4f77944 Binary files /dev/null and b/migrations/versions/__pycache__/84e064c18a0f_adjustmen_modules.cpython-311.pyc differ diff --git a/migrations/versions/__pycache__/8e2919155783_base_adjustment_1_5.cpython-311.pyc b/migrations/versions/__pycache__/8e2919155783_base_adjustment_1_5.cpython-311.pyc new file mode 100644 index 0000000..fec32ea Binary files /dev/null and b/migrations/versions/__pycache__/8e2919155783_base_adjustment_1_5.cpython-311.pyc differ diff --git a/migrations/versions/__pycache__/b7441b91f586_base_adjustment.cpython-311.pyc b/migrations/versions/__pycache__/b7441b91f586_base_adjustment.cpython-311.pyc new file mode 100644 index 0000000..d38caac Binary files /dev/null and b/migrations/versions/__pycache__/b7441b91f586_base_adjustment.cpython-311.pyc differ diff --git a/migrations/versions/__pycache__/b7bce6fc0d7e_fix_moves_relations.cpython-311.pyc b/migrations/versions/__pycache__/b7bce6fc0d7e_fix_moves_relations.cpython-311.pyc new file mode 100644 index 0000000..402bb41 Binary files /dev/null and b/migrations/versions/__pycache__/b7bce6fc0d7e_fix_moves_relations.cpython-311.pyc differ diff --git a/migrations/versions/__pycache__/c01f58ebc7d4_fix_relationships_foreign_keys.cpython-311.pyc b/migrations/versions/__pycache__/c01f58ebc7d4_fix_relationships_foreign_keys.cpython-311.pyc new file mode 100644 index 0000000..dcd3c81 Binary files /dev/null and b/migrations/versions/__pycache__/c01f58ebc7d4_fix_relationships_foreign_keys.cpython-311.pyc differ diff --git a/migrations/versions/__pycache__/d41d0457f936_initial_schema.cpython-311.pyc b/migrations/versions/__pycache__/d41d0457f936_initial_schema.cpython-311.pyc new file mode 100644 index 0000000..3707777 Binary files /dev/null and b/migrations/versions/__pycache__/d41d0457f936_initial_schema.cpython-311.pyc differ diff --git a/migrations/versions/__pycache__/fbbb979fa356_fixed_relations_ships.cpython-311.pyc b/migrations/versions/__pycache__/fbbb979fa356_fixed_relations_ships.cpython-311.pyc new file mode 100644 index 0000000..1a38d02 Binary files /dev/null and b/migrations/versions/__pycache__/fbbb979fa356_fixed_relations_ships.cpython-311.pyc differ diff --git a/migrations/versions/b7441b91f586_base_adjustment.py b/migrations/versions/b7441b91f586_base_adjustment.py new file mode 100644 index 0000000..1e0821f --- /dev/null +++ b/migrations/versions/b7441b91f586_base_adjustment.py @@ -0,0 +1,34 @@ +"""base_adjustment + +Revision ID: b7441b91f586 +Revises: 84e064c18a0f +Create Date: 2026-04-01 19:12:39.530941 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b7441b91f586' +down_revision: Union[str, None] = '84e064c18a0f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('branches', sa.Column('user_id', sa.Integer(), nullable=True)) + op.create_index('idx_branches_user', 'branches', ['user_id'], unique=False) + op.create_foreign_key(None, 'branches', 'users', ['user_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'branches', type_='foreignkey') + op.drop_index('idx_branches_user', table_name='branches') + op.drop_column('branches', 'user_id') + # ### end Alembic commands ### diff --git a/migrations/versions/b7bce6fc0d7e_fix_moves_relations.py b/migrations/versions/b7bce6fc0d7e_fix_moves_relations.py new file mode 100644 index 0000000..6856768 --- /dev/null +++ b/migrations/versions/b7bce6fc0d7e_fix_moves_relations.py @@ -0,0 +1,30 @@ +"""fix-moves-relations + +Revision ID: b7bce6fc0d7e +Revises: fbbb979fa356 +Create Date: 2026-04-01 20:05:19.603068 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b7bce6fc0d7e' +down_revision: Union[str, None] = 'fbbb979fa356' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/c01f58ebc7d4_fix_relationships_foreign_keys.py b/migrations/versions/c01f58ebc7d4_fix_relationships_foreign_keys.py new file mode 100644 index 0000000..ee1a79d --- /dev/null +++ b/migrations/versions/c01f58ebc7d4_fix_relationships_foreign_keys.py @@ -0,0 +1,30 @@ +"""fix relationships foreign_keys + +Revision ID: c01f58ebc7d4 +Revises: b7bce6fc0d7e +Create Date: 2026-04-01 20:14:07.773329 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c01f58ebc7d4' +down_revision: Union[str, None] = 'b7bce6fc0d7e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/d41d0457f936_initial_schema.py b/migrations/versions/d41d0457f936_initial_schema.py new file mode 100644 index 0000000..6895b32 --- /dev/null +++ b/migrations/versions/d41d0457f936_initial_schema.py @@ -0,0 +1,30 @@ +"""initial_schema + +Revision ID: d41d0457f936 +Revises: +Create Date: 2026-04-01 17:39:33.561392 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd41d0457f936' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/fbbb979fa356_fixed_relations_ships.py b/migrations/versions/fbbb979fa356_fixed_relations_ships.py new file mode 100644 index 0000000..2c1b361 --- /dev/null +++ b/migrations/versions/fbbb979fa356_fixed_relations_ships.py @@ -0,0 +1,56 @@ +"""fixed_Relations_ships + +Revision ID: fbbb979fa356 +Revises: 37e3df20754e +Create Date: 2026-04-01 19:51:19.291930 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fbbb979fa356' +down_revision: Union[str, None] = '37e3df20754e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_foreign_key(None, 'affidavit_logs', 'users', ['deleted_by'], ['id']) + op.create_foreign_key(None, 'affidavit_logs', 'users', ['updated_by'], ['id']) + op.create_foreign_key(None, 'affidavit_logs', 'users', ['created_by'], ['id']) + op.create_foreign_key(None, 'coments', 'users', ['deactivated_by'], ['id']) + op.create_foreign_key(None, 'coments', 'users', ['created_by'], ['id']) + op.alter_column('feed', 'created_by', + existing_type=sa.INTEGER(), + nullable=True) + op.create_foreign_key(None, 'feed', 'users', ['created_by'], ['id']) + op.create_foreign_key(None, 'feed', 'users', ['updated_by'], ['id']) + op.create_foreign_key(None, 'interacctions', 'feed', ['feed_id'], ['id']) + op.create_foreign_key(None, 'license', 'location', ['location_id'], ['id']) + op.create_foreign_key(None, 'moves', 'users', ['created_by'], ['id']) + op.create_foreign_key(None, 'moves', 'users', ['deleted_by'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'moves', type_='foreignkey') + op.drop_constraint(None, 'moves', type_='foreignkey') + op.drop_constraint(None, 'license', type_='foreignkey') + op.drop_constraint(None, 'interacctions', type_='foreignkey') + op.drop_constraint(None, 'feed', type_='foreignkey') + op.drop_constraint(None, 'feed', type_='foreignkey') + op.alter_column('feed', 'created_by', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_constraint(None, 'coments', type_='foreignkey') + op.drop_constraint(None, 'coments', type_='foreignkey') + op.drop_constraint(None, 'affidavit_logs', type_='foreignkey') + op.drop_constraint(None, 'affidavit_logs', type_='foreignkey') + op.drop_constraint(None, 'affidavit_logs', type_='foreignkey') + # ### end Alembic commands ### diff --git a/requerimiento_minimos.txt b/requerimiento_minimos.txt new file mode 100644 index 0000000..82b815e --- /dev/null +++ b/requerimiento_minimos.txt @@ -0,0 +1,38 @@ +qeuitar condenados_contenados + +ajustar el envio de notificacions +ajustar los matchs de visualizacion +ajustar las listas y el cargado de las listas CSV del sat(dejar listo para automatizacion) + + + +ajustar dos enpoints para ingresar las listas tal cual las entrega el sat y hacer el handle si cambia el formato +ajustar un proceso para automatizarlo + +las notificacion iran integradas a las interacciones. Coments tiene que estar ligados aun id del feed(publicaciones de inicio), asi como las interacciones. + +En la configuracion agregamos el smtp para el envio de correos. y dejamos espacio para ootras configuraciones. + +cada cliente es el nodo principal de la rastreabilidad, un cliente anida, sus provedores(suppliers) una licencia(license). +con sus sitema tokenizado de activacion ligado al client id. +las acciones del client se tienen que registrar en moves, +si se da el caso, un cliente puede registrar su sucursal (branch), aun si no la tuviera, cada cliente tiene que estar ligado a una +hubicacion registrada el location, para regitrar los movimientos, el cliente registrara su usuario, y con esto el; usuario ligado a un cliente. +registra movimientos de cada consulta que haga. + +enterprises queda como una opcion futura a interacciones complejas entre empresas anidadas al sistema. cliente - cliente(posible multitenant) + +no le veo caso a guaradad impuestos(taxes) ni a los condenados a pagarlos(textpayers) +pero usaremos los taxes como un catalogo de monedas, asi como un comparardor. + +tenemos que ajustar edos, efos y creditos a como entrega sat las listas. en files guardamos las cantidades registradas de cada uno de estos y las fechas de actialuzacion. +buscamos la manera de hacerlo automatico. + + +diot es un servico especifico que aun falta investigar correctamente + +falta ajustar como se van a comprobar las facturas (invoices), que podemos tomar desde las listas de edos efos, y solo guardar el numero cfdi y su estatus respecto al emisor + +reportes no tiene nada en puerta, pero buscaremos como aprovecharlo, tal vez metricas de uso por semana y mes. + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..974b93a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# requirements.txt +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +pydantic==2.5.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +pyjwt==2.8.0 +email-validator==2.1.0 +python-dateutil==2.8.2 +bcrypt==4.0.1 \ No newline at end of file