from datetime import UTC, datetime from sqlalchemy import Select, func, or_, select from sqlalchemy.orm import Session, selectinload from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate class RepairRepository: @staticmethod def query( *, status: str | None = None, q: str | None = None, customer_id: int | None = None, source: str | None = None, priority: str | None = None, ) -> Select[tuple[Repair]]: query = select(Repair).options(selectinload(Repair.history)) if status: query = query.where(Repair.status == status) if customer_id is not None: query = query.where(Repair.customer_id == customer_id) if source: query = query.where(Repair.source == source) if priority: query = query.where(Repair.priority == priority) if q: term = f"%{q.strip()}%" query = query.where( or_( Repair.repair_number.ilike(term), Repair.customer_name.ilike(term), Repair.device_manufacturer.ilike(term), Repair.device_model.ilike(term), Repair.fault_description.ilike(term), Repair.source_reference.ilike(term), ) ) return query @staticmethod def list( db: Session, *, status: str | None = None, q: str | None = None, customer_id: int | None = None, source: str | None = None, priority: str | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[Repair], int]: base_query = RepairRepository.query( status=status, q=q, customer_id=customer_id, source=source, priority=priority, ) total = db.scalar(select(func.count()).select_from(base_query.subquery())) or 0 items = list( db.scalars( base_query .order_by(Repair.created_at.desc(), Repair.id.desc()) .offset(offset) .limit(limit) ) ) return items, total @staticmethod def get_by_id(db: Session, repair_id: int) -> Repair | None: return db.scalar(select(Repair).options(selectinload(Repair.history)).where(Repair.id == repair_id)) @staticmethod def get_next_number(db: Session, year: int) -> str: prefix = f"R{year}-" latest = db.scalar( select(Repair.repair_number) .where(Repair.repair_number.like(f"{prefix}%")) .order_by(Repair.repair_number.desc()) .limit(1) ) next_number = 1 if latest: next_number = int(latest.split("-")[-1]) + 1 return f"{prefix}{next_number:06d}" @staticmethod def create(db: Session, payload: RepairCreate, *, repair_number: str) -> Repair: repair = Repair(repair_number=repair_number, **RepairRepository._payload_data(payload)) db.add(repair) db.flush() RepairRepository.add_history(db, repair.id, old_status=None, new_status=repair.status, note="Reparatur angelegt", actor_user_id=None) db.commit() db.refresh(repair) return RepairRepository.get_by_id(db, repair.id) or repair @staticmethod def update(db: Session, repair: Repair, payload: RepairUpdate) -> Repair: for key, value in RepairRepository._payload_data(payload).items(): setattr(repair, key, value) RepairRepository._apply_status_timestamps(repair) db.commit() db.refresh(repair) return RepairRepository.get_by_id(db, repair.id) or repair @staticmethod def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor_user_id: int | None) -> Repair: old_status = repair.status repair.status = payload.status RepairRepository._apply_status_timestamps(repair) RepairRepository.add_history( db, repair.id, old_status=old_status, new_status=payload.status, note=payload.note, actor_user_id=actor_user_id, ) db.commit() db.refresh(repair) return RepairRepository.get_by_id(db, repair.id) or repair @staticmethod def cancel(db: Session, repair: Repair, *, actor_user_id: int | None) -> Repair: old_status = repair.status repair.status = "cancelled" repair.completed_at = datetime.now(UTC) RepairRepository.add_history( db, repair.id, old_status=old_status, new_status="cancelled", note="Reparatur storniert", actor_user_id=actor_user_id, ) db.commit() db.refresh(repair) return repair @staticmethod def add_history(db: Session, repair_id: int, *, old_status: str | None, new_status: str, note: str | None, actor_user_id: int | None) -> RepairStatusHistory: history = RepairStatusHistory( repair_id=repair_id, old_status=old_status, new_status=new_status, note=note, actor_user_id=actor_user_id, ) db.add(history) return history @staticmethod def get_history(db: Session, repair_id: int) -> list[RepairStatusHistory]: return list( db.scalars( select(RepairStatusHistory) .options(selectinload(RepairStatusHistory.actor)) .where(RepairStatusHistory.repair_id == repair_id) .order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc()) ) ) @staticmethod def get_history_public(db: Session, repair_id: int) -> list[RepairStatusHistory]: return list( db.scalars( select(RepairStatusHistory) .where(RepairStatusHistory.repair_id == repair_id) .order_by(RepairStatusHistory.created_at.asc(), RepairStatusHistory.id.asc()) ) ) @staticmethod def count_by_status(db: Session, status: str) -> int: return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0 @staticmethod def create_intake_event( db: Session, *, external_source: str, external_reference: str | None, payload: dict, status: str, error_message: str | None = None, repair_id: int | None = None, processed_at: datetime | None = None, ) -> RepairIntakeEvent: event = RepairIntakeEvent( external_source=external_source, external_reference=external_reference, payload=payload, status=status, error_message=error_message, repair_id=repair_id, processed_at=processed_at, ) db.add(event) db.commit() db.refresh(event) return event @staticmethod def update_intake_event(db: Session, event: RepairIntakeEvent, *, status: str, repair_id: int | None = None, error_message: str | None = None) -> RepairIntakeEvent: event.status = status event.repair_id = repair_id event.error_message = error_message event.processed_at = datetime.now(UTC) db.commit() db.refresh(event) return event @staticmethod def get_active_public_link(db: Session, repair_id: int) -> RepairPublicAccessToken | None: now = datetime.now(UTC) return db.scalar( select(RepairPublicAccessToken) .where(RepairPublicAccessToken.repair_id == repair_id) .where(RepairPublicAccessToken.is_active.is_(True)) .where(RepairPublicAccessToken.revoked_at.is_(None)) .where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now)) .order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc()) .limit(1) ) @staticmethod def get_public_link_by_hash(db: Session, token_hash: str) -> RepairPublicAccessToken | None: now = datetime.now(UTC) return db.scalar( select(RepairPublicAccessToken) .options(selectinload(RepairPublicAccessToken.repair)) .where(RepairPublicAccessToken.token_hash == token_hash) .where(RepairPublicAccessToken.is_active.is_(True)) .where(RepairPublicAccessToken.revoked_at.is_(None)) .where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now)) .limit(1) ) @staticmethod def create_public_link( db: Session, *, repair_id: int, token_hash: str, token_hint: str, expires_at: datetime | None = None, ) -> RepairPublicAccessToken: RepairRepository.revoke_public_links(db, repair_id=repair_id, commit=False) public_link = RepairPublicAccessToken( repair_id=repair_id, token_hash=token_hash, token_hint=token_hint, expires_at=expires_at, ) db.add(public_link) db.commit() db.refresh(public_link) return public_link @staticmethod def revoke_public_links(db: Session, *, repair_id: int, commit: bool = True) -> None: now = datetime.now(UTC) links = list( db.scalars( select(RepairPublicAccessToken) .where(RepairPublicAccessToken.repair_id == repair_id) .where(RepairPublicAccessToken.is_active.is_(True)) ) ) for link in links: link.is_active = False link.revoked_at = now if commit: db.commit() @staticmethod def mark_public_link_used(db: Session, public_link: RepairPublicAccessToken) -> None: public_link.last_used_at = datetime.now(UTC) db.commit() @staticmethod def list_notification_events(db: Session, repair_id: int) -> list[RepairNotificationEvent]: return list( db.scalars( select(RepairNotificationEvent) .where(RepairNotificationEvent.repair_id == repair_id) .order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc()) ) ) @staticmethod def create_notification_event( db: Session, *, repair_id: int, event_type: str, channel: str, recipient: str, subject: str, template: str, status: str, success: bool, error_message: str | None = None, sent_at: datetime | None = None, ) -> RepairNotificationEvent: event = RepairNotificationEvent( repair_id=repair_id, event_type=event_type, channel=channel, recipient=recipient, subject=subject, template=template, status=status, success=success, error_message=error_message, sent_at=sent_at, ) db.add(event) db.commit() db.refresh(event) return event @staticmethod def count_status_mails_sent_today(db: Session) -> int: today = datetime.now(UTC).date() return db.scalar( select(func.count(RepairNotificationEvent.id)) .where(RepairNotificationEvent.event_type == "repair_status_mail") .where(RepairNotificationEvent.success.is_(True)) .where(func.date(RepairNotificationEvent.created_at) == today) ) or 0 @staticmethod def count_failed_status_mails(db: Session) -> int: return db.scalar( select(func.count(RepairNotificationEvent.id)) .where(RepairNotificationEvent.event_type == "repair_status_mail") .where(RepairNotificationEvent.success.is_(False)) .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) ) or 0 @staticmethod def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None: return db.scalar( select(RepairNotificationEvent) .where(RepairNotificationEvent.event_type == "repair_status_mail") .where(RepairNotificationEvent.success.is_(False)) .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) .order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc()) .limit(1) ) @staticmethod def count_open_repairs_without_customer_email(db: Session) -> int: return db.scalar( select(func.count(Repair.id)) .where(~Repair.status.in_(["completed", "cancelled"])) .where((Repair.customer_email == "") | Repair.customer_email.is_(None)) ) or 0 @staticmethod def list_documents(db: Session, repair_id: int) -> list[RepairDocument]: return list( db.scalars( select(RepairDocument) .where(RepairDocument.repair_id == repair_id) .order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc()) ) ) @staticmethod def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None: return db.scalar( select(RepairDocument) .where(RepairDocument.repair_id == repair_id) .where(RepairDocument.id == document_id) ) @staticmethod def create_document( db: Session, *, repair_id: int, title: str, document_type: str, original_filename: str, stored_filename: str, storage_path: str, mime_type: str, size_bytes: int, checksum_sha256: str, visibility: str, note: str | None, uploaded_by_user_id: int | None, ) -> RepairDocument: document = RepairDocument( repair_id=repair_id, title=title, document_type=document_type, original_filename=original_filename, stored_filename=stored_filename, storage_path=storage_path, mime_type=mime_type, size_bytes=size_bytes, checksum_sha256=checksum_sha256, visibility=visibility, note=note, uploaded_by_user_id=uploaded_by_user_id, ) db.add(document) db.commit() db.refresh(document) return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document @staticmethod def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument: for key, value in payload.model_dump().items(): setattr(document, key, value) db.commit() db.refresh(document) return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document @staticmethod def delete_document(db: Session, document: RepairDocument) -> None: db.delete(document) db.commit() @staticmethod def count_documents(db: Session) -> int: return db.scalar(select(func.count(RepairDocument.id))) or 0 @staticmethod def _payload_data(payload: RepairCreate | RepairUpdate) -> dict: data = payload.model_dump() data["customer_email"] = str(payload.customer_email or "") data["device_serial_number"] = payload.device_serial_number or None data["source_reference"] = payload.source_reference or None return data @staticmethod def _apply_status_timestamps(repair: Repair) -> None: if repair.status == "approved" and repair.approved_at is None: repair.approved_at = datetime.now(UTC) if repair.status in {"completed", "cancelled"} and repair.completed_at is None: repair.completed_at = datetime.now(UTC)