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:
co-authored by
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent
f8afa49ab9
commit
62cb5b5865
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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}:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+10
-2
@@ -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):
|
||||
|
||||
+15
-4
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user