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
32 changes: 32 additions & 0 deletions project/block_manager/migrations/0005_project_sharing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import uuid
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('block_manager', '0004_add_user_to_project'),
]

operations = [
migrations.AddField(
model_name='project',
name='share_token',
field=models.UUIDField(
blank=True,
db_index=True,
default=None,
help_text='Unique token for public sharing; generated on first share',
null=True,
unique=True,
),
),
migrations.AddField(
model_name='project',
name='is_shared',
field=models.BooleanField(
default=False,
help_text='Whether this project is publicly accessible via share link',
),
),
]
14 changes: 13 additions & 1 deletion project/block_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ class Project(models.Model):
choices=[('pytorch', 'PyTorch'), ('tensorflow', 'TensorFlow')],
default='pytorch'
)
share_token = models.UUIDField(
default=None,
null=True,
blank=True,
unique=True,
db_index=True,
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

share_token is marked unique=True, which already creates an index in most databases; db_index=True is likely redundant and may create unnecessary DB overhead. Consider removing db_index=True here (and in the migration) unless there’s a DB-specific reason to keep it.

Suggested change
db_index=True,

Copilot uses AI. Check for mistakes.
help_text='Unique token for public sharing; generated on first share'
)
is_shared = models.BooleanField(
default=False,
help_text='Whether this project is publicly accessible via share link'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Expand All @@ -36,7 +48,7 @@ class Meta:

def __str__(self):
return self.name

class ModelArchitecture(models.Model):
"""Stores the architecture graph for a project"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
Expand Down
6 changes: 4 additions & 2 deletions project/block_manager/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,10 @@ class Meta:
model = Project
fields = [
'id', 'name', 'description', 'framework',
'share_token', 'is_shared',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
read_only_fields = ['id', 'share_token', 'is_shared', 'created_at', 'updated_at']
Comment on lines 211 to +215
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

ProjectSerializer now exposes share_token/is_shared, but ProjectDetailSerializer (used for retrieve) does not. This makes list/create vs. retrieve responses inconsistent and prevents the frontend from reliably showing sharing status. Consider adding share_token/is_shared to the detail serializer as well.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback



class ProjectDetailSerializer(serializers.ModelSerializer):
Expand All @@ -222,9 +223,10 @@ class Meta:
model = Project
fields = [
'id', 'name', 'description', 'framework',
'share_token', 'is_shared',
'architecture', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
read_only_fields = ['id', 'share_token', 'is_shared', 'created_at', 'updated_at']


class SaveArchitectureSerializer(serializers.Serializer):
Expand Down
12 changes: 12 additions & 0 deletions project/block_manager/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
from block_manager.views.export_views import export_model
from block_manager.views.chat_views import chat_message, get_suggestions, get_environment_info
from block_manager.views.group_views import group_definition_list, group_definition_detail
from block_manager.views.sharing_views import (
get_shared_project,
get_shared_architecture,
enable_sharing,
disable_sharing,
)

# Create router for viewsets
router = DefaultRouter()
Expand Down Expand Up @@ -47,4 +53,10 @@

# Environment info endpoint
path('environment', get_environment_info, name='environment-info'),

# Sharing endpoints
path('shared/<uuid:share_token>/', get_shared_project, name='shared-project'),
path('shared/<uuid:share_token>/architecture/', get_shared_architecture, name='shared-architecture'),
path('projects/<uuid:project_id>/share/', enable_sharing, name='enable-sharing'),
path('projects/<uuid:project_id>/unshare/', disable_sharing, name='disable-sharing'),
]
122 changes: 122 additions & 0 deletions project/block_manager/views/sharing_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import uuid
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.shortcuts import get_object_or_404

from block_manager.models import Project


@api_view(['GET'])
def get_shared_project(request, share_token):
"""
Public endpoint — returns project metadata for a shared project.
No authentication required.
Returns 404 if the token doesn't exist or sharing is disabled.
"""
try:
project = Project.objects.get(share_token=share_token, is_shared=True)
except Project.DoesNotExist:
return Response(
{'error': 'Shared project not found or link is no longer active'},
status=status.HTTP_404_NOT_FOUND
)

owner_display_name = None
if project.user:
owner_display_name = project.user.display_name or "Anonymous"

return Response({
'name': project.name,
'description': project.description,
'framework': project.framework,
'owner_display_name': owner_display_name,
'share_token': str(project.share_token),
})


@api_view(['GET'])
def get_shared_architecture(request, share_token):
"""
Public endpoint — returns the canvas state for a shared project.
No authentication required.
Returns 404 if the token doesn't exist or sharing is disabled.
"""
try:
project = Project.objects.get(share_token=share_token, is_shared=True)
except Project.DoesNotExist:
return Response(
{'error': 'Shared project not found or link is no longer active'},
status=status.HTTP_404_NOT_FOUND
)

try:
architecture = project.architecture
except Exception:
return Response({'nodes': [], 'edges': [], 'groupDefinitions': []})

if architecture.canvas_state:
return Response(architecture.canvas_state)

return Response({'nodes': [], 'edges': [], 'groupDefinitions': []})


@api_view(['POST'])
def enable_sharing(request, project_id):
"""
Enable public sharing for a project.
Authentication required; only the project owner can call this.
Generates a share_token on first use; reuses the existing token on subsequent calls
so the perma-link stays stable.
"""
if not hasattr(request, 'firebase_user') or not request.firebase_user:
return Response(
{'error': 'Authentication required'},
status=status.HTTP_401_UNAUTHORIZED
)

project = get_object_or_404(Project, pk=project_id)

if project.user != request.firebase_user:
return Response(
{'error': 'You do not have permission to share this project'},
status=status.HTTP_403_FORBIDDEN
)

if project.share_token is None:
project.share_token = uuid.uuid4()

project.is_shared = True
project.save(update_fields=['share_token', 'is_shared'])

return Response({
'share_token': str(project.share_token),
'is_shared': project.is_shared,
})


@api_view(['DELETE'])
def disable_sharing(request, project_id):
"""
Disable public sharing for a project.
Authentication required; only the project owner can call this.
The share_token is preserved so re-enabling restores the same URL.
"""
if not hasattr(request, 'firebase_user') or not request.firebase_user:
return Response(
{'error': 'Authentication required'},
status=status.HTTP_401_UNAUTHORIZED
)

project = get_object_or_404(Project, pk=project_id)

if project.user != request.firebase_user:
return Response(
{'error': 'You do not have permission to modify this project'},
status=status.HTTP_403_FORBIDDEN
)

project.is_shared = False
project.save(update_fields=['is_shared'])

return Response({'is_shared': False})
2 changes: 2 additions & 0 deletions project/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const ConfigPanel = lazy(() => import('./components/ConfigPanel'))
const ChatBot = lazy(() => import('./components/ChatBot'))
const LandingPage = lazy(() => import('./landing').then(module => ({ default: module.LandingPage })))
const Dashboard = lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })))
const SharedProjectCanvas = lazy(() => import('./components/SharedProjectCanvas'))

// Loading spinner component
function LoadingSpinner() {
Expand Down Expand Up @@ -163,6 +164,7 @@ function App() {
/>
<Route path="/project" element={<ProjectCanvas />} />
<Route path="/project/:projectId" element={<ProjectCanvas />} />
<Route path="/shared/:shareToken" element={<SharedProjectCanvas />} />
</Routes>
</Suspense>
)
Expand Down
65 changes: 42 additions & 23 deletions project/frontend/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ const nodeTypes = {
interface CanvasProps {
onDragStart: (type: string) => void
onRegisterAddNode: (handler: (blockType: string) => void) => void
readOnly?: boolean
}

function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (blockType: string) => void) => void }) {
function FlowCanvas({ onRegisterAddNode, readOnly = false }: { onRegisterAddNode: (handler: (blockType: string) => void) => void; readOnly?: boolean }) {
const {
nodes,
edges,
Expand Down Expand Up @@ -88,8 +89,9 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
// Validation is now triggered manually via the Validate button in Header
// Removed automatic validation on nodes/edges change

// Keyboard shortcuts for undo/redo/delete/group/expand
// Keyboard shortcuts for undo/redo/delete/group/expand (disabled in read-only mode)
useEffect(() => {
if (readOnly) return
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Ctrl (Windows/Linux) or Cmd (Mac)
const isMod = e.ctrlKey || e.metaKey
Expand Down Expand Up @@ -130,7 +132,7 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block

window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [undo, redo, removeNode, removeEdge, selectedNodeId, selectedEdgeId, setSelectedEdgeId, nodes])
}, [readOnly, undo, redo, removeNode, removeEdge, selectedNodeId, selectedEdgeId, setSelectedEdgeId, nodes])

// Find a suitable position for a new node
const findAvailablePosition = useCallback(() => {
Expand Down Expand Up @@ -400,6 +402,22 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
[nodes, setNodes]
)

// In read-only mode we still need to propagate selection changes so that
// ReactFlow sets node.selected=true (which reveals the group-block expand button
// and drives ConfigPanel display). We filter out position/remove/add changes so
// nothing can mutate the graph structure.
const onNodesChangeReadOnly = useCallback(
(changes: any) => {
const allowed = changes.filter((c: any) =>
c.type === 'select' || c.type === 'dimensions' || c.type === 'reset'
)
if (allowed.length > 0) {
setNodes(applyNodeChanges(allowed, nodes))
}
},
[nodes, setNodes]
)

const onEdgesChange = useCallback(
(changes: any) => {
setEdges(applyEdgeChanges(changes, edges))
Expand Down Expand Up @@ -688,32 +706,33 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
return (
<div
className="flex-1 h-full"
onDrop={onDrop}
onDragOver={onDragOver}
onDrop={readOnly ? undefined : onDrop}
onDragOver={readOnly ? undefined : onDragOver}
>
<HistoryToolbar />
{!readOnly && <HistoryToolbar />}
<ReactFlow
nodes={nodesWithHandlers}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={isInteractive ? onNodeClick : undefined}
onEdgeClick={isInteractive ? onEdgeClick : undefined}
onPaneClick={isInteractive ? onPaneClick : undefined}
onPaneContextMenu={isInteractive ? onPaneContextMenu : undefined}
onNodeContextMenu={isInteractive ? onNodeContextMenu : undefined}
onReconnect={onReconnect}
edgesReconnectable={true}
onNodesChange={readOnly ? onNodesChangeReadOnly : onNodesChange}
onEdgesChange={readOnly ? undefined : onEdgesChange}
Comment on lines 713 to +717
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

readOnly mode currently disables drag/connect/context menus, but node-internal actions can still mutate the graph (e.g., replicate-as-custom adds nodes; group expand/collapse rewires nodes/edges). To make shared view truly read-only, ensure these node-level mutating actions are disabled when readOnly is true (e.g., don’t attach onReplicate/onViewCode handlers and pass a readOnly flag so nodes can hide/disable expand buttons).

Copilot uses AI. Check for mistakes.
onConnect={readOnly ? undefined : onConnect}
onNodeClick={onNodeClick}
onEdgeClick={readOnly ? undefined : onEdgeClick}
onPaneClick={onPaneClick}
onPaneContextMenu={readOnly ? undefined : (isInteractive ? onPaneContextMenu : undefined)}
onNodeContextMenu={readOnly ? undefined : (isInteractive ? onNodeContextMenu : undefined)}
onReconnect={readOnly ? undefined : onReconnect}
edgesReconnectable={!readOnly}
nodeTypes={nodeTypes}
connectionLineComponent={CustomConnectionLine}
fitView
minZoom={0.5}
maxZoom={1.5}
nodesDraggable={isInteractive}
nodesConnectable={isInteractive}
elementsSelectable={isInteractive}
onInteractiveChange={setIsInteractive}
nodesDraggable={readOnly ? false : isInteractive}
nodesConnectable={readOnly ? false : isInteractive}
elementsSelectable={true}
deleteKeyCode={readOnly ? null : undefined}
onInteractiveChange={readOnly ? undefined : setIsInteractive}
defaultEdgeOptions={{
animated: true,
style: { stroke: '#6366f1', strokeWidth: 2 }
Expand Down Expand Up @@ -751,7 +770,7 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
position="bottom-right"
/>
</ReactFlow>
{contextMenu && (
{!readOnly && contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
Expand Down Expand Up @@ -803,10 +822,10 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
)
}

export default function Canvas({ onDragStart, onRegisterAddNode }: CanvasProps) {
export default function Canvas({ onDragStart, onRegisterAddNode, readOnly = false }: CanvasProps) {
return (
<ReactFlowProvider>
<FlowCanvas onRegisterAddNode={onRegisterAddNode} />
<FlowCanvas onRegisterAddNode={onRegisterAddNode} readOnly={readOnly} />
</ReactFlowProvider>
)
}
Expand Down
Loading