Skip to content

Commit 1fa86ec

Browse files
johnyrahulclaude
authored andcommitted
UN-2774 [FEAT] Add sharing functionality for ETL/TASK pipelines (#1545)
* UN-2774 [FEAT] Add sharing functionality for ETL pipelines - Added shared_users and shared_to_org fields to Pipeline model - Implemented IsOwnerOrSharedUser permission using common permission class - Added API endpoints for managing pipeline sharing - Updated frontend to support sharing modal and user selection - Fixed issue with empty user list in sharing modal 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * [REFACTOR] Move Django Q import to top of file Move the Django Q import from inside the method to the top-level imports for better code organization and consistency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * [FEAT] Add granular permissions for pipeline operations - Restrict destroy, update operations to owners only - Allow shared users access to other operations - Add typing import for type annotations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * [FEAT] Enhance sharing notification system - Add conditional import for notification plugin with fallback - Map pipeline types to proper resource types (ETL/TASK) - Improve error handling and logging for notification failures - Prevent update operation failure when notifications fail 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Addressing review comments --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f1a1440 commit 1fa86ec

8 files changed

Lines changed: 317 additions & 5 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 4.2.1 on 2025-09-17 13:18
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
10+
("pipeline_v2", "0002_remove_pipeline_unique_pipeline_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="pipeline",
16+
name="shared_to_org",
17+
field=models.BooleanField(
18+
db_comment="Whether this pipeline is shared with the entire organization",
19+
default=False,
20+
),
21+
),
22+
migrations.AddField(
23+
model_name="pipeline",
24+
name="shared_users",
25+
field=models.ManyToManyField(
26+
blank=True,
27+
db_comment="Users with whom this pipeline is shared",
28+
related_name="shared_pipelines",
29+
to=settings.AUTH_USER_MODEL,
30+
),
31+
),
32+
]

backend/pipeline_v2/models.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from account_v2.models import User
44
from django.conf import settings
55
from django.db import models
6+
from django.db.models import Q
67
from utils.models.base_model import BaseModel
78
from utils.models.organization_mixin import (
89
DefaultOrganizationManagerMixin,
@@ -18,7 +19,16 @@
1819

1920

2021
class PipelineModelManager(DefaultOrganizationManagerMixin, models.Manager):
21-
pass
22+
def for_user(self, user):
23+
"""Filter pipelines that the user can access:
24+
- Pipelines created by the user
25+
- Pipelines shared with the user
26+
"""
27+
return self.filter(
28+
Q(created_by=user) # Owned by user
29+
| Q(shared_users=user) # Shared with user
30+
# Q(shared_to_org=True) # Org-wide sharing (optional)
31+
).distinct()
2232

2333

2434
class Pipeline(DefaultOrganizationMixin, BaseModel):
@@ -91,6 +101,17 @@ class PipelineStatus(models.TextChoices):
91101
null=True,
92102
blank=True,
93103
)
104+
# Sharing fields
105+
shared_users = models.ManyToManyField(
106+
User,
107+
related_name="shared_pipelines",
108+
blank=True,
109+
db_comment="Users with whom this pipeline is shared",
110+
)
111+
shared_to_org = models.BooleanField(
112+
default=False,
113+
db_comment="Whether this pipeline is shared with the entire organization",
114+
)
94115

95116
# Manager
96117
objects = PipelineModelManager()

backend/pipeline_v2/serializers/crud.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
class PipelineSerializer(IntegrityErrorMixin, AuditSerializer):
2626
api_endpoint = SerializerMethodField()
27+
created_by_email = SerializerMethodField()
2728

2829
class Meta:
2930
model = Pipeline
@@ -195,6 +196,10 @@ def get_api_endpoint(self, instance: Pipeline):
195196
"""
196197
return instance.api_endpoint
197198

199+
def get_created_by_email(self, obj):
200+
"""Get the creator's email address."""
201+
return obj.created_by.email if obj.created_by else None
202+
198203
def create(self, validated_data: dict[str, Any]) -> Any:
199204
# TODO: Deduce pipeline type based on WF?
200205
validated_data[PK.ACTIVE] = True
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Serializers for pipeline sharing functionality."""
2+
3+
from account_v2.serializer import UserSerializer
4+
from pipeline_v2.models import Pipeline
5+
from rest_framework import serializers
6+
from rest_framework.serializers import SerializerMethodField
7+
8+
9+
class SharedUserListSerializer(serializers.ModelSerializer):
10+
"""Serializer for returning pipeline with shared user details."""
11+
12+
shared_users = SerializerMethodField()
13+
created_by = SerializerMethodField()
14+
created_by_email = SerializerMethodField()
15+
16+
class Meta:
17+
model = Pipeline
18+
fields = [
19+
"id",
20+
"pipeline_name",
21+
"shared_users",
22+
"shared_to_org",
23+
"created_by",
24+
"created_by_email",
25+
]
26+
27+
def get_shared_users(self, obj):
28+
"""Get list of shared users with their details."""
29+
return UserSerializer(obj.shared_users.all(), many=True).data
30+
31+
def get_created_by(self, obj):
32+
"""Get the creator's username."""
33+
return obj.created_by.username if obj.created_by else None
34+
35+
def get_created_by_email(self, obj):
36+
"""Get the creator's email."""
37+
return obj.created_by.email if obj.created_by else None

backend/pipeline_v2/urls.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
}
3232
)
3333

34+
list_shared_users = PipelineViewSet.as_view(
35+
{
36+
"get": PipelineViewSet.list_of_shared_users.__name__,
37+
}
38+
)
39+
3440
pipeline_execute = PipelineViewSet.as_view({"post": "execute"})
3541

3642

@@ -44,6 +50,11 @@
4450
name=PipelineURL.EXECUTIONS,
4551
),
4652
path("pipeline/execute/", pipeline_execute, name=PipelineURL.EXECUTE),
53+
path(
54+
"pipeline/<uuid:pk>/users/",
55+
list_shared_users,
56+
name="pipeline-shared-users",
57+
),
4758
path(
4859
"pipeline/api/postman_collection/<uuid:pk>/",
4960
download_postman_collection,

backend/pipeline_v2/views.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
from typing import Any
34

45
from account_v2.custom_exceptions import DuplicateData
56
from api_v2.exceptions import NoActiveAPIKeyError
@@ -8,7 +9,7 @@
89
from django.db import IntegrityError
910
from django.db.models import QuerySet
1011
from django.http import HttpResponse
11-
from permissions.permission import IsOwner
12+
from permissions.permission import IsOwner, IsOwnerOrSharedUser
1213
from rest_framework import serializers, status, viewsets
1314
from rest_framework.decorators import action
1415
from rest_framework.request import Request
@@ -29,18 +30,35 @@
2930
from pipeline_v2.serializers.execute import (
3031
PipelineExecuteSerializer as ExecuteSerializer,
3132
)
33+
from pipeline_v2.serializers.sharing import SharedUserListSerializer
34+
35+
try:
36+
from plugins.notification.constants import ResourceType
37+
from plugins.notification.sharing_notification import SharingNotificationService
38+
39+
NOTIFICATION_PLUGIN_AVAILABLE = True
40+
sharing_notification_service = SharingNotificationService()
41+
except ImportError:
42+
NOTIFICATION_PLUGIN_AVAILABLE = False
43+
sharing_notification_service = None
3244

3345
logger = logging.getLogger(__name__)
3446

3547

3648
class PipelineViewSet(viewsets.ModelViewSet):
3749
versioning_class = URLPathVersioning
3850
queryset = Pipeline.objects.all()
39-
permission_classes = [IsOwner]
51+
52+
def get_permissions(self) -> list[Any]:
53+
if self.action in ["destroy", "partial_update", "update"]:
54+
return [IsOwner()]
55+
return [IsOwnerOrSharedUser()]
56+
4057
serializer_class = PipelineSerializer
4158

4259
def get_queryset(self) -> QuerySet:
43-
queryset = Pipeline.objects.filter(created_by=self.request.user)
60+
# Use for_user manager method to include shared pipelines
61+
queryset = Pipeline.objects.for_user(self.request.user)
4462

4563
# Apply type filter if specified
4664
pipeline_type = self.request.query_params.get(PipelineConstants.TYPE)
@@ -101,6 +119,59 @@ def perform_destroy(self, instance: Pipeline) -> None:
101119
super().perform_destroy(instance)
102120
return SchedulerHelper.remove_job(pipeline_to_remove)
103121

122+
@action(detail=True, methods=["get"], url_path="users", permission_classes=[IsOwner])
123+
def list_of_shared_users(self, request: Request, pk: str | None = None) -> Response:
124+
"""Returns the list of users the pipeline is shared with."""
125+
pipeline = self.get_object()
126+
serializer = SharedUserListSerializer(pipeline)
127+
return Response(serializer.data, status=status.HTTP_200_OK)
128+
129+
def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
130+
"""Override to handle sharing notifications."""
131+
instance = self.get_object()
132+
current_shared_users = set(instance.shared_users.all())
133+
134+
response = super().partial_update(request, *args, **kwargs)
135+
136+
if (
137+
response.status_code == 200
138+
and "shared_users" in request.data
139+
and NOTIFICATION_PLUGIN_AVAILABLE
140+
):
141+
try:
142+
instance.refresh_from_db()
143+
new_shared_users = set(instance.shared_users.all())
144+
newly_shared_users = new_shared_users - current_shared_users
145+
146+
if ResourceType.ETL.value == instance.pipeline_type:
147+
resource_type = ResourceType.ETL.value
148+
elif ResourceType.TASK.value == instance.pipeline_type:
149+
resource_type = ResourceType.TASK.value
150+
151+
if newly_shared_users:
152+
# Only send notifications if there are newly shared users
153+
sharing_notification_service.send_sharing_notification(
154+
resource_type=resource_type,
155+
resource_name=instance.pipeline_name,
156+
resource_id=str(instance.id),
157+
shared_by=request.user,
158+
shared_to=list(newly_shared_users),
159+
resource_instance=instance,
160+
)
161+
162+
logger.info(
163+
f"Sent sharing notifications for {instance.pipeline_type} "
164+
f"to {len(newly_shared_users)} users"
165+
)
166+
167+
except Exception as e:
168+
# Log error but don't fail the update operation
169+
logger.exception(
170+
f"Failed to send sharing notification, continuing update though: {str(e)}"
171+
)
172+
173+
return response
174+
104175
@action(detail=True, methods=["get"])
105176
def download_postman_collection(
106177
self, request: Request, pk: str | None = None

frontend/src/components/pipelines-or-deployments/pipeline-service.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,32 @@ function pipelineService() {
9494
};
9595
return axiosPrivate(requestOptions);
9696
},
97+
getSharedUsers: (pipelineId) => {
98+
const requestOptions = {
99+
method: "GET",
100+
url: `${path}/pipeline/${pipelineId}/users/`,
101+
};
102+
return axiosPrivate(requestOptions);
103+
},
104+
updateSharing: (pipelineId, sharedUsers, shareWithEveryone = false) => {
105+
const requestOptions = {
106+
method: "PATCH",
107+
url: `${path}/pipeline/${pipelineId}/`,
108+
headers: requestHeaders,
109+
data: {
110+
shared_users: sharedUsers,
111+
shared_to_org: shareWithEveryone,
112+
},
113+
};
114+
return axiosPrivate(requestOptions);
115+
},
116+
getAllUsers: () => {
117+
const requestOptions = {
118+
method: "GET",
119+
url: `${path}/users/`,
120+
};
121+
return axiosPrivate(requestOptions);
122+
},
97123
};
98124
}
99125

0 commit comments

Comments
 (0)