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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .semgrep/rules/security/idor-team-scoped-models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ rules:
|WikiPageProposal
|WarehouseColumnAnnotation
|WarehouseColumnStatistics
|WarehouseSuggestion
|WebAnalyticsAchievementProgress
|WebAnalyticsFilterPreset
|WebAnalyticsInteraction
Expand Down Expand Up @@ -875,6 +876,7 @@ rules:
|WikiPageProposal
|WarehouseColumnAnnotation
|WarehouseColumnStatistics
|WarehouseSuggestion
|WebAnalyticsAchievementProgress
|WebAnalyticsFilterPreset
|WebAnalyticsInteraction
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"@posthog/products-tracing": "workspace:*",
"@posthog/products-visual-review": "workspace:*",
"@posthog/products-warehouse-sources": "workspace:*",
"@posthog/products-warehouse-suggestions": "workspace:*",
"@posthog/products-web-analytics": "workspace:*",
"@posthog/products-webmcp": "workspace:*",
"@posthog/products-wizard": "workspace:*",
Expand Down
5 changes: 5 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions posthog/models/activity_logging/activity_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
"TableCertification",
"DataQualityCheck",
"DataQualityCheckSchedule",
"WarehouseSuggestion",
"Billing",
"Loop",
"StamphogRepoConfig",
Expand Down Expand Up @@ -360,6 +361,7 @@ def mask_change_values(scope: str, field: str, before: Any, after: Any) -> Maske

field_with_masked_contents: dict[AuditableScope, list[str]] = {
"AccountView": ["name", "content", "text_content"],
"WarehouseSuggestion": ["dismissal_note"],
"HogFunction": [
"inputs",
"mappings",
Expand Down Expand Up @@ -733,6 +735,11 @@ def mask_change_values(scope: str, field: str, before: Any, after: Any) -> Maske
"saved_query",
"table",
],
"WarehouseSuggestion": [
"evidence",
"score_inputs",
"payload",
],
"Loop": [
# FK relations are not JSON-serializable for the change detail (same reason
# FeatureFlag/Subscription exclude theirs).
Expand Down
2 changes: 2 additions & 0 deletions posthog/settings/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"products.data_quality.backend.apps.DataQualityConfig",
"products.security.backend.apps.SecurityConfig",
"products.webmcp.backend.apps.WebmcpConfig",
"products.warehouse_suggestions.backend.apps.WarehouseSuggestionsConfig",
]

INSTALLED_APPS = [
Expand Down Expand Up @@ -688,6 +689,7 @@ def static_varies_origin(headers, path, url):
"TraceNodeKindEnum": "products.ai_observability.backend.facade.contracts.TRACE_NODE_KINDS",
"SignalSourceProduct": "products.signals.backend.enums.SIGNAL_SOURCE_PRODUCT_VALUES",
"SignalSourceType": "products.signals.backend.enums.SIGNAL_SOURCE_TYPE_VALUES",
"DismissalReasonEnum": "products.signals.backend.views.SIGNAL_REPORT_DISMISSAL_REASON_CHOICES",
"ErrorTrackingIssueSeverityRuleEnum": ["low", "medium", "high", "critical"],
#
# The choices come from a typing.Literal via get_args; there is no class.
Expand Down
1 change: 1 addition & 0 deletions posthog/test/repo_invariants/setup_receivers_baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ model_activity:products.tasks.backend.activity_logging.handle_loop_change
model_activity:products.tracing.backend.activity_logging.handle_traces_retention_rule_activity
model_activity:products.warehouse_sources.backend.activity_logging.handle_external_data_schema_change
model_activity:products.warehouse_sources.backend.activity_logging.handle_external_data_source_change
model_activity:products.warehouse_suggestions.backend.activity_logging.handle_warehouse_suggestion_activity
post_delete:ee.vercel.receivers.delete_experiment_experimentation_item
post_delete:ee.vercel.receivers.delete_resource_experimentation_item
post_delete:posthog.caching.organization_serializer_cache._connect_invalidation.<locals>.receiver_fn
Expand Down
1 change: 1 addition & 0 deletions posthog/test/repo_invariants/test_pagination_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@
"products.visual_review.backend.presentation.views.RunViewSet",
"products.warehouse_sources.backend.presentation.views.column_statistics.WarehouseColumnStatisticsViewSet",
"products.warehouse_sources.backend.presentation.views.public_source_configs.PublicSourceConfigViewSet",
"products.warehouse_suggestions.backend.presentation.views.WarehouseSuggestionViewSet",
"products.web_analytics.backend.api.custom_bot_rules.CustomBotRuleViewSet",
"products.web_analytics.backend.api.heatmaps_api.HeatmapViewSet",
"products.web_analytics.backend.api.heatmaps_api.LegacyHeatmapViewSet",
Expand Down
4 changes: 4 additions & 0 deletions products/platform_features/frontend/generated/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file.
Empty file.
33 changes: 33 additions & 0 deletions products/warehouse_suggestions/backend/activity_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Any, Optional, cast

from posthog.models import User
from posthog.models.activity_logging.activity_log import AuditableScope, Detail, changes_between, log_activity
from posthog.models.signals import model_activity_signal, mutable_receiver

from .models import WarehouseSuggestion


@mutable_receiver(model_activity_signal, sender=WarehouseSuggestion)
def handle_warehouse_suggestion_activity(
sender: type,
scope: str,
before_update: Optional[WarehouseSuggestion],
after_update: WarehouseSuggestion,
activity: str,
user: Optional[User],
was_impersonated: bool = False,
**kwargs: Any,
) -> None:
log_activity(
organization_id=None,
team_id=after_update.team_id,
user=user,
was_impersonated=was_impersonated,
item_id=str(after_update.id),
scope=scope,
activity=activity,
detail=Detail(
name=f"{after_update.kind} suggestion",
changes=changes_between(cast(AuditableScope, scope), previous=before_update, current=after_update),
),
)
10 changes: 10 additions & 0 deletions products/warehouse_suggestions/backend/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.apps import AppConfig


class WarehouseSuggestionsConfig(AppConfig):
name = "products.warehouse_suggestions.backend"
label = "warehouse_suggestions"
verbose_name = "Warehouse suggestions"

def ready(self) -> None:
from . import activity_logging # noqa: F401, PLC0415
Empty file.
152 changes: 152 additions & 0 deletions products/warehouse_suggestions/backend/facade/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Facade for warehouse_suggestions."""

from collections.abc import Sequence
from typing import TYPE_CHECKING
from uuid import UUID

from ..logic import suggestions
from ..logic.access import SubjectAccess, visible_suggestions
from ..logic.flags import is_warehouse_suggestions_enabled
from ..models import WarehouseSuggestion
from .contracts import (
SubjectEditAccessRequiredError,
Suggestion,
SuggestionDraft,
SuggestionNotFoundError,
SuggestionPage,
SuggestionReviewer,
)
from .enums import (
WarehouseSuggestionAssetOutcome,
WarehouseSuggestionDismissalReason,
WarehouseSuggestionKind,
WarehouseSuggestionStatus,
WarehouseSuggestionSubjectKind,
)

if TYPE_CHECKING:
from products.access_control.backend.facade.user_access_control import UserAccessControl

__all__ = [
"dismiss_suggestion",
"get_suggestion",
"is_warehouse_suggestions_enabled",
"list_suggestions",
"resume_suggestion",
"upsert_suggestions",
]


def upsert_suggestions(team_id: int, drafts: Sequence[SuggestionDraft]) -> None:
suggestions.upsert_suggestions(team_id, drafts)


def list_suggestions(
team_id: int,
user_access_control: "UserAccessControl",
*,
kind: WarehouseSuggestionKind | None,
status: WarehouseSuggestionStatus | None,
limit: int,
offset: int,
) -> SuggestionPage:
visible, access = visible_suggestions(team_id, user_access_control, kind=kind, status=status)
page = visible[offset : offset + limit]
return SuggestionPage(count=visible.count(), results=[_to_contract(row, access) for row in page])


def get_suggestion(team_id: int, user_access_control: "UserAccessControl", suggestion_id: UUID) -> Suggestion:
row, access = _visible_suggestion(team_id, user_access_control, suggestion_id)
return _to_contract(row, access)


def dismiss_suggestion(
team_id: int,
user_access_control: "UserAccessControl",
suggestion_id: UUID,
*,
user_id: int,
reason: WarehouseSuggestionDismissalReason,
note: str | None,
) -> Suggestion:
return _decide(
team_id,
user_access_control,
suggestion_id,
WarehouseSuggestionStatus.DISMISSED,
user_id=user_id,
reason=reason,
note=note,
)


def resume_suggestion(
team_id: int, user_access_control: "UserAccessControl", suggestion_id: UUID, *, user_id: int
) -> Suggestion:
return _decide(team_id, user_access_control, suggestion_id, WarehouseSuggestionStatus.PROPOSED, user_id=user_id)


def _decide(
team_id: int,
user_access_control: "UserAccessControl",
suggestion_id: UUID,
new_status: WarehouseSuggestionStatus,
*,
user_id: int,
reason: WarehouseSuggestionDismissalReason | None = None,
note: str | None = None,
) -> Suggestion:
row, access = _visible_suggestion(team_id, user_access_control, suggestion_id)
if not access.can_act_on(row):
raise SubjectEditAccessRequiredError(WarehouseSuggestionSubjectKind(row.subject_kind))
decided = suggestions.transition_to(
row.id,
team_id,
new_status,
user_id=user_id,
reason=reason,
note=note,
transitions=suggestions.HUMAN_TRANSITIONS,
)
return _to_contract(decided, access)


def _visible_suggestion(
team_id: int, user_access_control: "UserAccessControl", suggestion_id: UUID
) -> tuple[WarehouseSuggestion, SubjectAccess]:
visible, access = visible_suggestions(team_id, user_access_control, suggestion_id=suggestion_id)
row = visible.first()
if row is None:
raise SuggestionNotFoundError(suggestion_id)
return row, access


def _to_contract(row: WarehouseSuggestion, access: SubjectAccess) -> Suggestion:
return Suggestion(
id=row.id,
kind=WarehouseSuggestionKind(row.kind),
subject_kind=WarehouseSuggestionSubjectKind(row.subject_kind),
subject_id=row.subject_id,
payload=row.payload,
payload_version=row.payload_version,
evidence=row.evidence,
evidence_window_start=row.evidence_window_start,
evidence_window_end=row.evidence_window_end,
last_seen_at=row.last_seen_at,
score=row.score,
status=WarehouseSuggestionStatus(row.status),
surfaced_at=row.surfaced_at,
reviewed_by=_to_reviewer(row),
reviewed_at=row.reviewed_at,
dismissal_reason=WarehouseSuggestionDismissalReason(row.dismissal_reason) if row.dismissal_reason else None,
dismissal_note=row.dismissal_note,
created_asset=row.created_asset,
asset_outcome=WarehouseSuggestionAssetOutcome(row.asset_outcome) if row.asset_outcome else None,
can_act=access.can_act_on(row),
)


def _to_reviewer(row: WarehouseSuggestion) -> SuggestionReviewer | None:
if row.reviewed_by is None:
return None
return SuggestionReviewer(id=row.reviewed_by.id, first_name=row.reviewed_by.first_name, email=row.reviewed_by.email)
Loading