feat: complete modern urban issues management platform (ShahrNegar)

This commit is contained in:
Antigravity Bot
2026-08-30 01:54:03 +03:30
parent 8dee99de1b
commit 06e1266662
5 changed files with 2340 additions and 0 deletions
+338
View File
@@ -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"))