diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..d59ad33 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Instalar dependencias del sistema necesarias para PostgreSQL y manejo de imágenes +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "run.py"] \ No newline at end of file diff --git a/api/app/commands.py b/api/app/commands.py new file mode 100644 index 0000000..4dfaa3b --- /dev/null +++ b/api/app/commands.py @@ -0,0 +1,139 @@ +import click +from flask.cli import with_appcontext +from app.database import db +from app.models import Role, User +from app.models.biometria import ( + Persona, OrigenHuella, TiposF, Subclasificacion, + Huella, PuntoCaracteristico, HuellaPuntoCaracteristico, FichaClasificacion +) +from datetime import datetime + +@click.command(name="seed") # Definimos explícitamente el nombre del comando CLI +@with_appcontext +def seed_db(): + """Siembra de roles, usuarios administrativos y datos iniciales del S.I.P""" + click.echo("Iniciando el sembrado de datos en PostgreSQL...") + + # --------------------------------------------------------- + # [ROLES Y USUARIOS] + # --------------------------------------------------------- + admin_role = Role.query.filter_by(name='admin').first() + if not admin_role: + admin_role = Role(name='admin', permissions=['read:users', 'write:users', 'manage:biometria']) + db.session.add(admin_role) + db.session.commit() + + investigador_role = Role.query.filter_by(name='investigador').first() + if not investigador_role: + investigador_role = Role(name='investigador', permissions=['manage:biometria']) + db.session.add(investigador_role) + db.session.commit() + + admin_user = User.query.filter_by(username='admin_root').first() + if not admin_user: + admin_user = User(username='admin_root', email='admin@uader.edu.ar', role_id=admin_role.id) + admin_user.set_password('admin1234') + db.session.add(admin_user) + db.session.commit() + + investigador_user = User.query.filter_by(username='investigador_demo').first() + if not investigador_user: + investigador_user = User(username='investigador_demo', email='investigador@uader.edu.ar', role_id=investigador_role.id) + investigador_user.set_password('investigador1234') + db.session.add(investigador_user) + db.session.commit() + + # --------------------------------------------------------- + # 1. TABLA MAESTRA: PERSONAS + # --------------------------------------------------------- + if Persona.query.count() == 0: + personas_data = [ + Persona(cod_id=1, cod_nombre='Juan Pérez', fecha_nacimiento=datetime.strptime('1990-05-20', '%Y-%m-%d').date(), sexo='M'), + Persona(cod_id=2, cod_nombre='Ana Gómez', fecha_nacimiento=datetime.strptime('1985-12-01', '%Y-%m-%d').date(), sexo='F'), + Persona(cod_id=3, cod_nombre='Carlos López', fecha_nacimiento=datetime.strptime('2000-07-15', '%Y-%m-%d').date(), sexo='M') + ] + db.session.bulk_save_objects(personas_data) + + # --------------------------------------------------------- + # 2. TABLA MAESTRA: ORIGEN DE HUELLAS + # --------------------------------------------------------- + if OrigenHuella.query.count() == 0: + origenes_data = [ + OrigenHuella(id=1, descripcion='Índice derecho', lado='derecho'), + OrigenHuella(id=2, descripcion='Índice izquierdo', lado='izquierdo'), + OrigenHuella(id=3, descripcion='Pulgar derecho', lado='derecho') + ] + db.session.bulk_save_objects(origenes_data) + + # --------------------------------------------------------- + # 3. TABLA MAESTRA: TIPOS F + # --------------------------------------------------------- + if TiposF.query.count() == 0: + tipos_data = [ + TiposF(id=1, nombre='Arco', descripcion='Huella en forma de arco', foto=None), + TiposF(id=2, nombre='Presilla', descripcion='Huella en forma de presilla', foto=None), + TiposF(id=3, nombre='Verticilo', descripcion='Huella en forma de espiral', foto=None) + ] + db.session.bulk_save_objects(tipos_data) + db.session.commit() + + # --------------------------------------------------------- + # 4. SUBTABLA DEPENDIENTE: SUBCLASIFICACIÓN + # --------------------------------------------------------- + if Subclasificacion.query.count() == 0: + sub_data = [ + Subclasificacion(id=1, nombre='Arco simple', tipo_f_id=1), + Subclasificacion(id=2, nombre='Presilla derecha', tipo_f_id=2), + Subclasificacion(id=3, nombre='Verticilo central', tipo_f_id=3) + ] + db.session.bulk_save_objects(sub_data) + + # --------------------------------------------------------- + # 5. TABLA MAESTRA: PUNTOS CARACTERÍSTICOS + # --------------------------------------------------------- + if PuntoCaracteristico.query.count() == 0: + puntos_data = [ + PuntoCaracteristico(id=1, nombre='Bifurcación', foto=None), + PuntoCaracteristico(id=2, nombre='Final de cresta', foto=None), + PuntoCaracteristico(id=3, nombre='Punto de isla', foto=None) + ] + db.session.bulk_save_objects(puntos_data) + + db.session.commit() + + # --------------------------------------------------------- + # 6. NÚCLEO BIOMÉTRICO: HUELLAS + # --------------------------------------------------------- + if Huella.query.count() == 0: + huellas_data = [ + Huella(id=1, descripcion='Huella digital clara', origen_hd='Escáner', foto=None, persona_cod_id=1, origen_huella_id=1), + Huella(id=2, descripcion='Huella con ligera distorsión', origen_hd='Tinta', foto=None, persona_cod_id=2, origen_huella_id=2), + Huella(id=3, descripcion='Huella poco definida', origen_hd='Escáner', foto=None, persona_cod_id=3, origen_huella_id=3) + ] + db.session.bulk_save_objects(huellas_data) + db.session.commit() + + # --------------------------------------------------------- + # 7. TABLA PIVOTE: HUELLA / PUNTOS + # --------------------------------------------------------- + if HuellaPuntoCaracteristico.query.count() == 0: + coordenadas_data = [ + HuellaPuntoCaracteristico(huella_id=1, punto_id=1, x=12.345678, y=45.678912, angulo=30.00), + HuellaPuntoCaracteristico(huella_id=1, punto_id=2, x=15.111111, y=50.222222, angulo=45.00), + HuellaPuntoCaracteristico(huella_id=2, punto_id=3, x=20.333333, y=60.444444, angulo=60.00) + ] + db.session.bulk_save_objects(coordenadas_data) + + # --------------------------------------------------------- + # 8. FICHA DE CLASIFICACIÓN + # --------------------------------------------------------- + if FichaClasificacion.query.count() == 0: + fichas_data = [ + FichaClasificacion(id=1, huella_id=1, subclasificacion_id=1, tipos_f_id=1, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()), + FichaClasificacion(id=2, huella_id=2, subclasificacion_id=2, tipos_f_id=2, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()), + FichaClasificacion(id=3, huella_id=3, subclasificacion_id=3, tipos_f_id=3, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()) + ] + db.session.bulk_save_objects(fichas_data) + + db.session.commit() + click.echo("¡Base de datos del S.I.P sembrada e integrada al 100%!") \ No newline at end of file diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/database.py b/api/app/database.py new file mode 100644 index 0000000..de1947d --- /dev/null +++ b/api/app/database.py @@ -0,0 +1,5 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate + +db = SQLAlchemy() +migrate = Migrate() \ No newline at end of file diff --git a/api/app/middlewares/auth_middleware.py b/api/app/middlewares/auth_middleware.py new file mode 100644 index 0000000..d5a1868 --- /dev/null +++ b/api/app/middlewares/auth_middleware.py @@ -0,0 +1,33 @@ +from functools import wraps +from flask import request, jsonify +import jwt +import os + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + # ⬇️ MOVE DE ACÁ LA IMPORTACIÓN DEL MODELO ⬇️ + from app.models.user import User + + token = request.headers.get('Authorization') + if not token: + return jsonify({'message': 'Token faltante'}), 401 + try: + token = token.split(" ")[1] if " " in token else token + data = jwt.decode(token, os.environ.get('JWT_SECRET_KEY'), algorithms=["HS256"]) + current_user = User.query.get(data['user_id']) + except Exception as e: + return jsonify({'message': 'Token inválido o expirado'}), 401 + + return f(current_user, *args, **kwargs) + return decorated + +def has_permission(required_permission): + def decorator(f): + @wraps(f) + def decorated_function(current_user, *args, **kwargs): + if not current_user.role or required_permission not in current_user.role.permissions: + return jsonify({'message': 'No tenés permisos para realizar esta acción'}), 403 + return f(current_user, *args, **kwargs) + return decorated_function + return decorator \ No newline at end of file diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py new file mode 100644 index 0000000..1827f20 --- /dev/null +++ b/api/app/models/__init__.py @@ -0,0 +1,12 @@ +# api/app/models/__init__.py +from app.models.user import User, Role +from app.models.biometria import ( + Persona, OrigenHuella, TiposF, Subclasificacion, + Huella, PuntoCaracteristico, HuellaPuntoCaracteristico, FichaClasificacion +) + +__all__ = [ + 'User', 'Role', 'Persona', 'OrigenHuella', 'TiposF', + 'Subclasificacion', 'Huella', 'PuntoCaracteristico', + 'HuellaPuntoCaracteristico', 'FichaClasificacion' +] \ No newline at end of file diff --git a/api/app/models/biometria.py b/api/app/models/biometria.py new file mode 100644 index 0000000..55e96f4 --- /dev/null +++ b/api/app/models/biometria.py @@ -0,0 +1,92 @@ +from app.database import db + +class Persona(db.Model): + __tablename__ = 'personas' + + cod_id = db.Column(db.Integer, primary_key=True) + cod_nombre = db.Column(db.String(255), nullable=False) + fecha_nacimiento = db.Column(db.Date, nullable=True) + sexo = db.Column(db.String(1), nullable=True) # 'M', 'F' u otros + + # Relación inversa: Una persona puede tener varias huellas registradas + huellas = db.relationship('Huella', backref='persona', cascade='all, delete-orphan', lazy=True) + + +class OrigenHuella(db.Model): + __tablename__ = 'origen_huellas' + + id = db.Column(db.Integer, primary_key=True) + descripcion = db.Column(db.String(255), nullable=False) # Ej: Índice, pulgar + lado = db.Column(db.String(50), nullable=True) # Ej: Derecho, izquierdo + + huellas = db.relationship('Huella', backref='origen', lazy=True) + +#Tipo fundamental para la clasificación dactilar, con relaciones a subclasificaciones y fichas de clasificación +class TiposF(db.Model): + __tablename__ = 'tipos_f' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + nombre = db.Column(db.String(255), nullable=False) # Ej: Arco, Presilla, Verticilo + foto = db.Column(db.LargeBinary, nullable=True) # Almacenamiento binario eficiente en Postgres + descripcion = db.Column(db.Text, nullable=True) + + subclasificaciones = db.relationship('Subclasificacion', backref='tipo_f', lazy=True) + fichas = db.relationship('FichaClasificacion', backref='tipo_f', lazy=True) + + +class Subclasificacion(db.Model): + __tablename__ = 'subclasificaciones' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + nombre = db.Column(db.String(255), nullable=False) # Ej: Arco simple, Presilla derecha + tipo_f_id = db.Column(db.Integer, db.ForeignKey('tipos_f.id', ondelete='RESTRICT'), nullable=False) + + fichas = db.relationship('FichaClasificacion', backref='subclasificacion', lazy=True) + + +class Huella(db.Model): + __tablename__ = 'huellas' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + descripcion = db.Column(db.String(255), nullable=True) + origen_hd = db.Column(db.String(255), nullable=True) # Ej: Escáner, Tinta + foto = db.Column(db.LargeBinary, nullable=True) # Imagen de la huella dactilar (Fichero RAW/WSQ) + persona_cod_id = db.Column(db.Integer, db.ForeignKey('personas.cod_id', ondelete='CASCADE'), nullable=False) + origen_huella_id = db.Column(db.Integer, db.ForeignKey('origen_huellas.id', ondelete='RESTRICT'), nullable=False) + + # Relaciones Muchos a Muchos mediante tabla intermedia con atributos compuestos + puntos_caracteristicos = db.relationship('HuellaPuntoCaracteristico', backref='huella', cascade='all, delete-orphan', lazy=True) + fichas = db.relationship('FichaClasificacion', backref='huella', cascade='all, delete-orphan', lazy=True) + + +class PuntoCaracteristico(db.Model): + __tablename__ = 'puntos_caracteristicos' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + nombre = db.Column(db.String(255), nullable=False) # Ej: Bifurcación, Final de cresta + foto = db.Column(db.LargeBinary, nullable=True) # Gráfico patrón del punto + + huellas_asociadas = db.relationship('HuellaPuntoCaracteristico', backref='punto', cascade='all, delete-orphan', lazy=True) + + +class HuellaPuntoCaracteristico(db.Model): + """ Tabla pivote relacional con atributos de coordenadas geo-espaciales (X, Y) y ángulo de la minucia """ + __tablename__ = 'huella_punto_caracteristico' + + huella_id = db.Column(db.Integer, db.ForeignKey('huellas.id', ondelete='CASCADE'), primary_key=True) + punto_id = db.Column(db.Integer, db.ForeignKey('puntos_caracteristicos.id', ondelete='CASCADE'), primary_key=True) + + # Coordenadas numéricas precisas para algoritmos de correspondencia (Matching) + x = db.Column(db.Numeric(10, 6), nullable=False) + y = db.Column(db.Numeric(10, 6), nullable=False) + angulo = db.Column(db.Numeric(5, 2), nullable=False) + + +class FichaClasificacion(db.Model): + __tablename__ = 'fichas_clasificacion' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + huella_id = db.Column(db.Integer, db.ForeignKey('huellas.id', ondelete='CASCADE'), nullable=False) + subclasificacion_id = db.Column(db.Integer, db.ForeignKey('subclasificaciones.id', ondelete='RESTRICT'), nullable=False) + tipos_f_id = db.Column(db.Integer, db.ForeignKey('tipos_f.id', ondelete='RESTRICT'), nullable=False) + fecha = db.Column(db.Date, nullable=False, default=db.func.current_date()) \ No newline at end of file diff --git a/api/app/models/fingerprint.py b/api/app/models/fingerprint.py new file mode 100644 index 0000000..296dbb1 --- /dev/null +++ b/api/app/models/fingerprint.py @@ -0,0 +1,23 @@ +from flask import Blueprint, request, jsonify +from app.database import db +from app.models.fingerprint import Fingerprint # Asumiendo que creás este modelo +from app.middlewares.auth_middleware import token_required, has_permission + +fingerprints_bp = Blueprint('fingerprints', __name__) + +@fingerprints_bp.route('/api/fingerprints', methods=['POST']) +@token_required +@has_permission('write:fingerprints') +def register_fingerprint(current_user): + data = request.get_json() + + # Lógica de persistencia equivalente a: Fingerprint::create([...]) + new_print = Fingerprint( + patient_id=data.get('patient_id'), + image_path=data.get('image_path'), # Ruta local o S3 + researcher_id=current_user.id + ) + db.session.add(new_print) + db.session.commit() + + return jsonify({'status': 'Huella registrada con éxito', 'id': new_print.id}), 21 \ No newline at end of file diff --git a/api/app/models/report.py b/api/app/models/report.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/models/user.py b/api/app/models/user.py new file mode 100644 index 0000000..637fb94 --- /dev/null +++ b/api/app/models/user.py @@ -0,0 +1,33 @@ +from app.database import db +import bcrypt + +class Role(db.Model): + __tablename__ = 'roles' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + permissions = db.Column(db.JSON, nullable=False) + + users = db.relationship('User', backref='role', lazy=True) + +class User(db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(128), nullable=False) + role_id = db.Column(db.Integer, db.ForeignKey('roles.id'), nullable=True) + + def set_password(self, password): + self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + def check_password(self, password): + return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8')) + + def to_dict(self): + return { + 'id': self.id, + 'username': self.username, + 'email': self.email, + 'role': self.role.name if self.role else None, + 'permissions': self.role.permissions if self.role else [] + } \ No newline at end of file diff --git a/api/app/routes/__init__.py b/api/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routes/auth.py b/api/app/routes/auth.py new file mode 100644 index 0000000..b5e5373 --- /dev/null +++ b/api/app/routes/auth.py @@ -0,0 +1,69 @@ +from flask import Blueprint, request, jsonify +from app.database import db +# ⬇️ Cambiamos esta línea para importar desde el módulo raíz de modelos ⬇️ +from app.models import User, Role +from app.middlewares.auth_middleware import token_required +import jwt +import datetime +import os + +# (El resto del código del archivo auth.py queda exactamente igual) + +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/register', methods=['POST']) +def register(): + data = request.get_json() + + if not data or not data.get('username') or not data.get('password') or not data.get('email'): + return jsonify({'message': 'Datos incompletos'}), 400 + + if User.query.filter((User.username == data['username']) | (User.email == data['email'])).first(): + return jsonify({'message': 'El usuario o email ya existe'}), 400 + + # Asignar un rol por defecto si no se especifica (ej: el ID del rol investigador) + default_role = Role.query.filter_by(name='investigador').first() + + new_user = User( + username=data['username'], + email=data['email'], + role_id=default_role.id if default_role else None + ) + new_user.set_password(data['password']) + + db.session.add(new_user) + db.session.commit() + + return jsonify({'message': 'Usuario registrado con éxito'}), 201 + + +@auth_bp.route('/login', methods=['POST']) +def login(): + data = request.get_json() + + if not data or not data.get('username') or not data.get('password'): + return jsonify({'message': 'Credenciales requeridas'}), 400 + + user = User.query.filter_by(username=data['username']).first() + + if not user or not user.check_password(data['password']): + return jsonify({'message': 'Usuario o contraseña incorrectos'}), 401 + + # Generación del JWT Token + payload = { + 'user_id': user.id, + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=2) + } + token = jwt.encode(payload, os.environ.get('JWT_SECRET_KEY', 'secret'), algorithm='HS256') + + return jsonify({ + 'token': token, + 'user': user.to_dict() + }), 200 + + +@auth_bp.route('/me', methods=['GET']) +@token_required +def me(current_user): + # 'current_user' es inyectado automáticamente por nuestro middleware 'token_required' + return jsonify(current_user.to_dict()), 200 \ No newline at end of file diff --git a/api/app/routes/fingerprints.py b/api/app/routes/fingerprints.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routes/reports.py b/api/app/routes/reports.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routes/roles.py b/api/app/routes/roles.py new file mode 100644 index 0000000..b9798ec --- /dev/null +++ b/api/app/routes/roles.py @@ -0,0 +1,60 @@ +from flask import Blueprint, request, jsonify +from app.database import db +from app.models.user import Role +from app.middlewares.auth_middleware import token_required, has_permission + +roles_bp = Blueprint('roles', __name__) + +# INDEX: Listar todos los roles +@roles_bp.route('/roles', methods=['GET']) +@token_required +@has_permission('read:users') # Permiso administrativo general +def index(current_user): + roles = Role.query.all() + return jsonify([{'id': r.id, 'name': r.name, 'permissions': r.permissions} for r in roles]), 200 + +# STORE: Crear un nuevo rol +@roles_bp.route('/roles', methods=['POST']) +@token_required +@has_permission('write:users') +def store(current_user): + data = request.get_json() + if not data or not data.get('name') or not data.get('permissions'): + return jsonify({'message': 'Datos incompletos'}), 400 + + if Role.query.filter_by(name=data['name']).first(): + return jsonify({'message': 'El rol ya existe'}), 400 + + new_role = Role(name=data['name'], permissions=data['permissions']) # Recibe lista de strings + db.session.add(new_role) + db.session.commit() + return jsonify({'message': 'Rol creado con éxito', 'id': new_role.id}), 201 + +# UPDATE: Modificar un rol existente +@roles_bp.route('/roles/', methods=['PUT']) +@token_required +@has_permission('write:users') +def update(current_user, id): + role = Role.query.get_or_404(id) + data = request.get_json() + + role.name = data.get('name', role.name) + role.permissions = data.get('permissions', role.permissions) + + db.session.commit() + return jsonify({'message': 'Rol actualizado con éxito'}), 200 + +# DESTROY: Eliminar un rol +@roles_bp.route('/roles/', methods=['DELETE']) +@token_required +@has_permission('write:users') +def destroy(current_user, id): + role = Role.query.get_or_404(id) + + # Evitar romper restricciones de clave foránea si hay usuarios usándolo + if len(role.users) > 0: + return jsonify({'message': 'No se puede eliminar un rol asignado a usuarios activos'}), 400 + + db.session.delete(role) + db.session.commit() + return jsonify({'message': 'Rol eliminado con éxito'}), 200 \ No newline at end of file diff --git a/api/app/routes/users.py b/api/app/routes/users.py new file mode 100644 index 0000000..5de1fe0 --- /dev/null +++ b/api/app/routes/users.py @@ -0,0 +1,12 @@ +from flask import Blueprint, jsonify +from app.models import User +from app.middlewares.auth_middleware import token_required, has_permission + +users_bp = Blueprint('users', __name__) + +@users_bp.route('/users', methods=['GET']) +@token_required +@has_permission('read:users') +def index(current_user): + users = User.query.all() + return jsonify([user.to_dict() for user in users]), 200 \ No newline at end of file diff --git a/api/migrations/README b/api/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/api/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/api/migrations/alembic.ini b/api/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/api/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[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 + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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/api/migrations/env.py b/api/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/api/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/api/migrations/script.py.mako b/api/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/api/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/api/migrations/versions/04e47df0b26f_crear_tablas_iniciales.py b/api/migrations/versions/04e47df0b26f_crear_tablas_iniciales.py new file mode 100644 index 0000000..fb38749 --- /dev/null +++ b/api/migrations/versions/04e47df0b26f_crear_tablas_iniciales.py @@ -0,0 +1,46 @@ +"""crear_tablas_iniciales + +Revision ID: 04e47df0b26f +Revises: +Create Date: 2026-05-21 16:20:06.426487 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '04e47df0b26f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('permissions', sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password_hash', sa.String(length=128), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + op.drop_table('roles') + # ### end Alembic commands ### diff --git a/api/migrations/versions/a946075b6c2c_migracion_sistema_papiloscopico.py b/api/migrations/versions/a946075b6c2c_migracion_sistema_papiloscopico.py new file mode 100644 index 0000000..eac767e --- /dev/null +++ b/api/migrations/versions/a946075b6c2c_migracion_sistema_papiloscopico.py @@ -0,0 +1,99 @@ +"""migracion_sistema_papiloscopico + +Revision ID: a946075b6c2c +Revises: 04e47df0b26f +Create Date: 2026-05-21 19:06:40.586192 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a946075b6c2c' +down_revision = '04e47df0b26f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('origen_huellas', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('descripcion', sa.String(length=255), nullable=False), + sa.Column('lado', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('personas', + sa.Column('cod_id', sa.Integer(), nullable=False), + sa.Column('cod_nombre', sa.String(length=255), nullable=False), + sa.Column('fecha_nacimiento', sa.Date(), nullable=True), + sa.Column('sexo', sa.String(length=1), nullable=True), + sa.PrimaryKeyConstraint('cod_id') + ) + op.create_table('puntos_caracteristicos', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('nombre', sa.String(length=255), nullable=False), + sa.Column('foto', sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tipos_f', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('nombre', sa.String(length=255), nullable=False), + sa.Column('foto', sa.LargeBinary(), nullable=True), + sa.Column('descripcion', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('huellas', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('descripcion', sa.String(length=255), nullable=True), + sa.Column('origen_hd', sa.String(length=255), nullable=True), + sa.Column('foto', sa.LargeBinary(), nullable=True), + sa.Column('persona_cod_id', sa.Integer(), nullable=False), + sa.Column('origen_huella_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['origen_huella_id'], ['origen_huellas.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['persona_cod_id'], ['personas.cod_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('subclasificaciones', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('nombre', sa.String(length=255), nullable=False), + sa.Column('tipo_f_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['tipo_f_id'], ['tipos_f.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('fichas_clasificacion', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('huella_id', sa.Integer(), nullable=False), + sa.Column('subclasificacion_id', sa.Integer(), nullable=False), + sa.Column('tipos_f_id', sa.Integer(), nullable=False), + sa.Column('fecha', sa.Date(), nullable=False), + sa.ForeignKeyConstraint(['huella_id'], ['huellas.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['subclasificacion_id'], ['subclasificaciones.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['tipos_f_id'], ['tipos_f.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('huella_punto_caracteristico', + sa.Column('huella_id', sa.Integer(), nullable=False), + sa.Column('punto_id', sa.Integer(), nullable=False), + sa.Column('x', sa.Numeric(precision=10, scale=6), nullable=False), + sa.Column('y', sa.Numeric(precision=10, scale=6), nullable=False), + sa.Column('angulo', sa.Numeric(precision=5, scale=2), nullable=False), + sa.ForeignKeyConstraint(['huella_id'], ['huellas.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['punto_id'], ['puntos_caracteristicos.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('huella_id', 'punto_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('huella_punto_caracteristico') + op.drop_table('fichas_clasificacion') + op.drop_table('subclasificaciones') + op.drop_table('huellas') + op.drop_table('tipos_f') + op.drop_table('puntos_caracteristicos') + op.drop_table('personas') + op.drop_table('origen_huellas') + # ### end Alembic commands ### diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..bd6c68d --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,7 @@ +Flask==3.0.2 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.7 +psycopg2-binary==2.9.9 +PyJWT==2.8.0 +bcrypt==4.1.2 +Flask-CORS==4.0.0 \ No newline at end of file diff --git a/api/run.py b/api/run.py new file mode 100644 index 0000000..361b3fd --- /dev/null +++ b/api/run.py @@ -0,0 +1,215 @@ +import click +from flask import Flask +from flask.cli import with_appcontext +from flask_migrate import Migrate +from datetime import datetime +import os +from flask_cors import CORS +# Importamos la base de datos y los modelos del S.I.P +from app.database import db +from app.models import Role, User +from app.models.biometria import ( + Persona, OrigenHuella, TiposF, Subclasificacion, + Huella, PuntoCaracteristico, HuellaPuntoCaracteristico, FichaClasificacion +) + +# ========================================================= +# 1. COMANDO CLI DE CLICK (Configurado Correctamente) +# ========================================================= +@click.command(name="seed") +@with_appcontext +def seed_db_command(): + """Siembra de roles, usuarios administrativos y datos iniciales del S.I.P""" + click.echo("Iniciando el sembrado de datos en PostgreSQL...") + + # --- [ROLES Y USUARIOS] --- + admin_role = Role.query.filter_by(name='admin').first() + if not admin_role: + admin_role = Role(name='admin', permissions=['read:users', 'write:users', 'manage:biometria']) + db.session.add(admin_role) + db.session.commit() + + investigador_role = Role.query.filter_by(name='investigador').first() + if not investigador_role: + investigador_role = Role(name='investigador', permissions=['manage:biometria']) + db.session.add(investigador_role) + db.session.commit() + + admin_user = User.query.filter_by(username='admin_root').first() + if not admin_user: + admin_user = User(username='admin_root', email='admin@uader.edu.ar', role_id=admin_role.id) + admin_user.set_password('admin1234') + db.session.add(admin_user) + db.session.commit() + + investigador_user = User.query.filter_by(username='investigador_demo').first() + if not investigador_user: + investigador_user = User(username='investigador_demo', email='investigador@uader.edu.ar', role_id=investigador_role.id) + investigador_user.set_password('investigador1234') + db.session.add(investigador_user) + db.session.commit() + + # --- [1. PERSONAS] --- + if Persona.query.count() == 0: + personas_data = [ + Persona(cod_id=1, cod_nombre='Juan Pérez', fecha_nacimiento=datetime.strptime('1990-05-20', '%Y-%m-%d').date(), sexo='M'), + Persona(cod_id=2, cod_nombre='Ana Gómez', fecha_nacimiento=datetime.strptime('1985-12-01', '%Y-%m-%d').date(), sexo='F'), + Persona(cod_id=3, cod_nombre='Carlos López', fecha_nacimiento=datetime.strptime('2000-07-15', '%Y-%m-%d').date(), sexo='M') + ] + db.session.bulk_save_objects(personas_data) + + # --- [2. ORIGEN DE HUELLAS] --- + if OrigenHuella.query.count() == 0: + origenes_data = [ + OrigenHuella(id=1, descripcion='Índice derecho', lado='derecho'), + OrigenHuella(id=2, descripcion='Índice izquierdo', lado='izquierdo'), + OrigenHuella(id=3, descripcion='Pulgar derecho', lado='derecho') + ] + db.session.bulk_save_objects(origenes_data) + + # --- [3. TIPOS FUNDAMENTALES] --- + if TiposF.query.count() == 0: + tipos_data = [ + TiposF(id=1, nombre='Arco', descripcion='Huella en forma de arco', foto=None), + TiposF(id=2, nombre='Presilla', descripcion='Huella en forma de presilla', foto=None), + TiposF(id=3, nombre='Verticilo', descripcion='Huella en forma de espiral', foto=None) + ] + db.session.bulk_save_objects(tipos_data) + db.session.commit() + + # --- [4. SUBCLASIFICACIONES] --- + if Subclasificacion.query.count() == 0: + sub_data = [ + Subclasificacion(id=1, nombre='Arco simple', tipo_f_id=1), + Subclasificacion(id=2, nombre='Presilla derecha', tipo_f_id=2), + Subclasificacion(id=3, nombre='Verticilo central', tipo_f_id=3) + ] + db.session.bulk_save_objects(sub_data) + + # --- [5. PUNTOS CARACTERÍSTICOS] --- + if PuntoCaracteristico.query.count() == 0: + puntos_data = [ + PuntoCaracteristico(id=1, nombre='Bifurcación', foto=None), + PuntoCaracteristico(id=2, nombre='Final de cresta', foto=None), + PuntoCaracteristico(id=3, nombre='Punto de isla', foto=None) + ] + db.session.bulk_save_objects(puntos_data) + + db.session.commit() + + # --- [6. HUELLAS] --- + if Huella.query.count() == 0: + huellas_data = [ + Huella(id=1, descripcion='Huella digital clara', origen_hd='Escáner', foto=None, persona_cod_id=1, origen_huella_id=1), + Huella(id=2, descripcion='Huella con ligera distorsión', origen_hd='Tinta', foto=None, persona_cod_id=2, origen_huella_id=2), + Huella(id=3, descripcion='Huella poco definida', origen_hd='Escáner', foto=None, persona_cod_id=3, origen_huella_id=3) + ] + db.session.bulk_save_objects(huellas_data) + db.session.commit() + + # --- [7. COORDENADAS PIVOTE] --- + if HuellaPuntoCaracteristico.query.count() == 0: + coordenadas_data = [ + HuellaPuntoCaracteristico(huella_id=1, punto_id=1, x=12.345678, y=45.678912, angulo=30.00), + HuellaPuntoCaracteristico(huella_id=1, punto_id=2, x=15.111111, y=50.222222, angulo=45.00), + HuellaPuntoCaracteristico(huella_id=2, punto_id=3, x=20.333333, y=60.444444, angulo=60.00) + ] + db.session.bulk_save_objects(coordenadas_data) + + # --- [8. FICHAS DE CLASIFICACIÓN] --- + if FichaClasificacion.query.count() == 0: + fichas_data = [ + FichaClasificacion(id=1, huella_id=1, subclasificacion_id=1, tipos_f_id=1, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()), + FichaClasificacion(id=2, huella_id=2, subclasificacion_id=2, tipos_f_id=2, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()), + FichaClasificacion(id=3, huella_id=3, subclasificacion_id=3, tipos_f_id=3, fecha=datetime.strptime('2025-09-30', '%Y-%m-%d').date()) + ] + db.session.bulk_save_objects(fichas_data) + + db.session.commit() + click.echo("¡Base de datos del S.I.P sembrada e integrada al 100%!") + + +# ========================================================= +# 2. FABRICA DE LA APLICACIÓN (Application Factory) +# ========================================================= + + +def create_app(): + app = Flask(__name__) + + # 3. Inicialización de Flask-CORS agresiva a nivel de aplicación interna + CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True) + + # 4. CAPA DE CONTROL ADICIONAL: Interceptor nativo de Preflight Checks + @app.after_request + def add_cors_headers(response): + # Permitimos cualquier origen de desarrollo (como tu puerto 8080) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization" + response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,DELETE,OPTIONS" + return response + + @app.before_request + def handle_options_directly(): + from flask import make_response, request + # Si el navegador pregunta mediante OPTIONS, respondemos de inmediato con 200 OK + # saltándonos cualquier validación posterior de rutas o Blueprints + if request.method.upper() == "OPTIONS": + response = make_response() + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization" + response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,DELETE,OPTIONS" + return response, 200 + + # Mapeo exacto de tu archivo .env de la raíz + POSTGRES_USER = os.getenv('POSTGRES_USER', 'admin') + POSTGRES_PASSWORD = os.getenv('POSTGRES_PASSWORD', 'secretpassword_2026_x8') + POSTGRES_DB = os.getenv('POSTGRES_DB', 'huellas_investigacion') + POSTGRES_HOST = os.getenv('POSTGRES_HOST', 'db') # Cambiado a 'db' según tu .env + POSTGRES_PORT = os.getenv('POSTGRES_PORT', '5432') + + # Reconstruimos la URI de forma segura en Python para evitar fallos de interpolación string + app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}' + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + # Secreto para los Tokens JWT de tu módulo auth + app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'laravel_style_jwt_secret_token_scientific_2026') + + # Inicializar base de datos y migraciones + db.init_app(app) + Migrate(app, db) + + # ========================================================= + # REGISTRO DE BLUEPRINTS (Normalización de Prefijos REST) + # ========================================================= + try: + # 1. Rutas de Autenticación + from app.routes.auth import auth_bp + # Mapea directamente: POST /api/auth/login y GET /api/auth/me + app.register_blueprint(auth_bp, url_prefix='/api/auth') + print("-> Blueprint 'auth' registrado en /api/auth") + + # 2. Rutas de Usuarios + from app.routes.users import users_bp + # Al poner el prefijo '/api' a secas, mapea exacto: GET /api/users + app.register_blueprint(users_bp, url_prefix='/api') + print("-> Blueprint 'users' registrado en /api") + + # 3. Rutas de Roles + from app.routes.roles import roles_bp + # Al poner el prefijo '/api' a secas, mapea exacto: GET /api/roles y POST /api/roles + app.register_blueprint(roles_bp, url_prefix='/api') + print("-> Blueprint 'roles' registrado en /api") + + except ImportError as e: + print(f"⚠️ Error al importar los módulos de rutas: {e}") + + # Registrar comando seed + app.cli.add_command(seed_db_command) + + return app + +app = create_app() + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) \ No newline at end of file