From ef13c211adc22ab7bb6c8ca550adfd26a07c7ff3 Mon Sep 17 00:00:00 2001 From: Luis Sanchez Date: Mon, 29 Dec 2025 11:56:46 -0500 Subject: [PATCH] Arquitectura organizada en capas --- .gitignore | 1 + backend/alembic/env.py | 4 +- backend/app/api/__init__.py | 0 backend/app/api/deps.py | 44 ++++ backend/app/api/v1/__init__.py | 0 backend/app/api/v1/api.py | 26 +++ backend/app/api/v1/endpoints/__init__.py | 0 backend/app/api/v1/endpoints/activities.py | 87 +++++++ backend/app/api/v1/endpoints/auth.py | 22 ++ backend/app/api/v1/endpoints/contractors.py | 50 ++++ backend/app/api/v1/endpoints/guest.py | 36 +++ .../app/api/v1/endpoints/non_conformities.py | 67 ++++++ backend/app/api/v1/endpoints/projects.py | 48 ++++ backend/app/api/v1/endpoints/specialties.py | 12 + backend/app/api/v1/endpoints/transcription.py | 9 + backend/app/api/v1/endpoints/users.py | 29 +++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 46 ++++ backend/app/core/security.py | 38 ++++ backend/app/db/base.py | 4 + backend/app/db/database.py | 23 +- backend/app/init_db.py | 2 +- backend/app/models/__init__.py | 7 + backend/app/models/activity.py | 39 ++++ backend/app/models/contractor.py | 22 ++ backend/app/models/evidence.py | 23 ++ backend/app/models/models.py | 184 +-------------- backend/app/models/non_conformity.py | 46 ++++ backend/app/models/project.py | 38 ++++ backend/app/models/specialty.py | 8 + backend/app/models/user.py | 21 ++ backend/app/routers/activities.py | 147 ------------ backend/app/routers/auth.py | 25 -- backend/app/routers/contractors.py | 75 ------ backend/app/routers/guest.py | 97 -------- backend/app/routers/non_conformities.py | 186 --------------- backend/app/routers/projects.py | 95 -------- backend/app/routers/specialties.py | 17 -- backend/app/routers/transcription.py | 54 ----- backend/app/routers/users.py | 27 --- backend/app/schemas.py | 213 ++---------------- backend/app/schemas/__init__.py | 8 + backend/app/schemas/activity.py | 44 ++++ backend/app/schemas/contractor.py | 43 ++++ backend/app/schemas/evidence.py | 21 ++ backend/app/schemas/non_conformity.py | 46 ++++ backend/app/schemas/project.py | 27 +++ backend/app/schemas/specialty.py | 9 + backend/app/schemas/token.py | 9 + backend/app/schemas/user.py | 18 ++ backend/app/security.py | 22 +- backend/app/services/activities.py | 99 +++++--- backend/app/services/auth_service.py | 29 +++ backend/app/services/contractors.py | 55 +++++ backend/app/services/email_service.py | 20 +- backend/app/services/guest.py | 72 ++++++ backend/app/services/non_conformities.py | 140 ++++++++++++ backend/app/services/projects.py | 84 +++++++ backend/app/services/transcription.py | 38 ++++ backend/app/services/transcription_worker.py | 122 +++++----- backend/app/services/users.py | 44 ++-- backend/main.py | 39 +--- .../environments/environment.development.ts | 2 +- frontend/src/environments/environment.ts | 2 +- 64 files changed, 1595 insertions(+), 1270 deletions(-) create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/api.py create mode 100644 backend/app/api/v1/endpoints/__init__.py create mode 100644 backend/app/api/v1/endpoints/activities.py create mode 100644 backend/app/api/v1/endpoints/auth.py create mode 100644 backend/app/api/v1/endpoints/contractors.py create mode 100644 backend/app/api/v1/endpoints/guest.py create mode 100644 backend/app/api/v1/endpoints/non_conformities.py create mode 100644 backend/app/api/v1/endpoints/projects.py create mode 100644 backend/app/api/v1/endpoints/specialties.py create mode 100644 backend/app/api/v1/endpoints/transcription.py create mode 100644 backend/app/api/v1/endpoints/users.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/activity.py create mode 100644 backend/app/models/contractor.py create mode 100644 backend/app/models/evidence.py create mode 100644 backend/app/models/non_conformity.py create mode 100644 backend/app/models/project.py create mode 100644 backend/app/models/specialty.py create mode 100644 backend/app/models/user.py delete mode 100644 backend/app/routers/activities.py delete mode 100644 backend/app/routers/auth.py delete mode 100644 backend/app/routers/contractors.py delete mode 100644 backend/app/routers/guest.py delete mode 100644 backend/app/routers/non_conformities.py delete mode 100644 backend/app/routers/projects.py delete mode 100644 backend/app/routers/specialties.py delete mode 100644 backend/app/routers/transcription.py delete mode 100644 backend/app/routers/users.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/activity.py create mode 100644 backend/app/schemas/contractor.py create mode 100644 backend/app/schemas/evidence.py create mode 100644 backend/app/schemas/non_conformity.py create mode 100644 backend/app/schemas/project.py create mode 100644 backend/app/schemas/specialty.py create mode 100644 backend/app/schemas/token.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/contractors.py create mode 100644 backend/app/services/guest.py create mode 100644 backend/app/services/non_conformities.py create mode 100644 backend/app/services/projects.py create mode 100644 backend/app/services/transcription.py diff --git a/.gitignore b/.gitignore index f5d4173..66bf286 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ backend/venv/** backend/alembic/.DS_Store diseño.md backend/uploads/** +**/__pycache__ diff --git a/backend/alembic/env.py b/backend/alembic/env.py index fc80c41..eb2882e 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..f1b517f --- /dev/null +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py new file mode 100644 index 0000000..16c1deb --- /dev/null +++ b/backend/app/api/v1/api.py @@ -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"]) diff --git a/backend/app/api/v1/endpoints/__init__.py b/backend/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/endpoints/activities.py b/backend/app/api/v1/endpoints/activities.py new file mode 100644 index 0000000..428cf6b --- /dev/null +++ b/backend/app/api/v1/endpoints/activities.py @@ -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) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..f4126c9 --- /dev/null +++ b/backend/app/api/v1/endpoints/auth.py @@ -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) diff --git a/backend/app/api/v1/endpoints/contractors.py b/backend/app/api/v1/endpoints/contractors.py new file mode 100644 index 0000000..fefb93b --- /dev/null +++ b/backend/app/api/v1/endpoints/contractors.py @@ -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) diff --git a/backend/app/api/v1/endpoints/guest.py b/backend/app/api/v1/endpoints/guest.py new file mode 100644 index 0000000..07cbabc --- /dev/null +++ b/backend/app/api/v1/endpoints/guest.py @@ -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) diff --git a/backend/app/api/v1/endpoints/non_conformities.py b/backend/app/api/v1/endpoints/non_conformities.py new file mode 100644 index 0000000..53fcfb6 --- /dev/null +++ b/backend/app/api/v1/endpoints/non_conformities.py @@ -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) diff --git a/backend/app/api/v1/endpoints/projects.py b/backend/app/api/v1/endpoints/projects.py new file mode 100644 index 0000000..581b692 --- /dev/null +++ b/backend/app/api/v1/endpoints/projects.py @@ -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) diff --git a/backend/app/api/v1/endpoints/specialties.py b/backend/app/api/v1/endpoints/specialties.py new file mode 100644 index 0000000..e1ebdbb --- /dev/null +++ b/backend/app/api/v1/endpoints/specialties.py @@ -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() diff --git a/backend/app/api/v1/endpoints/transcription.py b/backend/app/api/v1/endpoints/transcription.py new file mode 100644 index 0000000..dba8915 --- /dev/null +++ b/backend/app/api/v1/endpoints/transcription.py @@ -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} diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..b25da41 --- /dev/null +++ b/backend/app/api/v1/endpoints/users.py @@ -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 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e9404c3 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..e03523d --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..974954b --- /dev/null +++ b/backend/app/db/base.py @@ -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 diff --git a/backend/app/db/database.py b/backend/app/db/database.py index e3cacec..356e706 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -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() diff --git a/backend/app/init_db.py b/backend/app/init_db.py index 942ad47..a7855a6 100644 --- a/backend/app/init_db.py +++ b/backend/app/init_db.py @@ -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(): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..89bc113 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/activity.py b/backend/app/models/activity.py new file mode 100644 index 0000000..c21c8f4 --- /dev/null +++ b/backend/app/models/activity.py @@ -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") diff --git a/backend/app/models/contractor.py b/backend/app/models/contractor.py new file mode 100644 index 0000000..65b9665 --- /dev/null +++ b/backend/app/models/contractor.py @@ -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") diff --git a/backend/app/models/evidence.py b/backend/app/models/evidence.py new file mode 100644 index 0000000..35c950f --- /dev/null +++ b/backend/app/models/evidence.py @@ -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") diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 041e69e..8c0f44b 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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 diff --git a/backend/app/models/non_conformity.py b/backend/app/models/non_conformity.py new file mode 100644 index 0000000..2662d58 --- /dev/null +++ b/backend/app/models/non_conformity.py @@ -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") diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..a5b3763 --- /dev/null +++ b/backend/app/models/project.py @@ -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) diff --git a/backend/app/models/specialty.py b/backend/app/models/specialty.py new file mode 100644 index 0000000..2d5942c --- /dev/null +++ b/backend/app/models/specialty.py @@ -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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..d18d235 --- /dev/null +++ b/backend/app/models/user.py @@ -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) diff --git a/backend/app/routers/activities.py b/backend/app/routers/activities.py deleted file mode 100644 index ada3bc2..0000000 --- a/backend/app/routers/activities.py +++ /dev/null @@ -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"} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py deleted file mode 100644 index 20d5052..0000000 --- a/backend/app/routers/auth.py +++ /dev/null @@ -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"} diff --git a/backend/app/routers/contractors.py b/backend/app/routers/contractors.py deleted file mode 100644 index 93a9ea4..0000000 --- a/backend/app/routers/contractors.py +++ /dev/null @@ -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 diff --git a/backend/app/routers/guest.py b/backend/app/routers/guest.py deleted file mode 100644 index 676fb10..0000000 --- a/backend/app/routers/guest.py +++ /dev/null @@ -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 diff --git a/backend/app/routers/non_conformities.py b/backend/app/routers/non_conformities.py deleted file mode 100644 index 4c62e68..0000000 --- a/backend/app/routers/non_conformities.py +++ /dev/null @@ -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} diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py deleted file mode 100644 index e3df68c..0000000 --- a/backend/app/routers/projects.py +++ /dev/null @@ -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 diff --git a/backend/app/routers/specialties.py b/backend/app/routers/specialties.py deleted file mode 100644 index 1301a2d..0000000 --- a/backend/app/routers/specialties.py +++ /dev/null @@ -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() diff --git a/backend/app/routers/transcription.py b/backend/app/routers/transcription.py deleted file mode 100644 index fe116cd..0000000 --- a/backend/app/routers/transcription.py +++ /dev/null @@ -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)) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py deleted file mode 100644 index 7c4ac7a..0000000 --- a/backend/app/routers/users.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 09f5ead..cadd1d1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..ee909a9 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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 diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py new file mode 100644 index 0000000..1553510 --- /dev/null +++ b/backend/app/schemas/activity.py @@ -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 diff --git a/backend/app/schemas/contractor.py b/backend/app/schemas/contractor.py new file mode 100644 index 0000000..213d5d5 --- /dev/null +++ b/backend/app/schemas/contractor.py @@ -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 diff --git a/backend/app/schemas/evidence.py b/backend/app/schemas/evidence.py new file mode 100644 index 0000000..88a4f78 --- /dev/null +++ b/backend/app/schemas/evidence.py @@ -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 diff --git a/backend/app/schemas/non_conformity.py b/backend/app/schemas/non_conformity.py new file mode 100644 index 0000000..9159971 --- /dev/null +++ b/backend/app/schemas/non_conformity.py @@ -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 diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..0dbc709 --- /dev/null +++ b/backend/app/schemas/project.py @@ -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 diff --git a/backend/app/schemas/specialty.py b/backend/app/schemas/specialty.py new file mode 100644 index 0000000..5c8b042 --- /dev/null +++ b/backend/app/schemas/specialty.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +class SpecialtyBase(BaseModel): + name: str + +class Specialty(SpecialtyBase): + id: int + class Config: + from_attributes = True diff --git a/backend/app/schemas/token.py b/backend/app/schemas/token.py new file mode 100644 index 0000000..5796abc --- /dev/null +++ b/backend/app/schemas/token.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..66c61f5 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/security.py b/backend/app/security.py index 7bc43ac..aadd11e 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -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): - return pwd_context.verify(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() diff --git a/backend/app/services/activities.py b/backend/app/services/activities.py index ff8fc68..613bdbd 100644 --- a/backend/app/services/activities.py +++ b/backend/app/services/activities.py @@ -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 \ No newline at end of file + 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"} \ No newline at end of file diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..7eda580 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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) \ No newline at end of file diff --git a/backend/app/services/contractors.py b/backend/app/services/contractors.py new file mode 100644 index 0000000..9c16d7b --- /dev/null +++ b/backend/app/services/contractors.py @@ -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 diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index d17d1a2..474de52 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -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.") diff --git a/backend/app/services/guest.py b/backend/app/services/guest.py new file mode 100644 index 0000000..bc953fd --- /dev/null +++ b/backend/app/services/guest.py @@ -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 diff --git a/backend/app/services/non_conformities.py b/backend/app/services/non_conformities.py new file mode 100644 index 0000000..54bbd89 --- /dev/null +++ b/backend/app/services/non_conformities.py @@ -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} diff --git a/backend/app/services/projects.py b/backend/app/services/projects.py new file mode 100644 index 0000000..90e5350 --- /dev/null +++ b/backend/app/services/projects.py @@ -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 diff --git a/backend/app/services/transcription.py b/backend/app/services/transcription.py new file mode 100644 index 0000000..f83a581 --- /dev/null +++ b/backend/app/services/transcription.py @@ -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)) diff --git a/backend/app/services/transcription_worker.py b/backend/app/services/transcription_worker.py index 512530b..67d3c5b 100644 --- a/backend/app/services/transcription_worker.py +++ b/backend/app/services/transcription_worker.py @@ -2,70 +2,66 @@ 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): - """ - 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() - if not evidence: - return +class TranscriptionWorker: + def __init__(self, db: Session): + self._db = db - 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 - - file_path = evidence.file_path - if not os.path.exists(file_path): - evidence.transcription_status = "error" - evidence.transcription = "Error: File not found" - db.commit() - return - - api_key = os.getenv("GOOGLE_API_KEY") - if api_key: - try: - genai.configure(api_key=api_key) - - # 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") - response = model.generate_content([ - "Por favor, transcribe exactamente lo que se dice en este audio. Solo devuelve el texto transcrito.", - audio_file - ]) - - evidence.transcription = response.text - evidence.transcription_status = "completed" - except Exception as e: - evidence.transcription_status = "error" - evidence.transcription = f"Error: {str(e)}" - else: - # Mock transcription if no API key (Local Model Simulation) - 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() - except Exception as e: - print(f"Transcription error: {e}") + def process_transcription(self, evidence_id: int): + """ + Background task to transcribe audio. + """ try: - evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first() - if evidence: + evidence = self._db.query(Evidence).filter(Evidence.id == evidence_id).first() + if not evidence: + return + + evidence.transcription_status = "processing" + self._db.commit() + + file_path = evidence.file_path + if not os.path.exists(file_path): evidence.transcription_status = "error" - evidence.transcription = f"Unexpected error: {str(e)}" - db.commit() - except: - pass - finally: - db.close() + evidence.transcription = "Error: File not found" + self._db.commit() + return + + api_key = settings.GOOGLE_API_KEY + if api_key: + try: + genai.configure(api_key=api_key) + + # Upload to Gemini (Media Service) + audio_file = genai.upload_file(path=file_path, mime_type=evidence.media_type or "audio/wav") + + # 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 + ]) + + evidence.transcription = response.text + evidence.transcription_status = "completed" + except Exception as e: + evidence.transcription_status = "error" + evidence.transcription = f"Error: {str(e)}" + else: + # 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" + + self._db.commit() + except Exception as e: + print(f"Transcription error: {e}") + try: + evidence = self._db.query(Evidence).filter(Evidence.id == evidence_id).first() + if evidence: + evidence.transcription_status = "error" + evidence.transcription = f"Unexpected error: {str(e)}" + self._db.commit() + except: + pass diff --git a/backend/app/services/users.py b/backend/app/services/users.py index 747ff6c..87b5a42 100644 --- a/backend/app/services/users.py +++ b/backend/app/services/users.py @@ -1,24 +1,28 @@ 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() - if db_user: - raise HTTPException(status_code=400, detail="Email already registered") - hashed_password = get_password_hash(user.password) - db_user = User( - email=user.email, - hashed_password=hashed_password, - full_name=user.full_name, - role=user.role - ) - db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user \ No newline at end of file + 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) + db_user = User( + email=user.email, + hashed_password=hashed_password, + full_name=user.full_name, + role=user.role + ) + self._db.add(db_user) + self._db.commit() + self._db.refresh(db_user) + return db_user \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index af81544..31821e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(): diff --git a/frontend/src/environments/environment.development.ts b/frontend/src/environments/environment.development.ts index e1ee2b8..f374a03 100644 --- a/frontend/src/environments/environment.development.ts +++ b/frontend/src/environments/environment.development.ts @@ -1,3 +1,3 @@ export const environment = { - apiUrl: 'http://192.168.1.76:8000' + apiUrl: 'http://192.168.1.76:8000/api/v1' }; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index e1ee2b8..f374a03 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -1,3 +1,3 @@ export const environment = { - apiUrl: 'http://192.168.1.76:8000' + apiUrl: 'http://192.168.1.76:8000/api/v1' };