Add superadmin routes to API
This commit is contained in:
@@ -9,6 +9,8 @@ from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
|
||||
from gatehouse_app.services.oidc_token_service import OIDCTokenService
|
||||
from gatehouse_app.services.oidc_session_service import OIDCSessionService
|
||||
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
|
||||
from gatehouse_app.services.superadmin_auth_service import SuperadminAuthService
|
||||
from gatehouse_app.services.superadmin_organization_service import SuperadminOrganizationService
|
||||
|
||||
__all__ = [
|
||||
"AuthService",
|
||||
@@ -22,4 +24,6 @@ __all__ = [
|
||||
"OIDCTokenService",
|
||||
"OIDCSessionService",
|
||||
"OIDCAuditService",
|
||||
"SuperadminAuthService",
|
||||
"SuperadminOrganizationService",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Billing service for superadmin operations."""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
from gatehouse_app.models.billing.subscription import Subscription, SubscriptionStatus, BillingCycle
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BillingService:
|
||||
"""Service for billing operations."""
|
||||
|
||||
@staticmethod
|
||||
def get_plan(plan_id: str) -> Plan:
|
||||
"""Get a plan by ID."""
|
||||
plan = Plan.query.get(plan_id)
|
||||
if not plan:
|
||||
raise ValueError("Plan not found")
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def list_plans() -> list:
|
||||
"""List all active plans."""
|
||||
return Plan.query.filter(Plan.is_active == True).order_by(Plan.price_monthly.asc()).all()
|
||||
|
||||
@staticmethod
|
||||
def create_subscription(
|
||||
organization_id: str,
|
||||
plan_id: str,
|
||||
billing_cycle: str = "monthly"
|
||||
) -> Subscription:
|
||||
"""Create a new subscription for an organization.
|
||||
|
||||
Args:
|
||||
organization_id: Organization UUID
|
||||
plan_id: Plan UUID
|
||||
billing_cycle: 'monthly' or 'yearly'
|
||||
|
||||
Returns:
|
||||
New subscription
|
||||
"""
|
||||
org = Organization.query.get(organization_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
plan = Plan.query.get(plan_id)
|
||||
if not plan:
|
||||
raise ValueError("Plan not found")
|
||||
|
||||
# Check if subscription already exists
|
||||
existing = Subscription.query.filter_by(organization_id=organization_id).first()
|
||||
if existing:
|
||||
raise ValueError("Organization already has a subscription")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Calculate period
|
||||
if billing_cycle == "yearly":
|
||||
period_end = now + timedelta(days=365)
|
||||
else:
|
||||
period_end = now + timedelta(days=30)
|
||||
|
||||
subscription = Subscription(
|
||||
organization_id=organization_id,
|
||||
plan_id=plan_id,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
billing_cycle=BillingCycle.MONTHLY if billing_cycle == "monthly" else BillingCycle.YEARLY,
|
||||
current_period_start=now,
|
||||
current_period_end=period_end,
|
||||
)
|
||||
|
||||
db.session.add(subscription)
|
||||
db.session.commit()
|
||||
|
||||
return subscription
|
||||
|
||||
@staticmethod
|
||||
def change_plan(organization_id: str, new_plan_id: str) -> Subscription:
|
||||
"""Change subscription plan.
|
||||
|
||||
Args:
|
||||
organization_id: Organization UUID
|
||||
new_plan_id: New plan UUID
|
||||
|
||||
Returns:
|
||||
Updated subscription
|
||||
"""
|
||||
subscription = Subscription.query.filter_by(organization_id=organization_id).first()
|
||||
if not subscription:
|
||||
raise ValueError("No subscription found for organization")
|
||||
|
||||
new_plan = Plan.query.get(new_plan_id)
|
||||
if not new_plan:
|
||||
raise ValueError("Plan not found")
|
||||
|
||||
subscription.plan_id = new_plan_id
|
||||
db.session.commit()
|
||||
|
||||
return subscription
|
||||
|
||||
@staticmethod
|
||||
def cancel_subscription(organization_id: str) -> Subscription:
|
||||
"""Cancel subscription at period end.
|
||||
|
||||
Args:
|
||||
organization_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Updated subscription
|
||||
"""
|
||||
subscription = Subscription.query.filter_by(organization_id=organization_id).first()
|
||||
if not subscription:
|
||||
raise ValueError("No subscription found for organization")
|
||||
|
||||
subscription.cancel_at_period_end = True
|
||||
subscription.status = SubscriptionStatus.CANCELLED
|
||||
db.session.commit()
|
||||
|
||||
return subscription
|
||||
|
||||
@staticmethod
|
||||
def extend_trial(organization_id: str, days: int) -> Subscription:
|
||||
"""Extend trial period.
|
||||
|
||||
Args:
|
||||
organization_id: Organization UUID
|
||||
days: Number of days to extend
|
||||
|
||||
Returns:
|
||||
Updated subscription
|
||||
"""
|
||||
subscription = Subscription.query.filter_by(organization_id=organization_id).first()
|
||||
if not subscription:
|
||||
raise ValueError("No subscription found for organization")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if subscription.trial_ends_at:
|
||||
subscription.trial_ends_at = subscription.trial_ends_at + timedelta(days=days)
|
||||
else:
|
||||
subscription.trial_ends_at = now + timedelta(days=days)
|
||||
|
||||
subscription.status = SubscriptionStatus.TRIAL
|
||||
db.session.commit()
|
||||
|
||||
return subscription
|
||||
|
||||
@staticmethod
|
||||
def calculate_overage(organization_id: str) -> dict:
|
||||
"""Calculate overage charges for an organization.
|
||||
|
||||
Args:
|
||||
organization_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Overage calculation with details
|
||||
"""
|
||||
subscription = Subscription.query.filter_by(organization_id=organization_id).first()
|
||||
if not subscription:
|
||||
return {"has_overage": False, "overage_cost": 0, "user_count": 0, "included_users": 0}
|
||||
|
||||
plan = Plan.query.get(subscription.plan_id) if subscription.plan_id else None
|
||||
if not plan:
|
||||
return {"has_overage": False, "overage_cost": 0, "user_count": 0, "included_users": 0}
|
||||
|
||||
# Count current users
|
||||
user_count = OrganizationMember.query.filter(
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).count()
|
||||
|
||||
included_users = plan.included_users
|
||||
overage_users = max(0, user_count - included_users)
|
||||
|
||||
if overage_users > 0 and plan.overage_rate_per_user > 0:
|
||||
overage_cost = overage_users * plan.overage_rate_per_user
|
||||
has_overage = True
|
||||
else:
|
||||
overage_cost = 0
|
||||
has_overage = False
|
||||
|
||||
return {
|
||||
"has_overage": has_overage,
|
||||
"user_count": user_count,
|
||||
"included_users": included_users,
|
||||
"overage_users": overage_users,
|
||||
"overage_rate_per_user": plan.overage_rate_per_user,
|
||||
"overage_cost": overage_cost,
|
||||
}
|
||||
@@ -302,7 +302,7 @@ class OrganizationService:
|
||||
Raises:
|
||||
ConflictError: If user is already a member
|
||||
"""
|
||||
# Check if already a member (active or soft-deleted — both blocked by DB unique constraint)
|
||||
# Check for any membership (active or soft-deleted) to enable reactivation
|
||||
existing = OrganizationMember.query.filter_by(
|
||||
user_id=user_id,
|
||||
organization_id=org.id,
|
||||
@@ -310,7 +310,7 @@ class OrganizationService:
|
||||
|
||||
# Development-only debug logging for membership validation
|
||||
if current_app.config.get('ENV') == 'development':
|
||||
logger.debug(f"[Org] Member check: org_id={org.id}, user_id={user_id}, already_member={existing is not None}")
|
||||
logger.debug(f"[Org] Member check: org_id={org.id}, user_id={user_id}, already_member={existing is not None}, soft_deleted={existing.deleted_at is not None if existing else False}")
|
||||
|
||||
if existing:
|
||||
if existing.deleted_at is not None:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Analytics service for platform-wide statistics."""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuperadminAnalyticsService:
|
||||
"""Service for platform-wide analytics and statistics."""
|
||||
|
||||
@staticmethod
|
||||
def get_dashboard_stats() -> dict:
|
||||
"""Get dashboard statistics for the overview page.
|
||||
|
||||
Returns:
|
||||
Dashboard stats including org count, user count, etc.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
# Total organizations
|
||||
total_orgs = Organization.query.filter(Organization.deleted_at.is_(None)).count()
|
||||
active_orgs = Organization.query.filter(
|
||||
Organization.deleted_at.is_(None),
|
||||
Organization.is_active == True, # noqa: E712
|
||||
).count()
|
||||
|
||||
# Total users
|
||||
total_users = User.query.filter(User.deleted_at.is_(None)).count()
|
||||
|
||||
# Active sessions
|
||||
active_sessions = Session.query.filter(
|
||||
Session.deleted_at.is_(None),
|
||||
Session.status == "active",
|
||||
).count()
|
||||
|
||||
# New signups in last 30 days
|
||||
new_users_30d = User.query.filter(
|
||||
User.deleted_at.is_(None),
|
||||
User.created_at >= thirty_days_ago,
|
||||
).count()
|
||||
|
||||
# New organizations in last 30 days
|
||||
new_orgs_30d = Organization.query.filter(
|
||||
Organization.deleted_at.is_(None),
|
||||
Organization.created_at >= thirty_days_ago,
|
||||
).count()
|
||||
|
||||
# Suspended organizations
|
||||
suspended_orgs = Organization.query.filter(
|
||||
Organization.deleted_at.is_(None),
|
||||
Organization.is_active == False, # noqa: E712
|
||||
).count()
|
||||
|
||||
return {
|
||||
"total_organizations": total_orgs,
|
||||
"active_organizations": active_orgs,
|
||||
"suspended_organizations": suspended_orgs,
|
||||
"total_users": total_users,
|
||||
"active_sessions": active_sessions,
|
||||
"new_users_30d": new_users_30d,
|
||||
"new_orgs_30d": new_orgs_30d,
|
||||
"generated_at": now.isoformat() + "Z",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_signup_trends(days: int = 30) -> dict:
|
||||
"""Get signup trends over time.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
|
||||
Returns:
|
||||
Daily signup data
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Get all users created in period
|
||||
users = User.query.filter(
|
||||
User.deleted_at.is_(None),
|
||||
User.created_at >= start_date,
|
||||
).all()
|
||||
|
||||
# Group by day
|
||||
daily_signups = {}
|
||||
for i in range(days):
|
||||
date = (start_date + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
daily_signups[date] = 0
|
||||
|
||||
for user in users:
|
||||
date = user.created_at.strftime("%Y-%m-%d")
|
||||
if date in daily_signups:
|
||||
daily_signups[date] += 1
|
||||
|
||||
# Convert to list
|
||||
history = [
|
||||
{"date": date, "value": count}
|
||||
for date, count in sorted(daily_signups.items())
|
||||
]
|
||||
|
||||
return {
|
||||
"period_start": start_date.isoformat() + "Z",
|
||||
"period_end": now.isoformat() + "Z",
|
||||
"total": len(users),
|
||||
"history": history,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_org_distribution() -> dict:
|
||||
"""Get distribution of organizations by size.
|
||||
|
||||
Returns:
|
||||
Organization size distribution
|
||||
"""
|
||||
orgs = Organization.query.filter(Organization.deleted_at.is_(None)).all()
|
||||
|
||||
distribution = {
|
||||
"solo": 0, # 1 user
|
||||
"small": 0, # 2-10 users
|
||||
"medium": 0, # 11-50 users
|
||||
"large": 0, # 51-200 users
|
||||
"enterprise": 0, # 200+ users
|
||||
}
|
||||
|
||||
for org in orgs:
|
||||
count = org.get_member_count()
|
||||
if count == 1:
|
||||
distribution["solo"] += 1
|
||||
elif count <= 10:
|
||||
distribution["small"] += 1
|
||||
elif count <= 50:
|
||||
distribution["medium"] += 1
|
||||
elif count <= 200:
|
||||
distribution["large"] += 1
|
||||
else:
|
||||
distribution["enterprise"] += 1
|
||||
|
||||
return {
|
||||
"distribution": distribution,
|
||||
"total_orgs": len(orgs),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_recent_activity(limit: int = 20) -> list:
|
||||
"""Get recent superadmin actions.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of actions to return
|
||||
|
||||
Returns:
|
||||
List of recent audit log entries
|
||||
"""
|
||||
logs = SuperadminAuditLog.query.filter(
|
||||
SuperadminAuditLog.deleted_at.is_(None),
|
||||
).order_by(
|
||||
SuperadminAuditLog.created_at.desc()
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"superadmin_id": log.superadmin_id,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"extra_data": log.extra_data,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"created_at": log.created_at.isoformat() + "Z" if log.created_at else None,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Superadmin authentication service."""
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from flask import request, current_app
|
||||
from gatehouse_app.extensions import db, bcrypt
|
||||
from gatehouse_app.models.superadmin import Superadmin, SuperadminSession
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuperadminAuthService:
|
||||
"""Service for superadmin authentication operations."""
|
||||
|
||||
@staticmethod
|
||||
def authenticate(email, credentials):
|
||||
"""Authenticate superadmin with email/password credentials.
|
||||
|
||||
Args:
|
||||
email: Superadmin email
|
||||
credentials: Plain text credential
|
||||
|
||||
Returns:
|
||||
Superadmin instance if authentication succeeds
|
||||
|
||||
Raises:
|
||||
InvalidCredentialsError: If credentials are invalid or account is disabled
|
||||
"""
|
||||
# Find superadmin by email
|
||||
superadmin = Superadmin.query.filter_by(email=email.lower()).first()
|
||||
|
||||
if not superadmin:
|
||||
logger.warning(f"[SuperadminAuth] Login attempt for non-existent email: {email}")
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
# Check if account is active
|
||||
if not superadmin.is_active:
|
||||
logger.warning(f"[SuperadminAuth] Login attempt for disabled account: {email}")
|
||||
raise InvalidCredentialsError("Account is disabled")
|
||||
|
||||
# Check credential
|
||||
if not superadmin.password_hash:
|
||||
logger.warning(f"[SuperadminAuth] Login attempt for account with no credential set: {email}")
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
# Verify credential
|
||||
password_valid = bcrypt.check_password_hash(superadmin.password_hash, credentials)
|
||||
|
||||
if not password_valid:
|
||||
logger.warning(f"[SuperadminAuth] Invalid password for: {email}")
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
# Update last login
|
||||
superadmin.last_login_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[SuperadminAuth] Successful login for: {email}")
|
||||
return superadmin
|
||||
|
||||
@staticmethod
|
||||
def create_session(superadmin_id, duration_seconds=28800):
|
||||
"""Create a new session for superadmin.
|
||||
|
||||
Args:
|
||||
superadmin_id: Superadmin ID
|
||||
duration_seconds: Session duration in seconds (default 8 hours)
|
||||
|
||||
Returns:
|
||||
SuperadminSession instance
|
||||
"""
|
||||
# Generate secure token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# Create session
|
||||
session = SuperadminSession(
|
||||
superadmin_id=superadmin_id,
|
||||
token=token,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(seconds=duration_seconds),
|
||||
last_activity_at=datetime.now(timezone.utc),
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
session.save()
|
||||
|
||||
logger.info(f"[SuperadminAuth] Session created for superadmin_id={superadmin_id}")
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def revoke_session(session_id, reason=None):
|
||||
"""Revoke a superadmin session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to revoke
|
||||
reason: Optional revocation reason
|
||||
"""
|
||||
session = SuperadminSession.query.get(session_id)
|
||||
if session:
|
||||
session.revoke(reason=reason)
|
||||
logger.info(f"[SuperadminAuth] Session {session_id} revoked: {reason or 'No reason'}")
|
||||
|
||||
@staticmethod
|
||||
def revoke_all_sessions(superadmin_id, except_token=None, reason=None):
|
||||
"""Revoke all sessions for a superadmin.
|
||||
|
||||
Args:
|
||||
superadmin_id: Superadmin ID
|
||||
except_token: Optional token to keep (current session)
|
||||
reason: Optional revocation reason
|
||||
"""
|
||||
query = SuperadminSession.query.filter_by(superadmin_id=superadmin_id)
|
||||
if except_token:
|
||||
query = query.filter(SuperadminSession.token != except_token)
|
||||
|
||||
sessions = query.all()
|
||||
for session in sessions:
|
||||
session.revoke(reason=reason)
|
||||
|
||||
logger.info(f"[SuperadminAuth] Revoked {len(sessions)} sessions for superadmin_id={superadmin_id}")
|
||||
return len(sessions)
|
||||
|
||||
@staticmethod
|
||||
def create_emergency_access(superadmin_id, target_user_id, reason, duration_minutes=15):
|
||||
"""Create emergency access to a user's account.
|
||||
|
||||
This creates a special emergency session that grants temporary elevated access.
|
||||
|
||||
Args:
|
||||
superadmin_id: Superadmin ID initiating emergency access
|
||||
target_user_id: User ID to access
|
||||
reason: Reason for emergency access
|
||||
duration_minutes: Duration of emergency access in minutes
|
||||
|
||||
Returns:
|
||||
Dictionary with emergency session info
|
||||
"""
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.services.session_service import SessionService
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
# Verify target user exists
|
||||
target_user = User.query.get(target_user_id)
|
||||
if not target_user:
|
||||
raise ValueError(f"Target user not found: {target_user_id}")
|
||||
|
||||
# Create emergency session for the target user
|
||||
emergency_session = SessionService.create_session(
|
||||
user=target_user,
|
||||
duration_seconds=duration_minutes * 60,
|
||||
is_compliance_only=False
|
||||
)
|
||||
|
||||
# Log the emergency access
|
||||
logger.warning(
|
||||
f"[SuperadminAuth] EMERGENCY ACCESS: superadmin_id={superadmin_id} "
|
||||
f"accessed user_id={target_user_id} reason={reason}"
|
||||
)
|
||||
|
||||
return {
|
||||
"session": emergency_session,
|
||||
"expires_at": emergency_session.expires_at,
|
||||
"reason": reason,
|
||||
"target_user_id": target_user_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def hash_password(plain_credential):
|
||||
"""Hash a credential for storage.
|
||||
|
||||
Args:
|
||||
plain_credential: Plain text credential
|
||||
|
||||
Returns:
|
||||
Hashed credential string
|
||||
"""
|
||||
return bcrypt.generate_password_hash(plain_credential).decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def create_superadmin(email, credential, full_name=None):
|
||||
"""Create a new superadmin.
|
||||
|
||||
Args:
|
||||
email: Superadmin email
|
||||
credential: Plain text credential
|
||||
full_name: Optional full name
|
||||
|
||||
Returns:
|
||||
Superadmin instance
|
||||
"""
|
||||
# Check if email already exists
|
||||
existing = Superadmin.query.filter_by(email=email.lower()).first()
|
||||
if existing:
|
||||
raise ValueError(f"Superadmin with email {email} already exists")
|
||||
|
||||
# Hash credential
|
||||
password_hash = bcrypt.generate_password_hash(credential).decode("utf-8")
|
||||
|
||||
# Create superadmin
|
||||
superadmin = Superadmin(
|
||||
email=email.lower(),
|
||||
password_hash=password_hash,
|
||||
full_name=full_name,
|
||||
is_active=True,
|
||||
)
|
||||
superadmin.save()
|
||||
|
||||
logger.info(f"[SuperadminAuth] Created new superadmin: {email}")
|
||||
return superadmin
|
||||
|
||||
@staticmethod
|
||||
def update_superadmin(superadmin_id, **kwargs):
|
||||
"""Update superadmin details.
|
||||
|
||||
Args:
|
||||
superadmin_id: Superadmin ID
|
||||
**kwargs: Fields to update (email, full_name, is_active, credential)
|
||||
|
||||
Returns:
|
||||
Updated Superadmin instance
|
||||
"""
|
||||
superadmin = Superadmin.query.get(superadmin_id)
|
||||
if not superadmin:
|
||||
raise ValueError(f"Superadmin not found: {superadmin_id}")
|
||||
|
||||
# Handle credential update
|
||||
if 'password' in kwargs:
|
||||
kwargs['password_hash'] = bcrypt.generate_password_hash(kwargs.pop('password')).decode("utf-8")
|
||||
|
||||
# Update fields
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(superadmin, key):
|
||||
setattr(superadmin, key, value)
|
||||
|
||||
superadmin.save()
|
||||
logger.info(f"[SuperadminAuth] Updated superadmin_id={superadmin_id}")
|
||||
return superadmin
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Superadmin organization management service."""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.user.session import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuperadminOrganizationService:
|
||||
"""Service for superadmin organization management operations."""
|
||||
|
||||
@staticmethod
|
||||
def list_organizations(
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
plan_slug: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""List organizations with pagination and filtering.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
per_page: Items per page
|
||||
search: Search by name or slug
|
||||
status: Filter by status (active, suspended)
|
||||
plan_slug: Filter by plan slug (not implemented yet - requires Subscription model)
|
||||
|
||||
Returns:
|
||||
Paginated response with organization summaries
|
||||
"""
|
||||
query = Organization.query
|
||||
|
||||
# Search filter
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Organization.name.ilike(search_term),
|
||||
Organization.slug.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
# Status filter
|
||||
if status == "active":
|
||||
query = query.filter(Organization.is_active.is_(True))
|
||||
elif status == "suspended":
|
||||
query = query.filter(Organization.is_active.is_(False))
|
||||
|
||||
# Note: plan_slug filtering requires Plan/Subscription models (Phase 4)
|
||||
# Currently ignored but parameter is accepted for API compatibility
|
||||
|
||||
# Order by created_at desc
|
||||
query = query.order_by(Organization.created_at.desc())
|
||||
|
||||
# Paginate
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
# Build response
|
||||
items = []
|
||||
for org in pagination.items:
|
||||
item = {
|
||||
"id": org.id,
|
||||
"name": org.name,
|
||||
"slug": org.slug,
|
||||
"description": org.description,
|
||||
"is_active": org.is_active,
|
||||
"member_count": org.get_member_count(),
|
||||
"created_at": org.created_at.isoformat() + "Z" if org.created_at else None,
|
||||
"updated_at": org.updated_at.isoformat() + "Z" if org.updated_at else None,
|
||||
}
|
||||
items.append(item)
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": pagination.total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": pagination.pages,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_organization_detail(org_id: str) -> dict:
|
||||
"""Get detailed organization information.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Organization detail with member_count, owner, stats
|
||||
|
||||
Raises:
|
||||
ValueError: If organization not found
|
||||
"""
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
owner = org.get_owner()
|
||||
|
||||
# Count active sessions for org members
|
||||
member_user_ids = [m.user_id for m in org.members if m.deleted_at is None]
|
||||
active_sessions = Session.query.filter(
|
||||
Session.user_id.in_(member_user_ids),
|
||||
Session.deleted_at.is_(None),
|
||||
).count()
|
||||
|
||||
return {
|
||||
"id": org.id,
|
||||
"name": org.name,
|
||||
"slug": org.slug,
|
||||
"description": org.description,
|
||||
"is_active": org.is_active,
|
||||
"settings": org.settings or {},
|
||||
"member_count": org.get_member_count(),
|
||||
"owner": {
|
||||
"id": owner.id,
|
||||
"email": owner.email,
|
||||
"full_name": owner.full_name,
|
||||
} if owner else None,
|
||||
"active_sessions": active_sessions,
|
||||
"created_at": org.created_at.isoformat() + "Z" if org.created_at else None,
|
||||
"updated_at": org.updated_at.isoformat() + "Z" if org.updated_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_organization(
|
||||
org_id: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> Organization:
|
||||
"""Update organization details.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
name: New name (optional)
|
||||
description: New description (optional)
|
||||
is_active: New active status (optional)
|
||||
|
||||
Returns:
|
||||
Updated organization
|
||||
|
||||
Raises:
|
||||
ValueError: If organization not found
|
||||
"""
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
if name is not None:
|
||||
org.name = name
|
||||
if description is not None:
|
||||
org.description = description
|
||||
if is_active is not None:
|
||||
org.is_active = is_active
|
||||
|
||||
db.session.commit()
|
||||
logger.info(f"[SuperadminOrg] Updated organization {org_id}")
|
||||
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def suspend_organization(org_id: str) -> Organization:
|
||||
"""Suspend an organization.
|
||||
|
||||
Sets is_active=False and invalidates all member sessions.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Suspended organization
|
||||
|
||||
Raises:
|
||||
ValueError: If organization not found
|
||||
"""
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
org.is_active = False
|
||||
|
||||
# Invalidate all member sessions
|
||||
member_user_ids = [m.user_id for m in org.members if m.deleted_at is None]
|
||||
Session.query.filter(
|
||||
Session.user_id.in_(member_user_ids),
|
||||
Session.deleted_at.is_(None),
|
||||
).update({"deleted_at": db.func.now()})
|
||||
|
||||
db.session.commit()
|
||||
logger.warning(f"[SuperadminOrg] Suspended organization {org_id}")
|
||||
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def restore_organization(org_id: str) -> Organization:
|
||||
"""Restore a suspended organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Restored organization
|
||||
|
||||
Raises:
|
||||
ValueError: If organization not found
|
||||
"""
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
org.is_active = True
|
||||
db.session.commit()
|
||||
logger.info(f"[SuperadminOrg] Restored organization {org_id}")
|
||||
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def soft_delete_organization(org_id: str) -> Organization:
|
||||
"""Soft-delete an organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Soft-deleted organization
|
||||
|
||||
Raises:
|
||||
ValueError: If organization not found
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
org.deleted_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
logger.warning(f"[SuperadminOrg] Soft-deleted organization {org_id}")
|
||||
|
||||
return org
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Usage tracking service for superadmin operations."""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UsageMetric:
|
||||
"""Usage metric types."""
|
||||
USERS = "users"
|
||||
SESSIONS = "sessions"
|
||||
ACTIVE_SESSIONS = "active_sessions"
|
||||
API_CALLS = "api_calls"
|
||||
|
||||
|
||||
class SuperadminUsageService:
|
||||
"""Service for tracking and retrieving usage metrics."""
|
||||
|
||||
@staticmethod
|
||||
def get_current_usage(org_id: str) -> dict:
|
||||
"""Get current period usage for an organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Current usage metrics including user count and active sessions
|
||||
"""
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
# Get active member count
|
||||
member_count = org.get_member_count()
|
||||
|
||||
# Get active sessions count
|
||||
member_user_ids = [m.user_id for m in org.members if m.deleted_at is None]
|
||||
active_sessions = Session.query.filter(
|
||||
Session.user_id.in_(member_user_ids),
|
||||
Session.deleted_at.is_(None),
|
||||
Session.status == "active",
|
||||
).count()
|
||||
|
||||
# Get max concurrent sessions this month
|
||||
now = datetime.now(timezone.utc)
|
||||
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# For simplicity, we'll track peak concurrent sessions
|
||||
# In production, you'd want a separate tracking table
|
||||
max_sessions_this_month = active_sessions # Placeholder
|
||||
|
||||
return {
|
||||
"organization_id": org_id,
|
||||
"period_start": period_start.isoformat() + "Z",
|
||||
"period_end": now.isoformat() + "Z",
|
||||
"metrics": {
|
||||
"users": {
|
||||
"current": member_count,
|
||||
"limit": None, # Will come from plan
|
||||
"description": "Total organization members",
|
||||
},
|
||||
"active_sessions": {
|
||||
"current": active_sessions,
|
||||
"max_this_month": max_sessions_this_month,
|
||||
"description": "Currently active user sessions",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_usage_history(
|
||||
org_id: str,
|
||||
metric: str,
|
||||
days: int = 30,
|
||||
) -> dict:
|
||||
"""Get usage history for a specific metric.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
metric: Metric type (users, sessions)
|
||||
days: Number of days of history
|
||||
|
||||
Returns:
|
||||
List of daily usage data points
|
||||
"""
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Get member history (simplified - would need a history table in production)
|
||||
history = []
|
||||
current_count = org.get_member_count()
|
||||
|
||||
# Generate daily data points (placeholder - real implementation needs history table)
|
||||
for i in range(days):
|
||||
date = start_date + timedelta(days=i)
|
||||
history.append({
|
||||
"date": date.strftime("%Y-%m-%d"),
|
||||
"value": current_count, # Simplified
|
||||
})
|
||||
|
||||
return {
|
||||
"organization_id": org_id,
|
||||
"metric": metric,
|
||||
"period_start": start_date.isoformat() + "Z",
|
||||
"period_end": now.isoformat() + "Z",
|
||||
"history": history,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_seat_count_for_period(org_id: str, year: int, month: int) -> dict:
|
||||
"""Calculate maximum seat count used in a given month.
|
||||
|
||||
For billing purposes - tracks the peak number of users.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
year: Year
|
||||
month: Month
|
||||
|
||||
Returns:
|
||||
Seat count data for the period
|
||||
"""
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
# Calculate first and last day of month
|
||||
first_day = datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
if month == 12:
|
||||
last_day = datetime(year + 1, 1, 1, tzinfo=timezone.utc) - timedelta(seconds=1)
|
||||
else:
|
||||
last_day = datetime(year, month + 1, 1, tzinfo=timezone.utc) - timedelta(seconds=1)
|
||||
|
||||
# Get all members that existed during this period
|
||||
members = OrganizationMember.query.filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
# Count unique users who were members at any point during the month
|
||||
max_seats = len(members)
|
||||
|
||||
# Current count at end of month
|
||||
current_seats = len([m for m in members if m.deleted_at is None or m.deleted_at > last_day])
|
||||
|
||||
return {
|
||||
"organization_id": org_id,
|
||||
"period": f"{year}-{month:02d}",
|
||||
"max_seats": max_seats,
|
||||
"current_seats": current_seats,
|
||||
"period_start": first_day.isoformat() + "Z",
|
||||
"period_end": last_day.isoformat() + "Z",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def adjust_usage(
|
||||
org_id: str,
|
||||
metric: str,
|
||||
adjustment: int,
|
||||
reason: str,
|
||||
superadmin_id: str,
|
||||
) -> dict:
|
||||
"""Apply a manual usage adjustment (credit or charge).
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
metric: Metric to adjust
|
||||
adjustment: Positive (credit) or negative (charge)
|
||||
reason: Reason for adjustment
|
||||
superadmin_id: Superadmin making the adjustment
|
||||
|
||||
Returns:
|
||||
Adjustment confirmation
|
||||
"""
|
||||
org = Organization.query.get(org_id)
|
||||
if not org:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
# In production, you'd create a UsageAdjustment record
|
||||
# For now, just log and return
|
||||
logger.warning(
|
||||
f"[SuperadminUsage] Adjustment: org={org_id}, metric={metric}, "
|
||||
f"adjustment={adjustment}, reason={reason}, by={superadmin_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"organization_id": org_id,
|
||||
"metric": metric,
|
||||
"adjustment": adjustment,
|
||||
"reason": reason,
|
||||
"applied_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
"""User management service for superadmin operations."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
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__)
|
||||
|
||||
|
||||
class SuperadminUserService:
|
||||
"""Service for managing users across the platform."""
|
||||
|
||||
@staticmethod
|
||||
def list_users(
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
org_id: str = None,
|
||||
status: str = None,
|
||||
search: str = None,
|
||||
) -> dict:
|
||||
"""List users with filters and pagination.
|
||||
|
||||
Args:
|
||||
page: Page number
|
||||
per_page: Items per page
|
||||
org_id: Filter by organization
|
||||
status: Filter by status (active/suspended)
|
||||
search: Search by email or name
|
||||
|
||||
Returns:
|
||||
Paginated user list with metadata
|
||||
"""
|
||||
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
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
User.email.ilike(search_filter),
|
||||
User.full_name.ilike(search_filter),
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
total = query.count()
|
||||
users = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
items = []
|
||||
for user in users:
|
||||
# Get org memberships
|
||||
memberships = OrganizationMember.query.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,
|
||||
})
|
||||
|
||||
# Get active sessions 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,
|
||||
"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 {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if per_page > 0 else 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_user_detail(user_id: str) -> dict:
|
||||
"""Get detailed user information.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
User detail with orgs, sessions, security methods
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Get org memberships
|
||||
memberships = OrganizationMember.query.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,
|
||||
} for s in sessions]
|
||||
|
||||
# Security methods
|
||||
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 {
|
||||
"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,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def suspend_user(user_id: str) -> dict:
|
||||
"""Globally suspend a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Updated user info and count of revoked sessions
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
if user.status == "GLOBAL_SUSPENDED":
|
||||
raise ValueError("User is already suspended")
|
||||
|
||||
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()
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"status": user.status,
|
||||
},
|
||||
"sessions_revoked": revoked_count,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def unsuspend_user(user_id: str) -> dict:
|
||||
"""Remove global suspension from a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Updated user info
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
if user.status != "GLOBAL_SUSPENDED":
|
||||
raise ValueError("User is not suspended")
|
||||
|
||||
user.status = "active"
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def reset_password(user_id: str) -> dict:
|
||||
"""Trigger password reset for user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Email of user
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# In production, this would call AuthService.send_password_reset_email
|
||||
logger.info(f"[SuperadminUserService] Password reset requested for {user.email}")
|
||||
|
||||
return {"email": user.email}
|
||||
|
||||
@staticmethod
|
||||
def revoke_all_sessions(user_id: str) -> dict:
|
||||
"""Revoke all sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Count of revoked sessions
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
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 {
|
||||
"user_id": user_id,
|
||||
"count": result,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def add_to_org(user_id: str, org_id: str, role: str = "member") -> dict:
|
||||
"""Add a user to an organization.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
org_id: Organization UUID
|
||||
role: Membership role
|
||||
|
||||
Returns:
|
||||
Membership details
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
org = Organization.query.get(org_id)
|
||||
if not org or org.deleted_at is not None:
|
||||
raise ValueError("Organization 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:
|
||||
raise ValueError("User is already a member of this organization")
|
||||
|
||||
membership = OrganizationMember(
|
||||
user_id=user_id,
|
||||
organization_id=org_id,
|
||||
role=role,
|
||||
)
|
||||
db.session.add(membership)
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"organization_id": org_id,
|
||||
"role": role,
|
||||
"joined_at": membership.created_at.isoformat() + "Z" if membership.created_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def remove_from_org(user_id: str, org_id: str) -> dict:
|
||||
"""Remove a user from an organization.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
org_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Confirmation
|
||||
"""
|
||||
membership = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
raise ValueError("User is not a member of this organization")
|
||||
|
||||
# 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:
|
||||
raise ValueError("Cannot remove the only owner from an organization. Transfer ownership first.")
|
||||
|
||||
membership.deleted_at = db.func.now()
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"organization_id": org_id,
|
||||
}
|
||||
Reference in New Issue
Block a user