Add comprehensive backend features and mobile UI improvements
Backend: - Add 2FA authentication with TOTP support - Add API keys management system - Add audit logging for security events - Add file upload/management system - Add notifications system with preferences - Add session management - Add webhooks integration - Add analytics endpoints - Add export functionality - Add password policy enforcement - Add new database migrations for core tables Frontend: - Add module position system (top/bottom sidebar sections) - Add search and notifications module configuration tabs - Add mobile logo replacing hamburger menu - Center page title absolutely when no tabs present - Align sidebar footer toggles with navigation items - Add lighter icon color in dark theme for mobile - Add API keys management page - Add notifications page with context - Add admin analytics and audit logs pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
280
backend/app/api/v1/analytics.py
Normal file
280
backend/app/api/v1/analytics.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Dashboard analytics endpoints."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
|
||||
from app.dependencies import get_db, get_current_superuser
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.session import UserSession
|
||||
from app.models.notification import Notification
|
||||
from app.models.api_key import APIKey
|
||||
from app import crud
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
def get_analytics_overview(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Get dashboard overview analytics.
|
||||
Returns summary statistics for the admin dashboard.
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
month_start = today_start.replace(day=1)
|
||||
|
||||
# User statistics
|
||||
total_users = db.query(func.count(User.id)).scalar() or 0
|
||||
active_users = db.query(func.count(User.id)).filter(User.is_active == True).scalar() or 0
|
||||
new_users_today = db.query(func.count(User.id)).filter(User.created_at >= today_start).scalar() or 0
|
||||
new_users_week = db.query(func.count(User.id)).filter(User.created_at >= week_start).scalar() or 0
|
||||
new_users_month = db.query(func.count(User.id)).filter(User.created_at >= month_start).scalar() or 0
|
||||
|
||||
# Active sessions
|
||||
active_sessions = db.query(func.count(UserSession.id))\
|
||||
.filter(UserSession.is_active == True).scalar() or 0
|
||||
|
||||
# API Keys
|
||||
total_api_keys = db.query(func.count(APIKey.id)).scalar() or 0
|
||||
active_api_keys = db.query(func.count(APIKey.id)).filter(APIKey.is_active == True).scalar() or 0
|
||||
|
||||
# Recent logins (last 24h)
|
||||
logins_24h = db.query(func.count(AuditLog.id))\
|
||||
.filter(AuditLog.action == "login")\
|
||||
.filter(AuditLog.status == "success")\
|
||||
.filter(AuditLog.created_at >= now - timedelta(hours=24))\
|
||||
.scalar() or 0
|
||||
|
||||
# Failed logins (last 24h)
|
||||
failed_logins_24h = db.query(func.count(AuditLog.id))\
|
||||
.filter(AuditLog.action == "login")\
|
||||
.filter(AuditLog.status == "failure")\
|
||||
.filter(AuditLog.created_at >= now - timedelta(hours=24))\
|
||||
.scalar() or 0
|
||||
|
||||
# Notifications stats
|
||||
unread_notifications = db.query(func.count(Notification.id))\
|
||||
.filter(Notification.is_read == False).scalar() or 0
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"new_today": new_users_today,
|
||||
"new_this_week": new_users_week,
|
||||
"new_this_month": new_users_month
|
||||
},
|
||||
"sessions": {
|
||||
"active": active_sessions
|
||||
},
|
||||
"api_keys": {
|
||||
"total": total_api_keys,
|
||||
"active": active_api_keys
|
||||
},
|
||||
"security": {
|
||||
"logins_24h": logins_24h,
|
||||
"failed_logins_24h": failed_logins_24h
|
||||
},
|
||||
"notifications": {
|
||||
"unread_total": unread_notifications
|
||||
},
|
||||
"generated_at": now.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/activity")
|
||||
def get_recent_activity(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
limit: int = Query(20, ge=1, le=100)
|
||||
) -> Any:
|
||||
"""
|
||||
Get recent activity from audit logs.
|
||||
"""
|
||||
logs = db.query(AuditLog)\
|
||||
.order_by(desc(AuditLog.created_at))\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"username": log.username,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"status": log.status,
|
||||
"ip_address": log.ip_address,
|
||||
"created_at": log.created_at.isoformat()
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users/activity")
|
||||
def get_user_activity_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
days: int = Query(7, ge=1, le=90)
|
||||
) -> Any:
|
||||
"""
|
||||
Get user activity statistics over time.
|
||||
Returns daily active users and new registrations.
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Get daily stats
|
||||
daily_stats = []
|
||||
for i in range(days):
|
||||
day_start = (start_date + timedelta(days=i)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
# Active users (users who logged in that day)
|
||||
active = db.query(func.count(func.distinct(AuditLog.user_id)))\
|
||||
.filter(AuditLog.action == "login")\
|
||||
.filter(AuditLog.status == "success")\
|
||||
.filter(AuditLog.created_at >= day_start)\
|
||||
.filter(AuditLog.created_at < day_end)\
|
||||
.scalar() or 0
|
||||
|
||||
# New registrations
|
||||
new_users = db.query(func.count(User.id))\
|
||||
.filter(User.created_at >= day_start)\
|
||||
.filter(User.created_at < day_end)\
|
||||
.scalar() or 0
|
||||
|
||||
daily_stats.append({
|
||||
"date": day_start.strftime("%Y-%m-%d"),
|
||||
"active_users": active,
|
||||
"new_users": new_users
|
||||
})
|
||||
|
||||
return {"daily_stats": daily_stats}
|
||||
|
||||
|
||||
@router.get("/actions/breakdown")
|
||||
def get_actions_breakdown(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
hours: int = Query(24, ge=1, le=168)
|
||||
) -> Any:
|
||||
"""
|
||||
Get breakdown of actions by type.
|
||||
"""
|
||||
since = datetime.utcnow() - timedelta(hours=hours)
|
||||
|
||||
# Group by action
|
||||
actions = db.query(
|
||||
AuditLog.action,
|
||||
func.count(AuditLog.id).label('count')
|
||||
).filter(AuditLog.created_at >= since)\
|
||||
.group_by(AuditLog.action)\
|
||||
.order_by(desc('count'))\
|
||||
.all()
|
||||
|
||||
return {
|
||||
"period_hours": hours,
|
||||
"actions": [{"action": action, "count": count} for action, count in actions]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/top-users")
|
||||
def get_top_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
days: int = Query(7, ge=1, le=90)
|
||||
) -> Any:
|
||||
"""
|
||||
Get most active users by action count.
|
||||
"""
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
top_users = db.query(
|
||||
AuditLog.user_id,
|
||||
AuditLog.username,
|
||||
func.count(AuditLog.id).label('action_count')
|
||||
).filter(AuditLog.created_at >= since)\
|
||||
.filter(AuditLog.user_id.isnot(None))\
|
||||
.group_by(AuditLog.user_id, AuditLog.username)\
|
||||
.order_by(desc('action_count'))\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"users": [
|
||||
{"user_id": uid, "username": uname, "action_count": count}
|
||||
for uid, uname, count in top_users
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/security/failed-logins")
|
||||
def get_failed_logins(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
hours: int = Query(24, ge=1, le=168)
|
||||
) -> Any:
|
||||
"""
|
||||
Get failed login attempts grouped by IP address.
|
||||
"""
|
||||
since = datetime.utcnow() - timedelta(hours=hours)
|
||||
|
||||
# Failed logins by IP
|
||||
by_ip = db.query(
|
||||
AuditLog.ip_address,
|
||||
func.count(AuditLog.id).label('count')
|
||||
).filter(AuditLog.action == "login")\
|
||||
.filter(AuditLog.status == "failure")\
|
||||
.filter(AuditLog.created_at >= since)\
|
||||
.group_by(AuditLog.ip_address)\
|
||||
.order_by(desc('count'))\
|
||||
.limit(20)\
|
||||
.all()
|
||||
|
||||
# Recent failed login details
|
||||
recent = db.query(AuditLog)\
|
||||
.filter(AuditLog.action == "login")\
|
||||
.filter(AuditLog.status == "failure")\
|
||||
.filter(AuditLog.created_at >= since)\
|
||||
.order_by(desc(AuditLog.created_at))\
|
||||
.limit(50)\
|
||||
.all()
|
||||
|
||||
recent_items = []
|
||||
for log in recent:
|
||||
attempted_username = log.username
|
||||
if not attempted_username and log.details:
|
||||
try:
|
||||
parsed = json.loads(log.details)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
attempted_username = parsed.get("username")
|
||||
|
||||
recent_items.append(
|
||||
{
|
||||
"id": log.id,
|
||||
"username": attempted_username,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"created_at": log.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return {"period_hours": hours, "by_ip": [{"ip": ip, "count": count} for ip, count in by_ip], "recent": recent_items}
|
||||
246
backend/app/api/v1/api_keys.py
Normal file
246
backend/app/api/v1/api_keys.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""API Key management endpoints."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app import crud
|
||||
from app.schemas.api_key import (
|
||||
APIKey,
|
||||
APIKeyCreate,
|
||||
APIKeyUpdate,
|
||||
APIKeyWithSecret,
|
||||
APIKeyList
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_KEYS_PER_USER = 10 # Limit API keys per user
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def serialize_api_key(db_obj) -> dict:
|
||||
"""Serialize API key for response."""
|
||||
scopes = None
|
||||
if db_obj.scopes:
|
||||
try:
|
||||
scopes = json.loads(db_obj.scopes)
|
||||
except json.JSONDecodeError:
|
||||
scopes = []
|
||||
|
||||
return {
|
||||
"id": db_obj.id,
|
||||
"user_id": db_obj.user_id,
|
||||
"name": db_obj.name,
|
||||
"key_prefix": db_obj.key_prefix,
|
||||
"scopes": scopes,
|
||||
"is_active": db_obj.is_active,
|
||||
"last_used_at": db_obj.last_used_at,
|
||||
"last_used_ip": db_obj.last_used_ip,
|
||||
"usage_count": int(db_obj.usage_count or "0"),
|
||||
"expires_at": db_obj.expires_at,
|
||||
"created_at": db_obj.created_at,
|
||||
"updated_at": db_obj.updated_at
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=APIKeyWithSecret, status_code=status.HTTP_201_CREATED)
|
||||
def create_api_key(
|
||||
request: Request,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
key_in: APIKeyCreate
|
||||
) -> Any:
|
||||
"""
|
||||
Create a new API key.
|
||||
The actual key is only returned once on creation.
|
||||
"""
|
||||
# Check key limit
|
||||
key_count = crud.api_key.count_by_user(db, current_user.id)
|
||||
if key_count >= MAX_KEYS_PER_USER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Maximum {MAX_KEYS_PER_USER} API keys allowed per user"
|
||||
)
|
||||
|
||||
db_obj, plain_key = crud.api_key.create(db, obj_in=key_in, user_id=current_user.id)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="create",
|
||||
resource_type="api_key",
|
||||
resource_id=db_obj.id,
|
||||
details={"name": key_in.name},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
result = serialize_api_key(db_obj)
|
||||
result["key"] = plain_key
|
||||
return result
|
||||
|
||||
|
||||
@router.get("", response_model=APIKeyList)
|
||||
def list_api_keys(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
List all API keys for the current user.
|
||||
"""
|
||||
keys = crud.api_key.get_multi_by_user(db, user_id=current_user.id)
|
||||
total = crud.api_key.count_by_user(db, current_user.id)
|
||||
|
||||
return {
|
||||
"items": [serialize_api_key(k) for k in keys],
|
||||
"total": total
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{key_id}", response_model=APIKey)
|
||||
def get_api_key(
|
||||
key_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get a specific API key.
|
||||
"""
|
||||
db_obj = crud.api_key.get(db, id=key_id)
|
||||
if not db_obj or db_obj.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
return serialize_api_key(db_obj)
|
||||
|
||||
|
||||
@router.patch("/{key_id}", response_model=APIKey)
|
||||
def update_api_key(
|
||||
request: Request,
|
||||
key_id: str,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
key_in: APIKeyUpdate
|
||||
) -> Any:
|
||||
"""
|
||||
Update an API key.
|
||||
"""
|
||||
db_obj = crud.api_key.get(db, id=key_id)
|
||||
if not db_obj or db_obj.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
db_obj = crud.api_key.update(db, db_obj=db_obj, obj_in=key_in)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="update",
|
||||
resource_type="api_key",
|
||||
resource_id=db_obj.id,
|
||||
details={"name": db_obj.name, "changes": key_in.model_dump(exclude_unset=True)},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return serialize_api_key(db_obj)
|
||||
|
||||
|
||||
@router.post("/{key_id}/revoke", response_model=APIKey)
|
||||
def revoke_api_key(
|
||||
request: Request,
|
||||
key_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Revoke (deactivate) an API key.
|
||||
"""
|
||||
db_obj = crud.api_key.get(db, id=key_id)
|
||||
if not db_obj or db_obj.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
db_obj = crud.api_key.revoke(db, id=key_id)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="revoke",
|
||||
resource_type="api_key",
|
||||
resource_id=db_obj.id,
|
||||
details={"name": db_obj.name},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return serialize_api_key(db_obj)
|
||||
|
||||
|
||||
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_api_key(
|
||||
request: Request,
|
||||
key_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""
|
||||
Delete an API key.
|
||||
"""
|
||||
db_obj = crud.api_key.get(db, id=key_id)
|
||||
if not db_obj or db_obj.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
key_name = db_obj.name
|
||||
|
||||
if not crud.api_key.delete(db, id=key_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete API key"
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="delete",
|
||||
resource_type="api_key",
|
||||
resource_id=key_id,
|
||||
details={"name": key_name},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
162
backend/app/api/v1/audit.py
Normal file
162
backend/app/api/v1/audit.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Audit log API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_superuser
|
||||
from app.models.user import User
|
||||
from app import crud
|
||||
from app.schemas.audit_log import (
|
||||
AuditLog,
|
||||
AuditLogList,
|
||||
AuditLogFilter,
|
||||
AuditLogStats
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=AuditLogList)
|
||||
def get_audit_logs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
user_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
):
|
||||
"""
|
||||
Get paginated audit logs with optional filtering.
|
||||
Requires superuser authentication.
|
||||
"""
|
||||
filters = AuditLogFilter(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
status=status_filter,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
items, total = crud.audit_log.get_multi(
|
||||
db, skip=skip, limit=page_size, filters=filters
|
||||
)
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
return AuditLogList(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AuditLogStats)
|
||||
def get_audit_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Get audit log statistics.
|
||||
Requires superuser authentication.
|
||||
"""
|
||||
return crud.audit_log.get_stats(db)
|
||||
|
||||
|
||||
@router.get("/actions")
|
||||
def get_distinct_actions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Get list of distinct action types for filtering.
|
||||
"""
|
||||
return {"actions": crud.audit_log.get_distinct_actions(db)}
|
||||
|
||||
|
||||
@router.get("/resource-types")
|
||||
def get_distinct_resource_types(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Get list of distinct resource types for filtering.
|
||||
"""
|
||||
return {"resource_types": crud.audit_log.get_distinct_resource_types(db)}
|
||||
|
||||
|
||||
@router.get("/user/{user_id}", response_model=AuditLogList)
|
||||
def get_user_audit_logs(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
Get audit logs for a specific user.
|
||||
Requires superuser authentication.
|
||||
"""
|
||||
filters = AuditLogFilter(user_id=user_id)
|
||||
skip = (page - 1) * page_size
|
||||
items, total = crud.audit_log.get_multi(
|
||||
db, skip=skip, limit=page_size, filters=filters
|
||||
)
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
return AuditLogList(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{log_id}", response_model=AuditLog)
|
||||
def get_audit_log(
|
||||
log_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Get a specific audit log entry.
|
||||
Requires superuser authentication.
|
||||
"""
|
||||
log = crud.audit_log.get(db, id=log_id)
|
||||
if not log:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Audit log not found"
|
||||
)
|
||||
return log
|
||||
|
||||
|
||||
@router.delete("/cleanup")
|
||||
def cleanup_old_logs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
days: int = Query(90, ge=1, le=365),
|
||||
):
|
||||
"""
|
||||
Delete audit logs older than specified days.
|
||||
Requires superuser authentication.
|
||||
Default: 90 days.
|
||||
"""
|
||||
deleted = crud.audit_log.delete_old(db, days=days)
|
||||
return {"deleted": deleted, "days_threshold": days}
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app import crud, schemas
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.dependencies import get_db, get_current_user, security
|
||||
from app.core.security import create_access_token
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
@@ -14,9 +17,22 @@ from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Rate limiter for auth endpoints
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request, considering proxies."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
@router.post("/register", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("5/minute") # Limit registration attempts
|
||||
def register(
|
||||
request: Request,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
user_in: schemas.RegisterRequest
|
||||
@@ -27,32 +43,54 @@ def register(
|
||||
Creates a new user account with the provided credentials.
|
||||
Registration can be disabled by administrators via settings.
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
user_agent = request.headers.get("User-Agent", "")[:500]
|
||||
|
||||
# Check if this is the first user (always allow for initial setup)
|
||||
user_count = db.query(User).count()
|
||||
is_first_user = user_count == 0
|
||||
|
||||
|
||||
# If not the first user, check if registration is enabled
|
||||
if not is_first_user:
|
||||
registration_enabled_raw = crud.settings.get_setting_value(
|
||||
db,
|
||||
key="registration_enabled",
|
||||
db,
|
||||
key="registration_enabled",
|
||||
default=True # Default to enabled if setting doesn't exist
|
||||
)
|
||||
|
||||
|
||||
if isinstance(registration_enabled_raw, str):
|
||||
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
|
||||
else:
|
||||
registration_enabled = bool(registration_enabled_raw)
|
||||
|
||||
if not registration_enabled:
|
||||
# Log failed registration attempt
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
action="register",
|
||||
resource_type="user",
|
||||
details={"username": user_in.username, "reason": "registration_disabled"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User registration is currently disabled"
|
||||
)
|
||||
|
||||
|
||||
# Check if username already exists
|
||||
user = crud.user.get_by_username(db, username=user_in.username)
|
||||
if user:
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
action="register",
|
||||
resource_type="user",
|
||||
details={"username": user_in.username, "reason": "username_exists"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered"
|
||||
@@ -61,11 +99,44 @@ def register(
|
||||
# Check if email already exists
|
||||
user = crud.user.get_by_email(db, email=user_in.email)
|
||||
if user:
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
action="register",
|
||||
resource_type="user",
|
||||
details={"username": user_in.username, "reason": "email_exists"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Validate password against policy
|
||||
from app.core.password_policy import get_password_policy, validate_password
|
||||
policy = get_password_policy(db)
|
||||
password_errors = validate_password(
|
||||
user_in.password,
|
||||
policy=policy,
|
||||
username=user_in.username,
|
||||
email=user_in.email
|
||||
)
|
||||
if password_errors:
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
action="register",
|
||||
resource_type="user",
|
||||
details={"username": user_in.username, "reason": "weak_password"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Password does not meet requirements", "errors": password_errors}
|
||||
)
|
||||
|
||||
# Create new user (first user becomes superuser)
|
||||
user_create = schemas.UserCreate(
|
||||
username=user_in.username,
|
||||
@@ -76,11 +147,28 @@ def register(
|
||||
)
|
||||
|
||||
user = crud.user.create(db, obj_in=user_create)
|
||||
|
||||
# Log successful registration
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
action="register",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
details={"is_first_user": is_first_user},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="success"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=schemas.Token)
|
||||
@router.post("/login")
|
||||
@limiter.limit("10/minute") # Stricter limit for login attempts
|
||||
def login(
|
||||
request: Request,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
credentials: schemas.LoginRequest
|
||||
@@ -89,7 +177,11 @@ def login(
|
||||
Login and get access token.
|
||||
|
||||
Authenticates user and returns a JWT access token.
|
||||
If 2FA is enabled, returns requires_2fa=True with a temp_token.
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
user_agent = request.headers.get("User-Agent", "")[:500]
|
||||
|
||||
# Authenticate user
|
||||
user = crud.user.authenticate(
|
||||
db,
|
||||
@@ -98,6 +190,16 @@ def login(
|
||||
)
|
||||
|
||||
if not user:
|
||||
# Log failed login attempt
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
action="login",
|
||||
resource_type="auth",
|
||||
details={"username": credentials.username, "reason": "invalid_credentials"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
@@ -105,11 +207,70 @@ def login(
|
||||
)
|
||||
|
||||
if not crud.user.is_active(user):
|
||||
# Log inactive user login attempt
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
action="login",
|
||||
resource_type="auth",
|
||||
details={"reason": "inactive_user"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# Check if 2FA is enabled
|
||||
if user.totp_enabled:
|
||||
# If TOTP code provided, verify it
|
||||
if credentials.totp_code:
|
||||
from app.api.v1.two_factor import verify_totp_or_backup
|
||||
if not verify_totp_or_backup(user, credentials.totp_code, db):
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
action="login",
|
||||
resource_type="auth",
|
||||
details={"reason": "invalid_2fa_code"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid 2FA code"
|
||||
)
|
||||
else:
|
||||
# No code provided, return temp token for 2FA verification
|
||||
temp_token_expires = timedelta(minutes=5) # 5 minute expiry
|
||||
temp_token = create_access_token(
|
||||
data={"sub": user.id, "temp": True, "purpose": "2fa"},
|
||||
expires_delta=temp_token_expires
|
||||
)
|
||||
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
action="login_2fa_required",
|
||||
resource_type="auth",
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="pending"
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": None,
|
||||
"token_type": "bearer",
|
||||
"requires_2fa": True,
|
||||
"temp_token": temp_token
|
||||
}
|
||||
|
||||
# Update last_login timestamp
|
||||
user.last_login = datetime.utcnow()
|
||||
db.add(user)
|
||||
@@ -122,6 +283,135 @@ def login(
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
# Track session for revocation / active sessions
|
||||
session = crud.session.create(
|
||||
db,
|
||||
user_id=user.id,
|
||||
token=access_token,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
expires_at=datetime.utcnow() + access_token_expires,
|
||||
)
|
||||
crud.session.mark_as_current(db, session_id=session.id, user_id=user.id)
|
||||
|
||||
# Log successful login
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
action="login",
|
||||
resource_type="auth",
|
||||
details={"session_id": session.id},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"requires_2fa": False,
|
||||
"temp_token": None
|
||||
}
|
||||
|
||||
|
||||
@router.post("/verify-2fa", response_model=schemas.Token)
|
||||
def verify_2fa_login(
|
||||
request: Request,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
verify_request: schemas.Verify2FARequest
|
||||
) -> Any:
|
||||
"""
|
||||
Complete login by verifying 2FA code.
|
||||
|
||||
Use the temp_token from the login response along with the TOTP code.
|
||||
"""
|
||||
from jose import JWTError
|
||||
from app.core.security import decode_access_token
|
||||
from app.api.v1.two_factor import verify_totp_or_backup
|
||||
|
||||
ip_address = get_client_ip(request)
|
||||
user_agent = request.headers.get("User-Agent", "")[:500]
|
||||
|
||||
try:
|
||||
payload = decode_access_token(verify_request.temp_token)
|
||||
user_id = payload.get("sub")
|
||||
is_temp = payload.get("temp")
|
||||
purpose = payload.get("purpose")
|
||||
|
||||
if not user_id or not is_temp or purpose != "2fa":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid temporary token"
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired temporary token"
|
||||
)
|
||||
|
||||
user = crud.user.get(db, id=user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Verify TOTP code
|
||||
if not verify_totp_or_backup(user, verify_request.code, db):
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
action="login_2fa_verify",
|
||||
resource_type="auth",
|
||||
details={"reason": "invalid_code"},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid 2FA code"
|
||||
)
|
||||
|
||||
# Update last_login timestamp
|
||||
user.last_login = datetime.utcnow()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
# Create full access token
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.id},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
# Track session for revocation / active sessions
|
||||
session = crud.session.create(
|
||||
db,
|
||||
user_id=user.id,
|
||||
token=access_token,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
expires_at=datetime.utcnow() + access_token_expires,
|
||||
)
|
||||
crud.session.mark_as_current(db, session_id=session.id, user_id=user.id)
|
||||
|
||||
# Log successful login
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
action="login",
|
||||
resource_type="auth",
|
||||
details={"method": "2fa", "session_id": session.id},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer"
|
||||
@@ -141,6 +431,41 @@ def read_users_me(
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Logout by revoking the current session.
|
||||
|
||||
This invalidates the current access token server-side (session revocation).
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
user_agent = request.headers.get("User-Agent", "")[:500]
|
||||
token = credentials.credentials
|
||||
|
||||
session = crud.session.get_by_token(db, token)
|
||||
if session:
|
||||
crud.session.revoke(db, id=session.id, user_id=current_user.id)
|
||||
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="logout",
|
||||
resource_type="auth",
|
||||
details={"session_id": session.id if session else None},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status="success",
|
||||
)
|
||||
|
||||
return {"message": "Logged out"}
|
||||
|
||||
|
||||
@router.get("/registration-status")
|
||||
def get_registration_status(
|
||||
db: Session = Depends(get_db)
|
||||
@@ -163,3 +488,43 @@ def get_registration_status(
|
||||
registration_enabled = bool(registration_enabled_raw)
|
||||
|
||||
return {"registration_enabled": registration_enabled}
|
||||
|
||||
|
||||
@router.get("/password-requirements")
|
||||
def get_password_requirements(
|
||||
db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
"""
|
||||
Get password requirements/policy.
|
||||
|
||||
This is a public endpoint that returns the password policy
|
||||
for display during registration.
|
||||
"""
|
||||
from app.core.password_policy import get_password_policy, get_password_requirements as get_reqs
|
||||
|
||||
policy = get_password_policy(db)
|
||||
return get_reqs(policy)
|
||||
|
||||
|
||||
@router.post("/check-password-strength")
|
||||
def check_password_strength(
|
||||
password_data: dict,
|
||||
db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
"""
|
||||
Check password strength.
|
||||
|
||||
This is a public endpoint that returns password strength analysis
|
||||
for real-time feedback during registration.
|
||||
"""
|
||||
from app.core.password_policy import check_password_strength as check_strength
|
||||
|
||||
password = password_data.get("password", "")
|
||||
if not password:
|
||||
return {
|
||||
"score": 0,
|
||||
"level": "weak",
|
||||
"feedback": ["Password is required"]
|
||||
}
|
||||
|
||||
return check_strength(password)
|
||||
|
||||
370
backend/app/api/v1/export.py
Normal file
370
backend/app/api/v1/export.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""Data export/import endpoints."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.dependencies import get_db, get_current_superuser
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
from app import crud, schemas
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
"""Import operation result."""
|
||||
success: int = 0
|
||||
failed: int = 0
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXPORT ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/users/csv")
|
||||
def export_users_csv(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Export all users to CSV format.
|
||||
"""
|
||||
users = db.query(User).all()
|
||||
|
||||
# Create CSV in memory
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Header
|
||||
writer.writerow([
|
||||
"id", "username", "email", "is_active", "is_superuser",
|
||||
"totp_enabled", "created_at", "last_login"
|
||||
])
|
||||
|
||||
# Data rows
|
||||
for user in users:
|
||||
writer.writerow([
|
||||
user.id,
|
||||
user.username,
|
||||
user.email,
|
||||
user.is_active,
|
||||
user.is_superuser,
|
||||
user.totp_enabled,
|
||||
user.created_at.isoformat() if user.created_at else "",
|
||||
user.last_login.isoformat() if user.last_login else ""
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Log export
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="export",
|
||||
resource_type="users",
|
||||
details={"format": "csv", "count": len(users)},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=users_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/json")
|
||||
def export_users_json(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Export all users to JSON format.
|
||||
"""
|
||||
users = db.query(User).all()
|
||||
|
||||
data = {
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"exported_by": current_user.username,
|
||||
"count": len(users),
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_active": user.is_active,
|
||||
"is_superuser": user.is_superuser,
|
||||
"totp_enabled": user.totp_enabled,
|
||||
"permissions": user.permissions,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
}
|
||||
|
||||
# Log export
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="export",
|
||||
resource_type="users",
|
||||
details={"format": "json", "count": len(users)},
|
||||
status="success"
|
||||
)
|
||||
|
||||
content = json.dumps(data, indent=2)
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=users_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings/json")
|
||||
def export_settings_json(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Export all settings to JSON format.
|
||||
"""
|
||||
from app.models.settings import Settings
|
||||
|
||||
settings_list = db.query(Settings).all()
|
||||
|
||||
data = {
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"exported_by": current_user.username,
|
||||
"count": len(settings_list),
|
||||
"settings": {
|
||||
setting.key: setting.value
|
||||
for setting in settings_list
|
||||
}
|
||||
}
|
||||
|
||||
# Log export
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="export",
|
||||
resource_type="settings",
|
||||
details={"format": "json", "count": len(settings_list)},
|
||||
status="success"
|
||||
)
|
||||
|
||||
content = json.dumps(data, indent=2)
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=settings_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit/csv")
|
||||
def export_audit_csv(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
days: int = 30
|
||||
):
|
||||
"""
|
||||
Export audit logs to CSV format.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
logs = db.query(AuditLog).filter(AuditLog.created_at >= since).all()
|
||||
|
||||
# Create CSV in memory
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Header
|
||||
writer.writerow([
|
||||
"id", "user_id", "username", "action", "resource_type",
|
||||
"resource_id", "status", "ip_address", "created_at"
|
||||
])
|
||||
|
||||
# Data rows
|
||||
for log in logs:
|
||||
writer.writerow([
|
||||
log.id,
|
||||
log.user_id,
|
||||
log.username,
|
||||
log.action,
|
||||
log.resource_type,
|
||||
log.resource_id,
|
||||
log.status,
|
||||
log.ip_address,
|
||||
log.created_at.isoformat() if log.created_at else ""
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=audit_logs_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# IMPORT ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/users/json", response_model=ImportResult)
|
||||
async def import_users_json(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Import users from JSON file.
|
||||
Only creates new users, does not update existing ones.
|
||||
"""
|
||||
result = ImportResult()
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode())
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON file: {str(e)}"
|
||||
)
|
||||
|
||||
users_data = data.get("users", [])
|
||||
if not users_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No users found in file"
|
||||
)
|
||||
|
||||
for user_data in users_data:
|
||||
try:
|
||||
username = user_data.get("username")
|
||||
email = user_data.get("email")
|
||||
|
||||
# Check if user already exists
|
||||
if crud.user.get_by_username(db, username=username):
|
||||
result.errors.append(f"User '{username}' already exists")
|
||||
result.failed += 1
|
||||
continue
|
||||
|
||||
if crud.user.get_by_email(db, email=email):
|
||||
result.errors.append(f"Email '{email}' already exists")
|
||||
result.failed += 1
|
||||
continue
|
||||
|
||||
# Create user with a default password (must be changed)
|
||||
import secrets
|
||||
temp_password = secrets.token_urlsafe(16)
|
||||
|
||||
user_create = schemas.UserCreate(
|
||||
username=username,
|
||||
email=email,
|
||||
password=temp_password,
|
||||
is_active=user_data.get("is_active", True),
|
||||
is_superuser=user_data.get("is_superuser", False),
|
||||
permissions=user_data.get("permissions")
|
||||
)
|
||||
|
||||
crud.user.create(db, obj_in=user_create)
|
||||
result.success += 1
|
||||
|
||||
except Exception as e:
|
||||
result.errors.append(f"Error importing user: {str(e)}")
|
||||
result.failed += 1
|
||||
|
||||
# Log import
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="import",
|
||||
resource_type="users",
|
||||
details={
|
||||
"format": "json",
|
||||
"success": result.success,
|
||||
"failed": result.failed
|
||||
},
|
||||
status="success" if result.failed == 0 else "partial"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/settings/json", response_model=ImportResult)
|
||||
async def import_settings_json(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Import settings from JSON file.
|
||||
Updates existing settings and creates new ones.
|
||||
"""
|
||||
result = ImportResult()
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode())
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON file: {str(e)}"
|
||||
)
|
||||
|
||||
settings_data = data.get("settings", {})
|
||||
if not settings_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No settings found in file"
|
||||
)
|
||||
|
||||
for key, value in settings_data.items():
|
||||
try:
|
||||
crud.settings.update_setting(db, key=key, value=value)
|
||||
result.success += 1
|
||||
except Exception as e:
|
||||
result.errors.append(f"Error importing setting '{key}': {str(e)}")
|
||||
result.failed += 1
|
||||
|
||||
# Log import
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="import",
|
||||
resource_type="settings",
|
||||
details={
|
||||
"format": "json",
|
||||
"success": result.success,
|
||||
"failed": result.failed
|
||||
},
|
||||
status="success" if result.failed == 0 else "partial"
|
||||
)
|
||||
|
||||
return result
|
||||
377
backend/app/api/v1/files.py
Normal file
377
backend/app/api/v1/files.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""File storage endpoints."""
|
||||
|
||||
import json
|
||||
from typing import Any, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_user, get_current_superuser
|
||||
from app.models.user import User
|
||||
from app import crud
|
||||
from app.schemas.file import (
|
||||
StoredFile as StoredFileSchema,
|
||||
FileCreate,
|
||||
FileUpdate,
|
||||
FileUploadResponse,
|
||||
FileListResponse,
|
||||
ALLOWED_CONTENT_TYPES,
|
||||
MAX_FILE_SIZE,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def file_to_schema(db_file) -> dict:
|
||||
"""Convert a StoredFile model to schema dict."""
|
||||
return {
|
||||
"id": db_file.id,
|
||||
"original_filename": db_file.original_filename,
|
||||
"content_type": db_file.content_type,
|
||||
"size_bytes": db_file.size_bytes,
|
||||
"storage_type": db_file.storage_type,
|
||||
"description": db_file.description,
|
||||
"tags": json.loads(db_file.tags) if db_file.tags else None,
|
||||
"is_public": db_file.is_public,
|
||||
"uploaded_by": db_file.uploaded_by,
|
||||
"file_hash": db_file.file_hash,
|
||||
"created_at": db_file.created_at,
|
||||
"updated_at": db_file.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[str] = None, # Comma-separated tags
|
||||
is_public: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Upload a file.
|
||||
Returns file metadata with download URL.
|
||||
"""
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
size = len(content)
|
||||
|
||||
# Validate upload
|
||||
is_valid, error = crud.file_storage.validate_upload(
|
||||
content_type=file.content_type,
|
||||
size_bytes=size
|
||||
)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error
|
||||
)
|
||||
|
||||
# Parse tags
|
||||
tag_list = None
|
||||
if tags:
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
# Create file metadata
|
||||
metadata = FileCreate(
|
||||
description=description,
|
||||
tags=tag_list,
|
||||
is_public=is_public
|
||||
)
|
||||
|
||||
# Reset file position for reading
|
||||
await file.seek(0)
|
||||
|
||||
# Save file
|
||||
import io
|
||||
stored_file = crud.file_storage.create(
|
||||
db,
|
||||
file=io.BytesIO(content),
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
size_bytes=size,
|
||||
uploaded_by=current_user.id,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="upload",
|
||||
resource_type="file",
|
||||
resource_id=stored_file.id,
|
||||
details={"filename": file.filename, "size": size},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": stored_file.id,
|
||||
"original_filename": stored_file.original_filename,
|
||||
"content_type": stored_file.content_type,
|
||||
"size_bytes": stored_file.size_bytes,
|
||||
"download_url": f"/api/v1/files/{stored_file.id}/download"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=FileListResponse)
|
||||
def list_files(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
mine_only: bool = False,
|
||||
is_public: Optional[bool] = None,
|
||||
content_type: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
List files with pagination and filtering.
|
||||
Regular users can only see their own files and public files.
|
||||
Superusers can see all files.
|
||||
"""
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# Filter by ownership for non-superusers
|
||||
if not current_user.is_superuser:
|
||||
if mine_only:
|
||||
uploaded_by = current_user.id
|
||||
is_public = None
|
||||
else:
|
||||
# Show user's files and public files
|
||||
own_files = crud.file_storage.get_multi(
|
||||
db,
|
||||
skip=0,
|
||||
limit=1000, # Get all for filtering
|
||||
uploaded_by=current_user.id
|
||||
)
|
||||
public_files = crud.file_storage.get_multi(
|
||||
db,
|
||||
skip=0,
|
||||
limit=1000,
|
||||
is_public=True
|
||||
)
|
||||
# Combine and deduplicate
|
||||
all_files = {f.id: f for f in own_files}
|
||||
all_files.update({f.id: f for f in public_files})
|
||||
files_list = list(all_files.values())
|
||||
# Sort by created_at desc
|
||||
files_list.sort(key=lambda x: x.created_at, reverse=True)
|
||||
# Paginate
|
||||
total = len(files_list)
|
||||
files = files_list[skip:skip + page_size]
|
||||
|
||||
return {
|
||||
"files": [file_to_schema(f) for f in files],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
uploaded_by = current_user.id if mine_only else None
|
||||
else:
|
||||
uploaded_by = current_user.id if mine_only else None
|
||||
|
||||
files = crud.file_storage.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
uploaded_by=uploaded_by,
|
||||
is_public=is_public,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
total = crud.file_storage.count(
|
||||
db,
|
||||
uploaded_by=uploaded_by,
|
||||
is_public=is_public
|
||||
)
|
||||
|
||||
return {
|
||||
"files": [file_to_schema(f) for f in files],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{file_id}", response_model=StoredFileSchema)
|
||||
def get_file(
|
||||
file_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get file metadata.
|
||||
Users can only access their own files or public files.
|
||||
"""
|
||||
stored_file = crud.file_storage.get(db, id=file_id)
|
||||
if not stored_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check access
|
||||
if not stored_file.is_public and stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
return file_to_schema(stored_file)
|
||||
|
||||
|
||||
@router.get("/{file_id}/download")
|
||||
def download_file(
|
||||
file_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Download a file.
|
||||
Users can only download their own files or public files.
|
||||
"""
|
||||
stored_file = crud.file_storage.get(db, id=file_id)
|
||||
if not stored_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check access
|
||||
if not stored_file.is_public and stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Get file path
|
||||
file_path = crud.file_storage.get_file_content(stored_file)
|
||||
if not file_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File content not found"
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=stored_file.original_filename,
|
||||
media_type=stored_file.content_type
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{file_id}", response_model=StoredFileSchema)
|
||||
def update_file(
|
||||
file_id: str,
|
||||
file_in: FileUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update file metadata.
|
||||
Users can only update their own files.
|
||||
"""
|
||||
stored_file = crud.file_storage.get(db, id=file_id)
|
||||
if not stored_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
updated = crud.file_storage.update(db, db_obj=stored_file, obj_in=file_in)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="update",
|
||||
resource_type="file",
|
||||
resource_id=file_id,
|
||||
details={"filename": stored_file.original_filename},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return file_to_schema(updated)
|
||||
|
||||
|
||||
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_file(
|
||||
file_id: str,
|
||||
permanent: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a file.
|
||||
Users can only delete their own files.
|
||||
Superusers can permanently delete files.
|
||||
"""
|
||||
stored_file = crud.file_storage.get(db, id=file_id)
|
||||
if not stored_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="delete",
|
||||
resource_type="file",
|
||||
resource_id=file_id,
|
||||
details={
|
||||
"filename": stored_file.original_filename,
|
||||
"permanent": permanent
|
||||
},
|
||||
status="success"
|
||||
)
|
||||
|
||||
if permanent and current_user.is_superuser:
|
||||
crud.file_storage.hard_delete(db, id=file_id)
|
||||
else:
|
||||
crud.file_storage.soft_delete(db, id=file_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/allowed-types/", response_model=List[str])
|
||||
def get_allowed_types(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get list of allowed file types for upload.
|
||||
"""
|
||||
return ALLOWED_CONTENT_TYPES
|
||||
|
||||
|
||||
@router.get("/max-size/", response_model=dict)
|
||||
def get_max_size(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get maximum allowed file size.
|
||||
"""
|
||||
return {
|
||||
"max_size_bytes": MAX_FILE_SIZE,
|
||||
"max_size_mb": MAX_FILE_SIZE / (1024 * 1024)
|
||||
}
|
||||
@@ -1,20 +1,65 @@
|
||||
"""Health check endpoints."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import psutil
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.dependencies import get_db, get_current_user, get_current_superuser
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Store app start time
|
||||
APP_START_TIME = datetime.utcnow()
|
||||
|
||||
|
||||
def get_system_info() -> dict:
|
||||
"""Get detailed system information."""
|
||||
# Memory info
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
# Disk info
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
# CPU info
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||
|
||||
return {
|
||||
"memory": {
|
||||
"total_gb": round(memory.total / (1024**3), 2),
|
||||
"used_gb": round(memory.used / (1024**3), 2),
|
||||
"available_gb": round(memory.available / (1024**3), 2),
|
||||
"percent": memory.percent
|
||||
},
|
||||
"disk": {
|
||||
"total_gb": round(disk.total / (1024**3), 2),
|
||||
"used_gb": round(disk.used / (1024**3), 2),
|
||||
"free_gb": round(disk.free / (1024**3), 2),
|
||||
"percent": disk.percent
|
||||
},
|
||||
"cpu": {
|
||||
"percent": cpu_percent,
|
||||
"cores": psutil.cpu_count()
|
||||
},
|
||||
"platform": {
|
||||
"system": platform.system(),
|
||||
"release": platform.release(),
|
||||
"python": platform.python_version()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def health_check(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Health check endpoint that verifies database connectivity.
|
||||
Public endpoint for monitoring services.
|
||||
|
||||
Returns:
|
||||
Dictionary with health status and database connectivity
|
||||
@@ -26,9 +71,56 @@ def health_check(db: Session = Depends(get_db)):
|
||||
except Exception as e:
|
||||
db_status = f"error: {str(e)}"
|
||||
|
||||
# Calculate uptime
|
||||
uptime_seconds = (datetime.utcnow() - APP_START_TIME).total_seconds()
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "connected" else "unhealthy",
|
||||
"app": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"database": db_status
|
||||
"database": db_status,
|
||||
"uptime_seconds": int(uptime_seconds)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/detailed")
|
||||
def health_check_detailed(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser)
|
||||
):
|
||||
"""
|
||||
Detailed health check with system information.
|
||||
Requires superuser authentication.
|
||||
|
||||
Returns:
|
||||
Dictionary with detailed health status and system info
|
||||
"""
|
||||
# Basic health check
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
db_status = "connected"
|
||||
except Exception as e:
|
||||
db_status = f"error: {str(e)}"
|
||||
|
||||
# Calculate uptime
|
||||
uptime_seconds = (datetime.utcnow() - APP_START_TIME).total_seconds()
|
||||
days, remainder = divmod(int(uptime_seconds), 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "connected" else "unhealthy",
|
||||
"app": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"database": db_status,
|
||||
"uptime": {
|
||||
"seconds": int(uptime_seconds),
|
||||
"formatted": f"{days}d {hours}h {minutes}m {seconds}s"
|
||||
},
|
||||
"started_at": APP_START_TIME.isoformat(),
|
||||
"system": get_system_info(),
|
||||
"environment": {
|
||||
"debug": settings.DEBUG,
|
||||
"log_level": settings.LOG_LEVEL
|
||||
}
|
||||
}
|
||||
|
||||
244
backend/app/api/v1/notifications.py
Normal file
244
backend/app/api/v1/notifications.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""Notification API endpoints."""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_user, get_current_superuser
|
||||
from app.models.user import User
|
||||
from app import crud
|
||||
from app.schemas.notification import (
|
||||
Notification,
|
||||
NotificationCreate,
|
||||
NotificationCreateForUser,
|
||||
NotificationList,
|
||||
NotificationStats,
|
||||
NotificationBulkAction
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def serialize_notification(db_obj) -> dict:
|
||||
"""Serialize notification for response."""
|
||||
metadata = None
|
||||
if db_obj.metadata:
|
||||
try:
|
||||
metadata = json.loads(db_obj.metadata)
|
||||
except json.JSONDecodeError:
|
||||
metadata = None
|
||||
|
||||
return {
|
||||
"id": db_obj.id,
|
||||
"user_id": db_obj.user_id,
|
||||
"title": db_obj.title,
|
||||
"message": db_obj.message,
|
||||
"type": db_obj.type,
|
||||
"link": db_obj.link,
|
||||
"metadata": metadata,
|
||||
"is_read": db_obj.is_read,
|
||||
"created_at": db_obj.created_at,
|
||||
"read_at": db_obj.read_at
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationList)
|
||||
def get_notifications(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
unread_only: bool = False
|
||||
) -> Any:
|
||||
"""
|
||||
Get notifications for the current user.
|
||||
"""
|
||||
notifications = crud.notification.get_multi_by_user(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
unread_only=unread_only
|
||||
)
|
||||
total = crud.notification.count_by_user(db, current_user.id)
|
||||
unread_count = crud.notification.count_unread_by_user(db, current_user.id)
|
||||
|
||||
return {
|
||||
"items": [serialize_notification(n) for n in notifications],
|
||||
"total": total,
|
||||
"unread_count": unread_count
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
def get_unread_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get unread notification count for the current user.
|
||||
"""
|
||||
count = crud.notification.count_unread_by_user(db, current_user.id)
|
||||
return {"unread_count": count}
|
||||
|
||||
|
||||
@router.get("/stats", response_model=NotificationStats)
|
||||
def get_notification_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get notification statistics for the current user.
|
||||
"""
|
||||
return crud.notification.get_stats_by_user(db, current_user.id)
|
||||
|
||||
|
||||
@router.get("/{notification_id}", response_model=Notification)
|
||||
def get_notification(
|
||||
notification_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get a specific notification.
|
||||
"""
|
||||
db_obj = crud.notification.get(db, id=notification_id)
|
||||
if not db_obj or db_obj.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
return serialize_notification(db_obj)
|
||||
|
||||
|
||||
@router.post("/{notification_id}/read", response_model=Notification)
|
||||
def mark_as_read(
|
||||
notification_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Mark a notification as read.
|
||||
"""
|
||||
db_obj = crud.notification.mark_as_read(
|
||||
db, id=notification_id, user_id=current_user.id
|
||||
)
|
||||
if not db_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
return serialize_notification(db_obj)
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
def mark_all_as_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Mark all notifications as read.
|
||||
"""
|
||||
count = crud.notification.mark_all_as_read(db, user_id=current_user.id)
|
||||
return {"marked_as_read": count}
|
||||
|
||||
|
||||
@router.post("/read-multiple")
|
||||
def mark_multiple_as_read(
|
||||
action: NotificationBulkAction,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Mark multiple notifications as read.
|
||||
"""
|
||||
count = crud.notification.mark_multiple_as_read(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
notification_ids=action.notification_ids
|
||||
)
|
||||
return {"marked_as_read": count}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_notification(
|
||||
notification_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""
|
||||
Delete a notification.
|
||||
"""
|
||||
if not crud.notification.delete(db, id=notification_id, user_id=current_user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/read/all")
|
||||
def delete_all_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Delete all read notifications.
|
||||
"""
|
||||
count = crud.notification.delete_all_read(db, user_id=current_user.id)
|
||||
return {"deleted": count}
|
||||
|
||||
|
||||
@router.post("/delete-multiple")
|
||||
def delete_multiple(
|
||||
action: NotificationBulkAction,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Delete multiple notifications.
|
||||
"""
|
||||
count = crud.notification.delete_multiple(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
notification_ids=action.notification_ids
|
||||
)
|
||||
return {"deleted": count}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
|
||||
@router.post("/admin/send", status_code=status.HTTP_201_CREATED)
|
||||
def send_notification_to_user(
|
||||
notification_in: NotificationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Send a notification to a specific user (admin only).
|
||||
"""
|
||||
db_obj = crud.notification.create(db, obj_in=notification_in)
|
||||
return serialize_notification(db_obj)
|
||||
|
||||
|
||||
@router.post("/admin/broadcast")
|
||||
def broadcast_notification(
|
||||
notification_in: NotificationCreateForUser,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Send a notification to all users (admin only).
|
||||
"""
|
||||
count = crud.notification.create_for_all_users(
|
||||
db,
|
||||
title=notification_in.title,
|
||||
message=notification_in.message,
|
||||
type=notification_in.type,
|
||||
link=notification_in.link,
|
||||
metadata=notification_in.metadata
|
||||
)
|
||||
return {"sent_to": count}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import health, auth, users, settings
|
||||
from app.api.v1 import health, auth, users, settings, audit, api_keys, notifications, two_factor, sessions, analytics, export, webhooks, files
|
||||
|
||||
|
||||
# Create main API v1 router
|
||||
@@ -11,5 +11,14 @@ router = APIRouter()
|
||||
# Include all sub-routers
|
||||
router.include_router(health.router, prefix="/health", tags=["Health"])
|
||||
router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
||||
router.include_router(two_factor.router, prefix="/2fa", tags=["Two-Factor Auth"])
|
||||
router.include_router(sessions.router, prefix="/sessions", tags=["Sessions"])
|
||||
router.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
router.include_router(settings.router, prefix="/settings", tags=["Settings"])
|
||||
router.include_router(audit.router, prefix="/audit", tags=["Audit"])
|
||||
router.include_router(api_keys.router, prefix="/api-keys", tags=["API Keys"])
|
||||
router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"])
|
||||
router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
|
||||
router.include_router(export.router, prefix="/export", tags=["Export/Import"])
|
||||
router.include_router(webhooks.router, prefix="/webhooks", tags=["Webhooks"])
|
||||
router.include_router(files.router, prefix="/files", tags=["Files"])
|
||||
|
||||
259
backend/app/api/v1/sessions.py
Normal file
259
backend/app/api/v1/sessions.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""User Session management endpoints."""
|
||||
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_user, security
|
||||
from app.models.user import User
|
||||
from app import crud
|
||||
from app.schemas.session import (
|
||||
Session as SessionSchema,
|
||||
SessionList,
|
||||
SessionRevokeRequest
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def serialize_session(db_obj, current_token_hash: str = None) -> dict:
|
||||
"""Serialize session for response."""
|
||||
from app.crud.session import hash_token
|
||||
|
||||
is_current = False
|
||||
if current_token_hash:
|
||||
is_current = db_obj.token_hash == current_token_hash
|
||||
|
||||
return {
|
||||
"id": db_obj.id,
|
||||
"user_id": db_obj.user_id,
|
||||
"device_name": db_obj.device_name,
|
||||
"device_type": db_obj.device_type,
|
||||
"browser": db_obj.browser,
|
||||
"os": db_obj.os,
|
||||
"ip_address": db_obj.ip_address,
|
||||
"location": db_obj.location,
|
||||
"is_active": db_obj.is_active,
|
||||
"is_current": is_current or db_obj.is_current,
|
||||
"created_at": db_obj.created_at,
|
||||
"last_active_at": db_obj.last_active_at,
|
||||
"expires_at": db_obj.expires_at
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=SessionList)
|
||||
def get_sessions(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get all active sessions for the current user.
|
||||
"""
|
||||
from app.crud.session import hash_token
|
||||
|
||||
current_token = credentials.credentials
|
||||
current_token_hash = hash_token(current_token)
|
||||
|
||||
sessions = crud.session.get_multi_by_user(db, user_id=current_user.id, active_only=True)
|
||||
total = len(sessions)
|
||||
active_count = sum(1 for s in sessions if s.is_active)
|
||||
|
||||
return {
|
||||
"items": [serialize_session(s, current_token_hash) for s in sessions],
|
||||
"total": total,
|
||||
"active_count": active_count
|
||||
}
|
||||
|
||||
|
||||
@router.get("/all", response_model=SessionList)
|
||||
def get_all_sessions(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get all sessions (including inactive) for the current user.
|
||||
"""
|
||||
from app.crud.session import hash_token
|
||||
|
||||
current_token = credentials.credentials
|
||||
current_token_hash = hash_token(current_token)
|
||||
|
||||
sessions = crud.session.get_multi_by_user(db, user_id=current_user.id, active_only=False)
|
||||
total = len(sessions)
|
||||
active_count = sum(1 for s in sessions if s.is_active)
|
||||
|
||||
return {
|
||||
"items": [serialize_session(s, current_token_hash) for s in sessions],
|
||||
"total": total,
|
||||
"active_count": active_count
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current", response_model=SessionSchema)
|
||||
def get_current_session(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get the current session.
|
||||
"""
|
||||
from app.crud.session import hash_token
|
||||
|
||||
current_token = credentials.credentials
|
||||
session = crud.session.get_by_token(db, current_token)
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Current session not found"
|
||||
)
|
||||
|
||||
return serialize_session(session, hash_token(current_token))
|
||||
|
||||
|
||||
@router.post("/{session_id}/revoke", response_model=SessionSchema)
|
||||
def revoke_session(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Revoke a specific session.
|
||||
"""
|
||||
from app.crud.session import hash_token
|
||||
|
||||
current_token = credentials.credentials
|
||||
current_token_hash = hash_token(current_token)
|
||||
|
||||
# Get the session to check if it's the current one
|
||||
target_session = crud.session.get(db, id=session_id)
|
||||
if not target_session or target_session.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
# Don't allow revoking the current session through this endpoint
|
||||
if target_session.token_hash == current_token_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot revoke current session. Use logout instead."
|
||||
)
|
||||
|
||||
session = crud.session.revoke(db, id=session_id, user_id=current_user.id)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="session_revoke",
|
||||
resource_type="session",
|
||||
resource_id=session_id,
|
||||
details={"device": session.device_name},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return serialize_session(session, current_token_hash)
|
||||
|
||||
|
||||
@router.post("/revoke-all")
|
||||
def revoke_all_sessions(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Revoke all sessions except the current one.
|
||||
"""
|
||||
from app.crud.session import hash_token
|
||||
|
||||
current_token = credentials.credentials
|
||||
current_session = crud.session.get_by_token(db, current_token)
|
||||
|
||||
except_id = current_session.id if current_session else None
|
||||
count = crud.session.revoke_all_except(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
except_session_id=except_id
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="sessions_revoke_all",
|
||||
resource_type="session",
|
||||
details={"revoked_count": count},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {"revoked": count}
|
||||
|
||||
|
||||
@router.post("/revoke-multiple")
|
||||
def revoke_multiple_sessions(
|
||||
request: Request,
|
||||
revoke_request: SessionRevokeRequest,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Revoke multiple sessions.
|
||||
"""
|
||||
from app.crud.session import hash_token
|
||||
|
||||
current_token = credentials.credentials
|
||||
current_session = crud.session.get_by_token(db, current_token)
|
||||
|
||||
# Filter out current session if included
|
||||
session_ids = [
|
||||
sid for sid in revoke_request.session_ids
|
||||
if not current_session or sid != current_session.id
|
||||
]
|
||||
|
||||
count = crud.session.revoke_multiple(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
session_ids=session_ids
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="sessions_revoke_multiple",
|
||||
resource_type="session",
|
||||
details={"revoked_count": count, "requested_ids": len(revoke_request.session_ids)},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {"revoked": count}
|
||||
361
backend/app/api/v1/two_factor.py
Normal file
361
backend/app/api/v1/two_factor.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""Two-factor authentication (2FA) endpoints."""
|
||||
|
||||
import io
|
||||
import secrets
|
||||
import base64
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
import pyotp
|
||||
import qrcode
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app import crud
|
||||
from app.config import settings
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TOTPSetupResponse(BaseModel):
|
||||
"""Response for TOTP setup initiation."""
|
||||
secret: str
|
||||
uri: str
|
||||
qr_code: str # Base64 encoded QR code image
|
||||
|
||||
|
||||
class TOTPVerifyRequest(BaseModel):
|
||||
"""Request to verify TOTP code."""
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class TOTPDisableRequest(BaseModel):
|
||||
"""Request to disable TOTP."""
|
||||
password: str
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class BackupCodesResponse(BaseModel):
|
||||
"""Response with backup codes."""
|
||||
backup_codes: list[str]
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def generate_backup_codes(count: int = 10) -> list[str]:
|
||||
"""Generate backup codes for 2FA recovery."""
|
||||
return [secrets.token_hex(4).upper() for _ in range(count)]
|
||||
|
||||
|
||||
def generate_qr_code(uri: str) -> str:
|
||||
"""Generate QR code as base64 encoded PNG."""
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
return base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_2fa_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get 2FA status for the current user.
|
||||
"""
|
||||
return {
|
||||
"enabled": current_user.totp_enabled,
|
||||
"has_backup_codes": bool(current_user.backup_codes)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/setup", response_model=TOTPSetupResponse)
|
||||
def setup_2fa(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Initiate 2FA setup by generating a new TOTP secret.
|
||||
Returns the secret, URI, and QR code for authenticator app setup.
|
||||
The user must verify the code before 2FA is enabled.
|
||||
"""
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA is already enabled"
|
||||
)
|
||||
|
||||
# Generate new secret
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
# Store secret temporarily (not enabled yet)
|
||||
current_user.totp_secret = secret
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
|
||||
# Generate URI for authenticator app
|
||||
totp = pyotp.TOTP(secret)
|
||||
uri = totp.provisioning_uri(
|
||||
name=current_user.email,
|
||||
issuer_name=settings.APP_NAME
|
||||
)
|
||||
|
||||
# Generate QR code
|
||||
qr_code = generate_qr_code(uri)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="2fa_setup_initiated",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"secret": secret,
|
||||
"uri": uri,
|
||||
"qr_code": qr_code
|
||||
}
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
def verify_and_enable_2fa(
|
||||
request: Request,
|
||||
verify_request: TOTPVerifyRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Verify TOTP code and enable 2FA.
|
||||
This must be called after setup to complete the 2FA activation.
|
||||
"""
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA is already enabled"
|
||||
)
|
||||
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA setup not initiated. Call /setup first."
|
||||
)
|
||||
|
||||
# Verify the code
|
||||
totp = pyotp.TOTP(current_user.totp_secret)
|
||||
if not totp.verify(verify_request.code, valid_window=1):
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="2fa_verify",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
details={"reason": "invalid_code"},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid verification code"
|
||||
)
|
||||
|
||||
# Generate backup codes
|
||||
backup_codes = generate_backup_codes()
|
||||
|
||||
# Enable 2FA
|
||||
current_user.totp_enabled = True
|
||||
current_user.backup_codes = backup_codes
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="2fa_enabled",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "2FA enabled successfully",
|
||||
"backup_codes": backup_codes
|
||||
}
|
||||
|
||||
|
||||
@router.post("/disable")
|
||||
def disable_2fa(
|
||||
request: Request,
|
||||
disable_request: TOTPDisableRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Disable 2FA. Requires password and current TOTP code.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA is not enabled"
|
||||
)
|
||||
|
||||
# Verify password
|
||||
from app.core.security import verify_password
|
||||
if not verify_password(disable_request.password, current_user.hashed_password):
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="2fa_disable",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
details={"reason": "invalid_password"},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid password"
|
||||
)
|
||||
|
||||
# Verify TOTP code
|
||||
totp = pyotp.TOTP(current_user.totp_secret)
|
||||
if not totp.verify(disable_request.code, valid_window=1):
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="2fa_disable",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
details={"reason": "invalid_code"},
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="failure"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid verification code"
|
||||
)
|
||||
|
||||
# Disable 2FA
|
||||
current_user.totp_enabled = False
|
||||
current_user.totp_secret = None
|
||||
current_user.totp_backup_codes = None
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="2fa_disabled",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {"message": "2FA disabled successfully"}
|
||||
|
||||
|
||||
@router.post("/regenerate-backup-codes", response_model=BackupCodesResponse)
|
||||
def regenerate_backup_codes(
|
||||
request: Request,
|
||||
verify_request: TOTPVerifyRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Regenerate backup codes. Requires current TOTP code.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA is not enabled"
|
||||
)
|
||||
|
||||
# Verify TOTP code
|
||||
totp = pyotp.TOTP(current_user.totp_secret)
|
||||
if not totp.verify(verify_request.code, valid_window=1):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid verification code"
|
||||
)
|
||||
|
||||
# Generate new backup codes
|
||||
backup_codes = generate_backup_codes()
|
||||
current_user.backup_codes = backup_codes
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="2fa_backup_codes_regenerated",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.headers.get("User-Agent", "")[:500],
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {"backup_codes": backup_codes}
|
||||
|
||||
|
||||
def verify_totp_or_backup(user: User, code: str, db: Session) -> bool:
|
||||
"""
|
||||
Verify TOTP code or backup code.
|
||||
Returns True if valid, False otherwise.
|
||||
If backup code is used, it's removed from the list.
|
||||
"""
|
||||
if not user.totp_enabled or not user.totp_secret:
|
||||
return True # 2FA not enabled
|
||||
|
||||
# Try TOTP verification first
|
||||
totp = pyotp.TOTP(user.totp_secret)
|
||||
if totp.verify(code, valid_window=1):
|
||||
return True
|
||||
|
||||
# Try backup code
|
||||
backup_codes = user.backup_codes
|
||||
code_upper = code.upper().replace("-", "")
|
||||
if code_upper in backup_codes:
|
||||
backup_codes.remove(code_upper)
|
||||
user.backup_codes = backup_codes
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
380
backend/app/api/v1/webhooks.py
Normal file
380
backend/app/api/v1/webhooks.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""Webhook management endpoints."""
|
||||
|
||||
import json
|
||||
from typing import Any, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_superuser
|
||||
from app.models.user import User
|
||||
from app import crud
|
||||
from app.schemas.webhook import (
|
||||
WebhookCreate,
|
||||
WebhookUpdate,
|
||||
Webhook as WebhookSchema,
|
||||
WebhookWithSecret,
|
||||
WebhookDelivery as WebhookDeliverySchema,
|
||||
WebhookTest,
|
||||
WEBHOOK_EVENTS,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[WebhookSchema])
|
||||
def list_webhooks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: bool = None
|
||||
):
|
||||
"""
|
||||
List all webhooks.
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhooks = crud.webhook.get_multi(db, skip=skip, limit=limit, is_active=is_active)
|
||||
# Convert events from JSON string to list
|
||||
result = []
|
||||
for webhook in webhooks:
|
||||
webhook_dict = {
|
||||
"id": webhook.id,
|
||||
"name": webhook.name,
|
||||
"url": webhook.url,
|
||||
"secret": webhook.secret,
|
||||
"events": json.loads(webhook.events) if webhook.events else [],
|
||||
"is_active": webhook.is_active,
|
||||
"retry_count": webhook.retry_count,
|
||||
"timeout_seconds": webhook.timeout_seconds,
|
||||
"created_by": webhook.created_by,
|
||||
"created_at": webhook.created_at,
|
||||
"updated_at": webhook.updated_at,
|
||||
"last_triggered_at": webhook.last_triggered_at,
|
||||
"success_count": webhook.success_count,
|
||||
"failure_count": webhook.failure_count,
|
||||
}
|
||||
result.append(webhook_dict)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=WebhookWithSecret, status_code=status.HTTP_201_CREATED)
|
||||
def create_webhook(
|
||||
webhook_in: WebhookCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Create a new webhook.
|
||||
Returns the webhook with its secret (only shown once at creation).
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhook = crud.webhook.create(db, obj_in=webhook_in, created_by=current_user.id)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="create",
|
||||
resource_type="webhook",
|
||||
resource_id=webhook.id,
|
||||
details={"name": webhook.name, "url": webhook.url},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": webhook.id,
|
||||
"name": webhook.name,
|
||||
"url": webhook.url,
|
||||
"secret": webhook.secret,
|
||||
"events": json.loads(webhook.events) if webhook.events else [],
|
||||
"is_active": webhook.is_active,
|
||||
"retry_count": webhook.retry_count,
|
||||
"timeout_seconds": webhook.timeout_seconds,
|
||||
"created_by": webhook.created_by,
|
||||
"created_at": webhook.created_at,
|
||||
"updated_at": webhook.updated_at,
|
||||
"last_triggered_at": webhook.last_triggered_at,
|
||||
"success_count": webhook.success_count,
|
||||
"failure_count": webhook.failure_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/events", response_model=List[str])
|
||||
def list_webhook_events(
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
List all available webhook event types.
|
||||
"""
|
||||
return WEBHOOK_EVENTS
|
||||
|
||||
|
||||
@router.get("/{webhook_id}", response_model=WebhookSchema)
|
||||
def get_webhook(
|
||||
webhook_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Get a specific webhook.
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhook = crud.webhook.get(db, id=webhook_id)
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": webhook.id,
|
||||
"name": webhook.name,
|
||||
"url": webhook.url,
|
||||
"secret": webhook.secret,
|
||||
"events": json.loads(webhook.events) if webhook.events else [],
|
||||
"is_active": webhook.is_active,
|
||||
"retry_count": webhook.retry_count,
|
||||
"timeout_seconds": webhook.timeout_seconds,
|
||||
"created_by": webhook.created_by,
|
||||
"created_at": webhook.created_at,
|
||||
"updated_at": webhook.updated_at,
|
||||
"last_triggered_at": webhook.last_triggered_at,
|
||||
"success_count": webhook.success_count,
|
||||
"failure_count": webhook.failure_count,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{webhook_id}", response_model=WebhookSchema)
|
||||
def update_webhook(
|
||||
webhook_id: str,
|
||||
webhook_in: WebhookUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Update a webhook.
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhook = crud.webhook.get(db, id=webhook_id)
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found"
|
||||
)
|
||||
|
||||
webhook = crud.webhook.update(db, db_obj=webhook, obj_in=webhook_in)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="update",
|
||||
resource_type="webhook",
|
||||
resource_id=webhook.id,
|
||||
details={"name": webhook.name},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": webhook.id,
|
||||
"name": webhook.name,
|
||||
"url": webhook.url,
|
||||
"secret": webhook.secret,
|
||||
"events": json.loads(webhook.events) if webhook.events else [],
|
||||
"is_active": webhook.is_active,
|
||||
"retry_count": webhook.retry_count,
|
||||
"timeout_seconds": webhook.timeout_seconds,
|
||||
"created_by": webhook.created_by,
|
||||
"created_at": webhook.created_at,
|
||||
"updated_at": webhook.updated_at,
|
||||
"last_triggered_at": webhook.last_triggered_at,
|
||||
"success_count": webhook.success_count,
|
||||
"failure_count": webhook.failure_count,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_webhook(
|
||||
webhook_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Delete a webhook.
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhook = crud.webhook.get(db, id=webhook_id)
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found"
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="delete",
|
||||
resource_type="webhook",
|
||||
resource_id=webhook_id,
|
||||
details={"name": webhook.name},
|
||||
status="success"
|
||||
)
|
||||
|
||||
crud.webhook.delete(db, id=webhook_id)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/regenerate-secret", response_model=WebhookWithSecret)
|
||||
def regenerate_webhook_secret(
|
||||
webhook_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Regenerate the secret for a webhook.
|
||||
Returns the new secret (only shown once).
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhook = crud.webhook.get(db, id=webhook_id)
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found"
|
||||
)
|
||||
|
||||
webhook = crud.webhook.regenerate_secret(db, db_obj=webhook)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="regenerate_secret",
|
||||
resource_type="webhook",
|
||||
resource_id=webhook.id,
|
||||
details={"name": webhook.name},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": webhook.id,
|
||||
"name": webhook.name,
|
||||
"url": webhook.url,
|
||||
"secret": webhook.secret,
|
||||
"events": json.loads(webhook.events) if webhook.events else [],
|
||||
"is_active": webhook.is_active,
|
||||
"retry_count": webhook.retry_count,
|
||||
"timeout_seconds": webhook.timeout_seconds,
|
||||
"created_by": webhook.created_by,
|
||||
"created_at": webhook.created_at,
|
||||
"updated_at": webhook.updated_at,
|
||||
"last_triggered_at": webhook.last_triggered_at,
|
||||
"success_count": webhook.success_count,
|
||||
"failure_count": webhook.failure_count,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/test", response_model=WebhookDeliverySchema)
|
||||
async def test_webhook(
|
||||
webhook_id: str,
|
||||
test_data: WebhookTest = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Send a test delivery to a webhook.
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhook = crud.webhook.get(db, id=webhook_id)
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found"
|
||||
)
|
||||
|
||||
event_type = test_data.event_type if test_data else "test.ping"
|
||||
payload = test_data.payload if test_data and test_data.payload else None
|
||||
|
||||
delivery = await crud.webhook_service.test_webhook(
|
||||
db,
|
||||
webhook=webhook,
|
||||
event_type=event_type,
|
||||
payload=payload
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="test",
|
||||
resource_type="webhook",
|
||||
resource_id=webhook.id,
|
||||
details={"status": delivery.status},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return delivery
|
||||
|
||||
|
||||
@router.get("/{webhook_id}/deliveries", response_model=List[WebhookDeliverySchema])
|
||||
def list_webhook_deliveries(
|
||||
webhook_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
skip: int = 0,
|
||||
limit: int = 50
|
||||
):
|
||||
"""
|
||||
List deliveries for a specific webhook.
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhook = crud.webhook.get(db, id=webhook_id)
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found"
|
||||
)
|
||||
|
||||
return crud.webhook_delivery.get_by_webhook(
|
||||
db,
|
||||
webhook_id=webhook_id,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/deliveries/{delivery_id}/retry", response_model=WebhookDeliverySchema)
|
||||
async def retry_webhook_delivery(
|
||||
webhook_id: str,
|
||||
delivery_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""
|
||||
Retry a failed webhook delivery.
|
||||
Requires superuser permissions.
|
||||
"""
|
||||
webhook = crud.webhook.get(db, id=webhook_id)
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Webhook not found"
|
||||
)
|
||||
|
||||
delivery = crud.webhook_delivery.get(db, id=delivery_id)
|
||||
if not delivery or delivery.webhook_id != webhook_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Delivery not found"
|
||||
)
|
||||
|
||||
await crud.webhook_service.deliver(db, webhook, delivery)
|
||||
|
||||
db.refresh(delivery)
|
||||
return delivery
|
||||
Reference in New Issue
Block a user