softdelete users and migration script
This commit is contained in:
+12
-1
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user