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
+24 -2
View File
@@ -138,6 +138,14 @@
border:1px dashed var(--dv-fg-faint);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer;
}
.dv-tile-request:hover{border-color:var(--dv-accent);border-style:solid;color:var(--dv-accent)}
.dv-pilot-badge{
display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em;
color:#0a1929;background:var(--dv-accent);border-radius:4px;padding:2px 5px;vertical-align:middle;
}
.dv-tile-invite{
position:relative;z-index:2;margin-top:auto;align-self:flex-start;padding:7px 14px;border-radius:7px;
border:1px solid var(--dv-line);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;
}
.dv-page-footer{margin-top:50px;padding-top:20px;border-top:1px solid var(--dv-line);display:flex;
justify-content:space-between;flex-wrap:wrap;gap:8px;font:400 12px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)}
@@ -252,7 +260,7 @@
<a class="dv-tile-hit go-link" href="/go/{{ service.slug }}" aria-label="Открыть {{ service.name }}"></a>
<div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
<div class="dv-tile-name">{{ service.name }}</div>
<div class="dv-tile-name">{{ service.name }}{% if service.id in pilot_ids %} <span class="dv-pilot-badge">ПИЛОТ</span>{% endif %}</div>
</div>
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
<a class="dv-tile-enter go-link" href="/go/{{ service.slug }}">Войти →</a>
@@ -270,6 +278,16 @@
<div class="dv-section-head"><h2>Доступно по запросу</h2><span class="dv-section-count">{{ '%02d'|format(locked_services|length) }}</span></div>
<div class="dv-tile-grid">
{% for service in locked_services %}
{% if service.id in pilot_ids %}
<div class="dv-tile locked pilot-locked">
<div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
<div class="dv-tile-name">{{ service.name }}</div>
</div>
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
<span class="dv-tile-invite" title="Доступ выдаёт администратор полигона">По приглашению</span>
</div>
{% else %}
<div class="dv-tile locked">
<div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
@@ -279,6 +297,7 @@
<button type="button" class="dv-tile-request" data-product="{{ service.name }}">Запросить доступ</button>
<span class="dv-tile-lock" title="Нет доступа">🔒</span>
</div>
{% endif %}
{% endfor %}
</div>
</section>
@@ -352,7 +371,10 @@
const modalBody = document.getElementById('req-modal-body');
function allLockedTiles() {
return Array.prototype.slice.call(document.querySelectorAll('.dv-tile.locked'));
// Pilots (.pilot-locked) are invite-only and must never end up in the
// self-service "request access" checklist, even via another product's
// request button pulling in every locked tile on the page.
return Array.prototype.slice.call(document.querySelectorAll('.dv-tile.locked:not(.pilot-locked)'));
}
function openRequestModal(preselect) {