Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7ff40c9
refactor: Add dynamic plugin loading for enterprise components
hari-kuriakose Jan 9, 2026
92b8575
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 9, 2026
6e43db3
Merge branch 'main' into chore/plugin-loading
hari-kuriakose Jan 13, 2026
7e5c60a
refactor: Use get_plugin() for API Hub usage utilities
hari-kuriakose Jan 13, 2026
16e04a7
Refactor random sampling logic in utils.py
hari-kuriakose Jan 13, 2026
d9d5d86
fix: Add Traefik port labels and clean up service ignore list
hari-kuriakose Jan 15, 2026
b5057ea
fix: Update frontend Docker config for nginx serving
hari-kuriakose Feb 5, 2026
6d6d730
Merge branch 'main' into chore/plugin-loading
hari-kuriakose Feb 8, 2026
d4bcc39
fix: Use ARG instead of ENV for BUILD_CONTEXT_PATH in frontend Docker…
hari-kuriakose Feb 8, 2026
6938350
feat: Add HubSpot integration plugin for contact event tracking
hari-kuriakose Feb 10, 2026
e010111
[FIX] Fix HITL review screen showing "Never expires" despite TTL bein…
vishnuszipstack Feb 9, 2026
01d8b90
refactor: Add dynamic plugin loading for enterprise components (#1736)
hari-kuriakose Feb 13, 2026
a5d6a0b
feat: Add auth error code for forbidden emails (#1789)
hari-kuriakose Feb 16, 2026
e67ecc5
[MISC] Improve dev experience by adding a compose debug override (#1765)
chandrasekharan-zipstack Feb 19, 2026
fed6355
[FIX] Optimize queries made by worker and retry config of worker base…
chandrasekharan-zipstack Feb 19, 2026
2c42638
UN-2971 [FEAT] Pass selectedProduct to login/signup API for OAuth pro…
hari-kuriakose Feb 24, 2026
08736d6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 24, 2026
dbd3dac
Merge branch 'main' into feat/plugin-integrations
hari-kuriakose Feb 24, 2026
7fc47ba
Merge branch 'main' into feat/plugin-integrations
vishnuszipstack Feb 26, 2026
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
3 changes: 0 additions & 3 deletions backend/account_v2/authentication_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,6 @@ def get_invitations(self, organization_id: str) -> list[MemberInvitation]:
def frictionless_onboarding(self, organization: Organization, user: User) -> None:
raise MethodNotImplemented()

def hubspot_signup_api(self, request: Request) -> None:
raise MethodNotImplemented()

def delete_invitation(self, organization_id: str, invitation_id: str) -> bool:
raise MethodNotImplemented()

Expand Down
35 changes: 35 additions & 0 deletions backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ def fetch_one(self, request: Request, pk: str | None = None) -> Response:
def create(
self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any]
) -> Response:
# Check deployment count before create for HubSpot notification
deployment_count_before = APIDeployment.objects.count()

serializer: Serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
Expand All @@ -228,13 +231,45 @@ def create(
{"api_key": api_key.api_key, **serializer.data}
)

# Notify HubSpot about API deployment
self._notify_hubspot_first_api_deploy(request.user, deployment_count_before)

headers = self.get_success_headers(serializer.data)
return Response(
response_serializer.data,
status=status.HTTP_201_CREATED,
headers=headers,
)

def _notify_hubspot_first_api_deploy(
self, user, deployment_count_before: int
) -> None:
"""Notify HubSpot when an API is deployed.

Checks if HubSpot plugin is available and notifies it about
the API deployment. The plugin decides whether to act based
on the is_first_for_org flag.
"""
hubspot_plugin = get_plugin("hubspot")
if not hubspot_plugin:
return

try:
# First API deploy if count was 0 before deploy
is_first_for_org = deployment_count_before == 0

from plugins.integrations.hubspot import HubSpotEvent

service = hubspot_plugin["service_class"]()
service.update_contact(
user=user,
events=[HubSpotEvent.API_DEPLOY],
is_first_for_org=is_first_for_org,
)
except Exception as e:
# Log but don't fail the request
logger.warning(f"Failed to notify HubSpot for API deployment: {e}")

@action(detail=False, methods=["get"])
def by_prompt_studio_tool(self, request: Request) -> Response:
"""Get API deployments for a specific prompt studio tool."""
Expand Down
137 changes: 137 additions & 0 deletions backend/prompt_studio/prompt_studio_core_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
PromptStudioDocumentHelper,
)
from prompt_studio.prompt_studio_index_manager_v2.models import IndexManager
from prompt_studio.prompt_studio_output_manager_v2.models import PromptStudioOutputManager
from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry
from prompt_studio.prompt_studio_registry_v2.prompt_studio_registry_helper import (
PromptStudioRegistryHelper,
)
Expand Down Expand Up @@ -118,8 +120,41 @@ def create(self, request: HttpRequest) -> Response:
PromptStudioHelper.create_default_profile_manager(
request.user, serializer.data["tool_id"]
)

# Notify HubSpot if this is the first Prompt Studio project for the org
self._notify_hubspot_first_project(request.user)

return Response(serializer.data, status=status.HTTP_201_CREATED)

def _notify_hubspot_first_project(self, user) -> None:
"""Notify HubSpot when a Prompt Studio project is created.

Checks if HubSpot plugin is available and notifies it about
the project creation. The plugin decides whether to act based
on the is_first_for_org flag.
"""
hubspot_plugin = get_plugin("hubspot")
if not hubspot_plugin:
return

try:
# Check if this is the first CustomTool for the organization
# (count == 1 means the one we just created is the first)
org_project_count = CustomTool.objects.count()
is_first_for_org = org_project_count == 1
Comment on lines +143 to +144
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's check the model definitions and their managers
echo "=== CustomTool model definition ==="
rg -n "class CustomTool" -A 20 --type=py | head -40

echo ""
echo "=== PromptStudioOutputManager model/manager ==="
rg -n "class PromptStudioOutputManager" -A 15 --type=py | head -40

echo ""
echo "=== DocumentManager model/manager ==="
rg -n "class DocumentManager" -A 15 --type=py | head -40

echo ""
echo "=== PromptStudioRegistry model definition ==="
rg -n "class PromptStudioRegistry" -A 20 --type=py | head -40

Repository: Zipstack/unstract

Length of output: 12049


🏁 Script executed:

# Check the views.py file structure and context around the mentioned lines
echo "=== Context around line 143-144 in views.py ==="
wc -l backend/prompt_studio/prompt_studio_core_v2/views.py

Repository: Zipstack/unstract

Length of output: 165


🏁 Script executed:

# Read specific sections of views.py
echo "=== Lines 135-155 (around 143-144) ==="
sed -n '135,155p' backend/prompt_studio/prompt_studio_core_v2/views.py

echo ""
echo "=== Lines 440-460 (around 448) ==="
sed -n '440,460p' backend/prompt_studio/prompt_studio_core_v2/views.py

echo ""
echo "=== Lines 617-635 (around 625) ==="
sed -n '617,635p' backend/prompt_studio/prompt_studio_core_v2/views.py

echo ""
echo "=== Lines 736-754 (around 744) ==="
sed -n '736,754p' backend/prompt_studio/prompt_studio_core_v2/views.py

Repository: Zipstack/unstract

Length of output: 3637


🏁 Script executed:

# Check DefaultOrganizationManagerMixin definition
echo "=== DefaultOrganizationManagerMixin ==="
rg -n "class DefaultOrganizationManagerMixin" -A 20 --type=py

echo ""
echo "=== BaseModel definition ==="
rg -n "class BaseModel" -A 15 --type=py | head -50

echo ""
echo "=== PromptStudioOutputManager full model ==="
sed -n '1,100p' backend/prompt_studio/prompt_studio_output_manager_v2/models.py

echo ""
echo "=== DocumentManager full model (first 80 lines) ==="
sed -n '1,80p' backend/prompt_studio/prompt_studio_document_manager_v2/models.py

Repository: Zipstack/unstract

Length of output: 5688


🏁 Script executed:

# Check if PromptStudioRegistry has its get_queryset method
echo "=== PromptStudioRegistry model and manager ==="
rg -n "class PromptStudioRegistry" -A 30 --type=py

echo ""
echo "=== PromptStudioRegistryModelManager.get_queryset ==="
sed -n '24,40p' backend/prompt_studio/prompt_studio_registry_v2/models.py

Repository: Zipstack/unstract

Length of output: 17687


🏁 Script executed:

# Check how DefaultOrganizationManagerMixin.get_queryset() filters
echo "=== DefaultOrganizationManagerMixin get_queryset implementation ==="
rg -n "def get_queryset" -B 2 -A 10 --type=py | grep -A 12 "DefaultOrganization"

Repository: Zipstack/unstract

Length of output: 4423


🏁 Script executed:

# Check if PromptStudioOutputManager and DocumentManager define custom 'objects' manager
echo "=== PromptStudioOutputManager objects manager ==="
sed -n '1,100p' backend/prompt_studio/prompt_studio_output_manager_v2/models.py | grep -i "objects"

echo ""
echo "=== DocumentManager objects manager ==="
sed -n '1,80p' backend/prompt_studio/prompt_studio_document_manager_v2/models.py | grep -i "objects"

echo ""
echo "=== Check if BaseModel defines objects manager ==="
cat backend/utils/models/base_model.py

Repository: Zipstack/unstract

Length of output: 417


🏁 Script executed:

# Verify DefaultOrganizationMixin is used by these models
echo "=== Check inheritance of PromptStudioOutputManager ==="
grep -A 1 "^class PromptStudioOutputManager" backend/prompt_studio/prompt_studio_output_manager_v2/models.py

echo ""
echo "=== Check inheritance of DocumentManager ==="
grep -A 1 "^class DocumentManager" backend/prompt_studio/prompt_studio_document_manager_v2/models.py

Repository: Zipstack/unstract

Length of output: 347


Fix organization scoping for PromptStudioOutputManager and DocumentManager count checks.

The .objects.count() calls at lines 448 and 625 are unscoped and may span all organizations. PromptStudioOutputManager and DocumentManager use only BaseModel without a custom manager that implements organization filtering, unlike CustomTool and PromptStudioRegistry which use DefaultOrganizationManagerMixin.

To fix, either:

  • Add DefaultOrganizationManagerMixin to a custom manager for these models and assign it as the default manager
  • Filter explicitly: .filter(organization=UserContext.get_organization()).count()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/prompt_studio/prompt_studio_core_v2/views.py` around lines 143 - 144,
The count() calls for PromptStudioOutputManager and DocumentManager are unscoped
and may return totals across all organizations; update their queries to be
organization-scoped by either adding DefaultOrganizationManagerMixin to a custom
default manager on the PromptStudioOutputManager and DocumentManager models (so
their .objects enforces organization filtering like CustomTool and
PromptStudioRegistry) or change the count calls to explicitly filter by
organization, e.g. .filter(organization=UserContext.get_organization()).count(),
ensuring you modify the references to PromptStudioOutputManager and
DocumentManager (or their default .objects) accordingly.


from plugins.integrations.hubspot import HubSpotEvent

service = hubspot_plugin["service_class"]()
service.update_contact(
user=user,
events=[HubSpotEvent.PROMPT_STUDIO_PROJECT_CREATE],
is_first_for_org=is_first_for_org,
)
except Exception as e:
# Log but don't fail the request
logger.warning(f"Failed to notify HubSpot for project creation: {e}")

def perform_destroy(self, instance: CustomTool) -> None:
organization_id = UserSessionUtils.get_organization_id(self.request)
instance.delete(organization_id)
Expand Down Expand Up @@ -408,6 +443,10 @@ def fetch_response(self, request: HttpRequest, pk: Any = None) -> Response:
if not run_id:
# Generate a run_id
run_id = CommonUtils.generate_uuid()

# Check output count before prompt run for HubSpot notification
output_count_before = PromptStudioOutputManager.objects.count()

response: dict[str, Any] = PromptStudioHelper.prompt_responder(
id=id,
tool_id=tool_id,
Expand All @@ -417,8 +456,39 @@ def fetch_response(self, request: HttpRequest, pk: Any = None) -> Response:
run_id=run_id,
profile_manager_id=profile_manager,
)

# Notify HubSpot about prompt run
self._notify_hubspot_first_prompt_run(request.user, output_count_before)

return Response(response, status=status.HTTP_200_OK)

def _notify_hubspot_first_prompt_run(self, user, output_count_before: int) -> None:
"""Notify HubSpot when a prompt is run.

Checks if HubSpot plugin is available and notifies it about
the prompt run. The plugin decides whether to act based
on the is_first_for_org flag.
"""
hubspot_plugin = get_plugin("hubspot")
if not hubspot_plugin:
return

try:
# First prompt run if count was 0 before run
is_first_for_org = output_count_before == 0

from plugins.integrations.hubspot import HubSpotEvent

service = hubspot_plugin["service_class"]()
service.update_contact(
user=user,
events=[HubSpotEvent.PROMPT_RUN],
is_first_for_org=is_first_for_org,
)
except Exception as e:
# Log but don't fail the request
logger.warning(f"Failed to notify HubSpot for prompt run: {e}")

@action(detail=True, methods=["post"])
def single_pass_extraction(self, request: HttpRequest, pk: uuid) -> Response:
"""API Entry point method to fetch response to prompt.
Expand Down Expand Up @@ -551,6 +621,9 @@ def upload_for_ide(self, request: HttpRequest, pk: Any = None) -> Response:
uploaded_files: Any = serializer.validated_data.get("file")
file_converter_plugin = get_plugin("file_converter")

# Check document count before upload for HubSpot notification
doc_count_before = DocumentManager.objects.count()

documents = []
for uploaded_file in uploaded_files:
# Store file
Expand Down Expand Up @@ -585,8 +658,39 @@ def upload_for_ide(self, request: HttpRequest, pk: Any = None) -> Response:
"tool": document.tool.tool_id,
}
documents.append(doc)

# Notify HubSpot about document upload
self._notify_hubspot_first_document(request.user, doc_count_before)

return Response({"data": documents})

def _notify_hubspot_first_document(self, user, doc_count_before: int) -> None:
"""Notify HubSpot when a document is uploaded.

Checks if HubSpot plugin is available and notifies it about
the document upload. The plugin decides whether to act based
on the is_first_for_org flag.
"""
hubspot_plugin = get_plugin("hubspot")
if not hubspot_plugin:
return

try:
# First document upload if count was 0 before upload
is_first_for_org = doc_count_before == 0

from plugins.integrations.hubspot import HubSpotEvent

service = hubspot_plugin["service_class"]()
service.update_contact(
user=user,
events=[HubSpotEvent.DOCUMENT_UPLOAD],
is_first_for_org=is_first_for_org,
)
except Exception as e:
# Log but don't fail the request
logger.warning(f"Failed to notify HubSpot for document upload: {e}")

@action(detail=True, methods=["delete"])
def delete_for_ide(self, request: HttpRequest, pk: uuid) -> Response:
custom_tool = self.get_object()
Expand Down Expand Up @@ -636,18 +740,51 @@ def export_tool(self, request: Request, pk: Any = None) -> Response:
user_ids = set(serializer.validated_data.get("user_id"))
force_export = serializer.validated_data.get("force_export")

# Check registry count before export for HubSpot notification
registry_count_before = PromptStudioRegistry.objects.count()

PromptStudioRegistryHelper.update_or_create_psr_tool(
custom_tool=custom_tool,
shared_with_org=is_shared_with_org,
user_ids=user_ids,
force_export=force_export,
)

# Notify HubSpot about tool export
self._notify_hubspot_first_tool_export(request.user, registry_count_before)

return Response(
{"message": "Custom tool exported sucessfully."},
status=status.HTTP_200_OK,
)

def _notify_hubspot_first_tool_export(self, user, registry_count_before: int) -> None:
"""Notify HubSpot when a tool is exported.

Checks if HubSpot plugin is available and notifies it about
the tool export. The plugin decides whether to act based
on the is_first_for_org flag.
"""
hubspot_plugin = get_plugin("hubspot")
if not hubspot_plugin:
return

try:
# First tool export if count was 0 before export
is_first_for_org = registry_count_before == 0

from plugins.integrations.hubspot import HubSpotEvent

service = hubspot_plugin["service_class"]()
service.update_contact(
user=user,
events=[HubSpotEvent.TOOL_EXPORT],
is_first_for_org=is_first_for_org,
)
except Exception as e:
# Log but don't fail the request
logger.warning(f"Failed to notify HubSpot for tool export: {e}")

@action(detail=True, methods=["get"])
def export_tool_info(self, request: Request, pk: Any = None) -> Response:
custom_tool = self.get_object()
Expand Down