Skip to content
GeeknuxPublic

About

Gozar (Django-Bi-Admin): a Django admin replacement with BI-grade reporting and a natural-language (Persian-first) report interface.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Gozar — Django-Bi-Admin

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 Sales overview dashboard on the home page: KPI cards comparing this month with last month, monthly revenue against last year, orders by status and revenue by city

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).

Features tour

Every screenshot is from the example project's demo data (how to refresh them).

Report builder showing revenue by city for the last 3 months as a bar chart, with a lighter previous-period bar next to each city

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).

Pivot table of order counts with cities as rows and order statuses as columns

A pivot table of orders, cities by status, over the past 12 months (M3 pivot).

Drill-down dialog listing the Tehran orders behind one report cell, each order linked to its edit page

Clicking a report cell opens the Tehran orders behind it, each linked to its edit page (M3 drill-down).

The Ask box with the question "monthly revenue this year" and the follow-up "compared to last year", each with its interpretation, above the resulting line chart

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).

Orders list filtered to paid orders in 2026, with the month-by-month date hierarchy, editable status dropdowns in each row and the filter sidebar

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).

Order edit page with the customer, status, amount and date fieldsets, a collapsed Payment fieldset and the order items as a tabular inline

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).

Product edit page with its pictures as a stacked inline, each image previewed with its caption and position

A product's pictures as a stacked inline, with image previews, upload and replace (M6.5 files and images).

Group edit page with the permission tree expanded for the Shop app: view, add, change and delete checkboxes per model

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).

Import dialog after checking a products CSV: two updates, two new rows and one row with an invalid category, nothing saved

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 results for "Ahmadi" grouped by model: matching orders and customers

Global search (Ctrl+K) across every model the user may view, grouped by model (M6.5 global search).

System page: bar charts of the largest tables by size and by row count above the list of every table with its model, kind and exact row count

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 Scale section of the System page: two fresh pre-aggregations with rows, size, last refresh and hits, and the DuckDB columnar engine in use

The System page's Scale section: the pre-aggregations and the DuckDB copy, with freshness, hits and Refresh, Rebuild and Sync buttons (M7 scale).

Edit email schedule dialog: the Sales overview dashboard sent weekly on Saturday at 08:00 Tehran time to east, manager and tehran as Excel

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 Sales overview dashboard in Persian: right-to-left layout, Persian digits and Jalali month names on the chart axis

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).

The same revenue by city report side by side: the manager sees ten cities, the tehran user only Tehran, Karaj and Qom

One report, two users: the manager sees all ten cities, tehran only Tehran, Karaj and Qom (M4 row-level rules).

Development

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 pytest

seed_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 pytest

manage.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 runserver

manage.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).

Using it in a project

# 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")}

Data management (admin parity and beyond)

  • Forms: fieldsets (several fields per line, collapse, descriptions), widgets (textarea, radio, select, rich_select, raw_id, autocomplete, dual_list, permissions…) plus admin's radio_fields, raw_id_fields, autocomplete_fields, filter_horizontal/filter_vertical, readonly_fields with computed callables, prepopulated_fields, form, add_form/add_fieldsets, save_as, save_on_top and view_on_site (get_absolute_url). Misconfigurations are system check bi_admin.E005.
  • Inlines: TabularInline / StackedInline with extra, min_num, max_num, can_delete, show_change_link and 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/ImageField upload, preview, replace and clear through Django's storage API (STORAGES["default"]), limited by GOZAR["UPLOAD_MAX_BYTES"] (10 MB). ImageField needs Pillow (pip install "gozar[images]"). Serve MEDIA_URL as 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.

System page

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.

Reports: semantic models

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):

{
  "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
}

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.

Report UI

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).

Comparisons, saved reports, dashboards, e-mail

  • Comparison periods: ReportSpec.compare_to = previous_period (the span just before: last_3_months → the 3 months before, this_month to date → the same days of last month) or same_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, __change and __change_pct columns (role comparison), 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": true in /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>/resolve returns 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 with manage.py gozar_send_schedules from cron (--dry-run, --schedule ID), or let gozar_worker do it (WORKER_SENDS_SCHEDULES). Uses Django's e-mail framework (EMAIL_BACKEND, DEFAULT_FROM_EMAIL or GOZAR["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.

Language and calendar

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.

Access control

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 options

Rules 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).

Execution: limits, cache, replica, background jobs

  • Limits: REPORT_MAX_ROWS, REPORT_MAX_DIMENSIONS, REPORT_MAX_FILTERS (422 with limit_exceeded, too_many_dimensions, too_many_filters) and REPORT_STATEMENT_TIMEOUT_MS (PostgreSQL SET LOCAL statement_timeout; an interrupting progress handler on SQLite) → 504 statement_timeout.
  • Result cache (Django's cache framework, REPORT_CACHE alias, REPORT_CACHE_TIMEOUT seconds, 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) — call bi_admin.execution.cache.bump(Model) after bulk writes. The response's cache says {"hit", "stored_at"}; ?cache=false (the SPA's Refresh) bypasses it. Use a cache shared by all processes (Redis, Memcached, files — not LocMemCache) when you run several workers or the job worker.
  • Read replica: GOZAR["REPORTS_DATABASE"] = "replica" plus DATABASE_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 on default.
  • Background jobs: reports run synchronously by default. With REPORT_ASYNC = True, Semantic.run_async = True or POST /api/reports/run?mode=async, the API answers 202 {"job": …} and the SPA polls GET /api/reports/jobs/<id> (then /result, /export, /cancel). The default DatabaseBackend stores jobs in bi_admin.ReportJob; run manage.py gozar_worker (--burst to drain the queue and exit). Running jobs hold a lease kept alive by a heartbeat; if a worker dies, its job is requeued after JOB_LEASE_SECONDS (60) and failed (worker_lost) after JOB_MAX_ATTEMPTS (3). Plug in another JOB_BACKEND (e.g. one that enqueues a Celery task calling bi_admin.execution.jobs.execute(job_id)).

Scale: pre-aggregations and DuckDB

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, considered with 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_worker refreshes 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 or GOZAR["SEMANTIC_ENGINES"] = {"order_metrics": "duckdb"}, GOZAR["DUCKDB"] = {"PATH": BASE_DIR / "gozar.duckdb"}, then manage.py gozar_columnar_sync (or the worker, every SYNC_EVERY when 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.ColumnarBackend is the interface for other engines (ClickHouse is not implemented).

Asking questions (natural language)

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-instruct

or 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).

Alerts (Telegram)

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 now

The 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")).

Layout

  • src/bi_admin/ — the installable app:
    • discovery/ — registry built from _meta, relation graph, lookup-path validation;
    • crud/ — ModelOptions and inlines (the ModelAdmin counterparts), 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, CalendarDay table 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 (with InlineEditor, DualList, PermissionTree, FileInput, RawIdInput) the form widgets, src/components/DatePicker.tsx the Gregorian/Jalali date picker, src/components/TranslatedInput.tsx the per-language name fields, and src/bidi.tsx the mixed-direction helpers (UserText, isolate()) checked by scripts/check-bidi.mjs.
  • example_project/ — demo project with the shop app (City, Customer, Tag, Product, ProductImage, Order, OrderItem), its gozar.py (CRUD options with inlines + OrderMetrics), a public product page (the "View on site" target) and the seed_demo command (--seed, --customers, --orders, --days, --flush, --locale en|fa, --users, --password, --no-media, --content-only) and gozar_benchmark. The default English data set uses real Iranian cities with English names; --locale fa writes 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).

License

MIT

About

Gozar (Django-Bi-Admin): a Django admin replacement with BI-grade reporting and a natural-language (Persian-first) report interface.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages