adapt-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adapt/__init__.py +3 -0
- adapt/__main__.py +5 -0
- adapt/admin/__init__.py +10 -0
- adapt/admin/api_keys.py +123 -0
- adapt/admin/audit_logs.py +66 -0
- adapt/admin/cache.py +78 -0
- adapt/admin/groups.py +170 -0
- adapt/admin/locks.py +82 -0
- adapt/admin/models.py +37 -0
- adapt/admin/permissions.py +169 -0
- adapt/admin/resources.py +25 -0
- adapt/admin/ui.py +32 -0
- adapt/admin/users.py +114 -0
- adapt/api_keys.py +82 -0
- adapt/app.py +256 -0
- adapt/audit.py +49 -0
- adapt/auth/__init__.py +15 -0
- adapt/auth/dependencies.py +109 -0
- adapt/auth/password.py +24 -0
- adapt/auth/routes.py +198 -0
- adapt/auth/session.py +53 -0
- adapt/cache.py +152 -0
- adapt/cli.py +124 -0
- adapt/commands/__init__.py +1 -0
- adapt/commands/addsuperuser.py +46 -0
- adapt/commands/admin/__init__.py +73 -0
- adapt/commands/admin/add_to_group.py +42 -0
- adapt/commands/admin/create_group.py +29 -0
- adapt/commands/admin/create_permissions.py +167 -0
- adapt/commands/admin/create_user.py +40 -0
- adapt/commands/admin/delete_group.py +28 -0
- adapt/commands/admin/delete_user.py +28 -0
- adapt/commands/admin/list_groups.py +57 -0
- adapt/commands/admin/list_resources.py +20 -0
- adapt/commands/admin/list_users.py +32 -0
- adapt/commands/admin/remove_from_group.py +42 -0
- adapt/commands/check.py +41 -0
- adapt/commands/list_endpoints.py +37 -0
- adapt/commands/serve.py +78 -0
- adapt/config.py +230 -0
- adapt/discovery.py +117 -0
- adapt/locks.py +140 -0
- adapt/models.py +15 -0
- adapt/permissions.py +57 -0
- adapt/plugins/__init__.py +22 -0
- adapt/plugins/base.py +137 -0
- adapt/plugins/csv_plugin.py +67 -0
- adapt/plugins/dataset_plugin.py +390 -0
- adapt/plugins/excel_plugin.py +130 -0
- adapt/plugins/html_plugin.py +104 -0
- adapt/plugins/markdown_plugin.py +113 -0
- adapt/plugins/media_plugin.py +202 -0
- adapt/plugins/parquet_plugin.py +156 -0
- adapt/plugins/python_plugin.py +92 -0
- adapt/routes.py +68 -0
- adapt/static/admin/app.js +672 -0
- adapt/static/admin/style.css +299 -0
- adapt/storage.py +138 -0
- adapt/templates/admin/index.html +452 -0
- adapt/templates/admin_base.html +78 -0
- adapt/templates/base.html +81 -0
- adapt/templates/datatable.html +234 -0
- adapt/templates/landing.html +56 -0
- adapt/templates/login.html +128 -0
- adapt/templates/media_gallery.html +51 -0
- adapt/templates/media_player.html +23 -0
- adapt/templates/profile.html +169 -0
- adapt/utils/__init__.py +82 -0
- adapt/utils/query.py +91 -0
- adapt_server-0.1.0.dist-info/METADATA +576 -0
- adapt_server-0.1.0.dist-info/RECORD +75 -0
- adapt_server-0.1.0.dist-info/WHEEL +5 -0
- adapt_server-0.1.0.dist-info/entry_points.txt +2 -0
- adapt_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- adapt_server-0.1.0.dist-info/top_level.txt +1 -0
adapt/__init__.py
ADDED
adapt/__main__.py
ADDED
adapt/admin/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
7
|
+
logger.debug("Admin router initialized with prefix /admin")
|
|
8
|
+
|
|
9
|
+
# Import all submodules to register routes
|
|
10
|
+
from . import ui, users, locks, groups, permissions, api_keys, audit_logs, cache
|
adapt/admin/api_keys.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, Request, Query
|
|
2
|
+
from sqlalchemy import asc, desc
|
|
3
|
+
from sqlmodel import Session, select
|
|
4
|
+
from typing import List
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from ..auth import require_superuser
|
|
9
|
+
from ..storage import User, APIKey, get_db_session
|
|
10
|
+
from ..api_keys import generate_api_key
|
|
11
|
+
from ..audit import log_action
|
|
12
|
+
from . import router
|
|
13
|
+
from .models import APIKeyCreate
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
@router.get("/api-keys", response_model=List[APIKey])
|
|
18
|
+
def list_api_keys(
|
|
19
|
+
db: Session = Depends(get_db_session),
|
|
20
|
+
user_id: int | None = Query(None, ge=1),
|
|
21
|
+
is_active: bool | None = Query(None),
|
|
22
|
+
limit: int = Query(None, ge=1, le=1000),
|
|
23
|
+
offset: int = Query(0, ge=0),
|
|
24
|
+
sort: str = Query(None, pattern="^(created_at|expires_at|last_used_at|is_active|description)$"),
|
|
25
|
+
order: str = Query("asc", pattern="^(asc|desc)$"),
|
|
26
|
+
filter: str = None,
|
|
27
|
+
user = Depends(require_superuser)
|
|
28
|
+
):
|
|
29
|
+
"""List all API keys in the system with optional query parameters."""
|
|
30
|
+
import json
|
|
31
|
+
|
|
32
|
+
stmt = select(APIKey)
|
|
33
|
+
|
|
34
|
+
if user_id is not None:
|
|
35
|
+
stmt = stmt.where(APIKey.user_id == user_id)
|
|
36
|
+
if is_active is not None:
|
|
37
|
+
stmt = stmt.where(APIKey.is_active == is_active)
|
|
38
|
+
|
|
39
|
+
if filter:
|
|
40
|
+
filter_dict = json.loads(filter)
|
|
41
|
+
allowed_filters = {
|
|
42
|
+
"user_id": APIKey.user_id,
|
|
43
|
+
"description": APIKey.description,
|
|
44
|
+
"is_active": APIKey.is_active,
|
|
45
|
+
}
|
|
46
|
+
for key, value in filter_dict.items():
|
|
47
|
+
column = allowed_filters.get(key)
|
|
48
|
+
if column is not None:
|
|
49
|
+
stmt = stmt.where(column == value)
|
|
50
|
+
|
|
51
|
+
sort_columns = {
|
|
52
|
+
"created_at": APIKey.created_at,
|
|
53
|
+
"expires_at": APIKey.expires_at,
|
|
54
|
+
"last_used_at": APIKey.last_used_at,
|
|
55
|
+
"is_active": APIKey.is_active,
|
|
56
|
+
"description": APIKey.description,
|
|
57
|
+
}
|
|
58
|
+
sort_column = sort_columns.get(sort, APIKey.created_at)
|
|
59
|
+
stmt = stmt.order_by(asc(sort_column) if order == "asc" else desc(sort_column))
|
|
60
|
+
|
|
61
|
+
stmt = stmt.offset(offset)
|
|
62
|
+
if limit is not None:
|
|
63
|
+
stmt = stmt.limit(limit)
|
|
64
|
+
|
|
65
|
+
result = db.exec(stmt).all()
|
|
66
|
+
logger.debug("Listed %d API keys with SQL query params", len(result))
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
@router.post("/api-keys")
|
|
70
|
+
def create_api_key(key_data: APIKeyCreate, request: Request, db: Session = Depends(get_db_session), user = Depends(require_superuser)):
|
|
71
|
+
"""Create a new API key for a user."""
|
|
72
|
+
target_user = db.get(User, key_data.user_id)
|
|
73
|
+
if not target_user:
|
|
74
|
+
logger.warning("Attempted to create API key for non-existent user %d", key_data.user_id)
|
|
75
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
76
|
+
|
|
77
|
+
raw_key, key_hash = generate_api_key()
|
|
78
|
+
|
|
79
|
+
expires_at = None
|
|
80
|
+
if key_data.expires_in_days is not None:
|
|
81
|
+
if key_data.expires_in_days > 365:
|
|
82
|
+
raise HTTPException(status_code=400, detail="Expiration cannot exceed 1 year (365 days)")
|
|
83
|
+
expires_at = datetime.now(tz=timezone.utc) + timedelta(days=key_data.expires_in_days)
|
|
84
|
+
|
|
85
|
+
api_key = APIKey(
|
|
86
|
+
user_id=key_data.user_id,
|
|
87
|
+
key_hash=key_hash,
|
|
88
|
+
description=key_data.description,
|
|
89
|
+
expires_at=expires_at
|
|
90
|
+
)
|
|
91
|
+
db.add(api_key)
|
|
92
|
+
db.commit()
|
|
93
|
+
db.refresh(api_key)
|
|
94
|
+
|
|
95
|
+
log_action(request, "create_api_key", "apikey", f"Created API key for user {target_user.username}", user.id)
|
|
96
|
+
logger.info("Created API key %d for user %s", api_key.id, target_user.username)
|
|
97
|
+
|
|
98
|
+
# Return the raw key only once!
|
|
99
|
+
return {
|
|
100
|
+
"id": api_key.id,
|
|
101
|
+
"key": raw_key,
|
|
102
|
+
"user_id": api_key.user_id,
|
|
103
|
+
"description": api_key.description,
|
|
104
|
+
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
|
105
|
+
"created_at": api_key.created_at.isoformat() if api_key.created_at else None,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@router.delete("/api-keys/{key_id}")
|
|
109
|
+
def revoke_api_key(key_id: int, request: Request, db: Session = Depends(get_db_session), user = Depends(require_superuser)):
|
|
110
|
+
"""Revoke an API key by ID."""
|
|
111
|
+
api_key = db.get(APIKey, key_id)
|
|
112
|
+
if not api_key:
|
|
113
|
+
logger.warning("Attempted to revoke non-existent API key %d", key_id)
|
|
114
|
+
raise HTTPException(status_code=404, detail="API Key not found")
|
|
115
|
+
|
|
116
|
+
api_key.is_active = False
|
|
117
|
+
db.add(api_key)
|
|
118
|
+
db.commit()
|
|
119
|
+
|
|
120
|
+
log_action(request, "revoke_api_key", "apikey", f"Revoked API key {key_id}", user.id)
|
|
121
|
+
logger.info("Revoked API key %d", key_id)
|
|
122
|
+
|
|
123
|
+
return {"success": True}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from fastapi import Depends, Query
|
|
2
|
+
from sqlalchemy import asc, desc
|
|
3
|
+
from sqlmodel import Session, select
|
|
4
|
+
from typing import List
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from ..auth import require_superuser
|
|
8
|
+
from ..storage import AuditLog, get_db_session
|
|
9
|
+
from . import router
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
@router.get("/audit-logs", response_model=List[AuditLog])
|
|
14
|
+
def list_audit_logs(
|
|
15
|
+
db: Session = Depends(get_db_session),
|
|
16
|
+
user_id: int | None = Query(None, ge=1),
|
|
17
|
+
action: str | None = Query(None),
|
|
18
|
+
resource: str | None = Query(None),
|
|
19
|
+
limit: int = Query(None, ge=1, le=1000),
|
|
20
|
+
offset: int = Query(0, ge=0),
|
|
21
|
+
sort: str = Query(None, pattern="^(timestamp|action|resource)$"),
|
|
22
|
+
order: str = Query("desc", pattern="^(asc|desc)$"),
|
|
23
|
+
filter: str = None,
|
|
24
|
+
user = Depends(require_superuser)
|
|
25
|
+
):
|
|
26
|
+
"""List audit logs with optional query parameters."""
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
stmt = select(AuditLog)
|
|
30
|
+
|
|
31
|
+
if user_id is not None:
|
|
32
|
+
stmt = stmt.where(AuditLog.user_id == user_id)
|
|
33
|
+
if action:
|
|
34
|
+
stmt = stmt.where(AuditLog.action == action)
|
|
35
|
+
if resource:
|
|
36
|
+
stmt = stmt.where(AuditLog.resource == resource)
|
|
37
|
+
|
|
38
|
+
if filter:
|
|
39
|
+
filter_dict = json.loads(filter)
|
|
40
|
+
allowed_filters = {
|
|
41
|
+
"user_id": AuditLog.user_id,
|
|
42
|
+
"action": AuditLog.action,
|
|
43
|
+
"resource": AuditLog.resource,
|
|
44
|
+
"details": AuditLog.details,
|
|
45
|
+
"ip_address": AuditLog.ip_address,
|
|
46
|
+
}
|
|
47
|
+
for key, value in filter_dict.items():
|
|
48
|
+
column = allowed_filters.get(key)
|
|
49
|
+
if column is not None:
|
|
50
|
+
stmt = stmt.where(column == value)
|
|
51
|
+
|
|
52
|
+
sort_columns = {
|
|
53
|
+
"timestamp": AuditLog.timestamp,
|
|
54
|
+
"action": AuditLog.action,
|
|
55
|
+
"resource": AuditLog.resource,
|
|
56
|
+
}
|
|
57
|
+
sort_column = sort_columns.get(sort, AuditLog.timestamp)
|
|
58
|
+
stmt = stmt.order_by(asc(sort_column) if order == "asc" else desc(sort_column))
|
|
59
|
+
|
|
60
|
+
stmt = stmt.offset(offset)
|
|
61
|
+
if limit is not None:
|
|
62
|
+
stmt = stmt.limit(limit)
|
|
63
|
+
|
|
64
|
+
result = db.exec(stmt).all()
|
|
65
|
+
logger.debug("Listed %d audit logs with SQL query params", len(result))
|
|
66
|
+
return result
|
adapt/admin/cache.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, Request, Query
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from ..auth import require_superuser
|
|
7
|
+
from ..cache import list_cache, invalidate_cache
|
|
8
|
+
from ..audit import log_action
|
|
9
|
+
from . import router
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class CacheEntry(BaseModel):
|
|
14
|
+
"""Model for cache entry representation."""
|
|
15
|
+
key: str
|
|
16
|
+
expires_at: str
|
|
17
|
+
resource: str
|
|
18
|
+
user: Optional[str]
|
|
19
|
+
|
|
20
|
+
@router.get("/cache", response_model=List[CacheEntry])
|
|
21
|
+
def list_cache_entries(
|
|
22
|
+
resource: Optional[str] = None,
|
|
23
|
+
limit: int = Query(None, ge=1, le=1000),
|
|
24
|
+
offset: int = Query(0, ge=0),
|
|
25
|
+
sort: str = Query(None, pattern="^(key|expires_at|resource|user)$"),
|
|
26
|
+
order: str = Query("asc", pattern="^(asc|desc)$"),
|
|
27
|
+
filter: str = None,
|
|
28
|
+
user = Depends(require_superuser)
|
|
29
|
+
):
|
|
30
|
+
"""List cache entries with optional query parameters."""
|
|
31
|
+
from ..utils.query import apply_filter, apply_sort, apply_pagination
|
|
32
|
+
import json
|
|
33
|
+
|
|
34
|
+
entries = list_cache(resource)
|
|
35
|
+
|
|
36
|
+
# Convert to dicts for filtering/sorting
|
|
37
|
+
entry_dicts = []
|
|
38
|
+
for e in entries:
|
|
39
|
+
entry_dicts.append({
|
|
40
|
+
"key": e['key'],
|
|
41
|
+
"expires_at": e['expires_at'],
|
|
42
|
+
"resource": e['resource'],
|
|
43
|
+
"user": e['user']
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
# Apply filters
|
|
47
|
+
if filter:
|
|
48
|
+
filter_dict = json.loads(filter)
|
|
49
|
+
entry_dicts = apply_filter(entry_dicts, filter_dict)
|
|
50
|
+
|
|
51
|
+
# Apply sorting
|
|
52
|
+
if sort:
|
|
53
|
+
entry_dicts = apply_sort(entry_dicts, sort, order)
|
|
54
|
+
|
|
55
|
+
# Apply pagination
|
|
56
|
+
entry_dicts = apply_pagination(entry_dicts, offset, limit)
|
|
57
|
+
|
|
58
|
+
# Convert back to CacheEntry objects
|
|
59
|
+
result = [CacheEntry(key=e['key'], expires_at=e['expires_at'], resource=e['resource'], user=e['user']) for e in entry_dicts]
|
|
60
|
+
|
|
61
|
+
logger.debug("Listed %d cache entries with query params", len(result))
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
@router.delete("/cache")
|
|
65
|
+
def clear_cache(request: Request, resource: Optional[str] = None, user = Depends(require_superuser)):
|
|
66
|
+
"""Clear cache entries, optionally for a specific resource."""
|
|
67
|
+
invalidate_cache(resource)
|
|
68
|
+
log_action(request, "clear_cache", "cache", f"Cleared cache for resource {resource or 'all'}", user.id)
|
|
69
|
+
logger.info("Cleared cache for resource %s", resource or "all")
|
|
70
|
+
return {"success": True}
|
|
71
|
+
|
|
72
|
+
@router.delete("/cache/{key}")
|
|
73
|
+
def delete_cache_entry(key: str, resource: str, request: Request, user = Depends(require_superuser)):
|
|
74
|
+
"""Delete a specific cache entry."""
|
|
75
|
+
invalidate_cache(resource, key)
|
|
76
|
+
log_action(request, "delete_cache_entry", "cache", f"Deleted cache entry {key} for {resource}", user.id)
|
|
77
|
+
logger.info("Deleted cache entry %s for resource %s", key, resource)
|
|
78
|
+
return {"success": True}
|
adapt/admin/groups.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, Request, Query
|
|
2
|
+
from sqlmodel import Session, select
|
|
3
|
+
from typing import List
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from ..auth import require_superuser
|
|
7
|
+
from ..storage import User, Group, UserGroup, get_db_session
|
|
8
|
+
from ..audit import log_action
|
|
9
|
+
from . import router
|
|
10
|
+
from .models import GroupCreate, GroupRead
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
@router.get("/groups", response_model=List[Group])
|
|
15
|
+
def list_groups(
|
|
16
|
+
db: Session = Depends(get_db_session),
|
|
17
|
+
limit: int = Query(None, ge=1, le=1000),
|
|
18
|
+
offset: int = Query(0, ge=0),
|
|
19
|
+
sort: str = Query(None, pattern="^(name|description)$"),
|
|
20
|
+
order: str = Query("asc", pattern="^(asc|desc)$"),
|
|
21
|
+
filter: str = None,
|
|
22
|
+
user = Depends(require_superuser)
|
|
23
|
+
):
|
|
24
|
+
"""List all groups with optional query parameters."""
|
|
25
|
+
from ..utils.query import apply_filter, apply_sort, apply_pagination
|
|
26
|
+
import json
|
|
27
|
+
|
|
28
|
+
groups = db.exec(select(Group)).all()
|
|
29
|
+
|
|
30
|
+
# Convert to dicts
|
|
31
|
+
group_dicts = []
|
|
32
|
+
for g in groups:
|
|
33
|
+
group_dicts.append({
|
|
34
|
+
"id": g.id,
|
|
35
|
+
"name": g.name,
|
|
36
|
+
"description": g.description
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
# Apply filters
|
|
40
|
+
if filter:
|
|
41
|
+
filter_dict = json.loads(filter)
|
|
42
|
+
group_dicts = apply_filter(group_dicts, filter_dict)
|
|
43
|
+
|
|
44
|
+
# Apply sorting
|
|
45
|
+
if sort:
|
|
46
|
+
group_dicts = apply_sort(group_dicts, sort, order)
|
|
47
|
+
|
|
48
|
+
# Apply pagination
|
|
49
|
+
group_dicts = apply_pagination(group_dicts, offset, limit)
|
|
50
|
+
|
|
51
|
+
# Convert back to Group objects
|
|
52
|
+
result = []
|
|
53
|
+
for gd in group_dicts:
|
|
54
|
+
g = db.get(Group, gd["id"])
|
|
55
|
+
if g:
|
|
56
|
+
result.append(g)
|
|
57
|
+
|
|
58
|
+
logger.debug("Listed %d groups with query params", len(result))
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
@router.get("/groups/{group_id}", response_model=GroupRead)
|
|
62
|
+
def get_group(group_id: int, db: Session = Depends(get_db_session), user = Depends(require_superuser)):
|
|
63
|
+
"""Get a group by ID, including its users."""
|
|
64
|
+
group = db.get(Group, group_id)
|
|
65
|
+
if not group:
|
|
66
|
+
logger.warning("Attempted to get non-existent group %d", group_id)
|
|
67
|
+
raise HTTPException(status_code=404, detail="Group not found")
|
|
68
|
+
# Manually load users since it's a many-to-many relationship
|
|
69
|
+
# Note: SQLModel should handle this if configured correctly, but let's be explicit
|
|
70
|
+
# We need to join UserGroup and User
|
|
71
|
+
stmt = select(User).join(UserGroup).where(UserGroup.group_id == group_id)
|
|
72
|
+
users = db.exec(stmt).all()
|
|
73
|
+
|
|
74
|
+
logger.debug("Retrieved group %s with %d users", group.name, len(users))
|
|
75
|
+
return GroupRead(
|
|
76
|
+
id=group.id,
|
|
77
|
+
name=group.name,
|
|
78
|
+
description=group.description,
|
|
79
|
+
users=users
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@router.post("/groups", response_model=Group)
|
|
83
|
+
def create_group(group_data: GroupCreate, request: Request, db: Session = Depends(get_db_session), user = Depends(require_superuser)):
|
|
84
|
+
"""Create a new group."""
|
|
85
|
+
# Check if server is in read-only mode
|
|
86
|
+
if request.app.state.config.readonly:
|
|
87
|
+
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
88
|
+
|
|
89
|
+
existing = db.exec(select(Group).where(Group.name == group_data.name)).first()
|
|
90
|
+
if existing:
|
|
91
|
+
logger.warning("Attempted to create group with existing name %s", group_data.name)
|
|
92
|
+
raise HTTPException(status_code=400, detail="Group already exists")
|
|
93
|
+
|
|
94
|
+
group = Group(name=group_data.name, description=group_data.description)
|
|
95
|
+
db.add(group)
|
|
96
|
+
db.commit()
|
|
97
|
+
db.refresh(group)
|
|
98
|
+
|
|
99
|
+
log_action(request, "create_group", "group", f"Created group {group.name}", user.id)
|
|
100
|
+
logger.info("Created group %s", group.name)
|
|
101
|
+
|
|
102
|
+
return group
|
|
103
|
+
|
|
104
|
+
@router.delete("/groups/{group_id}")
|
|
105
|
+
def delete_group(group_id: int, request: Request, db: Session = Depends(get_db_session), user = Depends(require_superuser)):
|
|
106
|
+
"""Delete a group by ID."""
|
|
107
|
+
# Check if server is in read-only mode
|
|
108
|
+
if request.app.state.config.readonly:
|
|
109
|
+
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
110
|
+
|
|
111
|
+
group = db.get(Group, group_id)
|
|
112
|
+
if not group:
|
|
113
|
+
logger.warning("Attempted to delete non-existent group %d", group_id)
|
|
114
|
+
raise HTTPException(status_code=404, detail="Group not found")
|
|
115
|
+
db.delete(group)
|
|
116
|
+
db.commit()
|
|
117
|
+
|
|
118
|
+
log_action(request, "delete_group", "group", f"Deleted group {group.name}", user.id)
|
|
119
|
+
logger.info("Deleted group %s", group.name)
|
|
120
|
+
|
|
121
|
+
return {"success": True}
|
|
122
|
+
|
|
123
|
+
@router.post("/groups/{group_id}/users/{user_id}")
|
|
124
|
+
def add_user_to_group(group_id: int, user_id: int, request: Request, db: Session = Depends(get_db_session), user = Depends(require_superuser)):
|
|
125
|
+
"""Add a user to a group."""
|
|
126
|
+
# Check if server is in read-only mode
|
|
127
|
+
if request.app.state.config.readonly:
|
|
128
|
+
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
129
|
+
|
|
130
|
+
# Check existence
|
|
131
|
+
target_user = db.get(User, user_id)
|
|
132
|
+
target_group = db.get(Group, group_id)
|
|
133
|
+
if not target_user or not target_group:
|
|
134
|
+
logger.warning("Attempted to add user %d to group %d: user or group not found", user_id, group_id)
|
|
135
|
+
raise HTTPException(status_code=404, detail="User or Group not found")
|
|
136
|
+
|
|
137
|
+
link = db.get(UserGroup, (user_id, group_id))
|
|
138
|
+
if link:
|
|
139
|
+
logger.debug("User %s already in group %s", target_user.username, target_group.name)
|
|
140
|
+
return {"success": True} # Already member
|
|
141
|
+
|
|
142
|
+
link = UserGroup(user_id=user_id, group_id=group_id)
|
|
143
|
+
db.add(link)
|
|
144
|
+
db.commit()
|
|
145
|
+
log_action(request, "add_user_to_group", "group", f"Added user {target_user.username} to group {target_group.name}", user.id)
|
|
146
|
+
logger.info("Added user %s to group %s", target_user.username, target_group.name)
|
|
147
|
+
return {"success": True}
|
|
148
|
+
|
|
149
|
+
@router.delete("/groups/{group_id}/users/{user_id}")
|
|
150
|
+
def remove_user_from_group(group_id: int, user_id: int, request: Request, db: Session = Depends(get_db_session), user = Depends(require_superuser)):
|
|
151
|
+
"""Remove a user from a group."""
|
|
152
|
+
# Check if server is in read-only mode
|
|
153
|
+
if request.app.state.config.readonly:
|
|
154
|
+
raise HTTPException(status_code=405, detail="Server is in read-only mode")
|
|
155
|
+
|
|
156
|
+
target_user = db.get(User, user_id)
|
|
157
|
+
target_group = db.get(Group, group_id)
|
|
158
|
+
if not target_user or not target_group:
|
|
159
|
+
logger.warning("Attempted to remove user %d from group %d: user or group not found", user_id, group_id)
|
|
160
|
+
raise HTTPException(status_code=404, detail="User or Group not found")
|
|
161
|
+
|
|
162
|
+
link = db.get(UserGroup, (user_id, group_id))
|
|
163
|
+
if link:
|
|
164
|
+
db.delete(link)
|
|
165
|
+
db.commit()
|
|
166
|
+
log_action(request, "remove_user_from_group", "group", f"Removed user {target_user.username} from group {target_group.name}", user.id)
|
|
167
|
+
logger.info("Removed user %s from group %s", target_user.username, target_group.name)
|
|
168
|
+
else:
|
|
169
|
+
logger.debug("User %s not in group %s", target_user.username, target_group.name)
|
|
170
|
+
return {"success": True}
|
adapt/admin/locks.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, Request, Query
|
|
2
|
+
from sqlmodel import Session, select
|
|
3
|
+
from typing import List
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from ..auth import require_superuser
|
|
7
|
+
from ..storage import LockRecord, get_db_session
|
|
8
|
+
from ..locks import LockManager
|
|
9
|
+
from ..audit import log_action
|
|
10
|
+
from . import router
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
@router.get("/locks", response_model=List[LockRecord])
|
|
15
|
+
def list_locks(
|
|
16
|
+
db: Session = Depends(get_db_session),
|
|
17
|
+
limit: int = Query(None, ge=1, le=1000),
|
|
18
|
+
offset: int = Query(0, ge=0),
|
|
19
|
+
sort: str = Query(None, pattern="^(id|resource|owner|acquired_at|expires_at|reason)$"),
|
|
20
|
+
order: str = Query("asc", pattern="^(asc|desc)$"),
|
|
21
|
+
filter: str = None,
|
|
22
|
+
user = Depends(require_superuser)
|
|
23
|
+
):
|
|
24
|
+
"""List all active locks with optional query parameters."""
|
|
25
|
+
from ..utils.query import apply_filter, apply_sort, apply_pagination
|
|
26
|
+
import json
|
|
27
|
+
|
|
28
|
+
locks = db.exec(select(LockRecord)).all()
|
|
29
|
+
|
|
30
|
+
# Convert to dicts for filtering/sorting
|
|
31
|
+
lock_dicts = []
|
|
32
|
+
for l in locks:
|
|
33
|
+
lock_dicts.append({
|
|
34
|
+
"id": l.id,
|
|
35
|
+
"resource": l.resource,
|
|
36
|
+
"owner": l.owner,
|
|
37
|
+
"acquired_at": l.acquired_at.isoformat() if l.acquired_at else None,
|
|
38
|
+
"expires_at": l.expires_at.isoformat() if l.expires_at else None,
|
|
39
|
+
"reason": l.reason
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
# Apply filters
|
|
43
|
+
if filter:
|
|
44
|
+
filter_dict = json.loads(filter)
|
|
45
|
+
lock_dicts = apply_filter(lock_dicts, filter_dict)
|
|
46
|
+
|
|
47
|
+
# Apply sorting
|
|
48
|
+
if sort:
|
|
49
|
+
lock_dicts = apply_sort(lock_dicts, sort, order)
|
|
50
|
+
|
|
51
|
+
# Apply pagination
|
|
52
|
+
lock_dicts = apply_pagination(lock_dicts, offset, limit)
|
|
53
|
+
|
|
54
|
+
# Convert back to LockRecord objects
|
|
55
|
+
result = []
|
|
56
|
+
for ld in lock_dicts:
|
|
57
|
+
l = db.get(LockRecord, ld["id"])
|
|
58
|
+
if l:
|
|
59
|
+
result.append(l)
|
|
60
|
+
|
|
61
|
+
logger.debug("Listed %d active locks with query params", len(result))
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
@router.delete("/locks/{lock_id}")
|
|
65
|
+
def release_lock(lock_id: int, request: Request, user = Depends(require_superuser)):
|
|
66
|
+
"""Release a specific lock by ID."""
|
|
67
|
+
manager: LockManager = request.app.state.lock_manager
|
|
68
|
+
if manager.release_lock(lock_id):
|
|
69
|
+
log_action(request, "release_lock", "lock", f"Released lock {lock_id}", user.id)
|
|
70
|
+
logger.info("Released lock %d", lock_id)
|
|
71
|
+
return {"success": True}
|
|
72
|
+
logger.warning("Attempted to release non-existent lock %d", lock_id)
|
|
73
|
+
raise HTTPException(status_code=404, detail="Lock not found")
|
|
74
|
+
|
|
75
|
+
@router.post("/locks/clean")
|
|
76
|
+
def clean_stale_locks(request: Request, user = Depends(require_superuser)):
|
|
77
|
+
"""Clean up stale locks."""
|
|
78
|
+
manager: LockManager = request.app.state.lock_manager
|
|
79
|
+
count = manager.release_stale_locks()
|
|
80
|
+
log_action(request, "clean_stale_locks", "lock", f"Released {count} stale locks", user.id)
|
|
81
|
+
logger.info("Cleaned up %d stale locks", count)
|
|
82
|
+
return {"released": count}
|
adapt/admin/models.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from ..storage import User
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class UserCreate(BaseModel):
|
|
10
|
+
"""Model for creating a new user."""
|
|
11
|
+
username: str
|
|
12
|
+
password: str
|
|
13
|
+
is_superuser: bool = False
|
|
14
|
+
|
|
15
|
+
class GroupCreate(BaseModel):
|
|
16
|
+
"""Model for creating a new group."""
|
|
17
|
+
name: str
|
|
18
|
+
description: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
class GroupRead(BaseModel):
|
|
21
|
+
"""Model for reading group data with users."""
|
|
22
|
+
id: int
|
|
23
|
+
name: str
|
|
24
|
+
description: Optional[str] = None
|
|
25
|
+
users: List[User] = []
|
|
26
|
+
|
|
27
|
+
class PermissionCreate(BaseModel):
|
|
28
|
+
"""Model for creating a new permission."""
|
|
29
|
+
resource: str
|
|
30
|
+
action: str
|
|
31
|
+
description: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
class APIKeyCreate(BaseModel):
|
|
34
|
+
"""Model for creating a new API key."""
|
|
35
|
+
user_id: int
|
|
36
|
+
description: Optional[str] = None
|
|
37
|
+
expires_in_days: Optional[int] = None
|