A from-scratch replacement for django.contrib.admin with BI-grade reporting
and a natural-language (Persian-first) report interface. See
DESIGN.md for the full design.
Status: M7 (scale) done — the roadmap is complete. Reports are answered from pre-aggregations (summary tables declared on a semantic model, built and kept fresh by Gozar) whenever that is provably the same as querying the base rows — row-level rules included — and, optionally, from a DuckDB copy of the data, with parity tests holding both to the ORM's exact numbers; the System page shows and refreshes them, and a benchmark command measures the difference. Below that: M6.5's admin parity (inlines, uploads, fieldsets and widgets, list editing, the Gregorian/Jalali date hierarchy, the user admin, CSV/XLSX import and export, bulk edit, saved views, global search, field-level history, shortcuts) and System page, M6's dashboards, comparisons, saved reports and scheduled e-mail, M5's natural-language interface (English and Persian, offline or with any OpenAI-compatible model), M4's row-level rules, semantic visibility, audit log, limits, cache, replica routing and background jobs, and M3's report builder, charts, pivot, drill-down and export.
The demo's Sales overview dashboard, pinned as the manager's home page: KPI cards with this month vs last month and a monthly revenue line against last year (M6 dashboards and comparisons).
Every screenshot is from the example project's demo data (how to refresh them).
The report builder: revenue by city for the last 3 months as a bar chart, each city next to the previous period (M3 report builder, M6 comparison periods).
A pivot table of orders, cities by status, over the past 12 months (M3 pivot).
Clicking a report cell opens the Tehran orders behind it, each linked to its edit page (M3 drill-down).
Ask in plain words: "monthly revenue this year", then the follow-up "compared to last year" refines the same report; each answer says how it was understood (M5 natural-language interface).
The orders list: paid orders in 2026 with the date hierarchy by month, the
status editable in place (list_editable) and the filter sidebar (M1 lists,
M6.5 admin parity).
An order's edit page: fieldsets with several fields per line, a collapsed "Payment" section and the order items as a tabular inline (M6.5 forms and inlines).
A product's pictures as a stacked inline, with image previews, upload and replace (M6.5 files and images).
The permission tree used for users and groups — app, model, then view/add/change/delete — here for the "Shop managers" group (M6.5 user admin).
Importing products from CSV: the dry run shows which rows update, which are new and which have errors, and nothing is saved until all are valid (M6.5 import).
Global search (Ctrl+K) across every model the user may view, grouped by model (M6.5 global search).
The System page's database section: the largest tables by size and by rows, and every table with its model and exact row count (M6.5 System page).
The System page's Scale section: the pre-aggregations and the DuckDB copy, with freshness, hits and Refresh, Rebuild and Sync buttons (M7 scale).
A weekly e-mail of the Sales overview dashboard to three users; each gets the numbers their own account may see (M6 scheduled e-mail).
The same dashboard for a user who chose Persian and the Jalali calendar: right-to-left, Persian digits, Jalali months, and the dashboard's, widgets' and reports' own names and description in Persian (M2 Jalali calendar, per-user language and calendar, translatable names).
One report, two users: the manager sees all ten cities, tehran only Tehran,
Karaj and Qom (M4 row-level rules).
uv sync # install deps + dev tools
uv run python example_project/manage.py migrate
uv run python example_project/manage.py seed_demo --users # English data + demo accounts
uv run python example_project/manage.py gozar_calendar # calendar table (Jalali reports)
uv run python example_project/manage.py gozar_preaggregate # build the summary tables
./scripts/build-frontend.sh # React SPA → src/bi_admin/static/bi_admin/spa
uv run python example_project/manage.py runserver # open http://127.0.0.1:8000/gozar/
uv run pytestseed_demo --users creates the accounts below, all with the password
gozar-demo (--password changes it), to try the access rules:
| user | sees |
|---|---|
admin |
superuser: everything |
manager |
all shop data, the finance-only Refunds measure, the report audit log and question log |
tehran |
only Tehran, Karaj and Qom (row rules), no Refunds |
east |
only Mashhad and Kerman |
It also creates demo content owned by manager and shared with everyone: four
saved reports, the Sales overview dashboard (manager's home page) and a
weekly e-mail of it to manager, tehran and east — named and described in
English and Persian (seed_demo --content-only refreshes just this content in a
seeded database). Send it now with
manage.py gozar_send_schedules --schedule 1: the e-mails land in
example_project/.mail/ (file e-mail backend; set
GOZAR_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend to print
them), and each recipient's numbers differ by region.
The demo also has product pictures and a datasheet (written to
example_project/media/ through Django's storage API; seed_demo --no-media
skips them), tags, and order items and product images as inlines.
scripts/screenshots.py recreates the screenshots
above in docs/screenshots after UI changes. Seed the demo first; the script
runs the example project on a temporary copy of db.sqlite3 (port 8100), so it
changes no demo data:
uv run --with playwright playwright install chromium # once
uv run --with playwright python scripts/screenshots.py # --only report-pivot-table …scripts/check_layout.py uses the same setup to check
the layout in English and Persian at phone and desktop widths: no table on the
System page or in the logs scrolls sideways from 1280 px up, and pie legends show
every slice name whole (--shots DIR keeps a screenshot of each page). The layout
rules behind charts on narrow widgets are unit-tested by npm test in frontend/.
Frontend hot reload: run npm run dev in frontend/ and start Django with
GOZAR_VITE_DEV_SERVER=http://localhost:5173.
SQLite is used by default. For PostgreSQL (16+):
uv sync --extra postgres
GOZAR_DB=postgres POSTGRES_HOST=localhost uv run pytestmanage.py gozar_worker runs background report jobs and e-mail schedules,
keeps the pre-aggregations fresh and sends alert messages. The optional DuckDB engine (and its parity
tests) needs the extra; then put the demo's OrderMetrics on it:
uv sync --extra duckdb # add --extra postgres as needed
GOZAR_DUCKDB=1 uv run python example_project/manage.py gozar_columnar_sync
GOZAR_DUCKDB=1 uv run python example_project/manage.py runservermanage.py gozar_benchmark seeds a million orders (--orders) into
example_project/.benchmark/ — never db.sqlite3 — or into a dedicated
PostgreSQL database (--postgres-db gozar_bench), and times ten reports on the
ORM, the pre-aggregations and DuckDB (measured numbers: DESIGN.md §10, M7).
# settings.py
INSTALLED_APPS += ["bi_admin"]
# urls.py
urlpatterns += [path("gozar/", include("bi_admin.urls"))]Every model is discovered automatically. Customise one in a gozar.py module
inside any app (auto-discovered, like admin.py). The options are
ModelAdmin's, so admin classes port over with few changes:
from bi_admin.crud import ModelOptions, StackedInline, TabularInline, action, display, register
class ItemInline(TabularInline):
model = OrderItem
extra = 1
autocomplete_fields = ["product"]
@register(Order)
class OrderOptions(ModelOptions):
list_display = ["__str__", "customer", "customer__city__name", "status", "amount", "is_paid"]
list_editable = ["status"]
search_fields = ["customer__name", "^customer__phone"]
search_help_text = "Search by customer name or phone number."
list_filter = ["status", "created_at", "customer__city"]
date_hierarchy = "created_at"
raw_id_fields = ["customer"]
radio_fields = {"status": "horizontal"}
fieldsets = [
(None, {"fields": ["customer", "status", ("amount", "created_at")]}),
("Payment", {"fields": ["paid_at"], "classes": ["collapse"]}),
]
readonly_fields = ["paid_at"]
inlines = [ItemInline]
actions = ["mark_cancelled"]
@display(description="paid", boolean=True, ordering="paid_at")
def is_paid(self, order):
return order.status == "paid"
@action("Cancel selected orders")
def mark_cancelled(self, request, queryset):
return {"updated": queryset.filter(status="pending").update(status="cancelled")}- Forms:
fieldsets(several fields per line,collapse, descriptions),widgets(textarea,radio,select,rich_select,raw_id,autocomplete,dual_list,permissions…) plus admin'sradio_fields,raw_id_fields,autocomplete_fields,filter_horizontal/filter_vertical,readonly_fieldswith computed callables,prepopulated_fields,form,add_form/add_fieldsets,save_as,save_on_topandview_on_site(get_absolute_url). Misconfigurations are system checkbi_admin.E005. - Inlines:
TabularInline/StackedInlinewithextra,min_num,max_num,can_delete,show_change_linkand the inline model's own permissions; the parent and every row are validated as Django formsets and saved in one transaction. - Files and images:
FileField/ImageFieldupload, preview, replace and clear through Django's storage API (STORAGES["default"]), limited byGOZAR["UPLOAD_MAX_BYTES"](10 MB).ImageFieldneeds Pillow (pip install "gozar[images]"). ServeMEDIA_URLas for any Django project. - Lists:
list_editable(one Save, all or nothing), boolean icons,empty_value_display,sortable_by,show_full_result_count,list_select_related,search_help_text, the date hierarchy in the user's calendar, and select-all-matching for actions. - Users: the two-password add form, a change-password page with the validators, a dual list for groups and a permission tree (app → model → view/add/change/delete). Non-superusers can't touch superusers or grant permissions they don't hold.
- Beyond admin: CSV/XLSX export of any filtered list (or a selection),
CSV/XLSX import with a dry-run report (rows matched by ID update, the rest
are added; headers by field name or label in English or Persian; nothing is
saved while any row has an error), bulk edit of chosen fields, per-user
saved views (search, filters, ordering, columns; one can be the
default), global search across every model you may view (Ctrl+K),
field-level diffs in the history, recent actions on the home page and
keyboard shortcuts (Ctrl+S saves,
/searches the list,?lists them).
Row-level rules apply everywhere: inline rows, relation pickers, list editing, bulk edit, import (a row outside your scope is "not found"), export, search, history diffs (a related row you can't see shows as Restricted) and recent actions.
Superusers, and staff with the bi_admin.view_system permission, get
System in the sidebar: database engine, version, host and name (never
credentials), total size, every table with its model or kind, estimated and
exact (on demand) row counts, table/index/total size and indexes (PostgreSQL
through pg_class and the size functions, SQLite through dbstat or
page_count × page_size), the largest tables, unapplied migrations, Django's
system checks including the deployment checks, versions, installed apps, a
cache check, the report job queue and worker heartbeat, users and sessions,
change-log activity, report usage, recent e-mail deliveries and media usage.
Statistics run on GOZAR["REPORTS_DATABASE"] when set and are cached for
GOZAR["SYSTEM_CACHE_TIMEOUT"] (60 s); Refresh recomputes them. Its
Scale section lists the pre-aggregations (rows, size, last refresh,
freshness, hits) and columnar engines (file, last sync) with Refresh,
Rebuild and Sync now buttons, which queue jobs for the worker.
Describe what can be reported on in the same gozar.py. Labels, synonyms and
descriptions (Persian and English) feed the catalog and the NL interface:
from django.db.models import Count, Q, Sum
from bi_admin.semantic import Dimension, Measure, Semantic, register
@register
class OrderMetrics(Semantic): # ReportSpec.semantic_model = "order_metrics"
model = Order
time_dimension = "created_at"
dimensions = {
"created_at": Dimension("created_at", type="time",
label={"fa": "تاریخ ثبت سفارش", "en": "Order date"}),
"city": Dimension("customer__city__name", label={"fa": "شهر", "en": "City"},
synonyms={"fa": ["محل"]}),
"status": Dimension("status"), # type and labels inferred from the field
}
measures = {
"revenue": Measure(Sum("amount", filter=Q(status="paid")), format="currency",
label={"fa": "درآمد", "en": "Revenue"},
synonyms={"fa": ["فروش", "گردش مالی"], "en": ["sales"]}),
"orders": Measure(Count("id"), label={"fa": "تعداد سفارش", "en": "Orders"}),
}Then run a ReportSpec (POST /gozar/api/reports/run, or
bi_admin.engine.run(spec, request) in Python):
Grains are day/week/month/season/year in the Gregorian calendar (the default)
or the Jalali one (grouped in the database through the CalendarDay table — run
manage.py gozar_calendar once). Relative periods such as this_month, last_season,
last_3_months, past_30_days or last_year are resolved in Python against
today's date. POST /gozar/api/reports/validate returns structured errors with
suggestions, and GET /gozar/api/reports/catalog (user's language; ?lang=fa|en|all) lists the semantic
models the user may report on. Reports need the same staff session and model
view permission as the CRUD API.
Reports in the sidebar opens the report builder: pick the data (semantic
model), measures, group-by fields, date field, grain, calendar, a relative
period or a custom date range, filters (value pickers search the dimension's
values), sort and row limit. The spec is validated as you edit — problems show
next to the field — and the report re-runs automatically. Results show as a
sortable table, bar/line/area/pie charts (Apache ECharts), a pivot table (two
group-by fields) or KPI cards (no group-by). Click any value, bar, slice or
pivot cell to open the records behind it, linked to their edit pages. Export
the result as CSV or Excel. The whole report lives in the URL
(/gozar/reports?spec=…), so Copy link shares it.
Endpoints behind it (all staff session + the model's view permission):
GET /api/reports/values?semantic_model=&dimension=&q= (filter values),
POST /api/reports/drilldown ({"spec": …, "cell": {"city": "Tehran", "created_at": "2026-09-01"}, "measure": "revenue", "page": 1} → paginated
records) and POST /api/reports/export?format=csv|xlsx (body: the spec;
headers in the user's language).
- Comparison periods:
ReportSpec.compare_to=previous_period(the span just before:last_3_months→ the 3 months before,this_monthto date → the same days of last month) orsame_period_last_year(a year back in the report's calendar; weeks move 52 weeks). It needs a period — a relative period or a date range on the time dimension. Each measure gets<measure>__compare,__changeand__change_pctcolumns (rolecomparison), and time buckets line up across the two periods (Esfand 1404 ↔ Esfand 1403, Dec 2025 ↔ Dec 2024). The builder's Compare to control shows them as table columns with ▲/▼, KPI deltas and a dashed second series in line and bar charts; clicking a comparison value drills into the comparison period ("comparison": truein/reports/drilldown). - Saved reports (
/api/saved-reports, Saved reports in the sidebar): named specs with a description and sharing — only me, specific users and groups, or everyone who may see the data. Save, save as, rename/share, duplicate and delete from the builder (/reports?saved=<id>) and from an Ask answer. Only the owner edits; anyone who can open one may duplicate it. A shared report always runs as the viewer: their permissions, visible measures and row-level scope. - Dashboards (
/api/dashboards, Dashboards): a 12-column grid of widgets — saved reports or reports defined in the dashboard — each with its own chart and size. The owner adds, removes, resizes and drags them into place (react-grid-layout) and picks which category filters viewers get; viewers set a period or date range and values (e.g. cities), which apply to every widget whose data has that field (POST /api/dashboards/<id>/resolvereturns each widget's effective spec). Widgets load in parallel through/reports/run(result cache and background jobs included) and keep drill-down and export. Pin as home page shows a dashboard on the home page. - E-mail schedules (
/api/schedules, Email schedules): a saved report or dashboard sent daily, weekly or monthly (a Gregorian or Jalali day of the month) at a local time in a time zone (the owner's Settings → Time zone by default). Recipients are users of the system who can open it; each gets an HTML e-mail with the tables and KPI values (comparisons included) built as them — in their language and calendar — plus CSV or XLSX attachments. Send due schedules withmanage.py gozar_send_schedulesfrom cron (--dry-run,--schedule ID), or letgozar_workerdo it (WORKER_SENDS_SCHEDULES). Uses Django's e-mail framework (EMAIL_BACKEND,DEFAULT_FROM_EMAILorGOZAR["EMAIL_FROM"]);GOZAR["BASE_URL"]adds an "Open in Gozar" link. Every e-mail is logged (GET /api/audit/deliveries, Audit log → Email deliveries) and every report it ran is in the audit log with kind Scheduled email.
English and Gregorian are the defaults; Persian (RTL, Persian digits) and the
Jalali calendar are a per-user choice on the Settings page (top bar), saved
through GET/PATCH /gozar/api/preferences. API labels, verbose names,
validation errors and report labels come back in the user's language, and a
ReportSpec without calendar uses the user's calendar. Add the middleware
(it replaces Django's LocaleMiddleware) and list both languages:
MIDDLEWARE = [
...,
"django.contrib.auth.middleware.AuthenticationMiddleware",
"bi_admin.middleware.PreferencesMiddleware",
...,
]
LANGUAGE_CODE = "en"
LANGUAGES = [("en", "English"), ("fa", "فارسی")]
GOZAR = {"DEFAULT_LANGUAGE": "en", "DEFAULT_CALENDAR": "gregorian"}Translations ship as gettext catalogs (src/bi_admin/locale/fa, compiled .mo
included). After changing strings, run django-admin makemessages -l fa and
django-admin compilemessages in src/bi_admin (and example_project/shop);
tests/test_locale.py fails while a string in the code has no Persian
translation, is fuzzy, or the .mo is out of date. SPA strings live in
frontend/src/i18n/en.ts and fa.ts (typed against en, and npm test checks
their placeholders match).
What users type — the names and descriptions of saved reports, dashboards and
e-mail schedules, and widget titles — can be given in every configured language:
the dialogs show one field per language, and each viewer (and each e-mail
recipient) gets their language, else the default language's text. The API
returns name/description (widgets: title) in the viewer's language plus
name_i18n/description_i18n/title_i18n ({"en": "…", "fa": "…"}), and
accepts the latter; search, exports (?title= names the file and sheet) and
e-mails use the viewer's language.
Mixed-direction text: user and data text is isolated wherever it appears —
English names inside Persian pages and Persian inside English ones keep their
order, in pages, charts (ECharts labels and tooltips), tooltips, messages and
e-mails. In SPA code, render such text in <bdi> or <UserText> (or pass it
through isolate() when it leaves the DOM, e.g. chart labels) — see
frontend/src/bidi.tsx; npm run lint:bidi (part of npm run build) flags
JSX that forgets.
Row-level rules decide which rows a user may see and touch. A rule takes the
user and returns a Q, True/None (no restriction) or False (no rows):
from bi_admin.access import rule
rule(Order, lambda user: Q(customer__city__in=user.profile.cities.all())) # a model
rule(OrderMetrics, lambda user: Q(status="paid")) # reports on one semantic model only
@register(Customer)
class CustomerOptions(ModelOptions):
row_rules = [lambda user: Q(city__in=user.profile.cities.all())] # same, on the optionsRules are AND-ed and applied in the engine and the CRUD service, so every path
honours them: report run, drill-down, export, filter value pickers, background
jobs, CRUD list/filters/detail/history, autocomplete and relation fields,
actions and delete plans. Related rows outside the scope never show either: M2M
lists leave them out (saving keeps those hidden links), a hidden FK target and
list cells read through one show as Restricted. Saving a row so it would fall outside the user's scope
is refused (403 out_of_scope), and a delete that would cascade into rows they
can't see is blocked. Superusers bypass rules (ROW_RULES_BYPASS_SUPERUSER). A
rule that raises denies every row. The demo (shop/gozar.py) limits the
"Sales: … region" groups to their cities.
Semantic visibility: Dimension(..., permission="shop.view_margin") or
Measure(..., permission=lambda user: ...) (a permission, a list of them, or a
predicate). What a user may not see is left out of their catalog and rejected
exactly like an unknown key — in validation, sort, filters, drill-down, export
and value lookups — and list columns only such items expose don't appear in
drill-down rows. The demo's Refunds measure needs shop.change_order.
Audit log: every run, export and drill-down (also refused and failed ones,
and background jobs) is stored in bi_admin.ReportLog with the user, the
normalised spec, the row-level scope (the WHERE clause), duration, row count,
status, error code and cache hit. Users with bi_admin.view_reportlog get
Audit log in the sidebar (GET /api/audit/reports, filterable).
- Limits:
REPORT_MAX_ROWS,REPORT_MAX_DIMENSIONS,REPORT_MAX_FILTERS(422withlimit_exceeded,too_many_dimensions,too_many_filters) andREPORT_STATEMENT_TIMEOUT_MS(PostgreSQLSET LOCAL statement_timeout; an interrupting progress handler on SQLite) →504 statement_timeout. - Result cache (Django's cache framework,
REPORT_CACHEalias,REPORT_CACHE_TIMEOUTseconds,0= off): keyed by the normalised spec, the user's scope, the language and a data version per model the report reads, which changes on every save/delete (signals) — callbi_admin.execution.cache.bump(Model)after bulk writes. The response'scachesays{"hit", "stored_at"};?cache=false(the SPA's Refresh) bypasses it. Use a cache shared by all processes (Redis, Memcached, files — notLocMemCache) when you run several workers or the job worker. - Read replica:
GOZAR["REPORTS_DATABASE"] = "replica"plusDATABASE_ROUTERS = ["bi_admin.execution.router.ReportsRouter"]sends report, drill-down, export and value-lookup reads there; CRUD, all writes, the audit log and jobs stay ondefault. - Background jobs: reports run synchronously by default. With
REPORT_ASYNC = True,Semantic.run_async = TrueorPOST /api/reports/run?mode=async, the API answers202 {"job": …}and the SPA pollsGET /api/reports/jobs/<id>(then/result,/export,/cancel). The defaultDatabaseBackendstores jobs inbi_admin.ReportJob; runmanage.py gozar_worker(--burstto drain the queue and exit). Running jobs hold a lease kept alive by a heartbeat; if a worker dies, its job is requeued afterJOB_LEASE_SECONDS(60) and failed (worker_lost) afterJOB_MAX_ATTEMPTS(3). Plug in anotherJOB_BACKEND(e.g. one that enqueues a Celery task callingbi_admin.execution.jobs.execute(job_id)).
Declare summary tables on a semantic model; Gozar creates them (no migrations), answers every report they can answer exactly from them, and falls back to the ORM for the rest:
from bi_admin.scale import PreAggregation
@semantic.register
class OrderMetrics(semantic.Semantic):
...
pre_aggregations = [
PreAggregation(
"daily_sales",
measures=["revenue", "orders", "aov"], # sums, counts, averages, min/max
dimensions=["city", "status"],
time_grain="day", # day buckets serve every grain, both calendars
scope_fields=["customer__city__name_en"], # what the row rules read
incremental_window=7, # scheduled refreshes recompute recent days
),
]- When it is used: every measure and group-by stored, filters only on stored
dimensions or the time dimension, date ranges (and the comparison period) on
bucket boundaries, a compatible grain and calendar, and the user's row rules
expressible on stored columns — otherwise it steps aside. Distinct counts and
anything crossing a to-many relation are refused when the class is loaded.
The response's
execution(engine,pre_aggregation,consideredwith the reasons) and the audit log say which path ran; the report page shows it. - Freshness: a table is fresh until a model it read changes (the result
cache's data versions); stale ones answer only within
max_staleness(GOZAR["PREAGG_MAX_STALENESS"], default 0). Writes record which days changed, so refreshes can be incremental;gozar_workerrefreshes what is due (PREAGG_REFRESH_EVERY,PREAGG_REBUILD_EVERY),manage.py gozar_preaggregate [--rebuild|--incremental|--due|--list]does it by hand. - DuckDB (
pip install "gozar[duckdb]"):engine = "duckdb"on the semantic model orGOZAR["SEMANTIC_ENGINES"] = {"order_metrics": "duckdb"},GOZAR["DUCKDB"] = {"PATH": BASE_DIR / "gozar.duckdb"}, thenmanage.py gozar_columnar_sync(or the worker, everySYNC_EVERYwhen stale). SQL is generated from the validated spec only; results equal the ORM's, including sort order under your database's collation; whatever it can't reproduce exactly goes to the ORM.bi_admin.scale.columnar.ColumnarBackendis the interface for other engines (ClickHouse is not implemented).
The Ask box on the home page and at the top of Reports takes a question
in English or Persian. Gozar turns it into a ReportSpec — never SQL — checks
it with the same validator, permissions and row rules as the builder, and shows
an interpretation sentence ("Revenue by City, the last 3 months (Jul 1 – Sep 30,
2026), where Status is Paid.") with the report. Follow-up questions edit the
current report ("now only Tehran", "make it monthly", "by province instead",
«فقط تهران», «ماهانهاش کن»); Open in report builder hands the spec to the
normal filter UI.
- Offline by default. With no model configured, a deterministic interpreter answers: measures, group-by fields, time grain, relative periods, explicit dates/years/months (Jalali or Gregorian), category and choice filters, number comparisons, top/bottom N, sort and chart type, in both languages. It uses the semantic layer's labels and synonyms, so better synonyms mean better answers.
- Values are matched on the server: «تهران» finds
Tehran(and vice versa) through letter normalisation, transliteration and fuzzy matching against the distinct values the user may see; an unknown value gets a "did you mean" question with in-scope suggestions only. - Privacy: only catalog metadata (keys, labels, synonyms, descriptions,
declared choices) and the user's words reach a model — never rows. Category
values in a spec being refined are sent as placeholders (
@v1). - Relative dates are resolved by Gozar, not by the model, in the user's calendar.
Pointing Gozar at a real LLM (any OpenAI-compatible /chat/completions):
# hosted (OpenAI or a compatible gateway)
export GOZAR_NL_BASE_URL=https://api.openai.com/v1
export GOZAR_NL_MODEL=gpt-4o-mini
export GOZAR_NL_API_KEY=sk-... # read from the environment only
# or local: Ollama / vLLM (no key needed)
export GOZAR_NL_BASE_URL=http://localhost:11434/v1 # vLLM: http://localhost:8000/v1
export GOZAR_NL_MODEL=qwen2.5:7b-instructor in settings (the key still comes from the environment; a literal key in
settings fails system check bi_admin.E003):
GOZAR = {
"NL": {
"BASE_URL": "http://localhost:11434/v1",
"MODEL": "qwen2.5:7b-instruct",
"API_KEY_ENV": "GOZAR_NL_API_KEY", # name of the env variable holding the key
"TIMEOUT": 30,
"RESPONSE_FORMAT": "json_schema", # or "json_object" / "none" for older servers
}
}The model must return the spec as JSON (structured outputs are requested and
JSON mode is the automatic fallback). Invalid specs are sent back with the
validation errors (MAX_RETRIES, 2); if the server can't be reached, the
offline interpreter answers instead (FALLBACK). Other GOZAR["NL"] keys (see
bi_admin/nl/conf.py): PROVIDER (auto | openai | heuristic | stub |
a dotted path), TEMPERATURE, EXTRA_HEADERS, TOP_K and MAX_MODELS
(catalog retrieval), VALUE_INDEX_MAX/VALUE_INDEX_TIMEOUT, CACHE_TIMEOUT
(NL → spec cache, 3600 s), LOG_PROMPTS (opt-in: keep prompts and raw outputs
for evaluation), MAX_QUESTION_LENGTH (500) and RATE_LIMIT (30 questions per
user per minute).
Every question is recorded in bi_admin.NLLog (readable with
bi_admin.view_nllog on the Question log page or GET /api/audit/questions); reports it runs are in the report audit log as usual.
Declare few-shot examples per semantic model with nl_examples = [{"question": …, "spec": {…}}] — they also become the suggested questions in the UI.
API: POST /api/nl/ask {"question", "previous"?, "execute"?} → {status: ok | clarification | invalid, spec, interpretation, clarification, suggestions, warnings, …} (with execute, also the report result or a background
job); GET /api/nl/status; POST /api/nl/interpret {"spec"}.
POST /api/reports/validate returns the interpretation too.
The full reference — pipeline, privacy guarantees, providers, every setting, recipes for OpenAI, Claude, gateways and Ollama/vLLM, evaluation and the open work — is docs/nl-interface.md.
Evaluation: tests/nl_golden/ holds 198 questions (130 Persian, 44 English,
24 follow-ups) with their expected specs. CI scores them against the offline
interpreter and a stub model; score a real model with
GOZAR_NL_BASE_URL=... GOZAR_NL_MODEL=... uv run python example_project/manage.py \
gozar_nl_eval --verbose-failures --json nl-report.json(exact and semantic match rate, clarification rate and recall, retry rate;
--provider, --only fa, --user, --fail-under 0.9). Expected values are
those of the English demo data (seed_demo).
Gozar records operational alerts and sends them to a Telegram bot. Each one is
on the System page (Alerts: channel status, sources, a Send test alert
button, recent alerts with filters and Acknowledge) for users with the
System page permission. Sources, each with an on/off switch and a minimum
severity (info < warning < error < critical), set in settings and
changeable on the System page:
| Source | What raises it |
|---|---|
exceptions |
unhandled exceptions and HTTP 5xx responses (a logging handler on django.request): method, path, route, user id, exception type and message, the innermost traceback frames and the release, scrubbed of secrets |
jobs |
background jobs that failed on the server (5xx), and worker_lost |
worker |
no gozar_worker heartbeat for WORKER_STALE_SECONDS, or a worker crash |
schedules |
e-mail schedule sends with failed recipients (resolved by the next clean send) |
preaggregations, columnar |
refresh/sync failures, and copies stale longer than max(max_staleness, refresh interval) + STALENESS_GRACE |
nl |
NL_ERRORS language model errors or fallbacks within NL_ERRORS_WINDOW |
checks |
Django's system and deployment checks (errors and warnings) at worker start and every SYSTEM_CHECKS_EVERY |
database |
database size, table growth and disk usage above their thresholds |
logins |
FAILED_LOGINS failed logins from one IP within FAILED_LOGINS_WINDOW (never the username or password) |
telegram |
someone outside the allow-list writing to the bot |
test |
the test button and manage.py gozar_alert_test |
Every alert has a stable key: the same key is sent at most once per window
(WINDOW, per source window), and the next message after the window says how
many repeats were held back. A condition that clears sends a resolved notice.
In optional quiet hours only critical alerts are sent; the rest wait until they
end. Requests never send anything: alerts go to an outbox that gozar_worker
sends (every 5 s) with retries and exponential backoff; Telegram failures are
only logged.
Set it up with a bot from @BotFather and the numeric ids of the people who should get the alerts. The token is read only from the environment; never put it (or real ids) in settings files:
export GOZAR_TELEGRAM_BOT_TOKEN=... # environment only; never stored or shown
export GOZAR_TELEGRAM_ALLOWED_CHAT_IDS=... # numeric user/chat ids, comma separated
uv run python example_project/manage.py gozar_telegram_whoami # getMe: is the token right?
uv run python example_project/manage.py gozar_alert_test # send a test alert nowThe bot sends only to the allowed ids. Without a token and a non-empty
allow-list the channel stays off (system check bi_admin.W002, a warning on
the System page); a token in settings is system check error bi_admin.E006.
With INTERACTIVE on, the worker (or manage.py gozar_telegram_poll) long-polls
getUpdates — no webhook — and answers /status, /alerts, /ack <id>,
/mute <key> <duration>, /unmute <key> and /help with operational status
only, never report data. It answers only plain messages in a private chat whose
chat id is the sender's id and in the allow-list; everything else (groups,
forwarded messages, other senders) is ignored without a reply, with a
rate-limited log line and a telegram alert for messages from strangers.
manage.py gozar_alerts runs the checks and sends the outbox without a worker;
run it from cron as a watchdog, since a dead worker can't report itself.
Environment variables: GOZAR_TELEGRAM_BOT_TOKEN (the token; the variable's
name is TELEGRAM["TOKEN_ENV"]), GOZAR_TELEGRAM_ALLOWED_CHAT_IDS (used when
TELEGRAM["ALLOWED_CHAT_IDS"] isn't set), GOZAR_RELEASE (the release shown in
alerts; default Gozar's version plus the git sha). The demo also reads
GOZAR_ALERTS_LANGUAGE, GOZAR_ALERTS_QUIET_HOURS and
GOZAR_TELEGRAM_INTERACTIVE=1. Settings, with their defaults
(bi_admin/alerts/conf.py):
GOZAR["ALERTS"] = {
"ENABLED": True, "LANGUAGE": "en", # "en" | "fa" (right-to-left messages)
"WINDOW": 900, # seconds between messages of one key
"QUIET_HOURS": None, "QUIET_HOURS_TIMEZONE": None, # "23:00-07:00", TIME_ZONE
"SOURCES": {}, # {"checks": {"enabled": False, "min_severity": "error", "window": 3600}}
"LOG_HANDLER": True, # attach the handler to django.request
"RELEASE": None, "TRACEBACK_FRAMES": 4,
"MAX_ATTEMPTS": 6, "RETRY_BACKOFF": 30, "RETRY_BACKOFF_MAX": 3600,
"DELIVERY_LEASE_SECONDS": 120, "KEEP_DAYS": 30,
"CHECK_EVERY": 300, "SYSTEM_CHECKS_EVERY": 21600, "WORKER_STALE_SECONDS": 300,
"STALENESS_GRACE": 600,
"DATABASE_MAX_BYTES": None, "TABLE_GROWTH_PERCENT": None,
"TABLE_GROWTH_MIN_BYTES": 52428800, "TABLE_GROWTH_PERIOD": 86400,
"DISK_USAGE_PERCENT": 90, "DISK_PATHS": None, # None: MEDIA_ROOT and the SQLite file
"FAILED_LOGINS": 10, "FAILED_LOGINS_WINDOW": 600,
"CLIENT_IP_HEADER": None, # e.g. "HTTP_X_FORWARDED_FOR" behind a trusted proxy
"NL_ERRORS": 5, "NL_ERRORS_WINDOW": 600,
"TELEGRAM": {
"ENABLED": None, # None: on when token and ids are set
"TOKEN_ENV": "GOZAR_TELEGRAM_BOT_TOKEN",
"ALLOWED_CHAT_IDS": None, # None: GOZAR_TELEGRAM_ALLOWED_CHAT_IDS
"INTERACTIVE": False, "POLL_IN_WORKER": True,
"API_URL": "https://api.telegram.org", "TIMEOUT": 10, "POLL_TIMEOUT": 25,
"MAX_MUTE": 604800,
},
}With GOZAR["BASE_URL"] set, every message links to the alert on the System
page. Your own code can raise alerts too:
from bi_admin import alerts; alerts.raise_alert("jobs", "nightly-import", "error", "Nightly import failed", str(exc)) and alerts.resolve("jobs", "nightly-import").
Optional settings (defaults in bi_admin/conf.py): GOZAR = {"INCLUDE_APPS": [...], "EXCLUDE_APPS": [...], "EXCLUDE_MODELS": ["app.model"], "VITE_DEV_SERVER": None, "WEEKEND_DAYS": [4], "DEFAULT_LANGUAGE": "en", "DEFAULT_CALENDAR": "gregorian", "ROW_RULES_BYPASS_SUPERUSER": True, "AUDIT_REPORTS": True, "REPORT_MAX_ROWS": 10000, "REPORT_MAX_DIMENSIONS": 6, "REPORT_MAX_FILTERS": 20, "REPORT_STATEMENT_TIMEOUT_MS": 30000, "REPORTS_DATABASE": None, "REPORT_CACHE": "default", "REPORT_CACHE_TIMEOUT": 300, "REPORT_ASYNC": False, "JOB_BACKEND": "bi_admin.execution.jobs.DatabaseBackend", "JOB_LEASE_SECONDS": 60, "JOB_MAX_ATTEMPTS": 3, "API_DOCS": "staff", "NL": {…}, "EMAIL_FROM": None, "EMAIL_MAX_ROWS": 50, "BASE_URL": None, "WORKER_SENDS_SCHEDULES": True, "UPLOAD_MAX_BYTES": 10485760, "CRUD_EXPORT_MAX_ROWS": 50000, "CRUD_IMPORT_MAX_ROWS": 5000, "BULK_EDIT_MAX_ROWS": 1000, "GLOBAL_SEARCH_PER_MODEL": 5, "SYSTEM_CACHE_TIMEOUT": 60, "ALERTS": {…}} (alerts: above).
The JSON API (django-ninja, staff session + CSRF) is documented at
/gozar/api/docs (Swagger UI; schema at /gozar/api/openapi.json). The docs are
for staff only by default — anonymous visitors are sent to the login first; set
GOZAR["API_DOCS"] = "public" to open them. They work wherever you mount
bi_admin.urls, including under your own instance namespace
(include("bi_admin.urls", namespace="reports")).
src/bi_admin/— the installable app:discovery/— registry built from_meta, relation graph, lookup-path validation;crud/—ModelOptionsand inlines (theModelAdmincounterparts), the CRUD service, forms and uploads, inline formsets, the date hierarchy, list editing and bulk edit, import/export, saved views, global search and the user admin's pages;system/— the System page's database statistics (per backend), checks and Scale section;semantic/—Semantic,Dimension,Measure,@register, the catalog;engine/—ReportSpec, validator, ORM compiler, runner, drill-down, export (CSV/XLSX), dimension values;calendar/— Jalali helpers,CalendarDaytable builder, relative periods;access/— row-level rules and the report audit log;execution/— statement timeout, result cache, replica router, background jobs;scale/— pre-aggregations (declaration, additive parts, matcher, refresh, freshness and change records, scope translation), the engine planner and the columnar engines (columnar/duckdb.py);alerts/— operational alerts: settings, sources, the pipeline (dedup, windows, quiet hours, mutes), the outbox, the Telegram channel and bot, the logging handler, the periodic checks and the secret scrubber;api/— auth, metadata, CRUD, report, job, audit, preferences, saved-report, dashboard and schedule endpoints;sharing.py,dashboards.py,scheduling.py,formatting.py— who may open saved reports and dashboards, widget specs and global filters, e-mail schedules (due times, per-recipient e-mails), server-side value formatting;preferences.py,middleware.py— per-user language/calendar;translations.py— per-language names and descriptions of saved reports, dashboards, widgets and schedules (Translatable);locale/fa/— Persian gettext catalog;nl/— the NL interface: text normalisation and transliteration, the visible catalog and retrieval, the value index, the prompt, providers (OpenAI-compatible, offline heuristic, stub), the pipeline, the interpretation sentence and the golden-set evaluation;
frontend/— React + TypeScript SPA (Vite);src/i18n/holds the en/fa dictionaries and formatting (LTR/RTL, digits, Gregorian/Jalali dates),src/reports/the report builder, results, ECharts charts, drill-down and the Ask box (AskBox.tsx,AskPanel.tsx) and saved reports,src/dashboards/the dashboard view, grid editor and global filters,src/schedules/e-mail schedules,src/system/the System page,src/pages/list/the list's hierarchy bar, views menu, import and bulk-edit dialogs,src/components/FieldInput.tsx(withInlineEditor,DualList,PermissionTree,FileInput,RawIdInput) the form widgets,src/components/DatePicker.tsxthe Gregorian/Jalali date picker,src/components/TranslatedInput.tsxthe per-language name fields, andsrc/bidi.tsxthe mixed-direction helpers (UserText,isolate()) checked byscripts/check-bidi.mjs.example_project/— demo project with theshopapp (City, Customer, Tag, Product, ProductImage, Order, OrderItem), itsgozar.py(CRUD options with inlines +OrderMetrics), a public product page (the "View on site" target) and theseed_democommand (--seed,--customers,--orders,--days,--flush,--locale en|fa,--users,--password,--no-media,--content-only) andgozar_benchmark. The default English data set uses real Iranian cities with English names;--locale fawrites the same data with Persian names, handy for RTL demos and Arabic/Persian letter search (seed_demo --flush --locale fa).docs/— reference documents (nl-interface.md: the natural-language interface).tests/— pytest suite;tests/nl_golden/holds the NL golden set (question → expected ReportSpec, JSON).
















{ "semantic_model": "order_metrics", "measures": ["revenue", "orders"], "dimensions": ["city"], "time_grain": "month", "calendar": "jalali", // optional: defaults to the user's calendar (Gregorian) "relative_period": "last_3_months", "filters": [{"field": "status", "op": "in", "value": ["paid", "refunded"]}], "sort": [{"field": "revenue", "direction": "desc"}], "limit": 100 }