diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 0290049..1037a27 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -181,5 +181,4 @@ def health_check(): return jsonify({ "status": "ok", "service": "SkillStat API", - }), 200 - \ No newline at end of file + }), 200 \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ba8073c..927b57a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,21 @@ -# models/__init__ — SkillStat \ No newline at end of file +from .category import Category +from .city import City +from .skill import Skill +from .job import Job +from .job_skill import JobSkill +from .user import User +from .alert import Alert +from .trend_snapshot import TrendSnapshot +from .backup import Backup + +__all__ = [ + 'Category', + 'City', + 'Skill', + 'Job', + 'JobSkill', + 'User', + 'Alert', + 'TrendSnapshot', + 'Backup' +] \ No newline at end of file diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index dcfcf33..89973ae 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -1 +1,12 @@ -# alert — SkillStat \ No newline at end of file +from app.extensions import db +from datetime import datetime, timezone + +class Alert(db.Model): + __tablename__ = 'user_alerts' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + skill_id = db.Column(db.Integer, db.ForeignKey('skills.id'), nullable=False) + threshold = db.Column(db.Integer, nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file diff --git a/backend/app/models/backup.py b/backend/app/models/backup.py index dd81b4e..1dc4ca6 100644 --- a/backend/app/models/backup.py +++ b/backend/app/models/backup.py @@ -1 +1,17 @@ -# backup — SkillStat \ No newline at end of file +from app.extensions import db +from datetime import datetime, timezone + +class Backup(db.Model): + __tablename__ = 'backups' + + id = db.Column(db.Integer, primary_key=True) + # Habilitamos nulos para soportar la ejecución de respaldos automáticos desde el scheduler sin un usuario físico atado + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + filename = db.Column(db.String(255), nullable=False) + storage_url = db.Column(db.String(500), nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + status = db.Column(db.String(20), nullable=False) + + __table_args__ = ( + db.CheckConstraint("status IN ('PENDING', 'COMPLETED', 'FAILED')", name='chk_backups_status'), + ) \ No newline at end of file diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 02ab375..e5197d2 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -1 +1,9 @@ -# category — SkillStat \ No newline at end of file +from app.extensions import db + +class Category(db.Model): + __tablename__ = 'categories' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), nullable=False, unique=True) + + skills = db.relationship('Skill', backref='category', lazy=True) \ No newline at end of file diff --git a/backend/app/models/city.py b/backend/app/models/city.py index 522d78c..65414c0 100644 --- a/backend/app/models/city.py +++ b/backend/app/models/city.py @@ -1 +1,15 @@ -# city — SkillStat \ No newline at end of file +from app.extensions import db + +class City(db.Model): + __tablename__ = 'cities' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + state = db.Column(db.String(100), nullable=True) + # Establecemos México como default tanto a nivel aplicación como base de datos + country = db.Column(db.String(10), default='MX', server_default='MX', nullable=True) + lat = db.Column(db.Numeric(9, 6), nullable=True) + lon = db.Column(db.Numeric(9, 6), nullable=True) + + jobs = db.relationship('Job', backref='city', lazy=True) + trend_snapshots = db.relationship('TrendSnapshot', backref='city', lazy=True) \ No newline at end of file diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 69318c1..d429322 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -1 +1,24 @@ -# job — SkillStat \ No newline at end of file +from app.extensions import db +from datetime import datetime, timezone + +class Job(db.Model): + __tablename__ = 'jobs' + + id = db.Column(db.Integer, primary_key=True) + source = db.Column(db.String(50), nullable=False) + title = db.Column(db.String(255), nullable=False) + company = db.Column(db.String(255), nullable=True) + # Permitimos nulos en city_id para no bloquear la ingesta de vacantes remotas o sin geolocalización + city_id = db.Column(db.Integer, db.ForeignKey('cities.id'), nullable=True) + salary_min = db.Column(db.Numeric(10, 2), nullable=True) + salary_max = db.Column(db.Numeric(10, 2), nullable=True) + raw_description = db.Column(db.Text, nullable=False) + + # Guardamos el hash de la descripción para detectar rápidamente si una vacante ya fue procesada o si cambió en su origen + description_hash = db.Column(db.String(64), nullable=False) + processed = db.Column(db.Boolean, default=False, nullable=False) + + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + job_skills = db.relationship('JobSkill', backref='job', lazy=True) \ No newline at end of file diff --git a/backend/app/models/job_skill.py b/backend/app/models/job_skill.py index e81b798..009ebaa 100644 --- a/backend/app/models/job_skill.py +++ b/backend/app/models/job_skill.py @@ -1 +1,12 @@ -# job_skill — SkillStat \ No newline at end of file +from app.extensions import db +from datetime import datetime, timezone + +class JobSkill(db.Model): + __tablename__ = 'job_skills' + + # Utilizamos llave primaria compuesta para evitar identificadores subrogados innecesarios y cumplir la 3FN + job_id = db.Column(db.Integer, db.ForeignKey('jobs.id'), primary_key=True) + skill_id = db.Column(db.Integer, db.ForeignKey('skills.id'), primary_key=True) + + confidence_score = db.Column(db.Numeric(4, 3), nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py index 39caa02..1a7f586 100644 --- a/backend/app/models/skill.py +++ b/backend/app/models/skill.py @@ -1 +1,14 @@ -# skill — SkillStat \ No newline at end of file +from app.extensions import db + +class Skill(db.Model): + __tablename__ = 'skills' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + canonical_name = db.Column(db.String(100), nullable=False, unique=True) + category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False) + + # Relacionamos bidireccionalmente mediante el patrón Association Object para mantener la normalización 3FN en job_skills + job_skills = db.relationship('JobSkill', backref='skill', lazy=True) + alerts = db.relationship('Alert', backref='skill', lazy=True) + trend_snapshots = db.relationship('TrendSnapshot', backref='skill', lazy=True) \ No newline at end of file diff --git a/backend/app/models/trend_snapshot.py b/backend/app/models/trend_snapshot.py index 4cce0af..0603189 100644 --- a/backend/app/models/trend_snapshot.py +++ b/backend/app/models/trend_snapshot.py @@ -1 +1,17 @@ -# trend_snapshot — SkillStat \ No newline at end of file +from app.extensions import db + +class TrendSnapshot(db.Model): + __tablename__ = 'trend_snapshots' + + id = db.Column(db.Integer, primary_key=True) + skill_id = db.Column(db.Integer, db.ForeignKey('skills.id'), nullable=False) + city_id = db.Column(db.Integer, db.ForeignKey('cities.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + demand_count = db.Column(db.Integer, default=0, nullable=True) + growth_rate = db.Column(db.Numeric(6, 2), nullable=True) + avg_salary = db.Column(db.Numeric(10, 2), nullable=True) + + # Forzamos unicidad combinada para garantizar que no existan métricas duplicadas para la misma ciudad, habilidad y fecha + __table_args__ = ( + db.UniqueConstraint('skill_id', 'city_id', 'date', name='uq_trend_snapshot_skill_city_date'), + ) \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5356314..eef1164 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1 +1,19 @@ -# user — SkillStat \ No newline at end of file +from app.extensions import db +from datetime import datetime, timezone + +class User(db.Model): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(20), nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + alerts = db.relationship('Alert', backref='user', lazy=True) + backups = db.relationship('Backup', backref='user', lazy=True) + + # Restringimos los roles permitidos directamente en la base de datos por seguridad + __table_args__ = ( + db.CheckConstraint("role IN ('GUEST', 'REGISTERED', 'ADMIN')", name='chk_users_role'), + ) \ No newline at end of file