fix(api): scope nested resource lookups by owner refs (#38177)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
WH-2099
2026-06-30 08:19:58 +00:00
committed by GitHub
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent f8afa49ab9
commit 62cb5b5865
54 changed files with 1636 additions and 506 deletions
+28 -7
View File
@@ -4,6 +4,8 @@ from uuid import UUID
from flask import abort, make_response, request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, field_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from controllers.common.errors import NoFileUploadedError, TooManyFilesError
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
@@ -30,7 +32,8 @@ from fields.annotation_fields import (
)
from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
from libs.login import current_account_with_tenant, login_required
from models.model import App
from services.annotation_service import (
AppAnnotationService,
EnableAnnotationArgs,
@@ -38,6 +41,17 @@ from services.annotation_service import (
UpdateAnnotationSettingArgs,
UpsertAnnotationArgs,
)
from services.app_ref_service import AppRef, AppRefService
def _get_app_ref(app_id: str) -> AppRef:
_, current_tenant_id = current_account_with_tenant()
app = db.session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if app is None:
raise NotFound("App not found")
return AppRefService.create_app_ref(app)
class AnnotationReplyPayload(BaseModel):
@@ -330,7 +344,8 @@ class AnnotationApi(Resource):
"message": "annotation_ids are required if the parameter is provided.",
}, 400
AppAnnotationService.delete_app_annotations_in_batch(str(app_id), annotation_ids)
app_ref = _get_app_ref(str(app_id))
AppAnnotationService.delete_app_annotations_in_batch(app_ref, annotation_ids)
return "", 204
# If no annotation_ids are provided, handle clearing all annotations
else:
@@ -389,9 +404,9 @@ class AnnotationUpdateDeleteApi(Resource):
update_args["answer"] = args.answer
if args.question is not None:
update_args["question"] = args.question
annotation = AppAnnotationService.update_app_annotation_directly(
update_args, str(app_id), str(annotation_id), db.session
)
app_ref = _get_app_ref(str(app_id))
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
annotation = AppAnnotationService.update_app_annotation_directly(update_args, annotation_ref, db.session)
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required
@@ -401,7 +416,9 @@ class AnnotationUpdateDeleteApi(Resource):
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@console_ns.response(204, "Annotation deleted successfully")
def delete(self, app_id: UUID, annotation_id: UUID):
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id), db.session)
app_ref = _get_app_ref(str(app_id))
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
AppAnnotationService.delete_app_annotation(annotation_ref, db.session)
return "", 204
@@ -514,8 +531,12 @@ class AnnotationHitHistoryListApi(Resource):
def get(self, app_id: UUID, annotation_id: UUID):
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
app_ref = _get_app_ref(str(app_id))
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
annotation_hit_history_list, total = AppAnnotationService.get_annotation_hit_histories(
str(app_id), str(annotation_id), page, limit
annotation_ref,
page,
limit,
)
history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python(
annotation_hit_history_list, from_attributes=True
+11 -2
View File
@@ -32,8 +32,9 @@ from controllers.console.wraps import (
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import login_required
from libs.login import current_user, login_required
from models import App, AppMode
from services.app_ref_service import AppRefService
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -140,13 +141,21 @@ class ChatMessageTextApi(Resource):
def post(self, app_model: App):
try:
payload = TextToSpeechPayload.model_validate(console_ns.payload)
message_ref = None
if payload.message_id:
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
payload.message_id,
account_id=current_user.id,
)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=payload.text,
voice=payload.voice,
message_id=payload.message_id,
message_ref=message_ref,
is_draft=True,
)
return response
+4 -1
View File
@@ -3,6 +3,7 @@ from typing import Any, Literal
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.fields import SimpleDataResponse
@@ -216,7 +217,9 @@ class InstructionGenerateApi(Resource):
try:
# Generate from nothing for a workflow node
if (args.current in (code_template, "")) and args.node_id != "":
app = session.get(App, args.flow_id)
app = session.scalar(
select(App).where(App.id == args.flow_id, App.tenant_id == current_tenant_id).limit(1)
)
if not app:
return {"error": f"app {args.flow_id} not found"}, 400
workflow = WorkflowService().get_draft_workflow(app_model=app, session=session)
+12 -1
View File
@@ -26,6 +26,7 @@ from libs.helper import to_timestamp
from libs.login import login_required
from models.enums import AppMCPServerStatus
from models.model import App, AppMCPServer
from services.app_ref_service import AppRefService
class MCPServerCreatePayload(BaseModel):
@@ -146,7 +147,17 @@ class AppMCPServerController(Resource):
@get_app_model
def put(self, app_model: App):
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
server = db.session.get(AppMCPServer, payload.id)
app_ref = AppRefService.create_app_ref(app_model)
server_ref = AppRefService.create_mcp_server_ref(app_ref, payload.id)
server = db.session.scalar(
select(AppMCPServer)
.where(
AppMCPServer.id == server_ref.server_id,
AppMCPServer.tenant_id == server_ref.tenant_id,
AppMCPServer.app_id == server_ref.app_id,
)
.limit(1)
)
if not server:
raise NotFound()
+6 -3
View File
@@ -78,6 +78,7 @@ from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS
from services.app_generate_service import AppGenerateService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.workflow_ref_service import WorkflowRefService
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
logger = logging.getLogger(__name__)
@@ -1406,15 +1407,15 @@ class WorkflowByIdApi(Resource):
return {"message": "No valid fields to update"}, 400
workflow_service = WorkflowService()
workflow_ref = WorkflowRefService.create_app_workflow_ref(app_model, workflow_id)
# Create a session and manage the transaction
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
workflow = workflow_service.update_workflow(
session=session,
workflow_id=workflow_id,
tenant_id=app_model.tenant_id,
account_id=current_user.id,
data=update_data,
workflow_ref=workflow_ref,
)
if not workflow:
@@ -1434,12 +1435,14 @@ class WorkflowByIdApi(Resource):
Delete workflow
"""
workflow_service = WorkflowService()
workflow_ref = WorkflowRefService.create_app_workflow_ref(app_model, workflow_id)
# Create a session and manage the transaction
with sessionmaker(db.engine).begin() as session:
try:
workflow_service.delete_workflow(
session=session, workflow_id=workflow_id, tenant_id=app_model.tenant_id
session=session,
workflow_ref=workflow_ref,
)
except WorkflowInUseError as e:
abort(400, description=str(e))
@@ -49,6 +49,7 @@ from libs.login import login_required
from models import Account, DatasetProcessRule, Document, DocumentSegment, UploadFile
from models.dataset import DocumentPipelineExecutionLog
from models.enums import IndexingStatus, SegmentStatus
from services.dataset_ref_service import DatasetRefService
from services.dataset_service import DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel
from services.file_service import FileService
@@ -474,7 +475,8 @@ class DatasetDocumentListApi(Resource):
try:
document_ids = request.args.getlist("document_id")
DocumentService.delete_documents(dataset, document_ids, db.session)
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
DocumentService.delete_documents(dataset_ref, document_ids, dataset.doc_form, db.session)
except services.errors.document.DocumentIndexingError:
raise DocumentIndexingError("Cannot delete document during indexing.")
@@ -58,8 +58,9 @@ from graphon.model_runtime.entities.model_entities import ModelType
from libs.helper import dump_response, escape_like_pattern
from libs.login import login_required
from models import Account
from models.dataset import ChildChunk, DocumentSegment
from models.dataset import Dataset, Document, DocumentSegment
from models.model import UploadFile
from services.dataset_ref_service import DatasetRefService, SegmentRef
from services.dataset_service import DatasetService, DocumentService, SegmentService
from services.entities.knowledge_entities.knowledge_entities import ChildChunkUpdateArgs, SegmentUpdateArgs
from services.errors.chunk import ChildChunkDeleteIndexError as ChildChunkDeleteIndexServiceError
@@ -162,6 +163,21 @@ register_response_schema_models(
)
def _get_segment_for_document(
dataset: Dataset, document: Document, segment_id: str
) -> tuple[SegmentRef, DocumentSegment]:
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
document_ref = DatasetRefService.create_document_ref(dataset_ref, document)
if document_ref is None:
raise NotFound("Document not found.")
segment_ref = DatasetRefService.create_segment_ref(document_ref, segment_id)
segment = SegmentService.get_segment_by_ref(segment_ref)
if not segment:
raise NotFound("Segment not found.")
return segment_ref, segment
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/segments")
class DatasetDocumentSegmentListApi(Resource):
@console_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT)
@@ -465,6 +481,13 @@ class DatasetDocumentSegmentUpdateApi(Resource):
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
if not document:
raise NotFound("Document not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
# check embedding model setting
try:
@@ -481,22 +504,8 @@ class DatasetDocumentSegmentUpdateApi(Resource):
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
# check segment
segment_id_str = str(segment_id)
segment = db.session.scalar(
select(DocumentSegment)
.where(DocumentSegment.id == segment_id_str, DocumentSegment.tenant_id == current_tenant_id)
.limit(1)
)
if not segment:
raise NotFound("Segment not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
# validate args
payload = SegmentUpdatePayload.model_validate(console_ns.payload or {})
payload_dict = payload.model_dump(exclude_none=True)
@@ -541,15 +550,6 @@ class DatasetDocumentSegmentUpdateApi(Resource):
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
if not document:
raise NotFound("Document not found.")
# check segment
segment_id_str = str(segment_id)
segment = db.session.scalar(
select(DocumentSegment)
.where(DocumentSegment.id == segment_id_str, DocumentSegment.tenant_id == current_tenant_id)
.limit(1)
)
if not segment:
raise NotFound("Segment not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
@@ -557,6 +557,8 @@ class DatasetDocumentSegmentUpdateApi(Resource):
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
SegmentService.delete_segment(segment, document, dataset, db.session)
return "", 204
@@ -663,17 +665,12 @@ class ChildChunkAddApi(Resource):
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
if not document:
raise NotFound("Document not found.")
# check segment
segment_id_str = str(segment_id)
segment = db.session.scalar(
select(DocumentSegment)
.where(DocumentSegment.id == segment_id_str, DocumentSegment.tenant_id == current_tenant_id)
.limit(1)
)
if not segment:
raise NotFound("Segment not found.")
if not current_user.is_dataset_editor:
raise Forbidden()
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
# check embedding model setting
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
try:
@@ -690,10 +687,8 @@ class ChildChunkAddApi(Resource):
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
try:
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
# validate args
try:
payload = ChildChunkCreatePayload.model_validate(console_ns.payload or {})
@@ -723,15 +718,8 @@ class ChildChunkAddApi(Resource):
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
if not document:
raise NotFound("Document not found.")
# check segment
segment_id_str = str(segment_id)
segment = db.session.scalar(
select(DocumentSegment)
.where(DocumentSegment.id == segment_id_str, DocumentSegment.tenant_id == current_tenant_id)
.limit(1)
)
if not segment:
raise NotFound("Segment not found.")
_get_segment_for_document(dataset, document, segment_id_str)
args = query_params_from_request(ChildChunkListQuery, use_defaults_for_malformed_ints=True)
page = args.page
@@ -780,15 +768,6 @@ class ChildChunkAddApi(Resource):
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
if not document:
raise NotFound("Document not found.")
# check segment
segment_id_str = str(segment_id)
segment = db.session.scalar(
select(DocumentSegment)
.where(DocumentSegment.id == segment_id_str, DocumentSegment.tenant_id == current_tenant_id)
.limit(1)
)
if not segment:
raise NotFound("Segment not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
@@ -796,6 +775,8 @@ class ChildChunkAddApi(Resource):
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
# validate args
payload = ChildChunkBatchUpdatePayload.model_validate(console_ns.payload or {})
try:
@@ -839,29 +820,6 @@ class ChildChunkUpdateApi(Resource):
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
if not document:
raise NotFound("Document not found.")
# check segment
segment_id_str = str(segment_id)
segment = db.session.scalar(
select(DocumentSegment)
.where(DocumentSegment.id == segment_id_str, DocumentSegment.tenant_id == current_tenant_id)
.limit(1)
)
if not segment:
raise NotFound("Segment not found.")
# check child chunk
child_chunk_id_str = str(child_chunk_id)
child_chunk = db.session.scalar(
select(ChildChunk)
.where(
ChildChunk.id == child_chunk_id_str,
ChildChunk.tenant_id == current_tenant_id,
ChildChunk.segment_id == segment.id,
ChildChunk.document_id == document_id_str,
)
.limit(1)
)
if not child_chunk:
raise NotFound("Child chunk not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
@@ -869,6 +827,12 @@ class ChildChunkUpdateApi(Resource):
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
segment_ref, _ = _get_segment_for_document(dataset, document, segment_id_str)
child_chunk_id_str = str(child_chunk_id)
child_chunk = SegmentService.get_child_chunk_by_segment_ref(child_chunk_id_str, segment_ref)
if not child_chunk:
raise NotFound("Child chunk not found.")
try:
SegmentService.delete_child_chunk(child_chunk, dataset, db.session)
except ChildChunkDeleteIndexServiceError as e:
@@ -907,29 +871,6 @@ class ChildChunkUpdateApi(Resource):
document = DocumentService.get_document(dataset_id_str, document_id_str, session=db.session)
if not document:
raise NotFound("Document not found.")
# check segment
segment_id_str = str(segment_id)
segment = db.session.scalar(
select(DocumentSegment)
.where(DocumentSegment.id == segment_id_str, DocumentSegment.tenant_id == current_tenant_id)
.limit(1)
)
if not segment:
raise NotFound("Segment not found.")
# check child chunk
child_chunk_id_str = str(child_chunk_id)
child_chunk = db.session.scalar(
select(ChildChunk)
.where(
ChildChunk.id == child_chunk_id_str,
ChildChunk.tenant_id == current_tenant_id,
ChildChunk.segment_id == segment.id,
ChildChunk.document_id == document_id_str,
)
.limit(1)
)
if not child_chunk:
raise NotFound("Child chunk not found.")
# The role of the current user in the ta table must be admin, owner, dataset_operator, or editor
if not current_user.is_dataset_editor:
raise Forbidden()
@@ -937,6 +878,12 @@ class ChildChunkUpdateApi(Resource):
DatasetService.check_dataset_permission(dataset, current_user, db.session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
segment_id_str = str(segment_id)
segment_ref, segment = _get_segment_for_document(dataset, document, segment_id_str)
child_chunk_id_str = str(child_chunk_id)
child_chunk = SegmentService.get_child_chunk_by_segment_ref(child_chunk_id_str, segment_ref)
if not child_chunk:
raise NotFound("Child chunk not found.")
# validate args
try:
payload = ChildChunkUpdatePayload.model_validate(console_ns.payload or {})
@@ -64,6 +64,7 @@ from services.rag_pipeline.pipeline_generate_service import PipelineGenerateServ
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.rag_pipeline.rag_pipeline_manage_service import RagPipelineManageService
from services.rag_pipeline.rag_pipeline_transform_service import RagPipelineTransformService
from services.workflow_ref_service import WorkflowRefService
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
logger = logging.getLogger(__name__)
@@ -738,15 +739,15 @@ class RagPipelineByIdApi(Resource):
return {"message": "No valid fields to update"}, 400
rag_pipeline_service = RagPipelineService()
workflow_ref = WorkflowRefService.create_pipeline_workflow_ref(pipeline, workflow_id)
# Create a session and manage the transaction
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
workflow = rag_pipeline_service.update_workflow(
session=session,
workflow_id=workflow_id,
tenant_id=pipeline.tenant_id,
account_id=current_user.id,
data=update_data,
workflow_ref=workflow_ref,
)
if not workflow:
@@ -769,13 +770,13 @@ class RagPipelineByIdApi(Resource):
abort(400, description=f"Cannot delete workflow that is currently in use by pipeline '{pipeline.id}'")
workflow_service = WorkflowService()
workflow_ref = WorkflowRefService.create_pipeline_workflow_ref(pipeline, workflow_id)
with sessionmaker(db.engine).begin() as session:
try:
workflow_service.delete_workflow(
session=session,
workflow_id=workflow_id,
tenant_id=pipeline.tenant_id,
workflow_ref=workflow_ref,
)
except WorkflowInUseError as e:
abort(400, description=str(e))
+12 -1
View File
@@ -22,7 +22,9 @@ from controllers.console.explore.wraps import InstalledAppResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import current_account_with_tenant
from models.model import InstalledApp
from services.app_ref_service import AppRefService
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -99,13 +101,22 @@ class ChatTextApi(InstalledAppResource):
message_id = payload.message_id
text = payload.text
voice = payload.voice
message_ref = None
if message_id:
current_user, _ = current_account_with_tenant()
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
message_id,
account_id=current_user.id,
)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=text,
voice=voice,
message_id=message_id,
message_ref=message_ref,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
+10 -1
View File
@@ -81,6 +81,7 @@ from models.account import TenantStatus
from models.model import AppMode, Site
from models.workflow import Workflow
from services.app_generate_service import AppGenerateService
from services.app_ref_service import AppRefService
from services.app_service import AppService
from services.audio_service import AudioService
from services.dataset_service import DatasetService
@@ -414,6 +415,14 @@ class TrialChatTextApi(TrialAppResource):
message_id = request_data.message_id
text = request_data.text
voice = request_data.voice
message_ref = None
if message_id:
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
message_id,
account_id=current_user.id,
)
# Get IDs before they might be detached from session
app_id = app_model.id
@@ -424,7 +433,7 @@ class TrialChatTextApi(TrialAppResource):
session=db.session,
text=text,
voice=voice,
message_id=message_id,
message_ref=message_ref,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
return response
+2 -1
View File
@@ -117,7 +117,8 @@ def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
if not dify_config.RBAC_ENABLED:
return
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id).limit(1))
_, current_tenant_id = current_account_with_tenant()
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id, Tag.tenant_id == current_tenant_id).limit(1))
_enforce_snippet_tag_rbac_if_needed(tag_type)
@@ -21,6 +21,7 @@ from services.annotation_service import (
InsertAnnotationArgs,
UpdateAnnotationArgs,
)
from services.app_ref_service import AppRefService
class AnnotationCreatePayload(BaseModel):
@@ -282,9 +283,9 @@ class AnnotationUpdateDeleteApi(Resource):
"""Update an existing annotation."""
payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {})
update_args: UpdateAnnotationArgs = {"question": payload.question, "answer": payload.answer}
annotation = AppAnnotationService.update_app_annotation_directly(
update_args, app_model.id, str(annotation_id), db.session
)
app_ref = AppRefService.create_app_ref(app_model)
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
annotation = AppAnnotationService.update_app_annotation_directly(update_args, annotation_ref, db.session)
response = Annotation.model_validate(annotation, from_attributes=True)
return response.model_dump(mode="json")
@@ -313,5 +314,7 @@ class AnnotationUpdateDeleteApi(Resource):
@edit_permission_required
def delete(self, app_model: App, annotation_id: UUID):
"""Delete an annotation."""
AppAnnotationService.delete_app_annotation(app_model.id, str(annotation_id), db.session)
app_ref = AppRefService.create_app_ref(app_model)
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
AppAnnotationService.delete_app_annotation(annotation_ref, db.session)
return "", 204
+10 -1
View File
@@ -26,6 +26,7 @@ from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotIni
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from models.model import App, EndUser
from services.app_ref_service import AppRefService
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -177,13 +178,21 @@ class TextApi(Resource):
message_id = payload.message_id
text = payload.text
voice = payload.voice
message_ref = None
if message_id:
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
message_id,
end_user_id=end_user.id,
)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=text,
voice=voice,
end_user=end_user.external_user_id,
message_id=message_id,
message_ref=message_ref,
)
return response
@@ -943,9 +943,11 @@ class DatasetTagsApi(DatasetApiResource):
payload = TagUpdatePayload.model_validate(service_api_ns.payload or {})
tag_id = payload.tag_id
tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id, db.session)
tag = TagService.update_tags(
UpdateTagServicePayload(name=payload.name), tag_id, db.session, tag_type=TagType.KNOWLEDGE
)
binding_count = TagService.get_tag_binding_count(tag_id, db.session)
binding_count = TagService.get_tag_binding_count(tag_id, db.session, tag_type=TagType.KNOWLEDGE)
response = dump_response(
KnowledgeTagResponse,
@@ -975,7 +977,7 @@ class DatasetTagsApi(DatasetApiResource):
def delete(self, _):
"""Delete a knowledge type tag."""
payload = TagDeletePayload.model_validate(service_api_ns.payload or {})
TagService.delete_tag(payload.tag_id, db.session)
TagService.delete_tag(payload.tag_id, db.session, tag_type=TagType.KNOWLEDGE)
return "", 204
+31 -70
View File
@@ -37,7 +37,8 @@ from fields.segment_fields import (
from graphon.model_runtime.entities.model_entities import ModelType
from libs.helper import dump_response
from libs.login import current_account_with_tenant
from models.dataset import Dataset, DocumentSegment
from models.dataset import Dataset, Document, DocumentSegment
from services.dataset_ref_service import DatasetRefService, SegmentRef
from services.dataset_service import DatasetService, DocumentService, SegmentService
from services.entities.knowledge_entities.knowledge_entities import SegmentUpdateArgs
from services.errors.chunk import ChildChunkDeleteIndexError, ChildChunkIndexingError
@@ -127,6 +128,21 @@ register_response_schema_models(
)
def _get_segment_for_document(
dataset: Dataset, document: Document, segment_id: str
) -> tuple[SegmentRef, DocumentSegment]:
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
document_ref = DatasetRefService.create_document_ref(dataset_ref, document)
if document_ref is None:
raise NotFound("Document not found.")
segment_ref = DatasetRefService.create_segment_ref(document_ref, segment_id)
segment = SegmentService.get_segment_by_ref(segment_ref)
if not segment:
raise NotFound("Segment not found.")
return segment_ref, segment
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/segments")
class SegmentApi(DatasetApiResource):
"""Resource for segments."""
@@ -339,7 +355,7 @@ class DatasetSegmentApi(DatasetApiResource):
)
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def delete(self, tenant_id: str, dataset_id: UUID, document_id: UUID, segment_id: UUID):
_, current_tenant_id = current_account_with_tenant()
current_account_with_tenant()
dataset_id_str = str(dataset_id)
# check dataset
dataset = db.session.scalar(
@@ -355,12 +371,7 @@ class DatasetSegmentApi(DatasetApiResource):
if not document:
raise NotFound("Document not found.")
segment_id_str = str(segment_id)
# check segment
segment = SegmentService.get_segment_by_id(
segment_id=segment_id_str, tenant_id=current_tenant_id, session=db.session
)
if not segment:
raise NotFound("Segment not found.")
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
SegmentService.delete_segment(segment, document, dataset, db.session)
return "", 204
@@ -419,12 +430,7 @@ class DatasetSegmentApi(DatasetApiResource):
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
segment_id_str = str(segment_id)
# check segment
segment = SegmentService.get_segment_by_id(
segment_id=segment_id_str, tenant_id=current_tenant_id, session=db.session
)
if not segment:
raise NotFound("Segment not found.")
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
payload = SegmentUpdatePayload.model_validate(service_api_ns.payload or {})
@@ -463,7 +469,7 @@ class DatasetSegmentApi(DatasetApiResource):
service_api_ns.models[SegmentDetailResponse.__name__],
)
def get(self, tenant_id: str, dataset_id: UUID, document_id: UUID, segment_id: UUID):
_, current_tenant_id = current_account_with_tenant()
current_account_with_tenant()
dataset_id_str = str(dataset_id)
# check dataset
dataset = db.session.scalar(
@@ -479,12 +485,7 @@ class DatasetSegmentApi(DatasetApiResource):
if not document:
raise NotFound("Document not found.")
segment_id_str = str(segment_id)
# check segment
segment = SegmentService.get_segment_by_id(
segment_id=segment_id_str, tenant_id=current_tenant_id, session=db.session
)
if not segment:
raise NotFound("Segment not found.")
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
summary = SummaryIndexService.get_segment_summary(segment_id=segment.id, dataset_id=dataset_id_str)
response = {
@@ -546,12 +547,7 @@ class ChildChunkApi(DatasetApiResource):
raise NotFound("Document not found.")
segment_id_str = str(segment_id)
# check segment
segment = SegmentService.get_segment_by_id(
segment_id=segment_id_str, tenant_id=current_tenant_id, session=db.session
)
if not segment:
raise NotFound("Segment not found.")
_, segment = _get_segment_for_document(dataset, document, segment_id_str)
# check embedding model setting
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
@@ -605,7 +601,7 @@ class ChildChunkApi(DatasetApiResource):
service_api_ns.models[ChildChunkListResponse.__name__],
)
def get(self, tenant_id: str, dataset_id: UUID, document_id: UUID, segment_id: UUID):
_, current_tenant_id = current_account_with_tenant()
current_account_with_tenant()
"""Get child chunks."""
dataset_id_str = str(dataset_id)
# check dataset
@@ -622,12 +618,7 @@ class ChildChunkApi(DatasetApiResource):
raise NotFound("Document not found.")
segment_id_str = str(segment_id)
# check segment
segment = SegmentService.get_segment_by_id(
segment_id=segment_id_str, tenant_id=current_tenant_id, session=db.session
)
if not segment:
raise NotFound("Segment not found.")
_get_segment_for_document(dataset, document, segment_id_str)
args = query_params_from_request(ChildChunkListQuery, use_defaults_for_malformed_ints=True)
@@ -677,7 +668,7 @@ class DatasetChildChunkApi(DatasetApiResource):
@cloud_edition_billing_knowledge_limit_check("add_segment", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def delete(self, tenant_id: str, dataset_id: UUID, document_id: UUID, segment_id: UUID, child_chunk_id: UUID):
_, current_tenant_id = current_account_with_tenant()
current_account_with_tenant()
"""Delete child chunk."""
dataset_id_str = str(dataset_id)
# check dataset
@@ -694,29 +685,14 @@ class DatasetChildChunkApi(DatasetApiResource):
raise NotFound("Document not found.")
segment_id_str = str(segment_id)
# check segment
segment = SegmentService.get_segment_by_id(
segment_id=segment_id_str, tenant_id=current_tenant_id, session=db.session
)
if not segment:
raise NotFound("Segment not found.")
# validate segment belongs to the specified document
if segment.document_id != document_id_str:
raise NotFound("Document not found.")
segment_ref, _ = _get_segment_for_document(dataset, document, segment_id_str)
child_chunk_id_str = str(child_chunk_id)
# check child chunk
child_chunk = SegmentService.get_child_chunk_by_id(
child_chunk_id=child_chunk_id_str, tenant_id=current_tenant_id, session=db.session
)
child_chunk = SegmentService.get_child_chunk_by_segment_ref(child_chunk_id_str, segment_ref)
if not child_chunk:
raise NotFound("Child chunk not found.")
# validate child chunk belongs to the specified segment
if child_chunk.segment_id != segment.id:
raise NotFound("Child chunk not found.")
try:
SegmentService.delete_child_chunk(child_chunk, dataset, db.session)
except ChildChunkDeleteIndexServiceError as e:
@@ -753,7 +729,7 @@ class DatasetChildChunkApi(DatasetApiResource):
@cloud_edition_billing_knowledge_limit_check("add_segment", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def patch(self, tenant_id: str, dataset_id: UUID, document_id: UUID, segment_id: UUID, child_chunk_id: UUID):
_, current_tenant_id = current_account_with_tenant()
current_account_with_tenant()
"""Update child chunk."""
dataset_id_str = str(dataset_id)
# check dataset
@@ -770,29 +746,14 @@ class DatasetChildChunkApi(DatasetApiResource):
raise NotFound("Document not found.")
segment_id_str = str(segment_id)
# get segment
segment = SegmentService.get_segment_by_id(
segment_id=segment_id_str, tenant_id=current_tenant_id, session=db.session
)
if not segment:
raise NotFound("Segment not found.")
# validate segment belongs to the specified document
if segment.document_id != document_id_str:
raise NotFound("Segment not found.")
segment_ref, segment = _get_segment_for_document(dataset, document, segment_id_str)
child_chunk_id_str = str(child_chunk_id)
# get child chunk
child_chunk = SegmentService.get_child_chunk_by_id(
child_chunk_id=child_chunk_id_str, tenant_id=current_tenant_id, session=db.session
)
child_chunk = SegmentService.get_child_chunk_by_segment_ref(child_chunk_id_str, segment_ref)
if not child_chunk:
raise NotFound("Child chunk not found.")
# validate child chunk belongs to the specified segment
if child_chunk.segment_id != segment.id:
raise NotFound("Child chunk not found.")
# validate args
payload = ChildChunkUpdatePayload.model_validate(service_api_ns.payload or {})
+10 -1
View File
@@ -26,6 +26,7 @@ from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import uuid_value
from models.model import App, EndUser
from services.app_ref_service import AppRefService
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -130,13 +131,21 @@ class TextApi(WebApiResource):
message_id = payload.message_id
text = payload.text
voice = payload.voice
message_ref = None
if message_id:
app_ref = AppRefService.create_app_ref(app_model)
message_ref = AppRefService.create_message_ref(
app_ref,
message_id,
end_user_id=end_user.id,
)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=text,
voice=voice,
end_user=end_user.external_user_id,
message_id=message_id,
message_ref=message_ref,
)
return response
+6 -2
View File
@@ -498,7 +498,11 @@ class LLMGenerator:
ideal_output: str | None,
):
last_run: Message | None = db.session.scalar(
select(Message).where(Message.app_id == flow_id).order_by(Message.created_at.desc()).limit(1)
select(Message)
.join(App, App.id == Message.app_id)
.where(Message.app_id == flow_id, App.tenant_id == tenant_id)
.order_by(Message.created_at.desc())
.limit(1)
)
if not last_run:
return LLMGenerator.__instruction_modify_common(
@@ -540,7 +544,7 @@ class LLMGenerator:
):
session = db.session()
app: App | None = session.scalar(select(App).where(App.id == flow_id).limit(1))
app: App | None = session.scalar(select(App).where(App.id == flow_id, App.tenant_id == tenant_id).limit(1))
if not app:
raise ValueError("App not found.")
workflow = workflow_service.get_draft_workflow(app_model=app)
+43 -55
View File
@@ -14,6 +14,7 @@ from extensions.ext_redis import redis_client
from libs.datetime_utils import naive_utc_now
from libs.login import current_account_with_tenant
from models.model import App, AppAnnotationHitHistory, AppAnnotationSetting, Message, MessageAnnotation
from services.app_ref_service import AnnotationRef, AppRef
from services.feature_service import FeatureService
from tasks.annotation.add_annotation_to_index_task import add_annotation_to_index_task
from tasks.annotation.batch_import_annotations_task import batch_import_annotations_task
@@ -88,6 +89,17 @@ class UpdateAnnotationSettingArgs(TypedDict):
class AppAnnotationService:
@staticmethod
def _get_annotation_by_ref(annotation_ref: AnnotationRef, session: scoped_session) -> MessageAnnotation | None:
return session.scalar(
select(MessageAnnotation)
.where(
MessageAnnotation.id == annotation_ref.annotation_id,
MessageAnnotation.app_id == annotation_ref.app_id,
)
.limit(1)
)
@classmethod
def up_insert_app_annotation_from_message(cls, args: UpsertAnnotationArgs, app_id: str) -> MessageAnnotation:
# get app info
@@ -302,18 +314,9 @@ class AppAnnotationService:
@classmethod
def update_app_annotation_directly(
cls, args: UpdateAnnotationArgs, app_id: str, annotation_id: str, session: scoped_session
cls, args: UpdateAnnotationArgs, annotation_ref: AnnotationRef, session: scoped_session
):
# get app info
_, current_tenant_id = current_account_with_tenant()
app = session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if not app:
raise NotFound("App not found")
annotation = session.get(MessageAnnotation, annotation_id)
annotation = cls._get_annotation_by_ref(annotation_ref, session)
if not annotation:
raise NotFound("Annotation not found")
@@ -332,32 +335,23 @@ class AppAnnotationService:
session.commit()
# if annotation reply is enabled , add annotation to index
app_annotation_setting = session.scalar(
select(AppAnnotationSetting).where(AppAnnotationSetting.app_id == app_id).limit(1)
select(AppAnnotationSetting).where(AppAnnotationSetting.app_id == annotation_ref.app_id).limit(1)
)
if app_annotation_setting:
update_annotation_to_index_task.delay(
annotation.id,
annotation.question_text,
current_tenant_id,
app_id,
annotation_ref.tenant_id,
annotation_ref.app_id,
app_annotation_setting.collection_binding_id,
)
return annotation
@classmethod
def delete_app_annotation(cls, app_id: str, annotation_id: str, session: scoped_session):
# get app info
_, current_tenant_id = current_account_with_tenant()
app = session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if not app:
raise NotFound("App not found")
annotation = session.get(MessageAnnotation, annotation_id)
def delete_app_annotation(cls, annotation_ref: AnnotationRef, session: scoped_session):
annotation = cls._get_annotation_by_ref(annotation_ref, session)
if not annotation:
raise NotFound("Annotation not found")
@@ -365,7 +359,10 @@ class AppAnnotationService:
session.delete(annotation)
annotation_hit_histories = session.scalars(
select(AppAnnotationHitHistory).where(AppAnnotationHitHistory.annotation_id == annotation_id)
select(AppAnnotationHitHistory).where(
AppAnnotationHitHistory.app_id == annotation_ref.app_id,
AppAnnotationHitHistory.annotation_id == annotation_ref.annotation_id,
)
).all()
if annotation_hit_histories:
for annotation_hit_history in annotation_hit_histories:
@@ -374,30 +371,24 @@ class AppAnnotationService:
session.commit()
# if annotation reply is enabled , delete annotation index
app_annotation_setting = session.scalar(
select(AppAnnotationSetting).where(AppAnnotationSetting.app_id == app_id).limit(1)
select(AppAnnotationSetting).where(AppAnnotationSetting.app_id == annotation_ref.app_id).limit(1)
)
if app_annotation_setting:
delete_annotation_index_task.delay(
annotation.id, app_id, current_tenant_id, app_annotation_setting.collection_binding_id
annotation.id,
annotation_ref.app_id,
annotation_ref.tenant_id,
app_annotation_setting.collection_binding_id,
)
@classmethod
def delete_app_annotations_in_batch(cls, app_id: str, annotation_ids: list[str]):
# get app info
_, current_tenant_id = current_account_with_tenant()
app = db.session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if not app:
raise NotFound("App not found")
def delete_app_annotations_in_batch(cls, app_ref: AppRef, annotation_ids: list[str]):
# Fetch annotations and their settings in a single query
annotations_to_delete = db.session.execute(
select(MessageAnnotation, AppAnnotationSetting)
.outerjoin(AppAnnotationSetting, MessageAnnotation.app_id == AppAnnotationSetting.app_id)
.where(MessageAnnotation.id.in_(annotation_ids))
.where(MessageAnnotation.id.in_(annotation_ids), MessageAnnotation.app_id == app_ref.app_id)
).all()
if not annotations_to_delete:
@@ -408,19 +399,25 @@ class AppAnnotationService:
# Step 2: Bulk delete hit histories in a single query
db.session.execute(
delete(AppAnnotationHitHistory).where(AppAnnotationHitHistory.annotation_id.in_(annotation_ids_to_delete))
delete(AppAnnotationHitHistory).where(
AppAnnotationHitHistory.app_id == app_ref.app_id,
AppAnnotationHitHistory.annotation_id.in_(annotation_ids_to_delete),
)
)
# Step 3: Trigger async tasks for search index deletion
for annotation, annotation_setting in annotations_to_delete:
if annotation_setting:
delete_annotation_index_task.delay(
annotation.id, app_id, current_tenant_id, annotation_setting.collection_binding_id
annotation.id, app_ref.app_id, app_ref.tenant_id, annotation_setting.collection_binding_id
)
# Step 4: Bulk delete annotations in a single query
delete_result = db.session.execute(
delete(MessageAnnotation).where(MessageAnnotation.id.in_(annotation_ids_to_delete))
delete(MessageAnnotation).where(
MessageAnnotation.id.in_(annotation_ids_to_delete),
MessageAnnotation.app_id == app_ref.app_id,
)
)
deleted_count = getattr(delete_result, "rowcount", 0)
@@ -562,17 +559,8 @@ class AppAnnotationService:
return {"job_id": job_id, "job_status": "waiting", "record_count": len(result)}
@classmethod
def get_annotation_hit_histories(cls, app_id: str, annotation_id: str, page, limit):
_, current_tenant_id = current_account_with_tenant()
# get app info
app = db.session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if not app:
raise NotFound("App not found")
annotation = db.session.get(MessageAnnotation, annotation_id)
def get_annotation_hit_histories(cls, annotation_ref: AnnotationRef, page, limit):
annotation = cls._get_annotation_by_ref(annotation_ref, db.session)
if not annotation:
raise NotFound("Annotation not found")
@@ -580,8 +568,8 @@ class AppAnnotationService:
stmt = (
select(AppAnnotationHitHistory)
.where(
AppAnnotationHitHistory.app_id == app_id,
AppAnnotationHitHistory.annotation_id == annotation_id,
AppAnnotationHitHistory.app_id == annotation_ref.app_id,
AppAnnotationHitHistory.annotation_id == annotation_ref.annotation_id,
)
.order_by(AppAnnotationHitHistory.created_at.desc())
)
+70
View File
@@ -0,0 +1,70 @@
"""Typed resource references for app ownership chains."""
from typing import NamedTuple
from models.model import App
class AppRef(NamedTuple):
"""App identifiers used to scope downstream resource lookups."""
tenant_id: str
app_id: str
class MessageRef(NamedTuple):
"""Message identifiers used to scope downstream resource lookups."""
tenant_id: str
app_id: str
message_id: str
end_user_id: str | None = None
account_id: str | None = None
class AnnotationRef(NamedTuple):
"""Annotation identifiers used to scope downstream resource lookups."""
tenant_id: str
app_id: str
annotation_id: str
class AppMCPServerRef(NamedTuple):
"""MCP server identifiers used to scope downstream resource lookups."""
tenant_id: str
app_id: str
server_id: str
class AppRefService:
"""Factory helpers for app and child resource refs."""
@staticmethod
def create_app_ref(app: App) -> AppRef:
return AppRef(tenant_id=app.tenant_id, app_id=app.id)
@staticmethod
def create_message_ref(
app_ref: AppRef,
message_id: str,
*,
end_user_id: str | None = None,
account_id: str | None = None,
) -> MessageRef:
return MessageRef(
tenant_id=app_ref.tenant_id,
app_id=app_ref.app_id,
message_id=message_id,
end_user_id=end_user_id,
account_id=account_id,
)
@staticmethod
def create_annotation_ref(app_ref: AppRef, annotation_id: str) -> AnnotationRef:
return AnnotationRef(tenant_id=app_ref.tenant_id, app_id=app_ref.app_id, annotation_id=annotation_id)
@staticmethod
def create_mcp_server_ref(app_ref: AppRef, server_id: str) -> AppMCPServerRef:
return AppMCPServerRef(tenant_id=app_ref.tenant_id, app_id=app_ref.app_id, server_id=server_id)
+15 -4
View File
@@ -5,6 +5,7 @@ from collections.abc import Generator
from typing import cast
from flask import Response, stream_with_context
from sqlalchemy import select
from sqlalchemy.orm import Session, scoped_session
from werkzeug.datastructures import FileStorage
@@ -13,6 +14,7 @@ from core.model_manager import ModelManager
from graphon.model_runtime.entities.model_entities import ModelType
from models.enums import MessageStatus
from models.model import App, AppMode, Message
from services.app_ref_service import MessageRef
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
@@ -29,6 +31,15 @@ logger = logging.getLogger(__name__)
class AudioService:
@staticmethod
def _get_message_by_ref(session: Session | scoped_session, message_ref: MessageRef) -> Message | None:
stmt = select(Message).where(Message.id == message_ref.message_id, Message.app_id == message_ref.app_id)
if message_ref.end_user_id is not None:
stmt = stmt.where(Message.from_end_user_id == message_ref.end_user_id)
if message_ref.account_id is not None:
stmt = stmt.where(Message.from_account_id == message_ref.account_id)
return session.scalar(stmt.limit(1))
@classmethod
def transcript_asr(cls, app_model: App, file: FileStorage | None, end_user: str | None = None):
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
@@ -82,7 +93,7 @@ class AudioService:
text: str | None = None,
voice: str | None = None,
end_user: str | None = None,
message_id: str | None = None,
message_ref: MessageRef | None = None,
is_draft: bool = False,
):
def invoke_tts(text_content: str, app_model: App, voice: str | None = None, is_draft: bool = False):
@@ -129,12 +140,12 @@ class AudioService:
except Exception as e:
raise e
if message_id:
if message_ref:
try:
uuid.UUID(message_id)
uuid.UUID(message_ref.message_id)
except ValueError:
return None
message = session.get(Message, message_id)
message = cls._get_message_by_ref(session, message_ref)
if message is None:
return None
if message.answer == "" and message.status in {MessageStatus.NORMAL, MessageStatus.PAUSED}:
+56
View File
@@ -0,0 +1,56 @@
"""Typed resource references for dataset ownership chains."""
from typing import NamedTuple
from models.dataset import Dataset, Document
class DatasetRef(NamedTuple):
"""Dataset identifiers used to scope downstream resource lookups."""
tenant_id: str
dataset_id: str
class DocumentRef(NamedTuple):
"""Document identifiers used to scope downstream resource lookups."""
tenant_id: str
dataset_id: str
document_id: str
class SegmentRef(NamedTuple):
"""Segment identifiers used to scope downstream resource lookups."""
tenant_id: str
dataset_id: str
document_id: str
segment_id: str
class DatasetRefService:
"""Factory helpers for dataset, document, and segment refs."""
@staticmethod
def create_dataset_ref(dataset: Dataset) -> DatasetRef:
return DatasetRef(tenant_id=dataset.tenant_id, dataset_id=dataset.id)
@staticmethod
def create_document_ref(dataset_ref: DatasetRef, document: Document) -> DocumentRef | None:
if document.tenant_id != dataset_ref.tenant_id or document.dataset_id != dataset_ref.dataset_id:
return None
return DocumentRef(
tenant_id=dataset_ref.tenant_id,
dataset_id=dataset_ref.dataset_id,
document_id=document.id,
)
@staticmethod
def create_segment_ref(document_ref: DocumentRef, segment_id: str) -> SegmentRef:
return SegmentRef(
tenant_id=document_ref.tenant_id,
dataset_id=document_ref.dataset_id,
document_id=document_ref.document_id,
segment_id=segment_id,
)
+48 -4
View File
@@ -64,6 +64,7 @@ from models.model import UploadFile
from models.provider_ids import ModelProviderID
from models.source import DataSourceOauthBinding
from models.workflow import Workflow
from services.dataset_ref_service import DatasetRef, SegmentRef
from services.document_indexing_proxy.document_indexing_task_proxy import DocumentIndexingTaskProxy
from services.document_indexing_proxy.duplicate_document_indexing_task_proxy import DuplicateDocumentIndexingTaskProxy
from services.enterprise import rbac_service as enterprise_rbac_service
@@ -1970,11 +1971,23 @@ class DocumentService:
session.commit()
@staticmethod
def delete_documents(dataset: Dataset, document_ids: list[str], session: scoped_session | Session):
def delete_documents(
dataset_ref: DatasetRef,
document_ids: list[str],
doc_form: str | None,
session: scoped_session | Session,
):
# Check if document_ids is not empty to avoid WHERE false condition
if not document_ids or len(document_ids) == 0:
return
documents = session.scalars(select(Document).where(Document.id.in_(document_ids))).all()
documents = session.scalars(
select(Document).where(
Document.id.in_(document_ids),
Document.tenant_id == dataset_ref.tenant_id,
Document.dataset_id == dataset_ref.dataset_id,
)
).all()
deleted_document_ids = [document.id for document in documents]
file_ids = [
document.data_source_info_dict.get("upload_file_id", "")
for document in documents
@@ -1989,8 +2002,8 @@ class DocumentService:
# Dispatch cleanup task after commit to avoid lock contention
# Task cleans up segments, files, and vector indexes
if dataset.doc_form is not None:
batch_clean_document_task.delay(document_ids, dataset.id, dataset.doc_form, file_ids)
if deleted_document_ids and doc_form is not None:
batch_clean_document_task.delay(deleted_document_ids, dataset_ref.dataset_id, doc_form, file_ids)
@staticmethod
def rename_document(dataset_id: str, document_id: str, name: str, session: scoped_session | Session) -> Document:
@@ -4120,6 +4133,22 @@ class SegmentService:
)
return result if isinstance(result, ChildChunk) else None
@classmethod
def get_child_chunk_by_segment_ref(cls, child_chunk_id: str, segment_ref: SegmentRef) -> ChildChunk | None:
"""Get a child chunk through the full tenant/dataset/document/segment chain."""
result = db.session.scalar(
select(ChildChunk)
.where(
ChildChunk.id == child_chunk_id,
ChildChunk.tenant_id == segment_ref.tenant_id,
ChildChunk.dataset_id == segment_ref.dataset_id,
ChildChunk.document_id == segment_ref.document_id,
ChildChunk.segment_id == segment_ref.segment_id,
)
.limit(1)
)
return result if isinstance(result, ChildChunk) else None
@classmethod
def get_segments(
cls,
@@ -4160,6 +4189,21 @@ class SegmentService:
)
return result if isinstance(result, DocumentSegment) else None
@classmethod
def get_segment_by_ref(cls, segment_ref: SegmentRef) -> DocumentSegment | None:
"""Get a segment through the full tenant/dataset/document ownership chain."""
result = db.session.scalar(
select(DocumentSegment)
.where(
DocumentSegment.id == segment_ref.segment_id,
DocumentSegment.tenant_id == segment_ref.tenant_id,
DocumentSegment.dataset_id == segment_ref.dataset_id,
DocumentSegment.document_id == segment_ref.document_id,
)
.limit(1)
)
return result if isinstance(result, DocumentSegment) else None
@classmethod
def get_segments_by_document_and_dataset(
cls,
+28 -6
View File
@@ -82,6 +82,7 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError,
from services.rag_pipeline.pipeline_template.pipeline_template_factory import PipelineTemplateRetrievalFactory
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
from services.workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader
from services.workflow_ref_service import WorkflowRef
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
logger = logging.getLogger(__name__)
@@ -984,8 +985,21 @@ class RagPipelineService:
if invoke_from:
if invoke_from.value == InvokeFrom.PUBLISHED_PIPELINE:
document_id = get_system_segment(variable_pool, SystemVariableKey.DOCUMENT_ID)
if document_id:
document = db.session.get(Document, document_id.value)
dataset_id = get_system_segment(variable_pool, SystemVariableKey.DATASET_ID)
pipeline_id = get_system_segment(variable_pool, SystemVariableKey.APP_ID)
if document_id and dataset_id and pipeline_id:
document = db.session.scalar(
select(Document)
.join(Dataset, Dataset.id == Document.dataset_id)
.where(
Document.id == document_id.value,
Document.tenant_id == tenant_id,
Document.dataset_id == dataset_id.value,
Dataset.tenant_id == tenant_id,
Dataset.pipeline_id == pipeline_id.value,
)
.limit(1)
)
if document:
document.indexing_status = IndexingStatus.ERROR
document.error = error
@@ -995,19 +1009,27 @@ class RagPipelineService:
return workflow_node_execution
def update_workflow(
self, *, session: Session, workflow_id: str, tenant_id: str, account_id: str, data: dict[str, Any]
self,
*,
session: Session,
account_id: str,
data: dict[str, Any],
workflow_ref: WorkflowRef,
) -> Workflow | None:
"""
Update workflow attributes
:param session: SQLAlchemy database session
:param workflow_id: Workflow ID
:param tenant_id: Tenant ID
:param account_id: Account ID (for permission check)
:param data: Dictionary containing fields to update
:param workflow_ref: Owner-bound workflow reference
:return: Updated workflow or None if not found
"""
stmt = select(Workflow).where(Workflow.id == workflow_id, Workflow.tenant_id == tenant_id)
stmt = select(Workflow).where(
Workflow.id == workflow_ref.workflow_id,
Workflow.tenant_id == workflow_ref.tenant_id,
Workflow.app_id == workflow_ref.owner_id,
)
workflow = session.scalar(stmt)
if not workflow:
+27 -7
View File
@@ -147,8 +147,14 @@ class TagService:
return tag
@staticmethod
def update_tags(payload: UpdateTagPayload, tag_id: str, session: scoped_session) -> Tag:
tag = session.scalar(select(Tag).where(Tag.id == tag_id).limit(1))
def update_tags(
payload: UpdateTagPayload, tag_id: str, session: scoped_session, *, tag_type: TagType | None = None
) -> Tag:
current_tenant_id = current_user.current_tenant_id
stmt = select(Tag).where(Tag.id == tag_id, Tag.tenant_id == current_tenant_id)
if tag_type is not None:
stmt = stmt.where(Tag.type == tag_type)
tag = session.scalar(stmt.limit(1))
if not tag:
raise NotFound("Tag not found")
if payload.name != tag.name:
@@ -169,18 +175,32 @@ class TagService:
return tag
@staticmethod
def get_tag_binding_count(tag_id: str, session: scoped_session) -> int:
count = session.scalar(select(func.count(TagBinding.id)).where(TagBinding.tag_id == tag_id)) or 0
def get_tag_binding_count(tag_id: str, session: scoped_session, *, tag_type: TagType | None = None) -> int:
current_tenant_id = current_user.current_tenant_id
stmt = (
select(func.count(TagBinding.id))
.join(Tag, Tag.id == TagBinding.tag_id)
.where(TagBinding.tag_id == tag_id, Tag.tenant_id == current_tenant_id)
)
if tag_type is not None:
stmt = stmt.where(Tag.type == tag_type)
count = session.scalar(stmt) or 0
return count
@staticmethod
def delete_tag(tag_id: str, session: scoped_session):
tag = session.scalar(select(Tag).where(Tag.id == tag_id).limit(1))
def delete_tag(tag_id: str, session: scoped_session, *, tag_type: TagType | None = None):
current_tenant_id = current_user.current_tenant_id
stmt = select(Tag).where(Tag.id == tag_id, Tag.tenant_id == current_tenant_id)
if tag_type is not None:
stmt = stmt.where(Tag.type == tag_type)
tag = session.scalar(stmt.limit(1))
if not tag:
raise NotFound("Tag not found")
session.delete(tag)
# delete tag binding
tag_bindings = session.scalars(select(TagBinding).where(TagBinding.tag_id == tag_id)).all()
tag_bindings = session.scalars(
select(TagBinding).where(TagBinding.tag_id == tag_id, TagBinding.tenant_id == current_tenant_id)
).all()
if tag_bindings:
for tag_binding in tag_bindings:
session.delete(tag_binding)
+26
View File
@@ -0,0 +1,26 @@
"""Typed resource references for workflow ownership chains."""
from typing import NamedTuple
from models.dataset import Pipeline
from models.model import App
class WorkflowRef(NamedTuple):
"""Workflow identifiers used to scope downstream resource lookups."""
tenant_id: str
owner_id: str
workflow_id: str
class WorkflowRefService:
"""Factory helpers for app and RAG pipeline workflow refs."""
@staticmethod
def create_app_workflow_ref(app: App, workflow_id: str) -> WorkflowRef:
return WorkflowRef(tenant_id=app.tenant_id, owner_id=app.id, workflow_id=workflow_id)
@staticmethod
def create_pipeline_workflow_ref(pipeline: Pipeline, workflow_id: str) -> WorkflowRef:
return WorkflowRef(tenant_id=pipeline.tenant_id, owner_id=pipeline.id, workflow_id=workflow_id)
+22 -10
View File
@@ -84,6 +84,7 @@ from services.errors.app import (
)
from services.human_input_service import HumanInputService
from services.workflow.workflow_converter import WorkflowConverter
from services.workflow_ref_service import WorkflowRef
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
from .human_input_delivery_test_service import (
@@ -1593,19 +1594,27 @@ class WorkflowService:
raise ValueError(f"Invalid HumanInput node data: {str(e)}")
def update_workflow(
self, *, session: Session, workflow_id: str, tenant_id: str, account_id: str, data: dict[str, Any]
self,
*,
session: Session,
account_id: str,
data: dict[str, Any],
workflow_ref: WorkflowRef,
) -> Workflow | None:
"""
Update workflow attributes
:param session: SQLAlchemy database session
:param workflow_id: Workflow ID
:param tenant_id: Tenant ID
:param account_id: Account ID (for permission check)
:param data: Dictionary containing fields to update
:param workflow_ref: Owner-bound workflow reference
:return: Updated workflow or None if not found
"""
stmt = select(Workflow).where(Workflow.id == workflow_id, Workflow.tenant_id == tenant_id)
stmt = select(Workflow).where(
Workflow.id == workflow_ref.workflow_id,
Workflow.tenant_id == workflow_ref.tenant_id,
Workflow.app_id == workflow_ref.owner_id,
)
workflow = session.scalar(stmt)
if not workflow:
@@ -1622,30 +1631,33 @@ class WorkflowService:
return workflow
def delete_workflow(self, *, session: Session, workflow_id: str, tenant_id: str) -> bool:
def delete_workflow(self, *, session: Session, workflow_ref: WorkflowRef) -> bool:
"""
Delete a workflow
:param session: SQLAlchemy database session
:param workflow_id: Workflow ID
:param tenant_id: Tenant ID
:param workflow_ref: Owner-bound workflow reference
:return: True if successful
:raises: ValueError if workflow not found
:raises: WorkflowInUseError if workflow is in use
:raises: DraftWorkflowDeletionError if workflow is a draft version
"""
stmt = select(Workflow).where(Workflow.id == workflow_id, Workflow.tenant_id == tenant_id)
stmt = select(Workflow).where(
Workflow.id == workflow_ref.workflow_id,
Workflow.tenant_id == workflow_ref.tenant_id,
Workflow.app_id == workflow_ref.owner_id,
)
workflow = session.scalar(stmt)
if not workflow:
raise ValueError(f"Workflow with ID {workflow_id} not found")
raise ValueError(f"Workflow with ID {workflow_ref.workflow_id} not found")
# Check if workflow is a draft version
if workflow.version == Workflow.VERSION_DRAFT:
raise DraftWorkflowDeletionError("Cannot delete draft workflow versions")
# Check if this workflow is currently referenced by an app
app_stmt = select(App).where(App.workflow_id == workflow_id)
app_stmt = select(App).where(App.workflow_id == workflow_ref.workflow_id)
app = session.scalar(app_stmt)
if app:
# Cannot delete a workflow that's currently in use by an app
@@ -1188,7 +1188,7 @@ class TestDatasetTagsApiDelete:
result = api.delete(_=None)
assert result == ("", 204)
mock_tag_svc.delete_tag.assert_called_once_with("tag-1", ANY)
mock_tag_svc.delete_tag.assert_called_once_with("tag-1", ANY, tag_type=TagType.KNOWLEDGE)
@patch("libs.login.current_user")
def test_delete_tag_forbidden(self, mock_current_user, app: Flask):
@@ -9,6 +9,7 @@ from models import Account
from models.enums import ConversationFromSource, InvokeFrom
from models.model import MessageAnnotation
from services.annotation_service import AppAnnotationService
from services.app_ref_service import AnnotationRef
from services.app_service import AppService, CreateAppParams
from tests.test_containers_integration_tests.helpers import generate_valid_password
@@ -119,6 +120,10 @@ class TestAnnotationService:
tenant_id,
)
@staticmethod
def _annotation_ref(app, annotation_id: str) -> AnnotationRef:
return AnnotationRef(tenant_id=app.tenant_id, app_id=app.id, annotation_id=annotation_id)
def _create_test_conversation(self, db_session_with_containers: Session, app, account, fake):
"""
Helper method to create a test conversation with all required fields.
@@ -282,7 +287,9 @@ class TestAnnotationService:
"answer": fake.text(max_nb_chars=200),
}
updated_annotation = AppAnnotationService.update_app_annotation_directly(
updated_args, app.id, annotation.id, db_session_with_containers
updated_args,
self._annotation_ref(app, annotation.id),
db_session_with_containers,
)
# Verify annotation was updated correctly
@@ -570,7 +577,7 @@ class TestAnnotationService:
annotation_id = annotation.id
# Delete the annotation
AppAnnotationService.delete_app_annotation(app.id, annotation_id, db_session_with_containers)
AppAnnotationService.delete_app_annotation(self._annotation_ref(app, annotation_id), db_session_with_containers)
# Verify annotation was deleted
@@ -583,22 +590,27 @@ class TestAnnotationService:
# Note: In this test, no annotation setting exists, so task should not be called
mock_external_service_dependencies["delete_task"].delay.assert_not_called()
def test_delete_app_annotation_app_not_found(
def test_delete_app_annotation_annotation_not_found_for_wrong_app(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test deletion of app annotation when app is not found.
Test deletion of app annotation when the annotation does not belong to the supplied app ref.
"""
fake = Faker()
non_existent_app_id = fake.uuid4()
annotation_id = fake.uuid4()
app_ref = AnnotationRef(
tenant_id=fake.uuid4(),
app_id=non_existent_app_id,
annotation_id=annotation_id,
)
# Mock random current user to avoid dependency issues
self._mock_current_user(mock_external_service_dependencies, fake.uuid4(), fake.uuid4())
# Try to delete annotation with non-existent app
with pytest.raises(NotFound, match="App not found"):
AppAnnotationService.delete_app_annotation(non_existent_app_id, annotation_id, db_session_with_containers)
# Try to delete annotation with a ref that cannot match any annotation row
with pytest.raises(NotFound, match="Annotation not found"):
AppAnnotationService.delete_app_annotation(app_ref, db_session_with_containers)
def test_delete_app_annotation_annotation_not_found(
self, db_session_with_containers: Session, mock_external_service_dependencies
@@ -612,7 +624,10 @@ class TestAnnotationService:
# Try to delete non-existent annotation
with pytest.raises(NotFound, match="Annotation not found"):
AppAnnotationService.delete_app_annotation(app.id, non_existent_annotation_id, db_session_with_containers)
AppAnnotationService.delete_app_annotation(
self._annotation_ref(app, non_existent_annotation_id),
db_session_with_containers,
)
def test_enable_app_annotation_success(
self, db_session_with_containers: Session, mock_external_service_dependencies
@@ -731,7 +746,9 @@ class TestAnnotationService:
# Get hit histories
hit_histories, total = AppAnnotationService.get_annotation_hit_histories(
app.id, annotation.id, page=1, limit=10
self._annotation_ref(app, annotation.id),
page=1,
limit=10,
)
# Verify results
@@ -1229,7 +1246,9 @@ class TestAnnotationService:
"answer": fake.text(max_nb_chars=200),
}
updated_annotation = AppAnnotationService.update_app_annotation_directly(
updated_args, app.id, annotation.id, db_session_with_containers
updated_args,
self._annotation_ref(app, annotation.id),
db_session_with_containers,
)
# Verify annotation was updated correctly
@@ -1300,7 +1319,7 @@ class TestAnnotationService:
mock_external_service_dependencies["delete_task"].delay.reset_mock()
# Delete the annotation
AppAnnotationService.delete_app_annotation(app.id, annotation_id, db_session_with_containers)
AppAnnotationService.delete_app_annotation(self._annotation_ref(app, annotation_id), db_session_with_containers)
# Verify annotation was deleted
deleted_annotation = (
@@ -24,6 +24,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom
from models.account import TenantAccountJoin
from models.enums import ConversationFromSource, MessageStatus
from models.model import App, AppMode, Conversation, Message
from services.app_ref_service import MessageRef
from services.audio_service import AudioService
from tests.test_containers_integration_tests.controllers.console.helpers import (
create_console_account_and_tenant,
@@ -159,7 +160,12 @@ class TestAudioServiceTranscriptTTSMessageLookup:
result = AudioService.transcript_tts(
app_model=app,
session=db_session_with_containers,
message_id=message.id,
message_ref=MessageRef(
tenant_id=app.tenant_id,
app_id=app.id,
message_id=message.id,
account_id=account_id,
),
voice="en-US-Neural",
)
@@ -176,7 +182,7 @@ class TestAudioServiceTranscriptTTSMessageLookup:
result = AudioService.transcript_tts(
app_model=app,
session=db_session_with_containers,
message_id="invalid-uuid",
message_ref=MessageRef(tenant_id=app.tenant_id, app_id=app.id, message_id="invalid-uuid"),
)
assert result is None
@@ -188,7 +194,7 @@ class TestAudioServiceTranscriptTTSMessageLookup:
result = AudioService.transcript_tts(
app_model=app,
session=db_session_with_containers,
message_id=str(uuid4()),
message_ref=MessageRef(tenant_id=app.tenant_id, app_id=app.id, message_id=str(uuid4())),
)
assert result is None
@@ -209,7 +215,12 @@ class TestAudioServiceTranscriptTTSMessageLookup:
result = AudioService.transcript_tts(
app_model=app,
session=db_session_with_containers,
message_id=message.id,
message_ref=MessageRef(
tenant_id=app.tenant_id,
app_id=app.id,
message_id=message.id,
account_id=account_id,
),
)
assert result is None
@@ -15,6 +15,7 @@ from models import Account
from models.dataset import Dataset, Document
from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus
from models.model import UploadFile
from services.dataset_ref_service import DatasetRef
from services.dataset_service import DocumentService
from services.errors.account import NoPermissionError
@@ -615,9 +616,10 @@ def test_delete_document_emits_signal_and_commits(db_session_with_containers: Se
def test_delete_documents_ignores_empty_input(db_session_with_containers: Session):
dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers)
dataset_ref = DatasetRef(tenant_id=dataset.tenant_id, dataset_id=dataset.id)
with patch("services.dataset_service.batch_clean_document_task.delay") as delay:
DocumentService.delete_documents(dataset, [], session=db_session_with_containers)
DocumentService.delete_documents(dataset_ref, [], dataset.doc_form, session=db_session_with_containers)
delay.assert_not_called()
@@ -649,9 +651,15 @@ def test_delete_documents_deletes_rows_and_dispatches_cleanup_task(db_session_wi
position=2,
data_source_info={"upload_file_id": upload_file_b.id},
)
dataset_ref = DatasetRef(tenant_id=dataset.tenant_id, dataset_id=dataset.id)
with patch("services.dataset_service.batch_clean_document_task.delay") as delay:
DocumentService.delete_documents(dataset, [document_a.id, document_b.id], session=db_session_with_containers)
DocumentService.delete_documents(
dataset_ref,
[document_a.id, document_b.id],
dataset.doc_form,
session=db_session_with_containers,
)
assert db_session_with_containers.get(Document, document_a.id) is None
assert db_session_with_containers.get(Document, document_b.id) is None
@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from models import Account, AccountStatus, App, TenantStatus, Workflow
from models.model import AppMode
from models.workflow import WorkflowType
from services.workflow_ref_service import WorkflowRef
from services.workflow_service import WorkflowService
@@ -1319,10 +1320,9 @@ class TestWorkflowService:
# Act
result = workflow_service.update_workflow(
session=db_session_with_containers,
workflow_id=workflow.id,
tenant_id=workflow.tenant_id,
account_id=account.id,
data=update_data,
workflow_ref=WorkflowRef(tenant_id=workflow.tenant_id, owner_id=app.id, workflow_id=workflow.id),
)
# Assert
@@ -1350,10 +1350,9 @@ class TestWorkflowService:
# Act
result = workflow_service.update_workflow(
session=db_session_with_containers,
workflow_id=non_existent_workflow_id,
tenant_id=app.tenant_id,
account_id=account.id,
data=update_data,
workflow_ref=WorkflowRef(tenant_id=app.tenant_id, owner_id=app.id, workflow_id=non_existent_workflow_id),
)
# Assert
@@ -1385,10 +1384,9 @@ class TestWorkflowService:
# Act
result = workflow_service.update_workflow(
session=db_session_with_containers,
workflow_id=workflow.id,
tenant_id=workflow.tenant_id,
account_id=account.id,
data=update_data,
workflow_ref=WorkflowRef(tenant_id=workflow.tenant_id, owner_id=app.id, workflow_id=workflow.id),
)
# Assert
@@ -1421,7 +1419,8 @@ class TestWorkflowService:
# Act
result = workflow_service.delete_workflow(
session=db_session_with_containers, workflow_id=workflow.id, tenant_id=workflow.tenant_id
session=db_session_with_containers,
workflow_ref=WorkflowRef(tenant_id=workflow.tenant_id, owner_id=app.id, workflow_id=workflow.id),
)
# Assert
@@ -1456,7 +1455,8 @@ class TestWorkflowService:
with pytest.raises(DraftWorkflowDeletionError, match="Cannot delete draft workflow versions"):
workflow_service.delete_workflow(
session=db_session_with_containers, workflow_id=workflow.id, tenant_id=workflow.tenant_id
session=db_session_with_containers,
workflow_ref=WorkflowRef(tenant_id=workflow.tenant_id, owner_id=app.id, workflow_id=workflow.id),
)
def test_delete_workflow_in_use_error(self, db_session_with_containers: Session):
@@ -1487,7 +1487,8 @@ class TestWorkflowService:
with pytest.raises(WorkflowInUseError, match="Cannot delete workflow that is currently in use by app"):
workflow_service.delete_workflow(
session=db_session_with_containers, workflow_id=workflow.id, tenant_id=workflow.tenant_id
session=db_session_with_containers,
workflow_ref=WorkflowRef(tenant_id=workflow.tenant_id, owner_id=app.id, workflow_id=workflow.id),
)
def test_delete_workflow_not_found_error(self, db_session_with_containers: Session):
@@ -1507,7 +1508,12 @@ class TestWorkflowService:
# Act & Assert
with pytest.raises(ValueError, match=f"Workflow with ID {non_existent_workflow_id} not found"):
workflow_service.delete_workflow(
session=db_session_with_containers, workflow_id=non_existent_workflow_id, tenant_id=app.tenant_id
session=db_session_with_containers,
workflow_ref=WorkflowRef(
tenant_id=app.tenant_id,
owner_id=app.id,
workflow_id=non_existent_workflow_id,
),
)
def test_run_free_workflow_node_success(self, db_session_with_containers: Session):
@@ -11,6 +11,7 @@ from models.account import Account, Tenant, TenantAccountJoin
from models.model import App
from models.tools import WorkflowToolProvider
from models.workflow import Workflow
from services.workflow_ref_service import WorkflowRef
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
@@ -111,7 +112,8 @@ class TestWorkflowDeletion:
service = WorkflowService(sessionmaker(bind=db.engine))
result = service.delete_workflow(
session=db_session_with_containers, workflow_id=workflow_id, tenant_id=tenant.id
session=db_session_with_containers,
workflow_ref=WorkflowRef(tenant_id=tenant.id, owner_id=app.id, workflow_id=workflow_id),
)
assert result is True
@@ -128,7 +130,10 @@ class TestWorkflowDeletion:
service = WorkflowService(sessionmaker(bind=db.engine))
with pytest.raises(DraftWorkflowDeletionError):
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
service.delete_workflow(
session=db_session_with_containers,
workflow_ref=WorkflowRef(tenant_id=tenant.id, owner_id=app.id, workflow_id=workflow.id),
)
def test_delete_workflow_in_use_by_app_raises_error(self, db_session_with_containers: Session):
tenant, account = self._create_tenant_and_account(db_session_with_containers)
@@ -142,7 +147,10 @@ class TestWorkflowDeletion:
service = WorkflowService(sessionmaker(bind=db.engine))
with pytest.raises(WorkflowInUseError, match="currently in use by app"):
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
service.delete_workflow(
session=db_session_with_containers,
workflow_ref=WorkflowRef(tenant_id=tenant.id, owner_id=app.id, workflow_id=workflow.id),
)
def test_delete_workflow_published_as_tool_raises_error(self, db_session_with_containers: Session):
tenant, account = self._create_tenant_and_account(db_session_with_containers)
@@ -155,4 +163,7 @@ class TestWorkflowDeletion:
service = WorkflowService(sessionmaker(bind=db.engine))
with pytest.raises(WorkflowInUseError, match="published as a tool"):
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
service.delete_workflow(
session=db_session_with_containers,
workflow_ref=WorkflowRef(tenant_id=tenant.id, owner_id=app.id, workflow_id=workflow.id),
)
@@ -1,6 +1,23 @@
from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import NotFound
from controllers.console.app import annotation as annotation_module
from services.app_ref_service import AnnotationRef, AppRef
def _app_model() -> SimpleNamespace:
return SimpleNamespace(id="app-1", tenant_id="tenant-1", status="normal")
def _annotation_model(annotation_id: str = "ann-1") -> SimpleNamespace:
return SimpleNamespace(id=annotation_id, question="q", content="a", hit_count=0, created_at=None)
def test_annotation_reply_payload_valid():
@@ -90,3 +107,113 @@ def test_annotation_file_payload_valid():
"""Test AnnotationFilePayload with valid message ID."""
payload = annotation_module.AnnotationFilePayload(message_id="550e8400-e29b-41d4-a716-446655440000")
assert payload.message_id == "550e8400-e29b-41d4-a716-446655440000"
def test_get_app_ref_raises_not_found_when_app_is_not_in_current_tenant():
with (
patch.object(
annotation_module,
"current_account_with_tenant",
return_value=(SimpleNamespace(id="account-1"), "tenant-1"),
),
patch.object(annotation_module.db.session, "scalar", return_value=None),
):
with pytest.raises(NotFound):
annotation_module._get_app_ref("app-1")
class TestConsoleAnnotationRefBoundaries:
def test_batch_delete_uses_app_ref(self, app: Flask):
api = annotation_module.AnnotationApi()
handler = unwrap(api.delete)
delete_mock = Mock()
with (
app.test_request_context("/?annotation_id=ann-1&annotation_id=ann-2", method="DELETE"),
patch.object(
annotation_module,
"current_account_with_tenant",
return_value=(SimpleNamespace(id="account-1"), "tenant-1"),
),
patch.object(annotation_module.db.session, "scalar", return_value=_app_model()),
patch.object(annotation_module.AppAnnotationService, "delete_app_annotations_in_batch", delete_mock),
):
response, status = handler(api, "app-1")
assert response == ""
assert status == 204
delete_mock.assert_called_once_with(AppRef("tenant-1", "app-1"), ["ann-1", "ann-2"])
def test_update_uses_annotation_ref(self, app: Flask):
api = annotation_module.AnnotationUpdateDeleteApi()
handler = unwrap(api.post)
update_mock = Mock(return_value=_annotation_model())
payload = {"question": "updated"}
with (
app.test_request_context("/annotations/ann-1", method="POST", json=payload),
patch.object(type(annotation_module.console_ns), "payload", payload),
patch.object(
annotation_module,
"current_account_with_tenant",
return_value=(SimpleNamespace(id="account-1"), "tenant-1"),
),
patch.object(annotation_module.db.session, "scalar", return_value=_app_model()),
patch.object(annotation_module.AppAnnotationService, "update_app_annotation_directly", update_mock),
):
response = handler(api, "app-1", "ann-1")
assert response["question"] == "q"
update_mock.assert_called_once()
assert update_mock.call_args.args[1] == AnnotationRef("tenant-1", "app-1", "ann-1")
def test_delete_uses_annotation_ref(self, app: Flask):
api = annotation_module.AnnotationUpdateDeleteApi()
handler = unwrap(api.delete)
delete_mock = Mock()
with (
app.test_request_context("/annotations/ann-1", method="DELETE"),
patch.object(
annotation_module,
"current_account_with_tenant",
return_value=(SimpleNamespace(id="account-1"), "tenant-1"),
),
patch.object(annotation_module.db.session, "scalar", return_value=_app_model()),
patch.object(annotation_module.AppAnnotationService, "delete_app_annotation", delete_mock),
):
response, status = handler(api, "app-1", "ann-1")
assert response == ""
assert status == 204
delete_mock.assert_called_once()
assert delete_mock.call_args.args[0] == AnnotationRef("tenant-1", "app-1", "ann-1")
def test_hit_history_uses_annotation_ref(self, app: Flask):
api = annotation_module.AnnotationHitHistoryListApi()
handler = unwrap(api.get)
history = SimpleNamespace(
id="history-1",
source="hit-testing",
score=0.9,
question="q",
annotation_question="q",
annotation_content="a",
created_at=None,
)
hit_history_mock = Mock(return_value=([history], 1))
with (
app.test_request_context("/hit-histories?page=2&limit=5", method="GET"),
patch.object(
annotation_module,
"current_account_with_tenant",
return_value=(SimpleNamespace(id="account-1"), "tenant-1"),
),
patch.object(annotation_module.db.session, "scalar", return_value=_app_model()),
patch.object(annotation_module.AppAnnotationService, "get_annotation_hit_histories", hit_history_mock),
):
response = handler(api, "app-1", "ann-1")
assert response["total"] == 1
hit_history_mock.assert_called_once_with(AnnotationRef("tenant-1", "app-1", "ann-1"), 2, 5)
@@ -3,6 +3,7 @@ from __future__ import annotations
import io
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from flask import Flask
@@ -23,6 +24,7 @@ from controllers.console.app.error import (
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
from services.app_ref_service import MessageRef
from services.audio_service import AudioService
from services.errors.app_model_config import AppModelConfigBrokenError
from services.errors.audio import (
@@ -103,6 +105,32 @@ def test_console_text_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -
assert response == {"audio": "ok"}
def test_console_text_api_builds_message_ref(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = ChatMessageTextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
calls = {}
def fake_transcript_tts(**kwargs):
calls.update(kwargs)
return {"audio": "ok"}
monkeypatch.setattr(AudioService, "transcript_tts", fake_transcript_tts)
with (
app.test_request_context(
"/console/api/apps/app-1/text-to-audio",
method="POST",
json={"text": "hello", "message_id": "message-1"},
),
patch("controllers.console.app.audio.current_user", SimpleNamespace(id="account-1")),
):
response = handler(api, app_model=app_model)
assert response == {"audio": "ok"}
assert calls["message_ref"] == MessageRef("tenant-1", "app-1", "message-1", account_id="account-1")
def test_console_text_api_error_mapping(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: (_ for _ in ()).throw(QuotaExceededError()))
@@ -70,7 +70,7 @@ def test_instruction_generate_app_not_found(app: Flask, monkeypatch: pytest.Monk
method = unwrap(api.post)
session = MagicMock()
session.get.return_value = None
session.scalar.return_value = None
with app.test_request_context(
"/console/api/instruction-generate",
@@ -86,7 +86,13 @@ def test_instruction_generate_app_not_found(app: Flask, monkeypatch: pytest.Monk
assert status == 400
assert response["error"] == "app app-1 not found"
session.get.assert_called_once_with(generator_module.App, "app-1")
stmt = session.scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "apps.id" in statement
assert "apps.tenant_id" in statement
assert "app-1" in compiled.params.values()
assert "t1" in compiled.params.values()
def test_instruction_generate_workflow_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -94,7 +100,7 @@ def test_instruction_generate_workflow_not_found(app: Flask, monkeypatch: pytest
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
_install_workflow_service(monkeypatch, workflow=None)
with app.test_request_context(
@@ -118,7 +124,7 @@ def test_instruction_generate_node_missing(app: Flask, monkeypatch: pytest.Monke
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
workflow = SimpleNamespace(graph_dict={"nodes": []})
_install_workflow_service(monkeypatch, workflow=workflow)
@@ -144,7 +150,7 @@ def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPa
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
workflow = SimpleNamespace(
graph_dict={
@@ -130,3 +130,50 @@ class TestAppMCPServerController:
assert response == {"id": "server-1"}
assert status_code == 201
def test_put_binds_server_lookup_to_app_ref(self):
api = AppMCPServerController()
method = unwrap(api.put)
payload = {"id": "server-1", "description": "Updated", "parameters": {"timeout": 30}, "status": "active"}
app = Flask(__name__)
app.config["TESTING"] = True
server = SimpleNamespace(
id="server-1",
tenant_id="tenant-1",
app_id="app-1",
name="Old",
description="Old",
parameters="{}",
status="active",
)
with (
app.test_request_context("/", json=payload),
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload),
patch("controllers.console.app.mcp_server.db.session.scalar", return_value=server) as scalar,
patch("controllers.console.app.mcp_server.db.session.get") as get_mock,
patch("controllers.console.app.mcp_server.db.session.commit") as commit,
patch(
"controllers.console.app.mcp_server.AppMCPServerResponse.model_validate",
return_value=_ValidatedResponse({"id": "server-1"}),
),
):
response = method(
api,
app_model=SimpleNamespace(
id="app-1", tenant_id="tenant-1", name="Demo App", description="App description"
),
)
stmt = scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "app_mcp_servers.id" in statement
assert "app_mcp_servers.tenant_id" in statement
assert "app_mcp_servers.app_id" in statement
assert payload["id"] in compiled.params.values()
assert "tenant-1" in compiled.params.values()
assert "app-1" in compiled.params.values()
get_mock.assert_not_called()
commit.assert_called_once()
assert response == {"id": "server-1"}
@@ -83,6 +83,7 @@ def make_dataset(**overrides):
"tenant_id": "tenant-1",
"name": "Dataset",
"indexing_technique": "economy",
"chunk_structure": IndexStructureType.PARAGRAPH_INDEX,
"created_by": "u1",
"summary_index_setting": {"enable": True},
}
@@ -108,6 +108,15 @@ def _segment_response_dict():
}
def _bind_dataset_document(dataset, document, dataset_id: str = "ds-1", document_id: str = "doc-1"):
dataset.id = dataset_id
dataset.tenant_id = "tenant-1"
document.id = document_id
document.dataset_id = dataset_id
document.tenant_id = "tenant-1"
return document
def test_segment_response_with_summary():
segment = _segment()
@@ -380,6 +389,7 @@ class TestDatasetDocumentSegmentAddApi:
document = MagicMock()
document.doc_form = IndexStructureType.PARAGRAPH_INDEX
_bind_dataset_document(dataset, document)
segment = _segment()
@@ -504,6 +514,7 @@ class TestDatasetDocumentSegmentUpdateApi:
document = MagicMock()
document.doc_form = IndexStructureType.PARAGRAPH_INDEX
_bind_dataset_document(dataset, document)
segment = _segment()
@@ -519,8 +530,8 @@ class TestDatasetDocumentSegmentUpdateApi:
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.db.session.scalar",
side_effect=[segment, None],
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=segment,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
@@ -538,6 +549,7 @@ class TestDatasetDocumentSegmentUpdateApi:
"controllers.console.datasets.datasets_segments.SummaryIndexService.get_segment_summary",
return_value=None,
),
patch("models.dataset.db.session.scalar", return_value=None),
patch("models.dataset.db.session.execute", return_value=MagicMock(all=MagicMock(return_value=[]))),
):
response, status = method(api, "tenant-1", user, "ds-1", "doc-1", "seg-1")
@@ -545,6 +557,75 @@ class TestDatasetDocumentSegmentUpdateApi:
assert status == 200
assert "data" in response
def test_patch_document_outside_dataset_is_not_found(self, app: Flask):
api = DatasetDocumentSegmentUpdateApi()
method = inspect.unwrap(api.patch)
payload = {"content": "updated"}
user = MagicMock(is_dataset_editor=True)
dataset = MagicMock(id="ds-1", tenant_id="tenant-1", indexing_technique="economy")
document = MagicMock(id="doc-1", dataset_id="other-dataset", tenant_id="tenant-1")
with (
app.test_request_context("/", json=payload),
patch.object(type(console_ns), "payload", payload),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_model_setting",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, "tenant-1", user, "ds-1", "doc-1", "seg-1")
def test_patch_segment_not_found(self, app: Flask):
api = DatasetDocumentSegmentUpdateApi()
method = inspect.unwrap(api.patch)
payload = {"content": "updated"}
user = MagicMock(is_dataset_editor=True)
dataset = MagicMock(indexing_technique="economy")
document = MagicMock()
_bind_dataset_document(dataset, document)
with (
app.test_request_context("/", json=payload),
patch.object(type(console_ns), "payload", payload),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_model_setting",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, "tenant-1", user, "ds-1", "doc-1", "seg-1")
def test_patch_llm_bad_request(self, app: Flask):
api = DatasetDocumentSegmentUpdateApi()
method = inspect.unwrap(api.patch)
@@ -576,6 +657,10 @@ class TestDatasetDocumentSegmentUpdateApi:
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_model_setting",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.ModelManager.get_model_instance",
side_effect=LLMBadRequestError(),
@@ -781,13 +866,15 @@ class TestChildChunkAddApi:
api = ChildChunkAddApi()
method = inspect.unwrap(api.get)
dataset = MagicMock()
document = _bind_dataset_document(dataset, MagicMock())
pagination = MagicMock(items=[], total=0, pages=0)
with (
app.test_request_context("/?page=bad&limit="),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=MagicMock(),
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_model_setting",
@@ -795,10 +882,10 @@ class TestChildChunkAddApi:
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=MagicMock(),
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.db.session.scalar",
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=MagicMock(),
),
patch(
@@ -826,6 +913,7 @@ class TestChildChunkAddApi:
dataset.indexing_technique = "economy"
document = MagicMock()
_bind_dataset_document(dataset, document)
segment = MagicMock()
child_chunk = _child_chunk()
@@ -841,7 +929,7 @@ class TestChildChunkAddApi:
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.db.session.scalar",
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=segment,
),
patch(
@@ -868,6 +956,7 @@ class TestChildChunkAddApi:
dataset = MagicMock(indexing_technique="economy")
document = MagicMock()
_bind_dataset_document(dataset, document)
segment = MagicMock()
with (
@@ -882,7 +971,7 @@ class TestChildChunkAddApi:
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.db.session.scalar",
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=segment,
),
patch(
@@ -897,6 +986,35 @@ class TestChildChunkAddApi:
with pytest.raises(ChildChunkIndexingError):
method(api, "tenant-1", user, "ds-1", "doc-1", "seg-1")
def test_post_permission_denied(self, app: Flask):
api = ChildChunkAddApi()
method = inspect.unwrap(api.post)
payload = {"content": "child"}
user = MagicMock(is_dataset_editor=True)
dataset = MagicMock(indexing_technique="economy")
document = MagicMock()
_bind_dataset_document(dataset, document)
with (
app.test_request_context("/", json=payload),
patch.object(type(console_ns), "payload", payload),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
side_effect=services.errors.account.NoPermissionError("no access"),
),
):
with pytest.raises(Forbidden):
method(api, "tenant-1", user, "ds-1", "doc-1", "seg-1")
class TestChildChunkUpdateApi:
def test_delete_success(self, app: Flask):
@@ -908,6 +1026,7 @@ class TestChildChunkUpdateApi:
dataset = MagicMock()
document = MagicMock()
_bind_dataset_document(dataset, document)
segment = MagicMock()
child_chunk = MagicMock()
@@ -922,8 +1041,12 @@ class TestChildChunkUpdateApi:
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.db.session.scalar",
side_effect=[segment, child_chunk],
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=segment,
),
patch(
"controllers.console.datasets.datasets_segments.SegmentService.get_child_chunk_by_segment_ref",
return_value=child_chunk,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
@@ -947,6 +1070,7 @@ class TestChildChunkUpdateApi:
dataset = MagicMock()
document = MagicMock()
_bind_dataset_document(dataset, document)
segment = MagicMock()
child_chunk = MagicMock()
@@ -961,8 +1085,12 @@ class TestChildChunkUpdateApi:
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.db.session.scalar",
side_effect=[segment, child_chunk],
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=segment,
),
patch(
"controllers.console.datasets.datasets_segments.SegmentService.get_child_chunk_by_segment_ref",
return_value=child_chunk,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
@@ -976,6 +1104,80 @@ class TestChildChunkUpdateApi:
with pytest.raises(ChildChunkDeleteIndexError):
method(api, "tenant-1", user, "ds-1", "doc-1", "seg-1", "cc-1")
def test_delete_child_chunk_not_found(self, app: Flask):
api = ChildChunkUpdateApi()
method = inspect.unwrap(api.delete)
user = MagicMock(is_dataset_editor=True)
dataset = MagicMock()
document = MagicMock()
_bind_dataset_document(dataset, document)
segment = MagicMock()
with (
app.test_request_context("/"),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=segment,
),
patch(
"controllers.console.datasets.datasets_segments.SegmentService.get_child_chunk_by_segment_ref",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, "tenant-1", user, "ds-1", "doc-1", "seg-1", "cc-1")
def test_patch_child_chunk_not_found(self, app: Flask):
api = ChildChunkUpdateApi()
method = inspect.unwrap(api.patch)
payload = {"content": "updated child"}
user = MagicMock(is_dataset_editor=True)
dataset = MagicMock()
document = MagicMock()
_bind_dataset_document(dataset, document)
segment = MagicMock()
with (
app.test_request_context("/", json=payload),
patch.object(type(console_ns), "payload", payload),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.SegmentService.get_segment_by_ref",
return_value=segment,
),
patch(
"controllers.console.datasets.datasets_segments.SegmentService.get_child_chunk_by_segment_ref",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
return_value=None,
),
):
with pytest.raises(NotFound):
method(api, "tenant-1", user, "ds-1", "doc-1", "seg-1", "cc-1")
class TestSegmentListAdvancedCases:
def test_segment_list_with_keyword_filter(self, app: Flask):
@@ -21,6 +21,7 @@ from core.errors.error import (
QuotaExceededError,
)
from graphon.model_runtime.errors.invoke import InvokeError
from services.app_ref_service import MessageRef
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
@@ -40,6 +41,8 @@ def unwrap(func):
def installed_app():
app = MagicMock()
app.app = MagicMock()
app.app.id = "app-1"
app.app.tenant_id = "tenant-1"
return app
@@ -237,20 +240,29 @@ class TestChatTextApi:
self.method = unwrap(self.api.post)
def test_post_success(self, app: Flask, installed_app):
transcript_tts = MagicMock(return_value={"audio": "ok"})
with (
app.test_request_context(
"/",
json={"message_id": "m1", "text": "hello", "voice": "v1"},
),
patch.object(
audio_module.AudioService,
"transcript_tts",
return_value={"audio": "ok"},
audio_module,
"current_account_with_tenant",
return_value=(MagicMock(id="account-1"), "tenant-1"),
),
patch.object(audio_module.AudioService, "transcript_tts", transcript_tts),
):
resp = self.method(installed_app)
assert resp == {"audio": "ok"}
assert transcript_tts.call_args.kwargs["message_ref"] == MessageRef(
tenant_id="tenant-1",
app_id="app-1",
message_id="m1",
account_id="account-1",
)
def test_provider_not_initialized(self, app: Flask, installed_app):
with (
@@ -32,6 +32,7 @@ from graphon.model_runtime.errors.invoke import InvokeError
from models import Account
from models.account import TenantStatus
from models.model import AppMode
from services.app_ref_service import MessageRef
from services.errors.conversation import ConversationNotExistsError
from services.errors.llm import InvokeRateLimitError
@@ -774,6 +775,27 @@ class TestTrialChatTextApi:
assert result == {"audio": "base64_data"}
def test_success_with_message_ref(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
method = unwrap(api.post)
transcript_tts = MagicMock(return_value={"audio": "base64_data"})
trial_app_chat.tenant_id = "tenant-1"
with (
app.test_request_context("/", json={"text": "hello", "message_id": "message-1"}),
patch.object(module.AudioService, "transcript_tts", transcript_tts),
patch.object(module.RecommendedAppService, "add_trial_app_record"),
):
result = method(api, account, trial_app_chat)
assert result == {"audio": "base64_data"}
assert transcript_tts.call_args.kwargs["message_ref"] == MessageRef(
"tenant-1",
"a-chat",
"message-1",
account_id="u1",
)
def test_app_config_broken(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatTextApi()
method = unwrap(api.post)
@@ -253,6 +253,35 @@ class TestTagUpdateDeleteApi:
delete_mock.assert_called_once_with("tag-1", module.db.session)
assert status == 204
def test_delete_snippet_tag_checks_type_in_current_tenant(self, app: Flask, admin_user):
api = TagUpdateDeleteApi()
method = unwrap(api.delete)
with (
app.test_request_context("/"),
patch("controllers.console.tag.tags.dify_config.RBAC_ENABLED", True),
patch(
"controllers.console.tag.tags.current_account_with_tenant",
return_value=(SimpleNamespace(id="user-1"), "tenant-1"),
),
patch.object(module.db.session, "scalar", return_value=TagType.SNIPPET) as scalar_mock,
patch("controllers.console.tag.tags.enforce_rbac_access") as enforce_mock,
patch("controllers.console.tag.tags.TagService.delete_tag") as delete_mock,
):
result, status = method(api, "tag-1")
scalar_mock.assert_called_once()
enforce_mock.assert_called_once_with(
tenant_id="tenant-1",
account_id="user-1",
resource_type=module.RBACResourceScope.WORKSPACE,
scene=module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
delete_mock.assert_called_once_with("tag-1", module.db.session)
assert result == ""
assert status == 204
class TestTagBindingCollectionApi:
def test_create_success(self, app: Flask, admin_user, payload_patch):
@@ -188,7 +188,7 @@ class TestAnnotationReplyActionApi:
api = AnnotationReplyActionApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="app")
app_model = SimpleNamespace(id="app", tenant_id="tenant")
with app.test_request_context(
"/apps/annotation-reply/enable",
@@ -206,7 +206,7 @@ class TestAnnotationReplyActionApi:
api = AnnotationReplyActionApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="app")
app_model = SimpleNamespace(id="app", tenant_id="tenant")
with app.test_request_context(
"/apps/annotation-reply/disable",
@@ -333,7 +333,7 @@ class TestAnnotationUpdateDeleteApi:
api = AnnotationUpdateDeleteApi()
put_handler = unwrap(api.put)
delete_handler = unwrap(api.delete)
app_model = SimpleNamespace(id="app")
app_model = SimpleNamespace(id="app", tenant_id="tenant")
with app.test_request_context("/apps/annotations/1", method="PUT", json={"question": "q", "answer": "a"}):
response = put_handler(api, app_model=app_model, annotation_id="1")
@@ -32,6 +32,7 @@ from controllers.service_api.app.error import (
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
from services.app_ref_service import MessageRef
from services.audio_service import AudioService
from services.errors.app_model_config import AppModelConfigBrokenError
from services.errors.audio import (
@@ -180,7 +181,6 @@ class TestAudioServiceMockedBehavior:
text="Hello world",
voice="nova",
end_user="user_123",
message_id="msg_123",
)
assert result["audio"] == "base64_audio_data"
@@ -245,7 +245,7 @@ class TestTextApi:
api = TextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
end_user = SimpleNamespace(external_user_id="ext")
end_user = SimpleNamespace(id="end-user-1", external_user_id="ext")
with app.test_request_context(
"/text-to-audio",
@@ -256,6 +256,30 @@ class TestTextApi:
assert response == {"audio": "ok"}
def test_success_with_message_ref(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
calls = {}
def fake_transcript_tts(**kwargs):
calls.update(kwargs)
return {"audio": "ok"}
monkeypatch.setattr(AudioService, "transcript_tts", fake_transcript_tts)
api = TextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1", tenant_id="tenant-1")
end_user = SimpleNamespace(id="end-user-1", external_user_id="ext")
with app.test_request_context(
"/text-to-audio",
method="POST",
json={"text": "hello", "message_id": "message-1"},
):
response = handler(api, app_model=app_model, end_user=end_user)
assert response == {"audio": "ok"}
assert calls["message_ref"] == MessageRef("tenant-1", "a1", "message-1", end_user_id="end-user-1")
def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
AudioService, "transcript_tts", lambda **_kwargs: (_ for _ in ()).throw(QuotaExceededError())
@@ -264,7 +288,7 @@ class TestTextApi:
api = TextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
end_user = SimpleNamespace(external_user_id="ext")
end_user = SimpleNamespace(id="end-user-1", external_user_id="ext")
with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello"}):
with pytest.raises(ProviderQuotaExceededError):
@@ -90,6 +90,19 @@ def _child_chunk() -> ChildChunk:
return child_chunk
def _document_for_dataset(
dataset: Dataset, document_id: str = "doc-id", doc_form: str = IndexStructureType.PARAGRAPH_INDEX
):
document = Mock()
document.id = document_id
document.dataset_id = dataset.id
document.tenant_id = dataset.tenant_id
document.indexing_status = "completed"
document.enabled = True
document.doc_form = doc_form
return document
class TestSegmentCreatePayload:
"""Test suite for SegmentCreatePayload Pydantic model."""
@@ -890,7 +903,9 @@ class TestSegmentApiGet:
# Arrange
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock(doc_form=IndexStructureType.PARAGRAPH_INDEX)
mock_doc_svc.get_document.return_value = _document_for_dataset(
mock_dataset, doc_form=IndexStructureType.PARAGRAPH_INDEX
)
mock_seg_svc.get_segments.return_value = ([mock_segment], 1)
mock_get_summaries.return_value = {}
mock_dump_segments.return_value = [_segment_response_dict()]
@@ -1010,7 +1025,7 @@ class TestSegmentApiPost:
mock_dataset.indexing_technique = "economy"
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc = _document_for_dataset(mock_dataset)
mock_doc.indexing_status = "completed"
mock_doc.enabled = True
mock_doc.doc_form = IndexStructureType.PARAGRAPH_INDEX
@@ -1062,7 +1077,7 @@ class TestSegmentApiPost:
mock_dataset.indexing_technique = "economy"
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc = _document_for_dataset(mock_dataset)
mock_doc.indexing_status = "completed"
mock_doc.enabled = True
mock_doc_svc.get_document.return_value = mock_doc
@@ -1104,7 +1119,7 @@ class TestSegmentApiPost:
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc = _document_for_dataset(mock_dataset)
mock_doc.indexing_status = "indexing" # Not completed
mock_doc_svc.get_document.return_value = mock_doc
@@ -1156,10 +1171,10 @@ class TestDatasetSegmentApiDelete:
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc = Mock()
mock_doc = _document_for_dataset(mock_dataset)
mock_doc_svc.get_document.return_value = mock_doc
mock_seg_svc.get_segment_by_id.return_value = mock_segment
mock_seg_svc.get_segment_by_ref.return_value = mock_segment
mock_seg_svc.delete_segment.return_value = None
# Act
@@ -1199,13 +1214,13 @@ class TestDatasetSegmentApiDelete:
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc = _document_for_dataset(mock_dataset)
mock_doc.indexing_status = "completed"
mock_doc.enabled = True
mock_doc.doc_form = IndexStructureType.PARAGRAPH_INDEX
mock_doc_svc.get_document.return_value = mock_doc
mock_seg_svc.get_segment_by_id.return_value = None # Segment not found
mock_seg_svc.get_segment_by_ref.return_value = None # Segment not found
# Act & Assert
with app.test_request_context(
@@ -1351,8 +1366,10 @@ class TestDatasetSegmentApiUpdate:
mock_dataset.indexing_technique = "economy"
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.get_document.return_value = Mock(doc_form=IndexStructureType.PARAGRAPH_INDEX)
mock_seg_svc.get_segment_by_id.return_value = mock_segment
mock_doc_svc.get_document.return_value = _document_for_dataset(
mock_dataset, doc_form=IndexStructureType.PARAGRAPH_INDEX
)
mock_seg_svc.get_segment_by_ref.return_value = mock_segment
updated = Mock()
updated.id = "updated-seg"
mock_seg_svc.update_segment.return_value = updated
@@ -1441,8 +1458,8 @@ class TestDatasetSegmentApiUpdate:
mock_dataset.indexing_technique = "economy"
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = None
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
mock_seg_svc.get_segment_by_ref.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/seg-id",
@@ -1492,9 +1509,9 @@ class TestDatasetSegmentApiGetSingle:
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc = Mock(doc_form=IndexStructureType.PARAGRAPH_INDEX)
mock_doc = _document_for_dataset(mock_dataset, doc_form=IndexStructureType.PARAGRAPH_INDEX)
mock_doc_svc.get_document.return_value = mock_doc
mock_seg_svc.get_segment_by_id.return_value = mock_segment
mock_seg_svc.get_segment_by_ref.return_value = mock_segment
mock_get_summary.return_value = None
mock_dump_segment.return_value = _segment_response_dict()
@@ -1539,9 +1556,9 @@ class TestDatasetSegmentApiGetSingle:
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc = Mock(doc_form=IndexStructureType.PARAGRAPH_INDEX)
mock_doc = _document_for_dataset(mock_dataset, doc_form=IndexStructureType.PARAGRAPH_INDEX)
mock_doc_svc.get_document.return_value = mock_doc
mock_seg_svc.get_segment_by_id.return_value = mock_segment
mock_seg_svc.get_segment_by_ref.return_value = mock_segment
mock_summary_record = Mock(summary_content="This is the segment summary")
mock_get_summary.return_value = mock_summary_record
mock_dump_segment.return_value = _segment_response_dict("This is the segment summary")
@@ -1641,8 +1658,8 @@ class TestDatasetSegmentApiGetSingle:
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = None
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
mock_seg_svc.get_segment_by_ref.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/seg-id",
@@ -1682,8 +1699,8 @@ class TestChildChunkApiGet:
"""Test successful child chunk list retrieval."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = Mock()
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
mock_seg_svc.get_segment_by_ref.return_value = Mock()
mock_pagination = Mock()
mock_pagination.items = [_child_chunk(), _child_chunk()]
@@ -1781,8 +1798,8 @@ class TestChildChunkApiGet:
"""Test 404 when segment not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = None
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
mock_seg_svc.get_segment_by_ref.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/seg-id/child_chunks",
@@ -1844,8 +1861,8 @@ class TestChildChunkApiPost:
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_dataset.indexing_technique = "economy"
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = Mock()
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
mock_seg_svc.get_segment_by_ref.return_value = Mock()
mock_child = _child_chunk()
mock_seg_svc.create_child_chunk.return_value = mock_child
@@ -1922,8 +1939,8 @@ class TestChildChunkApiPost:
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = None
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
mock_seg_svc.get_segment_by_ref.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/seg-id/child_chunks",
@@ -1976,19 +1993,19 @@ class TestDatasetChildChunkApiDelete:
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc = _document_for_dataset(mock_dataset)
mock_doc_svc.get_document.return_value = mock_doc
segment_id = str(uuid.uuid4())
mock_segment = Mock()
mock_segment.id = segment_id
mock_segment.document_id = "doc-id"
mock_seg_svc.get_segment_by_id.return_value = mock_segment
mock_seg_svc.get_segment_by_ref.return_value = mock_segment
child_chunk_id = str(uuid.uuid4())
mock_child = Mock()
mock_child.segment_id = segment_id
mock_seg_svc.get_child_chunk_by_id.return_value = mock_child
mock_seg_svc.get_child_chunk_by_segment_ref.return_value = mock_child
mock_seg_svc.delete_child_chunk.return_value = None
with app.test_request_context(
@@ -2025,14 +2042,14 @@ class TestDatasetChildChunkApiDelete:
"""Test 404 when child chunk not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
segment_id = str(uuid.uuid4())
mock_segment = Mock()
mock_segment.id = segment_id
mock_segment.document_id = "doc-id"
mock_seg_svc.get_segment_by_id.return_value = mock_segment
mock_seg_svc.get_child_chunk_by_id.return_value = None
mock_seg_svc.get_segment_by_ref.return_value = mock_segment
mock_seg_svc.get_child_chunk_by_segment_ref.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/{segment_id}/child_chunks/cc-id",
@@ -2066,13 +2083,10 @@ class TestDatasetChildChunkApiDelete:
"""Test 404 when segment does not belong to the document."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
segment_id = str(uuid.uuid4())
mock_segment = Mock()
mock_segment.id = segment_id
mock_segment.document_id = "different-doc-id"
mock_seg_svc.get_segment_by_id.return_value = mock_segment
mock_seg_svc.get_segment_by_ref.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/{segment_id}/child_chunks/cc-id",
@@ -2106,17 +2120,15 @@ class TestDatasetChildChunkApiDelete:
"""Test 404 when child chunk does not belong to the segment."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_doc_svc.get_document.return_value = _document_for_dataset(mock_dataset)
segment_id = str(uuid.uuid4())
mock_segment = Mock()
mock_segment.id = segment_id
mock_segment.document_id = "doc-id"
mock_seg_svc.get_segment_by_id.return_value = mock_segment
mock_seg_svc.get_segment_by_ref.return_value = mock_segment
mock_child = Mock()
mock_child.segment_id = "different-segment-id"
mock_seg_svc.get_child_chunk_by_id.return_value = mock_child
mock_seg_svc.get_child_chunk_by_segment_ref.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/{segment_id}/child_chunks/cc-id",
@@ -22,6 +22,7 @@ from controllers.web.error import (
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
from services.app_ref_service import MessageRef
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
@@ -122,6 +123,24 @@ class TestTextApi:
assert result == "audio-bytes"
mock_tts.assert_called_once()
@patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes")
@patch("controllers.web.audio.web_ns")
def test_happy_path_with_message_ref(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None:
message_id = "550e8400-e29b-41d4-a716-446655440000"
mock_ns.payload = {"text": "hello", "message_id": message_id}
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="chat")
with app.test_request_context("/text-to-audio", method="POST"):
result = TextApi().post(app_model, _end_user())
assert result == "audio-bytes"
assert mock_tts.call_args.kwargs["message_ref"] == MessageRef(
"tenant-1",
"app-1",
message_id,
end_user_id="eu-1",
)
@patch(
"controllers.web.audio.AudioService.transcript_tts",
side_effect=InvokeError(description="invoke failed"),
@@ -422,6 +422,13 @@ class TestLLMGenerator:
"tenant_id", "flow_id", "current", "instruction", model_config_entity, "ideal"
)
assert result == {"modified": "prompt"}
stmt = mock_scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "messages.app_id" in statement
assert "apps.tenant_id" in statement
assert "flow_id" in compiled.params.values()
assert "tenant_id" in compiled.params.values()
def test_instruction_modify_legacy_with_last_run(self, mock_model_instance, model_config_entity):
with patch("extensions.ext_database.db.session.scalar") as mock_scalar:
@@ -439,12 +446,26 @@ class TestLLMGenerator:
"tenant_id", "flow_id", "current", "instruction", model_config_entity, "ideal"
)
assert result == {"modified": "prompt"}
stmt = mock_scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "messages.app_id" in statement
assert "apps.tenant_id" in statement
assert "flow_id" in compiled.params.values()
assert "tenant_id" in compiled.params.values()
def test_instruction_modify_workflow_app_not_found(self):
with patch("extensions.ext_database.db.session") as mock_session:
mock_session.return_value.scalar.return_value = None
with pytest.raises(ValueError, match="App not found."):
LLMGenerator.instruction_modify_workflow("t", "f", "n", "c", "i", MagicMock(), "o", MagicMock())
stmt = mock_session.return_value.scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "apps.id" in statement
assert "apps.tenant_id" in statement
assert "f" in compiled.params.values()
assert "t" in compiled.params.values()
def test_instruction_modify_workflow_no_workflow(self):
with patch("extensions.ext_database.db.session") as mock_session:
@@ -12,6 +12,7 @@ from models.dataset import Dataset, Pipeline, PipelineCustomizedTemplate, Pipeli
from models.workflow import Workflow
from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, PipelineTemplateInfoEntity
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.workflow_ref_service import WorkflowRef
@pytest.fixture
@@ -335,15 +336,15 @@ def test_update_workflow_updates_allowed_fields(
workflow = SimpleNamespace(
id="wf-1", marked_name="", marked_comment="", updated_by=None, updated_at=None, disallowed="original"
)
workflow_ref = WorkflowRef(tenant_id="t1", owner_id="pipeline-1", workflow_id="wf-1")
session = mocker.Mock()
session.scalar.return_value = workflow
result = rag_pipeline_service.update_workflow(
session=session,
workflow_id="wf-1",
tenant_id="t1",
account_id="u1",
data={"marked_name": "v1", "marked_comment": "release", "disallowed": "hacked"},
workflow_ref=workflow_ref,
)
assert result.marked_name == "v1"
@@ -360,15 +361,43 @@ def test_update_workflow_returns_none_when_not_found(
result = rag_pipeline_service.update_workflow(
session=session,
workflow_id="wf-missing",
tenant_id="t1",
account_id="u1",
data={"marked_name": "v1"},
workflow_ref=WorkflowRef(tenant_id="t1", owner_id="pipeline-1", workflow_id="wf-missing"),
)
assert result is None
def test_update_workflow_with_ref_scopes_lookup_to_pipeline(
mocker: MockerFixture, rag_pipeline_service: RagPipelineService
) -> None:
workflow = SimpleNamespace(
id="wf-1", marked_name="", marked_comment="", updated_by=None, updated_at=None, disallowed="original"
)
workflow_ref = WorkflowRef(tenant_id="t1", owner_id="pipeline-1", workflow_id="wf-1")
session = mocker.Mock()
session.scalar.return_value = workflow
result = rag_pipeline_service.update_workflow(
session=session,
account_id="u1",
data={"marked_name": "v1"},
workflow_ref=workflow_ref,
)
stmt = session.scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "workflows.id" in statement
assert "workflows.tenant_id" in statement
assert "workflows.app_id" in statement
assert "wf-1" in compiled.params.values()
assert "t1" in compiled.params.values()
assert "pipeline-1" in compiled.params.values()
assert result is workflow
# --- get_rag_pipeline_paginate_workflow_runs ---
@@ -1627,6 +1656,8 @@ def test_handle_node_run_result_marks_document_error_for_published_invoke(
def __init__(self):
self._values = {
("sys", "invoke_from"): SimpleNamespace(value=InvokeFrom.PUBLISHED_PIPELINE),
("sys", "app_id"): SimpleNamespace(value="pipeline-1"),
("sys", "dataset_id"): SimpleNamespace(value="dataset-1"),
("sys", "document_id"): SimpleNamespace(value="doc-1"),
}
@@ -1660,7 +1691,8 @@ def test_handle_node_run_result_marks_document_error_for_published_invoke(
)
document = SimpleNamespace(indexing_status="waiting", error=None)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=document)
scalar_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=document)
get_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get")
add_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.add")
commit_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit")
@@ -1672,6 +1704,19 @@ def test_handle_node_run_result_marks_document_error_for_published_invoke(
)
assert result.status == WorkflowNodeExecutionStatus.FAILED
stmt = scalar_mock.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "documents.id" in statement
assert "documents.tenant_id" in statement
assert "documents.dataset_id" in statement
assert "datasets.tenant_id" in statement
assert "datasets.pipeline_id" in statement
assert "doc-1" in compiled.params.values()
assert "t1" in compiled.params.values()
assert "dataset-1" in compiled.params.values()
assert "pipeline-1" in compiled.params.values()
get_mock.assert_not_called()
assert document.indexing_status == "error"
assert document.error == "boom"
add_mock.assert_called_once_with(document)
@@ -15,6 +15,7 @@ from werkzeug.exceptions import NotFound
from models.model import App, AppAnnotationHitHistory, AppAnnotationSetting, Message, MessageAnnotation
from services.annotation_service import AppAnnotationService
from services.app_ref_service import AnnotationRef, AppRef
def _make_app(app_id: str = "app-1", tenant_id: str = "tenant-1") -> MagicMock:
@@ -25,6 +26,14 @@ def _make_app(app_id: str = "app-1", tenant_id: str = "tenant-1") -> MagicMock:
return app
def _make_app_ref(app: MagicMock) -> AppRef:
return AppRef(tenant_id=app.tenant_id, app_id=app.id)
def _make_annotation_ref(app: MagicMock, annotation_id: str = "ann-1") -> AnnotationRef:
return AnnotationRef(tenant_id=app.tenant_id, app_id=app.id, annotation_id=annotation_id)
def _make_user(user_id: str = "user-1") -> MagicMock:
user = MagicMock()
user.id = user_id
@@ -41,9 +50,10 @@ def _make_message(message_id: str = "msg-1", app_id: str = "app-1") -> MagicMock
return message
def _make_annotation(annotation_id: str = "ann-1") -> MagicMock:
def _make_annotation(annotation_id: str = "ann-1", app_id: str = "app-1") -> MagicMock:
annotation = MagicMock(spec=MessageAnnotation)
annotation.id = annotation_id
annotation.app_id = app_id
annotation.content = ""
annotation.question = ""
annotation.question_text = ""
@@ -66,6 +76,15 @@ def _make_file(content: bytes) -> FileStorage:
return FileStorage(stream=BytesIO(content))
def _assert_statement_binds_annotation(stmt: Any, annotation_id: str, app_id: str) -> None:
compiled = stmt.compile()
statement = str(compiled)
assert "message_annotations.id" in statement
assert "message_annotations.app_id" in statement
assert annotation_id in compiled.params.values()
assert app_id in compiled.params.values()
class TestAppAnnotationServiceUpInsert:
"""Test suite for up_insert_app_annotation_from_message."""
@@ -537,23 +556,6 @@ class TestAppAnnotationServiceDirectManipulation:
tenant_id = "tenant-1"
app = _make_app()
with (
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
mock_db.session.scalar.return_value = app
mock_db.session.get.return_value = None
# Act & Assert
with pytest.raises(NotFound):
AppAnnotationService.update_app_annotation_directly(args, app.id, "ann-1", mock_db.session)
def test_update_app_annotation_directly_should_raise_not_found_when_app_missing(self) -> None:
"""Test missing app raises NotFound in update path."""
# Arrange
args = {"answer": "hello", "question": "q1"}
tenant_id = "tenant-1"
with (
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
@@ -562,7 +564,11 @@ class TestAppAnnotationServiceDirectManipulation:
# Act & Assert
with pytest.raises(NotFound):
AppAnnotationService.update_app_annotation_directly(args, "app-1", "ann-1", mock_db.session)
AppAnnotationService.update_app_annotation_directly(
args,
_make_annotation_ref(app, "ann-1"),
mock_db.session,
)
def test_update_app_annotation_directly_should_raise_value_error_when_question_missing(self) -> None:
"""Test missing question raises ValueError."""
@@ -576,12 +582,13 @@ class TestAppAnnotationServiceDirectManipulation:
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
mock_db.session.scalar.return_value = app
mock_db.session.get.return_value = annotation
mock_db.session.scalar.return_value = annotation
# Act & Assert
with pytest.raises(ValueError):
AppAnnotationService.update_app_annotation_directly(args, app.id, annotation.id, mock_db.session)
AppAnnotationService.update_app_annotation_directly(
args, _make_annotation_ref(app, annotation.id), mock_db.session
)
def test_update_app_annotation_directly_should_update_annotation_and_index(self) -> None:
"""Test update changes fields and triggers index update."""
@@ -598,16 +605,19 @@ class TestAppAnnotationServiceDirectManipulation:
patch("services.annotation_service.db") as mock_db,
patch("services.annotation_service.update_annotation_to_index_task") as mock_task,
):
mock_db.session.scalar.side_effect = [app, setting]
mock_db.session.get.return_value = annotation
mock_db.session.scalar.side_effect = [annotation, setting]
# Act
result = AppAnnotationService.update_app_annotation_directly(args, app.id, annotation.id, mock_db.session)
result = AppAnnotationService.update_app_annotation_directly(
args, _make_annotation_ref(app, annotation.id), mock_db.session
)
# Assert
assert result == annotation
assert annotation.content == "hello"
assert annotation.question == "q1"
_assert_statement_binds_annotation(mock_db.session.scalar.call_args_list[0].args[0], annotation.id, app.id)
mock_db.session.get.assert_not_called()
mock_db.session.commit.assert_called_once()
mock_task.delay.assert_called_once_with(
annotation.id,
@@ -632,17 +642,18 @@ class TestAppAnnotationServiceDirectManipulation:
patch("services.annotation_service.db") as mock_db,
patch("services.annotation_service.delete_annotation_index_task") as mock_task,
):
mock_db.session.scalar.side_effect = [app, setting]
mock_db.session.get.return_value = annotation
mock_db.session.scalar.side_effect = [annotation, setting]
scalars_result = MagicMock()
scalars_result.all.return_value = [history1, history2]
mock_db.session.scalars.return_value = scalars_result
# Act
AppAnnotationService.delete_app_annotation(app.id, annotation.id, mock_db.session)
AppAnnotationService.delete_app_annotation(_make_annotation_ref(app, annotation.id), mock_db.session)
# Assert
_assert_statement_binds_annotation(mock_db.session.scalar.call_args_list[0].args[0], annotation.id, app.id)
mock_db.session.get.assert_not_called()
mock_db.session.delete.assert_any_call(annotation)
mock_db.session.delete.assert_any_call(history1)
mock_db.session.delete.assert_any_call(history2)
@@ -654,21 +665,6 @@ class TestAppAnnotationServiceDirectManipulation:
setting.collection_binding_id,
)
def test_delete_app_annotation_should_raise_not_found_when_app_missing(self) -> None:
"""Test delete raises NotFound when app is missing."""
# Arrange
tenant_id = "tenant-1"
with (
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(NotFound):
AppAnnotationService.delete_app_annotation("app-1", "ann-1", mock_db.session)
def test_delete_app_annotation_should_raise_not_found_when_annotation_missing(self) -> None:
"""Test delete raises NotFound when annotation is missing."""
# Arrange
@@ -679,12 +675,11 @@ class TestAppAnnotationServiceDirectManipulation:
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
mock_db.session.scalar.return_value = app
mock_db.session.get.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(NotFound):
AppAnnotationService.delete_app_annotation(app.id, "ann-1", mock_db.session)
AppAnnotationService.delete_app_annotation(_make_annotation_ref(app, "ann-1"), mock_db.session)
def test_delete_app_annotations_in_batch_should_return_zero_when_none_found(self) -> None:
"""Test batch delete returns zero when no annotations found."""
@@ -696,30 +691,14 @@ class TestAppAnnotationServiceDirectManipulation:
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
mock_db.session.scalar.return_value = app
mock_db.session.execute.return_value.all.return_value = []
# Act
result = AppAnnotationService.delete_app_annotations_in_batch(app.id, ["ann-1"])
result = AppAnnotationService.delete_app_annotations_in_batch(_make_app_ref(app), ["ann-1"])
# Assert
assert result == {"deleted_count": 0}
def test_delete_app_annotations_in_batch_should_raise_not_found_when_app_missing(self) -> None:
"""Test batch delete raises NotFound when app is missing."""
# Arrange
tenant_id = "tenant-1"
with (
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(NotFound):
AppAnnotationService.delete_app_annotations_in_batch("app-1", ["ann-1"])
def test_delete_app_annotations_in_batch_should_delete_annotations_and_histories(self) -> None:
"""Test batch delete removes annotations and triggers index deletion."""
# Arrange
@@ -734,8 +713,6 @@ class TestAppAnnotationServiceDirectManipulation:
patch("services.annotation_service.db") as mock_db,
patch("services.annotation_service.delete_annotation_index_task") as mock_task,
):
mock_db.session.scalar.return_value = app
# First execute().all() for multi-column query, subsequent execute() calls for deletes
execute_result_multi = MagicMock()
execute_result_multi.all.return_value = [(annotation1, setting), (annotation2, None)]
@@ -744,10 +721,17 @@ class TestAppAnnotationServiceDirectManipulation:
mock_db.session.execute.side_effect = [execute_result_multi, MagicMock(), execute_result_delete]
# Act
result = AppAnnotationService.delete_app_annotations_in_batch(app.id, ["ann-1", "ann-2"])
result = AppAnnotationService.delete_app_annotations_in_batch(_make_app_ref(app), ["ann-1", "ann-2"])
# Assert
assert result == {"deleted_count": 2}
fetch_stmt = mock_db.session.execute.call_args_list[0].args[0]
compiled = fetch_stmt.compile()
statement = str(compiled)
assert "message_annotations.id IN" in statement
assert "message_annotations.app_id" in statement
assert ["ann-1", "ann-2"] in compiled.params.values()
assert app.id in compiled.params.values()
mock_task.delay.assert_called_once_with(annotation1.id, app.id, tenant_id, setting.collection_binding_id)
mock_db.session.commit.assert_called_once()
@@ -1094,20 +1078,17 @@ class TestAppAnnotationServiceBatchImport:
class TestAppAnnotationServiceHitHistoryAndSettings:
"""Test suite for hit history and settings methods."""
def test_get_annotation_hit_histories_should_raise_not_found_when_app_missing(self) -> None:
"""Test missing app raises NotFound."""
def test_get_annotation_hit_histories_should_raise_not_found_when_annotation_missing(self) -> None:
"""Test missing annotation raises NotFound."""
# Arrange
tenant_id = "tenant-1"
app = _make_app()
with (
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
with patch("services.annotation_service.db") as mock_db:
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(NotFound):
AppAnnotationService.get_annotation_hit_histories("app-1", "ann-1", 1, 10)
AppAnnotationService.get_annotation_hit_histories(_make_annotation_ref(app, "ann-1"), 1, 10)
def test_get_annotation_hit_histories_should_return_items_and_total(self) -> None:
"""Test hit histories pagination returns items and total."""
@@ -1121,33 +1102,21 @@ class TestAppAnnotationServiceHitHistoryAndSettings:
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
mock_db.session.scalar.return_value = app
mock_db.session.get.return_value = annotation
mock_db.session.scalar.return_value = annotation
mock_db.paginate.return_value = pagination
# Act
items, total = AppAnnotationService.get_annotation_hit_histories(app.id, annotation.id, 1, 10)
items, total = AppAnnotationService.get_annotation_hit_histories(
_make_annotation_ref(app, annotation.id),
1,
10,
)
# Assert
assert items == ["h1"]
assert total == 2
def test_get_annotation_hit_histories_should_raise_not_found_when_annotation_missing(self) -> None:
"""Test missing annotation raises NotFound."""
# Arrange
tenant_id = "tenant-1"
app = _make_app()
with (
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
patch("services.annotation_service.db") as mock_db,
):
mock_db.session.scalar.return_value = app
mock_db.session.get.return_value = None
# Act & Assert
with pytest.raises(NotFound):
AppAnnotationService.get_annotation_hit_histories(app.id, "ann-1", 1, 10)
_assert_statement_binds_annotation(mock_db.session.scalar.call_args_list[0].args[0], annotation.id, app.id)
mock_db.session.get.assert_not_called()
def test_get_annotation_by_id_should_return_none_when_missing(self) -> None:
"""Test get_annotation_by_id returns None when not found."""
@@ -62,6 +62,7 @@ from werkzeug.datastructures import FileStorage
from models.enums import MessageStatus
from models.model import App, AppMode, AppModelConfig, Message
from models.workflow import Workflow
from services.app_ref_service import MessageRef
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -521,9 +522,16 @@ class TestAudioServiceTTS:
# Arrange
app = factory.create_app_mock(mode=AppMode.CHAT)
message_id = "00000000-0000-0000-0000-000000000001"
message_ref = MessageRef(
tenant_id=app.tenant_id,
app_id=app.id,
message_id=message_id,
end_user_id="end-user-1",
account_id="account-1",
)
message = factory.create_message_mock(message_id=message_id, answer="Message answer")
session = MagicMock()
session.get.return_value = message
session.scalar.return_value = message
mock_model_manager = mock_model_manager_class.return_value
mock_model_instance = MagicMock()
@@ -534,13 +542,25 @@ class TestAudioServiceTTS:
result = AudioService.transcript_tts(
app_model=app,
session=session,
message_id=message_id,
message_ref=message_ref,
voice="message-voice",
)
# Assert
assert result == b"message audio"
session.get.assert_called_once_with(Message, message_id)
session.scalar.assert_called_once()
session.get.assert_not_called()
stmt = session.scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "messages.id" in statement
assert "messages.app_id" in statement
assert "messages.from_end_user_id" in statement
assert "messages.from_account_id" in statement
assert message_id in compiled.params.values()
assert app.id in compiled.params.values()
assert "end-user-1" in compiled.params.values()
assert "account-1" in compiled.params.values()
mock_model_instance.invoke_tts.assert_called_once_with(
content_text="Message answer",
voice="message-voice",
@@ -1,5 +1,7 @@
"""Unit tests for DocumentService behaviors in dataset_service."""
from services.dataset_ref_service import DatasetRef
from .dataset_service_test_helpers import (
Account,
BuiltInField,
@@ -103,6 +105,39 @@ class TestDocumentServiceMutations:
assert DocumentService.check_archived(document) is expected
def test_delete_documents_limits_query_and_cleanup_to_dataset_ref(self):
dataset = _make_dataset(dataset_id="dataset-1", tenant_id="tenant-1")
dataset.doc_form = "paragraph_index"
document = _make_document(document_id="doc-1", dataset_id=dataset.id, tenant_id=dataset.tenant_id)
document.data_source_info_dict = {}
with (
patch("services.dataset_service.db") as mock_db,
patch("services.dataset_service.batch_clean_document_task") as clean_task,
):
mock_db.session.scalars.return_value.all.return_value = [document]
dataset_ref = DatasetRef(tenant_id=dataset.tenant_id, dataset_id=dataset.id)
DocumentService.delete_documents(
dataset_ref,
["doc-1", "other-doc"],
dataset.doc_form,
mock_db.session,
)
stmt = mock_db.session.scalars.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "documents.id IN" in statement
assert "documents.tenant_id" in statement
assert "documents.dataset_id" in statement
assert ["doc-1", "other-doc"] in compiled.params.values()
assert dataset.tenant_id in compiled.params.values()
assert dataset.id in compiled.params.values()
mock_db.session.delete.assert_called_once_with(document)
mock_db.session.commit.assert_called_once()
clean_task.delay.assert_called_once_with(["doc-1"], dataset.id, dataset.doc_form, [])
def test_rename_document_raises_when_dataset_is_missing(self, rename_account_context):
session = MagicMock()
@@ -1,5 +1,7 @@
"""Unit tests for SegmentService behaviors in dataset_service."""
from services.dataset_ref_service import DatasetRef, DatasetRefService
from .dataset_service_test_helpers import (
Account,
ChildChunk,
@@ -24,6 +26,41 @@ from .dataset_service_test_helpers import (
)
def _make_segment_ref(segment_id: str = "segment-1"):
dataset = _make_dataset()
document = _make_document(dataset_id=dataset.id, tenant_id=dataset.tenant_id)
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
document_ref = DatasetRefService.create_document_ref(dataset_ref, document)
assert document_ref is not None
return DatasetRefService.create_segment_ref(document_ref, segment_id)
class TestDatasetRefService:
"""Unit tests for typed dataset resource refs."""
def test_dataset_ref_is_plain_named_tuple(self):
dataset_ref = DatasetRef("tenant-1", "dataset-1")
assert dataset_ref.tenant_id == "tenant-1"
assert dataset_ref.dataset_id == "dataset-1"
assert tuple(dataset_ref) == ("tenant-1", "dataset-1")
def test_create_document_ref_rejects_document_outside_dataset(self):
dataset = _make_dataset(dataset_id="dataset-1", tenant_id="tenant-1")
document = _make_document(document_id="doc-1", dataset_id="other-dataset", tenant_id="tenant-1")
dataset_ref = DatasetRefService.create_dataset_ref(dataset)
assert DatasetRefService.create_document_ref(dataset_ref, document) is None
def test_create_segment_ref_carries_full_parent_chain(self):
segment_ref = _make_segment_ref()
assert segment_ref.tenant_id == "tenant-1"
assert segment_ref.dataset_id == "dataset-1"
assert segment_ref.document_id == "doc-1"
assert segment_ref.segment_id == "segment-1"
class TestSegmentServiceChildChunks:
"""Unit tests for child-chunk CRUD helpers."""
@@ -265,6 +302,23 @@ class TestSegmentServiceQueries:
assert result is None
def test_get_child_chunk_by_segment_ref_uses_full_ownership_chain(self):
child_chunk = _make_child_chunk()
segment_ref = _make_segment_ref()
with patch("services.dataset_service.db") as mock_db:
mock_db.session.scalar.return_value = child_chunk
result = SegmentService.get_child_chunk_by_segment_ref("child-a", segment_ref)
assert result is child_chunk
stmt = mock_db.session.scalar.call_args.args[0]
sql = str(stmt.compile(compile_kwargs={"literal_binds": True}))
assert "child_chunks.id = 'child-a'" in sql
assert "child_chunks.tenant_id = 'tenant-1'" in sql
assert "child_chunks.dataset_id = 'dataset-1'" in sql
assert "child_chunks.document_id = 'doc-1'" in sql
assert "child_chunks.segment_id = 'segment-1'" in sql
def test_get_segments_uses_status_and_keyword_filters(self):
paginated = SimpleNamespace(items=["segment"], total=1)
@@ -312,6 +366,32 @@ class TestSegmentServiceQueries:
assert result is None
def test_get_segment_by_ref_uses_full_ownership_chain(self):
segment = DocumentSegment(
tenant_id="tenant-1",
dataset_id="dataset-1",
document_id="doc-1",
position=1,
content="segment",
word_count=7,
tokens=2,
created_by="user-1",
)
segment.id = "segment-1"
segment_ref = _make_segment_ref()
with patch("services.dataset_service.db") as mock_db:
mock_db.session.scalar.return_value = segment
result = SegmentService.get_segment_by_ref(segment_ref)
assert result is segment
stmt = mock_db.session.scalar.call_args.args[0]
sql = str(stmt.compile(compile_kwargs={"literal_binds": True}))
assert "document_segments.id = 'segment-1'" in sql
assert "document_segments.tenant_id = 'tenant-1'" in sql
assert "document_segments.dataset_id = 'dataset-1'" in sql
assert "document_segments.document_id = 'doc-1'" in sql
def test_get_segments_by_document_and_dataset_returns_scalars_result(self):
segment = DocumentSegment(
tenant_id="tenant-1",
@@ -5,7 +5,7 @@ from pytest_mock import MockerFixture
from werkzeug.exceptions import NotFound
from models.enums import TagType
from services.tag_service import TagBindingCreatePayload, TagBindingDeletePayload, TagService
from services.tag_service import TagBindingCreatePayload, TagBindingDeletePayload, TagService, UpdateTagPayload
@pytest.fixture
@@ -78,6 +78,71 @@ def test_delete_tag_binding_does_not_commit_when_no_rows_deleted(mocker: MockerF
db_session.commit.assert_not_called()
def test_update_tags_scopes_lookup_to_current_tenant_and_type(current_user, db_session):
tag = SimpleNamespace(id="tag-1", name="old", type=TagType.KNOWLEDGE)
db_session.scalar.side_effect = [tag, None]
result = TagService.update_tags(UpdateTagPayload(name="new"), "tag-1", db_session, tag_type=TagType.KNOWLEDGE)
stmt = db_session.scalar.call_args_list[0].args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "tags.id" in statement
assert "tags.tenant_id" in statement
assert "tags.type" in statement
assert "tag-1" in compiled.params.values()
assert current_user.current_tenant_id in compiled.params.values()
assert TagType.KNOWLEDGE in compiled.params.values()
assert result is tag
assert tag.name == "new"
db_session.commit.assert_called_once()
def test_get_tag_binding_count_scopes_lookup_to_current_tenant_and_type(current_user, db_session):
db_session.scalar.return_value = 3
result = TagService.get_tag_binding_count("tag-1", db_session, tag_type=TagType.KNOWLEDGE)
stmt = db_session.scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "tag_bindings.tag_id" in statement
assert "tags.tenant_id" in statement
assert "tags.type" in statement
assert "tag-1" in compiled.params.values()
assert current_user.current_tenant_id in compiled.params.values()
assert TagType.KNOWLEDGE in compiled.params.values()
assert result == 3
def test_delete_tag_scopes_lookup_and_bindings_to_current_tenant(current_user, db_session):
tag = SimpleNamespace(id="tag-1", name="old", type=TagType.KNOWLEDGE)
binding = SimpleNamespace(id="binding-1")
db_session.scalar.return_value = tag
db_session.scalars.return_value.all.return_value = [binding]
TagService.delete_tag("tag-1", db_session, tag_type=TagType.KNOWLEDGE)
tag_stmt = db_session.scalar.call_args.args[0]
tag_compiled = tag_stmt.compile()
assert "tags.id" in str(tag_compiled)
assert "tags.tenant_id" in str(tag_compiled)
assert "tags.type" in str(tag_compiled)
assert "tag-1" in tag_compiled.params.values()
assert current_user.current_tenant_id in tag_compiled.params.values()
assert TagType.KNOWLEDGE in tag_compiled.params.values()
binding_stmt = db_session.scalars.call_args.args[0]
binding_compiled = binding_stmt.compile()
assert "tag_bindings.tag_id" in str(binding_compiled)
assert "tag_bindings.tenant_id" in str(binding_compiled)
assert "tag-1" in binding_compiled.params.values()
assert current_user.current_tenant_id in binding_compiled.params.values()
db_session.delete.assert_any_call(tag)
db_session.delete.assert_any_call(binding)
db_session.commit.assert_called_once()
def test_get_target_ids_by_tag_ids_returns_empty_without_query_for_empty_input(db_session):
result = TagService.get_target_ids_by_tag_ids(TagType.SNIPPET, "tenant-1", [], db_session)
@@ -36,6 +36,7 @@ from models.model import App, AppMode
from models.workflow import Workflow, WorkflowType
from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededError, WorkflowHashNotEqualError
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
from services.workflow_ref_service import WorkflowRef
from services.workflow_service import (
WorkflowService,
_rebuild_file_for_user_inputs_in_start_node,
@@ -1008,6 +1009,8 @@ class TestWorkflowService:
"""
workflow_id = "workflow-123"
tenant_id = "tenant-456"
app_id = "app-789"
workflow_ref = WorkflowRef(tenant_id=tenant_id, owner_id=app_id, workflow_id=workflow_id)
account_id = "user-123"
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id)
@@ -1021,10 +1024,9 @@ class TestWorkflowService:
result = workflow_service.update_workflow(
session=mock_session,
workflow_id=workflow_id,
tenant_id=tenant_id,
account_id=account_id,
data={"marked_name": "Updated Name", "marked_comment": "Updated Comment"},
workflow_ref=workflow_ref,
)
assert result == mock_workflow
@@ -1044,14 +1046,42 @@ class TestWorkflowService:
result = workflow_service.update_workflow(
session=mock_session,
workflow_id="nonexistent",
tenant_id="tenant-456",
account_id="user-123",
data={"marked_name": "Test"},
workflow_ref=WorkflowRef(tenant_id="tenant-456", owner_id="app-789", workflow_id="nonexistent"),
)
assert result is None
def test_update_workflow_with_ref_scopes_lookup_to_app(self, workflow_service: WorkflowService):
"""Test update_workflow includes the trusted app owner in the lookup."""
workflow_id = "workflow-123"
tenant_id = "tenant-456"
app_id = "app-789"
account_id = "user-123"
workflow_ref = WorkflowRef(tenant_id=tenant_id, owner_id=app_id, workflow_id=workflow_id)
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id)
mock_session = MagicMock()
mock_session.scalar.return_value = mock_workflow
result = workflow_service.update_workflow(
session=mock_session,
account_id=account_id,
data={"marked_name": "Updated Name"},
workflow_ref=workflow_ref,
)
stmt = mock_session.scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "workflows.id" in statement
assert "workflows.tenant_id" in statement
assert "workflows.app_id" in statement
assert workflow_id in compiled.params.values()
assert tenant_id in compiled.params.values()
assert app_id in compiled.params.values()
assert result == mock_workflow
# ==================== Delete Workflow Tests ====================
# These tests verify workflow deletion with safety checks
@@ -1064,6 +1094,8 @@ class TestWorkflowService:
"""
workflow_id = "workflow-123"
tenant_id = "tenant-456"
app_id = "app-789"
workflow_ref = WorkflowRef(tenant_id=tenant_id, owner_id=app_id, workflow_id=workflow_id)
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
mock_session = MagicMock()
@@ -1078,13 +1110,35 @@ class TestWorkflowService:
mock_select.return_value = mock_stmt
mock_stmt.where.return_value = mock_stmt
result = workflow_service.delete_workflow(
session=mock_session, workflow_id=workflow_id, tenant_id=tenant_id
)
result = workflow_service.delete_workflow(session=mock_session, workflow_ref=workflow_ref)
assert result is True
mock_session.delete.assert_called_once_with(mock_workflow)
def test_delete_workflow_with_ref_scopes_lookup_to_app(self, workflow_service: WorkflowService):
"""Test delete_workflow includes the trusted app owner in the lookup."""
workflow_id = "workflow-123"
tenant_id = "tenant-456"
app_id = "app-789"
workflow_ref = WorkflowRef(tenant_id=tenant_id, owner_id=app_id, workflow_id=workflow_id)
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
mock_session = MagicMock()
mock_session.scalar.side_effect = [mock_workflow, None, None]
result = workflow_service.delete_workflow(session=mock_session, workflow_ref=workflow_ref)
stmt = mock_session.scalar.call_args_list[0].args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "workflows.id" in statement
assert "workflows.tenant_id" in statement
assert "workflows.app_id" in statement
assert workflow_id in compiled.params.values()
assert tenant_id in compiled.params.values()
assert app_id in compiled.params.values()
assert result is True
mock_session.delete.assert_called_once_with(mock_workflow)
def test_delete_workflow_draft_raises_error(self, workflow_service: WorkflowService):
"""
Test delete_workflow raises error when trying to delete draft.
@@ -1094,6 +1148,7 @@ class TestWorkflowService:
"""
workflow_id = "workflow-123"
tenant_id = "tenant-456"
workflow_ref = WorkflowRef(tenant_id=tenant_id, owner_id="app-789", workflow_id=workflow_id)
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(
workflow_id=workflow_id, version=Workflow.VERSION_DRAFT
)
@@ -1107,7 +1162,7 @@ class TestWorkflowService:
mock_stmt.where.return_value = mock_stmt
with pytest.raises(DraftWorkflowDeletionError, match="Cannot delete draft workflow"):
workflow_service.delete_workflow(session=mock_session, workflow_id=workflow_id, tenant_id=tenant_id)
workflow_service.delete_workflow(session=mock_session, workflow_ref=workflow_ref)
def test_delete_workflow_in_use_by_app_raises_error(self, workflow_service: WorkflowService):
"""
@@ -1118,6 +1173,7 @@ class TestWorkflowService:
"""
workflow_id = "workflow-123"
tenant_id = "tenant-456"
workflow_ref = WorkflowRef(tenant_id=tenant_id, owner_id="app-789", workflow_id=workflow_id)
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
mock_app = TestWorkflowAssociatedDataFactory.create_app_mock(workflow_id=workflow_id)
@@ -1130,7 +1186,7 @@ class TestWorkflowService:
mock_stmt.where.return_value = mock_stmt
with pytest.raises(WorkflowInUseError, match="currently in use by app"):
workflow_service.delete_workflow(session=mock_session, workflow_id=workflow_id, tenant_id=tenant_id)
workflow_service.delete_workflow(session=mock_session, workflow_ref=workflow_ref)
def test_delete_workflow_published_as_tool_raises_error(self, workflow_service: WorkflowService):
"""
@@ -1142,6 +1198,7 @@ class TestWorkflowService:
"""
workflow_id = "workflow-123"
tenant_id = "tenant-456"
workflow_ref = WorkflowRef(tenant_id=tenant_id, owner_id="app-789", workflow_id=workflow_id)
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
mock_tool_provider = MagicMock()
@@ -1154,12 +1211,13 @@ class TestWorkflowService:
mock_stmt.where.return_value = mock_stmt
with pytest.raises(WorkflowInUseError, match="published as a tool"):
workflow_service.delete_workflow(session=mock_session, workflow_id=workflow_id, tenant_id=tenant_id)
workflow_service.delete_workflow(session=mock_session, workflow_ref=workflow_ref)
def test_delete_workflow_not_found_raises_error(self, workflow_service: WorkflowService):
"""Test delete_workflow raises error when workflow not found."""
workflow_id = "nonexistent"
tenant_id = "tenant-456"
workflow_ref = WorkflowRef(tenant_id=tenant_id, owner_id="app-789", workflow_id=workflow_id)
mock_session = MagicMock()
mock_session.scalar.return_value = None
@@ -1170,7 +1228,7 @@ class TestWorkflowService:
mock_stmt.where.return_value = mock_stmt
with pytest.raises(ValueError, match="not found"):
workflow_service.delete_workflow(session=mock_session, workflow_id=workflow_id, tenant_id=tenant_id)
workflow_service.delete_workflow(session=mock_session, workflow_ref=workflow_ref)
# ==================== Get Default Block Config Tests ====================
# These tests verify retrieval of default node configurations
+1 -3
View File
@@ -113,8 +113,6 @@ def test_plugin_install_response_without_task_is_non_blocking():
def test_import_response_with_warnings_and_app_id_is_success():
import_workflow_app = _load_setup_module("import_workflow_app")
assert import_workflow_app.is_successful_import_response(
{"status": "completed-with-warnings", "app_id": "app-id"}
)
assert import_workflow_app.is_successful_import_response({"status": "completed-with-warnings", "app_id": "app-id"})
assert not import_workflow_app.is_successful_import_response({"status": "failed", "app_id": "app-id"})
assert not import_workflow_app.is_successful_import_response({"status": "completed"})