diff --git a/api/alembic/versions/c52dc3cccfb0_add_last_validated_at_on_org_config.py b/api/alembic/versions/c52dc3cccfb0_add_last_validated_at_on_org_config.py new file mode 100644 index 00000000..f4ceeaf9 --- /dev/null +++ b/api/alembic/versions/c52dc3cccfb0_add_last_validated_at_on_org_config.py @@ -0,0 +1,29 @@ +"""add last_validated_at on org config + +Revision ID: c52dc3cccfb0 +Revises: b7e3c9a1d2f4 +Create Date: 2026-07-09 19:18:29.550267 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c52dc3cccfb0" +down_revision: Union[str, None] = "b7e3c9a1d2f4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "organization_configurations", + sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("organization_configurations", "last_validated_at") diff --git a/api/db/models.py b/api/db/models.py index 66837724..09a1a7dc 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -205,11 +205,8 @@ class OrganizationConfigurationModel(Base): key = Column(String, nullable=False) value = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) - updated_at = Column( - DateTime(timezone=True), - default=lambda: datetime.now(UTC), - onupdate=lambda: datetime.now(UTC), - ) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + last_validated_at = Column(DateTime(timezone=True), nullable=True) # Relationships organization = relationship("OrganizationModel", back_populates="configurations") diff --git a/api/db/organization_configuration_client.py b/api/db/organization_configuration_client.py index 3be9d12e..1d197a98 100644 --- a/api/db/organization_configuration_client.py +++ b/api/db/organization_configuration_client.py @@ -34,10 +34,15 @@ class OrganizationConfigurationClient(BaseDBClient): return result.scalars().all() async def upsert_configuration( - self, organization_id: int, key: str, value: Any + self, + organization_id: int, + key: str, + value: Any, + last_validated_at: datetime | None = None, ) -> OrganizationConfigurationModel: """Create or update a configuration for an organization.""" async with self.async_session() as session: + now = datetime.now(UTC) # First try to get existing configuration result = await session.execute( select(OrganizationConfigurationModel).where( @@ -50,12 +55,16 @@ class OrganizationConfigurationClient(BaseDBClient): if config: # Update existing configuration config.value = value + config.updated_at = now + config.last_validated_at = last_validated_at else: # Create new configuration config = OrganizationConfigurationModel( organization_id=organization_id, key=key, value=value, + updated_at=now, + last_validated_at=last_validated_at, ) session.add(config) @@ -67,10 +76,10 @@ class OrganizationConfigurationClient(BaseDBClient): await session.refresh(config) return config - async def touch_configuration( + async def mark_configuration_validated( self, organization_id: int, key: str ) -> Optional[OrganizationConfigurationModel]: - """Update the timestamp for an existing organization configuration.""" + """Update the validation timestamp for an existing organization configuration.""" async with self.async_session() as session: result = await session.execute( select(OrganizationConfigurationModel).where( @@ -82,7 +91,7 @@ class OrganizationConfigurationClient(BaseDBClient): if not config: return None - config.updated_at = datetime.now(UTC) + config.last_validated_at = datetime.now(UTC) try: await session.commit() except Exception as e: diff --git a/api/services/configuration/ai_model_configuration.py b/api/services/configuration/ai_model_configuration.py index 71a9e05e..f43b98a3 100644 --- a/api/services/configuration/ai_model_configuration.py +++ b/api/services/configuration/ai_model_configuration.py @@ -2,6 +2,7 @@ from __future__ import annotations import copy from dataclasses import dataclass +from datetime import UTC, datetime from typing import Literal from loguru import logger @@ -72,7 +73,9 @@ async def get_resolved_ai_model_configuration( if organization_configuration is not None: effective = compile_ai_model_configuration_v2(organization_configuration) if organization_configuration_row is not None: - effective.last_validated_at = organization_configuration_row.updated_at + effective.last_validated_at = ( + organization_configuration_row.last_validated_at + ) return ResolvedAIModelConfiguration( effective=effective, source="organization_v2", @@ -118,7 +121,7 @@ async def get_organization_ai_model_configuration_v2( async def update_organization_ai_model_configuration_last_validated_at( organization_id: int, ) -> None: - await db_client.touch_configuration( + await db_client.mark_configuration_validated( organization_id, OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, ) @@ -159,6 +162,7 @@ async def upsert_organization_ai_model_configuration_v2( organization_id, OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, configuration.model_dump(mode="json", exclude_none=True), + last_validated_at=datetime.now(UTC), ) return configuration diff --git a/api/tests/test_ai_model_configuration_v2.py b/api/tests/test_ai_model_configuration_v2.py index bdf3558c..ab1bd8a5 100644 --- a/api/tests/test_ai_model_configuration_v2.py +++ b/api/tests/test_ai_model_configuration_v2.py @@ -19,6 +19,7 @@ from api.services.configuration.ai_model_configuration import ( mask_ai_model_configuration_v2, merge_ai_model_configuration_v2_secrets, migrate_workflow_configuration_model_override_to_v2, + upsert_organization_ai_model_configuration_v2, ) from api.services.configuration.check_validity import UserConfigurationValidator from api.services.configuration.masking import mask_key @@ -150,7 +151,7 @@ async def test_byok_realtime_validator_does_not_require_stt_or_tts(): @pytest.mark.asyncio -async def test_resolved_org_v2_uses_configuration_updated_at_as_validation_cache( +async def test_resolved_org_v2_uses_last_validated_at_as_validation_cache( monkeypatch, ): from datetime import UTC, datetime @@ -164,7 +165,7 @@ async def test_resolved_org_v2_uses_configuration_updated_at_as_validation_cache ) row = SimpleNamespace( value=config.model_dump(mode="json", exclude_none=True), - updated_at=last_validated_at, + last_validated_at=last_validated_at, ) monkeypatch.setattr( ai_model_configuration.db_client, @@ -178,6 +179,54 @@ async def test_resolved_org_v2_uses_configuration_updated_at_as_validation_cache assert resolved.effective.last_validated_at == last_validated_at +@pytest.mark.asyncio +async def test_upsert_org_v2_marks_configuration_validated(monkeypatch): + from api.services.configuration import ai_model_configuration + + config = OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration(api_key="mps-secret"), + ) + upsert = AsyncMock() + monkeypatch.setattr( + ai_model_configuration.db_client, + "upsert_configuration", + upsert, + ) + + result = await upsert_organization_ai_model_configuration_v2(42, config) + + assert result is config + upsert.assert_awaited_once() + args = upsert.await_args.args + kwargs = upsert.await_args.kwargs + assert args == ( + 42, + "MODEL_CONFIGURATION_V2", + config.model_dump(mode="json", exclude_none=True), + ) + assert kwargs["last_validated_at"].tzinfo is not None + + +def test_org_config_validation_timestamp_update_does_not_touch_updated_at(): + from datetime import UTC, datetime + + from sqlalchemy import update + from sqlalchemy.dialects import postgresql + + from api.db.models import OrganizationConfigurationModel + + stmt = ( + update(OrganizationConfigurationModel) + .where(OrganizationConfigurationModel.id == 1) + .values(last_validated_at=datetime.now(UTC)) + ) + + compiled = str(stmt.compile(dialect=postgresql.dialect())) + assert "last_validated_at" in compiled + assert "updated_at" not in compiled + + @pytest.mark.asyncio async def test_pipeline_validator_requires_stt_and_tts_when_not_realtime(): effective = EffectiveAIModelConfiguration(