mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
refactor(api): decouple onboarding and notification services (#40759)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: hjlarry <hjlarry@163.com>
This commit is contained in:
parent
c6ca395042
commit
d25e2b51fc
@ -1,56 +1,16 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import TypedDict
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
only_edition_cloud,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
|
||||
# Notification content is stored under three lang tags.
|
||||
_FALLBACK_LANG = "en-US"
|
||||
|
||||
|
||||
class NotificationLangContent(TypedDict, total=False):
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
titlePicUrl: str
|
||||
|
||||
|
||||
class NotificationItemDict(TypedDict):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class NotificationResponseDict(TypedDict):
|
||||
should_show: bool
|
||||
notifications: list[NotificationItemDict]
|
||||
|
||||
|
||||
def _pick_lang_content(contents: Mapping[str, NotificationLangContent], lang: str) -> NotificationLangContent:
|
||||
"""Return the single LangContent for *lang*, falling back to English."""
|
||||
return (
|
||||
contents.get(lang) or contents.get(_FALLBACK_LANG) or next(iter(contents.values()), NotificationLangContent())
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
|
||||
|
||||
class DismissNotificationPayload(BaseModel):
|
||||
@ -92,39 +52,10 @@ class NotificationApi(Resource):
|
||||
},
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_user
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
def get(self, current_user: Account):
|
||||
result = BillingService.get_account_notification(str(current_user.id))
|
||||
|
||||
# Proto JSON uses camelCase field names (Kratos default marshaling).
|
||||
response: NotificationResponseDict
|
||||
if not result.get("shouldShow"):
|
||||
response = {"should_show": False, "notifications": []}
|
||||
return response, 200
|
||||
|
||||
lang = current_user.interface_language or _FALLBACK_LANG
|
||||
|
||||
notifications: list[NotificationItemDict] = []
|
||||
for notification in result.get("notifications") or []:
|
||||
contents: Mapping[str, NotificationLangContent] = notification.get("contents") or {}
|
||||
lang_content = _pick_lang_content(contents, lang)
|
||||
item: NotificationItemDict = {
|
||||
"notification_id": notification.get("notificationId"),
|
||||
"frequency": notification.get("frequency"),
|
||||
"lang": lang_content.get("lang", lang),
|
||||
"title": lang_content.get("title", ""),
|
||||
"subtitle": lang_content.get("subtitle", ""),
|
||||
"body": lang_content.get("body", ""),
|
||||
"title_pic_url": lang_content.get("titlePicUrl", ""),
|
||||
}
|
||||
notifications.append(item)
|
||||
|
||||
response = {"should_show": bool(notifications), "notifications": notifications}
|
||||
return response, 200
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def get(self, request_context: RequestContext):
|
||||
result = application_services().notifications.get_active(request_context)
|
||||
return dump_response(NotificationResponse, result), 200
|
||||
|
||||
|
||||
@console_ns.route("/notification/dismiss")
|
||||
@ -134,17 +65,10 @@ class NotificationDismissApi(Resource):
|
||||
description="Mark a notification as dismissed for the current user.",
|
||||
responses={200: "Success", 401: "Unauthorized"},
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_user
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
@console_ns.expect(console_ns.models[DismissNotificationPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@model_validate(DismissNotificationPayload)
|
||||
def post(self, payload: DismissNotificationPayload, current_user: Account):
|
||||
BillingService.dismiss_notification(
|
||||
notification_id=payload.notification_id,
|
||||
account_id=str(current_user.id),
|
||||
)
|
||||
return {"result": "success"}, 200
|
||||
def post(self, payload: DismissNotificationPayload, request_context: RequestContext):
|
||||
application_services().notifications.dismiss(request_context, payload.notification_id)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
@ -7,36 +7,20 @@ action-based so callers do not replace server-side arrays with stale snapshots.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.onboarding_entities import StepByStepTourAction, StepByStepTourPatch, StepByStepTourTaskId
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
|
||||
StepByStepTourAction = Literal[
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
]
|
||||
StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"]
|
||||
|
||||
|
||||
class StepByStepTourStatePatchPayload(BaseModel):
|
||||
@ -74,39 +58,22 @@ class StepByStepTourStateApi(Resource):
|
||||
@console_ns.doc("get_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Get account-level Step-by-step Tour state")
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.get_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
session=db.session,
|
||||
),
|
||||
application_services().step_by_step_tour.get_state(request_context),
|
||||
)
|
||||
|
||||
@console_ns.doc("patch_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Update account-level Step-by-step Tour state")
|
||||
@console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(StepByStepTourStatePatchPayload)
|
||||
def patch(self, req_data: StepByStepTourStatePatchPayload, current_tenant_id: str, current_user: Account):
|
||||
patch = cast(StepByStepTourPatch, req_data.model_dump(exclude_unset=True, exclude_none=True))
|
||||
def patch(self, req_data: StepByStepTourStatePatchPayload, request_context: RequestContext):
|
||||
patch = StepByStepTourPatch(action=req_data.action, task_id=req_data.task_id)
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.patch_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
patch=patch,
|
||||
session=db.session,
|
||||
),
|
||||
application_services().step_by_step_tour.patch_state(request_context, patch),
|
||||
)
|
||||
|
||||
@ -76,6 +76,10 @@ def _schema_markdown_type(schema: object) -> str:
|
||||
item_type = _schema_markdown_type(schema.get("items"))
|
||||
return f"[ {item_type or 'object'} ]"
|
||||
if isinstance(schema_type, str):
|
||||
enum_values = schema.get("enum")
|
||||
if isinstance(enum_values, list) and enum_values:
|
||||
rendered_values = ", ".join(json.dumps(value, ensure_ascii=False) for value in enum_values)
|
||||
return f"{schema_type}, <br>**Available values:** {rendered_values}"
|
||||
return schema_type
|
||||
|
||||
return ""
|
||||
|
||||
@ -31,6 +31,7 @@ from repositories.factory import DifyAPIRepositoryFactory
|
||||
from repositories.installation_state_repository import InstallationStateRepository
|
||||
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
|
||||
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
|
||||
from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourStateRepository
|
||||
from repositories.tag_repository import TagRepository
|
||||
from repositories.trial_app_query_repository import TrialAppQueryRepository
|
||||
from repositories.trial_app_usage_repository import TrialAppUsageRepository
|
||||
@ -104,6 +105,8 @@ from services.feature_service import FeatureService
|
||||
from services.feature_service_gateway import FeatureServiceGateway
|
||||
from services.file_service import FileService
|
||||
from services.init_validation_service import InitValidationService
|
||||
from services.notification_gateway import BillingNotificationGateway
|
||||
from services.notification_service import NotificationService
|
||||
from services.notion_data_source_gateway import NotionDataSourceGateway
|
||||
from services.oauth_server_service import OAUTH_ACCESS_TOKEN_EXPIRES_IN, OAuthServerService
|
||||
from services.partner_tenant_binding_service import PartnerTenantBindingService
|
||||
@ -122,6 +125,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi
|
||||
from services.schema_definition_service import SchemaDefinitionService
|
||||
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
|
||||
from services.setup_service import SetupService
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
from services.tag_application_service import TagApplicationService
|
||||
from services.trial_app_usage import TrialAppUsageRecorder
|
||||
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
|
||||
@ -188,6 +192,8 @@ class ApplicationServices:
|
||||
feature_queries: FeatureQueryService
|
||||
oauth_server: OAuthServerService
|
||||
init_validation: InitValidationService
|
||||
notifications: NotificationService
|
||||
step_by_step_tour: StepByStepTourService
|
||||
partner_tenant_bindings: PartnerTenantBindingService
|
||||
recommended_app_queries: RecommendedAppQueryService
|
||||
trial_app_usage: TrialAppUsageRecorder
|
||||
@ -434,6 +440,16 @@ def build_application_services(
|
||||
validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)),
|
||||
expected_password=initialization_password,
|
||||
),
|
||||
notifications=NotificationService(
|
||||
accounts=accounts,
|
||||
notifications=BillingNotificationGateway(),
|
||||
),
|
||||
step_by_step_tour=StepByStepTourService(
|
||||
accounts=accounts,
|
||||
states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client),
|
||||
enabled=dify_config.ENABLE_STEP_BY_STEP_TOUR,
|
||||
rollout_started_at=dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT,
|
||||
),
|
||||
partner_tenant_bindings=PartnerTenantBindingService(
|
||||
sync_bindings=BillingService.sync_partner_tenants_bindings,
|
||||
),
|
||||
|
||||
@ -13501,7 +13501,7 @@ default (the config form sends the full desired feature state on save).
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow", <br>**Default:** all | App mode filter<br>*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No |
|
||||
| name | string | Filter by app name | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number (1-99999) | No |
|
||||
| publication_status | string | Filter by published or draft Agent configuration status | No |
|
||||
| publication_status | string, <br>**Available values:** "drafts", "published" | Filter by published or draft Agent configuration status | No |
|
||||
| sort_by | string, <br>**Available values:** "earliest_created", "last_modified", "recently_created", <br>**Default:** last_modified | Sort apps by last modified, recently created, or earliest created<br>*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No |
|
||||
| tag_ids | [ string ] | Filter by tag IDs | No |
|
||||
|
||||
@ -15744,7 +15744,7 @@ AppMCPServer Status Enum
|
||||
| copyright | string | | No |
|
||||
| custom_disclaimer | string | | No |
|
||||
| customize_domain | string | | No |
|
||||
| customize_token_strategy | string | | No |
|
||||
| customize_token_strategy | string, <br>**Available values:** "allow", "must", "not_allow" | | No |
|
||||
| default_language | string | | No |
|
||||
| description | string | | No |
|
||||
| icon | string | | No |
|
||||
@ -16202,7 +16202,7 @@ TEAM: Team collaboration paid plan
|
||||
| files | [ object ] | | No |
|
||||
| inputs | object | | Yes |
|
||||
| query | string | | No |
|
||||
| response_mode | string | | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | | No |
|
||||
| retriever_from | string, <br>**Default:** explore_app | | No |
|
||||
|
||||
#### CompletionMessagePayload
|
||||
@ -16223,7 +16223,7 @@ TEAM: Team collaboration paid plan
|
||||
| files | [ object ] | | No |
|
||||
| inputs | object | | Yes |
|
||||
| query | string | | No |
|
||||
| response_mode | string | | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | | No |
|
||||
| retriever_from | string, <br>**Default:** explore_app | | No |
|
||||
|
||||
#### ComplianceDownloadQuery
|
||||
@ -18263,9 +18263,9 @@ Flask blueprint initialization.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| end_date | string | End date (YYYY-MM-DD) | No |
|
||||
| format | string, <br>**Available values:** "csv", "json", <br>**Default:** csv | Export format<br>*Enum:* `"csv"`, `"json"` | No |
|
||||
| from_source | string | Filter by feedback source | No |
|
||||
| from_source | string, <br>**Available values:** "admin", "user" | Filter by feedback source | No |
|
||||
| has_comment | boolean | Only include feedback with comments | No |
|
||||
| rating | string | Filter by rating | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Filter by rating | No |
|
||||
| start_date | string | Start date (YYYY-MM-DD) | No |
|
||||
|
||||
#### FeedbackStat
|
||||
@ -18663,7 +18663,7 @@ Icon information model.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_type | string, <br>**Available values:** "emoji", "image" | | No |
|
||||
| icon_url | string | | No |
|
||||
|
||||
#### IconType
|
||||
@ -19245,7 +19245,7 @@ Enum class for large language model mode.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| message_id | string | Message ID | Yes |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFile
|
||||
|
||||
@ -19306,7 +19306,7 @@ Metadata Filtering Condition.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No |
|
||||
| logical_operator | string | How to combine multiple conditions. | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or" | How to combine multiple conditions. | No |
|
||||
|
||||
#### MetadataOperationData
|
||||
|
||||
@ -19439,7 +19439,7 @@ Enum class for model property key.
|
||||
| is_exhausted | boolean | | Yes |
|
||||
| is_unlimited | boolean | | Yes |
|
||||
| next_credit_reset_date | integer | | Yes |
|
||||
| pool_type | string | | Yes |
|
||||
| pool_type | string, <br>**Available values:** "paid", "trial" | | Yes |
|
||||
| quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes |
|
||||
| quota_used | integer | | Yes |
|
||||
| remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes |
|
||||
@ -21434,7 +21434,7 @@ Model class for provider quota configuration.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No |
|
||||
| reranking_enable | boolean | Whether reranking is enabled. | Yes |
|
||||
| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_mode | string, <br>**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No |
|
||||
| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No |
|
||||
| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes |
|
||||
@ -21488,7 +21488,7 @@ Model class for provider quota configuration.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| parent_mode | string | Parent-child segmentation mode. | No |
|
||||
| parent_mode | string, <br>**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No |
|
||||
| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No |
|
||||
| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No |
|
||||
| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No |
|
||||
@ -22477,7 +22477,7 @@ Query parameters for listing snippet published workflows.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| action | string, <br>**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action<br>*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes |
|
||||
| task_id | string | Task ID for task actions | No |
|
||||
| task_id | string, <br>**Available values:** "home", "integration", "knowledge", "studio" | Task ID for task actions | No |
|
||||
|
||||
#### StepByStepTourStateResponse
|
||||
|
||||
@ -22943,7 +22943,7 @@ Tool label
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| visibility | string | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
|
||||
| visibility | string, <br>**Available values:** "all_team_members", "only_me" | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
|
||||
|
||||
#### ToolOAuthCustomClientPayload
|
||||
|
||||
@ -23075,7 +23075,7 @@ removes TOOLS_SELECTOR from PluginParameterType
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| type | string | | No |
|
||||
| type | string, <br>**Available values:** "api", "builtin", "mcp", "model", "workflow" | | No |
|
||||
|
||||
#### ToolProviderListResponse
|
||||
|
||||
@ -23693,7 +23693,7 @@ in form definition, or a variable while the workflow is running.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No |
|
||||
| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No |
|
||||
| weight_type | string | Strategy for balancing semantic and keyword search weights. | No |
|
||||
| weight_type | string, <br>**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No |
|
||||
|
||||
#### WeightVectorSetting
|
||||
|
||||
@ -24199,7 +24199,7 @@ can reuse its existing handler.
|
||||
| description | string | | No |
|
||||
| event | string | | No |
|
||||
| icon | string | | No |
|
||||
| mode | string | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| nodes | [ [WorkflowPlanNodeResponse](#workflowplannoderesponse) ] | | Yes |
|
||||
| start_inputs | [ [WorkflowPlanStartInputResponse](#workflowplanstartinputresponse) ] | | No |
|
||||
| title | string | | No |
|
||||
@ -24214,7 +24214,7 @@ can reuse its existing handler.
|
||||
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
|
||||
| icon | string | | No |
|
||||
| message | string | | No |
|
||||
| mode | string | | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | | No |
|
||||
|
||||
#### WorkflowGenerateResultEventResponse
|
||||
|
||||
@ -24227,7 +24227,7 @@ can reuse its existing handler.
|
||||
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
|
||||
| icon | string | | No |
|
||||
| message | string | | No |
|
||||
| mode | string | | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | | No |
|
||||
|
||||
#### WorkflowGenerateStreamEventResponse
|
||||
|
||||
@ -24527,9 +24527,9 @@ Lifecycle state for an asynchronous archive download request.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| status | string | Workflow run status filter | No |
|
||||
| status | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No |
|
||||
| time_range | string | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No |
|
||||
| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
| triggered_from | string, <br>**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
|
||||
#### WorkflowRunCountResponse
|
||||
|
||||
@ -24601,8 +24601,8 @@ Lifecycle state for an asynchronous archive download request.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| last_id | string | Last run ID for pagination | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page (1-100) | No |
|
||||
| status | string | Workflow run status filter | No |
|
||||
| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
| status | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No |
|
||||
| triggered_from | string, <br>**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
|
||||
#### WorkflowRunNodeExecutionListResponse
|
||||
|
||||
@ -24900,7 +24900,7 @@ Workflow tool configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string | Localized policy label language | No |
|
||||
| language | string, <br>**Available values:** "en", "ja", "zh" | Localized policy label language | No |
|
||||
|
||||
#### _AccessPolicyList
|
||||
|
||||
@ -24959,7 +24959,7 @@ Workflow tool configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string | Localized policy label language | No |
|
||||
| language | string, <br>**Available values:** "en", "ja", "zh" | Localized policy label language | No |
|
||||
| limit | integer | | No |
|
||||
| page | integer | | No |
|
||||
| reverse | boolean | | No |
|
||||
|
||||
@ -2587,7 +2587,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or question content. | Yes |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No |
|
||||
|
||||
#### ChatRequestPayloadWithUser
|
||||
@ -2599,7 +2599,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or question content. | Yes |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No |
|
||||
|
||||
@ -2672,7 +2672,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or prompt content. | No |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
|
||||
#### CompletionRequestPayloadWithUser
|
||||
|
||||
@ -2681,7 +2681,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or prompt content. | No |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### Condition
|
||||
@ -2797,7 +2797,7 @@ Enum class for custom configuration status.
|
||||
| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| external_knowledge_api_id | string | ID of the external knowledge API. | No |
|
||||
| external_knowledge_id | string | ID of the external knowledge base. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| name | string | Name of the knowledge base. | Yes |
|
||||
| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No |
|
||||
| provider | string, <br>**Available values:** "external", "vendor", <br>**Default:** vendor | Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.<br>*Enum:* `"external"`, `"vendor"` | No |
|
||||
@ -3039,7 +3039,7 @@ Enum class for custom configuration status.
|
||||
| external_knowledge_api_id | string | ID of the external knowledge API. | No |
|
||||
| external_knowledge_id | string | ID of the external knowledge base. | No |
|
||||
| external_retrieval_model | object | Retrieval settings for external knowledge bases. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| name | string | Name of the knowledge base. | No |
|
||||
| partial_member_list | [ object ] | List of team members with access when `permission` is `partial_members`. | No |
|
||||
| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No |
|
||||
@ -3167,7 +3167,7 @@ Request payload for bulk downloading documents as a zip archive.
|
||||
| keyword | string | Search keyword to filter by document name. | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page. Server caps at `100`. | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number to retrieve. | No |
|
||||
| status | string | Filter by display status. | No |
|
||||
| status | string, <br>**Available values:** "archived", "available", "disabled", "error", "indexing", "paused", "queuing" | Filter by display status. | No |
|
||||
|
||||
#### DocumentListResponse
|
||||
|
||||
@ -3265,7 +3265,7 @@ Request payload for bulk downloading documents as a zip archive.
|
||||
| doc_language | string, <br>**Default:** English | Language of the document for processing optimization. | No |
|
||||
| embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No |
|
||||
| name | string | Document name. | Yes |
|
||||
| original_document_id | string | Original document ID for replacement. | No |
|
||||
| process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No |
|
||||
@ -3614,14 +3614,14 @@ Model class for i18n object.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFeedbackPayloadWithUser
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### MessageFile
|
||||
@ -3701,7 +3701,7 @@ Metadata Filtering Condition.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No |
|
||||
| logical_operator | string | How to combine multiple conditions. | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or" | How to combine multiple conditions. | No |
|
||||
|
||||
#### MetadataOperationData
|
||||
|
||||
@ -3935,7 +3935,7 @@ Model class for provider with models response.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No |
|
||||
| reranking_enable | boolean | Whether reranking is enabled. | Yes |
|
||||
| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_mode | string, <br>**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No |
|
||||
| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No |
|
||||
| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes |
|
||||
@ -3969,7 +3969,7 @@ Model class for provider with models response.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| parent_mode | string | Parent-child segmentation mode. | No |
|
||||
| parent_mode | string, <br>**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No |
|
||||
| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No |
|
||||
| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No |
|
||||
| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No |
|
||||
@ -4300,7 +4300,7 @@ in form definition, or a variable while the workflow is running.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No |
|
||||
| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No |
|
||||
| weight_type | string | Strategy for balancing semantic and keyword search weights. | No |
|
||||
| weight_type | string, <br>**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No |
|
||||
|
||||
#### WeightVectorSetting
|
||||
|
||||
@ -4383,7 +4383,7 @@ Blocking workflow response for a finished or paused execution.
|
||||
| keyword | string | Keyword to search in logs. | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number for pagination. | No |
|
||||
| status | string | Filter by execution status. | No |
|
||||
| status | string, <br>**Available values:** "failed", "stopped", "succeeded" | Filter by execution status. | No |
|
||||
|
||||
#### WorkflowPauseReasonResponse
|
||||
|
||||
@ -4452,7 +4452,7 @@ Public pause reason emitted by a blocking Workflow execution.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes |
|
||||
| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
|
||||
#### WorkflowRunPayloadWithUser
|
||||
|
||||
@ -4460,7 +4460,7 @@ Public pause reason emitted by a blocking Workflow execution.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes |
|
||||
| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### WorkflowRunResponse
|
||||
|
||||
@ -1019,7 +1019,7 @@ Button styles for user actions.
|
||||
| inputs | object | Input variables for the chat | Yes |
|
||||
| parent_message_id | string | Parent message ID | No |
|
||||
| query | string | User query/message | Yes |
|
||||
| response_mode | string | Response mode: blocking or streaming | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No |
|
||||
| retriever_from | string, <br>**Default:** web_app | Source of retriever | No |
|
||||
|
||||
#### CompletionMessagePayload
|
||||
@ -1029,7 +1029,7 @@ Button styles for user actions.
|
||||
| files | [ object ] | Files to be processed | No |
|
||||
| inputs | object | Input variables for the completion | Yes |
|
||||
| query | string | Query text for completion | No |
|
||||
| response_mode | string | Response mode: blocking or streaming | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No |
|
||||
| retriever_from | string, <br>**Default:** web_app | Source of retriever | No |
|
||||
|
||||
#### ConversationInfiniteScrollPagination
|
||||
@ -1322,7 +1322,7 @@ Parsed multipart form fields for HITL uploads.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFile
|
||||
|
||||
|
||||
189
api/repositories/step_by_step_tour_repository.py
Normal file
189
api/repositories/step_by_step_tour_repository.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""SQLAlchemy repository for account Step-by-step Tour state."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol, override, runtime_checkable
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from services.entities.onboarding_entities import StepByStepTourState
|
||||
from services.step_by_step_tour_service import StepByStepTourStateRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MYSQL_RETRYABLE_LOCK_ERRNOS = frozenset({1205, 1213})
|
||||
_MAX_LOCK_ATTEMPTS = 3
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _ErrorWithErrno(Protocol):
|
||||
@property
|
||||
def errno(self) -> object: ...
|
||||
|
||||
|
||||
class SQLAlchemyStepByStepTourStateRepository(StepByStepTourStateRepository):
|
||||
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def get(self, account_id: str) -> StepByStepTourState | None:
|
||||
with self._session_factory() as session:
|
||||
model = self._get_model(account_id, session=session)
|
||||
return self._to_state(model) if model is not None else None
|
||||
|
||||
@override
|
||||
def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
|
||||
"""Create state with its first workspace, or atomically claim a legacy empty state."""
|
||||
return self._run_with_lock_retry(
|
||||
lambda: self._initialize_once(account_id, first_workspace_id),
|
||||
)
|
||||
|
||||
def _initialize_once(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
|
||||
with self._session_factory() as session:
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
model = AccountStepByStepTourState(
|
||||
account_id=account_id,
|
||||
first_workspace_id=first_workspace_id,
|
||||
)
|
||||
session.add(model)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
# A concurrent request inserted the account-owned row first.
|
||||
session.rollback()
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
raise
|
||||
else:
|
||||
session.refresh(model)
|
||||
return self._to_state(model)
|
||||
|
||||
if model.first_workspace_id is None:
|
||||
stmt = (
|
||||
update(AccountStepByStepTourState)
|
||||
.where(
|
||||
AccountStepByStepTourState.account_id == account_id,
|
||||
AccountStepByStepTourState.first_workspace_id.is_(None),
|
||||
)
|
||||
.values(first_workspace_id=first_workspace_id)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
# A competing conditional update may have won while this request waited.
|
||||
session.refresh(model)
|
||||
|
||||
return self._to_state(model)
|
||||
|
||||
@override
|
||||
def mutate(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState:
|
||||
"""Lock, create if needed, mutate, and persist account state in one transaction."""
|
||||
return self._run_with_lock_retry(
|
||||
lambda: self._mutate_once(account_id, mutation),
|
||||
)
|
||||
|
||||
def _mutate_once(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState:
|
||||
with self._session_factory() as session:
|
||||
# Probe without a locking read so a missing MySQL unique key does not
|
||||
# acquire a gap/next-key lock before the insert.
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
model = AccountStepByStepTourState(account_id=account_id)
|
||||
session.add(model)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
# A concurrent mutation created the row. Start a new transaction,
|
||||
# lock its committed state, and replay the pure mutation on it.
|
||||
session.rollback()
|
||||
model = self._get_model(account_id, session=session, lock_for_update=True)
|
||||
if model is None:
|
||||
raise
|
||||
else:
|
||||
model = self._get_model(account_id, session=session, lock_for_update=True)
|
||||
if model is None:
|
||||
raise RuntimeError("Step-by-step Tour state disappeared while acquiring its lock")
|
||||
|
||||
state = mutation(self._to_state(model))
|
||||
if state.account_id != account_id:
|
||||
raise ValueError("Step-by-step Tour mutation cannot change account ownership")
|
||||
# first_workspace_id is write-once and owned exclusively by initialize().
|
||||
model.skipped = state.skipped
|
||||
model.completed_task_ids = list(state.completed_task_ids)
|
||||
model.manually_enabled_workspace_ids = list(state.manually_enabled_workspace_ids)
|
||||
model.manually_disabled_workspace_ids = list(state.manually_disabled_workspace_ids)
|
||||
session.commit()
|
||||
session.refresh(model)
|
||||
return self._to_state(model)
|
||||
|
||||
@staticmethod
|
||||
def _run_with_lock_retry[T](operation: Callable[[], T]) -> T:
|
||||
for attempt in range(1, _MAX_LOCK_ATTEMPTS):
|
||||
try:
|
||||
return operation()
|
||||
except OperationalError as exc:
|
||||
if not _is_retryable_mysql_lock_error(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"Retrying Step-by-step Tour transaction after MySQL lock failure (attempt %s/%s)",
|
||||
attempt,
|
||||
_MAX_LOCK_ATTEMPTS,
|
||||
)
|
||||
return operation()
|
||||
|
||||
@staticmethod
|
||||
def _get_model(
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session,
|
||||
lock_for_update: bool = False,
|
||||
) -> AccountStepByStepTourState | None:
|
||||
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
|
||||
if lock_for_update:
|
||||
stmt = stmt.with_for_update().execution_options(populate_existing=True)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def _to_state(model: AccountStepByStepTourState) -> StepByStepTourState:
|
||||
return StepByStepTourState(
|
||||
account_id=model.account_id,
|
||||
first_workspace_id=model.first_workspace_id,
|
||||
skipped=model.skipped,
|
||||
completed_task_ids=tuple(model.completed_task_ids),
|
||||
manually_enabled_workspace_ids=tuple(model.manually_enabled_workspace_ids),
|
||||
manually_disabled_workspace_ids=tuple(model.manually_disabled_workspace_ids),
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _is_retryable_mysql_lock_error(exc: OperationalError) -> bool:
|
||||
orig = exc.orig
|
||||
if isinstance(orig, _ErrorWithErrno) and _is_retryable_mysql_lock_error_code(orig.errno):
|
||||
return True
|
||||
if not isinstance(orig, BaseException) or not orig.args:
|
||||
return False
|
||||
return _is_retryable_mysql_lock_error_code(orig.args[0])
|
||||
|
||||
|
||||
def _is_retryable_mysql_lock_error_code(candidate: object) -> bool:
|
||||
if isinstance(candidate, bool):
|
||||
return False
|
||||
if isinstance(candidate, int):
|
||||
code = candidate
|
||||
elif isinstance(candidate, str) and candidate.isdecimal():
|
||||
code = int(candidate)
|
||||
else:
|
||||
return False
|
||||
return code in _MYSQL_RETRYABLE_LOCK_ERRNOS
|
||||
38
api/services/entities/notification_entities.py
Normal file
38
api/services/entities/notification_entities.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""Framework-independent notification contracts."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class NotificationContent(NamedTuple):
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class AccountNotification(NamedTuple):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
contents: Mapping[str, NotificationContent]
|
||||
|
||||
|
||||
class AccountNotificationBatch(NamedTuple):
|
||||
should_show: bool
|
||||
notifications: tuple[AccountNotification, ...]
|
||||
|
||||
|
||||
class NotificationItem(NamedTuple):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class NotificationResult(NamedTuple):
|
||||
should_show: bool
|
||||
notifications: tuple[NotificationItem, ...]
|
||||
42
api/services/entities/onboarding_entities.py
Normal file
42
api/services/entities/onboarding_entities.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Framework-independent Step-by-step Tour contracts."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
# Assignment-form aliases preserve Literal enum values in Pydantic-generated OpenAPI schemas.
|
||||
StepByStepTourAction: TypeAlias = Literal[ # noqa: UP040
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
]
|
||||
StepByStepTourTaskId: TypeAlias = Literal["home", "studio", "knowledge", "integration"] # noqa: UP040
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourPatch:
|
||||
action: StepByStepTourAction
|
||||
task_id: StepByStepTourTaskId | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourState:
|
||||
account_id: str
|
||||
first_workspace_id: str | None = None
|
||||
skipped: bool = False
|
||||
completed_task_ids: tuple[str, ...] = ()
|
||||
manually_enabled_workspace_ids: tuple[str, ...] = ()
|
||||
manually_disabled_workspace_ids: tuple[str, ...] = ()
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourResult:
|
||||
first_workspace_id: str | None = None
|
||||
skipped: bool = False
|
||||
completed_task_ids: tuple[str, ...] = ()
|
||||
manually_enabled_workspace_ids: tuple[str, ...] = ()
|
||||
manually_disabled_workspace_ids: tuple[str, ...] = ()
|
||||
updated_at: datetime | None = None
|
||||
48
api/services/notification_gateway.py
Normal file
48
api/services/notification_gateway.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Billing-backed notification gateway."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, override
|
||||
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
NotificationContent,
|
||||
)
|
||||
from services.notification_service import NotificationGateway
|
||||
|
||||
|
||||
class BillingNotificationGateway(NotificationGateway):
|
||||
@override
|
||||
def get_active(self, account_id: str) -> AccountNotificationBatch:
|
||||
payload = BillingService.get_account_notification(account_id)
|
||||
notifications = tuple(self._map_notification(item) for item in payload.get("notifications") or ())
|
||||
return AccountNotificationBatch(
|
||||
should_show=bool(payload.get("shouldShow")),
|
||||
notifications=notifications,
|
||||
)
|
||||
|
||||
@override
|
||||
def dismiss(self, notification_id: str, account_id: str) -> None:
|
||||
BillingService.dismiss_notification(notification_id=notification_id, account_id=account_id)
|
||||
|
||||
@classmethod
|
||||
def _map_notification(cls, payload: Mapping[str, Any]) -> AccountNotification:
|
||||
raw_contents = payload.get("contents") or {}
|
||||
contents = {language: cls._map_content(content) for language, content in raw_contents.items() if content}
|
||||
return AccountNotification(
|
||||
notification_id=payload.get("notificationId"),
|
||||
frequency=payload.get("frequency"),
|
||||
contents=contents,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _map_content(payload: Mapping[str, Any]) -> NotificationContent:
|
||||
return NotificationContent(
|
||||
# The application service owns the requested-language fallback.
|
||||
lang=payload.get("lang") or "",
|
||||
title=payload.get("title") or "",
|
||||
subtitle=payload.get("subtitle") or "",
|
||||
body=payload.get("body") or "",
|
||||
title_pic_url=payload.get("titlePicUrl") or "",
|
||||
)
|
||||
60
api/services/notification_service.py
Normal file
60
api/services/notification_service.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Application service for Console account notifications."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
NotificationContent,
|
||||
NotificationItem,
|
||||
NotificationResult,
|
||||
)
|
||||
|
||||
_FALLBACK_LANGUAGE = "en-US"
|
||||
|
||||
|
||||
class NotificationGateway(Protocol):
|
||||
def get_active(self, account_id: str) -> AccountNotificationBatch: ...
|
||||
|
||||
def dismiss(self, notification_id: str, account_id: str) -> None: ...
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, *, accounts: AccountRepository, notifications: NotificationGateway) -> None:
|
||||
self._accounts = accounts
|
||||
self._notifications = notifications
|
||||
|
||||
def get_active(self, context: RequestContext) -> NotificationResult:
|
||||
batch = self._notifications.get_active(context.account_id)
|
||||
if not batch.should_show:
|
||||
return NotificationResult(should_show=False, notifications=())
|
||||
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("Console account admission resolved an unknown account")
|
||||
language = account.interface_language or _FALLBACK_LANGUAGE
|
||||
|
||||
notifications = tuple(self._localize(notification, language) for notification in batch.notifications)
|
||||
return NotificationResult(should_show=bool(notifications), notifications=notifications)
|
||||
|
||||
def dismiss(self, context: RequestContext, notification_id: str) -> None:
|
||||
self._notifications.dismiss(notification_id, context.account_id)
|
||||
|
||||
@staticmethod
|
||||
def _localize(notification: AccountNotification, language: str) -> NotificationItem:
|
||||
content = (
|
||||
notification.contents.get(language)
|
||||
or notification.contents.get(_FALLBACK_LANGUAGE)
|
||||
or next(iter(notification.contents.values()), NotificationContent(language, "", "", "", ""))
|
||||
)
|
||||
return NotificationItem(
|
||||
notification_id=notification.notification_id,
|
||||
frequency=notification.frequency,
|
||||
lang=content.lang or language,
|
||||
title=content.title,
|
||||
subtitle=content.subtitle,
|
||||
body=content.body,
|
||||
title_pic_url=content.title_pic_url,
|
||||
)
|
||||
@ -1,221 +1,161 @@
|
||||
"""Account-level Step-by-step Tour persistence."""
|
||||
"""Application service for account-level Step-by-step Tour use cases."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict
|
||||
from typing import Protocol, get_args
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
|
||||
from configs import dify_config
|
||||
from libs.datetime_utils import ensure_naive_utc
|
||||
from models.account import Account
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.onboarding_entities import (
|
||||
StepByStepTourPatch,
|
||||
StepByStepTourResult,
|
||||
StepByStepTourState,
|
||||
StepByStepTourTaskId,
|
||||
)
|
||||
|
||||
STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration"))
|
||||
_TASK_IDS: frozenset[str] = frozenset(get_args(StepByStepTourTaskId))
|
||||
|
||||
|
||||
class StepByStepTourStateResponse(TypedDict):
|
||||
first_workspace_id: str | None
|
||||
skipped: bool
|
||||
completed_task_ids: list[str]
|
||||
manually_enabled_workspace_ids: list[str]
|
||||
manually_disabled_workspace_ids: list[str]
|
||||
updated_at: datetime | None
|
||||
class StepByStepTourStateRepository(Protocol):
|
||||
def get(self, account_id: str) -> StepByStepTourState | None: ...
|
||||
|
||||
def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: ...
|
||||
|
||||
class StepByStepTourPatch(TypedDict):
|
||||
action: str
|
||||
task_id: NotRequired[str | None]
|
||||
def mutate(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState: ...
|
||||
|
||||
|
||||
class StepByStepTourService:
|
||||
"""Coordinate persisted tour state with account eligibility rules."""
|
||||
|
||||
@classmethod
|
||||
def get_state(
|
||||
cls,
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
eligible = cls.is_eligible(account)
|
||||
state = cls._get_state(account.id, session=session)
|
||||
accounts: AccountRepository,
|
||||
states: StepByStepTourStateRepository,
|
||||
enabled: bool,
|
||||
rollout_started_at: datetime | None,
|
||||
) -> None:
|
||||
self._accounts = accounts
|
||||
self._states = states
|
||||
self._enabled = enabled
|
||||
self._rollout_started_at = rollout_started_at
|
||||
|
||||
if eligible:
|
||||
state = cls._ensure_state(account.id, session=session, state=state)
|
||||
if state.first_workspace_id is None:
|
||||
state.first_workspace_id = current_tenant_id
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
def get_state(self, context: RequestContext) -> StepByStepTourResult:
|
||||
workspace_id = self._require_workspace(context)
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("Console account admission resolved an unknown account")
|
||||
|
||||
return cls._build_response(state=state)
|
||||
if not self._is_eligible(account.initialized_at or account.created_at):
|
||||
return self._to_result(self._states.get(context.account_id))
|
||||
|
||||
@classmethod
|
||||
def patch_state(
|
||||
cls,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
patch: StepByStepTourPatch,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
state = cls._ensure_state(account.id, session=session, state=None)
|
||||
cls._apply_action(
|
||||
state=state,
|
||||
action=patch["action"],
|
||||
task_id=patch.get("task_id"),
|
||||
current_tenant_id=current_tenant_id,
|
||||
return self._to_result(self._states.initialize(context.account_id, workspace_id))
|
||||
|
||||
def patch_state(self, context: RequestContext, patch: StepByStepTourPatch) -> StepByStepTourResult:
|
||||
workspace_id = self._require_workspace(context)
|
||||
state = self._states.mutate(
|
||||
context.account_id,
|
||||
lambda current: self._apply_action(current, patch=patch, workspace_id=workspace_id),
|
||||
)
|
||||
return self._to_result(state)
|
||||
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
return cls._build_response(state=state)
|
||||
|
||||
@classmethod
|
||||
def is_eligible(cls, account: Account) -> bool:
|
||||
if not dify_config.ENABLE_STEP_BY_STEP_TOUR:
|
||||
def _is_eligible(self, account_started_at: datetime) -> bool:
|
||||
if not self._enabled or self._rollout_started_at is None:
|
||||
return False
|
||||
|
||||
rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT
|
||||
if rollout_started_at is None:
|
||||
return False
|
||||
|
||||
account_started_at = account.initialized_at or account.created_at
|
||||
if account_started_at is None:
|
||||
return False
|
||||
|
||||
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at)
|
||||
|
||||
@classmethod
|
||||
def _get_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
) -> AccountStepByStepTourState | None:
|
||||
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def _ensure_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> AccountStepByStepTourState:
|
||||
if state is None:
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is not None:
|
||||
return state
|
||||
|
||||
state = AccountStepByStepTourState(account_id=account_id)
|
||||
session.add(state)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
# Another tab/device can create the account row between our read and insert.
|
||||
session.rollback()
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is None:
|
||||
raise
|
||||
return state
|
||||
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(self._rollout_started_at)
|
||||
|
||||
@classmethod
|
||||
def _apply_action(
|
||||
cls,
|
||||
state: StepByStepTourState,
|
||||
*,
|
||||
state: AccountStepByStepTourState,
|
||||
action: str,
|
||||
task_id: str | None,
|
||||
current_tenant_id: str,
|
||||
) -> None:
|
||||
match action:
|
||||
patch: StepByStepTourPatch,
|
||||
workspace_id: str,
|
||||
) -> StepByStepTourState:
|
||||
match patch.action:
|
||||
case "skip":
|
||||
state.skipped = True
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
skipped=True,
|
||||
manually_enabled_workspace_ids=cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case "complete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id)
|
||||
task_id = cls._require_task_id(patch.task_id)
|
||||
return replace(state, completed_task_ids=cls._add_id(state.completed_task_ids, task_id))
|
||||
case "uncomplete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id)
|
||||
task_id = cls._require_task_id(patch.task_id)
|
||||
return replace(state, completed_task_ids=cls._remove_id(state.completed_task_ids, task_id))
|
||||
case "enable_current_workspace":
|
||||
state.skipped = False
|
||||
state.manually_enabled_workspace_ids = cls._add_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._remove_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
skipped=False,
|
||||
manually_enabled_workspace_ids=cls._add_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
manually_disabled_workspace_ids=cls._remove_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case "disable_current_workspace":
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._add_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
manually_enabled_workspace_ids=cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
manually_disabled_workspace_ids=cls._add_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported action: {action}")
|
||||
|
||||
@classmethod
|
||||
def _build_response(
|
||||
cls,
|
||||
*,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> StepByStepTourStateResponse:
|
||||
if state is None:
|
||||
return {
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"first_workspace_id": state.first_workspace_id,
|
||||
"skipped": state.skipped,
|
||||
"completed_task_ids": cls._normalize_ids(state.completed_task_ids),
|
||||
"manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids),
|
||||
"manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids),
|
||||
"updated_at": state.updated_at,
|
||||
}
|
||||
raise ValueError(f"Unsupported action: {patch.action}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_task_id(task_id: str) -> None:
|
||||
if task_id not in STEP_BY_STEP_TOUR_TASK_IDS:
|
||||
def _require_workspace(context: RequestContext) -> str:
|
||||
if context.active_workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
return context.active_workspace_id
|
||||
|
||||
@staticmethod
|
||||
def _require_task_id(task_id: str | None) -> str:
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
if task_id not in _TASK_IDS:
|
||||
raise ValueError(f"Unsupported task_id: {task_id}")
|
||||
return task_id
|
||||
|
||||
@classmethod
|
||||
def _add_id(cls, values: list[str], value: str) -> list[str]:
|
||||
def _add_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]:
|
||||
normalized = cls._normalize_ids(values)
|
||||
if value in normalized:
|
||||
return normalized
|
||||
return [*normalized, value]
|
||||
return normalized if value in normalized else (*normalized, value)
|
||||
|
||||
@classmethod
|
||||
def _remove_id(cls, values: list[str], value: str) -> list[str]:
|
||||
return [item for item in cls._normalize_ids(values) if item != value]
|
||||
def _remove_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]:
|
||||
return tuple(item for item in cls._normalize_ids(values) if item != value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ids(values: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
def _normalize_ids(values: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
@staticmethod
|
||||
def _to_result(state: StepByStepTourState | None) -> StepByStepTourResult:
|
||||
if state is None:
|
||||
return StepByStepTourResult()
|
||||
return StepByStepTourResult(
|
||||
first_workspace_id=state.first_workspace_id,
|
||||
skipped=state.skipped,
|
||||
completed_task_ids=tuple(dict.fromkeys(state.completed_task_ids)),
|
||||
manually_enabled_workspace_ids=tuple(dict.fromkeys(state.manually_enabled_workspace_ids)),
|
||||
manually_disabled_workspace_ids=tuple(dict.fromkeys(state.manually_disabled_workspace_ids)),
|
||||
updated_at=state.updated_at,
|
||||
)
|
||||
|
||||
@ -238,6 +238,42 @@ def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_pat
|
||||
assert "| value | string<br>integer<br>number<br>boolean | | No |" in patched
|
||||
|
||||
|
||||
def test_patch_union_schema_markdown_preserves_nullable_enum_values(tmp_path: Path):
|
||||
module = _load_generate_swagger_markdown_docs_module()
|
||||
spec_path = tmp_path / "console-openapi.json"
|
||||
spec_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"StepByStepTourStatePatchPayload": {
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"anyOf": [
|
||||
{"enum": ["home", "studio"], "type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
markdown = """#### StepByStepTourStatePatchPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| task_id | string | Task ID | No |
|
||||
"""
|
||||
|
||||
patched = module._patch_union_schema_markdown(markdown, spec_path)
|
||||
|
||||
assert '| task_id | string, <br>**Available values:** "home", "studio" | Task ID | No |' in patched
|
||||
|
||||
|
||||
def test_patch_union_schema_markdown_fills_array_item_union_property(tmp_path: Path):
|
||||
module = _load_generate_swagger_markdown_docs_module()
|
||||
spec_path = tmp_path / "console-openapi.json"
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from controllers.console.notification import (
|
||||
DismissNotificationPayload,
|
||||
NotificationApi,
|
||||
NotificationDismissApi,
|
||||
)
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.notification_entities import NotificationItem, NotificationResult
|
||||
|
||||
|
||||
def _request_context() -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
|
||||
|
||||
def test_get_notification_delegates_and_serializes_result() -> None:
|
||||
service = Mock()
|
||||
service.get_active.return_value = NotificationResult(
|
||||
should_show=True,
|
||||
notifications=(
|
||||
NotificationItem(
|
||||
notification_id="notification-1",
|
||||
frequency="once",
|
||||
lang="en-US",
|
||||
title="Title",
|
||||
subtitle="Subtitle",
|
||||
body="Body",
|
||||
title_pic_url="https://example.com/title.png",
|
||||
),
|
||||
),
|
||||
)
|
||||
services = SimpleNamespace(notifications=service)
|
||||
api = NotificationApi()
|
||||
method = unwrap(api.get)
|
||||
context = _request_context()
|
||||
|
||||
with patch("controllers.console.notification.application_services", return_value=services):
|
||||
result, status = method(api, context)
|
||||
|
||||
assert status == 200
|
||||
assert result == {
|
||||
"should_show": True,
|
||||
"notifications": [
|
||||
{
|
||||
"notification_id": "notification-1",
|
||||
"frequency": "once",
|
||||
"lang": "en-US",
|
||||
"title": "Title",
|
||||
"subtitle": "Subtitle",
|
||||
"body": "Body",
|
||||
"title_pic_url": "https://example.com/title.png",
|
||||
}
|
||||
],
|
||||
}
|
||||
service.get_active.assert_called_once_with(context)
|
||||
|
||||
|
||||
def test_dismiss_notification_delegates_with_stable_account_context() -> None:
|
||||
service = Mock()
|
||||
services = SimpleNamespace(notifications=service)
|
||||
api = NotificationDismissApi()
|
||||
method = unwrap(api.post)
|
||||
context = _request_context()
|
||||
|
||||
with patch("controllers.console.notification.application_services", return_value=services):
|
||||
result, status = method(api, DismissNotificationPayload(notification_id="notification-1"), context)
|
||||
|
||||
assert status == 200
|
||||
assert result == {"result": "success"}
|
||||
service.dismiss.assert_called_once_with(context, "notification-1")
|
||||
@ -2,47 +2,48 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from unittest.mock import Mock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console.onboarding import (
|
||||
StepByStepTourStateApi,
|
||||
StepByStepTourStatePatchPayload,
|
||||
StepByStepTourStateResponse,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account, AccountStatus
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult
|
||||
|
||||
|
||||
def _account() -> Account:
|
||||
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
return account
|
||||
def _request_context() -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
|
||||
|
||||
def _state_response() -> dict[str, object]:
|
||||
return {
|
||||
"first_workspace_id": "workspace-1",
|
||||
"skipped": False,
|
||||
"completed_task_ids": ["home"],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": datetime(2026, 6, 28, tzinfo=UTC),
|
||||
}
|
||||
def _state_result() -> StepByStepTourResult:
|
||||
return StepByStepTourResult(
|
||||
first_workspace_id="workspace-1",
|
||||
completed_task_ids=("home",),
|
||||
updated_at=datetime(2026, 6, 28, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_state = Mock(return_value=_state_response())
|
||||
monkeypatch.setattr(StepByStepTourService, "get_state", get_state)
|
||||
|
||||
def test_get_step_by_step_tour_state_delegates_with_request_context() -> None:
|
||||
service = Mock()
|
||||
service.get_state.return_value = _state_result()
|
||||
services = SimpleNamespace(step_by_step_tour=service)
|
||||
api = StepByStepTourStateApi()
|
||||
method = unwrap(api.get)
|
||||
context = _request_context()
|
||||
|
||||
with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"):
|
||||
result = method(api, "workspace-1", _account())
|
||||
with patch("controllers.console.onboarding.application_services", return_value=services):
|
||||
result = method(api, context)
|
||||
|
||||
assert result == {
|
||||
"first_workspace_id": "workspace-1",
|
||||
@ -52,35 +53,26 @@ def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": "2026-06-28T00:00:00Z",
|
||||
}
|
||||
get_state.assert_called_once()
|
||||
assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
|
||||
assert get_state.call_args.kwargs["session"] is db.session
|
||||
service.get_state.assert_called_once_with(context)
|
||||
|
||||
|
||||
def test_patch_step_by_step_tour_state_passes_action_payload(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
patch_state = Mock(return_value=_state_response())
|
||||
monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state)
|
||||
|
||||
def test_patch_step_by_step_tour_state_maps_transport_payload_to_command() -> None:
|
||||
service = Mock()
|
||||
service.patch_state.return_value = _state_result()
|
||||
services = SimpleNamespace(step_by_step_tour=service)
|
||||
api = StepByStepTourStateApi()
|
||||
method = unwrap(api.patch)
|
||||
payload = {"action": "complete_task", "task_id": "studio"}
|
||||
context = _request_context()
|
||||
payload = StepByStepTourStatePatchPayload.model_validate({"action": "complete_task", "task_id": "studio"})
|
||||
|
||||
req_data = StepByStepTourStatePatchPayload.model_validate(payload)
|
||||
with app.test_request_context(
|
||||
"/console/api/onboarding/step-by-step-tour/state",
|
||||
method="PATCH",
|
||||
json=payload,
|
||||
):
|
||||
result = method(api, req_data, "workspace-1", _account())
|
||||
with patch("controllers.console.onboarding.application_services", return_value=services):
|
||||
result = method(api, payload, context)
|
||||
|
||||
assert result["completed_task_ids"] == ["home"]
|
||||
patch_state.assert_called_once()
|
||||
assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
|
||||
assert patch_state.call_args.kwargs["patch"] == payload
|
||||
assert patch_state.call_args.kwargs["session"] is db.session
|
||||
service.patch_state.assert_called_once_with(
|
||||
context,
|
||||
StepByStepTourPatch(action="complete_task", task_id="studio"),
|
||||
)
|
||||
|
||||
|
||||
def test_patch_payload_rejects_non_action_fields() -> None:
|
||||
@ -96,3 +88,21 @@ def test_patch_payload_rejects_task_id_without_task_action() -> None:
|
||||
def test_patch_payload_requires_action() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
StepByStepTourStatePatchPayload.model_validate({"task_id": "home"})
|
||||
|
||||
|
||||
def test_step_by_step_tour_schemas_preserve_enum_values() -> None:
|
||||
patch_schema = StepByStepTourStatePatchPayload.model_json_schema()
|
||||
action_schema = patch_schema["properties"]["action"]
|
||||
task_id_schema = patch_schema["properties"]["task_id"]
|
||||
task_id_values = next(candidate["enum"] for candidate in task_id_schema["anyOf"] if "enum" in candidate)
|
||||
response_schema = StepByStepTourStateResponse.model_json_schema()
|
||||
|
||||
assert set(action_schema["enum"]) == {
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
}
|
||||
assert set(task_id_values) == {"home", "studio", "knowledge", "integration"}
|
||||
assert set(response_schema["properties"]["completed_task_ids"]["items"]["enum"]) == set(task_id_values)
|
||||
|
||||
@ -382,6 +382,8 @@ def test_build_application_services_wires_account_profile_repository(
|
||||
assert email_registration._registration._session_factory is sqlite_session_factory
|
||||
assert services.accounts.education._accounts is accounts
|
||||
assert services.accounts.deletion._accounts is accounts
|
||||
assert services.notifications._accounts is accounts
|
||||
assert services.step_by_step_tour._accounts is accounts
|
||||
assert services.accounts.deletion._memberships is services.workspace_queries._workspaces
|
||||
integrations = services.accounts.integrations._integrations
|
||||
assert isinstance(integrations, SQLAlchemyAccountIntegrationRepository)
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from repositories.step_by_step_tour_repository import (
|
||||
SQLAlchemyStepByStepTourStateRepository,
|
||||
_is_retryable_mysql_lock_error,
|
||||
)
|
||||
|
||||
|
||||
class _ErrnoOnlyError(Exception):
|
||||
def __init__(self, errno: int | str) -> None:
|
||||
super().__init__()
|
||||
self.errno = errno
|
||||
|
||||
|
||||
def test_mutate_creates_and_updates_state_in_repository_owned_transaction(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
|
||||
saved = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, completed_task_ids=("home",)),
|
||||
)
|
||||
reloaded = repository.get("account-1")
|
||||
|
||||
assert saved.first_workspace_id is None
|
||||
assert saved.completed_task_ids == ("home",)
|
||||
assert saved.updated_at is not None
|
||||
assert reloaded == saved
|
||||
|
||||
|
||||
def test_initialize_creates_state_with_first_workspace_atomically(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
|
||||
result = repository.initialize("account-1", "workspace-1")
|
||||
|
||||
assert result.first_workspace_id == "workspace-1"
|
||||
assert repository.get("account-1") == result
|
||||
|
||||
|
||||
def test_initialize_claims_empty_state_once_without_overwriting_winner(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
with sqlite_session_factory() as session:
|
||||
session.add(AccountStepByStepTourState(account_id="account-1"))
|
||||
session.commit()
|
||||
|
||||
first = repository.initialize("account-1", "workspace-1")
|
||||
second = repository.initialize("account-1", "workspace-2")
|
||||
|
||||
assert first.first_workspace_id == "workspace-1"
|
||||
assert second.first_workspace_id == "workspace-1"
|
||||
|
||||
|
||||
def test_mutate_cannot_clear_or_overwrite_first_workspace(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
repository.initialize("account-1", "workspace-1")
|
||||
|
||||
result = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, first_workspace_id="workspace-2", skipped=True),
|
||||
)
|
||||
|
||||
assert result.first_workspace_id == "workspace-1"
|
||||
assert result.skipped is True
|
||||
|
||||
|
||||
def test_sequential_mutations_replay_against_latest_state(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
|
||||
|
||||
repository.mutate("account-1", lambda state: replace(state, completed_task_ids=("home",)))
|
||||
result = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
|
||||
)
|
||||
|
||||
assert result.completed_task_ids == ("home", "studio")
|
||||
|
||||
|
||||
def test_mutate_replays_after_concurrent_create_conflict() -> None:
|
||||
concurrent_state = AccountStepByStepTourState(account_id="account-1")
|
||||
concurrent_state.completed_task_ids = ["home"]
|
||||
concurrent_state.updated_at = datetime(2026, 8, 13)
|
||||
session = MagicMock(spec=Session)
|
||||
session.execute.return_value.scalar_one_or_none.side_effect = [None, concurrent_state]
|
||||
session.flush.side_effect = IntegrityError("insert", {}, Exception("duplicate"))
|
||||
factory = cast(sessionmaker[Session], Mock(return_value=nullcontext(session)))
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(factory)
|
||||
|
||||
result = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
|
||||
)
|
||||
|
||||
assert result.completed_task_ids == ("home", "studio")
|
||||
session.rollback.assert_called_once_with()
|
||||
initial_probe = session.execute.call_args_list[0].args[0]
|
||||
replay_statement = session.execute.call_args_list[1].args[0]
|
||||
assert initial_probe._for_update_arg is None
|
||||
assert replay_statement._for_update_arg is not None
|
||||
|
||||
|
||||
def test_mutate_retries_mysql_deadlock_with_fresh_session() -> None:
|
||||
concurrent_state = AccountStepByStepTourState(account_id="account-1")
|
||||
concurrent_state.completed_task_ids = ["home"]
|
||||
concurrent_state.updated_at = datetime(2026, 8, 13)
|
||||
|
||||
deadlocked_session = MagicMock(spec=Session)
|
||||
deadlocked_session.execute.return_value.scalar_one_or_none.return_value = None
|
||||
deadlocked_session.flush.side_effect = OperationalError(
|
||||
"INSERT",
|
||||
{},
|
||||
Exception(1213, "Deadlock found when trying to get lock"),
|
||||
)
|
||||
|
||||
retry_session = MagicMock(spec=Session)
|
||||
retry_session.execute.return_value.scalar_one_or_none.side_effect = [concurrent_state, concurrent_state]
|
||||
factory = Mock(side_effect=[nullcontext(deadlocked_session), nullcontext(retry_session)])
|
||||
repository = SQLAlchemyStepByStepTourStateRepository(cast(sessionmaker[Session], factory))
|
||||
|
||||
result = repository.mutate(
|
||||
"account-1",
|
||||
lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
|
||||
)
|
||||
|
||||
assert result.completed_task_ids == ("home", "studio")
|
||||
assert factory.call_count == 2
|
||||
retry_lock_statement = retry_session.execute.call_args_list[1].args[0]
|
||||
assert retry_lock_statement._for_update_arg is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("orig", "expected"),
|
||||
[
|
||||
pytest.param(_ErrnoOnlyError(1205), True, id="errno-attribute"),
|
||||
pytest.param(Exception(1213, "deadlock"), True, id="integer-args-code"),
|
||||
pytest.param(Exception("1213", "deadlock"), True, id="string-args-code"),
|
||||
pytest.param(Exception(9999, "other error"), False, id="non-retryable-code"),
|
||||
pytest.param(Exception(True), False, id="boolean-is-not-an-error-code"),
|
||||
pytest.param(Exception(), False, id="missing-error-code"),
|
||||
],
|
||||
)
|
||||
def test_mysql_lock_error_detection_preserves_errno_and_args_coverage(
|
||||
orig: BaseException,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
exc = OperationalError("statement", {}, orig)
|
||||
|
||||
assert _is_retryable_mysql_lock_error(exc) is expected
|
||||
|
||||
|
||||
def test_get_returns_none_for_unknown_account(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
assert SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory).get("missing") is None
|
||||
63
api/tests/unit_tests/services/test_notification_gateway.py
Normal file
63
api/tests/unit_tests/services/test_notification_gateway.py
Normal file
@ -0,0 +1,63 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.entities.notification_entities import NotificationContent
|
||||
from services.notification_gateway import BillingNotificationGateway
|
||||
|
||||
|
||||
def test_get_active_maps_billing_proto_json_contract() -> None:
|
||||
payload = {
|
||||
"shouldShow": True,
|
||||
"notifications": [
|
||||
{
|
||||
"notificationId": "notification-1",
|
||||
"frequency": "once",
|
||||
"contents": {
|
||||
"en-US": {
|
||||
"lang": "en-US",
|
||||
"title": "Title",
|
||||
"subtitle": "Subtitle",
|
||||
"body": "Body",
|
||||
"titlePicUrl": "title.png",
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload):
|
||||
result = BillingNotificationGateway().get_active("account-1")
|
||||
|
||||
assert result.should_show is True
|
||||
assert result.notifications[0].notification_id == "notification-1"
|
||||
assert result.notifications[0].contents["en-US"].title_pic_url == "title.png"
|
||||
|
||||
|
||||
def test_get_active_omits_empty_localized_content_so_service_can_fall_back() -> None:
|
||||
empty_localized_content: dict[str, str] = {}
|
||||
payload = {
|
||||
"shouldShow": True,
|
||||
"notifications": [
|
||||
{
|
||||
"notificationId": "notification-1",
|
||||
"frequency": "once",
|
||||
"contents": {
|
||||
"zh-Hans": empty_localized_content,
|
||||
"en-US": {"lang": "en-US", "title": "Title"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload):
|
||||
result = BillingNotificationGateway().get_active("account-1")
|
||||
|
||||
assert result.notifications[0].contents == {
|
||||
"en-US": NotificationContent("en-US", "Title", "", "", ""),
|
||||
}
|
||||
|
||||
|
||||
def test_dismiss_delegates_to_billing_service() -> None:
|
||||
with patch("services.notification_gateway.BillingService.dismiss_notification") as dismiss:
|
||||
BillingNotificationGateway().dismiss("notification-1", "account-1")
|
||||
|
||||
dismiss.assert_called_once_with(notification_id="notification-1", account_id="account-1")
|
||||
138
api/tests/unit_tests/services/test_notification_service.py
Normal file
138
api/tests/unit_tests/services/test_notification_service.py
Normal file
@ -0,0 +1,138 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import AccountSnapshot
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
NotificationContent,
|
||||
NotificationItem,
|
||||
NotificationResult,
|
||||
)
|
||||
from services.notification_service import NotificationService
|
||||
|
||||
|
||||
def _context() -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
|
||||
|
||||
class NotificationGatewayStub:
|
||||
def __init__(self, batch: AccountNotificationBatch) -> None:
|
||||
self.batch = batch
|
||||
self.get_account_ids: list[str] = []
|
||||
self.dismissals: list[tuple[str, str]] = []
|
||||
|
||||
def get_active(self, account_id: str) -> AccountNotificationBatch:
|
||||
self.get_account_ids.append(account_id)
|
||||
return self.batch
|
||||
|
||||
def dismiss(self, notification_id: str, account_id: str) -> None:
|
||||
self.dismissals.append((notification_id, account_id))
|
||||
|
||||
|
||||
def _account(language: str | None = "zh-Hans") -> AccountSnapshot:
|
||||
return AccountSnapshot(
|
||||
id="account-1",
|
||||
name="Account",
|
||||
email="account@example.com",
|
||||
avatar=None,
|
||||
is_password_set=False,
|
||||
interface_language=language,
|
||||
interface_theme="light",
|
||||
timezone="UTC",
|
||||
last_login_at=None,
|
||||
last_login_ip=None,
|
||||
status="active",
|
||||
initialized_at=None,
|
||||
created_at=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
def _accounts(account: AccountSnapshot | None) -> Mock:
|
||||
accounts = Mock(spec=AccountRepository)
|
||||
accounts.get.return_value = account
|
||||
return accounts
|
||||
|
||||
|
||||
def _notification(contents: dict[str, NotificationContent]) -> AccountNotification:
|
||||
return AccountNotification(
|
||||
notification_id="notification-1",
|
||||
frequency="once",
|
||||
contents=contents,
|
||||
)
|
||||
|
||||
|
||||
def test_get_active_localizes_notification_for_account_language() -> None:
|
||||
chinese = NotificationContent("zh-Hans", "标题", "副标题", "正文", "zh.png")
|
||||
english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
|
||||
gateway = NotificationGatewayStub(
|
||||
AccountNotificationBatch(True, (_notification({"zh-Hans": chinese, "en-US": english}),))
|
||||
)
|
||||
service = NotificationService(accounts=_accounts(_account()), notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
|
||||
assert result == NotificationResult(
|
||||
should_show=True,
|
||||
notifications=(NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"),),
|
||||
)
|
||||
assert gateway.get_account_ids == ["account-1"]
|
||||
|
||||
|
||||
def test_get_active_falls_back_to_english() -> None:
|
||||
english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({"en-US": english}),)))
|
||||
service = NotificationService(accounts=_accounts(_account("fr-FR")), notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
|
||||
assert result.notifications[0].lang == "en-US"
|
||||
assert result.notifications[0].title == "Title"
|
||||
|
||||
|
||||
def test_get_active_skips_account_query_when_gateway_says_not_to_show() -> None:
|
||||
accounts = _accounts(None)
|
||||
service = NotificationService(
|
||||
accounts=accounts,
|
||||
notifications=NotificationGatewayStub(AccountNotificationBatch(False, ())),
|
||||
)
|
||||
|
||||
result = service.get_active(_context())
|
||||
|
||||
assert result == NotificationResult(False, ())
|
||||
accounts.get.assert_not_called()
|
||||
|
||||
|
||||
def test_get_active_uses_empty_content_when_notification_has_no_translations() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),)))
|
||||
service = NotificationService(accounts=_accounts(_account(None)), notifications=gateway)
|
||||
|
||||
result = service.get_active(_context())
|
||||
|
||||
assert result.notifications == (NotificationItem("notification-1", "once", "en-US", "", "", "", ""),)
|
||||
|
||||
|
||||
def test_get_active_rejects_unknown_admitted_account() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),)))
|
||||
service = NotificationService(accounts=_accounts(None), notifications=gateway)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unknown account"):
|
||||
service.get_active(_context())
|
||||
|
||||
|
||||
def test_dismiss_delegates_identifiers_to_gateway() -> None:
|
||||
gateway = NotificationGatewayStub(AccountNotificationBatch(False, ()))
|
||||
service = NotificationService(accounts=_accounts(_account()), notifications=gateway)
|
||||
|
||||
service.dismiss(_context(), "notification-1")
|
||||
|
||||
assert gateway.dismissals == [("notification-1", "account-1")]
|
||||
@ -1,230 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from enums import DeploymentEdition
|
||||
from models.account import Account, AccountStatus
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import AccountSnapshot
|
||||
from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult, StepByStepTourState
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account:
|
||||
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
account.initialized_at = initialized_at
|
||||
account.created_at = created_at or datetime(2026, 6, 28)
|
||||
return account
|
||||
|
||||
|
||||
def _state() -> AccountStepByStepTourState:
|
||||
state = AccountStepByStepTourState(account_id="account-1")
|
||||
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
return state
|
||||
|
||||
|
||||
def _persist_state(session: Session, state: AccountStepByStepTourState) -> None:
|
||||
session.add(state)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _load_state(session: Session) -> AccountStepByStepTourState | None:
|
||||
return session.scalar(
|
||||
select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == "account-1")
|
||||
def _context(*, workspace_id: str | None = "workspace-1") -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
|
||||
def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None:
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
ENABLE_STEP_BY_STEP_TOUR=enabled,
|
||||
STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT=rollout_started_at,
|
||||
class StateRepositoryStub:
|
||||
def __init__(self, state: StepByStepTourState | None = None) -> None:
|
||||
self.state = state
|
||||
self.get_account_ids: list[str] = []
|
||||
self.initialize_calls: list[tuple[str, str]] = []
|
||||
self.mutation_account_ids: list[str] = []
|
||||
|
||||
def get(self, account_id: str) -> StepByStepTourState | None:
|
||||
self.get_account_ids.append(account_id)
|
||||
return self.state
|
||||
|
||||
def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
|
||||
self.initialize_calls.append((account_id, first_workspace_id))
|
||||
if self.state is None:
|
||||
self.state = StepByStepTourState(account_id=account_id, first_workspace_id=first_workspace_id)
|
||||
elif self.state.first_workspace_id is None:
|
||||
self.state = replace(self.state, first_workspace_id=first_workspace_id)
|
||||
return self.state
|
||||
|
||||
def mutate(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState:
|
||||
self.mutation_account_ids.append(account_id)
|
||||
if self.state is None:
|
||||
self.state = StepByStepTourState(account_id=account_id)
|
||||
self.state = mutation(self.state)
|
||||
return self.state
|
||||
|
||||
|
||||
def _account(*, started_at: datetime = datetime(2026, 6, 28)) -> AccountSnapshot:
|
||||
return AccountSnapshot(
|
||||
id="account-1",
|
||||
name="Account",
|
||||
email="account@example.com",
|
||||
avatar=None,
|
||||
is_password_set=False,
|
||||
interface_language="en-US",
|
||||
interface_theme="light",
|
||||
timezone="UTC",
|
||||
last_login_at=None,
|
||||
last_login_ip=None,
|
||||
status="active",
|
||||
initialized_at=started_at,
|
||||
created_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
def test_get_state_creates_state_and_records_first_workspace_for_eligible_account(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
def _accounts(account: AccountSnapshot | None) -> Mock:
|
||||
accounts = Mock(spec=AccountRepository)
|
||||
accounts.get.return_value = account
|
||||
return accounts
|
||||
|
||||
result = StepByStepTourService.get_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
session=sqlite_session,
|
||||
|
||||
def _service(
|
||||
*,
|
||||
states: StateRepositoryStub,
|
||||
account: AccountSnapshot | None = None,
|
||||
enabled: bool = True,
|
||||
rollout_started_at: datetime | None = datetime(2026, 6, 1),
|
||||
) -> StepByStepTourService:
|
||||
return StepByStepTourService(
|
||||
accounts=_accounts(account or _account()),
|
||||
states=states,
|
||||
enabled=enabled,
|
||||
rollout_started_at=rollout_started_at,
|
||||
)
|
||||
|
||||
assert result["first_workspace_id"] == "workspace-1"
|
||||
assert result["completed_task_ids"] == []
|
||||
with sqlite_session_factory() as observer:
|
||||
persisted = _load_state(observer)
|
||||
assert persisted is not None
|
||||
assert persisted.account_id == "account-1"
|
||||
assert persisted.first_workspace_id == "workspace-1"
|
||||
|
||||
def test_get_state_creates_state_and_records_first_workspace_for_eligible_account() -> None:
|
||||
states = StateRepositoryStub()
|
||||
|
||||
result = _service(states=states).get_state(_context())
|
||||
|
||||
assert result.first_workspace_id == "workspace-1"
|
||||
assert states.get_account_ids == []
|
||||
assert states.initialize_calls == [("account-1", "workspace-1")]
|
||||
assert states.mutation_account_ids == []
|
||||
|
||||
|
||||
def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
|
||||
def test_get_state_returns_existing_state_without_rewriting_first_workspace() -> None:
|
||||
state = StepByStepTourState(account_id="account-1", first_workspace_id="workspace-original")
|
||||
states = StateRepositoryStub(state)
|
||||
|
||||
result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28)))
|
||||
result = _service(states=states).get_state(_context(workspace_id="workspace-current"))
|
||||
|
||||
assert result is True
|
||||
assert result.first_workspace_id == "workspace-original"
|
||||
assert states.initialize_calls == [("account-1", "workspace-current")]
|
||||
assert states.mutation_account_ids == []
|
||||
|
||||
|
||||
def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
def test_get_state_does_not_create_state_for_ineligible_account() -> None:
|
||||
states = StateRepositoryStub()
|
||||
service = _service(states=states, account=_account(started_at=datetime(2026, 5, 31)))
|
||||
|
||||
result = StepByStepTourService.get_state(
|
||||
account=_account(initialized_at=datetime(2026, 5, 31)),
|
||||
current_tenant_id="workspace-1",
|
||||
session=sqlite_session,
|
||||
result = service.get_state(_context())
|
||||
|
||||
assert result == StepByStepTourResult()
|
||||
assert states.get_account_ids == ["account-1"]
|
||||
assert states.mutation_account_ids == []
|
||||
|
||||
|
||||
def test_get_state_does_not_create_state_when_tour_is_disabled() -> None:
|
||||
states = StateRepositoryStub()
|
||||
|
||||
result = _service(states=states, enabled=False).get_state(_context())
|
||||
|
||||
assert result == StepByStepTourResult()
|
||||
assert states.get_account_ids == ["account-1"]
|
||||
|
||||
|
||||
def test_patch_state_persists_even_when_tour_is_disabled() -> None:
|
||||
states = StateRepositoryStub()
|
||||
service = _service(states=states, enabled=False)
|
||||
|
||||
result = service.patch_state(_context(workspace_id="workspace-2"), StepByStepTourPatch("enable_current_workspace"))
|
||||
|
||||
assert result.manually_enabled_workspace_ids == ("workspace-2",)
|
||||
assert states.mutation_account_ids == ["account-1"]
|
||||
|
||||
|
||||
def test_patch_state_skip_removes_current_workspace_enable() -> None:
|
||||
states = StateRepositoryStub(
|
||||
StepByStepTourState(
|
||||
account_id="account-1",
|
||||
manually_enabled_workspace_ids=("workspace-1", "workspace-2"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
with sqlite_session_factory() as observer:
|
||||
assert _load_state(observer) is None
|
||||
result = _service(states=states).patch_state(_context(), StepByStepTourPatch("skip"))
|
||||
|
||||
assert result.skipped is True
|
||||
assert result.manually_enabled_workspace_ids == ("workspace-2",)
|
||||
|
||||
|
||||
def test_patch_state_persists_even_when_account_is_not_eligible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-2",
|
||||
patch={"action": "enable_current_workspace"},
|
||||
session=sqlite_session,
|
||||
def test_patch_state_disable_moves_current_workspace_to_disabled() -> None:
|
||||
states = StateRepositoryStub(
|
||||
StepByStepTourState(
|
||||
account_id="account-1",
|
||||
manually_enabled_workspace_ids=("workspace-1", "workspace-2"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["skipped"] is False
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == []
|
||||
with sqlite_session_factory() as observer:
|
||||
persisted = _load_state(observer)
|
||||
assert persisted is not None
|
||||
assert persisted.manually_enabled_workspace_ids == ["workspace-2"]
|
||||
|
||||
|
||||
def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
|
||||
_persist_state(sqlite_session, state)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "skip"},
|
||||
session=sqlite_session,
|
||||
result = _service(states=states).patch_state(
|
||||
_context(),
|
||||
StepByStepTourPatch("disable_current_workspace"),
|
||||
)
|
||||
|
||||
assert result["skipped"] is True
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == []
|
||||
assert _load_state(sqlite_session) is state
|
||||
assert result.manually_enabled_workspace_ids == ("workspace-2",)
|
||||
assert result.manually_disabled_workspace_ids == ("workspace-1",)
|
||||
|
||||
|
||||
def test_patch_state_disable_action_moves_current_workspace_to_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
|
||||
_persist_state(sqlite_session, state)
|
||||
def test_patch_state_complete_and_uncomplete_task() -> None:
|
||||
states = StateRepositoryStub(StepByStepTourState(account_id="account-1", completed_task_ids=("home",)))
|
||||
service = _service(states=states)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "disable_current_workspace"},
|
||||
session=sqlite_session,
|
||||
service.patch_state(_context(), StepByStepTourPatch("complete_task", "studio"))
|
||||
result = service.patch_state(_context(), StepByStepTourPatch("uncomplete_task", "home"))
|
||||
|
||||
assert result.completed_task_ids == ("studio",)
|
||||
|
||||
|
||||
def test_rejects_unsupported_task_id() -> None:
|
||||
with pytest.raises(ValueError, match="Unsupported task_id"):
|
||||
StepByStepTourService._require_task_id("unknown")
|
||||
|
||||
|
||||
def test_rejects_missing_workspace_before_using_state_repository() -> None:
|
||||
states = StateRepositoryStub()
|
||||
|
||||
with pytest.raises(RuntimeError, match="did not resolve an active workspace"):
|
||||
_service(states=states).patch_state(_context(workspace_id=None), StepByStepTourPatch("skip"))
|
||||
|
||||
assert states.mutation_account_ids == []
|
||||
|
||||
|
||||
def test_get_state_rejects_unknown_admitted_account() -> None:
|
||||
states = StateRepositoryStub()
|
||||
service = StepByStepTourService(
|
||||
accounts=_accounts(None),
|
||||
states=states,
|
||||
enabled=True,
|
||||
rollout_started_at=datetime(2026, 6, 1),
|
||||
)
|
||||
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == ["workspace-1"]
|
||||
assert _load_state(sqlite_session) is state
|
||||
|
||||
|
||||
def test_patch_state_complete_and_uncomplete_task(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.completed_task_ids = ["home"]
|
||||
_persist_state(sqlite_session, state)
|
||||
|
||||
StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "complete_task", "task_id": "studio"},
|
||||
session=sqlite_session,
|
||||
)
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "uncomplete_task", "task_id": "home"},
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result["completed_task_ids"] == ["studio"]
|
||||
|
||||
|
||||
def test_patch_state_recovers_when_concurrent_request_created_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
existing_state = _state()
|
||||
existing_state.manually_enabled_workspace_ids = ["workspace-1"]
|
||||
lifecycle_events: list[str] = []
|
||||
|
||||
@event.listens_for(sqlite_session, "before_flush", once=True)
|
||||
def add_conflicting_pending_state(session: Session, _flush_context, _instances) -> None:
|
||||
lifecycle_events.append("before_flush")
|
||||
session.add(AccountStepByStepTourState(account_id="account-1"))
|
||||
|
||||
@event.listens_for(sqlite_session, "after_soft_rollback", once=True)
|
||||
def persist_winning_request(_session: Session, _previous_transaction) -> None:
|
||||
lifecycle_events.append("after_soft_rollback")
|
||||
with sqlite_session_factory() as winner:
|
||||
winner.add(existing_state)
|
||||
winner.commit()
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-2",
|
||||
patch={"action": "enable_current_workspace"},
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"]
|
||||
assert lifecycle_events == ["before_flush", "after_soft_rollback"]
|
||||
with sqlite_session_factory() as observer:
|
||||
persisted = _load_state(observer)
|
||||
assert persisted is not None
|
||||
assert persisted.manually_enabled_workspace_ids == ["workspace-1", "workspace-2"]
|
||||
with pytest.raises(RuntimeError, match="unknown account"):
|
||||
service.get_state(_context())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user