feat: access request UI — catalog badges, request modal, admin approval page
Backend: - access_requests table in DuckDB schema - AccessRequestRepository with create/approve/deny/list - API: POST/GET /api/access-requests (submit, my requests, pending, approve, deny) UI: - Catalog: lock icon on private tables, "Request Access" button + modal - Catalog: "Pending" badge for tables with pending requests - Admin permissions page (/admin/permissions): approve/deny requests, grant/revoke permissions, view all user permissions - Cross-navigation between admin/tables and admin/permissions 733 tests passing.
This commit is contained in:
parent
1074d5ec49
commit
2e7d5d1fe9
8 changed files with 1726 additions and 7 deletions
97
app/api/access_requests.py
Normal file
97
app/api/access_requests.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Access request API — users request access, admins approve/deny."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import duckdb
|
||||
|
||||
from app.auth.dependencies import get_current_user, require_role, Role, _get_db
|
||||
from src.repositories.access_requests import AccessRequestRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/access-requests", tags=["access-requests"])
|
||||
|
||||
|
||||
class AccessRequestCreate(BaseModel):
|
||||
table_id: str
|
||||
reason: Optional[str] = ""
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_request(
|
||||
request: AccessRequestCreate,
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Submit an access request for a table."""
|
||||
repo = AccessRequestRepository(conn)
|
||||
|
||||
# Check for duplicate pending request
|
||||
if repo.has_pending_request(user["id"], request.table_id):
|
||||
raise HTTPException(status_code=409, detail="You already have a pending request for this table")
|
||||
|
||||
req_id = repo.create(
|
||||
user_id=user["id"],
|
||||
user_email=user.get("email", ""),
|
||||
table_id=request.table_id,
|
||||
reason=request.reason or "",
|
||||
)
|
||||
return {"id": req_id, "status": "pending", "table_id": request.table_id}
|
||||
|
||||
|
||||
@router.get("/my")
|
||||
async def my_requests(
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""List current user's access requests."""
|
||||
repo = AccessRequestRepository(conn)
|
||||
requests = repo.list_by_user(user["id"])
|
||||
# Serialize timestamps
|
||||
for r in requests:
|
||||
for k in ("created_at", "reviewed_at"):
|
||||
if r.get(k) and hasattr(r[k], "isoformat"):
|
||||
r[k] = r[k].isoformat()
|
||||
return {"requests": requests}
|
||||
|
||||
|
||||
@router.get("/pending")
|
||||
async def pending_requests(
|
||||
user: dict = Depends(require_role(Role.ADMIN)),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""List all pending access requests (admin only)."""
|
||||
repo = AccessRequestRepository(conn)
|
||||
requests = repo.list_pending()
|
||||
for r in requests:
|
||||
for k in ("created_at", "reviewed_at"):
|
||||
if r.get(k) and hasattr(r[k], "isoformat"):
|
||||
r[k] = r[k].isoformat()
|
||||
return {"requests": requests, "count": len(requests)}
|
||||
|
||||
|
||||
@router.post("/{request_id}/approve")
|
||||
async def approve_request(
|
||||
request_id: str,
|
||||
user: dict = Depends(require_role(Role.ADMIN)),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Approve an access request (admin only). Auto-grants permission."""
|
||||
repo = AccessRequestRepository(conn)
|
||||
if repo.approve(request_id, reviewed_by=user.get("email", "")):
|
||||
return {"status": "approved", "id": request_id}
|
||||
raise HTTPException(status_code=404, detail="Request not found or already processed")
|
||||
|
||||
|
||||
@router.post("/{request_id}/deny")
|
||||
async def deny_request(
|
||||
request_id: str,
|
||||
user: dict = Depends(require_role(Role.ADMIN)),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Deny an access request (admin only)."""
|
||||
repo = AccessRequestRepository(conn)
|
||||
if repo.deny(request_id, reviewed_by=user.get("email", "")):
|
||||
return {"status": "denied", "id": request_id}
|
||||
raise HTTPException(status_code=404, detail="Request not found or already processed")
|
||||
|
|
@ -24,6 +24,7 @@ from app.api.catalog import router as catalog_router
|
|||
from app.api.telegram import router as telegram_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.permissions import router as permissions_router
|
||||
from app.api.access_requests import router as access_requests_router
|
||||
from app.web.router import router as web_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -103,6 +104,7 @@ def create_app() -> FastAPI:
|
|||
app.include_router(telegram_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(access_requests_router)
|
||||
|
||||
# Web UI router (must be last — has catch-all routes)
|
||||
app.include_router(web_router)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from src.repositories.sync_settings import SyncSettingsRepository, DatasetPermis
|
|||
from src.repositories.knowledge import KnowledgeRepository
|
||||
from src.repositories.users import UserRepository
|
||||
from src.repositories.profiles import ProfileRepository
|
||||
from src.repositories.access_requests import AccessRequestRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["web"])
|
||||
|
|
@ -240,27 +241,45 @@ async def catalog(
|
|||
try:
|
||||
from src.repositories.table_registry import TableRegistryRepository
|
||||
table_repo = TableRegistryRepository(conn)
|
||||
perm_repo = DatasetPermissionRepository(conn)
|
||||
access_repo = AccessRequestRepository(conn)
|
||||
registered = table_repo.list_all()
|
||||
|
||||
# Pre-fetch user's pending access requests
|
||||
user_id = user.get("id", "")
|
||||
user_requests = access_repo.list_by_user(user_id)
|
||||
pending_request_table_ids = {
|
||||
r["table_id"] for r in user_requests if r.get("status") == "pending"
|
||||
}
|
||||
|
||||
tables = []
|
||||
for tc in registered:
|
||||
table_id = tc.get("id", "")
|
||||
is_public = tc.get("is_public", True)
|
||||
has_access = is_public or perm_repo.has_access(user_id, table_id)
|
||||
|
||||
table_data = {
|
||||
"id": tc.get("id", ""),
|
||||
"id": table_id,
|
||||
"name": tc.get("name", ""),
|
||||
"description": tc.get("description", ""),
|
||||
"dataset": tc.get("bucket"),
|
||||
"sync_strategy": tc.get("sync_strategy", "full_refresh"),
|
||||
"query_mode": tc.get("query_mode", "local"),
|
||||
"profile": all_profiles.get(tc.get("id", "")),
|
||||
"profile": all_profiles.get(table_id),
|
||||
"is_public": is_public,
|
||||
"has_access": has_access,
|
||||
"pending_request": table_id in pending_request_table_ids,
|
||||
}
|
||||
# Add sync state
|
||||
for state in all_states:
|
||||
if state["table_id"] == tc.get("id"):
|
||||
if state["table_id"] == table_id:
|
||||
table_data["last_sync"] = state.get("last_sync")
|
||||
table_data["rows"] = state.get("rows")
|
||||
break
|
||||
tables.append(table_data)
|
||||
except Exception as e:
|
||||
tables = []
|
||||
pending_request_table_ids = set()
|
||||
logger.warning(f"Could not load catalog: {e}")
|
||||
|
||||
# Build data_stats for catalog template
|
||||
|
|
@ -281,13 +300,20 @@ async def catalog(
|
|||
categories[ds] = {"name": ds, "tables": []}
|
||||
categories[ds]["tables"].append(t)
|
||||
|
||||
# Add count to each category (template expects .count)
|
||||
catalog_data = []
|
||||
for cat in categories.values():
|
||||
cat["count"] = len(cat["tables"])
|
||||
catalog_data.append(cat)
|
||||
|
||||
ctx = _build_context(
|
||||
request, user=user,
|
||||
tables=tables,
|
||||
datasets=datasets,
|
||||
enabled_datasets=enabled_datasets,
|
||||
data_stats=data_stats,
|
||||
categories=list(categories.values()),
|
||||
categories=catalog_data,
|
||||
catalog_data=catalog_data,
|
||||
metrics_data=[],
|
||||
sync_states=all_states,
|
||||
folder_mapping={},
|
||||
|
|
@ -391,3 +417,14 @@ async def admin_tables(
|
|||
tables = repo.list_all()
|
||||
ctx = _build_context(request, user=user, registered_tables=tables)
|
||||
return templates.TemplateResponse(request, "admin_tables.html", ctx)
|
||||
|
||||
|
||||
@router.get("/admin/permissions", response_class=HTMLResponse)
|
||||
async def admin_permissions_page(
|
||||
request: Request,
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Admin page for managing permissions and access requests."""
|
||||
ctx = _build_context(request, user=user)
|
||||
return templates.TemplateResponse(request, "admin_permissions.html", ctx)
|
||||
|
|
|
|||
1240
app/web/templates/admin_permissions.html
Normal file
1240
app/web/templates/admin_permissions.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -750,7 +750,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
Admin
|
||||
<a href="/admin/permissions" style="font-size: 12px; font-weight: 500; color: var(--primary); text-decoration: none; padding: 6px 12px; border-radius: 6px; transition: all 0.15s ease;">Permissions</a>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -373,6 +373,158 @@
|
|||
background: rgba(243, 244, 246, 0.5);
|
||||
}
|
||||
|
||||
.table-row-locked {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.table-row-locked:hover {
|
||||
background: rgba(234, 88, 12, 0.04);
|
||||
}
|
||||
|
||||
.access-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
font-family: var(--font-primary);
|
||||
}
|
||||
|
||||
.access-badge.locked {
|
||||
font-size: 13px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.access-badge.pending {
|
||||
background: #FFF7ED;
|
||||
color: #EA580C;
|
||||
border: 1px solid #FDBA74;
|
||||
}
|
||||
|
||||
.btn-request-access {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-request-access:hover {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Request Access Modal */
|
||||
.request-access-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1100;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.request-access-overlay.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.request-access-modal {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.request-access-modal .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.request-access-modal .modal-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.request-access-modal .modal-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.request-access-modal .modal-body label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.request-access-modal .modal-body textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-primary);
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.request-access-modal .modal-body textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.request-access-modal .modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 24px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.request-access-modal .btn-secondary {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.request-access-modal .btn-primary {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.request-access-modal .btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.table-row-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
@ -1414,10 +1566,17 @@
|
|||
</button>
|
||||
<div class="accordion-content">
|
||||
{% for table in category.tables %}
|
||||
<div class="table-row" {% if table.query_mode != 'remote' %}onclick="openProfiler('{{ table.name }}')"{% endif %}>
|
||||
<div class="table-row{% if not table.is_public and not table.has_access %} table-row-locked{% endif %}" {% if not table.is_public and not table.has_access %}{% if table.pending_request %}title="Access request pending"{% else %}onclick="openRequestModal('{{ table.id }}', '{{ table.name }}')"{% endif %}{% elif table.query_mode != 'remote' %}onclick="openProfiler('{{ table.name }}')"{% endif %}>
|
||||
<div class="table-row-left">
|
||||
<div class="table-row-name">
|
||||
{{ table.name }}
|
||||
{% if not table.is_public and not table.has_access %}
|
||||
{% if table.pending_request %}
|
||||
<span class="access-badge pending">Pending</span>
|
||||
{% else %}
|
||||
<span class="access-badge locked" title="Private table - request access">🔒</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if table.query_mode == 'remote' %}
|
||||
<span class="query-mode-badge live">Live</span>
|
||||
{% else %}
|
||||
|
|
@ -1426,7 +1585,9 @@
|
|||
</div>
|
||||
<div class="table-row-desc">{{ table.description }}</div>
|
||||
<div class="table-sync-info">
|
||||
{% if table.query_mode == 'remote' %}
|
||||
{% if not table.is_public and not table.has_access and not table.pending_request %}
|
||||
<span style="color: var(--text-secondary);">Click to request access</span>
|
||||
{% elif table.query_mode == 'remote' %}
|
||||
<span class="live-dot"></span> Queried directly from BigQuery
|
||||
{% elif table.last_sync %}
|
||||
Synced {{ table.last_sync }}
|
||||
|
|
@ -1434,6 +1595,13 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="table-row-right">
|
||||
{% if not table.is_public and not table.has_access %}
|
||||
{% if table.pending_request %}
|
||||
<span class="rows-badge" style="background: #FFF7ED; color: #EA580C;">Awaiting review</span>
|
||||
{% else %}
|
||||
<span class="btn-request-access" onclick="event.stopPropagation(); openRequestModal('{{ table.id }}', '{{ table.name }}')">Request Access</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="rows-badge{{ ' large' if table.rows_large }}">{{ table.rows_display }}</span>
|
||||
{% if table.query_mode != 'remote' %}
|
||||
<span class="profile-link">
|
||||
|
|
@ -1441,6 +1609,7 @@
|
|||
Profile
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
@ -2450,6 +2619,77 @@ document.addEventListener('keydown', e => {
|
|||
})();
|
||||
</script>
|
||||
|
||||
<!-- ═══════════════ REQUEST ACCESS MODAL ═══════════════ -->
|
||||
<div id="requestAccessOverlay" class="request-access-overlay" onclick="if(event.target===this)closeRequestModal()">
|
||||
<div class="request-access-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Request Access</h3>
|
||||
<button class="btn-secondary" style="border:none;font-size:18px;padding:4px 8px;line-height:1;" onclick="closeRequestModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Table: <strong id="requestTableName"></strong></p>
|
||||
<label for="requestReason">Reason (optional)</label>
|
||||
<textarea id="requestReason" rows="3" placeholder="Why do you need access to this table?"></textarea>
|
||||
<p id="requestStatus" style="margin-top:12px;display:none;font-size:13px;"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeRequestModal()">Cancel</button>
|
||||
<button class="btn-primary" id="submitRequestBtn" onclick="submitAccessRequest()">Request Access</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let requestTableId = '';
|
||||
|
||||
function openRequestModal(tableId, tableName) {
|
||||
requestTableId = tableId;
|
||||
document.getElementById('requestTableName').textContent = tableName;
|
||||
document.getElementById('requestReason').value = '';
|
||||
document.getElementById('requestStatus').style.display = 'none';
|
||||
document.getElementById('submitRequestBtn').disabled = false;
|
||||
document.getElementById('submitRequestBtn').textContent = 'Request Access';
|
||||
document.getElementById('requestAccessOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
function closeRequestModal() {
|
||||
document.getElementById('requestAccessOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
async function submitAccessRequest() {
|
||||
const reason = document.getElementById('requestReason').value;
|
||||
const btn = document.getElementById('submitRequestBtn');
|
||||
const status = document.getElementById('requestStatus');
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/access-requests', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({table_id: requestTableId, reason: reason})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
status.textContent = 'Request submitted! An admin will review it.';
|
||||
status.style.color = 'var(--success)';
|
||||
status.style.display = 'block';
|
||||
btn.textContent = 'Submitted';
|
||||
} else if (resp.status === 409) {
|
||||
status.textContent = 'You already have a pending request for this table.';
|
||||
status.style.color = 'var(--warning)';
|
||||
status.style.display = 'block';
|
||||
} else {
|
||||
throw new Error('Request failed');
|
||||
}
|
||||
} catch(e) {
|
||||
status.textContent = 'Error submitting request. Please try again.';
|
||||
status.style.color = 'var(--error)';
|
||||
status.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ═══════════════ METRIC MODAL ═══════════════ -->
|
||||
<div id="metricModalOverlay" class="metric-modal-overlay">
|
||||
<div id="metricModal" class="metric-modal" onclick="event.stopPropagation()">
|
||||
|
|
|
|||
12
src/db.py
12
src/db.py
|
|
@ -149,6 +149,18 @@ CREATE TABLE IF NOT EXISTS dataset_permissions (
|
|||
access VARCHAR DEFAULT 'read',
|
||||
PRIMARY KEY (user_id, dataset)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS access_requests (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
user_id VARCHAR NOT NULL,
|
||||
user_email VARCHAR NOT NULL,
|
||||
table_id VARCHAR NOT NULL,
|
||||
reason TEXT,
|
||||
status VARCHAR DEFAULT 'pending',
|
||||
reviewed_by VARCHAR,
|
||||
reviewed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT current_timestamp
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
90
src/repositories/access_requests.py
Normal file
90
src/repositories/access_requests.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Repository for access requests."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, List, Dict
|
||||
|
||||
import duckdb
|
||||
|
||||
|
||||
class AccessRequestRepository:
|
||||
def __init__(self, conn: duckdb.DuckDBPyConnection):
|
||||
self.conn = conn
|
||||
|
||||
def create(self, user_id: str, user_email: str, table_id: str, reason: str = "") -> str:
|
||||
"""Create a new access request. Returns request ID."""
|
||||
req_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
self.conn.execute(
|
||||
"""INSERT INTO access_requests (id, user_id, user_email, table_id, reason, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)""",
|
||||
[req_id, user_id, user_email, table_id, reason, now],
|
||||
)
|
||||
return req_id
|
||||
|
||||
def get(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
result = self.conn.execute("SELECT * FROM access_requests WHERE id = ?", [request_id]).fetchone()
|
||||
if not result:
|
||||
return None
|
||||
columns = [desc[0] for desc in self.conn.description]
|
||||
return dict(zip(columns, result))
|
||||
|
||||
def list_pending(self) -> List[Dict[str, Any]]:
|
||||
"""List all pending requests (for admin)."""
|
||||
results = self.conn.execute(
|
||||
"SELECT * FROM access_requests WHERE status = 'pending' ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
if not results:
|
||||
return []
|
||||
columns = [desc[0] for desc in self.conn.description]
|
||||
return [dict(zip(columns, row)) for row in results]
|
||||
|
||||
def list_by_user(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""List all requests by a user."""
|
||||
results = self.conn.execute(
|
||||
"SELECT * FROM access_requests WHERE user_id = ? ORDER BY created_at DESC",
|
||||
[user_id],
|
||||
).fetchall()
|
||||
if not results:
|
||||
return []
|
||||
columns = [desc[0] for desc in self.conn.description]
|
||||
return [dict(zip(columns, row)) for row in results]
|
||||
|
||||
def approve(self, request_id: str, reviewed_by: str) -> bool:
|
||||
"""Approve request and grant access."""
|
||||
req = self.get(request_id)
|
||||
if not req or req["status"] != "pending":
|
||||
return False
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
self.conn.execute(
|
||||
"UPDATE access_requests SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?",
|
||||
[reviewed_by, now, request_id],
|
||||
)
|
||||
|
||||
# Auto-grant permission
|
||||
from src.repositories.sync_settings import DatasetPermissionRepository
|
||||
DatasetPermissionRepository(self.conn).grant(req["user_id"], req["table_id"], "read")
|
||||
|
||||
return True
|
||||
|
||||
def deny(self, request_id: str, reviewed_by: str) -> bool:
|
||||
"""Deny a request."""
|
||||
req = self.get(request_id)
|
||||
if not req or req["status"] != "pending":
|
||||
return False
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
self.conn.execute(
|
||||
"UPDATE access_requests SET status = 'denied', reviewed_by = ?, reviewed_at = ? WHERE id = ?",
|
||||
[reviewed_by, now, request_id],
|
||||
)
|
||||
return True
|
||||
|
||||
def has_pending_request(self, user_id: str, table_id: str) -> bool:
|
||||
"""Check if user already has a pending request for this table."""
|
||||
result = self.conn.execute(
|
||||
"SELECT id FROM access_requests WHERE user_id = ? AND table_id = ? AND status = 'pending'",
|
||||
[user_id, table_id],
|
||||
).fetchone()
|
||||
return result is not None
|
||||
Loading…
Reference in a new issue