Add pilots: invite-only products with per-user RDP slots

New Service.is_pilot flag - a pilot is a Service (type RDP) that:
- is excluded from /api/public/services-by-category and
  /api/request-more-access (admin-granted only, no self-service)
- still shows as a locked card on the dashboard for users without
  access (Доступно по запросу section), but with a По приглашению
  badge instead of the self-request button/flow
- gets its own non-clickable teaser row on the public /login page
  (logos only, informational)

RdpSlot.assigned_user_id (nullable) - pilot slots are bound to one
specific user instead of being drawn from the shared pool; regular
RDP services are unaffected (field stays NULL, same pool behaviour
as before). The /go/ allocator branches on service.is_pilot to pick
the caller's assigned slot instead of any free one. Slots release
automatically (cleanup_loop) when the owning grant is revoked or
expires, and immediately on manual ACL revoke.

UserServiceAccess.expires_at (nullable) - per-grant access window,
used by pilots so their access can be shorter than the account's own
expires_at; NULL (unchanged default) means "follow the account".
has_access() and the dashboard's granted/locked split both honour it.

Admin UI: "Это пилот" checkbox on the RDP service form, an
assign-user dropdown on a pilot's slot table (replaces the
occupied-by column), and a per-pilot expiry date field in the user
ACL grid.

Schema is applied via the existing ensure_schema_compatibility()
idempotent ALTER TABLE pattern (no alembic in this project) - no
manual migration step needed, it runs at container startup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 06:32:05 +00:00
parent 9e525a44c3
commit 46e5b5a41e
8 changed files with 327 additions and 31 deletions
+25 -1
View File
@@ -10,7 +10,7 @@ from sqlalchemy import select
from config import ENABLE_STARTUP_MAINTENANCE, SESSION_IDLE_SECONDS, WEB_POOL_SIZE
from database import Base, SessionLocal, engine
from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User
from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User, UserServiceAccess
from utils import ensure_icons_dir, now_utc
from auth import hash_password
from runtime import (
@@ -76,6 +76,30 @@ def cleanup_loop():
db.commit()
for slot_id in rdp_slots_to_restart:
threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start()
# Pilots: a slot assigned to a user whose grant for that pilot
# service has since been revoked or has expired (per-grant
# UserServiceAccess.expires_at, or the row is just gone) goes
# back into the pool so an admin can hand it to someone else.
assigned_slots = db.scalars(
select(RdpSlot).where(RdpSlot.assigned_user_id.is_not(None))
).all()
if assigned_slots:
now = now_utc()
freed = 0
for slot in assigned_slots:
access = db.scalar(
select(UserServiceAccess).where(
UserServiceAccess.user_id == slot.assigned_user_id,
UserServiceAccess.service_id == slot.service_id,
)
)
if access is None or (access.expires_at is not None and access.expires_at <= now):
slot.assigned_user_id = None
freed += 1
if freed:
db.commit()
logger.info("pilot_slots_released count=%s", freed)
except Exception:
db.rollback()
logger.exception("cleanup_loop_failed")