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:
+13
-4
@@ -95,11 +95,20 @@ def validate_csrf(request: Request) -> None:
|
||||
|
||||
|
||||
def has_access(db: Session, user_id: int, service_id: int) -> bool:
|
||||
q = select(UserServiceAccess).where(
|
||||
UserServiceAccess.user_id == user_id,
|
||||
UserServiceAccess.service_id == service_id,
|
||||
access = db.scalar(
|
||||
select(UserServiceAccess).where(
|
||||
UserServiceAccess.user_id == user_id,
|
||||
UserServiceAccess.service_id == service_id,
|
||||
)
|
||||
)
|
||||
return db.scalar(q) is not None
|
||||
if access is None:
|
||||
return False
|
||||
# A per-grant expires_at (used by pilots, whose access window can be
|
||||
# shorter than the account's own expires_at) overrides the account
|
||||
# expiry for this one product. NULL means "follow the account".
|
||||
if access.expires_at is not None and access.expires_at <= now_utc():
|
||||
return False
|
||||
return True
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
Reference in New Issue
Block a user