mirror of
https://github.com/hanzoai/insights.git
synced 2026-11-03 00:52:32 +00:00
The ingest door resolves the key it is handed to an (org, project) before it accepts a row, so a key this deployment invents resolves to nothing and every event is refused with ingest_key_unknown. A team holding one looks configured and drops everything it is sent. So insights stops making them and holds the one cloud made. `mint` refuses KeyKind.PUBLISHABLE; `insights/ingest.py` asks cloud at POST /v1/projects with the acting person's IAM bearer, reading an existing project's key back on 409 so a re-run of the login pipeline lands on the same key. The bearer is refreshed through social-auth when it is past its own exp -- measured, three of four stored tokens were expired by 6-10 days -- and there is deliberately no service-identity fallback, because that token carries a different owner. The key reader could not read most of what cloud mints: cloud encodes 32 bytes as base64url and the tail alphabet was base57, which dropped three of the four real keys now in production. The tail is base64url now. Two callers borrowed the ingest minter for things that are not ingest keys. The conversations widget token gets its own kind and mark; the demo matrix stops re-randomising a token it cannot replace. Resetting an ingest key is removed end to end -- cloud has no rotation route, so a reset could only produce a key the door refuses -- and with it a Vercel notification whose import has not existed for some time and raised on every api_token change.
1237 lines
52 KiB
Python
1237 lines
52 KiB
Python
from datetime import timedelta
|
|
from decimal import Decimal
|
|
from functools import lru_cache
|
|
from typing import TYPE_CHECKING, Any, Optional, cast
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from django.conf import settings
|
|
from django.contrib.postgres.fields import ArrayField
|
|
from django.core.cache import cache
|
|
from django.core.validators import MaxValueValidator, MinLengthValidator, MinValueValidator
|
|
from django.db import connection, models, transaction
|
|
from django.db.models import QuerySet
|
|
from django.db.models.signals import post_delete, post_save
|
|
|
|
import pytz
|
|
import pydantic
|
|
|
|
from insights.cloud_utils import is_cloud
|
|
from insights.datastore.query_tagging import Feature, Product, tag_queries, tags_context
|
|
from insights.helpers.session_recording_playlist_templates import DEFAULT_PLAYLISTS
|
|
from insights.models.filters.filter import Filter
|
|
from insights.models.filters.mixins.utils import cached_property
|
|
from insights.models.filters.utils import GroupTypeIndex
|
|
from insights.models.instance_setting import get_instance_setting
|
|
from insights.models.organization import Organization, OrganizationMembership
|
|
from insights.models.signals import mutable_receiver, secret_api_token_rotated
|
|
from insights.models.utils import (
|
|
KeyKind,
|
|
UUIDTClassicModel,
|
|
generate_random_token_project,
|
|
generate_random_token_secret,
|
|
mask_key_value,
|
|
mint,
|
|
sane_repr,
|
|
validate_rate_limit,
|
|
)
|
|
from insights.ph_client import feature_enabled_or_false
|
|
from insights.rbac.decorators import field_access_control
|
|
from insights.session_recordings.models.session_recording_playlist import SessionRecordingPlaylist
|
|
from insights.settings.utils import get_list
|
|
|
|
# Relocated to the Django-free insights.week_start_day module so the InsightsQL engine can use it
|
|
# without booting Django; re-exported here for existing callers.
|
|
from insights.week_start_day import WeekStartDay # noqa: F401
|
|
|
|
from products.customer_analytics.backend.facade.constants import DEFAULT_ACTIVITY_EVENT
|
|
|
|
from ...insightsql.modifiers import set_default_modifier_values
|
|
from ...schema_enums import CurrencyCode, PersonsOnEventsMode
|
|
from .extensions import get_or_create_team_extension
|
|
from .team_caching import get_team_in_cache, set_team_in_cache
|
|
|
|
if TYPE_CHECKING:
|
|
from insights.schema import PathCleaningFilter
|
|
|
|
from insights.models.user import User
|
|
|
|
TIMEZONES = [(tz, tz) for tz in pytz.all_timezones]
|
|
|
|
# TODO: DEPRECATED; delete when these attributes can be fully removed from `Team` model
|
|
DEPRECATED_ATTRS = (
|
|
"plugins_opt_in",
|
|
"opt_out_capture",
|
|
"event_names",
|
|
"event_names_with_usage",
|
|
"event_properties",
|
|
"event_properties_with_usage",
|
|
"event_properties_numerical",
|
|
# Replaced by the `revenue_analytics_config` extension; only the raw-SQL Postgres->Datastore
|
|
# ETL still reads the column, and that bypasses this manager.
|
|
"revenue_tracking_config",
|
|
)
|
|
|
|
# Django requires a list of tuples for choices
|
|
CURRENCY_CODE_CHOICES = [(code.value, code.value) for code in CurrencyCode]
|
|
|
|
# Intentionally asserting this here to guarantee we remember
|
|
# to rerun migrations when a new currency is added
|
|
# python manage.py makemigrations
|
|
assert len(CURRENCY_CODE_CHOICES) == 152
|
|
|
|
DEFAULT_CURRENCY = CurrencyCode.USD.value
|
|
|
|
ASYNC_USER_PRODUCT_LIST_SYNC_THRESHOLD = 100
|
|
|
|
|
|
# keep in sync with insights/frontend/src/scenes/project/Settings/ExtraTeamSettings.tsx
|
|
class AvailableExtraSettings:
|
|
pass
|
|
|
|
|
|
class TeamManager(models.Manager):
|
|
def get_queryset(self):
|
|
return super().get_queryset().defer(*DEPRECATED_ATTRS)
|
|
|
|
def create_with_data(self, *, initiating_user: Optional["User"], **kwargs) -> "Team":
|
|
team = cast("Team", self.create(**kwargs))
|
|
|
|
# Create internal/test users cohort and set test_account_filters to exclude it
|
|
from products.cohorts.backend.models.cohort import get_or_create_internal_test_users_cohort
|
|
|
|
initiating_user_email = initiating_user.email if initiating_user else None
|
|
test_users_cohort = get_or_create_internal_test_users_cohort(team, initiating_user_email=initiating_user_email)
|
|
team.test_account_filters = [
|
|
{"key": "id", "type": "cohort", "value": test_users_cohort.pk, "operator": "not_in"}
|
|
]
|
|
|
|
if kwargs.get("is_demo"):
|
|
if initiating_user is None:
|
|
raise ValueError("initiating_user must be provided when creating a demo team")
|
|
team.save()
|
|
# Defer Celery task if we're inside an atomic block (e.g., ensure_account_and_save)
|
|
# to avoid sending task before transaction commits. In tests, Celery tasks run
|
|
# synchronously so we can call directly (and on_commit doesn't run in TestCase).
|
|
if connection.in_atomic_block and not settings.TEST:
|
|
transaction.on_commit(lambda: team.kick_off_demo_data_generation(initiating_user))
|
|
else:
|
|
team.kick_off_demo_data_generation(initiating_user)
|
|
return team # Return quickly, as the demo data and setup will be created asynchronously
|
|
|
|
# Get organization to apply defaults
|
|
organization = kwargs.get("organization") or Organization.objects.get(id=kwargs.get("organization_id"))
|
|
|
|
team.anonymize_ips = kwargs.get("anonymize_ips", organization.default_anonymize_ips)
|
|
|
|
# Self-hosted deployments get 5-year session recording retention by default
|
|
if not is_cloud():
|
|
team.session_recording_retention_period = kwargs.get(
|
|
"session_recording_retention_period",
|
|
SessionRecordingRetentionPeriod.FIVE_YEARS,
|
|
)
|
|
|
|
if team.extra_settings is None:
|
|
team.extra_settings = {}
|
|
team.extra_settings.setdefault("recorder_script", "insights-recorder")
|
|
|
|
# Create default dashboards (demo projects skip above)
|
|
from insights.helpers.dashboard_templates import create_from_template # noqa: PLC0415 — breaks team import cycle
|
|
|
|
from products.dashboards.backend.models.dashboard import Dashboard # noqa: PLC0415 — breaks team import cycle
|
|
from products.dashboards.backend.models.dashboard_templates import ( # noqa: PLC0415 — breaks team import cycle
|
|
DashboardTemplate,
|
|
)
|
|
|
|
template = DashboardTemplate.default_signup_template()
|
|
dashboard = Dashboard.objects.db_manager(self.db).create(
|
|
name="Your starter dashboard",
|
|
pinned=True,
|
|
team=team,
|
|
description=template.dashboard_description or "",
|
|
)
|
|
create_from_template(dashboard, template)
|
|
team.primary_dashboard = dashboard
|
|
|
|
# Create default session recording playlists
|
|
for playlist in DEFAULT_PLAYLISTS:
|
|
SessionRecordingPlaylist.objects.create(
|
|
team=team,
|
|
name=str(playlist["name"]),
|
|
filters=playlist["filters"],
|
|
description=str(playlist.get("description", "")),
|
|
type="filters",
|
|
)
|
|
team.save()
|
|
|
|
# Add UserProductList for all users who have access to this new team
|
|
# For large orgs, dispatch async to avoid request timeouts
|
|
from insights.tasks.tasks import sync_user_product_lists_for_new_team
|
|
|
|
user_count = OrganizationMembership.objects.filter(organization_id=team.organization_id).count()
|
|
if user_count > ASYNC_USER_PRODUCT_LIST_SYNC_THRESHOLD:
|
|
sync_user_product_lists_for_new_team.delay(team.id)
|
|
else:
|
|
sync_user_product_lists_for_new_team(team.id)
|
|
|
|
return team
|
|
|
|
def create(self, **kwargs):
|
|
from ..project import Project
|
|
|
|
with transaction.atomic(using=self.db):
|
|
if "id" not in kwargs:
|
|
kwargs["id"] = self.increment_id_sequence()
|
|
if kwargs.get("project") is None and kwargs.get("project_id") is None:
|
|
# If a parent project is not provided for this team, ensure there is one
|
|
# This should be removed once environments are fully rolled out
|
|
project_kwargs = {}
|
|
if organization := kwargs.get("organization"):
|
|
project_kwargs["organization"] = organization
|
|
elif organization_id := kwargs.get("organization_id"):
|
|
project_kwargs["organization_id"] = organization_id
|
|
if name := kwargs.get("name"):
|
|
project_kwargs["name"] = name
|
|
kwargs["project"] = Project.objects.db_manager(self.db).create(id=kwargs["id"], **project_kwargs)
|
|
return super().create(**kwargs)
|
|
|
|
def get_team_from_token(self, token: Optional[str]) -> Optional["Team"]:
|
|
if not token:
|
|
return None
|
|
try:
|
|
return Team.objects.get(api_token=token)
|
|
except Team.DoesNotExist:
|
|
return None
|
|
|
|
def get_team_from_cache_or_token(self, token: Optional[str]) -> Optional["Team"]:
|
|
if not token:
|
|
return None
|
|
try:
|
|
team = get_team_in_cache(token)
|
|
if team:
|
|
return team
|
|
|
|
team = Team.objects.get(api_token=token)
|
|
set_team_in_cache(token, team)
|
|
return team
|
|
|
|
except Team.DoesNotExist:
|
|
return None
|
|
|
|
def get_team_from_cache_or_secret_api_token(self, secret_api_token: Optional[str]) -> Optional["Team"]:
|
|
if not secret_api_token:
|
|
return None
|
|
try:
|
|
team = get_team_in_cache(secret_api_token)
|
|
if team:
|
|
return team
|
|
|
|
team = Team.objects.get(secret_api_token=secret_api_token)
|
|
set_team_in_cache(secret_api_token, team)
|
|
return team
|
|
|
|
except Team.DoesNotExist:
|
|
return None
|
|
|
|
def increment_id_sequence(self) -> int:
|
|
"""Increment the `Team.id` field's sequence and return the latest value.
|
|
|
|
Use only when actually neeeded to avoid wasting sequence values."""
|
|
cursor = connection.cursor()
|
|
cursor.execute("SELECT nextval('insights_team_id_seq')")
|
|
result = cursor.fetchone()
|
|
return result[0]
|
|
|
|
|
|
def get_default_data_attributes() -> list[str]:
|
|
return ["data-attr"]
|
|
|
|
|
|
class CookielessServerHashMode(models.IntegerChoices):
|
|
DISABLED = 0, "Disabled"
|
|
STATELESS = 1, "Stateless"
|
|
STATEFUL = 2, "Stateful"
|
|
|
|
|
|
class BusinessModel(models.TextChoices):
|
|
B2B = "b2b", "B2B"
|
|
B2C = "b2c", "B2C"
|
|
OTHER = "other", "Other"
|
|
|
|
|
|
class SessionRecordingRetentionPeriod(models.TextChoices):
|
|
THIRTY_DAYS = "30d", "30 Days"
|
|
NINETY_DAYS = "90d", "90 Days"
|
|
ONE_YEAR = "1y", "1 Year"
|
|
FIVE_YEARS = "5y", "5 Years"
|
|
|
|
|
|
class Team(UUIDTClassicModel):
|
|
"""Team means "environment" (historically it meant "project", but now we have the parent Project model for that)."""
|
|
|
|
class Meta:
|
|
verbose_name = "environment (aka team)"
|
|
verbose_name_plural = "environments (aka teams)"
|
|
# Route forward-FK / related-object loads (e.g. `request.user.current_team`) through the
|
|
# `objects` manager so they inherit its `.defer(*DEPRECATED_ATTRS)`. Without this, Django
|
|
# falls back to a bare `Manager()` for related access and re-loads the fat deprecated
|
|
# taxonomy columns on every such fetch — on the hot path that's every authenticated request.
|
|
base_manager_name = "objects"
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
name="project_id_is_not_null",
|
|
# We have this as a constraint rather than IS NOT NULL on the field, because setting IS NOT NULL cannot
|
|
# be done without locking the table. By adding this constraint using Postgres's `NOT VALID` option
|
|
# (via Django `AddConstraintNotValid()`) and subsequent `VALIDATE CONSTRAINT`, we avoid locking.
|
|
condition=models.Q(project_id__isnull=False),
|
|
),
|
|
models.CheckConstraint(
|
|
name="llm_gateway_overspend_allowance_usd_in_range",
|
|
# Mirrors OVERSPEND_ALLOWANCE_MIN/MAX_USD; null = unset stays valid. The Python
|
|
# write surfaces validate too, but update/bulk_update/shell/raw bypass them.
|
|
condition=models.Q(llm_gateway_overspend_allowance_usd__isnull=True)
|
|
| models.Q(
|
|
llm_gateway_overspend_allowance_usd__gte=0,
|
|
llm_gateway_overspend_allowance_usd__lte=10000,
|
|
),
|
|
),
|
|
]
|
|
|
|
objects: TeamManager = TeamManager()
|
|
|
|
organization = models.ForeignKey(
|
|
"insights.Organization",
|
|
on_delete=models.CASCADE,
|
|
related_name="teams",
|
|
related_query_name="team",
|
|
)
|
|
# NOTE: The deletion is not cascade due to us wanting to first of all solve deletion properly before allowing cascading deletes
|
|
parent_team = models.ForeignKey(
|
|
"insights.Team",
|
|
on_delete=models.SET_NULL,
|
|
related_name="child_teams",
|
|
related_query_name="child_team",
|
|
null=True,
|
|
)
|
|
# NOTE: To be removed in favour of parent_team
|
|
project = models.ForeignKey(
|
|
"insights.Project",
|
|
on_delete=models.CASCADE,
|
|
related_name="teams",
|
|
related_query_name="team",
|
|
)
|
|
api_token = models.CharField(
|
|
max_length=200,
|
|
unique=True,
|
|
default=generate_random_token_project,
|
|
validators=[MinLengthValidator(10, "Project's API token must be at least 10 characters long!")],
|
|
)
|
|
app_urls: ArrayField = field_access_control(
|
|
ArrayField(models.CharField(max_length=200, null=True), default=list, blank=True), "web_analytics", "editor"
|
|
)
|
|
name = models.CharField(
|
|
max_length=200,
|
|
default="Default project",
|
|
validators=[MinLengthValidator(1, "Project must have a name!")],
|
|
)
|
|
# DEPRECATED - not used anywhere and eligible for deletion
|
|
slack_incoming_webhook = models.CharField(max_length=500, null=True, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
anonymize_ips = field_access_control(models.BooleanField(default=False), "project", "admin")
|
|
completed_snippet_onboarding = models.BooleanField(default=False)
|
|
has_completed_onboarding_for = models.JSONField(null=True, blank=True)
|
|
onboarding_tasks = models.JSONField(null=True, blank=True)
|
|
ingested_event = models.BooleanField(default=False)
|
|
ingested_production_event = models.BooleanField(default=False, db_default=False)
|
|
ingested_production_event_last_checked_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
person_processing_opt_out = field_access_control(models.BooleanField(null=True, default=False), "project", "admin")
|
|
secret_api_token = models.CharField(
|
|
max_length=200,
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
secret_api_token_backup = models.CharField(
|
|
max_length=200,
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
|
|
# Session recording
|
|
session_recording_opt_in = field_access_control(models.BooleanField(default=False), "project", "admin")
|
|
session_recording_sample_rate = field_access_control(
|
|
models.DecimalField(
|
|
# will store a decimal between 0 and 1 allowing up to 2 decimal places
|
|
null=True,
|
|
blank=True,
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
validators=[MinValueValidator(Decimal(0)), MaxValueValidator(Decimal(1))],
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
session_recording_minimum_duration_milliseconds = field_access_control(
|
|
models.IntegerField(
|
|
null=True,
|
|
blank=True,
|
|
validators=[MinValueValidator(0), MaxValueValidator(30000)],
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
session_recording_linked_flag = field_access_control(models.JSONField(null=True, blank=True), "project", "admin")
|
|
session_recording_network_payload_capture_config = field_access_control(
|
|
models.JSONField(null=True, blank=True), "project", "admin"
|
|
)
|
|
session_recording_masking_config = field_access_control(models.JSONField(null=True, blank=True), "project", "admin")
|
|
session_recording_url_trigger_config = field_access_control(
|
|
ArrayField(models.JSONField(null=True, blank=True), default=list, blank=True, null=True),
|
|
"project",
|
|
"admin",
|
|
)
|
|
session_recording_url_blocklist_config = field_access_control(
|
|
ArrayField(models.JSONField(null=True, blank=True), default=list, blank=True, null=True),
|
|
"project",
|
|
"admin",
|
|
)
|
|
session_recording_event_trigger_config = field_access_control(
|
|
ArrayField(models.TextField(null=True, blank=True), default=list, blank=True, null=True),
|
|
"project",
|
|
"admin",
|
|
)
|
|
session_recording_trigger_match_type_config = field_access_control(
|
|
models.CharField(null=True, blank=True, max_length=24),
|
|
"project",
|
|
"admin",
|
|
)
|
|
session_recording_trigger_groups = field_access_control(
|
|
models.JSONField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="V2 trigger groups configuration for session recording. If present, takes precedence over legacy trigger fields.",
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
session_replay_config = field_access_control(models.JSONField(null=True, blank=True), "project", "admin")
|
|
session_recording_retention_period = field_access_control(
|
|
models.CharField(
|
|
max_length=3,
|
|
choices=SessionRecordingRetentionPeriod,
|
|
default=SessionRecordingRetentionPeriod.THIRTY_DAYS,
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
# Events data retention in months — denormalized from the billing entitlement by sync_events_retention, read on
|
|
# the InsightsQL hot path. Default 84 (7 years) grandfathers existing teams. db_default because insights_team is also
|
|
# written by raw inserts in nodejs/rust and the test-schema builder.
|
|
event_retention_months = field_access_control(
|
|
models.PositiveSmallIntegerField(default=84, db_default=84),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
# Conversations
|
|
conversations_enabled = field_access_control(models.BooleanField(null=True, blank=True), "project", "admin")
|
|
conversations_settings = field_access_control(models.JSONField(null=True, blank=True), "project", "admin")
|
|
|
|
# Proactive tasks (#team-self-driving)
|
|
proactive_tasks_enabled = models.BooleanField(null=True, blank=True)
|
|
|
|
# Surveys
|
|
survey_config = field_access_control(models.JSONField(null=True, blank=True), "project", "admin")
|
|
surveys_opt_in = field_access_control(models.BooleanField(null=True, blank=True), "project", "admin")
|
|
|
|
# Product tours
|
|
product_tours_opt_in = field_access_control(models.BooleanField(null=True, blank=True), "project", "admin")
|
|
|
|
# Capture / Autocapture
|
|
capture_console_log_opt_in = field_access_control(
|
|
models.BooleanField(null=True, blank=True, default=True), "project", "admin"
|
|
)
|
|
capture_performance_opt_in = field_access_control(
|
|
models.BooleanField(null=True, blank=True, default=True), "project", "admin"
|
|
)
|
|
capture_dead_clicks = field_access_control(
|
|
models.BooleanField(null=True, blank=True, default=False), "project", "admin"
|
|
)
|
|
autocapture_opt_out = field_access_control(models.BooleanField(null=True, blank=True), "project", "admin")
|
|
autocapture_web_vitals_opt_in = field_access_control(models.BooleanField(null=True, blank=True), "project", "admin")
|
|
autocapture_web_vitals_allowed_metrics = field_access_control(
|
|
models.JSONField(null=True, blank=True), "project", "admin"
|
|
)
|
|
autocapture_exceptions_opt_in = field_access_control(models.BooleanField(null=True, blank=True), "project", "admin")
|
|
autocapture_exceptions_errors_to_ignore = field_access_control(
|
|
models.JSONField(null=True, blank=True), "project", "admin"
|
|
)
|
|
|
|
# Logs
|
|
logs_settings = field_access_control(models.JSONField(null=True, blank=True), "project", "admin")
|
|
|
|
# LLM gateway — per-team admission projection read by the llm-gateway Go
|
|
# service via a purpose-built HyperCache blob (cache/team_tokens/<token>/
|
|
# team_metadata/llm_gateway_policy.json). The gateway admits a team only
|
|
# when llm_gateway_enabled_at is set and llm_gateway_revoked_at is null;
|
|
# revoke wins over enable. Null enabled_at = not enrolled (default-deny);
|
|
# null revoked_at = not revoked. Set by internal tooling/admin only.
|
|
llm_gateway_enabled_at = models.DateTimeField(null=True, blank=True)
|
|
llm_gateway_revoked_at = models.DateTimeField(null=True, blank=True)
|
|
# Per-team USD floor the gateway lets a team dispatch past $0 down to. Projected into
|
|
# gateway_credential.json as the `overspend_allowance_usd` wire key. Null = use the
|
|
# gateway default; 0 = no allowance. Range [0, 10000] validated at the write surfaces.
|
|
llm_gateway_overspend_allowance_usd = models.DecimalField(max_digits=20, decimal_places=6, null=True, blank=True)
|
|
|
|
# Heatmaps
|
|
heatmaps_opt_in = field_access_control(models.BooleanField(null=True, blank=True), "project", "admin")
|
|
|
|
# Activity logs
|
|
receive_org_level_activity_logs = field_access_control(
|
|
models.BooleanField(null=True, blank=True, default=False), "project", "admin"
|
|
)
|
|
|
|
# Web analytics
|
|
web_analytics_pre_aggregated_tables_enabled = field_access_control(
|
|
models.BooleanField(default=False, null=True), "project", "admin"
|
|
)
|
|
web_analytics_pre_aggregated_tables_version = field_access_control(
|
|
models.CharField(max_length=10, default="v2", null=True, choices=[("v1", "v1"), ("v2", "v2")]),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
# Feature flags
|
|
flags_persistence_default = field_access_control(
|
|
models.BooleanField(null=True, blank=True, default=False), "project", "admin"
|
|
)
|
|
feature_flag_confirmation_enabled = field_access_control(
|
|
models.BooleanField(null=True, blank=True, default=False), "project", "admin"
|
|
)
|
|
feature_flag_confirmation_message = field_access_control(
|
|
models.TextField(null=True, blank=True), "project", "admin"
|
|
)
|
|
# DEPRECATED: Use default_evaluation_contexts_enabled instead. Will be removed in a future migration after full rollout.
|
|
default_evaluation_environments_enabled = models.BooleanField(null=True, blank=True, default=False)
|
|
# DEPRECATED: Use require_evaluation_contexts instead. Will be removed in a future migration after full rollout.
|
|
require_evaluation_environment_tags = models.BooleanField(null=True, blank=True, default=False)
|
|
default_evaluation_contexts_enabled = field_access_control(
|
|
models.BooleanField(
|
|
null=True,
|
|
blank=True,
|
|
default=False,
|
|
help_text="Whether to automatically apply default evaluation contexts to new feature flags",
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
require_evaluation_contexts = field_access_control(
|
|
models.BooleanField(
|
|
null=True,
|
|
blank=True,
|
|
default=False,
|
|
help_text="Whether to require at least one evaluation context tag when creating new feature flags",
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
session_recording_version = field_access_control(
|
|
models.CharField(null=True, blank=True, max_length=24), "project", "admin"
|
|
)
|
|
signup_token = models.CharField(max_length=200, null=True, blank=True)
|
|
is_demo = field_access_control(models.BooleanField(default=False), "project", "admin")
|
|
|
|
# DEPRECATED - do not use
|
|
access_control = models.BooleanField(default=False)
|
|
|
|
week_start_day = field_access_control(
|
|
models.SmallIntegerField(null=True, blank=True, choices=WeekStartDay), "project", "admin"
|
|
)
|
|
# This is not a manual setting. It's updated automatically to reflect if the team uses site apps or not.
|
|
inject_web_apps = models.BooleanField(null=True)
|
|
|
|
test_account_filters = field_access_control(
|
|
models.JSONField(
|
|
default=list,
|
|
help_text="""Filters used to identify internal/test users. Each entry is a property filter.
|
|
|
|
Supported entry types and the exact shape each accepts:
|
|
|
|
# Person property — match (or exclude) by a person property
|
|
{"key": "email", "type": "person", "value": "@example.com", "operator": "icontains"}
|
|
|
|
# Event property — match by an event property
|
|
{"key": "$host", "type": "event", "value": "localhost", "operator": "icontains"}
|
|
|
|
# Cohort membership — match (or exclude) members of a cohort.
|
|
# Use operator "in" for inclusion and "not_in" for exclusion. Do NOT use a
|
|
# `negation` field here — `negation` is specific to cohort *definitions*
|
|
# (the inner sub-filters that build a cohort) and is rejected by the
|
|
# property-filter schema.
|
|
{"key": "id", "type": "cohort", "value": 8814, "operator": "not_in"}
|
|
|
|
Common operators: "exact", "is_not", "icontains", "not_icontains", "regex",
|
|
"not_regex", "gt", "lt", "gte", "lte", "is_set", "is_not_set", "in", "not_in".""",
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
test_account_filters_default_checked = field_access_control(
|
|
models.BooleanField(null=True, blank=True), "project", "admin"
|
|
)
|
|
|
|
path_cleaning_filters = field_access_control(
|
|
models.JSONField(default=list, null=True, blank=True), "project", "admin"
|
|
)
|
|
|
|
timezone = field_access_control(
|
|
models.CharField(max_length=240, choices=TIMEZONES, default="UTC"), "project", "admin"
|
|
)
|
|
data_attributes = field_access_control(
|
|
models.JSONField(default=get_default_data_attributes, blank=True), "project", "admin"
|
|
)
|
|
person_display_name_properties: ArrayField = field_access_control(
|
|
ArrayField(models.CharField(max_length=400), null=True, blank=True), "project", "admin"
|
|
)
|
|
live_events_columns: ArrayField = field_access_control(
|
|
ArrayField(models.TextField(), null=True, blank=True), "project", "admin"
|
|
)
|
|
recording_domains: ArrayField = field_access_control(
|
|
ArrayField(models.CharField(max_length=200, null=True), blank=True, null=True), "project", "admin"
|
|
)
|
|
human_friendly_comparison_periods = field_access_control(
|
|
models.BooleanField(default=False, null=True, blank=True), "project", "admin"
|
|
)
|
|
cookieless_server_hash_mode = field_access_control(
|
|
models.SmallIntegerField(
|
|
default=CookielessServerHashMode.DISABLED,
|
|
choices=CookielessServerHashMode,
|
|
null=True,
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
primary_dashboard = models.ForeignKey(
|
|
"dashboards.Dashboard",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name="primary_dashboard_teams",
|
|
blank=True,
|
|
) # Dashboard shown on project homepage
|
|
|
|
default_data_theme = field_access_control(models.IntegerField(null=True, blank=True), "project", "admin")
|
|
|
|
# Generic field for storing any team-specific context
|
|
# that likely doesn't deserve a dedicated column. Can be used for things like settings and overrides
|
|
# during feature releases.
|
|
extra_settings = models.JSONField(null=True, blank=True)
|
|
|
|
# Environment-level default InsightsQL query modifiers
|
|
modifiers = field_access_control(models.JSONField(null=True, blank=True), "project", "admin")
|
|
|
|
# This is meant to be used as a stopgap until https://github.com/Insights/meta/pull/39 gets implemented
|
|
# Switches _most_ queries to using distinct_id as aggregator instead of person_id
|
|
@property
|
|
def aggregate_users_by_distinct_id(self) -> bool:
|
|
return str(self.pk) in get_list(get_instance_setting("AGGREGATE_BY_DISTINCT_IDS_TEAMS"))
|
|
|
|
# This correlation_config is intended to be used initially for
|
|
# `excluded_person_property_names` but will be used as a general config
|
|
# repository for correlation related settings.
|
|
# NOTE: we're not doing any schema checking here, just storing whatever is
|
|
# thrown at us. Correlation code can handle schema related issues.
|
|
correlation_config = field_access_control(models.JSONField(default=dict, null=True, blank=True), "project", "admin")
|
|
|
|
# DEPRECATED, DISUSED: recordings on CH are cleared with Datastore's TTL
|
|
session_recording_retention_period_days = models.IntegerField(null=True, default=None, blank=True)
|
|
# DEPRECATED, DISUSED: plugins are enabled for everyone now
|
|
plugins_opt_in = models.BooleanField(default=False)
|
|
# DEPRECATED, DISUSED: replaced with env variable OPT_OUT_CAPTURE and User.anonymized_data
|
|
opt_out_capture = models.BooleanField(default=False)
|
|
# DEPRECATED: in favor of `EventDefinition` model
|
|
event_names = models.JSONField(default=list, blank=True)
|
|
event_names_with_usage = models.JSONField(default=list, blank=True)
|
|
# DEPRECATED: in favor of `PropertyDefinition` model
|
|
event_properties = models.JSONField(default=list, blank=True)
|
|
event_properties_with_usage = models.JSONField(default=list, blank=True)
|
|
event_properties_numerical = models.JSONField(default=list, blank=True)
|
|
external_data_workspace_id = models.CharField(max_length=400, null=True, blank=True)
|
|
external_data_workspace_last_synced_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
api_query_rate_limit = models.CharField(
|
|
max_length=32,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Custom rate limit for InsightsQL API queries in #requests/{sec,min,hour,day}",
|
|
validators=[validate_rate_limit],
|
|
)
|
|
|
|
# DEPRECATED: use `revenue_analytics_config` property instead
|
|
revenue_tracking_config = models.JSONField(null=True, blank=True)
|
|
|
|
# Duration for dropping events older than this threshold
|
|
drop_events_older_than = models.DurationField(
|
|
null=True,
|
|
blank=True,
|
|
validators=[MinValueValidator(timedelta(hours=1))], # For safety minimum 1h
|
|
help_text="Events older than this threshold will be dropped in ingestion. Empty means no timestamp restrictions.",
|
|
)
|
|
|
|
# Consolidated base currency for all analytics (revenue, marketing, etc.)
|
|
base_currency = field_access_control(
|
|
models.CharField(
|
|
max_length=3,
|
|
choices=CURRENCY_CODE_CHOICES,
|
|
default=DEFAULT_CURRENCY,
|
|
null=True,
|
|
blank=True,
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
experiment_recalculation_time = field_access_control(
|
|
models.TimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Time of day (UTC) when experiment metrics should be recalculated. If not set, uses the default recalculation time.",
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
default_experiment_confidence_level = field_access_control(
|
|
models.DecimalField(
|
|
max_digits=3,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Default confidence level for new experiments in this environment. Valid values: 0.90, 0.95, 0.99.",
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
default_experiment_stats_method = field_access_control(
|
|
models.CharField(
|
|
max_length=20,
|
|
choices=Organization.DefaultExperimentStatsMethod,
|
|
default=Organization.DefaultExperimentStatsMethod.BAYESIAN,
|
|
help_text="Default statistical method for new experiments in this environment.",
|
|
null=True,
|
|
blank=True,
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
business_model = field_access_control(
|
|
models.CharField(
|
|
max_length=10,
|
|
choices=BusinessModel,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Whether this project serves B2B or B2C customers, used to optimize the UI layout.",
|
|
),
|
|
"project",
|
|
"admin",
|
|
)
|
|
|
|
# Before adding new fields here, read insights/models/team/README.md
|
|
# Domain-specific config should use a Team Extension model instead.
|
|
|
|
# TRANSITIONAL: These accessors exist for backward compat with existing
|
|
# `team.<product>_config` call sites. New products should NOT add accessors
|
|
# here — use get_or_create_team_extension() at call sites instead.
|
|
|
|
@cached_property
|
|
def revenue_analytics_config(self):
|
|
from .team_revenue_analytics_config import TeamRevenueAnalyticsConfig
|
|
|
|
return get_or_create_team_extension(self, TeamRevenueAnalyticsConfig)
|
|
|
|
@cached_property
|
|
def marketing_analytics_config(self):
|
|
from .team_marketing_analytics_config import TeamMarketingAnalyticsConfig
|
|
|
|
return get_or_create_team_extension(self, TeamMarketingAnalyticsConfig)
|
|
|
|
@cached_property
|
|
def customer_analytics_config(self):
|
|
from products.customer_analytics.backend.facade.team_extension import TeamCustomerAnalyticsConfig
|
|
|
|
return get_or_create_team_extension(
|
|
self, TeamCustomerAnalyticsConfig, defaults={"activity_event": DEFAULT_ACTIVITY_EVENT}
|
|
)
|
|
|
|
@cached_property
|
|
def workflows_config(self):
|
|
from products.workflows.backend.models.team_workflows_config import TeamWorkflowsConfig
|
|
|
|
return get_or_create_team_extension(self, TeamWorkflowsConfig)
|
|
|
|
@property
|
|
def default_modifiers(self) -> dict:
|
|
# Deferred: insights.schema (the pydantic models) stays off django.setup(),
|
|
# where this model loads in every process.
|
|
from insights.schema import InsightsQLQueryModifiers # noqa: PLC0415
|
|
|
|
modifiers = InsightsQLQueryModifiers()
|
|
set_default_modifier_values(modifiers, self)
|
|
return modifiers.model_dump()
|
|
|
|
@property
|
|
def person_on_events_mode(self) -> PersonsOnEventsMode:
|
|
if self.modifiers and self.modifiers.get("personsOnEventsMode") is not None:
|
|
# InsightsQL modifiers (which also act as the project-level setting) take precedence
|
|
mode = PersonsOnEventsMode(self.modifiers["personsOnEventsMode"])
|
|
else:
|
|
# Otherwise use the flag-based default
|
|
mode = self.person_on_events_mode_flag_based_default
|
|
tag_queries(person_on_events_mode=mode)
|
|
return mode
|
|
|
|
@property
|
|
def person_on_events_mode_flag_based_default(self) -> PersonsOnEventsMode:
|
|
if self._person_on_events_person_id_override_properties_on_events:
|
|
return PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_ON_EVENTS
|
|
|
|
if self._person_on_events_person_id_no_override_properties_on_events:
|
|
return PersonsOnEventsMode.PERSON_ID_NO_OVERRIDE_PROPERTIES_ON_EVENTS
|
|
|
|
return PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_JOINED
|
|
|
|
# KLUDGE: DO NOT REFERENCE IN THE BACKEND!
|
|
# Keeping this property for now only to be used by the frontend in certain cases
|
|
@property
|
|
def person_on_events_querying_enabled(self) -> bool:
|
|
return self.person_on_events_mode in ( # Whether person properties on events are in use by default
|
|
PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_ON_EVENTS,
|
|
PersonsOnEventsMode.PERSON_ID_NO_OVERRIDE_PROPERTIES_ON_EVENTS,
|
|
)
|
|
|
|
@property
|
|
def _person_on_events_person_id_no_override_properties_on_events(self) -> bool:
|
|
person_on_events_override = getattr(settings, "PERSON_ON_EVENTS_OVERRIDE", None)
|
|
if person_on_events_override is not None:
|
|
return person_on_events_override
|
|
|
|
# on Insights Cloud, use the feature flag
|
|
if is_cloud():
|
|
return feature_enabled_or_false(
|
|
"persons-on-events-person-id-no-override-properties-on-events",
|
|
str(self.uuid),
|
|
groups={"project": str(self.id)},
|
|
group_properties={
|
|
"project": {
|
|
"id": str(self.id),
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"uuid": self.uuid,
|
|
}
|
|
},
|
|
only_evaluate_locally=True,
|
|
send_feature_flag_events=False,
|
|
)
|
|
|
|
# on self-hosted, use the instance setting
|
|
return get_instance_setting("PERSON_ON_EVENTS_ENABLED")
|
|
|
|
@property
|
|
def _person_on_events_person_id_override_properties_on_events(self) -> bool:
|
|
person_on_events_v2_override = getattr(settings, "PERSON_ON_EVENTS_V2_OVERRIDE", None)
|
|
if person_on_events_v2_override is not None:
|
|
return person_on_events_v2_override
|
|
|
|
# on Insights Cloud, use the feature flag
|
|
if is_cloud():
|
|
return feature_enabled_or_false(
|
|
"persons-on-events-v2-reads-enabled",
|
|
str(self.uuid),
|
|
groups={"organization": str(self.organization_id)},
|
|
group_properties={
|
|
"organization": {
|
|
"id": str(self.organization_id),
|
|
"created_at": self.organization.created_at.isoformat()
|
|
if self.organization.created_at
|
|
else None,
|
|
}
|
|
},
|
|
only_evaluate_locally=True,
|
|
send_feature_flag_events=False,
|
|
)
|
|
|
|
return get_instance_setting("PERSON_ON_EVENTS_V2_ENABLED")
|
|
|
|
@property
|
|
def strict_caching_enabled(self) -> bool:
|
|
enabled_teams = get_list(get_instance_setting("STRICT_CACHING_TEAMS"))
|
|
return str(self.pk) in enabled_teams or "all" in enabled_teams
|
|
|
|
@cached_property
|
|
def persons_seen_so_far(self) -> int:
|
|
from insights.datastore.client import sync_execute
|
|
from insights.queries.person_query import PersonQuery
|
|
|
|
filter = Filter(data={"full": "true"})
|
|
person_query, person_query_params = PersonQuery(filter, self.id).get_query()
|
|
|
|
with tags_context(product=Product.FEATURE_FLAGS, feature=Feature.QUERY):
|
|
return sync_execute(
|
|
f"""
|
|
SELECT count(1) FROM (
|
|
{person_query}
|
|
)
|
|
""",
|
|
{**person_query_params, **filter.insightsql_context.values},
|
|
)[0][0]
|
|
|
|
@lru_cache(maxsize=5) # noqa: B019 - TODO: refactor to module-level cache
|
|
def groups_seen_so_far(self, group_type_index: GroupTypeIndex) -> int:
|
|
from insights.datastore.client import sync_execute
|
|
|
|
with tags_context(product=Product.FEATURE_FLAGS, feature=Feature.QUERY):
|
|
# nosemgrep: datastore-fstring-param-audit - no interpolation, only parameterized values
|
|
return sync_execute(
|
|
f"""
|
|
SELECT
|
|
count(DISTINCT group_key)
|
|
FROM groups
|
|
WHERE team_id = %(team_id)s AND group_type_index = %(group_type_index)s
|
|
""",
|
|
{"team_id": self.pk, "group_type_index": group_type_index},
|
|
)[0][0]
|
|
|
|
@property
|
|
def timezone_info(self) -> ZoneInfo:
|
|
return ZoneInfo(self.timezone)
|
|
|
|
def path_cleaning_filter_models(self) -> list["PathCleaningFilter"]:
|
|
from insights.schema import PathCleaningFilter # noqa: PLC0415
|
|
|
|
filters = []
|
|
for f in self.path_cleaning_filters:
|
|
try:
|
|
filters.append(PathCleaningFilter.model_validate(f))
|
|
except pydantic.ValidationError:
|
|
continue
|
|
return filters
|
|
|
|
def set_token_and_save(self, *, new_token: str, user: "User", is_impersonated_session: bool):
|
|
new_token = new_token.strip()
|
|
if not new_token:
|
|
raise ValueError("New API token must be non-empty.")
|
|
api_token_field = self._meta.get_field("api_token")
|
|
if not isinstance(api_token_field, models.CharField) or api_token_field.max_length is None:
|
|
raise RuntimeError("Team.api_token field must declare a max_length")
|
|
api_token_max_length = api_token_field.max_length
|
|
if len(new_token) > api_token_max_length:
|
|
raise ValueError(f"New API token exceeds {api_token_max_length} characters.")
|
|
if new_token == self.api_token:
|
|
raise ValueError("New API token is identical to the current token.")
|
|
if Team.objects.filter(api_token=new_token).exclude(pk=self.pk).exists():
|
|
raise ValueError("New API token is already in use by another team.")
|
|
|
|
old_token = self.api_token
|
|
self.api_token = new_token
|
|
self._persist_api_token_change(old_token=old_token, user=user, is_impersonated_session=is_impersonated_session)
|
|
|
|
def _persist_api_token_change(self, *, old_token: str, user: "User", is_impersonated_session: bool) -> None:
|
|
from insights.models.activity_logging.activity_log import Change, Detail, log_activity
|
|
|
|
self.save()
|
|
set_team_in_cache(old_token, None)
|
|
set_team_in_cache(self.api_token, self)
|
|
log_activity(
|
|
organization_id=self.organization_id,
|
|
team_id=self.pk,
|
|
user=cast("User", user),
|
|
was_impersonated=is_impersonated_session,
|
|
scope="Team",
|
|
item_id=self.pk,
|
|
activity="updated",
|
|
detail=Detail(
|
|
name=str(self.name),
|
|
changes=[
|
|
Change(
|
|
type="Team",
|
|
action="changed",
|
|
field="api_token",
|
|
before=old_token,
|
|
after=self.api_token,
|
|
)
|
|
],
|
|
),
|
|
)
|
|
|
|
def rotate_secret_token_and_save(self, *, user: "User", is_impersonated_session: bool):
|
|
from insights.models.activity_logging.activity_log import Change, Detail, log_activity
|
|
|
|
# Rotate the tokens
|
|
old_primary_token = self.secret_api_token
|
|
new_token = generate_random_token_secret()
|
|
expired_token = self.secret_api_token_backup
|
|
self.secret_api_token = new_token
|
|
self.secret_api_token_backup = old_primary_token
|
|
self.save()
|
|
|
|
set_team_in_cache(new_token, self)
|
|
# Old token needs to continue to work until it's deleted.
|
|
if old_primary_token:
|
|
set_team_in_cache(old_primary_token, self)
|
|
if expired_token:
|
|
# Clear the previous backup token from cache since it's being replaced
|
|
set_team_in_cache(expired_token, None)
|
|
|
|
secret_api_token_rotated.send(sender=self.__class__, team=self)
|
|
|
|
# Build up the changes.
|
|
|
|
masked_old_primary_token = mask_key_value(old_primary_token) if old_primary_token else None
|
|
|
|
before = {
|
|
"secret_api_token": masked_old_primary_token,
|
|
}
|
|
after = {
|
|
"secret_api_token": mask_key_value(new_token),
|
|
}
|
|
|
|
if masked_old_primary_token:
|
|
# We rotated keys rather than generated a new one.
|
|
before["secret_api_token_backup"] = mask_key_value(expired_token) if expired_token else None
|
|
after["secret_api_token_backup"] = masked_old_primary_token
|
|
|
|
log_activity(
|
|
organization_id=self.organization_id,
|
|
team_id=self.pk,
|
|
user=cast("User", user),
|
|
was_impersonated=is_impersonated_session,
|
|
scope="Team",
|
|
item_id=self.pk,
|
|
activity="updated",
|
|
detail=Detail(
|
|
name=str(self.name),
|
|
changes=[
|
|
Change(
|
|
type="Team",
|
|
action="created" if old_primary_token is None else "changed",
|
|
field="secret_api_token",
|
|
before=before,
|
|
after=after,
|
|
)
|
|
],
|
|
),
|
|
)
|
|
|
|
def generate_conversations_public_token_and_save(self, *, user: "User", is_impersonated_session: bool):
|
|
"""Generate or regenerate the conversations public token for widget authentication."""
|
|
from insights.models.activity_logging.activity_log import Change, Detail, log_activity
|
|
|
|
settings = self.conversations_settings or {}
|
|
old_token = settings.get("widget_public_token")
|
|
new_token = mint(KeyKind.WIDGET)
|
|
settings["widget_public_token"] = new_token
|
|
self.conversations_settings = settings
|
|
self.save()
|
|
|
|
log_activity(
|
|
organization_id=self.organization_id,
|
|
team_id=self.pk,
|
|
user=cast("User", user),
|
|
was_impersonated=is_impersonated_session,
|
|
scope="Team",
|
|
item_id=self.pk,
|
|
activity="updated",
|
|
detail=Detail(
|
|
name=str(self.name),
|
|
changes=[
|
|
Change(
|
|
type="Team",
|
|
action="created" if old_token is None else "changed",
|
|
field="conversations_settings.widget_public_token",
|
|
before=mask_key_value(old_token) if old_token else None,
|
|
after=mask_key_value(new_token),
|
|
)
|
|
],
|
|
),
|
|
)
|
|
|
|
def delete_secret_token_backup_and_save(self, *, user: "User", is_impersonated_session: bool):
|
|
from insights.models.activity_logging.activity_log import Change, Detail, log_activity
|
|
from insights.models.utils import mask_key_value
|
|
|
|
old_backup_token = self.secret_api_token_backup
|
|
if not old_backup_token:
|
|
# Nothing to delete.
|
|
return
|
|
|
|
masked_old_backup_token = mask_key_value(old_backup_token)
|
|
self.secret_api_token_backup = None
|
|
self.save()
|
|
set_team_in_cache(old_backup_token, None)
|
|
|
|
log_activity(
|
|
organization_id=self.organization_id,
|
|
team_id=self.pk,
|
|
user=cast("User", user),
|
|
was_impersonated=is_impersonated_session,
|
|
scope="Team",
|
|
item_id=self.pk,
|
|
activity="updated",
|
|
detail=Detail(
|
|
name=str(self.name),
|
|
changes=[
|
|
Change(
|
|
type="Team",
|
|
action="deleted",
|
|
field="secret_api_token_backup",
|
|
before=masked_old_backup_token,
|
|
after=None,
|
|
)
|
|
],
|
|
),
|
|
)
|
|
|
|
def get_is_generating_demo_data(self) -> bool:
|
|
cache_key = f"is_generating_demo_data_{self.id}"
|
|
return cache.get(cache_key) == "True"
|
|
|
|
def kick_off_demo_data_generation(self, initiating_user: "User") -> None:
|
|
from insights.tasks.demo_create_data import create_data_for_demo_team
|
|
|
|
cache_key = f"is_generating_demo_data_{self.id}"
|
|
cache.set(cache_key, "True") # Create an item in the cache that we can use to see if the demo data is ready
|
|
create_data_for_demo_team.delay(self.id, initiating_user.id, cache_key)
|
|
|
|
def all_users_with_access(self) -> QuerySet["User"]:
|
|
# Imported here rather than at module scope because these are the `managed=False` models
|
|
# over the `ee_*` tables, which must not be imported before the app registry is ready. Their
|
|
# uses below outlived the import when `ee/` went, so every call to this raised `NameError` —
|
|
# including the one the request path makes.
|
|
from insights.constants import AvailableFeature
|
|
from insights.models.ee_models import AccessControl, RoleMembership
|
|
from insights.models.organization import OrganizationMembership
|
|
from insights.models.user import User
|
|
|
|
# Without ACCESS_CONTROL there is no notion of private teams — all org members have access.
|
|
# Mirrors User.teams and UserTeamPermissions.effective_membership_level_for_parent_membership.
|
|
if not self.organization.is_feature_available(AvailableFeature.ACCESS_CONTROL):
|
|
user_ids_queryset = OrganizationMembership.objects.filter(organization_id=self.organization_id).values_list(
|
|
"user_id", flat=True
|
|
)
|
|
return User.objects.filter(is_active=True, id__in=user_ids_queryset)
|
|
|
|
|
|
# First, check if the team is private
|
|
team_is_private = AccessControl.objects.filter(
|
|
team_id=self.id,
|
|
resource="project",
|
|
resource_id=str(self.id),
|
|
organization_member=None,
|
|
role=None,
|
|
access_level="none",
|
|
).exists()
|
|
|
|
if not team_is_private:
|
|
# If team is not private, all organization members have access
|
|
user_ids_queryset = OrganizationMembership.objects.filter(organization_id=self.organization_id).values_list(
|
|
"user_id", flat=True
|
|
)
|
|
else:
|
|
# Team is private, need to check specific access
|
|
|
|
# Get all organization admins and owners
|
|
admin_user_ids = OrganizationMembership.objects.filter(
|
|
organization_id=self.organization_id,
|
|
level__gte=OrganizationMembership.Level.ADMIN,
|
|
).values_list("user_id", flat=True)
|
|
|
|
# Get users with specific access control entries for this team
|
|
# First, get organization memberships with access to this team
|
|
org_memberships_with_access = AccessControl.objects.filter(
|
|
team_id=self.id,
|
|
resource="project",
|
|
resource_id=str(self.id),
|
|
organization_member__isnull=False,
|
|
access_level__in=["member", "admin"],
|
|
).values_list("organization_member", flat=True)
|
|
|
|
# Then get the user IDs from those memberships
|
|
member_access_user_ids = OrganizationMembership.objects.filter(
|
|
id__in=org_memberships_with_access
|
|
).values_list("user_id", flat=True)
|
|
|
|
# Role-backed access only contributes when the org has ROLE_BASED_ACCESS —
|
|
# same gate as the UI's "Roles" block on the project access settings page.
|
|
if self.organization.is_feature_available(AvailableFeature.ROLE_BASED_ACCESS):
|
|
roles_with_access = AccessControl.objects.filter(
|
|
team_id=self.id,
|
|
resource="project",
|
|
resource_id=str(self.id),
|
|
role__isnull=False,
|
|
access_level__in=["member", "admin"],
|
|
).values_list("role", flat=True)
|
|
|
|
role_user_ids = (
|
|
RoleMembership.objects.filter(role_id__in=roles_with_access)
|
|
.values_list("organization_member__user_id", flat=True)
|
|
.distinct()
|
|
)
|
|
else:
|
|
# Empty queryset (not a list) so `.union()` keeps working.
|
|
role_user_ids = RoleMembership.objects.none().values_list("organization_member__user_id", flat=True)
|
|
|
|
# Union all sets of user IDs
|
|
user_ids_queryset = cast(Any, admin_user_ids).union(member_access_user_ids, role_user_ids)
|
|
|
|
return User.objects.filter(is_active=True, id__in=user_ids_queryset)
|
|
|
|
def __str__(self):
|
|
if self.name:
|
|
return self.name
|
|
if self.app_urls and self.app_urls[0]:
|
|
return ", ".join(self.app_urls)
|
|
return str(self.pk)
|
|
|
|
__repr__ = sane_repr("id", "uuid", "project_id", "name", "api_token")
|
|
|
|
|
|
@mutable_receiver(post_save, sender=Team)
|
|
def put_team_in_cache_on_save(sender, instance: Team, **kwargs):
|
|
set_team_in_cache(instance.api_token, instance)
|
|
|
|
|
|
@mutable_receiver(post_delete, sender=Team)
|
|
def delete_team_in_cache_on_delete(sender, instance: Team, **kwargs):
|
|
set_team_in_cache(instance.api_token, None)
|
|
|
|
|
|
@mutable_receiver(post_save, sender=Team)
|
|
def reevaluate_authorized_urls_health(sender, instance: Team, **kwargs):
|
|
update_fields = kwargs.get("update_fields")
|
|
if update_fields is not None and "app_urls" not in update_fields:
|
|
return
|
|
|
|
from insights.tasks.health_checks import evaluate_health_check_for_team
|
|
|
|
team_id = instance.id
|
|
transaction.on_commit(lambda: evaluate_health_check_for_team.delay("authorized_urls", team_id))
|
|
|
|
|
|
def check_is_feature_available_for_team(team_id: int, feature_key: str, current_usage: Optional[int] = None):
|
|
available_product_features: Optional[list[dict[str, str]]] = (
|
|
Team.objects.select_related("organization")
|
|
.values_list("organization__available_product_features", flat=True)
|
|
.get(id=team_id)
|
|
)
|
|
if available_product_features is None:
|
|
return False
|
|
|
|
for feature in available_product_features:
|
|
if feature.get("key") == feature_key:
|
|
if current_usage is not None and feature.get("limit") is not None:
|
|
return current_usage < int(feature["limit"])
|
|
return True
|
|
return False
|