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:
+25
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user