Skip to content

Admin Panel

Mohammad-Hasan-Kaman edited this page Oct 20, 2026 · 2 revisions

Admin Panel

The admin panel is a server-rendered FastAPI app — Jinja2 templates, no SPA, no JavaScript framework. It lives at /admin and is mounted from app/routers/admin.py. Templates live in app/templates/.

There are 27 routes. Everything below is what the code actually does — not a product wishlist.


Two roles, two panels

Role Sees Home
admin One clinic. Its conversations, leads, flow, roster, feedback. /admin
super_admin All clinics. Creates tenants, issues keys, resets passwords, toggles clinics. /admin (renders the super view)

super_admin users are blocked from the clinic pages with 403:

if user["role"] == "super_admin":
    raise HTTPException(status_code=403, detail="این صفحه مخصوص مشتریان است.")

That guard is on /inbox, /leads, /export, /conflicts, /announcement, /flow, /upload, /consultants, /feedback, and /crawl*.


Authentication

Session cookies

Login is a POST form at /admin/login with username and password. On success the server sets a signed cookie (itsdangerous), with httponly and samesite=lax, and secure when the deployment is HTTPS. The cookie carries the user id and the role; it is not a JWT and is not readable by the client.

Every protected route calls _current_user(request) and redirects to /admin/login with a 303 when there is no valid session.

Password hashing

Passwords are hashed with PBKDF2-HMAC-SHA256, 100,000 iterations, with a per-user random salt (app/auth.py). Verification is hmac.compare_digest — constant time.

Login rate limit

10 attempts per 5 minutes per IP. The counter is a module-level dict[str, list[float]] capped at 5,000 keys, with stale entries pruned once the cap is passed. Over the limit, the login page re-renders with an error rather than a 429.

Changing a password

/admin/password is super-admin only — a clinic admin is told to contact the super admin. The form requires the current password, a new password of at least 8 characters, and the new password twice.


The clinic dashboard — /admin

_collect_tenant_stats(tenant_id) reads the numbers straight out of SQLite:

  • total users for the tenant,
  • total requests,
  • active chats,
  • and the last crawl information.

The page also shows the active announcement (if any) and the GHQ toggle state of the flow, and it exposes the action buttons for everything below.


The conversation inbox

/admin/inbox

Lists every session for the tenant: token, state, the stored data blob, creation and update timestamps, and the first line of the last bot message. The list is scoped by tenant_id for admin users.

/admin/inbox/{token}

The full transcript of one conversation — every message from widget_messages, in order, with the bot messages rendered as HTML and the visitor's as plain text. This is where staff read what actually happened before they call back.

The inbox is the reason widget_messages is written on every turn, including the welcome message. A session that was never answered is still visible.


Leads

/admin/leads

_list_leads(tenant_id) returns one row per request:

SELECT id, full_name, phone, gender, age, topic, ghq_total, request_number,
       created_at, recommendations
FROM user_requests WHERE tenant_id=? ORDER BY created_at ...

So a lead is a completed intake: who, how to reach them, the basic profile, the GHQ total if the screening was taken, which visit number it is, and the counselors that were recommended.

/admin/export

The same data as a Persian Excel file — the header row and the cells are written right-to-left so it opens correctly in Excel with a Persian locale.


Conflict resolution

/admin/conflicts

Two panels:

  1. Phone conflicts — user_db.list_phone_conflicts(tenant_id). A conflict exists when one phone number is registered under more than one name. This is the natural consequence of shared family numbers, and it needs a human decision, not a heuristic.
  2. Suspicious numbers — user_db.get_suspicious_phones(tenant_id).

POST /admin/conflicts/resolve

Marks a specific record as resolved, and redirects back with ?ok=1 so the page can flash "✅ تعارض رسیدگی شد.".


Announcements

POST /admin/announcement

Sets the tenant's broadcast text. The active announcement is delivered to every new session: POST /api/chat/session reads the latest row where active = 1 and returns it in the announcement field, and the widget renders it as a banner above the first message.


The flow editor

POST /admin/flow

The intake flow is edited as a form, not as code. The form posts:

Field Meaning
welcome The first message the visitor sees.
ghq Whether the GHQ screen is offered.
ending Which ending to use: recommend or lead_capture.
thanks_text The text shown in the lead_capture ending.
steps_json The step list, as a JSON array.

The server parses steps_json with json.loads, requires it to be a non-empty list, and then sanitises every step before saving — so the panel cannot store a malformed flow that would break the state machine at runtime.

Because the flow is stored in the database and read at request time, a change takes effect on the next session — no redeploy. That is the whole point of the design.

The step format, the conditional mechanism, and the two endings are described in Architecture and Intake-Flow.


The counselor roster

POST /admin/upload

Uploads an Excel file (.xlsx) that replaces the clinic's counselor data.

The handler checks three things before it touches the database:

  1. the file is at most 10 MB,
  2. it starts with the PK magic bytes — a real ZIP container, i.e. a real .xlsx rather than a renamed CSV,
  3. the filename ends in .xlsx or .xlsm.

The parsing itself is app/excel_to_json.py, and it is block-aware: in the center's real file each counselor spans several rows — the first row of the block holds the name, and the rows below carry the rest of the data. The parser therefore aggregates from each name row to the next one. It also normalises the work location into exactly three forms (ظفر / خیابان ایران / هر دو) and clamps the ability coefficient to 1.0, 2.0 or 3.0.

replace_consultants() fully replaces the tenant's roster in one transaction, and returns a diff — old_count, new_count, added, removed — which is shown in the flash message and written to upload_history.

/admin/consultants

Lists the current roster: name, ability, location, age range, education and experience, general area, license.

/admin/consultants/download

Downloads the current roster as an Excel file, so an admin can edit it offline and upload it back.


Feedback and the learning weights

POST /admin/feedback

Staff can mark a recommendation as right or wrong, per concept.

The form posts counselor_name, concept_num and verdict. concept_num indexes FEEDBACK_CONCEPTS, a table of 11 clinical concepts — each entry pairs a Persian label with the engine's key. An out-of-range number is rejected with "❌ شماره مفهوم نامعتبر است.".

The verdict is written through engine.record_feedback_direct(...), which adjusts the self-learning weight for that concept. Over time, a concept that keeps being rejected gets down-weighted, and one that keeps being accepted gets up-weighted. The mechanism is described in Matching Engine.


The crawler

GET /admin/crawl/status

Returns live progress as JSON so the dashboard can poll it:

{
  "running": false,
  "last_finished": "2026-10-19 21:14:02",
  "started_at": "2026-10-19 21:13:40",
  "total": 47,
  "done": 47,
  "current": ""
}

POST /admin/crawl

Starts a manual crawl of the availability calendar. The crawl is single-flight: a second request while one is running is rejected, and the status endpoint reports running: true.

Both crawler routes are restricted to the nikravan tenant — the availability calendar belongs to one specific center, so another clinic's admin gets 403:

if tenant["slug"] != "nikravan":
    return JSONResponse({"running": False}, status_code=403)

The crawler itself is described in Crawler.


The super-admin panel

Six routes under /admin/super/:

Route What it does
POST /admin/super/tenants Creates a new clinic: name, slug, password, API key, flow.
POST /admin/super/tenants/{id}/regenerate-key Issues a new API key and invalidates the old one.
POST /admin/super/tenants/{id}/reset-password Sets a new password for a clinic admin.
POST /admin/super/tenants/{id}/toggle Enables or disables a clinic. A disabled clinic's chat API returns 403.
POST /admin/super/tenants/{id}/domains Sets the allowed_domains list that the chat API enforces.
POST /admin/super/tenants/{id}/add-admin Adds another admin account for a clinic.

The super-admin dashboard (super_tenants.html) lists every clinic with its slug, status, key, user count and request count.


Templates

File Screen
base.html The shared layout: header, navigation, flash messages, RTL styling.
admin_login.html The login form.
admin_dashboard.html The clinic dashboard.
admin_inbox.html The conversation list.
admin_inbox_detail.html One full transcript.
admin_leads.html The lead table.
admin_conflicts.html Conflict resolution and suspicious numbers.
admin_consultants.html The roster manager.
admin_password.html The super-admin password change.
super_tenants.html The tenant management table.

All of them inherit from base.html, which carries the dir="rtl" and the Persian typography.


What the panel does not have

Being explicit about this is more useful than a feature list:

  • No API for the panel itself. Every panel action is a form POST that re-renders a page. There is no JSON admin API.
  • No real-time updates. The crawler status is polled by the dashboard with a plain fetch loop; the inbox does not push.
  • No CSRF tokens. The panel relies on SameSite=Lax cookies and the login rate limit. A form POST from a cross-site page would carry the cookie on a top-level navigation, so this is a deliberate simplification, not an oversight — but it is a real gap if you deploy this to the open internet.
  • No audit log. A change to the flow or the roster is not recorded with a who/when. upload_history records roster uploads, but nothing else.

Related pages

  • Multi-Tenancy — how a tenant is created, and what it isolates.
  • Intake-Flow — the step format the flow editor writes.
  • Crawler — the availability crawl and the booking links.
  • API Reference — the chat API the widget talks to.
  • Architecture — the request pipeline and the middleware stack.

Clone this wiki locally