feat: complete modern urban issues management platform (ShahrNegar)
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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"))
|
||||
Reference in New Issue
Block a user