import os import uuid import random from datetime import datetime from typing import Optional, List from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel import shutil from database import get_db_connection, init_db app = FastAPI(title="سامانه هوشمند مدیریت مسائل شهری - شهرنگار") # CORS setup app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "static", "uploads") os.makedirs(UPLOAD_DIR, exist_ok=True) # Initialize database on startup @app.on_event("startup") def on_startup(): init_db() # Pydantic models class IssueCreate(BaseModel): title: str category: str description: str address: str district: int priority: str = "medium" lat: float = 35.6892 lng: float = 51.3890 image_url: Optional[str] = None reporter_name: Optional[str] = "شهروند ناشناس" reporter_phone: Optional[str] = None class CommentCreate(BaseModel): author_name: str content: str class StatusUpdate(BaseModel): status: str official_response: Optional[str] = None timeline_title: Optional[str] = None timeline_desc: Optional[str] = None resolved_image_url: Optional[str] = None # Routes @app.get("/api/stats") def get_stats(): conn = get_db_connection() cursor = conn.cursor() total = cursor.execute("SELECT COUNT(*) FROM issues").fetchone()[0] pending = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'pending'").fetchone()[0] reviewing = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'reviewing'").fetchone()[0] in_progress = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'in_progress'").fetchone()[0] resolved = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'resolved'").fetchone()[0] upvotes = cursor.execute("SELECT COALESCE(SUM(upvotes), 0) FROM issues").fetchone()[0] # Category counts cat_rows = cursor.execute("SELECT category, COUNT(*) as cnt FROM issues GROUP BY category ORDER BY cnt DESC").fetchall() categories = [{"category": row["category"], "count": row["cnt"]} for row in cat_rows] # District counts dist_rows = cursor.execute("SELECT district, COUNT(*) as cnt FROM issues GROUP BY district ORDER BY district ASC").fetchall() districts = [{"district": row["district"], "count": row["cnt"]} for row in dist_rows] # Priority counts prio_rows = cursor.execute("SELECT priority, COUNT(*) as cnt FROM issues GROUP BY priority").fetchall() priorities = {row["priority"]: row["cnt"] for row in prio_rows} conn.close() return { "total": total, "pending": pending, "reviewing": reviewing, "in_progress": in_progress, "resolved": resolved, "total_upvotes": upvotes, "categories": categories, "districts": districts, "priorities": priorities, "resolution_rate": round((resolved / total * 100) if total > 0 else 0, 1) } @app.get("/api/issues") def list_issues( category: Optional[str] = None, status: Optional[str] = None, district: Optional[int] = None, priority: Optional[str] = None, search: Optional[str] = None, sort_by: Optional[str] = "newest" ): conn = get_db_connection() cursor = conn.cursor() query = "SELECT * FROM issues WHERE 1=1" params = [] if category and category != "all": query += " AND category = ?" params.append(category) if status and status != "all": query += " AND status = ?" params.append(status) if district and district > 0: query += " AND district = ?" params.append(district) if priority and priority != "all": query += " AND priority = ?" params.append(priority) if search: query += " AND (title LIKE ? OR description LIKE ? OR address LIKE ? OR tracking_code LIKE ?)" s_pattern = f"%{search}%" params.extend([s_pattern, s_pattern, s_pattern, s_pattern]) if sort_by == "upvotes": query += " ORDER BY upvotes DESC, id DESC" elif sort_by == "oldest": query += " ORDER BY id ASC" elif sort_by == "emergency": query += " ORDER BY CASE priority WHEN 'emergency' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, id DESC" else: # newest query += " ORDER BY id DESC" rows = cursor.execute(query, params).fetchall() issues = [] for r in rows: item = dict(r) # Fetch comment count c_count = cursor.execute("SELECT COUNT(*) FROM comments WHERE issue_id = ?", (r["id"],)).fetchone()[0] item["comment_count"] = c_count issues.append(item) conn.close() return issues @app.get("/api/issues/{issue_id}") def get_issue(issue_id: int): conn = get_db_connection() cursor = conn.cursor() issue_row = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone() if not issue_row: conn.close() raise HTTPException(status_code=404, detail="گزارش مورد نظر یافت نشد.") issue = dict(issue_row) # Timeline timeline_rows = cursor.execute("SELECT * FROM timeline WHERE issue_id = ? ORDER BY id ASC", (issue_id,)).fetchall() issue["timeline"] = [dict(t) for t in timeline_rows] # Comments comment_rows = cursor.execute("SELECT * FROM comments WHERE issue_id = ? ORDER BY id DESC", (issue_id,)).fetchall() issue["comments"] = [dict(c) for c in comment_rows] conn.close() return issue @app.get("/api/track/{tracking_code}") def track_issue(tracking_code: str): conn = get_db_connection() cursor = conn.cursor() code = tracking_code.strip().upper() issue_row = cursor.execute("SELECT * FROM issues WHERE UPPER(tracking_code) = ?", (code,)).fetchone() if not issue_row: conn.close() raise HTTPException(status_code=404, detail=f"هیچ گزارشی با کد رهگیری «{tracking_code}» یافت نشد.") issue_id = issue_row["id"] issue = dict(issue_row) timeline_rows = cursor.execute("SELECT * FROM timeline WHERE issue_id = ? ORDER BY id ASC", (issue_id,)).fetchall() issue["timeline"] = [dict(t) for t in timeline_rows] comment_rows = cursor.execute("SELECT * FROM comments WHERE issue_id = ? ORDER BY id DESC", (issue_id,)).fetchall() issue["comments"] = [dict(c) for c in comment_rows] conn.close() return issue @app.post("/api/issues") def create_issue(payload: IssueCreate): conn = get_db_connection() cursor = conn.cursor() # Generate unique tracking code rand_num = random.randint(10000, 99999) tracking_code = f"SHR-{rand_num}" now_str = datetime.now().strftime("%Y-%m-%d %H:%M") cursor.execute(""" INSERT INTO issues ( tracking_code, title, category, description, address, district, priority, status, lat, lng, image_url, reporter_name, reporter_phone, upvotes, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, 1, ?) """, ( tracking_code, payload.title, payload.category, payload.description, payload.address, payload.district, payload.priority, payload.lat, payload.lng, payload.image_url, payload.reporter_name or "شهروند", payload.reporter_phone or "", now_str )) issue_id = cursor.lastrowid # Add initial timeline event cursor.execute(""" INSERT INTO timeline (issue_id, status, title, description, created_at) VALUES (?, 'pending', 'ثبت اولیه گزارش در سامانه', 'گزارش شما با موفقیت در سامانه شهرنگار ثبت گردید و در نوبت بررسی کارشناسی قرار گرفت.', ?) """, (issue_id, now_str)) conn.commit() created = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone() res = dict(created) conn.close() return res @app.post("/api/issues/{issue_id}/upvote") def upvote_issue(issue_id: int): conn = get_db_connection() cursor = conn.cursor() cursor.execute("UPDATE issues SET upvotes = upvotes + 1 WHERE id = ?", (issue_id,)) if cursor.rowcount == 0: conn.close() raise HTTPException(status_code=404, detail="گزارش یافت نشد.") conn.commit() new_upvotes = cursor.execute("SELECT upvotes FROM issues WHERE id = ?", (issue_id,)).fetchone()[0] conn.close() return {"id": issue_id, "upvotes": new_upvotes} @app.post("/api/issues/{issue_id}/comment") def add_comment(issue_id: int, payload: CommentCreate): conn = get_db_connection() cursor = conn.cursor() check = cursor.execute("SELECT id FROM issues WHERE id = ?", (issue_id,)).fetchone() if not check: conn.close() raise HTTPException(status_code=404, detail="گزارش یافت نشد.") now_str = datetime.now().strftime("%Y-%m-%d %H:%M") cursor.execute(""" INSERT INTO comments (issue_id, author_name, content, created_at) VALUES (?, ?, ?, ?) """, (issue_id, payload.author_name or "شهروند محترم", payload.content, now_str)) conn.commit() comment_id = cursor.lastrowid comment = cursor.execute("SELECT * FROM comments WHERE id = ?", (comment_id,)).fetchone() conn.close() return dict(comment) @app.patch("/api/issues/{issue_id}/status") def update_issue_status(issue_id: int, payload: StatusUpdate): conn = get_db_connection() cursor = conn.cursor() check = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone() if not check: conn.close() raise HTTPException(status_code=404, detail="گزارش یافت نشد.") now_str = datetime.now().strftime("%Y-%m-%d %H:%M") resolved_at = now_str if payload.status == "resolved" else check["resolved_at"] # Update issue cursor.execute(""" UPDATE issues SET status = ?, official_response = COALESCE(?, official_response), resolved_image_url = COALESCE(?, resolved_image_url), resolved_at = ? WHERE id = ? """, (payload.status, payload.official_response, payload.resolved_image_url, resolved_at, issue_id)) # Add timeline entry if requested or automatically status_titles = { "pending": "در صف بررسی مجدد", "reviewing": "بررسی کارشناسی و ارجاع به معاونت مربوطه", "in_progress": "اعزام اکیپ اجرایی و آغاز عملیات میدانی", "resolved": "اتمام عملیات و رفع کامل مسئله", "rejected": "عدم احراز یا خارج از حیطه اختیارات شهرداری" } tl_title = payload.timeline_title or status_titles.get(payload.status, f"تغییر وضعیت به {payload.status}") tl_desc = payload.timeline_desc or payload.official_response or "وضعیت پرونده توسط مدیریت سامانه به‌روزرسانی شد." cursor.execute(""" INSERT INTO timeline (issue_id, status, title, description, created_at) VALUES (?, ?, ?, ?, ?) """, (issue_id, payload.status, tl_title, tl_desc, now_str)) conn.commit() updated = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone() res = dict(updated) conn.close() return res @app.post("/api/upload") async def upload_file(file: UploadFile = File(...)): filename = f"{uuid.uuid4().hex[:10]}_{file.filename}" filepath = os.path.join(UPLOAD_DIR, filename) with open(filepath, "wb") as buffer: shutil.copyfileobj(file.file, buffer) return {"url": f"/static/uploads/{filename}"} # Serve frontend static files app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static") @app.get("/") def serve_index(): from fastapi.responses import FileResponse return FileResponse(os.path.join(os.path.dirname(__file__), "static", "index.html"))