Add multiple network maps, graph UX improvements, and full backup import.

Support per-map contact membership with scoped graph views, relation editing on edges, layout caching, and automatic detection of full JSON backups so contacts and relations import together.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 17:54:51 +03:00
co-authored by Cursor
parent 35ec4c81ec
commit 5155b3a37a
50 changed files with 3176 additions and 372 deletions
+72 -46
View File
@@ -3,18 +3,25 @@ import io
import json
from rest_framework import viewsets, status
from rest_framework.decorators import api_view, action
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import (
Contact,
Relation,
NetworkMap,
NetworkMapMembership,
RELATION_TYPES,
LIFE_SPHERES,
NETWORK_CIRCLES,
INTERACTION_INTENSITY,
)
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer
from .serializers import (
ContactSerializer,
RelationSerializer,
NetworkMapSerializer,
NetworkMapMembershipSerializer,
)
class ContactViewSet(viewsets.ModelViewSet):
@@ -34,6 +41,53 @@ class RelationViewSet(viewsets.ModelViewSet):
serializer_class = RelationSerializer
class NetworkMapViewSet(viewsets.ModelViewSet):
queryset = NetworkMap.objects.all()
serializer_class = NetworkMapSerializer
class NetworkMapMembershipViewSet(viewsets.ModelViewSet):
serializer_class = NetworkMapMembershipSerializer
def get_queryset(self):
map_id = self.kwargs.get('map_pk')
return NetworkMapMembership.objects.filter(
map_id=map_id
).select_related('contact', 'map')
def perform_create(self, serializer):
map_id = self.kwargs.get('map_pk')
serializer.save(map_id=map_id)
def _node_from_membership(membership):
contact = membership.contact
return {
'id': contact.id,
'label': contact.name,
'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])),
'group': contact.organization or 'default',
'life_sphere': membership.life_sphere,
'network_circle': membership.network_circle,
'importance': membership.importance,
'map_angle': membership.map_angle,
'map_radius_ratio': membership.map_radius_ratio,
'membership_id': membership.id,
}
def _edge_from_relation(r):
return {
'id': r.id,
'from': r.source_id,
'to': r.target_id,
'label': r.get_relation_type_display(),
'title': r.description or r.get_relation_type_display(),
'relation_type': r.relation_type,
'interaction_intensity': r.interaction_intensity,
}
@api_view(['GET'])
def graph_data(request):
"""Возвращает граф: nodes + edges для vis.js."""
@@ -44,62 +98,34 @@ def graph_data(request):
'label': c.name,
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
'group': c.organization or 'default',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
'map_angle': c.map_angle,
'map_radius_ratio': c.map_radius_ratio,
}
for c in contacts
]
relations = Relation.objects.select_related('source', 'target').all()
edges = [
{
'id': r.id,
'from': r.source_id,
'to': r.target_id,
'label': r.get_relation_type_display(),
'title': r.description or r.get_relation_type_display(),
'relation_type': r.relation_type,
'interaction_intensity': r.interaction_intensity,
}
for r in relations
]
edges = [_edge_from_relation(r) for r in relations]
return Response({'nodes': nodes, 'edges': edges})
@api_view(['GET'])
def network_map_graph(request):
"""Граф только для карты сети: контакты с include_on_network_map и связи между ними."""
contacts = list(
Contact.objects.filter(include_on_network_map=True).order_by('name')
"""Граф для конкретной карты сети: участники и связи между ними."""
map_id = request.query_params.get('map_id')
if not map_id:
default_map = NetworkMap.objects.order_by('id').first()
if not default_map:
return Response({'nodes': [], 'edges': []})
map_id = default_map.id
memberships = list(
NetworkMapMembership.objects.filter(map_id=map_id)
.select_related('contact')
.order_by('contact__name')
)
allowed_ids = {c.id for c in contacts}
nodes = [
{
'id': c.id,
'label': c.name,
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
'group': c.organization or 'default',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
'map_angle': c.map_angle,
'map_radius_ratio': c.map_radius_ratio,
}
for c in contacts
]
allowed_ids = {m.contact_id for m in memberships}
nodes = [_node_from_membership(m) for m in memberships]
relations = Relation.objects.select_related('source', 'target').all()
edges = [
{
'id': r.id,
'from': r.source_id,
'to': r.target_id,
'label': r.get_relation_type_display(),
'title': r.description or r.get_relation_type_display(),
'relation_type': r.relation_type,
'interaction_intensity': r.interaction_intensity,
}
_edge_from_relation(r)
for r in relations
if r.source_id in allowed_ids and r.target_id in allowed_ids
]