Skip to content

Commit 112e80d

Browse files
authored
Merge pull request #61 from ForgeOpus/main
deploy main
2 parents 00f1fdf + 2492fe0 commit 112e80d

File tree

14 files changed

+1069
-143
lines changed

14 files changed

+1069
-143
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import uuid
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
7+
dependencies = [
8+
('block_manager', '0004_add_user_to_project'),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name='project',
14+
name='share_token',
15+
field=models.UUIDField(
16+
blank=True,
17+
db_index=True,
18+
default=None,
19+
help_text='Unique token for public sharing; generated on first share',
20+
null=True,
21+
unique=True,
22+
),
23+
),
24+
migrations.AddField(
25+
model_name='project',
26+
name='is_shared',
27+
field=models.BooleanField(
28+
default=False,
29+
help_text='Whether this project is publicly accessible via share link',
30+
),
31+
),
32+
]

project/block_manager/models.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ class Project(models.Model):
2828
choices=[('pytorch', 'PyTorch'), ('tensorflow', 'TensorFlow')],
2929
default='pytorch'
3030
)
31+
share_token = models.UUIDField(
32+
default=None,
33+
null=True,
34+
blank=True,
35+
unique=True,
36+
db_index=True,
37+
help_text='Unique token for public sharing; generated on first share'
38+
)
39+
is_shared = models.BooleanField(
40+
default=False,
41+
help_text='Whether this project is publicly accessible via share link'
42+
)
3143
created_at = models.DateTimeField(auto_now_add=True)
3244
updated_at = models.DateTimeField(auto_now=True)
3345

@@ -36,7 +48,7 @@ class Meta:
3648

3749
def __str__(self):
3850
return self.name
39-
51+
4052
class ModelArchitecture(models.Model):
4153
"""Stores the architecture graph for a project"""
4254
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

project/block_manager/serializers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,10 @@ class Meta:
209209
model = Project
210210
fields = [
211211
'id', 'name', 'description', 'framework',
212+
'share_token', 'is_shared',
212213
'created_at', 'updated_at'
213214
]
214-
read_only_fields = ['id', 'created_at', 'updated_at']
215+
read_only_fields = ['id', 'share_token', 'is_shared', 'created_at', 'updated_at']
215216

216217

217218
class ProjectDetailSerializer(serializers.ModelSerializer):
@@ -222,9 +223,10 @@ class Meta:
222223
model = Project
223224
fields = [
224225
'id', 'name', 'description', 'framework',
226+
'share_token', 'is_shared',
225227
'architecture', 'created_at', 'updated_at'
226228
]
227-
read_only_fields = ['id', 'created_at', 'updated_at']
229+
read_only_fields = ['id', 'share_token', 'is_shared', 'created_at', 'updated_at']
228230

229231

230232
class SaveArchitectureSerializer(serializers.Serializer):

project/block_manager/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
from block_manager.views.export_views import export_model
1414
from block_manager.views.chat_views import chat_message, get_suggestions, get_environment_info
1515
from block_manager.views.group_views import group_definition_list, group_definition_detail
16+
from block_manager.views.sharing_views import (
17+
get_shared_project,
18+
get_shared_architecture,
19+
enable_sharing,
20+
disable_sharing,
21+
)
1622

1723
# Create router for viewsets
1824
router = DefaultRouter()
@@ -47,4 +53,10 @@
4753

4854
# Environment info endpoint
4955
path('environment', get_environment_info, name='environment-info'),
56+
57+
# Sharing endpoints
58+
path('shared/<uuid:share_token>/', get_shared_project, name='shared-project'),
59+
path('shared/<uuid:share_token>/architecture/', get_shared_architecture, name='shared-architecture'),
60+
path('projects/<uuid:project_id>/share/', enable_sharing, name='enable-sharing'),
61+
path('projects/<uuid:project_id>/unshare/', disable_sharing, name='disable-sharing'),
5062
]
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import uuid
2+
from rest_framework import status
3+
from rest_framework.decorators import api_view
4+
from rest_framework.response import Response
5+
from django.shortcuts import get_object_or_404
6+
7+
from block_manager.models import Project
8+
9+
10+
@api_view(['GET'])
11+
def get_shared_project(request, share_token):
12+
"""
13+
Public endpoint — returns project metadata for a shared project.
14+
No authentication required.
15+
Returns 404 if the token doesn't exist or sharing is disabled.
16+
"""
17+
try:
18+
project = Project.objects.get(share_token=share_token, is_shared=True)
19+
except Project.DoesNotExist:
20+
return Response(
21+
{'error': 'Shared project not found or link is no longer active'},
22+
status=status.HTTP_404_NOT_FOUND
23+
)
24+
25+
owner_display_name = None
26+
if project.user:
27+
owner_display_name = project.user.display_name or "Anonymous"
28+
29+
return Response({
30+
'name': project.name,
31+
'description': project.description,
32+
'framework': project.framework,
33+
'owner_display_name': owner_display_name,
34+
'share_token': str(project.share_token),
35+
})
36+
37+
38+
@api_view(['GET'])
39+
def get_shared_architecture(request, share_token):
40+
"""
41+
Public endpoint — returns the canvas state for a shared project.
42+
No authentication required.
43+
Returns 404 if the token doesn't exist or sharing is disabled.
44+
"""
45+
try:
46+
project = Project.objects.get(share_token=share_token, is_shared=True)
47+
except Project.DoesNotExist:
48+
return Response(
49+
{'error': 'Shared project not found or link is no longer active'},
50+
status=status.HTTP_404_NOT_FOUND
51+
)
52+
53+
try:
54+
architecture = project.architecture
55+
except Exception:
56+
return Response({'nodes': [], 'edges': [], 'groupDefinitions': []})
57+
58+
if architecture.canvas_state:
59+
return Response(architecture.canvas_state)
60+
61+
return Response({'nodes': [], 'edges': [], 'groupDefinitions': []})
62+
63+
64+
@api_view(['POST'])
65+
def enable_sharing(request, project_id):
66+
"""
67+
Enable public sharing for a project.
68+
Authentication required; only the project owner can call this.
69+
Generates a share_token on first use; reuses the existing token on subsequent calls
70+
so the perma-link stays stable.
71+
"""
72+
if not hasattr(request, 'firebase_user') or not request.firebase_user:
73+
return Response(
74+
{'error': 'Authentication required'},
75+
status=status.HTTP_401_UNAUTHORIZED
76+
)
77+
78+
project = get_object_or_404(Project, pk=project_id)
79+
80+
if project.user != request.firebase_user:
81+
return Response(
82+
{'error': 'You do not have permission to share this project'},
83+
status=status.HTTP_403_FORBIDDEN
84+
)
85+
86+
if project.share_token is None:
87+
project.share_token = uuid.uuid4()
88+
89+
project.is_shared = True
90+
project.save(update_fields=['share_token', 'is_shared'])
91+
92+
return Response({
93+
'share_token': str(project.share_token),
94+
'is_shared': project.is_shared,
95+
})
96+
97+
98+
@api_view(['DELETE'])
99+
def disable_sharing(request, project_id):
100+
"""
101+
Disable public sharing for a project.
102+
Authentication required; only the project owner can call this.
103+
The share_token is preserved so re-enabling restores the same URL.
104+
"""
105+
if not hasattr(request, 'firebase_user') or not request.firebase_user:
106+
return Response(
107+
{'error': 'Authentication required'},
108+
status=status.HTTP_401_UNAUTHORIZED
109+
)
110+
111+
project = get_object_or_404(Project, pk=project_id)
112+
113+
if project.user != request.firebase_user:
114+
return Response(
115+
{'error': 'You do not have permission to modify this project'},
116+
status=status.HTTP_403_FORBIDDEN
117+
)
118+
119+
project.is_shared = False
120+
project.save(update_fields=['is_shared'])
121+
122+
return Response({'is_shared': False})

project/frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const ConfigPanel = lazy(() => import('./components/ConfigPanel'))
1515
const ChatBot = lazy(() => import('./components/ChatBot'))
1616
const LandingPage = lazy(() => import('./landing').then(module => ({ default: module.LandingPage })))
1717
const Dashboard = lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })))
18+
const SharedProjectCanvas = lazy(() => import('./components/SharedProjectCanvas'))
1819

1920
// Loading spinner component
2021
function LoadingSpinner() {
@@ -163,6 +164,7 @@ function App() {
163164
/>
164165
<Route path="/project" element={<ProjectCanvas />} />
165166
<Route path="/project/:projectId" element={<ProjectCanvas />} />
167+
<Route path="/shared/:shareToken" element={<SharedProjectCanvas />} />
166168
</Routes>
167169
</Suspense>
168170
)

project/frontend/src/components/Canvas.tsx

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ const nodeTypes = {
3737
interface CanvasProps {
3838
onDragStart: (type: string) => void
3939
onRegisterAddNode: (handler: (blockType: string) => void) => void
40+
readOnly?: boolean
4041
}
4142

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

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

131133
window.addEventListener('keydown', handleKeyDown)
132134
return () => window.removeEventListener('keydown', handleKeyDown)
133-
}, [undo, redo, removeNode, removeEdge, selectedNodeId, selectedEdgeId, setSelectedEdgeId, nodes])
135+
}, [readOnly, undo, redo, removeNode, removeEdge, selectedNodeId, selectedEdgeId, setSelectedEdgeId, nodes])
134136

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

405+
// In read-only mode we still need to propagate selection changes so that
406+
// ReactFlow sets node.selected=true (which reveals the group-block expand button
407+
// and drives ConfigPanel display). We filter out position/remove/add changes so
408+
// nothing can mutate the graph structure.
409+
const onNodesChangeReadOnly = useCallback(
410+
(changes: any) => {
411+
const allowed = changes.filter((c: any) =>
412+
c.type === 'select' || c.type === 'dimensions' || c.type === 'reset'
413+
)
414+
if (allowed.length > 0) {
415+
setNodes(applyNodeChanges(allowed, nodes))
416+
}
417+
},
418+
[nodes, setNodes]
419+
)
420+
403421
const onEdgesChange = useCallback(
404422
(changes: any) => {
405423
setEdges(applyEdgeChanges(changes, edges))
@@ -688,32 +706,33 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
688706
return (
689707
<div
690708
className="flex-1 h-full"
691-
onDrop={onDrop}
692-
onDragOver={onDragOver}
709+
onDrop={readOnly ? undefined : onDrop}
710+
onDragOver={readOnly ? undefined : onDragOver}
693711
>
694-
<HistoryToolbar />
712+
{!readOnly && <HistoryToolbar />}
695713
<ReactFlow
696714
nodes={nodesWithHandlers}
697715
edges={edges}
698-
onNodesChange={onNodesChange}
699-
onEdgesChange={onEdgesChange}
700-
onConnect={onConnect}
701-
onNodeClick={isInteractive ? onNodeClick : undefined}
702-
onEdgeClick={isInteractive ? onEdgeClick : undefined}
703-
onPaneClick={isInteractive ? onPaneClick : undefined}
704-
onPaneContextMenu={isInteractive ? onPaneContextMenu : undefined}
705-
onNodeContextMenu={isInteractive ? onNodeContextMenu : undefined}
706-
onReconnect={onReconnect}
707-
edgesReconnectable={true}
716+
onNodesChange={readOnly ? onNodesChangeReadOnly : onNodesChange}
717+
onEdgesChange={readOnly ? undefined : onEdgesChange}
718+
onConnect={readOnly ? undefined : onConnect}
719+
onNodeClick={onNodeClick}
720+
onEdgeClick={readOnly ? undefined : onEdgeClick}
721+
onPaneClick={onPaneClick}
722+
onPaneContextMenu={readOnly ? undefined : (isInteractive ? onPaneContextMenu : undefined)}
723+
onNodeContextMenu={readOnly ? undefined : (isInteractive ? onNodeContextMenu : undefined)}
724+
onReconnect={readOnly ? undefined : onReconnect}
725+
edgesReconnectable={!readOnly}
708726
nodeTypes={nodeTypes}
709727
connectionLineComponent={CustomConnectionLine}
710728
fitView
711729
minZoom={0.5}
712730
maxZoom={1.5}
713-
nodesDraggable={isInteractive}
714-
nodesConnectable={isInteractive}
715-
elementsSelectable={isInteractive}
716-
onInteractiveChange={setIsInteractive}
731+
nodesDraggable={readOnly ? false : isInteractive}
732+
nodesConnectable={readOnly ? false : isInteractive}
733+
elementsSelectable={true}
734+
deleteKeyCode={readOnly ? null : undefined}
735+
onInteractiveChange={readOnly ? undefined : setIsInteractive}
717736
defaultEdgeOptions={{
718737
animated: true,
719738
style: { stroke: '#6366f1', strokeWidth: 2 }
@@ -751,7 +770,7 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
751770
position="bottom-right"
752771
/>
753772
</ReactFlow>
754-
{contextMenu && (
773+
{!readOnly && contextMenu && (
755774
<ContextMenu
756775
x={contextMenu.x}
757776
y={contextMenu.y}
@@ -803,10 +822,10 @@ function FlowCanvas({ onRegisterAddNode }: { onRegisterAddNode: (handler: (block
803822
)
804823
}
805824

806-
export default function Canvas({ onDragStart, onRegisterAddNode }: CanvasProps) {
825+
export default function Canvas({ onDragStart, onRegisterAddNode, readOnly = false }: CanvasProps) {
807826
return (
808827
<ReactFlowProvider>
809-
<FlowCanvas onRegisterAddNode={onRegisterAddNode} />
828+
<FlowCanvas onRegisterAddNode={onRegisterAddNode} readOnly={readOnly} />
810829
</ReactFlowProvider>
811830
)
812831
}

0 commit comments

Comments
 (0)