Files
plugins/openedx-tenant-api/openedx_tenant_api/views.py
DamarKusumo 2b7027e37d Add openedx-tenant-api plugin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:20:57 +07:00

521 lines
20 KiB
Python

import logging
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.authentication import SessionAuthentication, BasicAuthentication
from django.db import transaction
from eox_tenant.models import TenantConfig, Route
from .models import TenantProvisioningLog
from .serializers import CreateTenantSerializer, TenantAdminCreateSerializer
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.conf import settings
User = get_user_model()
def _strip_ports_from_sources(sources):
"""
Strip port numbers from EDNX_ACCOUNT_REGISTRATION_SOURCES entries.
eox_tenant.auth.TenantAwareAuthBackend uses re.match(pattern+"$", site) to check
UserSignupSource.site against authorized_sources. UserSignupSource.site stores the bare
domain (no port), but the config was previously generated with ports like
"tenant.local.openedx.io:8000". These patterns would never match because
re.match("tenant.local.openedx.io:8000$", "tenant.local.openedx.io") fails
(extra ":8000" in pattern).
This strips ":PORT" suffixes so patterns become bare domains that match
UserSignupSource.site values correctly.
"""
if not sources:
return sources
return [src.split(':')[0] for src in sources]
class CsrfExemptSessionAuthentication(SessionAuthentication):
"""Session auth that doesn't require CSRF for API requests."""
def enforce_csrf(self, request):
return
# Default auth classes for API views
API_AUTH_CLASSES = [CsrfExemptSessionAuthentication, BasicAuthentication]
logger = logging.getLogger(__name__)
def generate_mfe_config(tenant_name, platform_name, theme_name, org_filter=None,
ednx_tenant_restrict_users=True,
ednx_tenant_user_filter_enabled=True,
ednx_use_signal=True,
ednx_account_registration_sources=None):
"""Generate complete MFE configuration for a tenant."""
lms_domain = f"{tenant_name}.local.openedx.io"
apps_domain = f"{tenant_name}.apps.local.openedx.io"
lms_url = f"http://{lms_domain}:8000"
apps_base = f"http://{apps_domain}"
# CORS and CSRF origins for all MFE ports
# IMPORTANT: Must include apps_domain (MFE subdomains) not just lms_domain
mfe_origins = [
f"http://{lms_domain}:8000",
f"http://{apps_domain}:1999",
f"http://{apps_domain}:1996",
f"http://{apps_domain}:1997",
f"http://{apps_domain}:1995",
f"http://{apps_domain}:2000",
f"http://{apps_domain}:2001",
f"http://{apps_domain}:2002",
# Also include main domain variants for completeness
f"http://{lms_domain}:1999",
f"http://{lms_domain}:1996",
f"http://{lms_domain}:1997",
f"http://{lms_domain}:1995",
f"http://{lms_domain}:2000",
f"http://{lms_domain}:2001",
f"http://{lms_domain}:2002",
]
config = {
# Base configuration
"LMS_BASE": f"{lms_domain}:8000",
"SITE_NAME": lms_domain,
"PLATFORM_NAME": platform_name or f"{tenant_name.title()} Learning",
"LMS_ROOT_URL": lms_url,
# Theme configuration - CRITICAL for LMS theming
"THEME_NAME": theme_name,
"DEFAULT_SITE_THEME": theme_name,
"ENABLE_COMPREHENSIVE_THEMING": True,
# MFE Base URLs (use main domain)
"BASE_URL": lms_domain,
"LMS_BASE_URL": lms_url,
"MFE_HOST": f"{lms_domain}:8000",
# Auth URLs
"LOGIN_URL": f"{lms_url}/login",
"LOGOUT_URL": f"{lms_url}/logout",
"REFRESH_ACCESS_TOKEN_ENDPOINT": f"{lms_url}/login_refresh",
# Branding
"LOGO_URL": f"{lms_url}/theming/asset/images/logo.png",
"LOGO_TRADEMARK_URL": f"{lms_url}/theming/asset/images/logo.png",
"LOGO_WHITE_URL": f"{lms_url}/theming/asset/images/logo.png",
"FAVICON_URL": f"{lms_url}/favicon.ico",
# MFE URLs - Critical for proper redirects (use apps subdomain since MFEs are only accessible there)
"LEARNER_HOME_MICROFRONTEND_URL": f"http://{apps_domain}:1996/learner-dashboard/",
"ACCOUNT_MICROFRONTEND_URL": f"http://{apps_domain}:1997/account/",
"ACCOUNT_SETTINGS_URL": f"http://{apps_domain}:1997/account/",
"PROFILE_MICROFRONTEND_URL": f"http://{apps_domain}:1995/profile/u/",
"AUTHN_MICROFRONTEND_URL": f"http://{apps_domain}:1999/authn",
"AUTHN_MICROFRONTEND_DOMAIN": f"{apps_domain}:1999/authn",
"LEARNING_MICROFRONTEND_URL": f"http://{apps_domain}:2000/learning",
"LEARNING_BASE_URL": f"http://{apps_domain}:2000/learning",
"COURSE_AUTHORING_MICROFRONTEND_URL": f"http://{apps_domain}:2001/authoring",
"DISCUSSIONS_MICROFRONTEND_URL": f"http://{apps_domain}:2002/discussions",
"DISCUSSIONS_MFE_BASE_URL": f"http://{apps_domain}:2002/discussions",
"WRITABLE_GRADEBOOK_URL": f"http://{apps_domain}:1994/gradebook",
"COMMUNICATIONS_MICROFRONTEND_URL": f"http://{apps_domain}:1984/communications",
"ORA_GRADING_MICROFRONTEND_URL": f"http://{apps_domain}:1993/ora-grading",
"ADMIN_CONSOLE_MICROFRONTEND_URL": f"http://{apps_domain}:2025/admin-console",
"ADMIN_CONSOLE_URL": f"http://{apps_domain}:2025/admin-console",
"ACCOUNT_PROFILE_URL": f"http://{apps_domain}:1995/profile",
# MFE Config Object - Required for MFEs to load configuration
# Note: Authn MFE is only accessible via apps subdomain
"MFE_CONFIG": {
"BASE_URL": lms_domain,
"LMS_BASE_URL": lms_url,
"LOGIN_URL": f"http://{apps_domain}:1999/authn/login",
"LOGOUT_URL": f"http://{apps_domain}:1999/logout",
"LOGIN_REDIRECT_URL": f"http://{apps_domain}:1999/authn/login",
"REFRESH_ACCESS_TOKEN_ENDPOINT": f"{lms_url}/login_refresh",
"SITE_NAME": platform_name or f"{tenant_name.title()} Learning",
"ACCOUNT_SETTINGS_URL": f"http://{apps_domain}:1997/account/",
"ACCOUNT_PROFILE_URL": f"http://{apps_domain}:1995/profile",
"LEARNING_BASE_URL": f"http://{apps_domain}:2000/learning",
"COURSE_AUTHORING_MICROFRONTEND_URL": f"http://{apps_domain}:2001/authoring",
"DISCUSSIONS_MFE_BASE_URL": f"http://{apps_domain}:2002/discussions",
"STUDIO_BASE_URL": "http://studio.local.openedx.io:8001",
},
# CORS Configuration - Required for MFEs to communicate with LMS
"CORS_ALLOW_CREDENTIALS": True,
"CORS_ORIGIN_WHITELIST": mfe_origins,
"CSRF_TRUSTED_ORIGINS": mfe_origins,
"CSRF_COOKIE_DOMAIN": lms_domain,
"CSRF_COOKIE_SAMESITE": "Lax",
"CSRF_COOKIE_SECURE": not settings.DEBUG,
# Feature flags
"ENABLE_AUTHN_MICROFRONTEND": True,
"ENABLE_LEARNING_MICROFRONTEND": True,
"ENABLE_PROFILE_MICROFRONTEND": True,
"ENABLE_DISCUSSIONS_MFE": True,
"ENABLE_LEARNER_HOME_MFE": True,
"ENABLE_DYNAMIC_REGISTRATION": True,
# Organization filter
"course_org_filter": org_filter or [],
# EDNX settings for tenant isolation
"EDNX_TENANT_RESTRICT_USERS": ednx_tenant_restrict_users,
"EDNX_TENANT_USER_FILTER_ENABLED": ednx_tenant_user_filter_enabled,
"EDNX_USE_SIGNAL": ednx_use_signal,
# Include both LMS domain and apps domain for MFE login.
# NOTE: UserSignupSource.site stores the bare domain (no port), so patterns
# must NOT include :8000 or :1999. eox_tenant auth uses re.match(pattern+"$", site)
# which would fail to match "mondaytest.local.openedx.io" against "mondaytest.local.openedx.io:8000$".
"EDNX_ACCOUNT_REGISTRATION_SOURCES": ednx_account_registration_sources or [
lms_domain,
f"{tenant_name}.apps.local.openedx.io"
],
# Session cookie configuration for tenant isolation
# Use wildcard domain (.local.openedx.io) to allow session sharing across subdomains
# e.g., mondaytest.local.openedx.io and studio.mondaytest.local.openedx.io
"SESSION_COOKIE_NAME": f"sessionid_{tenant_name.replace('-', '')}",
"SESSION_COOKIE_DOMAIN": ".local.openedx.io", # Wildcard for all subdomains
"SESSION_COOKIE_SAMESITE": "Lax",
"SESSION_COOKIE_SECURE": not settings.DEBUG,
}
return config
@api_view(['POST'])
@authentication_classes(API_AUTH_CLASSES)
@permission_classes([IsAdminUser])
def create_tenant(request):
"""
Create a new tenant with full MFE configuration.
POST /api/tenant/v1/create
Request Body:
{
"tenant_name": "talent2",
"platform_name": "Talent 2 Learning",
"theme_name": "indigo",
"org_filter": ["org1", "org2"]
}
"""
serializer = CreateTenantSerializer(data=request.data)
if not serializer.is_valid():
return Response({
'status': 'error',
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
data = serializer.validated_data
tenant_name = data['tenant_name']
platform_name = data.get('platform_name', f"{tenant_name.title()} Learning")
theme_name = data.get('theme_name', 'indigo')
org_filter = data.get('org_filter', [])
# Extract EDNX settings (with defaults)
ednx_tenant_restrict_users = data.get('ednx_tenant_restrict_users', True)
ednx_tenant_user_filter_enabled = data.get('ednx_tenant_user_filter_enabled', True)
ednx_use_signal = data.get('ednx_use_signal', True)
ednx_account_registration_sources = data.get('ednx_account_registration_sources', None)
external_key = f"{tenant_name}.local.openedx.io"
lms_domain = f"{tenant_name}.local.openedx.io"
apps_domain = f"{tenant_name}.apps.local.openedx.io"
# Check if tenant already exists
if TenantConfig.objects.filter(external_key=external_key).exists():
return Response({
'status': 'error',
'message': f"Tenant '{tenant_name}' already exists"
}, status=status.HTTP_409_CONFLICT)
if Route.objects.filter(domain=lms_domain).exists():
return Response({
'status': 'error',
'message': f"Route for domain '{lms_domain}' already exists"
}, status=status.HTTP_409_CONFLICT)
log_entry = None
try:
with transaction.atomic():
# Generate MFE configuration with EDNX settings
lms_configs = generate_mfe_config(
tenant_name, platform_name, theme_name, org_filter,
ednx_tenant_restrict_users=ednx_tenant_restrict_users,
ednx_tenant_user_filter_enabled=ednx_tenant_user_filter_enabled,
ednx_use_signal=ednx_use_signal,
ednx_account_registration_sources=ednx_account_registration_sources
)
# Create TenantConfig
tenant_config = TenantConfig.objects.create(
external_key=external_key,
lms_configs=lms_configs,
studio_configs={
"PLATFORM_NAME": platform_name,
"SITE_NAME": f"studio.{lms_domain}",
},
theming_configs={
"THEME_NAME": theme_name,
},
meta={
"created_via": "tenant_api",
"created_by": request.user.username,
}
)
# Create Route for LMS
Route.objects.create(
domain=lms_domain,
config=tenant_config
)
# Create audit log
log_entry = TenantProvisioningLog.objects.create(
tenant_name=tenant_name,
external_key=external_key,
created_by=request.user,
status='success',
tenant_config_id=tenant_config.id
)
logger.info(f"Tenant '{tenant_name}' created successfully by {request.user.username}")
return Response({
'status': 'success',
'tenant_id': tenant_config.id,
'tenant_name': tenant_name,
'lms_url': f"http://{lms_domain}:8000",
'authn_url': f"http://{apps_domain}:1999/authn",
'learner_dashboard_url': f"http://{apps_domain}:1996/learner-dashboard/",
'message': 'Tenant created successfully. Add DNS entries to your hosts file.'
})
except Exception as e:
logger.exception(f"Failed to create tenant '{tenant_name}'")
# Create failure log
if log_entry is None:
TenantProvisioningLog.objects.create(
tenant_name=tenant_name,
external_key=external_key,
created_by=request.user,
status='failed',
error_message=str(e)
)
else:
log_entry.status = 'failed'
log_entry.error_message = str(e)
log_entry.save()
return Response({
'status': 'error',
'message': 'An internal error occurred while creating the tenant'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@authentication_classes(API_AUTH_CLASSES)
@permission_classes([IsAdminUser])
def list_tenants(request):
"""
List all tenants created via API.
GET /api/tenant/v1/list
"""
logs = TenantProvisioningLog.objects.filter(status='success').select_related('created_by')
data = []
for log in logs:
data.append({
'tenant_name': log.tenant_name,
'external_key': log.external_key,
'created_at': log.created_at.isoformat(),
'created_by': log.created_by.username,
'tenant_config_id': log.tenant_config_id,
})
return Response({
'count': len(data),
'tenants': data
})
@api_view(['DELETE'])
@authentication_classes(API_AUTH_CLASSES)
@permission_classes([IsAdminUser])
def delete_tenant(request, tenant_name):
"""
Delete a tenant.
DELETE /api/tenant/v1/delete/{tenant_name}
"""
try:
external_key = f"{tenant_name}.local.openedx.io"
tenant_config = TenantConfig.objects.get(external_key=external_key)
# Delete associated routes
Route.objects.filter(config=tenant_config).delete()
# Delete tenant config
tenant_config.delete()
# Update log
TenantProvisioningLog.objects.filter(tenant_name=tenant_name).update(
status='deleted'
)
logger.info(f"Tenant '{tenant_name}' deleted by {request.user.username}")
return Response({
'status': 'success',
'message': f"Tenant '{tenant_name}' deleted successfully"
})
except TenantConfig.DoesNotExist:
return Response({
'status': 'error',
'message': f"Tenant '{tenant_name}' not found"
}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
logger.exception(f"Failed to delete tenant '{tenant_name}'")
return Response({
'status': 'error',
'message': 'An internal error occurred while deleting the tenant'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
def health_check(request):
"""
Health check endpoint (no auth required).
GET /api/tenant/v1/health
"""
try:
# Check eox-tenant is installed
from eox_tenant.models import TenantConfig
tenant_count = TenantConfig.objects.count()
return Response({
'status': 'healthy',
'eox_tenant_installed': True,
'tenant_count': tenant_count
})
except ImportError:
return Response({
'status': 'unhealthy',
'eox_tenant_installed': False,
'error': 'eox-tenant not installed'
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
@api_view(['POST'])
@authentication_classes(API_AUTH_CLASSES)
@permission_classes([IsAdminUser])
def create_tenant_admin(request):
"""
Create a new admin user for a tenant with proper Open edX roles.
POST /api/tenant/v1/admin/create
Request Body:
{
"tenant_name": "acmecorp",
"username": "acme_admin",
"email": "admin@acme.com",
"password": "secure_password123",
"org_name": "acme"
}
"""
# Check if user is superuser
if not request.user.is_superuser:
return Response({
'status': 'error',
'message': 'Only superusers can create tenant admins'
}, status=status.HTTP_403_FORBIDDEN)
serializer = TenantAdminCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response({
'status': 'error',
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
data = serializer.validated_data
tenant_name = data['tenant_name']
username = data['username']
email = data['email']
password = data['password']
org_name = data['org_name']
try:
with transaction.atomic():
# Create the user
user = User.objects.create_user(
username=username,
email=email,
password=password,
is_staff=True
)
# Create user profile - required for login to work
from common.djangoapps.student.models import UserProfile
UserProfile.objects.create(
user=user,
name=username.title()
)
# Import roles from student app
from common.djangoapps.student.roles import OrgStaffRole, CourseCreatorRole
# Assign OrgStaffRole for the organization
OrgStaffRole(org_name).add_users(user)
# Assign CourseCreatorRole
CourseCreatorRole().add_users(user)
# Create UserSignupSource record — REQUIRED for eox_tenant's TenantAwareAuthBackend
# to allow login. The auth backend checks usersignupsource_set.all() and denies login
# if the user has no matching signup source for the current tenant domain.
# Without this, login returns 400 "User not authorized to perform this action".
from common.djangoapps.student.models import UserSignupSource
lms_domain = f"{tenant_name}.local.openedx.io"
UserSignupSource.objects.create(
user=user,
site=lms_domain,
)
# Log the action
logger.info(
f"Tenant admin created: {username} for tenant {tenant_name} "
f"by {request.user.username}"
)
return Response({
'status': 'success',
'message': f"Admin user '{username}' created successfully for tenant '{tenant_name}'",
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'tenant': tenant_name,
'org': org_name,
'roles': ['OrgStaffRole', 'CourseCreatorRole']
}
})
except Exception as e:
logger.exception(f"Failed to create tenant admin '{username}'")
return Response({
'status': 'error',
'message': 'An internal error occurred while creating the tenant admin'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)