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.
This commit is contained in:
2025-09-27 09:31:40 +08:00
parent 0e3eea7de9
commit 77d5be8fd1
27 changed files with 800 additions and 256 deletions

Binary file not shown.

View File

@@ -46,6 +46,7 @@ class Ticket(Base):
category = Column(String, nullable=False)
severity = Column(Enum(SeverityLevel), nullable=False, default=SeverityLevel.NA)
description = Column(String, default="")
address = Column(String, nullable=True)
status = Column(Enum(TicketStatus), nullable=False, default=TicketStatus.NEW)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)

View File

@@ -1,37 +1,53 @@
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.ticket_service import TicketService, SeverityLevel
from app.models.ticket_model import User
from app.services.global_ai import get_ai_service
import os, uuid, logging
from app.utils import make_image_url, normalize_image_path_for_url
from pathlib import Path
import logging, uuid
router = APIRouter()
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
UPLOAD_DIR = "static/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
UPLOAD_DIR = Path("static") / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
@router.post("/report")
async def report_issue(
user_id: str = Form(...),
user_id: Optional[str] = Form(None),
user_name: Optional[str] = Form(None),
latitude: float = Form(...),
longitude: float = Form(...),
address: Optional[str] = Form(None),
description: str = Form(""),
image: UploadFile = File(...),
request: Request = None,
db: Session = Depends(get_db)
):
logger.debug("Received report request")
ticket_service = TicketService(db)
# Validate user
user = db.query(User).filter(User.id == user_id).first()
# Validate or create user
user = None
if user_id:
user = ticket_service.get_user(user_id)
if not user:
logger.error(f"User with id {user_id} not found")
raise HTTPException(status_code=404, detail=f"User with id {user_id} not found")
logger.debug(f"User found: {user.name} ({user.email})")
# Create a guest user automatically
guest_email = f"guest-{uuid.uuid4()}@example.local"
guest_name = user_name or f"Guest-{str(uuid.uuid4())[:8]}"
try:
user = ticket_service.create_user(name=guest_name, email=guest_email)
logger.info(f"Created guest user: {user}")
except Exception as e:
logger.exception("Failed to create guest user")
raise HTTPException(status_code=500, detail="Failed to ensure user")
logger.debug(f"Using user: {user.name} ({user.email})")
# Validate file type
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'}
@@ -40,7 +56,7 @@ async def report_issue(
'application/octet-stream' # Some cameras/mobile devices use this
}
file_ext = os.path.splitext(image.filename.lower())[1]
file_ext = Path(image.filename).suffix.lower()
if file_ext not in allowed_extensions:
logger.error(f"Invalid file extension: {file_ext}")
raise HTTPException(status_code=400, detail="Only image files are allowed")
@@ -51,12 +67,11 @@ async def report_issue(
# Save uploaded image
filename = f"{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, filename)
file_path_obj = UPLOAD_DIR / filename
try:
content = await image.read()
with open(file_path, "wb") as f:
f.write(content)
logger.debug(f"Saved image to {file_path} ({len(content)} bytes)")
file_path_obj.write_bytes(content)
logger.debug(f"Saved image to {file_path_obj} ({len(content)} bytes)")
except Exception as e:
logger.exception("Failed to save uploaded image")
raise HTTPException(status_code=500, detail="Failed to save uploaded image")
@@ -67,11 +82,11 @@ async def report_issue(
# Run AI predictions
try:
category = ai_service.classify_category(file_path)
category = ai_service.classify_category(str(file_path_obj))
logger.debug(f"Classification: {category}")
if category.lower() == "pothole":
severity_str, annotated_path = ai_service.detect_pothole_severity(file_path)
severity_str, annotated_path = ai_service.detect_pothole_severity(str(file_path_obj))
logger.debug(f"Detection: severity={severity_str}, path={annotated_path}")
severity = {
"High": SeverityLevel.HIGH,
@@ -87,20 +102,27 @@ async def report_issue(
category = "Unknown"
severity = SeverityLevel.NA
# Create ticket
# Create ticket (store relative posix path)
image_path_db = file_path_obj.as_posix()
ticket = ticket_service.create_ticket(
user_id=user.id,
image_path=file_path,
image_path=image_path_db,
category=category,
severity=severity,
latitude=latitude,
longitude=longitude,
description=description
description=description,
address=address
)
logger.info(f"Ticket created: {ticket.id} for user {user.id}")
# Normalize stored path and build absolute URL
rel_path = normalize_image_path_for_url(ticket.image_path)
image_url = make_image_url(rel_path, request)
response = {
"ticket_id": ticket.id,
"id": ticket.id,
"user_id": user.id,
"user_name": user.name,
"user_email": user.email,
@@ -108,7 +130,9 @@ async def report_issue(
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"image_path": ticket.image_path
"image_path": rel_path,
"image_url": image_url,
"address": ticket.address
}
logger.debug(f"Response: {response}")

View File

@@ -1,96 +1,81 @@
# app/routes/tickets.py
from typing import Optional, List
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.ticket_service import TicketService, TicketStatus, SeverityLevel
from pydantic import BaseModel
from app.utils import ticket_to_dict
router = APIRouter()
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
class TicketStatusUpdate(BaseModel):
new_status: TicketStatus
status: TicketStatus
# ----------------------
# GET /tickets
# ----------------------
@router.get("/tickets", response_model=List[dict])
def list_tickets(
request: Request,
user_id: Optional[str] = Query(None, description="Filter by user ID"),
category: Optional[str] = Query(None, description="Filter by category"),
severity: Optional[SeverityLevel] = Query(None, description="Filter by severity"),
status: Optional[TicketStatus] = Query(None, description="Filter by status"),
db: Session = Depends(get_db)
):
"""
Return all tickets by default. Optional query params may filter results.
Each item is serialized using ticket_to_dict(...) which guarantees:
- image_url is an absolute forward-slash URL
- created_at is ISO-8601 string
- consistent schema for dashboard & mobile clients
"""
service = TicketService(db)
tickets = service.list_tickets(user_id=user_id, category=category, severity=severity, status=status)
return [
{
"ticket_id": t.id,
"user_id": t.user_id,
"category": t.category,
"severity": t.severity.value,
"status": t.status.value,
"description": t.description,
"latitude": t.latitude,
"longitude": t.longitude,
"image_path": t.image_path,
"created_at": t.created_at,
"updated_at": t.updated_at
} for t in tickets
]
return [ticket_to_dict(t, request) for t in tickets]
# ----------------------
# GET /tickets/{ticket_id}
# ----------------------
@router.get("/tickets/{ticket_id}", response_model=dict)
def get_ticket(ticket_id: str, db: Session = Depends(get_db)):
def get_ticket(ticket_id: str, request: Request, db: Session = Depends(get_db)):
service = TicketService(db)
ticket = service.get_ticket(ticket_id)
if not ticket:
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
return {
"ticket_id": ticket.id,
"user_id": ticket.user_id,
"category": ticket.category,
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"image_path": ticket.image_path,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
return ticket_to_dict(ticket, request)
# ----------------------
# PATCH /tickets/{ticket_id} - Update status
# PATCH /tickets/{ticket_id}/status - Update status
# ----------------------
@router.patch("/tickets/{ticket_id}", response_model=dict)
@router.patch("/tickets/{ticket_id}/status", response_model=dict)
def update_ticket_status(
ticket_id: str,
status_update: TicketStatusUpdate, # JSON body with new_status
status_update: TicketStatusUpdate, # JSON body with status
request: Request,
db: Session = Depends(get_db)
):
service = TicketService(db)
try:
ticket = service.update_ticket_status(ticket_id, status_update.new_status)
ticket = service.update_ticket_status(ticket_id, status_update.status)
except Exception as e:
logger.error(f"Failed to update ticket status: {e}")
raise HTTPException(status_code=400, detail=str(e))
return {
"ticket_id": ticket.id,
"user_id": ticket.user_id,
"category": ticket.category,
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"image_path": ticket.image_path,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
return ticket_to_dict(ticket, request)
# ----------------------
# DELETE /tickets/{ticket_id} - Delete ticket + image
# ----------------------
@router.delete("/tickets/{ticket_id}", response_model=dict)
def delete_ticket(ticket_id: str, db: Session = Depends(get_db)):
service = TicketService(db)
try:
service.delete_ticket(ticket_id)
except Exception as e:
logger.error(f"Failed to delete ticket {ticket_id}: {e}")
raise HTTPException(status_code=400, detail=str(e))
return {"deleted": True, "id": ticket_id}

View File

@@ -1,9 +1,11 @@
# 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)
@@ -26,8 +28,6 @@ class TicketService:
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()
@@ -44,16 +44,27 @@ class TicketService:
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,
image_path=image_path_posix,
category=category,
severity=severity,
latitude=latitude,
longitude=longitude,
description=description,
address=address,
)
self.db.add(ticket)
self.db.commit()
@@ -91,6 +102,9 @@ class TicketService:
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)
@@ -101,3 +115,57 @@ class TicketService:
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

100
backend/app/utils.py Normal file
View File

@@ -0,0 +1,100 @@
from typing import Optional
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
# Path where uploaded images are stored (relative to project root)
UPLOADS_DIR = Path("static") / "uploads"
# Resolved absolute path for safety checks
try:
UPLOADS_DIR_RESOLVED = UPLOADS_DIR.resolve()
except Exception:
UPLOADS_DIR_RESOLVED = UPLOADS_DIR
def normalize_image_path_for_url(image_path: Optional[str]) -> Optional[str]:
"""
Normalize stored image_path to a web-accessible relative path under the /static/ mount.
Examples:
r"static\\uploads\\uuid.jpg" -> "static/uploads/uuid.jpg"
"C:\\project\\static\\uploads\\uuid.jpg" -> "static/uploads/uuid.jpg"
"uploads/uuid.jpg" -> "static/uploads/uuid.jpg"
"""
if not image_path:
return None
p = str(image_path).replace("\\", "/")
# strip leading './' and leading slashes
while p.startswith("./"):
p = p[2:]
p = p.lstrip("/")
# prefer existing 'static/' segment if present
if "static/" in p:
p = p[p.find("static/"):]
elif p.startswith("uploads/"):
p = f"static/{p}"
else:
# fallback to treating as filename only
p = f"static/uploads/{Path(p).name}"
# collapse accidental duplicates
p = p.replace("static/static", "static").replace("uploads/uploads", "uploads")
return p
def make_image_url(image_path: Optional[str], request) -> Optional[str]:
"""
Build an absolute URL for the given stored image_path using the FastAPI request.
Returns None if image_path is falsy.
"""
rel = normalize_image_path_for_url(image_path)
if not rel:
return None
base = str(request.base_url).rstrip("/")
return f"{base}/{rel.lstrip('/')}"
def ticket_to_dict(ticket, request=None) -> dict:
"""
Serialize a Ticket ORM object to the normalized schema expected by clients.
Schema:
id, category, severity, status, description,
user_id, user_name, user_email,
created_at (ISO8601), latitude, longitude, address,
image_url (absolute), image_path (relative POSIX under static/)
"""
created = None
try:
if getattr(ticket, "created_at", None):
created = ticket.created_at.isoformat()
except Exception:
created = None
# Normalize stored image path to a safe relative POSIX path under 'static/'
normalized_path = normalize_image_path_for_url(getattr(ticket, "image_path", None))
image_url = None
if request is not None:
try:
image_url = make_image_url(normalized_path, request)
except Exception:
logger.exception("Failed to build image_url")
image_url = None
return {
"id": ticket.id,
"category": ticket.category,
"severity": ticket.severity.value if getattr(ticket, "severity", None) else None,
"status": ticket.status.value if getattr(ticket, "status", None) else None,
"description": ticket.description,
"user_id": ticket.user_id,
"user_name": ticket.user.name if getattr(ticket, "user", None) else None,
"user_email": ticket.user.email if getattr(ticket, "user", None) else None,
"created_at": created,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"address": ticket.address,
"image_url": image_url,
"image_path": normalized_path
}

View File

@@ -3,6 +3,7 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.database import Base, engine
from app.routes import report, tickets, analytics, users
from app.services.global_ai import init_ai_service
@@ -44,6 +45,25 @@ UPLOAD_DIR = "static/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
# ----------------------
# CORS - allow dashboard & emulator origins
# ----------------------
DEFAULT_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000,http://10.0.2.2:3000,http://192.168.100.59:3000"
origins_env = os.environ.get("FIXMATE_CORS_ORIGINS", DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in origins_env.split(",") if o.strip()]
# Ensure common development origins are always allowed (localhost, emulator, LAN)
for origin in ("http://localhost:3000", "http://127.0.0.1:3000", "http://10.0.2.2:3000", "http://192.168.100.59:3000"):
if origin not in allowed_origins:
allowed_origins.append(origin)
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ----------------------
# Include routers
# ----------------------

View File

@@ -0,0 +1,33 @@
import sqlite3
import os
import sys
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
db_path = os.path.normpath(os.path.join(script_dir, '..', 'app', 'db', 'fixmate.db'))
print(f"Using database: {db_path}")
if not os.path.exists(db_path):
print(f"DB not found: {db_path}")
return 2
conn = sqlite3.connect(db_path)
try:
cur = conn.cursor()
cur.execute("PRAGMA table_info(tickets);")
cols = [row[1] for row in cur.fetchall()]
if 'address' in cols:
print("Column 'address' already exists")
return 0
cur.execute("ALTER TABLE tickets ADD COLUMN address TEXT;")
conn.commit()
print("Added 'address' column to 'tickets' table")
return 0
except Exception as e:
print("Failed to add 'address' column:", e)
return 1
finally:
conn.close()
if __name__ == '__main__':
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB