softdelete users and migration script
This commit is contained in:
+12
-1
@@ -84,13 +84,24 @@ class RadadminClient(Base):
|
|||||||
__tablename__ = "radadmin_clients"
|
__tablename__ = "radadmin_clients"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
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)
|
name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
alias: Mapped[str | None] = mapped_column(String(64), 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(
|
created_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
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):
|
class RadadminDevice(Base):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This router hides that fan-out behind mac_address + group + status.
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import ValidationError
|
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.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
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.
|
Does not commit — the caller controls the transaction boundary.
|
||||||
"""
|
"""
|
||||||
mac = cli.mac_address
|
mac = cli.mac_address
|
||||||
|
status = "paid"
|
||||||
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
|
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
|
||||||
db.add(RadUserGroup(username=mac, groupname=cli.group, priority=1))
|
db.add(RadUserGroup(username=mac, groupname=cli.group, priority=1))
|
||||||
db.add(Customer(username=mac, mac_address=mac, status="paid"))
|
db.add(Customer(username=mac, mac_address=mac, status=status))
|
||||||
db.add(RadadminClient(mac_address=mac, name=cli.name, phone=cli.phone, alias=cli.alias))
|
# 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])
|
@router.get("/", response_model=Page[ClientOut])
|
||||||
@@ -73,13 +79,19 @@ def list_clients(
|
|||||||
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
|
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
|
||||||
)
|
)
|
||||||
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
|
.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 over the same joins so filtered totals drive pagination correctly.
|
||||||
count_stmt = (
|
count_stmt = (
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Customer)
|
.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:
|
if search:
|
||||||
@@ -114,7 +126,10 @@ def get_client(mac_address: str, db: Session = Depends(get_db)):
|
|||||||
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
|
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
|
||||||
)
|
)
|
||||||
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
|
.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)
|
.where(Customer.username == mac)
|
||||||
)
|
)
|
||||||
row = db.execute(stmt).first()
|
row = db.execute(stmt).first()
|
||||||
@@ -216,29 +231,35 @@ def edit_client(payload: ClientEdit, db: Session = Depends(get_db)):
|
|||||||
if customer is None:
|
if customer is None:
|
||||||
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
|
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 payload.group is not None:
|
||||||
if not _group_exists(db, payload.group):
|
if not _group_exists(db, payload.group):
|
||||||
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
|
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
|
||||||
db.execute(
|
db.execute(
|
||||||
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
|
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
|
||||||
)
|
)
|
||||||
|
meta.groupname = payload.group
|
||||||
|
|
||||||
if payload.status is not None:
|
if payload.status is not None:
|
||||||
customer.status = payload.status
|
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:
|
if payload.name is not None:
|
||||||
meta = db.execute(
|
meta.name = payload.name
|
||||||
select(RadadminClient).where(RadadminClient.mac_address == mac)
|
if payload.phone is not None:
|
||||||
).scalar_one_or_none()
|
meta.phone = payload.phone
|
||||||
if meta is None:
|
if payload.alias is not None:
|
||||||
meta = RadadminClient(mac_address=mac)
|
meta.alias = payload.alias
|
||||||
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
|
|
||||||
|
|
||||||
db.commit()
|
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)
|
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
meta = db.execute(
|
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()
|
).scalar_one_or_none()
|
||||||
return ClientOut(
|
return ClientOut(
|
||||||
mac_address=mac, group=group, status=customer.status,
|
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)
|
@router.delete("/{mac_address}", status_code=204)
|
||||||
def delete_client(mac_address: str, db: Session = Depends(get_db)):
|
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(":", "-")
|
mac = mac_address.strip().upper().replace(":", "-")
|
||||||
if not _client_exists(db, mac):
|
if not _client_exists(db, mac):
|
||||||
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
|
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(RadCheck).where(RadCheck.username == mac))
|
||||||
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
|
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
|
||||||
db.execute(delete(Customer).where(Customer.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()
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Idempotent schema migrations for the radadmin API.
|
||||||
|
|
||||||
|
Applies incremental DDL that ``radadmin_schema.sql`` can't (it only CREATEs
|
||||||
|
tables IF NOT EXISTS, so it never alters an existing one). Each step checks
|
||||||
|
information_schema first, so this is safe to re-run and works on both MySQL and
|
||||||
|
MariaDB. Uses the same DB connection settings as the app (.env / config.py).
|
||||||
|
|
||||||
|
Run from the backend/ directory:
|
||||||
|
|
||||||
|
python migrate.py # apply pending migrations
|
||||||
|
python migrate.py --dry-run # show what would run, change nothing
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.database import engine
|
||||||
|
|
||||||
|
TABLE = "radadmin_clients"
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn, table: str, column: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t AND column_name = :c"
|
||||||
|
),
|
||||||
|
{"t": table, "c": column},
|
||||||
|
).scalar()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(conn, table: str, index: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i"
|
||||||
|
),
|
||||||
|
{"t": table, "i": index},
|
||||||
|
).scalar()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||||
|
),
|
||||||
|
{"t": table},
|
||||||
|
).scalar()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
# Each migration: (description, predicate that returns True when it still needs
|
||||||
|
# to run, SQL to run). Ordered — later steps may depend on earlier ones.
|
||||||
|
MIGRATIONS = [
|
||||||
|
(
|
||||||
|
"add radadmin_clients.groupname (mirror of radusergroup)",
|
||||||
|
lambda c: not _column_exists(c, TABLE, "groupname"),
|
||||||
|
f"ALTER TABLE {TABLE} ADD COLUMN groupname VARCHAR(64) NULL AFTER alias",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"add radadmin_clients.status (mirror of customers.status)",
|
||||||
|
lambda c: not _column_exists(c, TABLE, "status"),
|
||||||
|
f"ALTER TABLE {TABLE} ADD COLUMN status VARCHAR(10) NULL AFTER groupname",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"add radadmin_clients.deleted_at (soft-delete state)",
|
||||||
|
lambda c: not _column_exists(c, TABLE, "deleted_at"),
|
||||||
|
f"ALTER TABLE {TABLE} ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL AFTER created_at",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"add radadmin_clients.active_mac (generated; NULL when soft-deleted)",
|
||||||
|
lambda c: not _column_exists(c, TABLE, "active_mac"),
|
||||||
|
f"ALTER TABLE {TABLE} ADD COLUMN active_mac VARCHAR(17) "
|
||||||
|
"AS (IF(deleted_at IS NULL, mac_address, NULL)) STORED",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"drop old unique index uq_radadmin_clients_mac",
|
||||||
|
lambda c: _index_exists(c, TABLE, "uq_radadmin_clients_mac"),
|
||||||
|
f"ALTER TABLE {TABLE} DROP INDEX uq_radadmin_clients_mac",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"add partial-unique index uq_radadmin_clients_active_mac",
|
||||||
|
lambda c: not _index_exists(c, TABLE, "uq_radadmin_clients_active_mac"),
|
||||||
|
f"ALTER TABLE {TABLE} ADD UNIQUE KEY uq_radadmin_clients_active_mac (active_mac)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Apply radadmin schema migrations.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true", help="show pending migrations without applying them"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not _table_exists(conn, TABLE):
|
||||||
|
print(
|
||||||
|
f"error: table '{TABLE}' not found — import radadmin_schema.sql first.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
applied = 0
|
||||||
|
for desc, needs_run, sql in MIGRATIONS:
|
||||||
|
if not needs_run(conn):
|
||||||
|
print(f" skip {desc} (already applied)")
|
||||||
|
continue
|
||||||
|
if args.dry_run:
|
||||||
|
print(f" PEND {desc}\n {sql}")
|
||||||
|
applied += 1
|
||||||
|
continue
|
||||||
|
print(f" apply {desc}")
|
||||||
|
conn.execute(text(sql))
|
||||||
|
conn.commit() # DDL auto-commits, but be explicit for the connection
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"\n{applied} migration(s) pending. Re-run without --dry-run to apply.")
|
||||||
|
elif applied:
|
||||||
|
print(f"\nDone — {applied} migration(s) applied.")
|
||||||
|
else:
|
||||||
|
print("\nDatabase already up to date.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -12,17 +12,47 @@ CREATE TABLE IF NOT EXISTS customers (
|
|||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Soft delete: deleting a client hard-removes its radcheck/radusergroup/customers
|
||||||
|
-- rows but only SETS deleted_at here, so the human metadata survives and can be
|
||||||
|
-- restored/referenced. deleted_at IS NULL means "live". Uniqueness on mac_address
|
||||||
|
-- is enforced only among live rows via the generated active_mac column (NULL for
|
||||||
|
-- soft-deleted rows — MySQL/MariaDB allows many NULLs in a UNIQUE index), so a
|
||||||
|
-- re-added client coexists with any number of old soft-deleted copies.
|
||||||
|
-- groupname/status mirror the client's current group (radusergroup) and billing
|
||||||
|
-- status (customers): populated on add and kept in sync on edit. They stay on the
|
||||||
|
-- row when the client is soft-deleted, so a deleted client's group/status aren't
|
||||||
|
-- lost along with its hard-deleted RADIUS/billing rows.
|
||||||
CREATE TABLE IF NOT EXISTS radadmin_clients (
|
CREATE TABLE IF NOT EXISTS radadmin_clients (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
mac_address VARCHAR(17) NOT NULL,
|
mac_address VARCHAR(17) NOT NULL,
|
||||||
name VARCHAR(128) NULL,
|
name VARCHAR(128) NULL,
|
||||||
phone VARCHAR(32) NULL,
|
phone VARCHAR(32) NULL,
|
||||||
alias VARCHAR(64) NULL,
|
alias VARCHAR(64) NULL,
|
||||||
|
groupname VARCHAR(64) NULL,
|
||||||
|
status VARCHAR(10) NULL,
|
||||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL DEFAULT NULL,
|
||||||
|
active_mac VARCHAR(17) AS (IF(deleted_at IS NULL, mac_address, NULL)) STORED,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uq_radadmin_clients_mac (mac_address)
|
UNIQUE KEY uq_radadmin_clients_active_mac (active_mac)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Soft-delete migration for radadmin_clients tables created before this change
|
||||||
|
-- (MariaDB syntax; idempotent, and a no-op on the fresh schema created above).
|
||||||
|
ALTER TABLE radadmin_clients
|
||||||
|
ADD COLUMN IF NOT EXISTS groupname VARCHAR(64) NULL AFTER alias;
|
||||||
|
ALTER TABLE radadmin_clients
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(10) NULL AFTER groupname;
|
||||||
|
ALTER TABLE radadmin_clients
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL DEFAULT NULL AFTER created_at;
|
||||||
|
ALTER TABLE radadmin_clients
|
||||||
|
ADD COLUMN IF NOT EXISTS active_mac VARCHAR(17)
|
||||||
|
AS (IF(deleted_at IS NULL, mac_address, NULL)) STORED;
|
||||||
|
ALTER TABLE radadmin_clients
|
||||||
|
DROP INDEX IF EXISTS uq_radadmin_clients_mac;
|
||||||
|
ALTER TABLE radadmin_clients
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uq_radadmin_clients_active_mac (active_mac);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS radadmin_devices (
|
CREATE TABLE IF NOT EXISTS radadmin_devices (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
mac_address VARCHAR(17) NOT NULL,
|
mac_address VARCHAR(17) NOT NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user