Skip to content
4 changes: 2 additions & 2 deletions backend/adapter_processor_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from django.db.models import QuerySet
from tenant_account_v2.models import OrganizationMember
from utils.exceptions import InvalidEncryptionKey
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
Expand All @@ -29,7 +29,7 @@
logger = logging.getLogger(__name__)


class AdapterInstanceModelManager(DefaultOrganizationManagerMixin, models.Manager):
class AdapterInstanceModelManager(DefaultOrganizationManagerMixin, BaseModelManager):
def get_queryset(self) -> QuerySet[Any]:
return super().get_queryset()

Expand Down
4 changes: 2 additions & 2 deletions backend/api_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.db.models.signals import post_delete
from django.dispatch import receiver
from pipeline_v2.models import Pipeline
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
Expand All @@ -24,7 +24,7 @@
API_ENDPOINT_MAX_LENGTH = 255


class APIDeploymentModelManager(DefaultOrganizationManagerMixin, models.Manager):
class APIDeploymentModelManager(DefaultOrganizationManagerMixin, BaseModelManager):
def for_user(self, user):
"""Filter API deployments that the user can access:
- API deployments created by the user
Expand Down
4 changes: 2 additions & 2 deletions backend/connector_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from connector_processor.constants import ConnectorKeys
from django.db import models
from utils.fields import EncryptedBinaryField
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
Expand All @@ -22,7 +22,7 @@
logger = logging.getLogger(__name__)


class ConnectorInstanceModelManager(DefaultOrganizationManagerMixin, models.Manager):
class ConnectorInstanceModelManager(DefaultOrganizationManagerMixin, BaseModelManager):
def get_queryset(self) -> models.QuerySet:
return super().get_queryset()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ def _bulk_upsert_hourly(self, aggregations: dict) -> int:
"project",
"tag",
],
update_fields=["metric_type", "metric_value", "metric_count", "modified_at"],
update_fields=["metric_type", "metric_value", "metric_count"],
)
return len(objects)

Expand Down Expand Up @@ -457,7 +457,7 @@ def _bulk_upsert_daily(self, aggregations: dict) -> int:
"project",
"tag",
],
update_fields=["metric_type", "metric_value", "metric_count", "modified_at"],
update_fields=["metric_type", "metric_value", "metric_count"],
)
return len(objects)

Expand Down Expand Up @@ -490,6 +490,6 @@ def _bulk_upsert_monthly(self, aggregations: dict) -> int:
"project",
"tag",
],
update_fields=["metric_type", "metric_value", "metric_count", "modified_at"],
update_fields=["metric_type", "metric_value", "metric_count"],
)
return len(objects)
8 changes: 4 additions & 4 deletions backend/dashboard_metrics/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import uuid

from django.db import models
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
Expand All @@ -25,19 +25,19 @@ class MetricType(models.TextChoices):
HISTOGRAM = "histogram", "Histogram"


class EventMetricsHourlyManager(DefaultOrganizationManagerMixin):
class EventMetricsHourlyManager(DefaultOrganizationManagerMixin, BaseModelManager):
"""Manager for EventMetricsHourly with organization filtering."""

pass


class EventMetricsDailyManager(DefaultOrganizationManagerMixin):
class EventMetricsDailyManager(DefaultOrganizationManagerMixin, BaseModelManager):
"""Manager for EventMetricsDaily with organization filtering."""

pass


class EventMetricsMonthlyManager(DefaultOrganizationManagerMixin):
class EventMetricsMonthlyManager(DefaultOrganizationManagerMixin, BaseModelManager):
"""Manager for EventMetricsMonthly with organization filtering."""

pass
Expand Down
6 changes: 3 additions & 3 deletions backend/dashboard_metrics/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def _bulk_upsert_hourly(aggregations: dict) -> int:
objects,
update_conflicts=True,
unique_fields=["organization", "timestamp", "metric_name", "project", "tag"],
update_fields=["metric_type", "metric_value", "metric_count", "modified_at"],
update_fields=["metric_type", "metric_value", "metric_count"],
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.
)
return len(objects)

Expand Down Expand Up @@ -160,7 +160,7 @@ def _bulk_upsert_daily(aggregations: dict) -> int:
objects,
update_conflicts=True,
unique_fields=["organization", "date", "metric_name", "project", "tag"],
update_fields=["metric_type", "metric_value", "metric_count", "modified_at"],
update_fields=["metric_type", "metric_value", "metric_count"],
)
return len(objects)

Expand Down Expand Up @@ -199,7 +199,7 @@ def _bulk_upsert_monthly(aggregations: dict) -> int:
objects,
update_conflicts=True,
unique_fields=["organization", "month", "metric_name", "project", "tag"],
update_fields=["metric_type", "metric_value", "metric_count", "modified_at"],
update_fields=["metric_type", "metric_value", "metric_count"],
)
return len(objects)

Expand Down
4 changes: 2 additions & 2 deletions backend/pipeline_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.conf import settings
from django.db import models
from django.db.models import Q
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
Expand All @@ -18,7 +18,7 @@
PIPELINE_NAME_LENGTH = 32


class PipelineModelManager(DefaultOrganizationManagerMixin, models.Manager):
class PipelineModelManager(DefaultOrganizationManagerMixin, BaseModelManager):
def for_user(self, user):
"""Filter pipelines that the user can access:
- Pipelines created by the user
Expand Down
4 changes: 2 additions & 2 deletions backend/prompt_studio/prompt_studio_core_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.db.models import QuerySet
from utils.file_storage.constants import FileStorageKeys
from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
Expand All @@ -21,7 +21,7 @@
logger = logging.getLogger(__name__)


class CustomToolModelManager(DefaultOrganizationManagerMixin, models.Manager):
class CustomToolModelManager(DefaultOrganizationManagerMixin, BaseModelManager):
def for_user(self, user: User) -> QuerySet[Any]:
if getattr(user, "is_service_account", False):
return self.all()
Expand Down
4 changes: 2 additions & 2 deletions backend/prompt_studio/prompt_studio_registry_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from account_v2.models import User
from django.db import models
from django.db.models import QuerySet
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
Expand All @@ -21,7 +21,7 @@
logger = logging.getLogger(__name__)


class PromptStudioRegistryModelManager(DefaultOrganizationManagerMixin, models.Manager):
class PromptStudioRegistryModelManager(DefaultOrganizationManagerMixin, BaseModelManager):
def get_queryset(self) -> QuerySet[Any]:
return super().get_queryset()

Expand Down
4 changes: 2 additions & 2 deletions backend/tags/models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import uuid

from django.db import models
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
)
from utils.user_context import UserContext


class TagModelManager(DefaultOrganizationManagerMixin, models.Manager):
class TagModelManager(DefaultOrganizationManagerMixin, BaseModelManager):
def get_or_create_tags(self, tag_names: list[str]) -> list["Tag"]:
"""Retrieves or creates tags based on a list of tag names.

Expand Down
4 changes: 2 additions & 2 deletions backend/tool_instance_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
from account_v2.models import User
from django.db import models
from django.db.models import QuerySet
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from workflow_manager.workflow_v2.models.workflow import Workflow

TOOL_ID_LENGTH = 64
TOOL_VERSION_LENGTH = 16
TOOL_STATUS_LENGTH = 32


class ToolInstanceManager(models.Manager):
class ToolInstanceManager(BaseModelManager):
def get_instances_for_workflow(self, workflow: uuid.UUID) -> QuerySet["ToolInstance"]:
return self.filter(workflow=workflow)

Expand Down
4 changes: 2 additions & 2 deletions backend/usage_v2/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid

from django.db import models
from utils.models.base_model import BaseModel
from utils.models.base_model import BaseModel, BaseModelManager
from utils.models.organization_mixin import (
DefaultOrganizationManagerMixin,
DefaultOrganizationMixin,
Expand All @@ -19,7 +19,7 @@ class LLMUsageReason(models.TextChoices):
SUMMARIZE = "summarize", "Summarize"


class UsageModelManager(DefaultOrganizationManagerMixin, models.Manager):
class UsageModelManager(DefaultOrganizationManagerMixin, BaseModelManager):
pass


Expand Down
113 changes: 113 additions & 0 deletions backend/utils/models/base_model.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,122 @@
from django.db import models
from django.utils import timezone

_AUTO_NOW_FIELD = "modified_at"


def _with_modified_at(fields):
"""Return a new list containing ``fields`` plus ``modified_at`` if absent.

Centralises the "inject modified_at into a partial field list" rule so
``bulk_update``, ``bulk_create`` and ``BaseModel.save`` apply it the same
way.
"""
fields = list(fields)
if _AUTO_NOW_FIELD not in fields:
fields.append(_AUTO_NOW_FIELD)
return fields


class BaseModelQuerySet(models.QuerySet):
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.
"""QuerySet that mirrors ``auto_now`` semantics for bulk update paths.

``modified_at = models.DateTimeField(auto_now=True)`` only fires on
``Model.save()``. ``QuerySet.update()`` and ``QuerySet.bulk_update()``
issue raw SQL and bypass ``save()``, leaving ``modified_at`` at whatever
value it had before the bulk path ran (creation time for never-saved
rows, the previous save() timestamp for others) — silently drifting the
audit trail. This QuerySet patches both paths so callers don't have to
remember.

Callers can still override by passing ``modified_at`` explicitly (or by
including ``modified_at`` in the ``fields`` list for ``bulk_update``).

Note: this is a manager-level convention, not a model-level guarantee.
Subclasses that reassign ``objects`` to a plain ``models.Manager``, raw
SQL, and migration-time models returned by ``apps.get_model()`` all
bypass these overrides.
"""
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.

def update(self, **kwargs):
kwargs.setdefault(_AUTO_NOW_FIELD, timezone.now())
return super().update(**kwargs)

def bulk_update(self, objs, fields, *args, **kwargs):
# Stamp modified_at on each obj only when the caller didn't list it;
# materialize objs first because we iterate the sequence twice (once
# to stamp, once via super()) and a generator would be exhausted.
if _AUTO_NOW_FIELD not in fields:
objs = list(objs)
now = timezone.now()
for obj in objs:
obj.modified_at = now
fields = _with_modified_at(fields)
return super().bulk_update(objs, fields, *args, **kwargs)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def bulk_create(
self, objs, *args, update_conflicts=False, update_fields=None, **kwargs
):
# On upsert-on-conflict Django runs an UPDATE with only the listed
# fields, which skips auto_now the same way save(update_fields=...)
# does. Insert-only bulk_create already handles auto_now itself.
if update_conflicts and update_fields:
update_fields = _with_modified_at(update_fields)
return super().bulk_create(
objs,
*args,
update_conflicts=update_conflicts,
update_fields=update_fields,
**kwargs,
)


BaseModelManager = models.Manager.from_queryset(BaseModelQuerySet)


class BaseModel(models.Model):
"""Abstract base with managed ``created_at`` / ``modified_at`` timestamps.

Subclasses inherit ``BaseModelManager`` as the default manager, which
auto-bumps ``modified_at`` on ``QuerySet.update()``, ``bulk_update()``
and upsert-mode ``bulk_create()``. The ``save()`` override below does
the same for partial ``save(update_fields=[...])`` calls.

Subclasses that need a custom manager should compose ``BaseModelManager``
(e.g. ``class FooManager(MyMixin, BaseModelManager)``) — otherwise the
auto-bump on bulk paths is silently lost.
"""

created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)

objects = BaseModelManager()
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.

class Meta:
abstract = True

def save(
self,
force_insert=False,
force_update=False,
using=None,
update_fields=None,
**kwargs,
):
# Django's save(update_fields=...) only writes the listed columns.
# auto_now still updates modified_at on the in-memory instance, but
# the new value is never persisted unless modified_at is in
# update_fields. Auto-include it so partial saves don't silently drop
# the bump. Preserve Django's documented no-op semantics for
# update_fields=[] (signals-only save, no column writes).
#
# Signature mirrors Django's positional order so callers passing
# force_insert/force_update positionally still hit this override.
if update_fields:
update_fields = _with_modified_at(update_fields)
return super().save(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
**kwargs,
)
4 changes: 2 additions & 2 deletions backend/utils/models/org_aware_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import logging

from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.db.utils import OperationalError, ProgrammingError
from utils.models.base_model import BaseModelManager
from utils.models.org_path_discovery import get_org_path
from utils.user_context import UserContext

logger = logging.getLogger(__name__)


class OrgAwareManager(models.Manager):
class OrgAwareManager(BaseModelManager):
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.
"""Manager that auto-discovers FK path to Organization and applies
org filtering to all queries in request context.

Expand Down
Loading