From 9c4be5d098d3608de4c7e3a2bdd5942aa4fd1cea Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Tue, 1 Apr 2025 02:45:34 -0400 Subject: [PATCH] Feat/education api (#17168) --- api/configs/feature/__init__.py | 5 ++ api/controllers/console/error.py | 12 +++ api/controllers/console/workspace/account.py | 84 +++++++++++++++++++- api/controllers/console/wraps.py | 11 +++ api/services/billing_service.py | 44 +++++++++- api/services/feature_service.py | 8 ++ 6 files changed, 162 insertions(+), 2 deletions(-) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 46ded0244f..fa8e8c2bf6 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -848,6 +848,11 @@ class AccountConfig(BaseSettings): default=5, ) + EDUCATION_ENABLED: bool = Field( + description="whether to enable education identity", + default=False, + ) + class FeatureConfig( # place the configs in alphabet order diff --git a/api/controllers/console/error.py b/api/controllers/console/error.py index bd4ae9dc7f..b8fd1f0358 100644 --- a/api/controllers/console/error.py +++ b/api/controllers/console/error.py @@ -103,6 +103,18 @@ class AccountInFreezeError(BaseHTTPException): ) +class EducationVerifyLimitError(BaseHTTPException): + error_code = "education_verify_limit" + description = "Rate limit exceeded" + code = 429 + + +class EducationActivateLimitError(BaseHTTPException): + error_code = "education_activate_limit" + description = "Rate limit exceeded" + code = 429 + + class CompilanceRateLimitError(BaseHTTPException): error_code = "compilance_rate_limit" description = "Rate limit exceeded for downloading compliance report." diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index f1ec0f3d29..d2cc140489 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -15,7 +15,13 @@ from controllers.console.workspace.error import ( InvalidInvitationCodeError, RepeatPasswordNotMatchError, ) -from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required +from controllers.console.wraps import ( + account_initialization_required, + cloud_edition_billing_enabled, + enterprise_license_required, + only_edition_cloud, + setup_required, +) from extensions.ext_database import db from fields.member_fields import account_fields from libs.helper import TimestampField, timezone @@ -292,6 +298,79 @@ class AccountDeleteUpdateFeedbackApi(Resource): return {"result": "success"} +class EducationVerifyApi(Resource): + verify_fields = { + "token": fields.String, + } + + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @cloud_edition_billing_enabled + @marshal_with(verify_fields) + def get(self): + account = current_user + + return BillingService.EducationIdentity.verify(account.id, account.email) + + +class EducationApi(Resource): + status_fields = { + "result": fields.Boolean, + } + + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @cloud_edition_billing_enabled + def post(self): + account = current_user + + parser = reqparse.RequestParser() + parser.add_argument("token", type=str, required=True, location="json") + parser.add_argument("institution", type=str, required=True, location="json") + parser.add_argument("role", type=str, required=True, location="json") + args = parser.parse_args() + + return BillingService.EducationIdentity.activate(account, args["token"], args["institution"], args["role"]) + + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @cloud_edition_billing_enabled + @marshal_with(status_fields) + def get(self): + account = current_user + + return BillingService.EducationIdentity.is_active(account.id) + + +class EducationAutoCompleteApi(Resource): + data_fields = { + "data": fields.List(fields.String), + "curr_page": fields.Integer, + "has_next": fields.Boolean, + } + + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @cloud_edition_billing_enabled + @marshal_with(data_fields) + def get(self): + parser = reqparse.RequestParser() + parser.add_argument("keywords", type=str, required=True, location="args") + parser.add_argument("page", type=int, required=False, location="args", default=0) + parser.add_argument("limit", type=int, required=False, location="args", default=20) + args = parser.parse_args() + + return BillingService.EducationIdentity.autocomplete(args["keywords"], args["page"], args["limit"]) + + # Register API resources api.add_resource(AccountInitApi, "/account/init") api.add_resource(AccountProfileApi, "/account/profile") @@ -305,5 +384,8 @@ api.add_resource(AccountIntegrateApi, "/account/integrates") api.add_resource(AccountDeleteVerifyApi, "/account/delete/verify") api.add_resource(AccountDeleteApi, "/account/delete") api.add_resource(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback") +api.add_resource(EducationVerifyApi, "/account/education/verify") +api.add_resource(EducationApi, "/account/education") +api.add_resource(EducationAutoCompleteApi, "/account/education/autocomplete") # api.add_resource(AccountEmailApi, '/account/email') # api.add_resource(AccountEmailVerifyApi, '/account/email-verify') diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index ed6e16b035..6caaae87f4 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -54,6 +54,17 @@ def only_edition_self_hosted(view): return decorated +def cloud_edition_billing_enabled(view): + @wraps(view) + def decorated(*args, **kwargs): + features = FeatureService.get_features(current_user.current_tenant_id) + if not features.billing.enabled: + abort(403, "Billing feature is not enabled.") + return view(*args, **kwargs) + + return decorated + + def cloud_edition_billing_resource_check(resource: str): def interceptor(view): @wraps(view) diff --git a/api/services/billing_service.py b/api/services/billing_service.py index ab68aad45a..d44483ad89 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -6,7 +6,7 @@ from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fix from extensions.ext_database import db from libs.helper import RateLimiter -from models.account import TenantAccountJoin, TenantAccountRole +from models.account import Account, TenantAccountJoin, TenantAccountRole class BillingService: @@ -106,6 +106,48 @@ class BillingService: json = {"email": email, "feedback": feedback} return cls._send_request("POST", "/account/delete-feedback", json=json) + class EducationIdentity: + verification_rate_limit = RateLimiter(prefix="edu_verification_rate_limit", max_attempts=10, time_window=60) + activation_rate_limit = RateLimiter(prefix="edu_activation_rate_limit", max_attempts=10, time_window=60) + + @classmethod + def verify(cls, account_id: str, account_email: str): + if cls.verification_rate_limit.is_rate_limited(account_email): + from controllers.console.error import EducationVerifyLimitError + + raise EducationVerifyLimitError() + + cls.verification_rate_limit.increment_rate_limit(account_email) + + params = {"account_id": account_id} + return BillingService._send_request("GET", "/education/verify", params=params) + + @classmethod + def is_active(cls, account_id: str): + params = {"account_id": account_id} + return BillingService._send_request("GET", "/education/status", params=params) + + @classmethod + def activate(cls, account: Account, token: str, institution: str, role: str): + if cls.activation_rate_limit.is_rate_limited(account.email): + from controllers.console.error import EducationActivateLimitError + + raise EducationActivateLimitError() + + cls.activation_rate_limit.increment_rate_limit(account.email) + params = {"account_id": account.id, "curr_tenant_id": account.current_tenant_id} + json = { + "institution": institution, + "token": token, + "role": role, + } + return BillingService._send_request("POST", "/education/", json=json, params=params) + + @classmethod + def autocomplete(cls, keywords: str, page: int = 0, limit: int = 20): + params = {"keywords": keywords, "page": page, "limit": limit} + return BillingService._send_request("GET", "/education/autocomplete", params=params) + @classmethod def get_compliance_download_link( cls, diff --git a/api/services/feature_service.py b/api/services/feature_service.py index add34d9f43..c2226c319f 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -17,6 +17,11 @@ class BillingModel(BaseModel): subscription: SubscriptionModel = SubscriptionModel() +class EducationModel(BaseModel): + enabled: bool = False + activated: bool = False + + class LimitationModel(BaseModel): size: int = 0 limit: int = 0 @@ -38,6 +43,7 @@ class LicenseModel(BaseModel): class FeatureModel(BaseModel): billing: BillingModel = BillingModel() + education: EducationModel = EducationModel() members: LimitationModel = LimitationModel(size=0, limit=1) apps: LimitationModel = LimitationModel(size=0, limit=10) vector_space: LimitationModel = LimitationModel(size=0, limit=5) @@ -128,6 +134,7 @@ class FeatureService: features.can_replace_logo = dify_config.CAN_REPLACE_LOGO features.model_load_balancing_enabled = dify_config.MODEL_LB_ENABLED features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED + features.education.enabled = dify_config.EDUCATION_ENABLED @classmethod def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str): @@ -136,6 +143,7 @@ class FeatureService: features.billing.enabled = billing_info["enabled"] features.billing.subscription.plan = billing_info["subscription"]["plan"] features.billing.subscription.interval = billing_info["subscription"]["interval"] + features.education.activated = billing_info["subscription"].get("education", False) if "members" in billing_info: features.members.size = billing_info["members"]["size"]