Add superadmin routes to API
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""Superadmin API blueprint."""
|
||||
import logging
|
||||
from flask import Blueprint
|
||||
|
||||
from gatehouse_app.extensions import limiter
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create superadmin blueprint
|
||||
superadmin_bp = Blueprint("superadmin", __name__, url_prefix="/superadmin")
|
||||
|
||||
# Import route modules to register them
|
||||
from gatehouse_app.api.v1.superadmin import auth, organizations, organization_members, usage_analytics, users, billing, cas # noqa: F401
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Superadmin authentication endpoints."""
|
||||
import logging
|
||||
from flask import request, g, current_app
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from gatehouse_app.api.v1.superadmin import superadmin_bp
|
||||
from gatehouse_app.extensions import limiter
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.superadmin_auth_service import SuperadminAuthService
|
||||
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginSchema:
|
||||
"""Schema for superadmin login."""
|
||||
|
||||
@staticmethod
|
||||
def load(data):
|
||||
"""Validate login data."""
|
||||
errors = {}
|
||||
|
||||
if not data.get('email'):
|
||||
errors['email'] = ['Email is required']
|
||||
elif '@' not in data['email']:
|
||||
errors['email'] = ['Invalid email format']
|
||||
|
||||
if not data.get('password'):
|
||||
errors['password'] = ['Password is required']
|
||||
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
return {
|
||||
'email': data['email'].lower().strip(),
|
||||
'password': data['password'],
|
||||
}
|
||||
|
||||
|
||||
@superadmin_bp.route("/auth/login", methods=["POST"])
|
||||
@limiter.limit(lambda: current_app.config.get("RATELIMIT_AUTH_LOGIN", "100 per minute"))
|
||||
def login():
|
||||
"""Superadmin login endpoint.
|
||||
|
||||
Authenticates with email/password and returns a session token.
|
||||
"""
|
||||
try:
|
||||
schema = LoginSchema()
|
||||
data = schema.load(request.json)
|
||||
|
||||
# Authenticate
|
||||
superadmin = SuperadminAuthService.authenticate(
|
||||
email=data['email'],
|
||||
credentials=data['password']
|
||||
)
|
||||
|
||||
# Create session (default 8 hours)
|
||||
session = SuperadminAuthService.create_session(
|
||||
superadmin_id=superadmin.id,
|
||||
duration_seconds=28800 # 8 hours
|
||||
)
|
||||
|
||||
expires_str = session.expires_at.isoformat()
|
||||
if not expires_str.endswith('Z'):
|
||||
expires_str += 'Z'
|
||||
|
||||
logger.info(f"[SuperadminAuth] Login successful for: {superadmin.email}")
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"superadmin": superadmin.to_dict(),
|
||||
"token": session.token,
|
||||
"expires_at": expires_str,
|
||||
},
|
||||
message="Login successful",
|
||||
status=200
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Invalid email or password",
|
||||
status=401,
|
||||
error_type="INVALID_CREDENTIALS"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAuth] Login error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred during login",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/auth/logout", methods=["POST"])
|
||||
@superadmin_required
|
||||
def logout():
|
||||
"""Superadmin logout endpoint.
|
||||
|
||||
Invalidates the current session.
|
||||
"""
|
||||
try:
|
||||
session = g.superadmin_session
|
||||
if session:
|
||||
SuperadminAuthService.revoke_session(session.id, reason="Superadmin logout")
|
||||
|
||||
return api_response(
|
||||
message="Logout successful"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAuth] Logout error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred during logout",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/auth/me", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_current_superadmin():
|
||||
"""Get current superadmin profile.
|
||||
|
||||
Returns the profile of the currently authenticated superadmin.
|
||||
"""
|
||||
try:
|
||||
superadmin = g.current_superadmin
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"superadmin": superadmin.to_dict(),
|
||||
},
|
||||
message="Superadmin retrieved successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAuth] Get me error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/auth/impersonate/<user_id>", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="impersonate", resource_type="user")
|
||||
def impersonate_user(user_id):
|
||||
"""Create emergency access session by impersonating a user.
|
||||
|
||||
Creates a temporary session for the target user that allows
|
||||
the superadmin to access the platform as that user.
|
||||
|
||||
This action is fully audited.
|
||||
"""
|
||||
try:
|
||||
superadmin = g.current_superadmin
|
||||
data = request.json or {}
|
||||
reason = data.get('reason', 'Not specified')
|
||||
duration_minutes = data.get('duration_minutes', 15)
|
||||
|
||||
# Limit duration to max 60 minutes
|
||||
duration_minutes = min(duration_minutes, 60)
|
||||
|
||||
# Create emergency access
|
||||
result = SuperadminAuthService.create_emergency_access(
|
||||
superadmin_id=superadmin.id,
|
||||
target_user_id=user_id,
|
||||
reason=reason,
|
||||
duration_minutes=duration_minutes
|
||||
)
|
||||
|
||||
expires_str = result['expires_at'].isoformat()
|
||||
if not expires_str.endswith('Z'):
|
||||
expires_str += 'Z'
|
||||
|
||||
logger.warning(
|
||||
f"[SuperadminAuth] IMPERSONATION: superadmin={superadmin.email} "
|
||||
f"impersonated user_id={user_id} reason={reason}"
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"session_token": result['session'].token,
|
||||
"expires_at": expires_str,
|
||||
"target_user_id": user_id,
|
||||
"reason": reason,
|
||||
"duration_minutes": duration_minutes,
|
||||
},
|
||||
message="Emergency access session created",
|
||||
status=201
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAuth] Impersonate error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/auth/emergency/<user_id>", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="emergency_access", resource_type="user")
|
||||
def grant_emergency_access(user_id):
|
||||
"""Grant temporary elevated access to a user.
|
||||
|
||||
Similar to impersonate but grants elevated permissions
|
||||
rather than creating a session as the user.
|
||||
|
||||
This action is fully audited.
|
||||
"""
|
||||
try:
|
||||
superadmin = g.current_superadmin
|
||||
data = request.json or {}
|
||||
reason = data.get('reason', 'Not specified')
|
||||
duration_minutes = data.get('duration_minutes', 15)
|
||||
|
||||
# Limit duration to max 60 minutes
|
||||
duration_minutes = min(duration_minutes, 60)
|
||||
|
||||
# Create emergency access
|
||||
result = SuperadminAuthService.create_emergency_access(
|
||||
superadmin_id=superadmin.id,
|
||||
target_user_id=user_id,
|
||||
reason=reason,
|
||||
duration_minutes=duration_minutes
|
||||
)
|
||||
|
||||
expires_str = result['expires_at'].isoformat()
|
||||
if not expires_str.endswith('Z'):
|
||||
expires_str += 'Z'
|
||||
|
||||
logger.warning(
|
||||
f"[SuperadminAuth] EMERGENCY ACCESS: superadmin={superadmin.email} "
|
||||
f"granted access to user_id={user_id} reason={reason}"
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"session_token": result['session'].token,
|
||||
"expires_at": expires_str,
|
||||
"target_user_id": user_id,
|
||||
"reason": reason,
|
||||
"duration_minutes": duration_minutes,
|
||||
},
|
||||
message="Emergency access granted",
|
||||
status=201
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAuth] Emergency access error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR"
|
||||
)
|
||||
@@ -0,0 +1,568 @@
|
||||
"""Superadmin billing endpoints for plans and subscriptions."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from flask import request
|
||||
from gatehouse_app.api.v1.superadmin import superadmin_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============ Plans Endpoints ============
|
||||
|
||||
@superadmin_bp.route("/billing/plans", methods=["GET"])
|
||||
@superadmin_required
|
||||
def list_plans():
|
||||
"""Get all available plans."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
|
||||
plans = Plan.query.filter(Plan.is_active == True).order_by(Plan.price_monthly.asc()).all()
|
||||
|
||||
items = [{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"slug": p.slug,
|
||||
"description": p.description,
|
||||
"price_monthly": p.price_monthly,
|
||||
"price_yearly": p.price_yearly,
|
||||
"included_users": p.included_users,
|
||||
"overage_rate_per_user": p.overage_rate_per_user,
|
||||
"features": p.features,
|
||||
"stripe_price_id_monthly": p.stripe_price_id_monthly,
|
||||
"stripe_price_id_yearly": p.stripe_price_id_yearly,
|
||||
"is_active": p.is_active,
|
||||
"created_at": p.created_at.isoformat() + "Z" if p.created_at else None,
|
||||
} for p in plans]
|
||||
|
||||
return api_response(data={"items": items}, message="Plans retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Billing] List plans error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/billing/plans", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="plan.create", resource_type="plan")
|
||||
def create_plan():
|
||||
"""Create a new plan."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
from marshmallow import Schema, fields, validate
|
||||
|
||||
class CreatePlanSchema(Schema):
|
||||
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
|
||||
slug = fields.Str(required=True, validate=validate.Length(min=1, max=50))
|
||||
description = fields.Str(allow_none=True)
|
||||
price_monthly = fields.Int(required=True, validate=validate.Range(min=0))
|
||||
price_yearly = fields.Int(required=True, validate=validate.Range(min=0))
|
||||
included_users = fields.Int(required=True, validate=validate.Range(min=0))
|
||||
overage_rate_per_user = fields.Int(required=True, validate=validate.Range(min=0))
|
||||
features = fields.Dict(allow_none=True)
|
||||
stripe_price_id_monthly = fields.Str(allow_none=True)
|
||||
stripe_price_id_yearly = fields.Str(allow_none=True)
|
||||
|
||||
data = request.json or {}
|
||||
schema = CreatePlanSchema()
|
||||
errors = schema.validate(data)
|
||||
|
||||
if errors:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation error",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=errors,
|
||||
)
|
||||
|
||||
# Check if slug already exists
|
||||
existing = Plan.query.filter_by(slug=data["slug"]).first()
|
||||
if existing:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Plan with this slug already exists",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
plan = Plan(
|
||||
name=data["name"],
|
||||
slug=data["slug"],
|
||||
description=data.get("description"),
|
||||
price_monthly=data["price_monthly"],
|
||||
price_yearly=data["price_yearly"],
|
||||
included_users=data["included_users"],
|
||||
overage_rate_per_user=data["overage_rate_per_user"],
|
||||
features=data.get("features"),
|
||||
stripe_price_id_monthly=data.get("stripe_price_id_monthly"),
|
||||
stripe_price_id_yearly=data.get("stripe_price_id_yearly"),
|
||||
)
|
||||
db.session.add(plan)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={
|
||||
"id": plan.id,
|
||||
"name": plan.name,
|
||||
"slug": plan.slug,
|
||||
}, message="Plan created successfully", status=201)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[Billing] Create plan error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/billing/plans/<plan_id>", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_plan(plan_id):
|
||||
"""Get a single plan by ID."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
|
||||
plan = Plan.query.get(plan_id)
|
||||
if not plan:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Plan not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
return api_response(data={
|
||||
"id": plan.id,
|
||||
"name": plan.name,
|
||||
"slug": plan.slug,
|
||||
"description": plan.description,
|
||||
"price_monthly": plan.price_monthly,
|
||||
"price_yearly": plan.price_yearly,
|
||||
"included_users": plan.included_users,
|
||||
"overage_rate_per_user": plan.overage_rate_per_user,
|
||||
"features": plan.features,
|
||||
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
|
||||
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
|
||||
"is_active": plan.is_active,
|
||||
"created_at": plan.created_at.isoformat() + "Z" if plan.created_at else None,
|
||||
"updated_at": plan.updated_at.isoformat() + "Z" if plan.updated_at else None,
|
||||
}, message="Plan retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Billing] Get plan error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/billing/plans/<plan_id>", methods=["PATCH"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="plan.update", resource_type="plan")
|
||||
def update_plan(plan_id):
|
||||
"""Update a plan."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
from marshmallow import Schema, fields, validate
|
||||
|
||||
class UpdatePlanSchema(Schema):
|
||||
name = fields.Str(validate=validate.Length(min=1, max=100))
|
||||
description = fields.Str(allow_none=True)
|
||||
price_monthly = fields.Int(validate=validate.Range(min=0))
|
||||
price_yearly = fields.Int(validate=validate.Range(min=0))
|
||||
included_users = fields.Int(validate=validate.Range(min=0))
|
||||
overage_rate_per_user = fields.Int(validate=validate.Range(min=0))
|
||||
features = fields.Dict(allow_none=True)
|
||||
stripe_price_id_monthly = fields.Str(allow_none=True)
|
||||
stripe_price_id_yearly = fields.Str(allow_none=True)
|
||||
is_active = fields.Bool()
|
||||
|
||||
plan = Plan.query.get(plan_id)
|
||||
if not plan:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Plan not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
data = request.json or {}
|
||||
schema = UpdatePlanSchema()
|
||||
errors = schema.validate(data)
|
||||
|
||||
if errors:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation error",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=errors,
|
||||
)
|
||||
|
||||
# Update fields
|
||||
for key, value in data.items():
|
||||
if hasattr(plan, key):
|
||||
setattr(plan, key, value)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={
|
||||
"id": plan.id,
|
||||
"name": plan.name,
|
||||
"slug": plan.slug,
|
||||
}, message="Plan updated successfully")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[Billing] Update plan error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/billing/plans/<plan_id>", methods=["DELETE"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="plan.delete", resource_type="plan")
|
||||
def delete_plan(plan_id):
|
||||
"""Soft-delete a plan by setting is_active=False."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
|
||||
plan = Plan.query.get(plan_id)
|
||||
if not plan:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Plan not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
plan.is_active = False
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={"id": plan.id}, message="Plan deleted successfully")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[Billing] Delete plan error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
# ============ Subscriptions Endpoints ============
|
||||
|
||||
@superadmin_bp.route("/billing/subscriptions", methods=["GET"])
|
||||
@superadmin_required
|
||||
def list_subscriptions():
|
||||
"""Get all subscriptions with optional filters."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.subscription import Subscription
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
|
||||
plan_id = request.args.get("plan_id")
|
||||
status = request.args.get("status")
|
||||
|
||||
query = Subscription.query
|
||||
|
||||
if plan_id:
|
||||
query = query.filter(Subscription.plan_id == plan_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Subscription.status == status)
|
||||
|
||||
total = query.count()
|
||||
subs = query.order_by(Subscription.created_at.desc()).offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
items = []
|
||||
for sub in subs:
|
||||
org = Organization.query.get(sub.organization_id)
|
||||
plan = Plan.query.get(sub.plan_id) if sub.plan_id else None
|
||||
|
||||
# Calculate MRR
|
||||
if plan and sub.status == "active":
|
||||
mrr = plan.price_monthly if sub.billing_cycle == "monthly" else plan.price_yearly // 12
|
||||
else:
|
||||
mrr = 0
|
||||
|
||||
items.append({
|
||||
"id": sub.id,
|
||||
"organization_id": sub.organization_id,
|
||||
"org_name": org.name if org else "Unknown",
|
||||
"plan_id": sub.plan_id,
|
||||
"plan_name": plan.name if plan else "Unknown",
|
||||
"status": sub.status,
|
||||
"billing_cycle": sub.billing_cycle,
|
||||
"mrr": mrr,
|
||||
"current_period_start": sub.current_period_start.isoformat() + "Z" if sub.current_period_start else None,
|
||||
"current_period_end": sub.current_period_end.isoformat() + "Z" if sub.current_period_end else None,
|
||||
"trial_ends_at": sub.trial_ends_at.isoformat() + "Z" if sub.trial_ends_at else None,
|
||||
"cancel_at_period_end": sub.cancel_at_period_end,
|
||||
"created_at": sub.created_at.isoformat() + "Z" if sub.created_at else None,
|
||||
})
|
||||
|
||||
return api_response(data={
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if per_page > 0 else 0,
|
||||
}, message="Subscriptions retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Billing] List subscriptions error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/billing/subscriptions/<sub_id>", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_subscription(sub_id):
|
||||
"""Get a single subscription."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.subscription import Subscription
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
|
||||
sub = Subscription.query.get(sub_id)
|
||||
if not sub:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Subscription not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
org = Organization.query.get(sub.organization_id)
|
||||
plan = Plan.query.get(sub.plan_id) if sub.plan_id else None
|
||||
|
||||
return api_response(data={
|
||||
"id": sub.id,
|
||||
"organization_id": sub.organization_id,
|
||||
"org_name": org.name if org else "Unknown",
|
||||
"plan_id": sub.plan_id,
|
||||
"plan_name": plan.name if plan else None,
|
||||
"status": sub.status,
|
||||
"billing_cycle": sub.billing_cycle,
|
||||
"current_period_start": sub.current_period_start.isoformat() + "Z" if sub.current_period_start else None,
|
||||
"current_period_end": sub.current_period_end.isoformat() + "Z" if sub.current_period_end else None,
|
||||
"trial_ends_at": sub.trial_ends_at.isoformat() + "Z" if sub.trial_ends_at else None,
|
||||
"stripe_subscription_id": sub.stripe_subscription_id,
|
||||
"overage_enabled": sub.overage_enabled,
|
||||
"cancelled_at": sub.cancelled_at.isoformat() + "Z" if sub.cancelled_at else None,
|
||||
"cancel_at_period_end": sub.cancel_at_period_end,
|
||||
}, message="Subscription retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Billing] Get subscription error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/billing/subscriptions/<org_id>", methods=["PATCH"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="subscription.update", resource_type="subscription")
|
||||
def update_subscription(org_id):
|
||||
"""Update subscription plan or billing cycle for an organization."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.subscription import Subscription
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
sub = Subscription.query.filter_by(organization_id=org_id).first()
|
||||
if not sub:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No subscription found for this organization",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
if "plan_id" in data:
|
||||
plan = Plan.query.get(data["plan_id"])
|
||||
if not plan:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Plan not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
sub.plan_id = data["plan_id"]
|
||||
|
||||
if "billing_cycle" in data:
|
||||
if data["billing_cycle"] not in ["monthly", "yearly"]:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Invalid billing cycle. Must be 'monthly' or 'yearly'",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
sub.billing_cycle = data["billing_cycle"]
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={
|
||||
"id": sub.id,
|
||||
"organization_id": sub.organization_id,
|
||||
"plan_id": sub.plan_id,
|
||||
"billing_cycle": sub.billing_cycle,
|
||||
}, message="Subscription updated successfully")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[Billing] Update subscription error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/billing/subscriptions/<org_id>/cancel", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="subscription.cancel", resource_type="subscription")
|
||||
def cancel_subscription(org_id):
|
||||
"""Cancel subscription at period end."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.subscription import Subscription
|
||||
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
sub = Subscription.query.filter_by(organization_id=org_id).first()
|
||||
if not sub:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No subscription found for this organization",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
sub.cancel_at_period_end = True
|
||||
sub.status = "cancelled"
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={
|
||||
"id": sub.id,
|
||||
"cancel_at_period_end": True,
|
||||
"status": sub.status,
|
||||
}, message="Subscription will be cancelled at period end")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[Billing] Cancel subscription error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/billing/subscriptions/<org_id>/trial", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="subscription.extend_trial", resource_type="subscription")
|
||||
def extend_trial(org_id):
|
||||
"""Extend trial period for an organization."""
|
||||
try:
|
||||
from gatehouse_app.models.billing.subscription import Subscription
|
||||
from datetime import timedelta
|
||||
|
||||
data = request.json or {}
|
||||
days = data.get("days", 30)
|
||||
|
||||
if not isinstance(days, int) or days < 1:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Days must be a positive integer",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
sub = Subscription.query.filter_by(organization_id=org_id).first()
|
||||
if not sub:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No subscription found for this organization",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Extend trial
|
||||
if sub.trial_ends_at:
|
||||
sub.trial_ends_at = sub.trial_ends_at + timedelta(days=days)
|
||||
else:
|
||||
sub.trial_ends_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
|
||||
sub.status = "trial"
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={
|
||||
"id": sub.id,
|
||||
"trial_ends_at": sub.trial_ends_at.isoformat() + "Z",
|
||||
"days_added": days,
|
||||
}, message=f"Trial extended by {days} days")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[Billing] Extend trial error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Superadmin SSH CA management endpoints."""
|
||||
import logging
|
||||
from flask import request
|
||||
from gatehouse_app.api.v1.superadmin import superadmin_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>/cas/<ca_id>", methods=["DELETE"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="ca.delete", resource_type="CA")
|
||||
def delete_org_ca(org_id, ca_id):
|
||||
"""Soft-delete an SSH CA for an organization.
|
||||
|
||||
Sets is_active=False and deleted_at=now().
|
||||
"""
|
||||
from gatehouse_app.models.ssh_ca.ca import CA
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
|
||||
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
|
||||
if not org:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
|
||||
ca = CA.query.filter_by(id=ca_id, organization_id=org_id, deleted_at=None).first()
|
||||
if not ca:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="CA not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND"
|
||||
)
|
||||
|
||||
try:
|
||||
ca.is_active = False
|
||||
ca.delete(soft=True)
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={"ca_id": ca_id}, message="CA deleted successfully")
|
||||
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
logger.exception(f"Failed to delete CA {ca_id}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Failed to delete CA",
|
||||
status=500,
|
||||
error_type="SERVER_ERROR"
|
||||
)
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Superadmin organization member management endpoints."""
|
||||
import logging
|
||||
from flask import request, g
|
||||
from marshmallow import ValidationError
|
||||
from gatehouse_app.api.v1.superadmin import superadmin_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.utils.constants import OrganizationRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ListMembersSchema:
|
||||
"""Schema for list members query params."""
|
||||
|
||||
@staticmethod
|
||||
def load(args):
|
||||
"""Parse and validate query parameters."""
|
||||
try:
|
||||
page = max(1, int(args.get("page", 1)))
|
||||
per_page = min(100, max(1, int(args.get("per_page", 20))))
|
||||
except (ValueError, TypeError):
|
||||
page = 1
|
||||
per_page = 20
|
||||
|
||||
search = args.get("search")
|
||||
role = args.get("role")
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"search": search,
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
class AddMemberSchema:
|
||||
"""Schema for adding a member."""
|
||||
|
||||
@staticmethod
|
||||
def load(data):
|
||||
"""Parse and validate add member data."""
|
||||
errors = {}
|
||||
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
errors["user_id"] = ["User ID is required"]
|
||||
|
||||
role_str = data.get("role", "member")
|
||||
try:
|
||||
role = OrganizationRole(role_str)
|
||||
except ValueError:
|
||||
errors["role"] = [f"Invalid role. Must be one of: {', '.join(r.value for r in OrganizationRole)}"]
|
||||
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
return {"user_id": user_id, "role": role}
|
||||
|
||||
|
||||
class UpdateMemberSchema:
|
||||
"""Schema for updating a member role."""
|
||||
|
||||
@staticmethod
|
||||
def load(data):
|
||||
"""Parse and validate update data."""
|
||||
errors = {}
|
||||
|
||||
role_str = data.get("role")
|
||||
if not role_str:
|
||||
errors["role"] = ["Role is required"]
|
||||
|
||||
try:
|
||||
role = OrganizationRole(role_str)
|
||||
except ValueError:
|
||||
errors["role"] = [f"Invalid role. Must be one of: {', '.join(r.value for r in OrganizationRole)}"]
|
||||
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
return {"role": role}
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>/members", methods=["GET"])
|
||||
@superadmin_required
|
||||
def list_organization_members(org_id):
|
||||
"""List all members of an organization.
|
||||
|
||||
Query params:
|
||||
page: Page number (default 1)
|
||||
per_page: Items per page (default 20)
|
||||
search: Search by user email or name
|
||||
role: Filter by role
|
||||
"""
|
||||
try:
|
||||
# Verify org exists
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
schema = ListMembersSchema()
|
||||
params = schema.load(request.args)
|
||||
|
||||
query = OrganizationMember.query.filter_by(organization_id=org_id, deleted_at=None)
|
||||
|
||||
# Search by user email or name
|
||||
if params["search"]:
|
||||
search_term = f"%{params['search']}%"
|
||||
query = query.join(User).filter(
|
||||
db.or_(
|
||||
User.email.ilike(search_term),
|
||||
User.full_name.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by role
|
||||
if params["role"]:
|
||||
try:
|
||||
role = OrganizationRole(params["role"])
|
||||
query = query.filter(OrganizationMember.role == role)
|
||||
except ValueError:
|
||||
pass # Ignore invalid role filter
|
||||
|
||||
# Order by joined_at desc
|
||||
query = query.order_by(OrganizationMember.joined_at.desc())
|
||||
|
||||
# Paginate
|
||||
pagination = query.paginate(page=params["page"], per_page=params["per_page"], error_out=False)
|
||||
|
||||
# Build response
|
||||
items = []
|
||||
for member in pagination.items:
|
||||
user = User.query.get(member.user_id)
|
||||
item = {
|
||||
"user_id": member.user_id,
|
||||
"organization_id": member.organization_id,
|
||||
"role": member.role.value,
|
||||
"joined_at": member.joined_at.isoformat() + "Z" if member.joined_at else None,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"is_active": user.is_active,
|
||||
} if user else {"id": member.user_id, "email": "[deleted]", "full_name": None, "is_active": False},
|
||||
}
|
||||
items.append(item)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"items": items,
|
||||
"total": pagination.total,
|
||||
"page": params["page"],
|
||||
"per_page": params["per_page"],
|
||||
"pages": pagination.pages,
|
||||
},
|
||||
message="Members retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] List members error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>/members", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="add_member", resource_type="organization_member")
|
||||
def add_organization_member(org_id):
|
||||
"""Add a user to an organization.
|
||||
|
||||
Body:
|
||||
user_id: User UUID to add
|
||||
role: Role (owner, admin, member, guest)
|
||||
"""
|
||||
try:
|
||||
schema = AddMemberSchema()
|
||||
data = schema.load(request.json or {})
|
||||
|
||||
# Verify org exists
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify user exists
|
||||
user = User.query.get(data["user_id"])
|
||||
if not user:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Check if already a member
|
||||
existing = OrganizationMember.query.filter_by(
|
||||
user_id=data["user_id"],
|
||||
organization_id=org_id,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
if existing:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User is already a member of this organization",
|
||||
status=400,
|
||||
error_type="ALREADY_EXISTS",
|
||||
)
|
||||
|
||||
# Create membership
|
||||
member = OrganizationMember(
|
||||
user_id=data["user_id"],
|
||||
organization_id=org_id,
|
||||
role=data["role"],
|
||||
invited_by_id=g.current_superadmin.id,
|
||||
invited_at=db.func.now(),
|
||||
joined_at=db.func.now(),
|
||||
)
|
||||
db.session.add(member)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[SuperadminOrg] Added user {data['user_id']} to org {org_id} as {data['role'].value}")
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"member": {
|
||||
"user_id": member.user_id,
|
||||
"organization_id": member.organization_id,
|
||||
"role": member.role.value,
|
||||
"joined_at": member.joined_at.isoformat() + "Z" if member.joined_at else None,
|
||||
}
|
||||
},
|
||||
message="Member added successfully",
|
||||
status=201,
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Add member error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>/members/<user_id>", methods=["PATCH"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="update_member_role", resource_type="organization_member")
|
||||
def update_organization_member(org_id, user_id):
|
||||
"""Update a member's role.
|
||||
|
||||
Body:
|
||||
role: New role (owner, admin, member, guest)
|
||||
"""
|
||||
try:
|
||||
schema = UpdateMemberSchema()
|
||||
data = schema.load(request.json or {})
|
||||
|
||||
# Find member
|
||||
member = OrganizationMember.query.filter_by(
|
||||
user_id=user_id,
|
||||
organization_id=org_id,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
if not member:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Member not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Update role
|
||||
old_role = member.role
|
||||
member.role = data["role"]
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[SuperadminOrg] Updated member {user_id} role from {old_role.value} to {data['role'].value}")
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"member": {
|
||||
"user_id": member.user_id,
|
||||
"organization_id": member.organization_id,
|
||||
"role": member.role.value,
|
||||
}
|
||||
},
|
||||
message="Member role updated successfully",
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Validation failed",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
error_details=e.messages,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Update member error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>/members/<user_id>", methods=["DELETE"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="remove_member", resource_type="organization_member")
|
||||
def remove_organization_member(org_id, user_id):
|
||||
"""Remove a user from an organization."""
|
||||
try:
|
||||
# Find member
|
||||
member = OrganizationMember.query.filter_by(
|
||||
user_id=user_id,
|
||||
organization_id=org_id,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
if not member:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Member not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Prevent removing owner without transferring ownership
|
||||
if member.role == OrganizationRole.OWNER:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Cannot remove organization owner. Transfer ownership first.",
|
||||
status=400,
|
||||
error_type="AUTHORIZATION_ERROR",
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
from datetime import datetime, timezone
|
||||
member.deleted_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[SuperadminOrg] Removed user {user_id} from org {org_id}")
|
||||
|
||||
return api_response(message="Member removed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Remove member error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>/transfer-ownership/<user_id>", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="transfer_ownership", resource_type="organization_member")
|
||||
def transfer_organization_ownership(org_id, user_id):
|
||||
"""Transfer organization ownership to another member.
|
||||
|
||||
The target user must already be a member of the organization.
|
||||
The current owner will be changed to admin role.
|
||||
"""
|
||||
try:
|
||||
# Verify org exists
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Find current owner
|
||||
current_owner = OrganizationMember.query.filter_by(
|
||||
organization_id=org_id,
|
||||
role=OrganizationRole.OWNER,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
if not current_owner:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Current owner not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Find target user as member
|
||||
target_member = OrganizationMember.query.filter_by(
|
||||
user_id=user_id,
|
||||
organization_id=org_id,
|
||||
deleted_at=None,
|
||||
).first()
|
||||
if not target_member:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Target user is not a member of this organization",
|
||||
status=400,
|
||||
error_type="AUTHORIZATION_ERROR",
|
||||
)
|
||||
|
||||
# Transfer ownership
|
||||
current_owner.role = OrganizationRole.ADMIN
|
||||
target_member.role = OrganizationRole.OWNER
|
||||
db.session.commit()
|
||||
|
||||
logger.warning(
|
||||
f"[SuperadminOrg] TRANSFERRED OWNERSHIP: org={org_id} from user={current_owner.user_id} to user={user_id}"
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"owner": {
|
||||
"user_id": target_member.user_id,
|
||||
"role": target_member.role.value,
|
||||
}
|
||||
},
|
||||
message="Ownership transferred successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Transfer ownership error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Superadmin organization management endpoints."""
|
||||
import logging
|
||||
from flask import request
|
||||
from gatehouse_app.api.v1.superadmin import superadmin_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.superadmin_organization_service import SuperadminOrganizationService
|
||||
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ListOrganizationsSchema:
|
||||
"""Schema for list organizations query params."""
|
||||
|
||||
@staticmethod
|
||||
def load(args):
|
||||
"""Parse and validate query parameters."""
|
||||
try:
|
||||
page = max(1, int(args.get("page", 1)))
|
||||
per_page = min(100, max(1, int(args.get("per_page", 20))))
|
||||
except (ValueError, TypeError):
|
||||
page = 1
|
||||
per_page = 20
|
||||
|
||||
search = args.get("search")
|
||||
status = args.get("status")
|
||||
plan_slug = args.get("plan_slug")
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"search": search,
|
||||
"status": status,
|
||||
"plan_slug": plan_slug,
|
||||
}
|
||||
|
||||
|
||||
class UpdateOrganizationSchema:
|
||||
"""Schema for updating an organization."""
|
||||
|
||||
@staticmethod
|
||||
def load(data):
|
||||
"""Parse and validate update data."""
|
||||
result = {}
|
||||
|
||||
if "name" in data:
|
||||
result["name"] = data["name"]
|
||||
if "description" in data:
|
||||
result["description"] = data["description"]
|
||||
if "is_active" in data:
|
||||
result["is_active"] = bool(data["is_active"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations", methods=["GET"])
|
||||
@superadmin_required
|
||||
def list_organizations():
|
||||
"""List all organizations with pagination and filtering.
|
||||
|
||||
Query params:
|
||||
page: Page number (default 1)
|
||||
per_page: Items per page (default 20, max 100)
|
||||
search: Search by name or slug
|
||||
status: Filter by status (active, suspended)
|
||||
plan_slug: Filter by plan slug
|
||||
"""
|
||||
try:
|
||||
schema = ListOrganizationsSchema()
|
||||
params = schema.load(request.args)
|
||||
|
||||
result = SuperadminOrganizationService.list_organizations(
|
||||
page=params["page"],
|
||||
per_page=params["per_page"],
|
||||
search=params["search"],
|
||||
status=params["status"],
|
||||
plan_slug=params["plan_slug"],
|
||||
)
|
||||
|
||||
return api_response(data=result, message="Organizations retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] List organizations error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_organization(org_id):
|
||||
"""Get detailed organization information.
|
||||
|
||||
Returns org details including member count, owner info, and active sessions.
|
||||
"""
|
||||
try:
|
||||
result = SuperadminOrganizationService.get_organization_detail(org_id)
|
||||
return api_response(data={"organization": result}, message="Organization retrieved successfully")
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Get organization error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>", methods=["PATCH"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="update_organization", resource_type="organization")
|
||||
def update_organization(org_id):
|
||||
"""Update organization details.
|
||||
|
||||
Body:
|
||||
name: New name (optional)
|
||||
description: New description (optional)
|
||||
is_active: New active status (optional)
|
||||
"""
|
||||
try:
|
||||
schema = UpdateOrganizationSchema()
|
||||
data = schema.load(request.json or {})
|
||||
|
||||
if not data:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="No update data provided",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
org = SuperadminOrganizationService.update_organization(org_id, **data)
|
||||
|
||||
return api_response(
|
||||
data={"organization": org.to_dict()},
|
||||
message="Organization updated successfully",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Update organization error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>/suspend", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="suspend_organization", resource_type="organization")
|
||||
def suspend_organization(org_id):
|
||||
"""Suspend an organization.
|
||||
|
||||
Sets is_active=False and invalidates all member sessions.
|
||||
"""
|
||||
try:
|
||||
org = SuperadminOrganizationService.suspend_organization(org_id)
|
||||
|
||||
return api_response(
|
||||
data={"organization": org.to_dict()},
|
||||
message="Organization suspended successfully",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Suspend organization error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>/unsuspend", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="unsuspend_organization", resource_type="organization")
|
||||
def unsuspend_organization(org_id):
|
||||
"""Restore a suspended organization."""
|
||||
try:
|
||||
org = SuperadminOrganizationService.restore_organization(org_id)
|
||||
|
||||
return api_response(
|
||||
data={"organization": org.to_dict()},
|
||||
message="Organization restored successfully",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Unsuspend organization error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/organizations/<org_id>", methods=["DELETE"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="delete_organization", resource_type="organization")
|
||||
def delete_organization(org_id):
|
||||
"""Soft-delete an organization."""
|
||||
try:
|
||||
SuperadminOrganizationService.soft_delete_organization(org_id)
|
||||
|
||||
return api_response(message="Organization deleted successfully")
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminOrg] Delete organization error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Superadmin usage and analytics endpoints."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from flask import request
|
||||
from gatehouse_app.api.v1.superadmin import superadmin_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.services.superadmin_usage_service import SuperadminUsageService
|
||||
from gatehouse_app.services.superadmin_analytics_service import SuperadminAnalyticsService
|
||||
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============ Analytics Endpoints ============
|
||||
|
||||
@superadmin_bp.route("/analytics/dashboard", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_dashboard_stats():
|
||||
"""Get dashboard statistics for the overview page.
|
||||
|
||||
Returns aggregated stats: org counts, user counts, sessions, recent signups.
|
||||
"""
|
||||
try:
|
||||
stats = SuperadminAnalyticsService.get_dashboard_stats()
|
||||
return api_response(data=stats, message="Dashboard stats retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAnalytics] Dashboard stats error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/analytics/signup-trends", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_signup_trends():
|
||||
"""Get signup trends over time.
|
||||
|
||||
Query params:
|
||||
days: Number of days to analyze (default 30, max 365)
|
||||
"""
|
||||
try:
|
||||
days = min(365, max(1, int(request.args.get("days", 30))))
|
||||
trends = SuperadminAnalyticsService.get_signup_trends(days)
|
||||
return api_response(data=trends, message="Signup trends retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAnalytics] Signup trends error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/analytics/org-distribution", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_org_distribution():
|
||||
"""Get organization distribution by size."""
|
||||
try:
|
||||
distribution = SuperadminAnalyticsService.get_org_distribution()
|
||||
return api_response(data=distribution, message="Organization distribution retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAnalytics] Org distribution error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/analytics/recent-activity", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_recent_activity():
|
||||
"""Get recent superadmin actions.
|
||||
|
||||
Query params:
|
||||
limit: Maximum number of entries (default 20, max 100)
|
||||
"""
|
||||
try:
|
||||
limit = min(100, max(1, int(request.args.get("limit", 20))))
|
||||
activity = SuperadminAnalyticsService.get_recent_activity(limit)
|
||||
return api_response(data={"items": activity}, message="Recent activity retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAnalytics] Recent activity error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
# ============ Usage Endpoints ============
|
||||
|
||||
@superadmin_bp.route("/usage/<org_id>", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_organization_usage(org_id):
|
||||
"""Get current usage for an organization.
|
||||
|
||||
Returns current period usage metrics: user count, active sessions.
|
||||
"""
|
||||
try:
|
||||
usage = SuperadminUsageService.get_current_usage(org_id)
|
||||
return api_response(data=usage, message="Usage retrieved successfully")
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminUsage] Get usage error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/usage/<org_id>/history", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_usage_history(org_id):
|
||||
"""Get usage history for an organization.
|
||||
|
||||
Query params:
|
||||
metric: Metric type (users, sessions) - default users
|
||||
days: Number of days of history (default 30, max 365)
|
||||
"""
|
||||
try:
|
||||
metric = request.args.get("metric", "users")
|
||||
days = min(365, max(1, int(request.args.get("days", 30))))
|
||||
|
||||
history = SuperadminUsageService.get_usage_history(org_id, metric, days)
|
||||
return api_response(data=history, message="Usage history retrieved successfully")
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminUsage] Get usage history error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/usage/<org_id>/seats", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_seat_count(org_id):
|
||||
"""Get maximum seat count for billing period.
|
||||
|
||||
Query params:
|
||||
year: Year (default current year)
|
||||
month: Month (default current month)
|
||||
"""
|
||||
try:
|
||||
year = int(request.args.get("year", datetime.now().year))
|
||||
month = int(request.args.get("month", datetime.now().month))
|
||||
|
||||
seats = SuperadminUsageService.get_seat_count_for_period(org_id, year, month)
|
||||
return api_response(data=seats, message="Seat count retrieved successfully")
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminUsage] Get seat count error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/usage/<org_id>/adjust", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="usage_adjustment", resource_type="usage")
|
||||
def adjust_usage(org_id):
|
||||
"""Apply a manual usage adjustment.
|
||||
|
||||
Body:
|
||||
metric: Metric to adjust
|
||||
adjustment: Positive (credit) or negative (charge)
|
||||
reason: Reason for adjustment
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
|
||||
metric = data.get("metric", "users")
|
||||
adjustment = data.get("adjustment", 0)
|
||||
reason = data.get("reason", "")
|
||||
|
||||
if not reason:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Reason is required for usage adjustment",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
result = SuperadminUsageService.adjust_usage(
|
||||
org_id=org_id,
|
||||
metric=metric,
|
||||
adjustment=adjustment,
|
||||
reason=reason,
|
||||
superadmin_id="", # Will be filled from decorator
|
||||
)
|
||||
|
||||
return api_response(data=result, message="Usage adjustment applied successfully")
|
||||
|
||||
except ValueError as e:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=str(e),
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminUsage] Adjust usage error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
# ============ Invoice Data Endpoint ============
|
||||
|
||||
@superadmin_bp.route("/invoice-data/<org_id>", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_invoice_data(org_id):
|
||||
"""Get all data needed to generate an invoice for an organization.
|
||||
|
||||
Returns organization info, plan details, usage, and subscription status.
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Get seat count for current month
|
||||
now = datetime.now(timezone.utc)
|
||||
seats = SuperadminUsageService.get_seat_count_for_period(org_id, now.year, now.month)
|
||||
|
||||
# Get owner
|
||||
owner = org.get_owner()
|
||||
|
||||
invoice_data = {
|
||||
"organization": {
|
||||
"id": org.id,
|
||||
"name": org.name,
|
||||
"slug": org.slug,
|
||||
"owner_email": owner.email if owner else None,
|
||||
"created_at": org.created_at.isoformat() + "Z" if org.created_at else None,
|
||||
},
|
||||
"billing_period": {
|
||||
"year": now.year,
|
||||
"month": now.month,
|
||||
"start": seats["period_start"],
|
||||
"end": seats["period_end"],
|
||||
},
|
||||
"usage": {
|
||||
"max_seats": seats["max_seats"],
|
||||
"current_seats": seats["current_seats"],
|
||||
"included_seats": 0, # Would come from plan
|
||||
"overage": max(0, seats["current_seats"]), # Simplified
|
||||
},
|
||||
"subscription": {
|
||||
"status": "active" if org.is_active else "suspended",
|
||||
"is_active": org.is_active,
|
||||
},
|
||||
"line_items": [
|
||||
{
|
||||
"description": "Base subscription",
|
||||
"quantity": 1,
|
||||
"unit_price": 0, # Would come from plan
|
||||
"total": 0,
|
||||
},
|
||||
{
|
||||
"description": f"User seats ({seats['current_seats']})",
|
||||
"quantity": seats["current_seats"],
|
||||
"unit_price": 0, # Would come from plan per-seat price
|
||||
"total": 0,
|
||||
},
|
||||
],
|
||||
"total": 0, # Would be calculated
|
||||
"generated_at": now.isoformat() + "Z",
|
||||
}
|
||||
|
||||
return api_response(data=invoice_data, message="Invoice data retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminAnalytics] Invoice data error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
@@ -0,0 +1,516 @@
|
||||
"""Superadmin user management endpoints."""
|
||||
import logging
|
||||
from flask import request, g
|
||||
from gatehouse_app.api.v1.superadmin import superadmin_bp
|
||||
from gatehouse_app.utils.response import api_response
|
||||
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@superadmin_bp.route("/users", methods=["GET"])
|
||||
@superadmin_required
|
||||
def list_users():
|
||||
"""Get paginated list of users with optional filters.
|
||||
|
||||
Query params:
|
||||
page: Page number (default 1)
|
||||
per_page: Items per page (default 20, max 100)
|
||||
organization_id: Filter by organization
|
||||
status: Filter by status (active/suspended)
|
||||
search: Search by email or name
|
||||
"""
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
|
||||
org_id = request.args.get("organization_id")
|
||||
status = request.args.get("status")
|
||||
search = request.args.get("search", "").strip()
|
||||
|
||||
# Base query
|
||||
query = User.query.filter(User.deleted_at.is_(None))
|
||||
|
||||
# Filter by organization
|
||||
if org_id:
|
||||
member_user_ids = db.session.query(OrganizationMember.user_id).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).all()
|
||||
user_ids = [m.user_id for m in member_user_ids]
|
||||
query = query.filter(User.id.in_(user_ids))
|
||||
|
||||
# Filter by status
|
||||
if status == "suspended":
|
||||
query = query.filter(User.status == "GLOBAL_SUSPENDED")
|
||||
elif status == "active":
|
||||
query = query.filter(User.status != "GLOBAL_SUSPENDED")
|
||||
|
||||
# Search by email or name
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
User.email.ilike(search_filter),
|
||||
User.full_name.ilike(search_filter),
|
||||
)
|
||||
)
|
||||
|
||||
# Order by created_at desc
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
# Paginate
|
||||
total = query.count()
|
||||
users = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
# Get org memberships for each user
|
||||
items = []
|
||||
for user in users:
|
||||
# Get organization memberships
|
||||
memberships = db.session.query(OrganizationMember).filter(
|
||||
OrganizationMember.user_id == user.id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
orgs = []
|
||||
for m in memberships:
|
||||
org = Organization.query.get(m.organization_id)
|
||||
if org:
|
||||
orgs.append({
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"role": m.role,
|
||||
"joined_at": m.created_at.isoformat() + "Z" if m.created_at else None,
|
||||
})
|
||||
|
||||
# Get active session count
|
||||
active_sessions = Session.query.filter(
|
||||
Session.user_id == user.id,
|
||||
Session.deleted_at.is_(None),
|
||||
Session.status == "active",
|
||||
).count()
|
||||
|
||||
items.append({
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"status": user.status,
|
||||
"mfa_enabled": user.mfa_enabled if hasattr(user, 'mfa_enabled') else False,
|
||||
"org_count": len(orgs),
|
||||
"orgs": orgs,
|
||||
"active_sessions": active_sessions,
|
||||
"last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None,
|
||||
"created_at": user.created_at.isoformat() + "Z" if user.created_at else None,
|
||||
})
|
||||
|
||||
return api_response(data={
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if per_page > 0 else 0,
|
||||
}, message="Users retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminUsers] List users error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/users/<user_id>", methods=["GET"])
|
||||
@superadmin_required
|
||||
def get_user(user_id):
|
||||
"""Get detailed user information.
|
||||
|
||||
Returns user + all org memberships + active sessions + security methods.
|
||||
"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Get organization memberships
|
||||
memberships = db.session.query(OrganizationMember).filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
orgs = []
|
||||
for m in memberships:
|
||||
org = Organization.query.get(m.organization_id)
|
||||
if org:
|
||||
orgs.append({
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"org_slug": org.slug,
|
||||
"role": m.role,
|
||||
"joined_at": m.created_at.isoformat() + "Z" if m.created_at else None,
|
||||
})
|
||||
|
||||
# Get active sessions
|
||||
sessions = Session.query.filter(
|
||||
Session.user_id == user_id,
|
||||
Session.deleted_at.is_(None),
|
||||
Session.status == "active",
|
||||
).all()
|
||||
|
||||
active_sessions = [{
|
||||
"id": s.id,
|
||||
"ip_address": s.ip_address,
|
||||
"user_agent": s.user_agent,
|
||||
"created_at": s.created_at.isoformat() + "Z" if s.created_at else None,
|
||||
"last_active_at": s.last_active_at.isoformat() + "Z" if hasattr(s, 'last_active_at') and s.last_active_at else None,
|
||||
} for s in sessions]
|
||||
|
||||
# Get security methods (simplified - would need UserSecurityMethod model)
|
||||
security_methods = []
|
||||
if hasattr(user, 'totp_enabled') and user.totp_enabled:
|
||||
security_methods.append({"type": "totp", "enabled": True})
|
||||
if hasattr(user, 'webauthn_enabled') and user.webauthn_enabled:
|
||||
security_methods.append({"type": "webauthn", "enabled": True})
|
||||
|
||||
return api_response(data={
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"status": user.status,
|
||||
"mfa_enabled": user.mfa_enabled if hasattr(user, 'mfa_enabled') else False,
|
||||
"last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None,
|
||||
"created_at": user.created_at.isoformat() + "Z" if user.created_at else None,
|
||||
},
|
||||
"organizations": orgs,
|
||||
"active_sessions": active_sessions,
|
||||
"security_methods": security_methods,
|
||||
}, message="User retrieved successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminUsers] Get user error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/users/<user_id>/suspend", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="user.suspend", resource_type="user")
|
||||
def suspend_user(user_id):
|
||||
"""Globally suspend a user (sets status=GLOBAL_SUSPENDED)."""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
if user.status == "GLOBAL_SUSPENDED":
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User is already suspended",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
user.status = "GLOBAL_SUSPENDED"
|
||||
db.session.commit()
|
||||
|
||||
# Revoke all sessions
|
||||
revoked_count = Session.query.filter(
|
||||
Session.user_id == user_id,
|
||||
Session.deleted_at.is_(None),
|
||||
).update({"status": "revoked", "deleted_at": db.func.now()})
|
||||
db.session.commit()
|
||||
|
||||
logger.warning(f"[SuperadminUsers] User {user_id} globally suspended by {getattr(g, 'current_superadmin', {}).get('id', 'unknown')}")
|
||||
|
||||
return api_response(data={
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"status": user.status,
|
||||
},
|
||||
"sessions_revoked": revoked_count,
|
||||
}, message="User suspended successfully")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[SuperadminUsers] Suspend user error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/users/<user_id>/unsuspend", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="user.unsuspend", resource_type="user")
|
||||
def unsuspend_user(user_id):
|
||||
"""Remove global suspension from a user."""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
if user.status != "GLOBAL_SUSPENDED":
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User is not suspended",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
user.status = "active"
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"status": user.status,
|
||||
},
|
||||
}, message="User unsuspended successfully")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[SuperadminUsers] Unsuspend user error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/users/<user_id>/reset-password", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="user.reset_password", resource_type="user")
|
||||
def reset_user_password(user_id):
|
||||
"""Trigger password reset email flow for user."""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# In production, this would call AuthService.send_password_reset_email(user.email)
|
||||
# For now, just log and return success
|
||||
logger.info(f"[SuperadminUsers] Password reset requested for {user.email} by superadmin")
|
||||
|
||||
return api_response(data={
|
||||
"email": user.email,
|
||||
}, message="Password reset email sent successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SuperadminUsers] Reset password error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/users/<user_id>/sessions", methods=["DELETE"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="user.revoke_sessions", resource_type="user")
|
||||
def revoke_user_sessions(user_id):
|
||||
"""Revoke all sessions for a user."""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Revoke all sessions
|
||||
result = Session.query.filter(
|
||||
Session.user_id == user_id,
|
||||
Session.deleted_at.is_(None),
|
||||
).update({"status": "revoked", "deleted_at": db.func.now()})
|
||||
db.session.commit()
|
||||
|
||||
return api_response(data={
|
||||
"user_id": user_id,
|
||||
"count": result,
|
||||
}, message=f"All sessions revoked ({result} sessions)")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[SuperadminUsers] Revoke sessions error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/users/<user_id>/add-to-org/<org_id>", methods=["POST"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="user.add_to_org", resource_type="user")
|
||||
def add_user_to_org(user_id, org_id):
|
||||
"""Add a user to an organization with specified role."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
role = data.get("role", "member")
|
||||
|
||||
valid_roles = ["member", "admin", "owner"]
|
||||
if role not in valid_roles:
|
||||
return api_response(
|
||||
success=False,
|
||||
message=f"Invalid role. Must be one of: {', '.join(valid_roles)}",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
org = Organization.query.get(org_id)
|
||||
if not org or org.deleted_at is not None:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Organization not found",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Check if already a member
|
||||
existing = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User is already a member of this organization",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Create membership
|
||||
membership = OrganizationMember(
|
||||
user_id=user_id,
|
||||
organization_id=org_id,
|
||||
role=role,
|
||||
)
|
||||
db.session.add(membership)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[SuperadminUsers] User {user_id} added to org {org_id} as {role} by superadmin")
|
||||
|
||||
return api_response(data={
|
||||
"user_id": user_id,
|
||||
"organization_id": org_id,
|
||||
"role": role,
|
||||
"joined_at": membership.created_at.isoformat() + "Z" if membership.created_at else None,
|
||||
}, message="User added to organization successfully")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[SuperadminUsers] Add to org error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
|
||||
|
||||
@superadmin_bp.route("/users/<user_id>/orgs/<org_id>", methods=["DELETE"])
|
||||
@superadmin_required
|
||||
@superadmin_audit_log(action="user.remove_from_org", resource_type="user")
|
||||
def remove_user_from_org(user_id, org_id):
|
||||
"""Remove a user from an organization."""
|
||||
try:
|
||||
membership = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="User is not a member of this organization",
|
||||
status=404,
|
||||
error_type="NOT_FOUND",
|
||||
)
|
||||
|
||||
# Check if user is the only owner
|
||||
if membership.role == "owner":
|
||||
owner_count = OrganizationMember.query.filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role == "owner",
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).count()
|
||||
|
||||
if owner_count <= 1:
|
||||
return api_response(
|
||||
success=False,
|
||||
message="Cannot remove the only owner from an organization. Transfer ownership first.",
|
||||
status=400,
|
||||
error_type="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Soft delete membership
|
||||
membership.deleted_at = db.func.now()
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[SuperadminUsers] User {user_id} removed from org {org_id} by superadmin")
|
||||
|
||||
return api_response(data={
|
||||
"user_id": user_id,
|
||||
"organization_id": org_id,
|
||||
}, message="User removed from organization successfully")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[SuperadminUsers] Remove from org error: {e}")
|
||||
return api_response(
|
||||
success=False,
|
||||
message="An error occurred",
|
||||
status=500,
|
||||
error_type="INTERNAL_ERROR",
|
||||
)
|
||||
Reference in New Issue
Block a user