Files
citypulse/backend/app/services/ticket_service.py
Zahar 77d5be8fd1 feat(api,ui,db): add address, guest users, image URLs; update API
- Backend:
  - Add address column to tickets and migration script
  - Create guest users when user_id is missing; accept user_name and address
  - Normalize stored image paths and expose absolute image_url
  - Introduce utils for path normalization and ticket serialization
  - Add CORS configuration for dashboard/emulator origins
  - Tickets API:
    - Serialize via ticket_to_dict with consistent schema
    - Change status update to PATCH /api/tickets/{id}/status with JSON body
    - Add DELETE /api/tickets/{id} with safe file removal
- Dashboard:
  - Fetch tickets from backend, show thumbnails, absolute image URLs
  - Status select + PATCH updates, toasts for feedback
  - Add i18n key btn.viewDetails
- Mobile app:
  - Persist device user_id via SharedPreferences
  - Fetch and merge API tickets; prefer network imageUrl
  - Submit user_name and address; delete via API when available
  - Make location acquisition robust with fallbacks and non-blocking UX
- Android/deps:
  - Disable Geolocator NMEA listener to prevent crashes
  - Downgrade geolocator to ^11.0.0 for stability

BREAKING CHANGE:
- Status endpoint changed from PATCH /api/tickets/{id}?new_status=... to
  PATCH /api/tickets/{id}/status with JSON body: {"status":"in_progress"}.
- /api/tickets and /api/tickets/{id} responses now use "id" (replacing
  "ticket_id"), include "image_url", and normalize fields for clients. Update
  consumers to use the new schema.
2025-09-27 09:31:40 +08:00

171 lines
6.0 KiB
Python

# app/services/ticket_service.py
import uuid
from typing import List, Optional
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy.exc import NoResultFound
from app.models.ticket_model import User, Ticket, TicketAudit, TicketStatus, SeverityLevel
from app.utils import normalize_image_path_for_url, UPLOADS_DIR_RESOLVED
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ----------------------
# Ticket Service
# ----------------------
class TicketService:
def __init__(self, db: Session):
self.db = db
# ------------------
# User Operations
# ------------------
def create_user(self, name: str, email: str) -> User:
user = User(name=name, email=email)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
logger.info(f"Created user {user}")
return user # <-- return User object
def get_user(self, user_id: str) -> Optional[User]:
return self.db.query(User).filter(User.id == user_id).first()
# ------------------
# Ticket Operations
# ------------------
def create_ticket(
self,
user_id: str,
image_path: str,
category: str,
severity: SeverityLevel,
latitude: float,
longitude: float,
description: str = "",
address: Optional[str] = None,
) -> Ticket:
"""
Create a Ticket record.
image_path should be a relative POSIX path (e.g. 'static/uploads/uuid.jpg').
report.route uses Path.as_posix() to ensure forward slashes on save.
"""
# Normalize stored path to POSIX
image_path_posix = Path(str(image_path)).as_posix() if image_path else None
ticket = Ticket(
id=str(uuid.uuid4()),
user_id=user_id,
image_path=image_path_posix,
category=category,
severity=severity,
latitude=latitude,
longitude=longitude,
description=description,
address=address,
)
self.db.add(ticket)
self.db.commit()
self.db.refresh(ticket)
logger.info(f"Created ticket {ticket}")
return ticket
def update_ticket_status(self, ticket_id: str, new_status: TicketStatus) -> Ticket:
ticket = self.db.query(Ticket).filter(Ticket.id == ticket_id).first()
if not ticket:
raise NoResultFound(f"Ticket with id {ticket_id} not found")
# Log audit
audit = TicketAudit(
ticket_id=ticket.id,
old_status=ticket.status,
new_status=new_status,
)
self.db.add(audit)
# Update status
ticket.status = new_status
self.db.commit()
self.db.refresh(ticket)
logger.info(f"Updated ticket {ticket.id} status to {new_status}")
return ticket
def get_ticket(self, ticket_id: str) -> Optional[Ticket]:
return self.db.query(Ticket).filter(Ticket.id == ticket_id).first()
def list_tickets(
self,
user_id: Optional[str] = None,
category: Optional[str] = None,
severity: Optional[SeverityLevel] = None,
status: Optional[TicketStatus] = None
) -> List[Ticket]:
"""
Return tickets. By default returns all tickets unless optional filters are provided.
"""
query = self.db.query(Ticket)
if user_id:
query = query.filter(Ticket.user_id == user_id)
if category:
query = query.filter(Ticket.category == category)
if severity:
query = query.filter(Ticket.severity == severity)
if status:
query = query.filter(Ticket.status == status)
return query.order_by(Ticket.created_at.desc()).all()
def delete_ticket(self, ticket_id: str) -> bool:
"""
Delete a ticket and its associated image file if it exists.
Safety:
- Normalize stored image_path to a relative POSIX path under the static/ mount using
normalize_image_path_for_url().
- Resolve the resulting path and only delete if the resolved path is under the configured
uploads directory (UPLOADS_DIR_RESOLVED) to prevent path traversal.
"""
ticket = self.db.query(Ticket).filter(Ticket.id == ticket_id).first()
if not ticket:
raise NoResultFound(f"Ticket with id {ticket_id} not found")
# Attempt to delete the image file if present
try:
rel = normalize_image_path_for_url(ticket.image_path)
if rel:
file_path = Path(rel)
# Resolve to absolute path safely (works if file missing too)
try:
absolute = file_path.resolve()
except Exception:
absolute = (Path.cwd() / file_path).resolve()
# Ensure the file is inside the uploads directory
try:
absolute.relative_to(UPLOADS_DIR_RESOLVED)
inside_uploads = True
except Exception:
inside_uploads = False
if inside_uploads and absolute.exists():
try:
absolute.unlink()
logger.info(f"Deleted image file: {absolute}")
except Exception as e:
logger.warning(f"Failed to delete image file {absolute}: {e}")
else:
logger.debug(f"Image file not deleted (missing or outside uploads): {absolute}")
except Exception as e:
logger.exception(f"Error while attempting to remove image for ticket {ticket_id}: {e}")
# Delete ticket record
try:
self.db.delete(ticket)
self.db.commit()
logger.info(f"Deleted ticket {ticket_id}")
return True
except Exception as e:
logger.exception(f"Failed to delete ticket {ticket_id} from DB: {e}")
self.db.rollback()
raise