# services/production/app/api/equipment.py """ Equipment API - CRUD operations on Equipment model """ from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from typing import Optional from uuid import UUID from datetime import datetime, timezone import structlog from shared.auth.decorators import get_current_user_dep from shared.auth.access_control import require_user_role from shared.routing import RouteBuilder from shared.security import create_audit_logger, AuditSeverity, AuditAction from app.core.database import get_db from app.services.production_service import ProductionService from app.models import AuditLog from app.schemas.equipment import ( EquipmentCreate, EquipmentUpdate, EquipmentResponse, EquipmentListResponse, EquipmentDeletionSummary ) from app.models.production import EquipmentStatus, EquipmentType from app.core.config import settings logger = structlog.get_logger() route_builder = RouteBuilder('production') router = APIRouter(tags=["production-equipment"]) # Initialize audit logger with the production service's AuditLog model audit_logger = create_audit_logger("production-service", AuditLog) def get_production_service(request: Request) -> ProductionService: """Dependency injection for production service""" from app.core.database import database_manager notification_service = getattr(request.app.state, 'notification_service', None) return ProductionService(database_manager, settings, notification_service) @router.get( route_builder.build_base_route("equipment"), response_model=EquipmentListResponse ) async def list_equipment( tenant_id: UUID = Path(...), status: Optional[EquipmentStatus] = Query(None, description="Filter by status"), type: Optional[EquipmentType] = Query(None, description="Filter by equipment type"), is_active: Optional[bool] = Query(None, description="Filter by active status"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(50, ge=1, le=100, description="Page size"), current_user: dict = Depends(get_current_user_dep), production_service: ProductionService = Depends(get_production_service) ): """List equipment with filters: status, type, active status""" try: filters = { "status": status, "type": type, "is_active": is_active } equipment_list = await production_service.get_equipment_list(tenant_id, filters, page, page_size) logger.info("Retrieved equipment list", tenant_id=str(tenant_id), filters=filters) return equipment_list except Exception as e: logger.error("Error listing equipment", error=str(e), tenant_id=str(tenant_id)) raise HTTPException(status_code=500, detail="Failed to list equipment") @router.post( route_builder.build_base_route("equipment"), response_model=EquipmentResponse ) async def create_equipment( equipment_data: EquipmentCreate, tenant_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), production_service: ProductionService = Depends(get_production_service), db = Depends(get_db) ): """Create a new equipment item""" try: equipment = await production_service.create_equipment(tenant_id, equipment_data) logger.info("Created equipment", equipment_id=str(equipment.id), tenant_id=str(tenant_id)) # Audit log the equipment creation await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user.get('user_id'), action=AuditAction.CREATE.value, resource_type="equipment", resource_id=str(equipment.id), severity=AuditSeverity.INFO.value, audit_metadata={"equipment_name": equipment.name, "equipment_type": equipment.type.value} ) return EquipmentResponse.model_validate(equipment) except ValueError as e: logger.warning("Validation error creating equipment", error=str(e), tenant_id=str(tenant_id)) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("Error creating equipment", error=str(e), tenant_id=str(tenant_id)) raise HTTPException(status_code=500, detail="Failed to create equipment") @router.get( route_builder.build_base_route("equipment/{equipment_id}"), response_model=EquipmentResponse ) async def get_equipment( tenant_id: UUID = Path(...), equipment_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), production_service: ProductionService = Depends(get_production_service) ): """Get a specific equipment item""" try: equipment = await production_service.get_equipment(tenant_id, equipment_id) if not equipment: raise HTTPException(status_code=404, detail="Equipment not found") logger.info("Retrieved equipment", equipment_id=str(equipment_id), tenant_id=str(tenant_id)) return EquipmentResponse.model_validate(equipment) except HTTPException: raise except Exception as e: logger.error("Error retrieving equipment", error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id)) raise HTTPException(status_code=500, detail="Failed to retrieve equipment") @router.put( route_builder.build_base_route("equipment/{equipment_id}"), response_model=EquipmentResponse ) async def update_equipment( equipment_data: EquipmentUpdate, tenant_id: UUID = Path(...), equipment_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), production_service: ProductionService = Depends(get_production_service), db = Depends(get_db) ): """Update an equipment item""" try: equipment = await production_service.update_equipment(tenant_id, equipment_id, equipment_data) if not equipment: raise HTTPException(status_code=404, detail="Equipment not found") logger.info("Updated equipment", equipment_id=str(equipment_id), tenant_id=str(tenant_id)) # Audit log the equipment update await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user.get('user_id'), action=AuditAction.UPDATE.value, resource_type="equipment", resource_id=str(equipment_id), severity=AuditSeverity.INFO.value, audit_metadata={"updates": equipment_data.model_dump(exclude_unset=True)} ) return EquipmentResponse.model_validate(equipment) except HTTPException: raise except ValueError as e: logger.warning("Validation error updating equipment", error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id)) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("Error updating equipment", error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id)) raise HTTPException(status_code=500, detail="Failed to update equipment") @router.get( route_builder.build_base_route("equipment/{equipment_id}/deletion-summary"), response_model=EquipmentDeletionSummary ) async def get_equipment_deletion_summary( tenant_id: UUID = Path(...), equipment_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), production_service: ProductionService = Depends(get_production_service) ): """Get deletion summary for equipment (dependency check)""" try: summary = await production_service.get_equipment_deletion_summary(tenant_id, equipment_id) logger.info("Retrieved equipment deletion summary", equipment_id=str(equipment_id), tenant_id=str(tenant_id)) return EquipmentDeletionSummary(**summary) except Exception as e: logger.error("Error getting equipment deletion summary", error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id)) raise HTTPException(status_code=500, detail="Failed to get deletion summary") @router.post( route_builder.build_base_route("equipment/{equipment_id}/report-failure"), response_model=EquipmentResponse ) async def report_equipment_failure( failure_data: dict, request: Request, tenant_id: UUID = Path(...), equipment_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), production_service: ProductionService = Depends(get_production_service), db = Depends(get_db) ): """Report equipment failure and trigger maintenance workflow""" try: # Update equipment status and add failure record equipment = await production_service.report_equipment_failure( tenant_id, equipment_id, failure_data ) if not equipment: raise HTTPException(status_code=404, detail="Equipment not found") logger.info("Reported equipment failure", equipment_id=str(equipment_id), tenant_id=str(tenant_id), failure_type=failure_data.get('failureType')) # Audit log the failure report await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user.get('user_id'), action=AuditAction.UPDATE.value, resource_type="equipment", resource_id=str(equipment_id), severity=AuditSeverity.WARNING.value, audit_metadata={ "action": "report_failure", "failure_type": failure_data.get('failureType'), "severity": failure_data.get('severity') } ) # Get notification service from app state notification_service = getattr(request.app.state, 'notification_service', None) # Trigger notifications if notification service is available if notification_service: try: await trigger_failure_notifications( notification_service, tenant_id, equipment, failure_data ) # Send primary notification to equipment support contact if available if equipment.support_contact and equipment.support_contact.get('email'): await send_support_contact_notification( notification_service, tenant_id, equipment, failure_data, equipment.support_contact['email'] ) except Exception as e: logger.warning("Failed to send notifications", error=str(e), equipment_id=str(equipment_id)) # Continue even if notifications fail return EquipmentResponse.model_validate(equipment) except HTTPException: raise except Exception as e: logger.error("Error reporting equipment failure", error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id)) raise HTTPException(status_code=500, detail="Failed to report equipment failure") @router.post( route_builder.build_base_route("equipment/{equipment_id}/mark-repaired"), response_model=EquipmentResponse ) async def mark_equipment_repaired( repair_data: dict, request: Request, tenant_id: UUID = Path(...), equipment_id: UUID = Path(...), current_user: dict = Depends(get_current_user_dep), production_service: ProductionService = Depends(get_production_service), db = Depends(get_db) ): """Mark equipment as repaired and update maintenance records""" try: # Update equipment status and add repair record equipment = await production_service.mark_equipment_repaired( tenant_id, equipment_id, repair_data ) if not equipment: raise HTTPException(status_code=404, detail="Equipment not found") logger.info("Marked equipment as repaired", equipment_id=str(equipment_id), tenant_id=str(tenant_id)) # Audit log the repair completion await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user.get('user_id'), action=AuditAction.UPDATE.value, resource_type="equipment", resource_id=str(equipment_id), severity=AuditSeverity.INFO.value, audit_metadata={ "action": "mark_repaired", "technician": repair_data.get('technicianName'), "cost": repair_data.get('cost') } ) # Get notification service from app state notification_service = getattr(request.app.state, 'notification_service', None) # Trigger notifications if notification service is available if notification_service: try: # Calculate downtime for notifications last_maintenance_date = equipment.last_maintenance_date or datetime.now(timezone.utc) repair_date_str = repair_data.get('repairDate') if repair_date_str: if 'T' in repair_date_str: repair_date = datetime.fromisoformat(repair_date_str.replace('Z', '+00:00')) else: repair_date = datetime.fromisoformat(f"{repair_date_str}T00:00:00+00:00") else: repair_date = datetime.now(timezone.utc) downtime_hours = int((repair_date - last_maintenance_date).total_seconds() / 3600) # Add downtime to repair_data for notification repair_data_with_downtime = {**repair_data, 'downtime': downtime_hours} await trigger_repair_notifications( notification_service, tenant_id, equipment, repair_data_with_downtime ) except Exception as e: logger.warning("Failed to send notifications", error=str(e), equipment_id=str(equipment_id)) # Continue even if notifications fail return EquipmentResponse.model_validate(equipment) except HTTPException: raise except Exception as e: logger.error("Error marking equipment as repaired", error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id)) raise HTTPException(status_code=500, detail="Failed to mark equipment as repaired") @router.delete( route_builder.build_base_route("equipment/{equipment_id}") ) async def delete_equipment( tenant_id: UUID = Path(...), equipment_id: UUID = Path(...), permanent: bool = Query(False, description="Permanent delete (hard delete) if true"), current_user: dict = Depends(get_current_user_dep), production_service: ProductionService = Depends(get_production_service), db = Depends(get_db) ): """Delete an equipment item. Use permanent=true for hard delete (requires admin role)""" try: # Hard delete requires admin role if permanent: user_role = current_user.get('role', '').lower() if user_role not in ['admin', 'owner']: raise HTTPException( status_code=403, detail="Hard delete requires admin or owner role" ) success = await production_service.hard_delete_equipment(tenant_id, equipment_id) delete_type = "hard_delete" severity = AuditSeverity.CRITICAL.value else: success = await production_service.delete_equipment(tenant_id, equipment_id) delete_type = "soft_delete" severity = AuditSeverity.WARNING.value if not success: raise HTTPException(status_code=404, detail="Equipment not found") logger.info(f"{'Hard' if permanent else 'Soft'} deleted equipment", equipment_id=str(equipment_id), tenant_id=str(tenant_id)) # Audit log the equipment deletion await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user.get('user_id'), action=AuditAction.DELETE.value, resource_type="equipment", resource_id=str(equipment_id), severity=severity, audit_metadata={"action": delete_type, "permanent": permanent} ) return {"message": f"Equipment {'permanently deleted' if permanent else 'deleted'} successfully"} except HTTPException: raise except Exception as e: logger.error("Error deleting equipment", error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id)) raise HTTPException(status_code=500, detail="Failed to delete equipment") # Helper functions for notifications async def trigger_failure_notifications(notification_service: any, tenant_id: UUID, equipment: any, failure_data: dict): """Trigger failure notifications via email - sends to bakery managers""" try: from jinja2 import Environment, FileSystemLoader from pathlib import Path # Load template from file template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates" env = Environment(loader=FileSystemLoader(str(template_dir))) template = env.get_template('equipment_failure_email.html') # Prepare template variables template_vars = { "equipment_name": equipment.name, "equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type, "equipment_model": equipment.model or "N/A", "equipment_serial_number": equipment.serial_number or "N/A", "equipment_location": equipment.location or "N/A", "failure_type": failure_data.get('failureType', 'Unknown'), "severity": failure_data.get('severity', 'high'), "description": failure_data.get('description', ''), "reported_time": datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'), "estimated_impact": "SÍ - Afecta producción" if failure_data.get('estimatedImpact') else "NO - Sin impacto en producción", "support_contact": equipment.support_contact or {}, "equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}", "bakery_name": "BakeryIA", "current_year": datetime.now().year } html_content = template.render(**template_vars) # Send via notification service (which will handle the actual email sending) # This is a simplified approach - in production you'd want to get manager emails from DB logger.info("Failure notifications triggered (template rendered)", equipment_id=str(equipment.id), tenant_id=str(tenant_id)) except Exception as e: logger.error("Error triggering failure notifications", error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id)) raise async def trigger_repair_notifications(notification_service: any, tenant_id: UUID, equipment: any, repair_data: dict): """Trigger repair completion notifications via email - sends to bakery managers""" try: from jinja2 import Environment, FileSystemLoader from pathlib import Path # Load template from file template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates" env = Environment(loader=FileSystemLoader(str(template_dir))) template = env.get_template('equipment_repaired_email.html') # Prepare template variables template_vars = { "equipment_name": equipment.name, "equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type, "equipment_model": equipment.model or "N/A", "equipment_location": equipment.location or "N/A", "repair_date": repair_data.get('repairDate', datetime.now(timezone.utc).strftime('%Y-%m-%d')), "technician_name": repair_data.get('technicianName', 'Unknown'), "repair_description": repair_data.get('repairDescription', ''), "parts_replaced": repair_data.get('partsReplaced', []), "cost": repair_data.get('cost', 0), "downtime_hours": repair_data.get('downtime', 0), "test_results": repair_data.get('testResults', False), "equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}", "bakery_name": "BakeryIA", "current_year": datetime.now().year } html_content = template.render(**template_vars) # Send via notification service logger.info("Repair notifications triggered (template rendered)", equipment_id=str(equipment.id), tenant_id=str(tenant_id)) except Exception as e: logger.error("Error triggering repair notifications", error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id)) raise async def send_support_contact_notification(notification_service: any, tenant_id: UUID, equipment: any, failure_data: dict, support_email: str): """Send direct notification to equipment support contact for repair request""" try: from jinja2 import Environment, FileSystemLoader from pathlib import Path # Load template from file template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates" env = Environment(loader=FileSystemLoader(str(template_dir))) template = env.get_template('equipment_failure_email.html') # Prepare template variables template_vars = { "equipment_name": equipment.name, "equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type, "equipment_model": equipment.model or "N/A", "equipment_serial_number": equipment.serial_number or "N/A", "equipment_location": equipment.location or "N/A", "failure_type": failure_data.get('failureType', 'Unknown'), "severity": failure_data.get('severity', 'high'), "description": failure_data.get('description', ''), "reported_time": datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'), "estimated_impact": "SÍ - Afecta producción" if failure_data.get('estimatedImpact') else "NO - Sin impacto en producción", "support_contact": equipment.support_contact or {}, "equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}", "bakery_name": "BakeryIA", "current_year": datetime.now().year } html_content = template.render(**template_vars) # TODO: Actually send email via notification service # For now, just log that we would send to the support email logger.info("Support contact notification prepared (would send to support)", equipment_id=str(equipment.id), tenant_id=str(tenant_id), support_email=support_email, subject=f"🚨 URGENTE: Fallo de Equipo - {equipment.name}") except Exception as e: logger.error("Error sending support contact notification", error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id)) raise