Repository navigation
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.
| 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*.
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.
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.
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.
/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.
_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.
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.
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.
_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.
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.
Two panels:
-
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. -
Suspicious numbers —
user_db.get_suspicious_phones(tenant_id).
Marks a specific record as resolved, and redirects back with ?ok=1 so the page can
flash "✅ تعارض رسیدگی شد.".
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 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.
Uploads an Excel file (.xlsx) that replaces the clinic's counselor data.
The handler checks three things before it touches the database:
- the file is at most 10 MB,
- it starts with the
PKmagic bytes — a real ZIP container, i.e. a real.xlsxrather than a renamed CSV, - the filename ends in
.xlsxor.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.
Lists the current roster: name, ability, location, age range, education and experience, general area, license.
Downloads the current roster as an Excel file, so an admin can edit it offline and upload it back.
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.
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": ""
}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.
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.
| 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.
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
fetchloop; the inbox does not push. -
No CSRF tokens. The panel relies on
SameSite=Laxcookies 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_historyrecords roster uploads, but nothing else.
- 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.