Metadata-Version: 2.4
Name: django-hq
Version: 0.2.0
Summary: A developer console for Django projects: build, deployment, runtime and environment facts
Author: PragmaticMates
License-Expression: BSD-3-Clause
Project-URL: Homepage, https://github.com/PragmaticMates/django-hq
Project-URL: Repository, https://github.com/PragmaticMates/django-hq
Project-URL: Issues, https://github.com/PragmaticMates/django-hq/issues
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=4.2
Provides-Extra: sentry
Requires-Dist: sentry-sdk>=2.0; extra == "sentry"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-django; extra == "test"
Dynamic: license-file

# django-hq

A developer console for Django projects: which commit is live, when its image was built, when the
container came up, what is pending, and how full the disk is.

It answers the question you have at the worst possible moment — *what is actually running right
now?* — and it answers it without a CDN, without a build step and without an external service,
because those are the first things to be unavailable when you need to ask.

```
pip install django-hq
```

```python
# settings.py
INSTALLED_APPS = [
    ...
    "django.contrib.humanize",   # required: relative timestamps
    "hq",
]

HQ = {
    "SITE_NAME": "Widget",
    "RELEASE": {"REPO_URL": "https://github.com/acme/widget"},
}
```

```python
# urls.py
path("hq/", include("hq.urls")),
```

That is the whole install. `/hq/` is the console and `/hq/json/` is the same facts for anything
that reads rather than looks. Superuser-only by default.

Most of it works immediately. The build facts — commit, version, build number, image timestamp —
need the container to have been told them, which is what [the `/build` contract](docs/status.md)
is for.

## One page, made of panels

The console is a single page assembled from modules. Each contributes a panel, a fragment of the
JSON, and optionally a few tiles; `HQ["MODULES"]` decides **which appear and in what order**.

| Module | Panel |
|---|---|
| `release` | commit, subject, branch, version, commits since tag, build link, and a to-scale commit → build → deploy bar |
| `runtime` | container id, uptime, `DEBUG`, and the live OS / Python / Django / database versions |
| `migrations` | the whole pending plan, not a count — a number says something is wrong without saying what |
| `storage` | disk usage and what the application itself accounts for, as two charts |
| `environment_variables` | every variable, secrets reduced to a fingerprint — **not enabled by default** |
| `django_settings` | every setting, same treatment — **not enabled by default** |

```python
HQ = {
    "MODULES": [
        "hq.modules.storage.module.StorageModule",     # what you care about most, first
        "hq.modules.release.module.ReleaseModule",
        "hq.modules.runtime.module.RuntimeModule",
    ],
}
```

**A module that is not listed is never computed.** This is not merely a display filter: leaving
`django_settings` out means the settings walk never runs, and leaving `storage` out means nothing
walks your media directory.

## Settings

All optional. `HQ` is one dict; module settings nest under the module's slug in upper case.

### Console

| Key | Default | Meaning |
|---|---|---|
| `MODULES` | release, runtime, migrations, storage | Which panels, in what order. |
| `ACCESS_TEST` | superuser-only | Dotted path to `callable(user) -> bool`. A bad path raises rather than falling back — a typo here would widen access. |
| `TITLE` | `"Console"` | What the console calls itself, in the heading and page title. |
| `SITE_NAME` | `None` | Appended to the page title. |
| `HOME_URL` | `None` | URL name or literal path the brand mark links back to. `None` leaves it inert. |
| `FAVICON` | `None` | Static path. Resolved in Python, so a missing entry under manifest storage is not a 500. |
| `EXTRA_CSS` | `[]` | Static paths appended after the console's own stylesheets. |
| `FONTS_URL` | Google Fonts | Set `None` for a system font stack — offline deployments, strict CSP, or not wanting the request. |
| `ENVIRONMENT` | `settings.ENVIRONMENT` | A string, an `Enum` member or a callable; all three work. Leads the tile strip and marks `<body data-environment>`. |

### `HQ["RELEASE"]`

| Key | Default | Meaning |
|---|---|---|
| `REPO_URL` | `None` | Without it the repository tile and the commit/build links are simply absent. |
| `PROVIDER` | detected from the URL host | `bitbucket`, `github` or `gitlab`. |
| `COMMIT_URL_FORMAT` | from provider | `"{repo}/commit/{sha}"` — the escape hatch for anything else. |
| `BUILD_URL_FORMAT` | from provider | `"{repo}/actions/runs/{number}"`. |
| `RELEASE_PACKAGE` | `None` | Package name for `sentry_release()`. |

### `HQ["STORAGE"]`

| Key | Default | Meaning |
|---|---|---|
| `DISK_PATH` | `settings.MEDIA_ROOT` | Which filesystem the chart measures. |
| `MEASURE_DIRECTORY` | `True` | Whether to walk it at all. |
| `DIRECTORY_FILE_BUDGET` | `5000` | Past this the walk gives up and the panel says so, rather than holding a worker on a large upload tree. |

Secrets in the two dumps are never shown. They are reduced to eight hex characters of an HMAC
keyed off `SECRET_KEY` — enough to answer *did the container pick up the key I rotated* by
comparison, and nothing more.

## Writing a module

```python
from hq.registry import HQModule

class CronModule(HQModule):
    slug = "cron"                 # its key in /hq/json/ too
    label = "Scheduled jobs"
    icon = "timer"                # a key in hq.icons.LUCIDE_ICONS
    width = "half"                # or "full" (the default)

    def get_tiles(self, request):
        return [{"label": "Next run", "value": "in 12 min", "modifier": "accent"}]

    def get_panel(self, request):
        return {"template": "myapp/cron_panel.html", "context": {"jobs": jobs()}}

    def get_json(self, request):
        return {"next_run_seconds": 720}

    def is_available(self, request):
        return bool(settings.CRONTAB_PATH)
```

Then list it in `HQ["MODULES"]`. Everything except `slug` is optional; a module that renders
nothing and reports nothing is legal, merely useless.

Two things worth knowing:

- **A panel is rendered with its own context and nothing else.** It cannot come to depend on
  something a neighbouring module happened to provide, and so cannot break by being reordered.
  The flip side: a context of the wrong *shape* does not raise — a missing key in a Django
  template is silently falsy, and you get an empty panel. Test for something only your panel
  produces.
- **Collectors are memoised per request** via `hq.caching.per_request`, so answering tiles, panel
  and JSON in one request costs one read. Decorate yours the same way.

Slugs may not collide with the console's own JSON keys (`environment`, `generated_at`); the
registry refuses to start rather than let a module shadow one.

## Design notes

- **Nothing is fetched from a network.** The interface icons (lucide, ISC) and brand marks
  (Simple Icons, CC0) are vendored as inline SVG. The only optional external request is the
  webfont stylesheet, and `FONTS_URL = None` removes it.
- **A fact that cannot be read becomes `None`, never an exception.** A dead database drops one
  row rather than taking down the page you opened *because* the database looked dead.
- **The page and the JSON read the same collectors**, so they cannot drift apart. The JSON keys
  are deliberately not the page's labels: page text goes through `gettext` and is free to be
  translated, while timestamps are ISO-8601 with offsets and durations are seconds.
- **`hq.modules.release.buildinfo` imports nothing from Django** and must stay that way. Projects
  import it from their *settings* module to label an admin header or a Sentry release, and at
  that moment `django.conf.settings` is still being constructed. There is a test that enforces
  this in a subprocess.

## Development

```bash
pip install -e ".[test,sentry]"
pytest
```

The suite mounts the console at `/console/` under the namespace `console` on purpose — every link
is reversed from the request rather than hardcoded, and a suite that used the documented `hq/`
would never notice if that stopped being true.

One check is worth running by hand before a release, because a local checkout cannot catch it:

```bash
python -m build --wheel && unzip -l dist/*.whl | grep -E "templates|static"
```

Templates and static live under the `hq` package — including every panel's, since the
app-directories loader only searches installed apps and modules are not apps. If that ever
changes, a wheel missing them fails only in a built image, as `TemplateDoesNotExist`.
