Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/.importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,20 @@ forbidden_modules =
sqlalchemy
werkzeug

[importlinter:contract:app-preview-query-boundary]
name = App preview queries are framework and persistence neutral
type = forbidden
source_modules =
services.app_preview_query_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug

[importlinter:contract:message-suggested-questions-boundary]
name = Message suggested questions port is framework and persistence neutral
type = forbidden
Expand Down
26 changes: 26 additions & 0 deletions api/controllers/console/app/preview_admission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Public catalog preview admission, separate from authenticated trial execution."""

from collections.abc import Callable
from functools import wraps
from typing import Concatenate

from controllers.console.app.error import AppNotFoundError
from extensions.ext_application_services import application_services
from services.app_preview_query_service import AppPreviewRef, AppPreviewUnavailableError


def get_preview_app[T, **P, R](
view: Callable[Concatenate[T, AppPreviewRef, P], R],
) -> Callable[Concatenate[T, P], R]:
@wraps(view)
def decorated(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
app_id = kwargs.pop("app_id", None)
if app_id is None:
raise RuntimeError("The app preview admission route must provide app_id")
try:
app = application_services().app_previews.get_access(app_id=str(app_id))
except AppPreviewUnavailableError as error:
raise AppNotFoundError() from error
return view(self, app, *args, **kwargs)

return decorated
12 changes: 12 additions & 0 deletions api/controllers/console/explore/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ class RecommendedAppNotFoundError(BaseHTTPException):
code = 404


class AppPreviewSiteUnavailableError(BaseHTTPException):
error_code = "app_site_unavailable"
description = "The app preview site is unavailable."
code = 403


class AppPreviewOwnerUnavailableError(BaseHTTPException):
error_code = "app_owner_unavailable"
description = "The app preview owner is unavailable."
code = 403


class TrialAppNotAllowed(BaseHTTPException):
"""*403* `Trial App Not Allowed`

Expand Down
80 changes: 44 additions & 36 deletions api/controllers/console/explore/trial.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@
from flask import Response, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
from werkzeug.exceptions import InternalServerError, NotFound, Unauthorized

import services
from controllers.common.fields import (
Expand All @@ -27,6 +26,7 @@
)
from controllers.console import console_ns
from controllers.console.app.error import (
AppNotFoundError,
AppUnavailableError,
AudioTooLargeError,
CompletionRequestError,
Expand All @@ -40,7 +40,14 @@
SpeechToTextDisabledError,
UnsupportedAudioTypeError,
)
from controllers.console.app.preview_admission import get_preview_app
from controllers.console.app.wraps import get_previewable_app_model, with_session
from controllers.console.explore.error import (
AppPreviewOwnerUnavailableError as AppPreviewOwnerUnavailableHttpError,
)
from controllers.console.explore.error import (
AppPreviewSiteUnavailableError as AppPreviewSiteUnavailableHttpError,
)
from controllers.console.explore.error import (
AppSuggestedQuestionsAfterAnswerDisabledError,
NotChatAppError,
Expand Down Expand Up @@ -74,17 +81,19 @@
from libs.helper import dump_response, to_timestamp, uuid_value
from machinery.context import RequestContext
from models import Account
from models.account import TenantStatus
from models.enums import CreatorUserRole
from models.model import Site
from models.workflow import Workflow
from services.account_errors import AccountNotFoundError
from services.account_service import TenantService
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import (
AppPreviewOwnerUnavailableError,
AppPreviewRef,
AppPreviewSiteUnavailableError,
AppPreviewUnavailableError,
)
from services.app_ref_service import AppRefService
from services.app_service import AppResponseView, AppService
from services.audio_service import AudioService
from services.dataset_service import DatasetService
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
Expand Down Expand Up @@ -807,40 +816,31 @@ class TrialSitApi(Resource):
"""Resource for trial app sites."""

@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
@with_session(write=False)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
"""Retrieve app site info.

Returns the site configuration for the application including theme, icons, and text.
"""
site = session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))

if not site:
raise Forbidden()

tenant = TenantService.get_tenant_by_id(app_model.tenant_id, session=session)
assert tenant
if tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()

return SiteResponse.model_validate(site).model_dump(mode="json")
try:
site = application_services().app_previews.get_site(app=app)
except AppPreviewSiteUnavailableError as error:
raise AppPreviewSiteUnavailableHttpError(str(error)) from error
except AppPreviewOwnerUnavailableError as error:
raise AppPreviewOwnerUnavailableHttpError(str(error)) from error
return dump_response(SiteResponse, site)


class TrialAppParameterApi(Resource):
"""Resource for app variables."""

@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
@with_session(write=False)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
"""Retrieve app parameters."""

if app_model is None:
raise AppUnavailableError()

try:
parameters = application_services().app_definitions.get_parameters(app_model.id)
parameters = application_services().app_definitions.get_parameters(app.app_id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None

Expand Down Expand Up @@ -885,20 +885,28 @@ def get(self, session: Session, app_model):
class DatasetListApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
@console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__])
@with_session(write=False)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
@get_preview_app
def get(self, app: AppPreviewRef) -> dict[str, object]:
# These legacy fields are response metadata: the query returns all
# requested IDs. Keep their integer fallback and echo behavior.
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
ids = request.args.getlist("ids")

tenant_id = app_model.tenant_id
if ids:
datasets, total = DatasetService.get_datasets_by_ids(ids, tenant_id, session=session)
else:
if not ids:
raise NeedAddIdsError()

response = {"data": datasets, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
try:
datasets = application_services().app_previews.get_datasets(app=app, ids=ids)
except AppPreviewUnavailableError as error:
raise AppNotFoundError() from error

response = {
"data": datasets,
"has_more": len(datasets) == limit,
"limit": limit,
"total": len(datasets),
"page": page,
}
return dump_response(TrialDatasetListResponse, response)


Expand Down
18 changes: 13 additions & 5 deletions api/extensions/ext_application_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
)
from repositories.account_repository import SQLAlchemyAccountRepository
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
from repositories.app_preview_query_repository import AppPreviewQueryRepository
from repositories.app_site_command_repository import AppSiteCommandRepository
from repositories.app_statistic_query_repository import AppStatisticQueryRepository
from repositories.app_tracing_config_repository import SQLAlchemyAppTracingConfigRepository
Expand Down Expand Up @@ -136,6 +137,7 @@
from services.account_password_service import AccountPasswordService
from services.account_profile_service import AccountProfileService
from services.app_definition_query_service import AppDefinitionQueryService
from services.app_preview_query_service import AppPreviewQueryService
from services.app_site_service import AppSiteService
from services.app_statistic_query import AppStatisticQuery
from services.app_task_service import AppTaskControlService
Expand Down Expand Up @@ -260,6 +262,7 @@ class ApplicationServices:
accounts: AccountServices
account_activation: AccountActivationService
app_definitions: AppDefinitionQueryService
app_previews: AppPreviewQueryService
app_sites: AppSiteService
app_statistics: AppStatisticQuery
app_tracing_configs: AppTracingConfigService
Expand Down Expand Up @@ -446,6 +449,11 @@ def build_application_services(
database=database_catalog,
builtin=builtin_catalog,
)
recommended_app_queries = RecommendedAppQueryService(
catalog=recommended_app_catalog,
trial_apps=trial_apps,
trial_enabled=trial_app_enabled,
)
workspace_query_repository = WorkspaceQueryRepository(session_factory=database_client)
file_service = FileService(session_factory=database_client)
remote_file_service = RemoteFileService(files=file_service)
Expand Down Expand Up @@ -618,6 +626,10 @@ def build_application_services(
dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/"
),
),
app_previews=AppPreviewQueryService(
apps=AppPreviewQueryRepository(session_factory=database_client),
is_previewable=recommended_app_queries.is_previewable,
),
app_sites=AppSiteService(
sites=AppSiteCommandRepository(session_factory=database_client),
),
Expand Down Expand Up @@ -716,11 +728,7 @@ def build_application_services(
partner_tenant_bindings=PartnerTenantBindingService(
sync_bindings=BillingService.sync_partner_tenants_bindings,
),
recommended_app_queries=RecommendedAppQueryService(
catalog=recommended_app_catalog,
trial_apps=trial_apps,
trial_enabled=trial_app_enabled,
),
recommended_app_queries=recommended_app_queries,
remote_files=remote_file_service,
app_tasks=AppTaskControlService(redis_client=redis),
trial_app_access=TrialAppAccessService(apps=trial_apps),
Expand Down
6 changes: 3 additions & 3 deletions api/repositories/app_definition_query_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from services.web_app_runtime_query_service import WebAppRuntimeRecord


def _map_site_configuration(site: Site) -> AppSiteConfiguration:
def map_site_configuration(site: Site) -> AppSiteConfiguration:
return AppSiteConfiguration(
title=site.title,
chat_color_theme=site.chat_color_theme,
Expand Down Expand Up @@ -175,7 +175,7 @@ def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None:
if site is None:
return None

return _map_site_configuration(site)
return map_site_configuration(site)

def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None:
with self._session_factory() as session:
Expand All @@ -194,7 +194,7 @@ def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None:
app_id = app.id
tenant_id = app.tenant_id
enable_site = app.enable_site
site_configuration = _map_site_configuration(site)
site_configuration = map_site_configuration(site)
plan = tenant.plan
tenant_status = tenant.status.value
tenant_custom_config_json = tenant.custom_config
Expand Down
79 changes: 79 additions & 0 deletions api/repositories/app_preview_query_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Load detached app-preview data without trial or account admission policy."""

from collections.abc import Sequence
from typing import override

from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker

from models.account import Tenant
from models.dataset import Dataset
from models.model import App, Site
from repositories.app_definition_query_repository import map_site_configuration
from services.app_preview_query_service import (
AppPreviewDataset,
AppPreviewQuery,
AppPreviewRef,
AppPreviewSite,
)


class AppPreviewQueryRepository(AppPreviewQuery):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory

@override
def get_app(self, *, app_id: str) -> AppPreviewRef | None:
with self._session_factory() as session:
row = session.execute(
select(App.id, App.tenant_id).where(App.id == app_id, App.status == "normal")
).one_or_none()
if row is None:
return None
return AppPreviewRef(app_id=row.id, tenant_id=row.tenant_id)

@override
def get_site(self, *, app: AppPreviewRef) -> AppPreviewSite | None:
with self._session_factory() as session:
row = session.execute(
select(Site, Tenant.status)
.select_from(App)
.join(Site, Site.app_id == App.id)
.outerjoin(Tenant, Tenant.id == App.tenant_id)
.where(App.id == app.app_id, App.tenant_id == app.tenant_id, App.status == "normal")
.limit(1)
).first()
if row is None:
return None
site, owner_status = row
return AppPreviewSite(
configuration=map_site_configuration(site),
owner_status=owner_status.value if owner_status is not None else None,
)

@override
def get_datasets(self, *, app: AppPreviewRef, ids: Sequence[str]) -> tuple[AppPreviewDataset, ...] | None:
with self._session_factory() as session:
app_id = session.scalar(
select(App.id).where(App.id == app.app_id, App.tenant_id == app.tenant_id, App.status == "normal")
)
if app_id is None:
return None
if not ids:
return ()
datasets = session.scalars(select(Dataset).where(Dataset.id.in_(ids), Dataset.tenant_id == app.tenant_id))
return tuple(
AppPreviewDataset(
id=dataset.id,
name=dataset.name,
description=dataset.description,
permission=dataset.permission.value if dataset.permission is not None else None,
data_source_type=dataset.data_source_type.value if dataset.data_source_type is not None else None,
indexing_technique=dataset.indexing_technique.value
if dataset.indexing_technique is not None
else None,
created_by=dataset.created_by,
created_at=dataset.created_at,
)
for dataset in datasets
)
Loading
Loading