softdelete users and migration script

This commit is contained in:
2026-08-01 14:02:44 +05:00
parent 4a7de76155
commit 46f130150a
4 changed files with 233 additions and 24 deletions
+12 -1
View File
@@ -84,13 +84,24 @@ class RadadminClient(Base):
__tablename__ = "radadmin_clients"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True)
# Not DB-unique on its own — uniqueness is enforced only among live rows by the
# generated `active_mac` column (see radadmin_schema.sql). Soft-deleted rows
# (deleted_at set) may share a mac_address with the live row and each other.
mac_address: Mapped[str] = mapped_column(String(17), nullable=False)
name: Mapped[str | None] = mapped_column(String(128), nullable=True)
phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Mirror of the client's group (radusergroup) and billing status (customers):
# set on add, synced on edit, and kept frozen on the row after soft-delete so
# the deleted client's group/status survive their hard-deleted source rows.
groupname: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
status: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
# Soft delete: NULL = live, a timestamp = deleted. Deleted rows are never
# returned by the API and are kept only for restore/reference.
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
class RadadminDevice(Base):
+55 -22
View File
@@ -11,7 +11,7 @@ This router hides that fan-out behind mac_address + group + status.
"""
from fastapi import APIRouter, Depends
from pydantic import ValidationError
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -48,10 +48,16 @@ def _stage_client(db: Session, cli: ClientCreate) -> None:
Does not commit — the caller controls the transaction boundary.
"""
mac = cli.mac_address
status = "paid"
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=cli.group, priority=1))
db.add(Customer(username=mac, mac_address=mac, status="paid"))
db.add(RadadminClient(mac_address=mac, name=cli.name, phone=cli.phone, alias=cli.alias))
db.add(Customer(username=mac, mac_address=mac, status=status))
# radadmin_clients mirrors the client's group/status too, so the row is a full
# record that survives (frozen) after the RADIUS/billing rows are hard-deleted.
db.add(RadadminClient(
mac_address=mac, name=cli.name, phone=cli.phone, alias=cli.alias,
groupname=cli.group, status=status,
))
@router.get("/", response_model=Page[ClientOut])
@@ -73,13 +79,19 @@ def list_clients(
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
.outerjoin(
RadadminClient,
and_(RadadminClient.mac_address == Customer.username, RadadminClient.deleted_at.is_(None)),
)
)
# Count over the same joins so filtered totals drive pagination correctly.
count_stmt = (
select(func.count())
.select_from(Customer)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
.outerjoin(
RadadminClient,
and_(RadadminClient.mac_address == Customer.username, RadadminClient.deleted_at.is_(None)),
)
)
if search:
@@ -114,7 +126,10 @@ def get_client(mac_address: str, db: Session = Depends(get_db)):
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
.outerjoin(
RadadminClient,
and_(RadadminClient.mac_address == Customer.username, RadadminClient.deleted_at.is_(None)),
)
.where(Customer.username == mac)
)
row = db.execute(stmt).first()
@@ -216,29 +231,35 @@ def edit_client(payload: ClientEdit, db: Session = Depends(get_db)):
if customer is None:
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
# The live metadata row mirrors group/status/name/phone/alias; created at add,
# but create it here too in case a legacy client predates the mirror.
meta = db.execute(
select(RadadminClient).where(
RadadminClient.mac_address == mac, RadadminClient.deleted_at.is_(None)
)
).scalar_one_or_none()
if meta is None:
meta = RadadminClient(mac_address=mac, groupname=None, status=customer.status)
db.add(meta)
if payload.group is not None:
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
db.execute(
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
)
meta.groupname = payload.group
if payload.status is not None:
customer.status = payload.status
meta.status = payload.status
if payload.name is not None or payload.phone is not None or payload.alias is not None:
meta = db.execute(
select(RadadminClient).where(RadadminClient.mac_address == mac)
).scalar_one_or_none()
if meta is None:
meta = RadadminClient(mac_address=mac)
db.add(meta)
if payload.name is not None:
meta.name = payload.name
if payload.phone is not None:
meta.phone = payload.phone
if payload.alias is not None:
meta.alias = payload.alias
if payload.name is not None:
meta.name = payload.name
if payload.phone is not None:
meta.phone = payload.phone
if payload.alias is not None:
meta.alias = payload.alias
db.commit()
@@ -246,7 +267,9 @@ def edit_client(payload: ClientEdit, db: Session = Depends(get_db)):
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
).scalar_one_or_none()
meta = db.execute(
select(RadadminClient).where(RadadminClient.mac_address == mac)
select(RadadminClient).where(
RadadminClient.mac_address == mac, RadadminClient.deleted_at.is_(None)
)
).scalar_one_or_none()
return ClientOut(
mac_address=mac, group=group, status=customer.status,
@@ -258,13 +281,23 @@ def edit_client(payload: ClientEdit, db: Session = Depends(get_db)):
@router.delete("/{mac_address}", status_code=204)
def delete_client(mac_address: str, db: Session = Depends(get_db)):
"""Remove a client from radcheck, radusergroup and customers."""
"""Delete a client: hard-remove its radcheck, radusergroup and customers rows,
but only *soft* delete its radadmin_clients metadata (name/phone/alias) by
stamping deleted_at, so it can be restored/referenced later. Soft-deleted rows
are never returned by the API, and the client's MAC may be freely re-added."""
mac = mac_address.strip().upper().replace(":", "-")
if not _client_exists(db, mac):
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
# radadmin_clients already mirrors group/status (kept in sync on add/edit), so
# deleting just hard-removes the RADIUS/billing rows and soft-deletes metadata;
# the mirrored group/status stay frozen on the row for reference.
db.execute(delete(RadCheck).where(RadCheck.username == mac))
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
db.execute(delete(Customer).where(Customer.username == mac))
db.execute(delete(RadadminClient).where(RadadminClient.mac_address == mac))
db.execute(
update(RadadminClient)
.where(RadadminClient.mac_address == mac, RadadminClient.deleted_at.is_(None))
.values(deleted_at=func.now())
)
db.commit()