mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The rebase pulled upstream files that import ee/, a tree this distribution does not carry, so `django.setup()` raised ModuleNotFoundError: No module named 'ee' before any request could be served. main had already severed this; the rebase lost the severance. Restore insights/models/ee_models.py: the four RBAC models upstream keeps under ee/, mapped managed=False onto the tables that actually exist (ee_accesscontrol, ee_dashboardprivilege, ee_role, ee_rolemembership -- verified against the live database, which has those four and no other ee_ table). 131 files repointed at it. Restore main's insights/rbac/access_control_api_mixin.py. The rebase left a fallback of `class AccessControlViewSetMixin: pass`, which drops the dangerously_get_required_scopes hook that insights/api/project.py, insights/api/team.py and products/replay_vision reach through super() -- an AttributeError on scope derivation. Enforcement itself is unaffected either way: it lives in insights/rbac/user_access_control.py, in-tree. ExplicitTeamMembership has no table in this distribution, so it cannot emit the signal the org serializer cache subscribes to; drop the registration. SyncIterableToAsync moves in-tree next to the other streaming helpers, written from the interface rather than carried over, so the MCP store proxy no longer reaches into ee/ for a generic ASGI adapter. bin/debrand renamed the hog_flow directory and the HogFlow symbol but not the module path between them, leaving `from .hog_flow.hog_flow import InsightsFlow` pointing at a path that no longer exists (33 sites). Add the missing text rule so the rename is closed, and apply it.
232 lines
8.6 KiB
Python
232 lines
8.6 KiB
Python
from django.db import transaction
|
|
|
|
import structlog
|
|
import hanzo_insights
|
|
|
|
from insights.kafka_client.routing import get_producer
|
|
from insights.kafka_client.topics import KAFKA_NOTIFICATION_EVENTS
|
|
from insights.models import Organization, Team, User
|
|
|
|
from products.notifications.backend.cache import invalidate_unread_count_for_users
|
|
from products.notifications.backend.facade.contracts import NotificationData
|
|
from products.notifications.backend.facade.enums import (
|
|
AC_RESOURCE_TYPES,
|
|
RESOURCE_EDITED_EVENT_TYPE,
|
|
NotificationOnlyResourceType,
|
|
NotificationType,
|
|
TargetType,
|
|
)
|
|
from products.notifications.backend.models import NotificationEvent
|
|
from products.notifications.backend.resolvers import RecipientsResolver
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
def _publish_to_kafka(event: NotificationEvent) -> None:
|
|
try:
|
|
producer = get_producer(topic=KAFKA_NOTIFICATION_EVENTS)
|
|
producer.produce(
|
|
topic=KAFKA_NOTIFICATION_EVENTS,
|
|
data={
|
|
"id": str(event.id),
|
|
"organization_id": str(event.organization_id),
|
|
"team_id": event.team_id,
|
|
"notification_type": event.notification_type,
|
|
"priority": event.priority,
|
|
"title": event.title,
|
|
"body": event.body,
|
|
"resource_type": event.resource_type or "",
|
|
# The client groups rows by (type, target, resource) — a live-pushed notification
|
|
# without resource_id would land in the wrong group until the next refetch.
|
|
"resource_id": event.resource_id or "",
|
|
"source_url": event.source_url,
|
|
"source_type": event.source_type,
|
|
"source_id": event.source_id,
|
|
"resolved_user_ids": event.resolved_user_ids,
|
|
"created_at": event.created_at.isoformat(),
|
|
},
|
|
key=str(event.organization_id),
|
|
)
|
|
except Exception:
|
|
logger.exception("notifications.kafka_publish_failed", event_id=event.id)
|
|
|
|
|
|
def _filter_by_user_preferences(
|
|
user_ids: list[int],
|
|
notification_type: NotificationType,
|
|
team_id: int | None,
|
|
) -> list[int]:
|
|
# Per-team preferences only apply to team-level notifications; org-level dispatch has no team key to gate on.
|
|
if not user_ids or team_id is None:
|
|
return user_ids
|
|
rows = User.objects.filter(id__in=user_ids).values_list("id", "partial_notification_settings")
|
|
type_key = notification_type.value
|
|
team_key = str(team_id)
|
|
|
|
def _is_disabled(settings: dict | None) -> bool:
|
|
realtime = (settings or {}).get("realtime_notifications_disabled") or {}
|
|
type_map = realtime.get(type_key) or {}
|
|
return bool(type_map.get(team_key, False))
|
|
|
|
return [uid for uid, settings in rows if not _is_disabled(settings)]
|
|
|
|
|
|
def has_been_dispatched(
|
|
*,
|
|
notification_type: NotificationType,
|
|
target_type: TargetType,
|
|
target_id: str,
|
|
resource_id: str,
|
|
source_id: str | None = None,
|
|
) -> bool:
|
|
"""Idempotency check used by dispatchers whose trigger can fire multiple times for the
|
|
same logical event (e.g. Celery at-least-once retries, racing workers). Returns True if
|
|
a matching NotificationEvent already exists, so the caller can skip a duplicate write.
|
|
"""
|
|
return NotificationEvent.objects.filter(
|
|
notification_type=notification_type.value,
|
|
target_type=target_type.value,
|
|
target_id=target_id,
|
|
resource_id=resource_id,
|
|
source_id=source_id,
|
|
).exists()
|
|
|
|
|
|
def publish_resource_edited(
|
|
*,
|
|
team: Team,
|
|
resource_type: str,
|
|
resource_id: str,
|
|
updated_at: str,
|
|
actor_user_id: int | None = None,
|
|
ac_resource_type: str | None = None,
|
|
) -> None:
|
|
"""Push a transient "this resource was edited elsewhere" event over the realtime stream, so an
|
|
open editor (e.g. the workflow builder) can refresh instead of clobbering edits made via MCP/API.
|
|
|
|
Unlike create_notification this persists NO NotificationEvent row: it is editor-state sync, not an
|
|
inbox notification — it must not appear in the popover, must not bump the unread count, and there
|
|
are no user mute preferences to honour. It rides the same Kafka → livestream → SSE transport; the
|
|
Go handler passes unknown fields through and filters delivery by resolved_user_ids.
|
|
|
|
`resource_type` is the value the frontend matches on (e.g. "InsightsFlow"); `ac_resource_type` is the
|
|
access-control scope used to drop recipients without viewer access (e.g. "insights_flow").
|
|
"""
|
|
organization_id = team.organization_id
|
|
|
|
if not hanzo_insights.feature_enabled(
|
|
"real-time-notifications",
|
|
str(organization_id),
|
|
groups={"organization": str(organization_id)},
|
|
only_evaluate_locally=False,
|
|
send_feature_flag_events=False,
|
|
):
|
|
return
|
|
|
|
resolver = RecipientsResolver()
|
|
recipient_user_ids = resolver.resolve(TargetType.TEAM, str(team.id), team.id)
|
|
if ac_resource_type and ac_resource_type in AC_RESOURCE_TYPES:
|
|
recipient_user_ids = resolver.filter_by_access_control(recipient_user_ids, ac_resource_type, team)
|
|
|
|
if not recipient_user_ids:
|
|
return
|
|
|
|
payload = {
|
|
"organization_id": str(organization_id),
|
|
"team_id": team.id,
|
|
"notification_type": RESOURCE_EDITED_EVENT_TYPE,
|
|
"resource_type": resource_type,
|
|
"resource_id": str(resource_id),
|
|
"updated_at": updated_at,
|
|
"actor_user_id": actor_user_id,
|
|
"resolved_user_ids": recipient_user_ids,
|
|
"priority": "normal",
|
|
}
|
|
|
|
def _on_commit() -> None:
|
|
try:
|
|
producer = get_producer(topic=KAFKA_NOTIFICATION_EVENTS)
|
|
producer.produce(topic=KAFKA_NOTIFICATION_EVENTS, data=payload, key=str(organization_id))
|
|
except Exception:
|
|
logger.exception("notifications.resource_edited_publish_failed", resource_id=str(resource_id))
|
|
|
|
transaction.on_commit(_on_commit)
|
|
|
|
|
|
def create_notification(data: NotificationData) -> NotificationEvent | None:
|
|
team: Team | None = None
|
|
if data.team_id is not None:
|
|
try:
|
|
team = Team.objects.select_related("organization").get(id=data.team_id)
|
|
except Team.DoesNotExist:
|
|
logger.warning("notifications.team_not_found", team_id=data.team_id)
|
|
return None
|
|
organization = team.organization
|
|
elif data.organization_id is not None:
|
|
try:
|
|
organization = Organization.objects.get(id=data.organization_id)
|
|
except Organization.DoesNotExist:
|
|
logger.warning("notifications.organization_not_found", organization_id=data.organization_id)
|
|
return None
|
|
else:
|
|
logger.warning("notifications.no_target_scope")
|
|
return None
|
|
|
|
if not hanzo_insights.feature_enabled(
|
|
"real-time-notifications",
|
|
str(organization.id),
|
|
groups={"organization": str(organization.id)},
|
|
only_evaluate_locally=False,
|
|
send_feature_flag_events=False,
|
|
):
|
|
return None
|
|
|
|
resolver = data.resolver or RecipientsResolver()
|
|
resolved_user_ids = resolver.resolve(data.target_type, data.target_id, data.team_id)
|
|
|
|
if team is not None and data.resource_type and str(data.resource_type) in AC_RESOURCE_TYPES:
|
|
resolved_user_ids = resolver.filter_by_access_control(resolved_user_ids, str(data.resource_type), team)
|
|
|
|
# Per-user pref filter must run AFTER AC — prefs cannot override access denials.
|
|
resolved_user_ids = _filter_by_user_preferences(
|
|
resolved_user_ids,
|
|
notification_type=data.notification_type,
|
|
team_id=data.team_id,
|
|
)
|
|
|
|
if not resolved_user_ids:
|
|
logger.warning(
|
|
"notifications.no_recipients",
|
|
target_type=data.target_type,
|
|
target_id=data.target_id,
|
|
)
|
|
return None
|
|
|
|
event = NotificationEvent.objects.create(
|
|
organization=organization,
|
|
team=team,
|
|
notification_type=data.notification_type,
|
|
priority=data.priority,
|
|
title=data.title,
|
|
body=data.body,
|
|
resource_type=data.resource_type.value
|
|
if isinstance(data.resource_type, NotificationOnlyResourceType)
|
|
else data.resource_type,
|
|
resource_id=data.resource_id,
|
|
source_url=data.source_url,
|
|
source_type=data.source_type,
|
|
source_id=data.source_id,
|
|
target_type=data.target_type,
|
|
target_id=data.target_id,
|
|
resolved_user_ids=resolved_user_ids,
|
|
metadata=data.metadata,
|
|
)
|
|
|
|
def _on_commit() -> None:
|
|
_publish_to_kafka(event)
|
|
invalidate_unread_count_for_users(resolved_user_ids, organization.id)
|
|
|
|
transaction.on_commit(_on_commit)
|
|
|
|
return event
|