diff --git a/api/models/model.py b/api/models/model.py index f665a7dc46e..dde53291b81 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -15,7 +15,7 @@ import sqlalchemy as sa from flask import request from flask_login import UserMixin # type: ignore[import-untyped] from sqlalchemy import BigInteger, Float, Index, PrimaryKeyConstraint, String, exists, func, select, text -from sqlalchemy.orm import Mapped, Session, mapped_column +from sqlalchemy.orm import Mapped, Session, mapped_column, validates from configs import dify_config from constants import DEFAULT_FILE_NUMBER_LIMITS @@ -2316,7 +2316,15 @@ class AppMCPServer(TypeBase): return cast(dict[str, str], json.loads(self.parameters)) -class Site(Base): +class Site(TypeBase): + """Public site configuration backed by the nullable legacy ``sites`` schema. + + Only the app, title, language, and token strategy are required at + construction time. Nullable database columns keep ``None`` defaults so + converting this model to ``TypeBase`` does not make legacy call sites pass + values that the database has never required. + """ + __tablename__ = "sites" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="site_pkey"), @@ -2324,47 +2332,62 @@ class Site(Base): sa.Index("site_code_idx", "code", "status"), ) - id = mapped_column(StringUUID, default=lambda: str(uuid4())) - app_id = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False) - icon_type: Mapped[IconType | None] = mapped_column(EnumText(IconType, length=255), nullable=True) - icon: Mapped[str | None] = mapped_column(String(255)) - icon_background = mapped_column(String(255)) - description = mapped_column(LongText) default_language: Mapped[str] = mapped_column(String(255), nullable=False) - chat_color_theme = mapped_column(String(255)) - chat_color_theme_inverted: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) - copyright = mapped_column(String(255)) - privacy_policy = mapped_column(String(255)) - input_placeholder = mapped_column(String(255)) - show_workflow_steps: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true")) - use_icon_as_answer_icon: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) - _custom_disclaimer: Mapped[str] = mapped_column("custom_disclaimer", LongText, default="") - customize_domain = mapped_column(String(255)) customize_token_strategy: Mapped[CustomizeTokenStrategy] = mapped_column( EnumText(CustomizeTokenStrategy, length=255), nullable=False ) - prompt_public: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) + + id: Mapped[str] = mapped_column( + StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()) + ) + icon_type: Mapped[IconType | None] = mapped_column(EnumText(IconType, length=255), nullable=True, default=None) + icon: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + icon_background: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + description: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + copyright: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + privacy_policy: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + input_placeholder: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + code: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) + updated_at: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, + ) + + customize_domain: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + chat_color_theme: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + prompt_public: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, server_default=sa.text("false"), default=False + ) + chat_color_theme_inverted: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, server_default=sa.text("false"), default=False + ) + show_workflow_steps: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, server_default=sa.text("true"), default=True + ) + use_icon_as_answer_icon: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, server_default=sa.text("false"), default=False + ) + custom_disclaimer: Mapped[str] = mapped_column(LongText, nullable=False, default="") status: Mapped[AppStatus] = mapped_column( EnumText(AppStatus, length=255), nullable=False, server_default=sa.text("'normal'"), default=AppStatus.NORMAL ) - created_by = mapped_column(StringUUID, nullable=True) - created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) - updated_by = mapped_column(StringUUID, nullable=True) - updated_at = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() - ) - code = mapped_column(String(255)) - @property - def custom_disclaimer(self): - return self._custom_disclaimer - - @custom_disclaimer.setter - def custom_disclaimer(self, value: str): + @validates("custom_disclaimer") + def _validate_custom_disclaimer(self, _key: str, value: str) -> str: + """Reject disclaimers that exceed the public site API's 512-character limit.""" if len(value) > 512: raise ValueError("Custom disclaimer cannot exceed 512 characters.") - self._custom_disclaimer = value + return value @staticmethod def generate_code(n: int, *, session: Session) -> str: diff --git a/api/repositories/recommended_app_catalog_repository.py b/api/repositories/recommended_app_catalog_repository.py index d5a685267fd..320e95fa62e 100644 --- a/api/repositories/recommended_app_catalog_repository.py +++ b/api/repositories/recommended_app_catalog_repository.py @@ -149,9 +149,9 @@ class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery): icon_background=app.icon_background, ), app_id=recommended_app.app_id, - description=cast(str | None, site.description), - copyright=cast(str | None, site.copyright), - privacy_policy=cast(str | None, site.privacy_policy), + description=site.description, + copyright=site.copyright, + privacy_policy=site.privacy_policy, custom_disclaimer=cast(str | None, site.custom_disclaimer), categories=app_categories, position=recommended_app.position, diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py index d26965a0e99..363197e6c74 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -34,7 +34,7 @@ from models.human_input import ( RecipientType, StandaloneWebAppRecipientPayload, ) -from models.model import App, AppMode, CustomizeTokenStrategy, Site +from models.model import App, AppMode, CustomizeTokenStrategy, IconType, Site from models.workflow import WorkflowRun, WorkflowType from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository from services.entities.feature_entities import FeatureModel @@ -68,7 +68,7 @@ def _create_app_with_site(session: Session) -> tuple[App, Account]: name="Test App", description="", mode=AppMode.WORKFLOW, - icon_type="emoji", + icon_type=IconType.EMOJI, icon="app", icon_background="#ffffff", enable_site=True, @@ -82,7 +82,7 @@ def _create_app_with_site(session: Session) -> tuple[App, Account]: site = Site( app_id=app.id, title="Test Site", - icon_type="emoji", + icon_type=IconType.EMOJI, icon="robot", icon_background="#ffffff", description="desc", diff --git a/api/tests/test_containers_integration_tests/models/test_conversation_status_count.py b/api/tests/test_containers_integration_tests/models/test_conversation_status_count.py index e706c07c820..9ad43df5402 100644 --- a/api/tests/test_containers_integration_tests/models/test_conversation_status_count.py +++ b/api/tests/test_containers_integration_tests/models/test_conversation_status_count.py @@ -12,7 +12,7 @@ import pytest from sqlalchemy.orm import Session from graphon.enums import WorkflowExecutionStatus -from models.enums import ConversationFromSource, InvokeFrom +from models.enums import ConversationFromSource, CustomizeTokenStrategy, InvokeFrom from models.model import App, AppMode, Conversation, Message, Site from models.workflow import Workflow, WorkflowRun, WorkflowRunTriggeredFrom, WorkflowType @@ -300,7 +300,7 @@ class TestSiteGenerateCode: app_id=app.id, title="Test Site", default_language="en-US", - customize_token_strategy="not_allow", + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, ) # Set an explicit code so generate_code must avoid it site.code = "AAAAAAAA" diff --git a/api/tests/test_containers_integration_tests/repositories/test_recommended_app_catalog_repository.py b/api/tests/test_containers_integration_tests/repositories/test_recommended_app_catalog_repository.py index 242d8c8324c..9b0e4db5b12 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_recommended_app_catalog_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_recommended_app_catalog_repository.py @@ -4,6 +4,7 @@ from uuid import uuid4 from sqlalchemy.orm import Session, object_session, sessionmaker from extensions.ext_redis import RedisClientWrapper +from models.enums import CustomizeTokenStrategy from models.model import App, RecommendedApp, Site from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository from services.recommended_app_query_service import RecommendedAppDetailRecord @@ -46,7 +47,7 @@ def _add_catalog_app( app_id=app.id, title=f"site-{uuid4()}", default_language="en-US", - customize_token_strategy="not_allow", + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, description="description", copyright="copyright", privacy_policy="privacy", diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index 4c50966777d..76a67efb3b6 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -1347,13 +1347,14 @@ class TestAppService: app = app_service.create_app(tenant.id, app_args, account, session=db_session_with_containers) # Create a site for the app - site = Site() - site.app_id = app.id - site.code = fake.postalcode() - site.title = fake.company() - site.status = AppStatus.NORMAL - site.default_language = "en-US" - site.customize_token_strategy = CustomizeTokenStrategy.UUID + site = Site( + app_id=app.id, + code=fake.postalcode(), + title=fake.company(), + status=AppStatus.NORMAL, + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.UUID, + ) db_session_with_containers.add(site) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py index d63ba1190ec..b9bc82f4132 100644 --- a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py @@ -8,7 +8,8 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound, Unauthorized from libs.password import hash_password -from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus +from models.enums import AppStatus, CustomizeTokenStrategy from models.model import App, Site from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError from services.webapp_auth_service import WebAppAuthService @@ -67,7 +68,7 @@ class TestWebAppAuthService: email=unique_email, name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) @@ -76,7 +77,7 @@ class TestWebAppAuthService: # Create tenant for the account tenant = Tenant( name=fake.company(), - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -120,7 +121,7 @@ class TestWebAppAuthService: email=unique_email, name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) # Hash password @@ -139,7 +140,7 @@ class TestWebAppAuthService: # Create tenant for the account tenant = Tenant( name=fake.company(), - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -200,8 +201,8 @@ class TestWebAppAuthService: code=fake.unique.lexify(text="??????"), description=fake.text(max_nb_chars=100), default_language="en-US", - status="normal", - customize_token_strategy="not_allow", + status=AppStatus.NORMAL, + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, ) db_session_with_containers.add(site) db_session_with_containers.commit() @@ -342,7 +343,7 @@ class TestWebAppAuthService: email=unique_email, name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) @@ -724,8 +725,8 @@ class TestWebAppAuthService: code=fake.unique.lexify(text="??????"), description=fake.text(max_nb_chars=100), default_language="en-US", - status="normal", - customize_token_strategy="not_allow", + status=AppStatus.NORMAL, + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, ) db_session_with_containers.add(site) db_session_with_containers.commit() diff --git a/api/tests/unit_tests/models/test_app_models.py b/api/tests/unit_tests/models/test_app_models.py index 93fc3b046ea..d00060312ad 100644 --- a/api/tests/unit_tests/models/test_app_models.py +++ b/api/tests/unit_tests/models/test_app_models.py @@ -12,14 +12,15 @@ import json from datetime import UTC, datetime from decimal import Decimal from unittest.mock import PropertyMock, patch -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest +from sqlalchemy import select from sqlalchemy.orm import Session, scoped_session from models import model as model_module from models.dataset import DatasetCollectionBinding -from models.enums import CollectionBindingType, ConversationFromSource +from models.enums import CollectionBindingType, ConversationFromSource, CustomizeTokenStrategy from models.model import ( App, AppAnnotationHitHistory, @@ -1123,6 +1124,28 @@ class TestAppAnnotationHitHistory: class TestSiteModel: """Test suite for Site model.""" + def test_site_core_bulk_insert_generates_ids(self, sqlite_session: Session): + """Core bulk inserts generate an ID for every site row.""" + app_ids = [str(uuid4()), str(uuid4())] + + sqlite_session.execute( + Site.__table__.insert(), + [ + { + "app_id": app_id, + "title": f"Site {index}", + "default_language": "en-US", + "customize_token_strategy": CustomizeTokenStrategy.UUID, + } + for index, app_id in enumerate(app_ids) + ], + ) + + site_ids = sqlite_session.scalars(select(Site.id).where(Site.app_id.in_(app_ids))).all() + assert len(site_ids) == len(app_ids) + assert len(set(site_ids)) == len(app_ids) + assert all(UUID(site_id).version == 4 for site_id in site_ids) + def test_site_creation_with_required_fields(self): """Test creating a site with required fields.""" # Arrange @@ -1133,14 +1156,14 @@ class TestSiteModel: app_id=app_id, title="Test Site", default_language="en-US", - customize_token_strategy="uuid", + customize_token_strategy=CustomizeTokenStrategy.UUID, ) # Assert assert site.app_id == app_id assert site.title == "Test Site" assert site.default_language == "en-US" - assert site.customize_token_strategy == "uuid" + assert site.customize_token_strategy == CustomizeTokenStrategy.UUID def test_site_creation_with_optional_fields(self): """Test creating a site with optional fields.""" @@ -1149,7 +1172,7 @@ class TestSiteModel: app_id=str(uuid4()), title="Test Site", default_language="en-US", - customize_token_strategy="uuid", + customize_token_strategy=CustomizeTokenStrategy.UUID, icon_type=IconType.EMOJI, icon="🌐", icon_background="#0066CC", @@ -1173,7 +1196,7 @@ class TestSiteModel: app_id=str(uuid4()), title="Test Site", default_language="en-US", - customize_token_strategy="uuid", + customize_token_strategy=CustomizeTokenStrategy.UUID, ) # Act @@ -1189,7 +1212,7 @@ class TestSiteModel: app_id=str(uuid4()), title="Test Site", default_language="en-US", - customize_token_strategy="uuid", + customize_token_strategy=CustomizeTokenStrategy.UUID, ) long_disclaimer = "x" * 513 # Exceeds 512 character limit @@ -1380,7 +1403,7 @@ class TestModelIntegration: app_id=app_id, title="Test Site", default_language="en-US", - customize_token_strategy="uuid", + customize_token_strategy=CustomizeTokenStrategy.UUID, ) # Assert diff --git a/api/tests/unit_tests/repositories/test_recommended_app_catalog_repository.py b/api/tests/unit_tests/repositories/test_recommended_app_catalog_repository.py index 6cbe8558550..ef5cfbc95ba 100644 --- a/api/tests/unit_tests/repositories/test_recommended_app_catalog_repository.py +++ b/api/tests/unit_tests/repositories/test_recommended_app_catalog_repository.py @@ -7,6 +7,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from extensions.ext_redis import RedisClientWrapper +from models.enums import CustomizeTokenStrategy from models.model import App, AppMode, RecommendedApp, Site from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository from services.recommended_app_query_service import RecommendedAppDetailRecord @@ -58,7 +59,7 @@ def _add_catalog_app( privacy_policy="site privacy", custom_disclaimer="site disclaimer", default_language="en-US", - customize_token_strategy="not_allow", + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, ) ) session.commit() diff --git a/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py b/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py index 478898cdc5e..02c7d3d7546 100644 --- a/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py +++ b/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py @@ -4,6 +4,7 @@ import pytest from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session, sessionmaker +from models.enums import CustomizeTokenStrategy from models.model import Site from repositories.webapp_access_query_repository import WebAppAccessQueryRepository from services.webapp_access_query_service import WebAppAccessUnavailableError @@ -19,7 +20,7 @@ def test_find_app_id_by_code_returns_matching_site_app(sqlite_session_factory: s code="site-code", title="Test Site", default_language="en-US", - customize_token_strategy="uuid", + customize_token_strategy=CustomizeTokenStrategy.UUID, ) )