Arquitectura organizada en capas

This commit is contained in:
Luis Sanchez 2025-12-29 11:56:46 -05:00
parent c9982f01e8
commit ef13c211ad
64 changed files with 1595 additions and 1270 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ backend/venv/**
backend/alembic/.DS_Store
diseño.md
backend/uploads/**
**/__pycache__

View File

@ -5,8 +5,8 @@ from sqlalchemy import pool
from alembic import context
from app.db.database import Base
from app.models import *
from app.db.base import Base # noqa
target_metadata = Base.metadata
import os
from dotenv import load_dotenv

View File

44
backend/app/api/deps.py Normal file
View File

@ -0,0 +1,44 @@
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.database import get_db
from app.models.user import User
from app.schemas.token import TokenData
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/token"
)
def get_current_user(
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
email: str = payload.get("sub")
if email is None:
raise credentials_exception
token_data = TokenData(email=email)
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.email == token_data.email).first()
if user is None:
raise credentials_exception
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

View File

26
backend/app/api/v1/api.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter
from app.api.v1.endpoints import (
auth,
users,
projects,
activities,
specialties,
contractors,
transcription,
non_conformities,
guest
)
from app.api.deps import get_current_active_user
from fastapi import Depends
api_router = APIRouter()
api_router.include_router(auth.router, tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"], dependencies=[Depends(get_current_active_user)])
api_router.include_router(projects.router, prefix="/projects", tags=["Projects"], dependencies=[Depends(get_current_active_user)])
api_router.include_router(activities.router, prefix="/activities", tags=["Activities"], dependencies=[Depends(get_current_active_user)])
api_router.include_router(specialties.router, prefix="/specialties", tags=["Specialties"], dependencies=[Depends(get_current_active_user)])
api_router.include_router(contractors.router, prefix="/contractors", tags=["Contractors"], dependencies=[Depends(get_current_active_user)])
api_router.include_router(transcription.router, prefix="/transcription", tags=["Transcription"], dependencies=[Depends(get_current_active_user)])
api_router.include_router(non_conformities.router, prefix="/non-conformities", tags=["Non Conformities"], dependencies=[Depends(get_current_active_user)])
api_router.include_router(guest.router, prefix="/guest", tags=["Guest Access"])

View File

View File

@ -0,0 +1,87 @@
from fastapi import APIRouter, Depends, UploadFile, File, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
from app.db.database import get_db
from app.models.user import User
from app.api.deps import get_current_active_user
from app.services.activities import ActivityService
from app.schemas.activity import Activity, ActivityCreate, ActivityUpdate
from app.schemas.evidence import Evidence, EvidenceUpdate
router = APIRouter()
def get_activity_service(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
background_tasks: BackgroundTasks = None
) -> ActivityService:
return ActivityService(db, current_user, background_tasks)
@router.post("/", response_model=Activity)
def create_activity(
activity: ActivityCreate,
service: ActivityService = Depends(get_activity_service)
):
return service.create_activity(activity)
@router.get("/", response_model=List[Activity])
def read_activities(
project_id: Optional[int] = None,
specialty_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
service: ActivityService = Depends(get_activity_service)
):
return service.get_activities(project_id, specialty_id, skip, limit)
@router.get("/{activity_id}", response_model=Activity)
def read_activity(
activity_id: int,
service: ActivityService = Depends(get_activity_service)
):
return service.get_activity(activity_id)
@router.put("/{activity_id}", response_model=Activity)
def update_activity(
activity_id: int,
activity: ActivityUpdate,
service: ActivityService = Depends(get_activity_service)
):
return service.update_activity(activity_id, activity)
@router.post("/{activity_id}/upload", response_model=Evidence)
async def upload_evidence(
activity_id: int,
file: UploadFile = File(...),
description: Optional[str] = None,
captured_at: Optional[str] = None,
service: ActivityService = Depends(get_activity_service)
):
return service.upload_evidence(
activity_id,
file,
description,
captured_at
)
@router.post("/evidence/{evidence_id}/retry-transcription", response_model=Evidence)
async def retry_transcription(
evidence_id: int,
service: ActivityService = Depends(get_activity_service)
):
return service.retry_transcription(evidence_id)
@router.put("/evidence/{evidence_id}", response_model=Evidence)
def update_evidence(
evidence_id: int,
evidence: EvidenceUpdate,
service: ActivityService = Depends(get_activity_service)
):
return service.update_evidence(evidence_id, evidence)
@router.delete("/evidence/{evidence_id}")
def delete_evidence(
evidence_id: int,
service: ActivityService = Depends(get_activity_service)
):
return service.delete_evidence(evidence_id)

View File

@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.services.auth_service import AuthService
from app.schemas.token import Token
router = APIRouter()
@router.post("/token", response_model=Token)
def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
user = AuthService.authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return AuthService.create_user_token(user)

View File

@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from typing import List, Optional
from app.db.database import get_db
from app.services.contractors import ContractorService
from app.schemas.contractor import Contractor, ContractorCreate, ContractorUpdate
router = APIRouter()
def get_contractor_service(db: Session = Depends(get_db)) -> ContractorService:
return ContractorService(db)
@router.post("/", response_model=Contractor)
def create_contractor(
contractor: ContractorCreate,
service: ContractorService = Depends(get_contractor_service)
):
return service.create_contractor(contractor)
@router.get("/", response_model=List[Contractor])
def read_contractors(
parent_id: Optional[int] = None,
only_parents: bool = False,
is_active: Optional[bool] = None,
service: ContractorService = Depends(get_contractor_service)
):
return service.get_contractors(parent_id, only_parents, is_active)
@router.get("/{contractor_id}", response_model=Contractor)
def read_contractor(
contractor_id: int,
service: ContractorService = Depends(get_contractor_service)
):
return service.get_contractor(contractor_id)
@router.put("/{contractor_id}", response_model=Contractor)
@router.patch("/{contractor_id}", response_model=Contractor)
def update_contractor(
contractor_id: int,
contractor: ContractorUpdate,
service: ContractorService = Depends(get_contractor_service)
):
return service.update_contractor(contractor_id, contractor)
@router.delete("/{contractor_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_contractor(
contractor_id: int,
service: ContractorService = Depends(get_contractor_service)
):
return service.delete_contractor(contractor_id)

View File

@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, UploadFile, File
from sqlalchemy.orm import Session
from typing import Optional
from app.db.database import get_db
from app.services.guest import GuestService
from app.schemas.non_conformity import NonConformity, NonConformityUpdate
from app.schemas.evidence import Evidence
router = APIRouter()
def get_guest_service(db: Session = Depends(get_db)) -> GuestService:
return GuestService(db)
@router.get("/nc/{access_hash}", response_model=NonConformity)
def read_guest_nc(
access_hash: str,
service: GuestService = Depends(get_guest_service)
):
return service.get_nc_by_hash(access_hash)
@router.patch("/nc/{access_hash}", response_model=NonConformity)
def update_guest_nc(
access_hash: str,
nc_update: NonConformityUpdate,
service: GuestService = Depends(get_guest_service)
):
return service.update_guest_nc(access_hash, nc_update)
@router.post("/nc/{access_hash}/upload", response_model=Evidence)
async def upload_guest_evidence(
access_hash: str,
file: UploadFile = File(...),
description: Optional[str] = None,
service: GuestService = Depends(get_guest_service)
):
return service.upload_guest_evidence(access_hash, file, description)

View File

@ -0,0 +1,67 @@
from fastapi import APIRouter, Depends, UploadFile, File
from sqlalchemy.orm import Session
from typing import List, Optional
from app.db.database import get_db
from app.services.non_conformities import NonConformityService
from app.schemas.non_conformity import NonConformity, NonConformityCreate, NonConformityUpdate
from app.schemas.evidence import Evidence
router = APIRouter()
def get_nc_service(db: Session = Depends(get_db)) -> NonConformityService:
return NonConformityService(db)
@router.post("/", response_model=NonConformity)
def create_nc(
nc: NonConformityCreate,
service: NonConformityService = Depends(get_nc_service)
):
return service.create_nc(nc)
@router.get("/", response_model=List[NonConformity])
def read_ncs(
activity_id: Optional[int] = None,
status: Optional[str] = None,
service: NonConformityService = Depends(get_nc_service)
):
return service.get_ncs(activity_id, status)
@router.get("/{nc_id}", response_model=NonConformity)
def read_nc(
nc_id: int,
service: NonConformityService = Depends(get_nc_service)
):
return service.get_nc(nc_id)
@router.put("/{nc_id}", response_model=NonConformity)
@router.patch("/{nc_id}", response_model=NonConformity)
def update_nc(
nc_id: int,
nc: NonConformityUpdate,
service: NonConformityService = Depends(get_nc_service)
):
return service.update_nc(nc_id, nc)
@router.post("/{nc_id}/upload", response_model=Evidence)
async def upload_nc_evidence(
nc_id: int,
file: UploadFile = File(...),
description: Optional[str] = None,
captured_at: Optional[str] = None,
service: NonConformityService = Depends(get_nc_service)
):
return service.upload_nc_evidence(nc_id, file, description, captured_at)
@router.delete("/{nc_id}")
def delete_nc(
nc_id: int,
service: NonConformityService = Depends(get_nc_service)
):
return service.delete_nc(nc_id)
@router.post("/{nc_id}/notify")
def notify_responsible(
nc_id: int,
service: NonConformityService = Depends(get_nc_service)
):
return service.notify_responsible(nc_id)

View File

@ -0,0 +1,48 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from typing import List
from app.db.database import get_db
from app.services.projects import ProjectService
from app.schemas.project import Project, ProjectCreate
router = APIRouter()
def get_project_service(db: Session = Depends(get_db)) -> ProjectService:
return ProjectService(db)
@router.post("/", response_model=Project)
def create_project(
project: ProjectCreate,
service: ProjectService = Depends(get_project_service)
):
return service.create_project(project)
@router.get("/", response_model=List[Project])
def read_projects(
skip: int = 0,
limit: int = 100,
service: ProjectService = Depends(get_project_service)
):
return service.get_projects(skip=skip, limit=limit)
@router.get("/{project_id}", response_model=Project)
def read_project(
project_id: int,
service: ProjectService = Depends(get_project_service)
):
return service.get_project(project_id)
@router.put("/{project_id}", response_model=Project)
def update_project(
project_id: int,
project: ProjectCreate,
service: ProjectService = Depends(get_project_service)
):
return service.update_project(project_id, project)
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(
project_id: int,
service: ProjectService = Depends(get_project_service)
):
return service.delete_project(project_id)

View File

@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from typing import List
from app.db.database import get_db
from app.models.specialty import Specialty
from app.schemas.specialty import Specialty as SpecialtySchema
router = APIRouter()
@router.get("/", response_model=List[SpecialtySchema])
def read_specialties(db: Session = Depends(get_db)):
return db.query(Specialty).all()

View File

@ -0,0 +1,9 @@
from fastapi import APIRouter, File, UploadFile
from app.services.transcription import TranscriptionService
router = APIRouter()
@router.post("/")
async def transcribe_audio(file: UploadFile = File(...)):
text = await TranscriptionService.transcribe_audio(file)
return {"text": text}

View File

@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from typing import List
from app.db.database import get_db
from app.models.user import User as UserModel
from app.api.deps import get_current_active_user
from app.services.users import UserService
from app.schemas.user import UserCreate, User
router = APIRouter()
def get_user_service(db: Session = Depends(get_db)) -> UserService:
return UserService(db)
@router.post("/", response_model=User)
def create_user(user: UserCreate, service: UserService = Depends(get_user_service)):
return service.create_user(user)
@router.get("/", response_model=List[User])
def read_users(
skip: int = 0,
limit: int = 100,
service: UserService = Depends(get_user_service)
):
return service.get_users(skip=skip, limit=limit)
@router.get("/me", response_model=User)
def read_users_me(current_user: UserModel = Depends(get_current_active_user)):
return current_user

View File

View File

@ -0,0 +1,46 @@
import os
from dotenv import load_dotenv
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List, Optional
load_dotenv()
class Settings(BaseSettings):
PROJECT_NAME: str = "SumaQ Backend"
API_V1_STR: str = "/api/v1"
# Security
SECRET_KEY: str = os.getenv("SECRET_KEY", "Bt50MaUvRYJ28UOIberyBlRVQCcKiYzVF2JHOFKjbBQq5xoOpowyxjY1tCOEzYEL")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
# Base de Datos
# DATABASE_URL: Optional[str] = os.getenv("DATABASE_URL")
DATABASE_URL: Optional[str] = (
f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}"
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
)
# Google API
GOOGLE_API_KEY: Optional[str] = os.getenv("GOOGLE_API_KEY")
# Email
SMTP_HOST: Optional[str] = os.getenv("SMTP_HOST")
SMTP_PORT: Optional[int] = os.getenv("SMTP_PORT")
SMTP_USER: Optional[str] = os.getenv("SMTP_USER")
SMTP_PASSWORD: Optional[str] = os.getenv("SMTP_PASSWORD")
EMAILS_FROM_NAME: str = os.getenv("EMAILS_FROM_NAME", "Sistema SumaQ")
EMAILS_FROM_EMAIL: Optional[str] = os.getenv("EMAILS_FROM_EMAIL")
# Frontend
FRONTEND_URL: str = os.getenv("FRONTEND_URL", "http://localhost:4200")
model_config = SettingsConfigDict(
case_sensitive=True,
env_file=".env",
env_file_encoding="utf-8",
extra="ignore"
)
settings = Settings()

View File

@ -0,0 +1,38 @@
from datetime import datetime, timedelta
from typing import Optional, Union, Any
from jose import jwt
from passlib.context import CryptContext
import hashlib
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
# Pre-hash the password with SHA-256 to ensure it's within bcrypt's 72-byte limit
pre_hashed = hashlib.sha256(plain_password.encode()).hexdigest()
# Try verifying the pre-hashed password first
if pwd_context.verify(pre_hashed, hashed_password):
return True
# Fallback: Try verifying the raw password (legacy)
try:
return pwd_context.verify(plain_password, hashed_password)
except ValueError:
return False
def get_password_hash(password: str) -> str:
# Pre-hash with SHA-256 to handle any length and stay under bcrypt's 72-byte limit
pre_hashed = hashlib.sha256(password.encode()).hexdigest()
return pwd_context.hash(pre_hashed)
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt

4
backend/app/db/base.py Normal file
View File

@ -0,0 +1,4 @@
# Import all the models, so that Base has them before being
# imported by Alembic
from app.db.database import Base # noqa
from app.models.models import * # noqa

View File

@ -1,21 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from dotenv import load_dotenv
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import settings
load_dotenv()
# Using PostgreSQL for production
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgresql@localhost:5432/postgres")
engine = create_engine(
SQLALCHEMY_DATABASE_URL
)
# Use settings.DATABASE_URL
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
with SessionLocal() as session:
yield session
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@ -1,6 +1,6 @@
from app.db.database import engine, Base, SessionLocal
from app.models import User, Project, Specialty, Contractor, Activity, NonConformity, Evidence, UserRole
from app.security import get_password_hash # Import hashing function
from app.core.security import get_password_hash # Import hashing function from new core
import datetime
def init_db():

View File

@ -0,0 +1,7 @@
from .user import User, UserRole
from .project import Project, project_specialties, project_contractors
from .specialty import Specialty
from .contractor import Contractor
from .activity import Activity, ActivityType
from .non_conformity import NonConformity, NCLevel, NCType
from .evidence import Evidence

View File

@ -0,0 +1,39 @@
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text, Enum
from sqlalchemy.orm import relationship
from app.db.database import Base
import datetime
import enum
class ActivityType(str, enum.Enum):
INSPECTION = "inspection"
MEETING = "meeting"
VIRTUAL_MEETING = "virtual_meeting"
COORDINATION = "coordination"
TEST = "test"
OTHER = "other"
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"))
specialty_id = Column(Integer, ForeignKey("specialties.id"))
contractor_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
user_id = Column(Integer, ForeignKey("users.id")) # Reporter
date = Column(DateTime, default=datetime.datetime.utcnow)
end_date = Column(DateTime, nullable=True)
type = Column(Enum(ActivityType), default=ActivityType.INSPECTION)
area = Column(String) # Frente de obra / Linea
description = Column(Text)
observations = Column(Text)
audio_transcription = Column(Text, nullable=True)
status = Column(String, default="completed")
project = relationship("app.models.project.Project", back_populates="activities")
specialty = relationship("app.models.specialty.Specialty")
contractor = relationship("app.models.contractor.Contractor")
reporter = relationship("app.models.user.User")
non_conformities = relationship("NonConformity", back_populates="activity")
evidences = relationship("Evidence", back_populates="activity")

View File

@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.db.database import Base
class Contractor(Base):
__tablename__ = "contractors"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
ruc = Column(String, unique=True)
contact_name = Column(String)
email = Column(String, nullable=True)
phone = Column(String, nullable=True)
address = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
specialty_id = Column(Integer, ForeignKey("specialties.id"), nullable=True)
parent_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
specialty = relationship("app.models.specialty.Specialty")
parent = relationship("Contractor", remote_side=[id], back_populates="subcontractors")
subcontractors = relationship("Contractor", back_populates="parent")

View File

@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text
from sqlalchemy.orm import relationship
from app.db.database import Base
import datetime
class Evidence(Base):
__tablename__ = "evidences"
id = Column(Integer, primary_key=True, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=True)
non_conformity_id = Column(Integer, ForeignKey("non_conformities.id"), nullable=True)
file_path = Column(String, nullable=False)
media_type = Column(String) # image, video, document
description = Column(String)
captured_at = Column(DateTime, default=datetime.datetime.utcnow)
# Transcription fields for audio
transcription = Column(Text, nullable=True)
transcription_status = Column(String, default="none") # none, pending, processing, completed, error
activity = relationship("app.models.activity.Activity", back_populates="evidences")
non_conformity = relationship("app.models.non_conformity.NonConformity", back_populates="evidences")

View File

@ -1,177 +1,7 @@
from sqlalchemy import Table, Column, Integer, String, Boolean, ForeignKey, DateTime, Text, Enum, JSON
from sqlalchemy.orm import relationship
from app.db.database import Base
import datetime
import enum
# Junction Tables
project_specialties = Table(
'project_specialties',
Base.metadata,
Column('project_id', Integer, ForeignKey('projects.id'), primary_key=True),
Column('specialty_id', Integer, ForeignKey('specialties.id'), primary_key=True)
)
project_contractors = Table(
'project_contractors',
Base.metadata,
Column('project_id', Integer, ForeignKey('projects.id'), primary_key=True),
Column('contractor_id', Integer, ForeignKey('contractors.id'), primary_key=True)
)
class UserRole(str, enum.Enum):
ADMIN = "admin"
DIRECTOR = "director"
SUPERVISOR = "supervisor"
COORDINATOR = "coordinator"
CONTRACTOR = "contractor"
CLIENT = "client"
class ActivityType(str, enum.Enum):
INSPECTION = "inspection"
MEETING = "meeting"
VIRTUAL_MEETING = "virtual_meeting"
COORDINATION = "coordination"
TEST = "test"
OTHER = "other"
class NCLevel(str, enum.Enum):
CRITICAL = "critical"
MAJOR = "major"
MINOR = "minor"
class NCType(str, enum.Enum):
HUMAN_ERROR = "Errores humanos"
PROCESS_FAILURE = "Fallas en los procesos"
DESIGN_ISSUE = "Problemas de diseño"
UNCONTROLLED_CHANGE = "Cambios no controlados"
COMMUNICATION_FAILURE = "Falta de comunicación"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String)
role = Column(Enum(UserRole), default=UserRole.SUPERVISOR)
is_active = Column(Boolean, default=True)
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
code = Column(String, unique=True, index=True)
location = Column(String)
start_date = Column(DateTime)
end_date = Column(DateTime)
status = Column(String, default="active")
parent_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
# Relationships
parent = relationship("Project", remote_side=[id], back_populates="subprojects")
subprojects = relationship("Project", back_populates="parent")
activities = relationship("Activity", back_populates="project")
specialties = relationship("Specialty", secondary=project_specialties)
contractors = relationship("Contractor", secondary=project_contractors)
class Specialty(Base):
__tablename__ = "specialties"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False) # Civil, Mecánica, Eléctrica, SSOMA
class Contractor(Base):
__tablename__ = "contractors"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
ruc = Column(String, unique=True)
contact_name = Column(String)
email = Column(String, nullable=True)
phone = Column(String, nullable=True)
address = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
specialty_id = Column(Integer, ForeignKey("specialties.id"), nullable=True)
parent_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
specialty = relationship("Specialty")
parent = relationship("Contractor", remote_side=[id], back_populates="subcontractors")
subcontractors = relationship("Contractor", back_populates="parent")
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"))
specialty_id = Column(Integer, ForeignKey("specialties.id"))
contractor_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
user_id = Column(Integer, ForeignKey("users.id")) # Reporter
date = Column(DateTime, default=datetime.datetime.utcnow)
end_date = Column(DateTime, nullable=True)
type = Column(Enum(ActivityType), default=ActivityType.INSPECTION)
area = Column(String) # Frente de obra / Linea
description = Column(Text)
observations = Column(Text)
audio_transcription = Column(Text, nullable=True)
status = Column(String, default="completed")
project = relationship("Project", back_populates="activities")
specialty = relationship("Specialty")
contractor = relationship("Contractor")
reporter = relationship("User")
non_conformities = relationship("NonConformity", back_populates="activity")
evidences = relationship("Evidence", back_populates="activity")
class NonConformity(Base):
__tablename__ = "non_conformities"
id = Column(Integer, primary_key=True, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"))
level = Column(Enum(NCLevel), default=NCLevel.MINOR)
description = Column(Text, nullable=False)
status = Column(String, default="open") # open, closed
# New Fields
due_date = Column(DateTime, nullable=True)
responsible_person = Column(String, nullable=True) # Legend/Name
responsible_email = Column(String, nullable=True) # For guest access
access_hash = Column(String, unique=True, index=True, nullable=True)
contractor_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
action_checklist = Column(JSON, nullable=True) # List of dicts {text: str, completed: bool}
nc_type = Column(Enum(NCType), nullable=True)
impact_description = Column(Text, nullable=True)
closure_description = Column(Text, nullable=True)
guest_actions = Column(Text, nullable=True) # Field for guest to describe actions taken
parent_id = Column(Integer, ForeignKey("non_conformities.id"), nullable=True)
activity = relationship("Activity", back_populates="non_conformities")
contractor = relationship("Contractor")
evidences = relationship("Evidence", back_populates="non_conformity")
parent = relationship("NonConformity", remote_side=[id], back_populates="child_ncs")
child_ncs = relationship("NonConformity", back_populates="parent")
class Evidence(Base):
__tablename__ = "evidences"
id = Column(Integer, primary_key=True, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=True)
non_conformity_id = Column(Integer, ForeignKey("non_conformities.id"), nullable=True)
file_path = Column(String, nullable=False)
media_type = Column(String) # image, video, document
description = Column(String)
captured_at = Column(DateTime, default=datetime.datetime.utcnow)
# Transcription fields for audio
transcription = Column(Text, nullable=True)
transcription_status = Column(String, default="none") # none, pending, processing, completed, error
activity = relationship("Activity", back_populates="evidences")
non_conformity = relationship("NonConformity", back_populates="evidences")
from app.models.user import User, UserRole
from app.models.project import Project, project_specialties, project_contractors
from app.models.specialty import Specialty
from app.models.contractor import Contractor
from app.models.activity import Activity, ActivityType
from app.models.non_conformity import NonConformity, NCLevel, NCType
from app.models.evidence import Evidence

View File

@ -0,0 +1,46 @@
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text, Enum, JSON
from sqlalchemy.orm import relationship
from app.db.database import Base
import enum
class NCLevel(str, enum.Enum):
CRITICAL = "critical"
MAJOR = "major"
MINOR = "minor"
class NCType(str, enum.Enum):
HUMAN_ERROR = "Errores humanos"
PROCESS_FAILURE = "Fallas en los procesos"
DESIGN_ISSUE = "Problemas de diseño"
UNCONTROLLED_CHANGE = "Cambios no controlados"
COMMUNICATION_FAILURE = "Falta de comunicación"
class NonConformity(Base):
__tablename__ = "non_conformities"
id = Column(Integer, primary_key=True, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"))
level = Column(Enum(NCLevel), default=NCLevel.MINOR)
description = Column(Text, nullable=False)
status = Column(String, default="open") # open, closed
# New Fields
due_date = Column(DateTime, nullable=True)
responsible_person = Column(String, nullable=True) # Legend/Name
responsible_email = Column(String, nullable=True) # For guest access
access_hash = Column(String, unique=True, index=True, nullable=True)
contractor_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
action_checklist = Column(JSON, nullable=True) # List of dicts {text: str, completed: bool}
nc_type = Column(Enum(NCType), nullable=True)
impact_description = Column(Text, nullable=True)
closure_description = Column(Text, nullable=True)
guest_actions = Column(Text, nullable=True) # Field for guest to describe actions taken
parent_id = Column(Integer, ForeignKey("non_conformities.id"), nullable=True)
activity = relationship("app.models.activity.Activity", back_populates="non_conformities")
contractor = relationship("app.models.contractor.Contractor")
evidences = relationship("Evidence", back_populates="non_conformity")
parent = relationship("NonConformity", remote_side=[id], back_populates="child_ncs")
child_ncs = relationship("NonConformity", back_populates="parent")

View File

@ -0,0 +1,38 @@
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Table
from sqlalchemy.orm import relationship
from app.db.database import Base
import datetime
# Junction Tables
project_specialties = Table(
'project_specialties',
Base.metadata,
Column('project_id', Integer, ForeignKey('projects.id'), primary_key=True),
Column('specialty_id', Integer, ForeignKey('specialties.id'), primary_key=True)
)
project_contractors = Table(
'project_contractors',
Base.metadata,
Column('project_id', Integer, ForeignKey('projects.id'), primary_key=True),
Column('contractor_id', Integer, ForeignKey('contractors.id'), primary_key=True)
)
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
code = Column(String, unique=True, index=True)
location = Column(String)
start_date = Column(DateTime)
end_date = Column(DateTime)
status = Column(String, default="active")
parent_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
# Relationships
parent = relationship("Project", remote_side=[id], back_populates="subprojects")
subprojects = relationship("Project", back_populates="parent")
activities = relationship("Activity", back_populates="project")
specialties = relationship("app.models.specialty.Specialty", secondary=project_specialties)
contractors = relationship("app.models.contractor.Contractor", secondary=project_contractors)

View File

@ -0,0 +1,8 @@
from sqlalchemy import Column, Integer, String
from app.db.database import Base
class Specialty(Base):
__tablename__ = "specialties"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False) # Civil, Mecánica, Eléctrica, SSOMA

View File

@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Boolean, Enum
from app.db.database import Base
import enum
class UserRole(str, enum.Enum):
ADMIN = "admin"
DIRECTOR = "director"
SUPERVISOR = "supervisor"
COORDINATOR = "coordinator"
CONTRACTOR = "contractor"
CLIENT = "client"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String)
role = Column(Enum(UserRole), default=UserRole.SUPERVISOR)
is_active = Column(Boolean, default=True)

View File

@ -1,147 +0,0 @@
import os
import shutil
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
from app.db.database import get_db
from app.models.models import Activity, Evidence, User
from app.security import get_current_active_user
from app.services.activities import ActivityService
from app import schemas
from app.schemas import ActivityCreate, ActivityUpdate
router = APIRouter(
prefix="/activities",
tags=["Activities"]
)
def get_activity_service(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
) -> ActivityService:
return ActivityService(db, current_user)
UPLOAD_DIR = "uploads"
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
@router.post("/", response_model=schemas.Activity)
def create_activity(
activity: ActivityCreate,
service: ActivityService = Depends(get_activity_service)
):
return service.create_activity(activity)
@router.get("/", response_model=List[schemas.Activity])
def read_activities(
project_id: Optional[int] = None,
specialty_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
service: ActivityService = Depends(get_activity_service)
):
db_activities = service.get_activities(project_id, specialty_id, skip, limit)
return db_activities
@router.get("/{activity_id}", response_model=schemas.Activity)
def read_activity(
activity_id: int,
service: ActivityService = Depends(get_activity_service)
):
return service.get_activity(activity_id)
@router.put("/{activity_id}", response_model=schemas.Activity)
def update_activity(
activity_id: int,
activity: schemas.ActivityUpdate,
service: ActivityService = Depends(get_activity_service)
):
db_activity = service.update_activity(activity_id, activity)
return db_activity
@router.post("/{activity_id}/upload", response_model=schemas.Evidence)
async def upload_evidence(
activity_id: int,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
description: Optional[str] = None,
captured_at: Optional[str] = None,
service: ActivityService = Depends(get_activity_service)
):
db_evidence = service.upload_evidence(
activity_id,
background_tasks,
file,
description,
captured_at
)
return db_evidence
@router.post("/evidence/{evidence_id}/retry-transcription", response_model=schemas.Evidence)
async def retry_transcription(
evidence_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
if not db_evidence.media_type or "audio" not in db_evidence.media_type:
raise HTTPException(status_code=400, detail="Only audio evidence can be transcribed")
# Update status to pending
db_evidence.transcription_status = "pending"
db_evidence.transcription = None
db.commit()
db.refresh(db_evidence)
# Queue transcription task
from app.services.transcription_worker import process_transcription
background_tasks.add_task(process_transcription, db_evidence.id)
return db_evidence
@router.put("/evidence/{evidence_id}", response_model=schemas.Evidence)
def update_evidence(
evidence_id: int,
evidence: schemas.EvidenceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
update_data = evidence.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_evidence, key, value)
db.commit()
db.refresh(db_evidence)
return db_evidence
@router.delete("/evidence/{evidence_id}")
def delete_evidence(
evidence_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
# Optional: Delete file from disk
if db_evidence.file_path and os.path.exists(db_evidence.file_path):
try:
os.remove(db_evidence.file_path)
except:
pass
db.delete(db_evidence)
db.commit()
return {"detail": "Evidence deleted"}

View File

@ -1,25 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import timedelta
from app.db.database import get_db
from app.models.models import User
from app.security import verify_password, create_access_token
import app.schemas
router = APIRouter(tags=["Authentication"])
@router.post("/token", response_model=app.schemas.Token)
def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=300)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@ -1,75 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from app.db.database import get_db
from app.models.models import Contractor
from app.security import get_current_active_user
import app.schemas
router = APIRouter(
prefix="/contractors",
tags=["Contractors"],
dependencies=[Depends(get_current_active_user)]
)
@router.post("/", response_model=app.schemas.Contractor)
def create_contractor(contractor: app.schemas.ContractorCreate, db: Session = Depends(get_db)):
db_contractor = Contractor(**contractor.dict())
db.add(db_contractor)
db.commit()
db.refresh(db_contractor)
return db_contractor
@router.get("/", response_model=List[app.schemas.Contractor])
def read_contractors(
parent_id: Optional[int] = None,
only_parents: bool = False,
is_active: Optional[bool] = None,
db: Session = Depends(get_db)
):
query = db.query(Contractor)
if only_parents:
query = query.filter(Contractor.parent_id == None)
elif parent_id is not None:
query = query.filter(Contractor.parent_id == parent_id)
if is_active is not None:
query = query.filter(Contractor.is_active == is_active)
return query.all()
@router.get("/{contractor_id}", response_model=app.schemas.Contractor)
def read_contractor(contractor_id: int, db: Session = Depends(get_db)):
db_contractor = db.query(Contractor).filter(Contractor.id == contractor_id).first()
if not db_contractor:
raise HTTPException(status_code=404, detail="Contractor not found")
return db_contractor
@router.put("/{contractor_id}", response_model=app.schemas.Contractor)
@router.patch("/{contractor_id}", response_model=app.schemas.Contractor)
def update_contractor(
contractor_id: int,
contractor: app.schemas.ContractorUpdate,
db: Session = Depends(get_db)
):
db_contractor = db.query(Contractor).filter(Contractor.id == contractor_id).first()
if not db_contractor:
raise HTTPException(status_code=404, detail="Contractor not found")
update_data = contractor.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_contractor, key, value)
db.commit()
db.refresh(db_contractor)
return db_contractor
@router.delete("/{contractor_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_contractor(contractor_id: int, db: Session = Depends(get_db)):
db_contractor = db.query(Contractor).filter(Contractor.id == contractor_id).first()
if not db_contractor:
raise HTTPException(status_code=404, detail="Contractor not found")
# Optional: instead of hard delete, maybe just deactivate
db.delete(db_contractor)
db.commit()
return None

View File

@ -1,97 +0,0 @@
import os
import shutil
import uuid
import datetime
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from typing import List, Optional
from app.db.database import get_db
from app.models.models import NonConformity, Evidence
import app.schemas
router = APIRouter(
prefix="/guest",
tags=["Guest Access"]
)
UPLOAD_DIR = "uploads"
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
@router.get("/nc/{access_hash}", response_model=app.schemas.NonConformity)
def read_guest_nc(
access_hash: str,
db: Session = Depends(get_db)
):
db_nc = db.query(NonConformity).filter(NonConformity.access_hash == access_hash).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found or invalid link")
return db_nc
@router.patch("/nc/{access_hash}", response_model=app.schemas.NonConformity)
def update_guest_nc(
access_hash: str,
nc_update: app.schemas.NonConformityUpdate,
db: Session = Depends(get_db)
):
db_nc = db.query(NonConformity).filter(NonConformity.access_hash == access_hash).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found or invalid link")
# Only allow updating specific fields for guest
# guest_actions, closure_description? description?
# The requirement says: "Describing actions taken." -> guest_actions
# "Upload evidence" -> handled by upload endpoint
# We will trust the validation in schema but we might want to restrict what guests can change.
# For now, let's allow updating guest_actions and maybe status if they can close it?
# User said: "Add PATCH endpoint to update NC activities/closure by guest."
if nc_update.guest_actions is not None:
db_nc.guest_actions = nc_update.guest_actions
if nc_update.closure_description is not None:
db_nc.closure_description = nc_update.closure_description
if nc_update.status is not None:
# Maybe allow them to mark as "resolved" or something?
db_nc.status = nc_update.status
db.commit()
db.refresh(db_nc)
return db_nc
@router.post("/nc/{access_hash}/upload", response_model=app.schemas.Evidence)
async def upload_guest_evidence(
access_hash: str,
file: UploadFile = File(...),
description: Optional[str] = None,
db: Session = Depends(get_db)
):
db_nc = db.query(NonConformity).filter(NonConformity.access_hash == access_hash).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found or invalid link")
# Generate unique filename
file_ext = os.path.splitext(file.filename)[1]
unique_filename = f"guest_nc_{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_filename)
# Save file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Save to database
db_evidence = Evidence(
non_conformity_id=db_nc.id,
file_path=file_path,
media_type=file.content_type,
description=description,
captured_at=datetime.datetime.utcnow()
)
db.add(db_evidence)
db.commit()
db.refresh(db_evidence)
return db_evidence

View File

@ -1,186 +0,0 @@
import os
import shutil
import uuid
import datetime
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from typing import List, Optional
from app.db.database import get_db
from app.models.models import NonConformity, User, Activity, Evidence, Contractor
from app.security import get_current_active_user
import app.schemas
router = APIRouter(
prefix="/non-conformities",
tags=["Non-Conformities"]
)
UPLOAD_DIR = "uploads"
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
@router.post("/", response_model=app.schemas.NonConformity)
def create_nc(
nc: app.schemas.NonConformityCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_activity = db.query(Activity).filter(Activity.id == nc.activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail="Activity not found")
# Sync responsible email from contractor if not provided
if nc.contractor_id and not nc.responsible_email:
contractor = db.query(Contractor).filter(Contractor.id == nc.contractor_id).first()
if contractor and contractor.email:
nc.responsible_email = contractor.email
db_nc = NonConformity(**nc.dict())
db.add(db_nc)
db.commit()
db.refresh(db_nc)
return db_nc
@router.get("/", response_model=List[app.schemas.NonConformity])
def read_ncs(
activity_id: Optional[int] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
query = db.query(NonConformity)
if activity_id:
query = query.filter(NonConformity.activity_id == activity_id)
if status:
query = query.filter(NonConformity.status == status)
return query.all()
@router.get("/{nc_id}", response_model=app.schemas.NonConformity)
def read_nc(
nc_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if db_nc is None:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
return db_nc
@router.put("/{nc_id}", response_model=app.schemas.NonConformity)
@router.patch("/{nc_id}", response_model=app.schemas.NonConformity)
def update_nc(
nc_id: int,
nc: app.schemas.NonConformityUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
update_data = nc.dict(exclude_unset=True)
# Sync responsible email if contractor_id changes and email not explicitly provided
if 'contractor_id' in update_data and update_data['contractor_id']:
if 'responsible_email' not in update_data or not update_data['responsible_email']:
contractor = db.query(Contractor).filter(Contractor.id == update_data['contractor_id']).first()
if contractor and contractor.email:
update_data['responsible_email'] = contractor.email
for key, value in update_data.items():
setattr(db_nc, key, value)
db.commit()
db.refresh(db_nc)
return db_nc
@router.post("/{nc_id}/upload", response_model=app.schemas.Evidence)
async def upload_nc_evidence(
nc_id: int,
file: UploadFile = File(...),
description: Optional[str] = None,
captured_at: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
# Verify NC exists
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
# Generate unique filename
file_ext = os.path.splitext(file.filename)[1]
unique_filename = f"nc_{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_filename)
# Save file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
db_captured_at = None
if captured_at:
try:
db_captured_at = datetime.datetime.fromisoformat(captured_at.replace('Z', '+00:00'))
except:
db_captured_at = datetime.datetime.utcnow()
else:
db_captured_at = datetime.datetime.utcnow()
# Save to database
db_evidence = Evidence(
non_conformity_id=nc_id,
file_path=file_path,
media_type=file.content_type,
description=description,
captured_at=db_captured_at
)
db.add(db_evidence)
db.commit()
db.refresh(db_evidence)
return db_evidence
@router.delete("/{nc_id}")
def delete_nc(
nc_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
db.delete(db_nc)
db.commit()
return {"detail": "Non-Conformity deleted"}
@router.post("/{nc_id}/notify")
def notify_responsible(
nc_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
if not db_nc.responsible_email:
raise HTTPException(status_code=400, detail="No responsible email configured for this Non-Conformity")
# Generate hash if it doesn't exist
if not db_nc.access_hash:
db_nc.access_hash = str(uuid.uuid4())
db.commit()
db.refresh(db_nc)
# Send email
from app.services.email_service import EmailService
EmailService.send_nc_notification(db_nc.responsible_email, db_nc.access_hash, db_nc.description)
# Update status to in-checking
db_nc.status = 'in-checking'
db.commit()
db.refresh(db_nc)
return {"message": "Notification sent successfully", "access_hash": db_nc.access_hash, "status": db_nc.status}

View File

@ -1,95 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.db.database import get_db
from app.models.models import Project, Specialty, Contractor
from app.security import get_current_active_user
import app.schemas
router = APIRouter(
prefix="/projects",
tags=["Projects"],
dependencies=[Depends(get_current_active_user)]
)
@router.post("/", response_model=app.schemas.Project)
def create_project(project: app.schemas.ProjectCreate, db: Session = Depends(get_db)):
db_project = db.query(Project).filter(Project.code == project.code).first()
if db_project:
raise HTTPException(status_code=400, detail="Project code already exists")
project_data = project.dict(exclude={'specialty_ids', 'contractor_ids'})
db_project = Project(**project_data)
# Handle Parent Project
if project.parent_id:
parent = db.query(Project).filter(Project.id == project.parent_id).first()
if not parent:
raise HTTPException(status_code=404, detail="Parent project not found")
if project.specialty_ids:
specialties = db.query(Specialty).filter(Specialty.id.in_(project.specialty_ids)).all()
db_project.specialties = specialties
# Handle Contractors
if project.contractor_ids:
contractors = db.query(Contractor).filter(Contractor.id.in_(project.contractor_ids)).all()
db_project.contractors = contractors
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
@router.get("/", response_model=List[app.schemas.Project])
def read_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
projects = db.query(Project).offset(skip).limit(limit).all()
return projects
@router.get("/{project_id}", response_model=app.schemas.Project)
def read_project(project_id: int, db: Session = Depends(get_db)):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
return db_project
@router.put("/{project_id}", response_model=app.schemas.Project)
def update_project(project_id: int, project: app.schemas.ProjectCreate, db: Session = Depends(get_db)):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
# Update simple fields
for key, value in project.dict(exclude={'specialty_ids', 'contractor_ids'}).items():
setattr(db_project, key, value)
# Handle Parent Project
if project.parent_id is not None:
parent = db.query(Project).filter(Project.id == project.parent_id).first()
if not parent and project.parent_id != 0: # Allow 0 or null to clear? Actually null is enough
raise HTTPException(status_code=404, detail="Parent project not found")
db_project.parent_id = project.parent_id if project.parent_id != 0 else None
# Update Specialties
if project.specialty_ids is not None:
specialties = db.query(Specialty).filter(Specialty.id.in_(project.specialty_ids)).all()
db_project.specialties = specialties
# Update Contractors
if project.contractor_ids is not None:
contractors = db.query(Contractor).filter(Contractor.id.in_(project.contractor_ids)).all()
db_project.contractors = contractors
db.commit()
db.refresh(db_project)
return db_project
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(project_id: int, db: Session = Depends(get_db)):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
db.delete(db_project)
db.commit()
return None

View File

@ -1,17 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.db.database import get_db
from app.models.models import Specialty
from app.security import get_current_active_user
import app.schemas
router = APIRouter(
prefix="/specialties",
tags=["Specialties"],
dependencies=[Depends(get_current_active_user)]
)
@router.get("/", response_model=List[app.schemas.Specialty])
def read_specialties(db: Session = Depends(get_db)):
return db.query(Specialty).all()

View File

@ -1,54 +0,0 @@
import os
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
import google.generativeai as genai
import tempfile
from dotenv import load_dotenv
from app.security import get_current_active_user
load_dotenv()
router = APIRouter(
prefix="/transcription",
tags=["Transcription"],
dependencies=[Depends(get_current_active_user)]
)
# Initialize Google Gemini
api_key = os.getenv("GOOGLE_API_KEY")
if api_key:
genai.configure(api_key=api_key)
@router.post("/")
async def transcribe_audio(file: UploadFile = File(...)):
if not os.getenv("GOOGLE_API_KEY"):
# Mock transcription for development if no key is present
return {"text": f"[MOCK GEMINI TRANSCRIPTION] Se ha recibido un archivo de audio de tipo {file.content_type}. Configure GOOGLE_API_KEY para transcripción real con Gemini."}
try:
# Create a temporary file to store the upload
suffix = os.path.splitext(file.filename)[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
# Upload to Gemini (Media Service)
audio_file = genai.upload_file(path=tmp_path, mime_type=file.content_type or "audio/wav")
# Use Gemini 1.5 Flash for audio-to-text
model = genai.GenerativeModel("gemini-2.5-flash-lite")
response = model.generate_content([
"Por favor, transcribe exactamente lo que se dice en este audio. Solo devuelve el texto transcrito.",
audio_file
])
# Cleanup
os.unlink(tmp_path)
# Gemini files are ephemeral but we can delete explicitly if needed
# genai.delete_file(audio_file.name)
return {"text": response.text}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,27 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.db.database import get_db
from app.models.models import User
from app.security import get_password_hash, get_current_active_user
from app.services import users
from app.schemas import UserCreate, User
router = APIRouter(
prefix="/users",
tags=["Users"],
dependencies=[Depends(get_current_active_user)]
)
@router.post("/", response_model=User)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
return users.create_user(db, user)
@router.get("/", response_model=List[User])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
db_users = users.get_users(db, skip, limit)
return db_users
@router.get("/me", response_model=User)
def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user

View File

@ -1,202 +1,17 @@
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional, List
from datetime import datetime
from app.models.models import UserRole, ActivityType, NCLevel, NCType
from app.schemas.token import Token, TokenData
from app.schemas.user import User, UserCreate, UserBase
from app.schemas.project import Project, ProjectCreate, ProjectBase
from app.schemas.specialty import Specialty, SpecialtyBase
from app.schemas.contractor import Contractor, ContractorCreate, ContractorUpdate, ContractorBase
from app.schemas.evidence import Evidence, EvidenceUpdate, EvidenceBase
from app.schemas.activity import Activity, ActivityCreate, ActivityUpdate, ActivityBase
from app.schemas.non_conformity import NonConformity, NonConformityCreate, NonConformityUpdate, NonConformityBase
# Token
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None
# User
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
role: UserRole = UserRole.SUPERVISOR
is_active: bool = True
class UserCreate(UserBase):
password: str
class User(UserBase):
id: int
class Config:
from_attributes = True
# Project
class ProjectBase(BaseModel):
name: str
code: str
location: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
status: str = "active"
parent_id: Optional[int] = None
class ProjectCreate(ProjectBase):
specialty_ids: Optional[List[int]] = []
contractor_ids: Optional[List[int]] = []
class Project(ProjectBase):
id: int
specialties: List['Specialty'] = []
contractors: List['Contractor'] = []
subprojects: List['Project'] = []
class Config:
from_attributes = True
# Specialty
class SpecialtyBase(BaseModel):
name: str
class Specialty(SpecialtyBase):
id: int
class Config:
from_attributes = True
# Contractor
class ContractorBase(BaseModel):
name: str
ruc: Optional[str] = None
contact_name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
specialty_id: Optional[int] = None
parent_id: Optional[int] = None
is_active: bool = True
@field_validator('ruc', 'contact_name', 'email', 'phone', 'address', 'specialty_id', 'parent_id', mode='before')
@classmethod
def empty_string_to_none(cls, v):
if v == "":
return None
return v
class ContractorCreate(ContractorBase):
pass
class ContractorUpdate(BaseModel):
name: Optional[str] = None
ruc: Optional[str] = None
contact_name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
specialty_id: Optional[int] = None
parent_id: Optional[int] = None
is_active: Optional[bool] = None
class Contractor(ContractorBase):
id: int
specialty: Optional[Specialty] = None
subcontractors: List['Contractor'] = []
class Config:
from_attributes = True
# Evidence
class EvidenceBase(BaseModel):
file_path: str
media_type: Optional[str] = None
description: Optional[str] = None
captured_at: Optional[datetime] = None
transcription: Optional[str] = None
transcription_status: str = "none"
class Evidence(EvidenceBase):
id: int
activity_id: Optional[int] = None
non_conformity_id: Optional[int] = None
class Config:
from_attributes = True
class EvidenceUpdate(BaseModel):
description: Optional[str] = None
# Activity
class ActivityBase(BaseModel):
project_id: int
specialty_id: int
contractor_id: Optional[int] = None
type: ActivityType = ActivityType.INSPECTION
area: Optional[str] = None
description: Optional[str] = None
observations: Optional[str] = None
audio_transcription: Optional[str] = None
status: str = "completed"
date: Optional[datetime] = None
end_date: Optional[datetime] = None
class ActivityUpdate(BaseModel):
project_id: Optional[int] = None
specialty_id: Optional[int] = None
contractor_id: Optional[int] = None
area: Optional[str] = None
description: Optional[str] = None
observations: Optional[str] = None
status: Optional[str] = None
date: Optional[datetime] = None
end_date: Optional[datetime] = None
class ActivityCreate(ActivityBase):
pass
# NonConformity
class NonConformityBase(BaseModel):
level: NCLevel = NCLevel.MINOR
description: str
status: str = "open"
due_date: Optional[datetime] = None
responsible_person: Optional[str] = None
responsible_email: Optional[str] = None
contractor_id: Optional[int] = None
access_hash: Optional[str] = None
action_checklist: Optional[List[dict]] = None
nc_type: Optional[NCType] = None
impact_description: Optional[str] = None
closure_description: Optional[str] = None
guest_actions: Optional[str] = None
parent_id: Optional[int] = None
class NonConformityCreate(NonConformityBase):
activity_id: int
class NonConformityUpdate(BaseModel):
due_date: Optional[datetime] = None
responsible_person: Optional[str] = None
responsible_email: Optional[str] = None
contractor_id: Optional[int] = None
access_hash: Optional[str] = None
action_checklist: Optional[List[dict]] = None
nc_type: Optional[NCType] = None
impact_description: Optional[str] = None
closure_description: Optional[str] = None
status: Optional[str] = None
guest_actions: Optional[str] = None
class NonConformity(NonConformityBase):
id: int
activity_id: int
evidences: List[Evidence] = []
child_ncs: List['NonConformity'] = []
class Config:
from_attributes = True
class Activity(ActivityBase):
id: int
user_id: int
project: Optional[Project] = None
evidences: List[Evidence] = []
non_conformities: List[NonConformity] = []
class Config:
from_attributes = True
# Necessary for recursive models
from app.schemas.contractor import Contractor
from app.schemas.project import Project
from app.schemas.non_conformity import NonConformity
Contractor.model_rebuild()
Project.model_rebuild()
NonConformity.model_rebuild()

View File

@ -0,0 +1,8 @@
from .token import Token, TokenData
from .user import User, UserCreate
from .project import Project, ProjectCreate, ProjectBase
from .specialty import Specialty, SpecialtyBase
from .contractor import Contractor, ContractorCreate, ContractorUpdate
from .evidence import Evidence, EvidenceUpdate
from .activity import Activity, ActivityCreate, ActivityUpdate
from .non_conformity import NonConformity, NonConformityCreate, NonConformityUpdate

View File

@ -0,0 +1,44 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.models.models import ActivityType
from .project import Project
from .evidence import Evidence
from .non_conformity import NonConformity
class ActivityBase(BaseModel):
project_id: int
specialty_id: int
contractor_id: Optional[int] = None
type: ActivityType = ActivityType.INSPECTION
area: Optional[str] = None
description: Optional[str] = None
observations: Optional[str] = None
audio_transcription: Optional[str] = None
status: str = "completed"
date: Optional[datetime] = None
end_date: Optional[datetime] = None
class ActivityUpdate(BaseModel):
project_id: Optional[int] = None
specialty_id: Optional[int] = None
contractor_id: Optional[int] = None
area: Optional[str] = None
description: Optional[str] = None
observations: Optional[str] = None
status: Optional[str] = None
date: Optional[datetime] = None
end_date: Optional[datetime] = None
class ActivityCreate(ActivityBase):
pass
class Activity(ActivityBase):
id: int
user_id: int
project: Optional[Project] = None
evidences: List[Evidence] = []
non_conformities: List[NonConformity] = []
class Config:
from_attributes = True

View File

@ -0,0 +1,43 @@
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional, List
from .specialty import Specialty
class ContractorBase(BaseModel):
name: str
ruc: Optional[str] = None
contact_name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
specialty_id: Optional[int] = None
parent_id: Optional[int] = None
is_active: bool = True
@field_validator('ruc', 'contact_name', 'email', 'phone', 'address', 'specialty_id', 'parent_id', mode='before')
@classmethod
def empty_string_to_none(cls, v):
if v == "":
return None
return v
class ContractorCreate(ContractorBase):
pass
class ContractorUpdate(BaseModel):
name: Optional[str] = None
ruc: Optional[str] = None
contact_name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
specialty_id: Optional[int] = None
parent_id: Optional[int] = None
is_active: Optional[bool] = None
class Contractor(ContractorBase):
id: int
specialty: Optional[Specialty] = None
subcontractors: List['Contractor'] = []
class Config:
from_attributes = True

View File

@ -0,0 +1,21 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class EvidenceBase(BaseModel):
file_path: str
media_type: Optional[str] = None
description: Optional[str] = None
captured_at: Optional[datetime] = None
transcription: Optional[str] = None
transcription_status: str = "none"
class Evidence(EvidenceBase):
id: int
activity_id: Optional[int] = None
non_conformity_id: Optional[int] = None
class Config:
from_attributes = True
class EvidenceUpdate(BaseModel):
description: Optional[str] = None

View File

@ -0,0 +1,46 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.models.models import NCLevel, NCType
from .evidence import Evidence
class NonConformityBase(BaseModel):
level: NCLevel = NCLevel.MINOR
description: str
status: str = "open"
due_date: Optional[datetime] = None
responsible_person: Optional[str] = None
responsible_email: Optional[str] = None
contractor_id: Optional[int] = None
access_hash: Optional[str] = None
action_checklist: Optional[List[dict]] = None
nc_type: Optional[NCType] = None
impact_description: Optional[str] = None
closure_description: Optional[str] = None
guest_actions: Optional[str] = None
parent_id: Optional[int] = None
class NonConformityCreate(NonConformityBase):
activity_id: int
class NonConformityUpdate(BaseModel):
due_date: Optional[datetime] = None
responsible_person: Optional[str] = None
responsible_email: Optional[str] = None
contractor_id: Optional[int] = None
access_hash: Optional[str] = None
action_checklist: Optional[List[dict]] = None
nc_type: Optional[NCType] = None
impact_description: Optional[str] = None
closure_description: Optional[str] = None
status: Optional[str] = None
guest_actions: Optional[str] = None
class NonConformity(NonConformityBase):
id: int
activity_id: int
evidences: List[Evidence] = []
child_ncs: List['NonConformity'] = []
class Config:
from_attributes = True

View File

@ -0,0 +1,27 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from .specialty import Specialty
from .contractor import Contractor
class ProjectBase(BaseModel):
name: str
code: str
location: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
status: str = "active"
parent_id: Optional[int] = None
class ProjectCreate(ProjectBase):
specialty_ids: Optional[List[int]] = []
contractor_ids: Optional[List[int]] = []
class Project(ProjectBase):
id: int
specialties: List[Specialty] = []
contractors: List[Contractor] = []
subprojects: List['Project'] = []
class Config:
from_attributes = True

View File

@ -0,0 +1,9 @@
from pydantic import BaseModel
class SpecialtyBase(BaseModel):
name: str
class Specialty(SpecialtyBase):
id: int
class Config:
from_attributes = True

View File

@ -0,0 +1,9 @@
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None

View File

@ -0,0 +1,18 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from app.models.models import UserRole
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
role: UserRole = UserRole.SUPERVISOR
is_active: bool = True
class UserCreate(UserBase):
password: str
class User(UserBase):
id: int
class Config:
from_attributes = True

View File

@ -8,9 +8,10 @@ from sqlalchemy.orm import Session
from app.db.database import get_db
from app.models.models import User
import app.schemas
import hashlib
# Secret key for JWT (should be in env vars in production)
SECRET_KEY = "super_secret_key_for_fritos_fresh_supervision_system"
SECRET_KEY = "Bt50MaUvRYJ28UOIberyBlRVQCcKiYzVF2JHOFKjbBQq5xoOpowyxjY1tCOEzYEL"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 300
@ -18,10 +19,25 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain_password, hashed_password):
# Pre-hash the password with SHA-256 to ensure it's within bcrypt's 72-byte limit
pre_hashed = hashlib.sha256(plain_password.encode()).hexdigest()
# Try verifying the pre-hashed password first (new approach)
if pwd_context.verify(pre_hashed, hashed_password):
return True
# Fallback: Try verifying the raw password (legacy approach for existing accounts)
try:
return pwd_context.verify(plain_password, hashed_password)
except ValueError:
# If the password was already too long, it would have failed during registration
# but just in case, we catch the ValueError from bcrypt
return False
def get_password_hash(password):
return pwd_context.hash(password)
# Pre-hash with SHA-256 to handle any length and stay under bcrypt's 72-byte limit
pre_hashed = hashlib.sha256(password.encode()).hexdigest()
return pwd_context.hash(pre_hashed)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()

View File

@ -3,24 +3,27 @@ import uuid
import shutil
import datetime
from sqlalchemy.orm import Session
from typing import Optional
from typing import Optional, List
from fastapi import HTTPException, File, BackgroundTasks, UploadFile
from app.models.models import Activity, User, Evidence
from app.schemas import ActivityCreate, ActivityUpdate
from app.db.database import get_db
from app.security import get_current_active_user
from app.models.activity import Activity
from app.models.user import User
from app.models.evidence import Evidence
from app.schemas.activity import ActivityCreate, ActivityUpdate
from app.services.transcription_worker import TranscriptionWorker
UPLOAD_DIR = "uploads"
class ActivityService:
def __init__(self, db: Session, current_user: User):
def __init__(self, db: Session, current_user: User, background_tasks: Optional[BackgroundTasks] = None):
self._db = db
self._current_user = current_user
self._background_tasks = background_tasks
self._transcriptionWorker = TranscriptionWorker(self._db)
def get_activities(self, project_id: Optional[int] = None,
specialty_id: Optional[int] = None,
skip: int = 0,
limit: int = 100):
limit: int = 100) -> List[Activity]:
query = self._db.query(Activity)
if project_id:
@ -28,18 +31,17 @@ class ActivityService:
if specialty_id:
query = query.filter(Activity.specialty_id == specialty_id)
activities = query.offset(skip).limit(limit).all()
return activities
return query.offset(skip).limit(limit).all()
def get_activity(self, activity_id: int):
def get_activity(self, activity_id: int) -> Activity:
db_activity = self._db.query(Activity).filter(Activity.id == activity_id).first()
if db_activity is None:
raise HTTPException(status_code=404, detail="Activity not found")
return db_activity
def create_activity(self, activity: ActivityCreate):
def create_activity(self, activity: ActivityCreate) -> Activity:
db_activity = Activity(
**activity.dict(),
**activity.model_dump(),
user_id=self._current_user.id
)
self._db.add(db_activity)
@ -47,12 +49,10 @@ class ActivityService:
self._db.refresh(db_activity)
return db_activity
def update_activity(self, activity_id: int, activity: ActivityUpdate):
db_activity = self._db.query(Activity).filter(Activity.id == activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail="Activity not found")
def update_activity(self, activity_id: int, activity: ActivityUpdate) -> Activity:
db_activity = self.get_activity(activity_id)
update_data = activity.dict(exclude_unset=True)
update_data = activity.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_activity, key, value)
@ -63,15 +63,12 @@ class ActivityService:
def upload_evidence(
self,
activity_id: int,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
file: UploadFile,
description: Optional[str] = None,
captured_at: Optional[str] = None):
captured_at: Optional[str] = None) -> Evidence:
# Verify activity exists
db_activity = self._db.query(Activity).filter(Activity.id == activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail="Activity not found")
self.get_activity(activity_id)
# Generate unique filename
file_ext = os.path.splitext(file.filename)[1]
@ -113,8 +110,56 @@ class ActivityService:
self._db.refresh(db_evidence)
# If it's audio, queue transcription
if initial_status == "pending":
from app.services.transcription_worker import process_transcription
background_tasks.add_task(process_transcription, db_evidence.id)
if initial_status == "pending" and self._background_tasks:
self._background_tasks.add_task(self._transcriptionWorker.process_transcription, db_evidence.id)
return db_evidence
def retry_transcription(self, evidence_id: int) -> Evidence:
db_evidence = self._db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
if not db_evidence.media_type or "audio" not in db_evidence.media_type:
raise HTTPException(status_code=400, detail="Only audio evidence can be transcribed")
# Update status to pending
db_evidence.transcription_status = "pending"
db_evidence.transcription = None
self._db.commit()
self._db.refresh(db_evidence)
# Queue transcription task
if self._background_tasks:
self._background_tasks.add_task(self._transcriptionWorker.process_transcription, db_evidence.id)
return db_evidence
def update_evidence(self, evidence_id: int, evidence_update: any) -> Evidence:
db_evidence = self._db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
update_data = evidence_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_evidence, key, value)
self._db.commit()
self._db.refresh(db_evidence)
return db_evidence
def delete_evidence(self, evidence_id: int):
db_evidence = self._db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
# Optional: Delete file from disk
if db_evidence.file_path and os.path.exists(db_evidence.file_path):
try:
os.remove(db_evidence.file_path)
except:
pass
self._db.delete(db_evidence)
self._db.commit()
return {"detail": "Evidence deleted"}

View File

@ -0,0 +1,29 @@
from datetime import timedelta
from typing import Optional
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.user import User
from app.core.security import verify_password, create_access_token, get_password_hash
from app.core.config import settings
class AuthService:
@staticmethod
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
user = db.query(User).filter(User.email == email).first()
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
@staticmethod
def create_user_token(user: User) -> dict:
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
subject=user.email, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@staticmethod
def hash_password(password: str) -> str:
return get_password_hash(password)

View File

@ -0,0 +1,55 @@
from sqlalchemy.orm import Session
from typing import List, Optional
from fastapi import HTTPException
from app.models.contractor import Contractor
from app.schemas.contractor import ContractorCreate, ContractorUpdate
class ContractorService:
def __init__(self, db: Session):
self._db = db
def get_contractors(
self,
parent_id: Optional[int] = None,
only_parents: bool = False,
is_active: Optional[bool] = None
) -> List[Contractor]:
query = self._db.query(Contractor)
if only_parents:
query = query.filter(Contractor.parent_id == None)
elif parent_id is not None:
query = query.filter(Contractor.parent_id == parent_id)
if is_active is not None:
query = query.filter(Contractor.is_active == is_active)
return query.all()
def get_contractor(self, contractor_id: int) -> Contractor:
db_contractor = self._db.query(Contractor).filter(Contractor.id == contractor_id).first()
if not db_contractor:
raise HTTPException(status_code=404, detail="Contractor not found")
return db_contractor
def create_contractor(self, contractor: ContractorCreate) -> Contractor:
db_contractor = Contractor(**contractor.model_dump())
self._db.add(db_contractor)
self._db.commit()
self._db.refresh(db_contractor)
return db_contractor
def update_contractor(self, contractor_id: int, contractor: ContractorUpdate) -> Contractor:
db_contractor = self.get_contractor(contractor_id)
update_data = contractor.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_contractor, key, value)
self._db.commit()
self._db.refresh(db_contractor)
return db_contractor
def delete_contractor(self, contractor_id: int):
db_contractor = self.get_contractor(contractor_id)
self._db.delete(db_contractor)
self._db.commit()
return None

View File

@ -1,23 +1,19 @@
import smtplib
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from dotenv import load_dotenv
load_dotenv()
from app.core.config import settings
class EmailService:
@staticmethod
def send_nc_notification(email: str, access_hash: str, nc_description: str):
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
smtp_port = int(os.getenv("SMTP_PORT", 587))
smtp_user = os.getenv("SMTP_USER")
smtp_password = os.getenv("SMTP_PASSWORD")
from_name = os.getenv("EMAILS_FROM_NAME", "Sistema de Supervisión")
from_email = os.getenv("EMAILS_FROM_EMAIL", smtp_user)
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:4200")
smtp_host = settings.SMTP_HOST or "smtp.gmail.com"
smtp_port = settings.SMTP_PORT or 587
smtp_user = settings.SMTP_USER
smtp_password = settings.SMTP_PASSWORD
from_name = settings.EMAILS_FROM_NAME
from_email = settings.EMAILS_FROM_EMAIL or smtp_user
frontend_url = settings.FRONTEND_URL
if not smtp_user or not smtp_password:
print("WARNING: Email credentials not configured. Simulation mode.")

View File

@ -0,0 +1,72 @@
import os
import shutil
import uuid
import datetime
from sqlalchemy.orm import Session
from typing import Optional
from fastapi import HTTPException, UploadFile
from app.models.non_conformity import NonConformity
from app.models.evidence import Evidence
from app.schemas.non_conformity import NonConformityUpdate
UPLOAD_DIR = "uploads"
class GuestService:
def __init__(self, db: Session):
self._db = db
def get_nc_by_hash(self, access_hash: str) -> NonConformity:
db_nc = self._db.query(NonConformity).filter(NonConformity.access_hash == access_hash).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found or invalid link")
return db_nc
def update_guest_nc(self, access_hash: str, nc_update: NonConformityUpdate) -> NonConformity:
db_nc = self.get_nc_by_hash(access_hash)
if nc_update.guest_actions is not None:
db_nc.guest_actions = nc_update.guest_actions
if nc_update.closure_description is not None:
db_nc.closure_description = nc_update.closure_description
if nc_update.status is not None:
db_nc.status = nc_update.status
self._db.commit()
self._db.refresh(db_nc)
return db_nc
def upload_guest_evidence(
self,
access_hash: str,
file: UploadFile,
description: Optional[str] = None
) -> Evidence:
db_nc = self.get_nc_by_hash(access_hash)
# Generate unique filename
file_ext = os.path.splitext(file.filename)[1]
unique_filename = f"guest_nc_{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_filename)
# Save file
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Save to database
db_evidence = Evidence(
non_conformity_id=db_nc.id,
file_path=file_path,
media_type=file.content_type,
description=description,
captured_at=datetime.datetime.utcnow()
)
self._db.add(db_evidence)
self._db.commit()
self._db.refresh(db_evidence)
return db_evidence

View File

@ -0,0 +1,140 @@
import os
import shutil
import uuid
import datetime
from sqlalchemy.orm import Session
from typing import List, Optional
from fastapi import HTTPException, UploadFile
from app.models.non_conformity import NonConformity
from app.models.activity import Activity
from app.models.evidence import Evidence
from app.models.contractor import Contractor
from app.schemas.non_conformity import NonConformityCreate, NonConformityUpdate
from app.services.email_service import EmailService
UPLOAD_DIR = "uploads"
class NonConformityService:
def __init__(self, db: Session):
self._db = db
def get_ncs(self, activity_id: Optional[int] = None, status: Optional[str] = None) -> List[NonConformity]:
query = self._db.query(NonConformity)
if activity_id:
query = query.filter(NonConformity.activity_id == activity_id)
if status:
query = query.filter(NonConformity.status == status)
return query.all()
def get_nc(self, nc_id: int) -> NonConformity:
db_nc = self._db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if db_nc is None:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
return db_nc
def create_nc(self, nc: NonConformityCreate) -> NonConformity:
db_activity = self._db.query(Activity).filter(Activity.id == nc.activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail="Activity not found")
# Sync responsible email from contractor if not provided
if nc.contractor_id and not nc.responsible_email:
contractor = self._db.query(Contractor).filter(Contractor.id == nc.contractor_id).first()
if contractor and contractor.email:
nc.responsible_email = contractor.email
db_nc = NonConformity(**nc.model_dump())
self._db.add(db_nc)
self._db.commit()
self._db.refresh(db_nc)
return db_nc
def update_nc(self, nc_id: int, nc_update: NonConformityUpdate) -> NonConformity:
db_nc = self.get_nc(nc_id)
update_data = nc_update.model_dump(exclude_unset=True)
# Sync responsible email if contractor_id changes and email not explicitly provided
if 'contractor_id' in update_data and update_data['contractor_id']:
if 'responsible_email' not in update_data or not update_data['responsible_email']:
contractor = self._db.query(Contractor).filter(Contractor.id == update_data['contractor_id']).first()
if contractor and contractor.email:
update_data['responsible_email'] = contractor.email
for key, value in update_data.items():
setattr(db_nc, key, value)
self._db.commit()
self._db.refresh(db_nc)
return db_nc
def upload_nc_evidence(
self,
nc_id: int,
file: UploadFile,
description: Optional[str] = None,
captured_at: Optional[str] = None
) -> Evidence:
# Verify NC exists
self.get_nc(nc_id)
# Generate unique filename
file_ext = os.path.splitext(file.filename)[1]
unique_filename = f"nc_{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_filename)
# Save file
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
db_captured_at = None
if captured_at:
try:
db_captured_at = datetime.datetime.fromisoformat(captured_at.replace('Z', '+00:00'))
except:
db_captured_at = datetime.datetime.utcnow()
else:
db_captured_at = datetime.datetime.utcnow()
# Save to database
db_evidence = Evidence(
non_conformity_id=nc_id,
file_path=file_path,
media_type=file.content_type,
description=description,
captured_at=db_captured_at
)
self._db.add(db_evidence)
self._db.commit()
self._db.refresh(db_evidence)
return db_evidence
def delete_nc(self, nc_id: int):
db_nc = self.get_nc(nc_id)
self._db.delete(db_nc)
self._db.commit()
return {"detail": "Non-Conformity deleted"}
def notify_responsible(self, nc_id: int):
db_nc = self.get_nc(nc_id)
if not db_nc.responsible_email:
raise HTTPException(status_code=400, detail="No responsible email configured for this Non-Conformity")
# Generate hash if it doesn't exist
if not db_nc.access_hash:
db_nc.access_hash = str(uuid.uuid4())
self._db.commit()
# Send email
EmailService.send_nc_notification(db_nc.responsible_email, db_nc.access_hash, db_nc.description)
# Update status to in-checking
db_nc.status = 'in-checking'
self._db.commit()
self._db.refresh(db_nc)
return {"message": "Notification sent successfully", "access_hash": db_nc.access_hash, "status": db_nc.status}

View File

@ -0,0 +1,84 @@
from sqlalchemy.orm import Session
from typing import List, Optional
from fastapi import HTTPException
from app.models.project import Project
from app.models.specialty import Specialty
from app.models.contractor import Contractor
from app.schemas.project import ProjectCreate
class ProjectService:
def __init__(self, db: Session):
self._db = db
def get_projects(self, skip: int = 0, limit: int = 100) -> List[Project]:
return self._db.query(Project).offset(skip).limit(limit).all()
def get_project(self, project_id: int) -> Project:
db_project = self._db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
return db_project
def create_project(self, project: ProjectCreate) -> Project:
db_project = self._db.query(Project).filter(Project.code == project.code).first()
if db_project:
raise HTTPException(status_code=400, detail="Project code already exists")
project_data = project.model_dump(exclude={'specialty_ids', 'contractor_ids'})
db_project = Project(**project_data)
# Handle Parent Project
if project.parent_id:
parent = self.get_project(project.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Parent project not found")
# Handle Specialties
if project.specialty_ids:
specialties = self._db.query(Specialty).filter(Specialty.id.in_(project.specialty_ids)).all()
db_project.specialties = specialties
# Handle Contractors
if project.contractor_ids:
contractors = self._db.query(Contractor).filter(Contractor.id.in_(project.contractor_ids)).all()
db_project.contractors = contractors
self._db.add(db_project)
self._db.commit()
self._db.refresh(db_project)
return db_project
def update_project(self, project_id: int, project: ProjectCreate) -> Project:
db_project = self.get_project(project_id)
# Update simple fields
for key, value in project.model_dump(exclude={'specialty_ids', 'contractor_ids'}).items():
setattr(db_project, key, value)
# Handle Parent Project
if project.parent_id is not None:
if project.parent_id != 0:
self.get_project(project.parent_id)
db_project.parent_id = project.parent_id
else:
db_project.parent_id = None
# Update Specialties
if project.specialty_ids is not None:
specialties = self._db.query(Specialty).filter(Specialty.id.in_(project.specialty_ids)).all()
db_project.specialties = specialties
# Update Contractors
if project.contractor_ids is not None:
contractors = self._db.query(Contractor).filter(Contractor.id.in_(project.contractor_ids)).all()
db_project.contractors = contractors
self._db.commit()
self._db.refresh(db_project)
return db_project
def delete_project(self, project_id: int):
db_project = self.get_project(project_id)
self._db.delete(db_project)
self._db.commit()
return None

View File

@ -0,0 +1,38 @@
import os
import google.generativeai as genai
import tempfile
from fastapi import HTTPException, UploadFile
from app.core.config import settings
class TranscriptionService:
@staticmethod
async def transcribe_audio(file: UploadFile) -> str:
if not settings.GOOGLE_API_KEY:
return f"[MOCK GEMINI TRANSCRIPTION] Se ha recibido un archivo de audio de tipo {file.content_type}. Configure GOOGLE_API_KEY para transcripción real con Gemini."
try:
# Create a temporary file to store the upload
suffix = os.path.splitext(file.filename)[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
# Configure and upload
genai.configure(api_key=settings.GOOGLE_API_KEY)
audio_file = genai.upload_file(path=tmp_path, mime_type=file.content_type or "audio/wav")
# Use Gemini 2.0 Flash Lite
model = genai.GenerativeModel("gemini-2.0-flash-lite")
response = model.generate_content([
"Por favor, transcribe exactamente lo que se dice en este audio. Solo devuelve el texto transcrito.",
audio_file
])
# Cleanup
os.unlink(tmp_path)
return response.text
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -2,35 +2,33 @@ import os
import time
from sqlalchemy.orm import Session
import google.generativeai as genai
from app.models import Evidence
from app.database import SessionLocal
from app.models.evidence import Evidence
from app.core.config import settings
def process_transcription(evidence_id: int):
class TranscriptionWorker:
def __init__(self, db: Session):
self._db = db
def process_transcription(self, evidence_id: int):
"""
Background task to transcribe audio.
In a real scenario, this would call a local model runner like Ollama (Whisper)
or an external API like Gemini.
"""
db = SessionLocal()
try:
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
evidence = self._db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not evidence:
return
evidence.transcription_status = "processing"
db.commit()
# Simulate local processing or call a local model
# For now, we'll try to use Gemini if available, or a mock
self._db.commit()
file_path = evidence.file_path
if not os.path.exists(file_path):
evidence.transcription_status = "error"
evidence.transcription = "Error: File not found"
db.commit()
self._db.commit()
return
api_key = os.getenv("GOOGLE_API_KEY")
api_key = settings.GOOGLE_API_KEY
if api_key:
try:
genai.configure(api_key=api_key)
@ -38,8 +36,8 @@ def process_transcription(evidence_id: int):
# Upload to Gemini (Media Service)
audio_file = genai.upload_file(path=file_path, mime_type=evidence.media_type or "audio/wav")
# Use Gemini 1.5 Flash for audio-to-text
model = genai.GenerativeModel("gemini-2.5-flash-lite")
# Use Gemini 2.0 Flash Lite (as requested in original code, though it said 1.5 in comment)
model = genai.GenerativeModel("gemini-2.0-flash-lite")
response = model.generate_content([
"Por favor, transcribe exactamente lo que se dice en este audio. Solo devuelve el texto transcrito.",
audio_file
@ -51,21 +49,19 @@ def process_transcription(evidence_id: int):
evidence.transcription_status = "error"
evidence.transcription = f"Error: {str(e)}"
else:
# Mock transcription if no API key (Local Model Simulation)
# Mock transcription if no API key
time.sleep(5) # Simulate work
evidence.transcription = f"[LOCAL MOCK TRANSCRIPTION] Transcripción asíncrona completada para {os.path.basename(file_path)}"
evidence.transcription_status = "completed"
db.commit()
self._db.commit()
except Exception as e:
print(f"Transcription error: {e}")
try:
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
evidence = self._db.query(Evidence).filter(Evidence.id == evidence_id).first()
if evidence:
evidence.transcription_status = "error"
evidence.transcription = f"Unexpected error: {str(e)}"
db.commit()
self._db.commit()
except:
pass
finally:
db.close()

View File

@ -1,14 +1,18 @@
from sqlalchemy.orm import Session
from fastapi import HTTPException
from app.models.models import User
from app.security import get_password_hash
from app.schemas import UserCreate
from app.models.user import User
from app.core.security import get_password_hash
from app.schemas.user import UserCreate
def get_users(db: Session, skip: int = 0, limit: int = 100):
return db.query(User).offset(skip).limit(limit).all()
class UserService:
def __init__(self, db: Session):
self._db = db
def create_user(db: Session, user: UserCreate):
db_user = db.query(User).filter(User.email == user.email).first()
def get_users(self, skip: int = 0, limit: int = 100):
return self._db.query(User).offset(skip).limit(limit).all()
def create_user(self, user: UserCreate):
db_user = self._db.query(User).filter(User.email == user.email).first()
if db_user:
raise HTTPException(status_code=400, detail="Email already registered")
hashed_password = get_password_hash(user.password)
@ -18,7 +22,7 @@ def create_user(db: Session, user: UserCreate):
full_name=user.full_name,
role=user.role
)
db.add(db_user)
db.commit()
db.refresh(db_user)
self._db.add(db_user)
self._db.commit()
self._db.refresh(db_user)
return db_user

View File

@ -1,49 +1,34 @@
import sys
# Compatibility patch for importlib.metadata in Python < 3.10
# This fixes the AttributeError: module 'importlib.metadata' has no attribute 'packages_distributions'
if sys.version_info < (3, 10):
try:
import importlib_metadata
import importlib
importlib.metadata = importlib_metadata
except ImportError:
pass
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import auth, users, projects, activities, specialties, contractors, transcription, non_conformities, guest
import os
from fastapi.staticfiles import StaticFiles
import os
from app.api.v1.api import api_router
from app.core.config import settings
app = FastAPI(title="Sistema de Supervision API", version="0.1.0")
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json"
)
# CORS (allow all for dev)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[str(origin).strip("/") for origin in [settings.FRONTEND_URL, "*"]],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(projects.router)
app.include_router(activities.router)
app.include_router(specialties.router)
app.include_router(contractors.router)
app.include_router(transcription.router)
app.include_router(non_conformities.router)
app.include_router(guest.router)
app.include_router(api_router, prefix=settings.API_V1_STR)
# Mount uploads directory to serve files
if not os.path.exists("uploads"):
os.makedirs("uploads")
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
app.mount("/api/v1/uploads", StaticFiles(directory="uploads"), name="uploads")
@app.get("/")
def read_root():
return {"message": "Sistema de Supervision API is running"}
return {"message": f"{settings.PROJECT_NAME} is running"}
@app.get("/health")
def health_check():

View File

@ -1,3 +1,3 @@
export const environment = {
apiUrl: 'http://192.168.1.76:8000'
apiUrl: 'http://192.168.1.76:8000/api/v1'
};

View File

@ -1,3 +1,3 @@
export const environment = {
apiUrl: 'http://192.168.1.76:8000'
apiUrl: 'http://192.168.1.76:8000/api/v1'
};