Pilots: one form (add/edit merged), inline deactivate, unify access grant with slot assign

Admin Pilots tab:
- list and create/edit form merged into one - "+ Добавить пилота" at
  the top of the list clears the form into create mode, clicking a
  pilot switches it into edit mode (title, Delete button and icon
  box toggle accordingly) instead of keeping two separate stacked
  forms
- each list row gets an inline on/off toggle (⏻/▶) to flip active
  without opening the form
- categories and the login/password hint field removed from the
  pilot form per earlier feedback - not applicable to pilots

Access model: assigning a pilot's RDP slot to a user is now the
single action that grants them the pilot - it creates/updates their
UserServiceAccess row (with the optional expiry typed right next to
the assign dropdown) in the same call, and unassigning revokes it.
Previously slot assignment and the ACL grant were two separate steps
an admin could forget to pair up, leaving a user "granted" with no
working slot or a slot with no visible access.

Consequently: pilots are no longer listed in the Users tab's ACL
grid at all - the Pilots tab is now the only place pilot access is
managed. set_acl() was fixed to never touch pilot grants regardless
of what's posted (it used to free a user's pilot slot whenever it
saw a pilot id missing from service_ids - which happens on every
save now that the grid never includes pilots, so saving any other
product's ACL would have silently stripped every pilot grant).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 07:07:38 +00:00
parent c3521f605f
commit 684be97041
2 changed files with 136 additions and 128 deletions
+71 -38
View File
@@ -1166,11 +1166,8 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
service_category_map.setdefault(service_id, []).append(category_id)
acl_rows = db.scalars(select(UserServiceAccess)).all()
acl = {}
acl_expires = {}
for row in acl_rows:
acl.setdefault(row.user_id, []).append(row.service_id)
if row.expires_at is not None:
acl_expires.setdefault(row.user_id, {})[row.service_id] = row.expires_at.isoformat()
for user_id in acl:
acl[user_id] = sorted(acl[user_id])
pool_status = {s.id: get_pool_status_for_service(s) for s in services}
@@ -1258,9 +1255,18 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
u = db.get(User, active_sess.user_id)
occupied_username = u.username if u else f"id={active_sess.user_id}"
assigned_username = None
assigned_expires_at = None
if slot.assigned_user_id:
au = db.get(User, slot.assigned_user_id)
assigned_username = au.username if au else f"id={slot.assigned_user_id}"
access_row = db.scalar(
select(UserServiceAccess).where(
UserServiceAccess.user_id == slot.assigned_user_id,
UserServiceAccess.service_id == svc.id,
)
)
if access_row and access_row.expires_at:
assigned_expires_at = access_row.expires_at.isoformat()
slot_list.append({
"id": slot.id,
"rdp_username": slot.rdp_username,
@@ -1269,6 +1275,7 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
"occupied_username": occupied_username,
"assigned_user_id": slot.assigned_user_id,
"assigned_username": assigned_username,
"assigned_expires_at": assigned_expires_at,
})
rdp_slots[svc.id] = slot_list
return templates.TemplateResponse(
@@ -1289,7 +1296,6 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
"categories": categories,
"service_category_map": service_category_map,
"acl": acl,
"acl_expires": acl_expires,
"pool_status": pool_status,
"service_health": service_health,
"web_totals": web_totals,
@@ -2843,9 +2849,16 @@ def delete_rdp_slot(slot_id: int, request: Request, _: User = Depends(require_ad
@app.put("/api/admin/rdp-slots/{slot_id}/assign")
def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
"""Bind (or unbind, with user_id null) a pilot's RDP slot to one
specific person. Only meaningful for slots that belong to a pilot
service - a slot on a regular pooled RDP service doesn't need this,
since any free slot in the pool already works for anyone with access."""
specific person. This is the single action that gives someone a pilot:
it both points the slot at them AND grants/revokes their
UserServiceAccess row for the pilot service, so assigning a slot here
is enough to make the pilot usable for them - no separate ACL step on
the Users tab. Optional "expires_at" (ISO string or null) sets a
per-grant expiry shorter than the account's own.
Only meaningful for slots that belong to a pilot service - a slot on a
regular pooled RDP service doesn't need this, since any free slot in
the pool already works for anyone with access."""
validate_csrf(request)
slot = db.get(RdpSlot, slot_id)
if not slot:
@@ -2854,9 +2867,20 @@ def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Dep
if not service or not service.is_pilot:
raise HTTPException(status_code=400, detail="Слот принадлежит не пилотному сервису")
prev_user_id = slot.assigned_user_id
raw_user_id = payload.get("user_id")
if raw_user_id in (None, ""):
slot.assigned_user_id = None
if prev_user_id:
prev_access = db.scalar(
select(UserServiceAccess).where(
UserServiceAccess.user_id == prev_user_id,
UserServiceAccess.service_id == service.id,
)
)
if prev_access:
db.delete(prev_access)
db.commit()
audit(db, "RDP_SLOT_UNASSIGN", f"service={service.slug} slot={slot.id}", user_id=None)
return {"ok": True, "assigned_user_id": None}
@@ -2876,6 +2900,31 @@ def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Dep
status_code=409,
detail=f"У пользователя уже есть слот №{other.id} на этом пилоте",
)
raw_expires = payload.get("expires_at")
expires_at = dt.datetime.fromisoformat(raw_expires) if raw_expires else None
if prev_user_id and prev_user_id != target_user.id:
prev_access = db.scalar(
select(UserServiceAccess).where(
UserServiceAccess.user_id == prev_user_id,
UserServiceAccess.service_id == service.id,
)
)
if prev_access:
db.delete(prev_access)
access = db.scalar(
select(UserServiceAccess).where(
UserServiceAccess.user_id == target_user.id,
UserServiceAccess.service_id == service.id,
)
)
if access:
access.expires_at = expires_at
else:
db.add(UserServiceAccess(user_id=target_user.id, service_id=service.id, expires_at=expires_at))
slot.assigned_user_id = target_user.id
db.commit()
audit(db, "RDP_SLOT_ASSIGN", f"service={service.slug} slot={slot.id} user={target_user.username}", user_id=None)
@@ -2971,49 +3020,33 @@ def delete_user(user_id: int, request: Request, admin: User = Depends(require_ad
@app.put("/api/admin/users/{user_id}/acl")
def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
"""Replace a user's non-pilot product grants with the posted set.
Pilots are deliberately out of scope here - they're granted/revoked
only via the per-slot assign endpoint on the Pilots tab, which is also
what points a specific container at the person. The Users tab ACL grid
doesn't render pilot checkboxes at all, so service_ids posted from
there never includes them; if this endpoint treated "pilot id missing
from service_ids" as "revoke it", saving any other product's ACL would
silently strip every pilot grant the user has."""
validate_csrf(request)
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
service_ids = set(payload.get("service_ids", []))
# Optional per-service expiry override, e.g. {"12": "2026-11-01T00:00:00+00:00"}
# or {"12": null} to clear it back to "follow the account expiry".
# Used for pilots, whose access window can be shorter than the account's.
expires_by_service = payload.get("expires_at_by_service") or {}
pilot_ids = set(db.scalars(select(Service.id).where(Service.is_pilot == True)).all())
existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
existing_map = {x.service_id: x for x in existing}
rows_by_service = dict(existing_map)
for sid in service_ids:
for sid in service_ids - pilot_ids:
if sid not in existing_map:
new_row = UserServiceAccess(user_id=user_id, service_id=sid)
db.add(new_row)
rows_by_service[sid] = new_row
removed_ids = [sid for sid, row in existing_map.items() if sid not in service_ids]
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
removed_ids = [
sid for sid, row in existing_map.items()
if sid not in service_ids and sid not in pilot_ids
]
for sid in removed_ids:
db.delete(existing_map[sid])
for sid_str, iso_value in expires_by_service.items():
try:
sid = int(sid_str)
except (TypeError, ValueError):
continue
row = rows_by_service.get(sid)
if row is None:
continue
row.expires_at = dt.datetime.fromisoformat(iso_value) if iso_value else None
if removed_ids:
# Revoking a pilot immediately frees any slot reserved for this user
# on it, instead of waiting for the next cleanup_loop sweep.
for slot in db.scalars(
select(RdpSlot).where(
RdpSlot.assigned_user_id == user_id,
RdpSlot.service_id.in_(removed_ids),
)
).all():
slot.assigned_user_id = None
db.commit()
return {"ok": True}