RestorePoint hand-rolled json.loads for payload/coverage — now reads
through JsonColumnMixin._json_read like every other model, keeping the
models-json ratchet green.
test_restoring_the_last_domain_re_enables_the_site mocked create_site
and counted enable_site calls, but since write_vhost became the one
enabling door (enable=True re-links sites-enabled and create_site goes
through it), write_app_vhost no longer calls enable_site separately —
test_site_domain_writer_does_not_enable_twice pins that. The restore
test now asserts through write_vhost's enable flag instead, still
failing if a restore path ever opts out of enabling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
saved_view_service raises ValidationError/NotFoundError/ConflictError and
returns bare dicts; views.py drops the (value, err) tuple translations and
its not-found prose sniff. Duplicate names are now 409. Sniffing census
11 -> 10.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
on_restore_domain deliberately does not re-issue a certificate -- ssl_enabled
can still be True over cert files that expired while the row sat in the bin,
and quietly talking to Let's Encrypt is not something a "put it back" button
should do. That decision stays.
What was missing is telling anyone. The domain returns on plain HTTP while its
row still reads ssl_enabled, so the first sign is a browser warning.
A restore now has three outcomes instead of two: the side effect FAILED
(warning, error toast), it succeeded but left something actionable (notice,
warning toast), or it just worked. An on_restore hook returns a string to raise
a notice; it rides on the item so it cannot be confused with the error slot.
The notice only fires when nothing is actually serving HTTPS for that name --
app_vhost_kwargs re-attaches a covering wildcard, and warning about a domain
that came back on HTTPS anyway would just be noise.
3 tests: notice present, notice suppressed under a covering wildcard, and no
notice for a domain that was never HTTPS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps found by re-reading the seams rather than the pages.
useViewLink was wired into useTableChrome but NOT into useGridConfig, so
/domains -- the page the whole grid was built for -- was the one list page that
could not produce or accept a view link. Both hooks share it now, so a DataGrid
page and a DataTable page emit the same URLs.
SavedView had no pre_restore. Deleting a view frees its name AND its slug (the
unique indexes are partial), so either can be taken by the time you press
Restore; clearing the tombstone would then violate the index and 500 at commit.
Same guard Domain already had.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The teardown map for delete_domain landed after the hook shipped and found a
real hole in it.
When the deleted domain was the app's LAST one, delete_domain runs
disable_site() + delete_site() -- the sites-enabled SYMLINK is gone, not just
the server_name. The hook called NginxService.create_site and reloaded, which
rewrites the file and leaves it UNSERVED. Restoring the last domain of an app
looked like it worked and served nothing.
SiteDomainService.write_app_vhost is the correct seam and fixes three things at
once:
1. it calls enable_site, so the symlink comes back;
2. app_vhost_kwargs re-attaches a covering wildcard cert and the micro-cache
flag -- a bare create_site drops both, so a restored site would silently
fall back to plain HTTP;
3. it handles every host-nginx app type. delete_domain only touches nginx for
docker apps, so php/static/flask/wordpress vhosts still list the deleted
domain; the rewrite is idempotent, so running it there repairs the
asymmetry rather than preserving it.
New test pins the case that was broken -- restoring an app's only domain must
call BOTH create_site and enable_site -- and it fails against the previous
implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a domain did two things: tombstone the row and rewrite the app's
nginx vhost so traffic stopped. Restoring only undid the first, so a domain
could be "restored" and still not served. on_restore_domain rewrites the vhost
from the app's live domains -- the mirror of what delete_domain does -- and
reloads.
Adds a `pre_restore` hook to the registry, because "restorable" is a moving
target: deleting a domain FREES its name (the unique index covers live rows
only), so something else may hold it by the time you press Restore. Clearing
the tombstone then violates the index and raises IntegrityError at commit -- a
500 for what is really an explainable conflict. Now it refuses with the name of
the app that took it. Same guard for a parent Application that no longer
exists.
A restored domain that was primary does NOT become a second primary: whatever
stayed live keeps the role and this row returns as an alias.
The certificate is deliberately not re-issued. ssl_enabled may still be true
with cert files that expired while the domain sat in the bin, and quietly
talking to Let's Encrypt is not something a "put it back" button should do --
the domain returns on HTTP and SSL is re-enabled explicitly.
Five tests, including the two that would otherwise be 500s: name taken again,
and app gone. A failed vhost write warns and KEEPS the restore rather than
rolling back a row the user asked for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting is the only action in a control panel that repeating cannot undo, so
records a person destroys by hand now leave a tombstone and the Recycle Bin
hands them back.
SoftDeleteMixin adds deleted_at/deleted_by_id plus soft_delete/restore and
query_active()/query_deleted(). recycle_bin_service is a REGISTRY: a model
registers with a label function and an optional on_restore hook, so the API
lists and restores across types without knowing what a domain is. Adding the
next soft-deletable model is one register() call.
Applied to Domain (a real, destructive case -- deleting one used to lose the
record outright) and SavedView. DELETE /domains/<id> now tombstones and still
tears the vhost down; restoring a row does NOT re-do side effects unless the
type supplies on_restore, which is the honest default.
API: GET /recycle-bin (all kinds, newest first), POST
/recycle-bin/<kind>/<id>/restore, DELETE /recycle-bin/<kind>/<id> (purge,
admin-only -- the one irreversible action), POST /recycle-bin/purge-expired
with a 30-day default retention.
THE PART THAT NEEDED CARE: both tables had a plain UNIQUE covering what is now
a tombstone, so deleting a domain would have permanently burned its name and
deleting a view would have blocked re-creating one by the same name. Migration
083 replaces each with a PARTIAL unique index predicated on deleted_at IS NULL
-- supported by SQLite 3.8+ and Postgres. Tests pin exactly that: a deleted
name can be reused, and the live row stays unique.
Also gives SavedView a `slug` (backfilled), the handle the upcoming
?view=<slug> links resolve against. A tombstoned view does not reserve its
slug, so deleting a view frees the link name too.
Migration is idempotent on both columns and indexes: `deleted_at` is
index=True on the mixin, so a database built from the models already has that
index, and a bare create_index would abort the upgrade on exactly the installs
we most want to keep working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>