""" Didvan — standalone admin panel (independent FastAPI app, default port 8001). Run: uvicorn admin_app:app --app-dir admin --port 8001 Auth: HTTP Basic. Set ADMIN_USER / ADMIN_PASSWORD env vars (defaults below). Three sections: - کاربران : registered users (name / email / phone) from the `users` table - توکن منبع داده : view & update the scraping token (stored in app_config; sync.py reads it) - سلامت سرویسها : last-scrape recency per service + "test scrape" that validates the SteelStatista token by fetching a sample symbol's latest price """ import os import re import secrets import random from datetime import datetime, timezone import sqlite3 import requests from fastapi import FastAPI, Depends, HTTPException, UploadFile, File, Form, Query from fastapi.responses import HTMLResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from pydantic import BaseModel DB_PATH = os.environ.get("DB_PATH", r"C:\Users\DATIS STAR\asianmetal.db") # Where report PDFs live — must match the backend's REPORTS_DIR so uploads show # up in the dashboard's reports tab (on the VPS both point at the shared /data disk). REPORTS_DIR = os.environ.get("REPORTS_DIR", r"C:\Users\DATIS STAR\Desktop\asianmetal_reports") MAX_PDF_BYTES = 30 * 1024 * 1024 ADMIN_USER = os.environ.get("ADMIN_USER", "admin") # No insecure default — the admin panel exposes user PII + the data-source token, # so a password MUST be provided via the environment (fail closed). ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") if not ADMIN_PASSWORD: raise RuntimeError( "ADMIN_PASSWORD environment variable is required (no default). " "Set it before starting the admin app, e.g. $env:ADMIN_PASSWORD='a-strong-secret'." ) DATA_API_BASE = "https://api.SteelStatista.com" CATEGORY_IDS = [ 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 67, 68, 70, 71, 72, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 87, 88, 89, 90, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, ] app = FastAPI(title="Didvan Admin") security = HTTPBasic() def require_admin(creds: HTTPBasicCredentials = Depends(security)) -> str: ok = secrets.compare_digest(creds.username, ADMIN_USER) and secrets.compare_digest( creds.password, ADMIN_PASSWORD ) if not ok: raise HTTPException(status_code=401, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"}) return creds.username def conn(): c = sqlite3.connect(DB_PATH, timeout=10) c.row_factory = sqlite3.Row c.execute("PRAGMA journal_mode=WAL") return c def _get_token(): try: with conn() as c: row = c.execute("SELECT value FROM app_config WHERE key='asianmetal_token'").fetchone() if row and row["value"]: return row["value"] except Exception: pass return None def _age_minutes(ts): if not ts: return None try: dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) return round((datetime.now(timezone.utc) - dt).total_seconds() / 60, 1) except Exception: return None # ───────────────────────────── APIs ───────────────────────────── @app.get("/api/users") def api_users(_: str = Depends(require_admin)): with conn() as c: rows = c.execute( "SELECT id, name, email, phone, organization, " "COALESCE(status,'pending') AS status, created_at " "FROM users ORDER BY (COALESCE(status,'pending')='pending') DESC, id DESC" ).fetchall() return [dict(r) for r in rows] def _set_user_status(uid: int, status: str, _: str = Depends(require_admin)): with conn() as c: cur = c.execute("UPDATE users SET status=? WHERE id=?", (status, uid)) if cur.rowcount == 0: raise HTTPException(status_code=404, detail="کاربر یافت نشد") return {"ok": True, "id": uid, "status": status} @app.post("/api/users/{uid}/approve") def api_approve(uid: int, admin: str = Depends(require_admin)): return _set_user_status(uid, "approved", admin) @app.post("/api/users/{uid}/reject") def api_reject(uid: int, admin: str = Depends(require_admin)): # rejecting also kills any active session so access is revoked immediately with conn() as c: c.execute("DELETE FROM sessions WHERE user_id=?", (uid,)) return _set_user_status(uid, "rejected", admin) class TokenIn(BaseModel): token: str @app.get("/api/token") def api_token(_: str = Depends(require_admin)): t = _get_token() masked = (t[:14] + "…" + t[-10:]) if t and len(t) > 30 else (t or "") updated = None try: with conn() as c: row = c.execute("SELECT value FROM app_config WHERE key='asianmetal_token_updated'").fetchone() updated = row["value"] if row else None except Exception: pass return {"set": bool(t), "masked": masked, "updated_at": updated} @app.post("/api/token") def api_set_token(data: TokenIn, _: str = Depends(require_admin)): tok = (data.token or "").strip() if not tok: return {"ok": False, "error": "توکن خالی است"} now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") with conn() as c: c.execute( "INSERT INTO app_config(key,value) VALUES('asianmetal_token',?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (tok,), ) c.execute( "INSERT INTO app_config(key,value) VALUES('asianmetal_token_updated',?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (now,), ) return {"ok": True, "updated_at": now} def _count_change(c, table): try: row = c.execute(f"SELECT MAX(fetched_at) m, COUNT(*) n FROM {table}").fetchone() return row["n"], row["m"] except Exception: return 0, None @app.get("/api/health") def api_health(_: str = Depends(require_admin)): """Service health. TGJU services use a heartbeat (last completed scrape cycle), NOT MAX(fetched_at) — because dedup means the table only changes when a price moves, so a quiet market would otherwise look like a dead scraper.""" out = [] with conn() as c: hb = None try: row = c.execute("SELECT value FROM app_config WHERE key='tgju_last_scrape'").fetchone() hb = row["value"] if row else None except Exception: pass hb_age = _age_minutes(hb) # TGJU bundle (currency/gold/coin) — freshness from the shared heartbeat for table, label in [ ("currency_prices", "بازار داخلی — ارز"), ("gold_prices", "بازار داخلی — طلا"), ("coin_prices", "بازار داخلی — سکه"), ]: n, change = _count_change(c, table) out.append({ "key": table, "label": label, "last": hb, "age_min": hb_age, "count": n, "ok": hb_age is not None and hb_age <= 3, "changed": change, }) # Commodity (Trading Economics) — its own scraper, table timestamp is fine n, change = _count_change(c, "te_prices") c_age = _age_minutes(change) out.append({ "key": "commodity", "label": "کامودیتی", "last": change, "age_min": c_age, "count": n, "ok": c_age is not None and c_age <= 90, "changed": change, }) # Metals & steel — daily batch via sync.py (SteelStatista) try: row = c.execute("SELECT MAX(date) m, COUNT(*) n FROM prices").fetchone() out.append({ "key": "metals", "label": "فلزات و فولاد (روزانه)", "last": row["m"], "age_min": None, "count": row["n"], "ok": True, "daily": True, }) except Exception: pass return out def _yz_headers(tok): return { "Authorization": f"Bearer {tok}", "accept": "application/json", "lang": "fa", "platform": "web", "referer": "https://SteelStatista.com/", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", } @app.post("/api/test-scrape") def api_test_scrape(_: str = Depends(require_admin)): """Validate the data-source token by fetching latest prices for a random sample of ~12 symbols across random categories.""" tok = _get_token() if not tok: return {"ok": False, "error": "توکنی تنظیم نشده — اول توکن را در تب «توکن» ذخیره کنید."} headers = _yz_headers(tok) try: pool = [] for cid in random.sample(CATEGORY_IDS, min(6, len(CATEGORY_IDS))): try: r = requests.get(f"{DATA_API_BASE}/Symbol?categoryId={cid}", headers=headers, timeout=12) if r.status_code == 401: return {"ok": False, "error": "توکن منقضی یا نامعتبر است (401) — باید عوضش کنی."} r.raise_for_status() pool.extend(r.json() or []) except Exception: continue if not pool: return {"ok": False, "error": "هیچ نمادی برنگشت."} items = [] for s in random.sample(pool, min(12, len(pool))): try: r2 = requests.get(f"{DATA_API_BASE}/Symbol/{s['id']}", headers=headers, timeout=12) r2.raise_for_status() prices = r2.json().get("symbolPrices", []) if not prices: continue last = max(prices, key=lambda p: p["date"]) items.append({ "title": s.get("title"), "date": last["date"][:10], "mid": last.get("mid"), "low": last.get("low"), "high": last.get("high"), }) except Exception: continue if not items: return {"ok": False, "error": "قیمتی دریافت نشد."} items.sort(key=lambda x: x["title"] or "") return {"ok": True, "count": len(items), "items": items} except Exception as e: return {"ok": False, "error": f"خطا در اتصال به منبع داده: {e}"} _DATASETS = { "currency": ("currency_prices", "بازار داخلی — ارز", "fetched_at"), "gold": ("gold_prices", "بازار داخلی — طلا/نقره", "fetched_at"), "coin": ("coin_prices", "بازار داخلی — سکه", "fetched_at"), "commodity": ("te_prices", "کامودیتی", "fetched_at"), "metals": ("prices", "فلزات و فولاد", "date"), } @app.get("/api/reports") def api_reports(_: str = Depends(require_admin)): out = [] with conn() as c: for key, (table, label, datecol) in _DATASETS.items(): try: row = c.execute( f"SELECT COUNT(*) n, MIN({datecol}) mn, MAX({datecol}) mx FROM {table}" ).fetchone() out.append({"key": key, "label": label, "table": table, "count": row["n"], "first": row["mn"], "last": row["mx"]}) except Exception: out.append({"key": key, "label": label, "table": table, "count": 0, "first": None, "last": None}) return out @app.get("/api/export") def api_export(dataset: str, _: str = Depends(require_admin)): import csv import io from fastapi.responses import Response if dataset not in _DATASETS: raise HTTPException(status_code=404, detail="dataset not found") table, _label, datecol = _DATASETS[dataset] with conn() as c: if dataset == "metals": rows = c.execute( "SELECT grp, category, title, date, low, mid, high FROM prices " "WHERE date = (SELECT MAX(date) FROM prices) ORDER BY grp, category, title" ).fetchall() else: rows = c.execute( f"SELECT * FROM {table} WHERE {datecol} = (SELECT MAX({datecol}) FROM {table})" ).fetchall() buf = io.StringIO() if rows: w = csv.writer(buf) w.writerow(rows[0].keys()) for r in rows: w.writerow([r[k] for k in r.keys()]) return Response( content="" + buf.getvalue(), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{dataset}_latest.csv"'}, ) # ─────────────────────── Report PDF upload ─────────────────────── def _ensure_api_usage_table(c): c.execute(""" CREATE TABLE IF NOT EXISTS api_usage_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, partner TEXT, token_hash TEXT, method TEXT NOT NULL, path TEXT NOT NULL, query TEXT, status_code INTEGER, client_ip TEXT, user_agent TEXT, created_at TEXT NOT NULL ) """) c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_created ON api_usage_logs(created_at)") c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_partner ON api_usage_logs(partner)") c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_path ON api_usage_logs(path)") @app.get("/api/api-usage") def api_usage(limit: int = Query(100, ge=1, le=500), _: str = Depends(require_admin)): with conn() as c: _ensure_api_usage_table(c) summary = c.execute(""" SELECT COALESCE(partner, 'unknown') AS partner, COUNT(*) AS requests, SUM(CASE WHEN status_code BETWEEN 200 AND 399 THEN 1 ELSE 0 END) AS ok, SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS errors, MAX(created_at) AS last_seen FROM api_usage_logs GROUP BY COALESCE(partner, 'unknown') ORDER BY requests DESC, partner """).fetchall() rows = c.execute(""" SELECT id, partner, substr(token_hash, 1, 12) AS token_hash_prefix, method, path, query, status_code, client_ip, user_agent, created_at FROM api_usage_logs ORDER BY id DESC LIMIT ? """, (limit,)).fetchall() return {"summary": [dict(r) for r in summary], "rows": [dict(r) for r in rows]} def _safe_folder(folder: str): """One path segment, no traversal, filesystem-safe.""" f = (folder or "").strip().replace("\\", "/").split("/")[0].strip(". ") f = re.sub(r'[<>:"|?*\x00-\x1f]', "", f) return f or None @app.get("/api/report-folders") def report_folders(_: str = Depends(require_admin)): base = os.path.realpath(REPORTS_DIR) if not os.path.isdir(base): return {"dir": REPORTS_DIR, "folders": []} out = [] for e in os.scandir(base): if e.is_dir(): n = sum(1 for f in os.scandir(e.path) if f.is_file() and f.name.lower().endswith(".pdf")) out.append({"name": e.name, "count": n}) out.sort(key=lambda x: x["name"]) return {"dir": REPORTS_DIR, "folders": out} @app.post("/api/upload-report") async def upload_report(folder: str = Form(...), file: UploadFile = File(...), _: str = Depends(require_admin)): safe = _safe_folder(folder) if not safe: raise HTTPException(status_code=400, detail="نام پوشه نامعتبر است") name = os.path.basename(file.filename or "") if not name.lower().endswith(".pdf"): raise HTTPException(status_code=400, detail="فقط فایل PDF مجاز است") name = re.sub(r"[^0-9A-Za-z ._()\--ۿ]", "_", name) data = await file.read() if len(data) > MAX_PDF_BYTES: raise HTTPException(status_code=400, detail="حجم فایل بیش از ۳۰ مگابایت است") if data[:5] != b"%PDF-": # validate by content, not just extension raise HTTPException(status_code=400, detail="محتوای فایل PDF معتبر نیست") base = os.path.realpath(REPORTS_DIR) dest_dir = os.path.realpath(os.path.join(base, safe)) if dest_dir != base and not dest_dir.startswith(base + os.sep): raise HTTPException(status_code=400, detail="مسیر نامعتبر") os.makedirs(dest_dir, exist_ok=True) with open(os.path.join(dest_dir, name), "wb") as fh: fh.write(data) return {"ok": True, "folder": safe, "name": name, "size": len(data)} # ───────────────────────────── UI ───────────────────────────── @app.get("/", response_class=HTMLResponse) def home(_: str = Depends(require_admin)): return _HTML _HTML = """