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:
@@ -0,0 +1,119 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def migrate_contact_map_data(apps, schema_editor):
|
||||
Contact = apps.get_model('contacts', 'Contact')
|
||||
NetworkMap = apps.get_model('contacts', 'NetworkMap')
|
||||
NetworkMapMembership = apps.get_model('contacts', 'NetworkMapMembership')
|
||||
|
||||
default_map, _ = NetworkMap.objects.get_or_create(
|
||||
name='Основная карта',
|
||||
defaults={'description': 'Мигрировано из прежней единой карты сети'},
|
||||
)
|
||||
|
||||
for contact in Contact.objects.filter(include_on_network_map=True):
|
||||
NetworkMapMembership.objects.create(
|
||||
map=default_map,
|
||||
contact=contact,
|
||||
life_sphere=contact.life_sphere,
|
||||
network_circle=contact.network_circle,
|
||||
importance=contact.importance,
|
||||
map_angle=contact.map_angle,
|
||||
map_radius_ratio=contact.map_radius_ratio,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0005_contact_map_angle_contact_map_radius_ratio'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NetworkMap',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='Название')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Карта сети',
|
||||
'verbose_name_plural': 'Карты сети',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NetworkMapMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('life_sphere', models.CharField(
|
||||
choices=[
|
||||
('work', 'Работа'), ('study', 'Учёба'), ('hobby', 'Хобби'),
|
||||
('family', 'Семья'), ('health', 'Здоровье'), ('other', 'Другое'),
|
||||
],
|
||||
default='other',
|
||||
max_length=32,
|
||||
verbose_name='Сфера жизни',
|
||||
)),
|
||||
('network_circle', models.CharField(
|
||||
choices=[
|
||||
('support', 'Круг поддержки'),
|
||||
('productivity', 'Круг продуктивности'),
|
||||
('development', 'Круг развития'),
|
||||
],
|
||||
default='productivity',
|
||||
max_length=32,
|
||||
verbose_name='Круг сети',
|
||||
)),
|
||||
('importance', models.PositiveSmallIntegerField(
|
||||
default=3,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(5),
|
||||
],
|
||||
verbose_name='Важность (1–5)',
|
||||
)),
|
||||
('map_angle', models.FloatField(blank=True, null=True, verbose_name='Угол позиции на карте')),
|
||||
('map_radius_ratio', models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(1),
|
||||
],
|
||||
verbose_name='Радиус позиции на карте (доля)',
|
||||
)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('contact', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='map_memberships',
|
||||
to='contacts.contact',
|
||||
verbose_name='Контакт',
|
||||
)),
|
||||
('map', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='memberships',
|
||||
to='contacts.networkmap',
|
||||
verbose_name='Карта',
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Участие на карте',
|
||||
'verbose_name_plural': 'Участия на картах',
|
||||
'unique_together': {('map', 'contact')},
|
||||
},
|
||||
),
|
||||
migrations.RunPython(migrate_contact_map_data, migrations.RunPython.noop),
|
||||
migrations.RemoveField(model_name='contact', name='include_on_network_map'),
|
||||
migrations.RemoveField(model_name='contact', name='life_sphere'),
|
||||
migrations.RemoveField(model_name='contact', name='network_circle'),
|
||||
migrations.RemoveField(model_name='contact', name='importance'),
|
||||
migrations.RemoveField(model_name='contact', name='map_angle'),
|
||||
migrations.RemoveField(model_name='contact', name='map_radius_ratio'),
|
||||
]
|
||||
@@ -19,6 +19,7 @@ NETWORK_CIRCLES = [
|
||||
|
||||
INTERACTION_INTENSITY = [
|
||||
('intense', 'Интенсивные контакты'),
|
||||
('periodic', 'Периодические контакты'),
|
||||
('sparse', 'Редкие контакты'),
|
||||
]
|
||||
|
||||
@@ -32,6 +33,50 @@ class Contact(models.Model):
|
||||
organization = models.CharField(max_length=255, blank=True, verbose_name='Организация')
|
||||
position = models.CharField(max_length=255, blank=True, verbose_name='Должность')
|
||||
notes = models.TextField(blank=True, verbose_name='Заметки')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'Контакт'
|
||||
verbose_name_plural = 'Контакты'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class NetworkMap(models.Model):
|
||||
"""Карта сети — отдельный контекст для визуализации подмножества контактов."""
|
||||
|
||||
name = models.CharField(max_length=255, verbose_name='Название')
|
||||
description = models.TextField(blank=True, verbose_name='Описание')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'Карта сети'
|
||||
verbose_name_plural = 'Карты сети'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class NetworkMapMembership(models.Model):
|
||||
"""Участие контакта на конкретной карте сети."""
|
||||
|
||||
map = models.ForeignKey(
|
||||
NetworkMap,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='memberships',
|
||||
verbose_name='Карта',
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
Contact,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='map_memberships',
|
||||
verbose_name='Контакт',
|
||||
)
|
||||
life_sphere = models.CharField(
|
||||
max_length=32,
|
||||
choices=LIFE_SPHERES,
|
||||
@@ -49,10 +94,6 @@ class Contact(models.Model):
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
||||
verbose_name='Важность (1–5)',
|
||||
)
|
||||
include_on_network_map = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Показывать на карте сети',
|
||||
)
|
||||
map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте')
|
||||
map_radius_ratio = models.FloatField(
|
||||
null=True,
|
||||
@@ -64,12 +105,12 @@ class Contact(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'Контакт'
|
||||
verbose_name_plural = 'Контакты'
|
||||
unique_together = ('map', 'contact')
|
||||
verbose_name = 'Участие на карте'
|
||||
verbose_name_plural = 'Участия на картах'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
return f'{self.contact} на {self.map}'
|
||||
|
||||
|
||||
RELATION_TYPES = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Contact, Relation
|
||||
from .models import Contact, Relation, NetworkMap, NetworkMapMembership
|
||||
|
||||
|
||||
class ContactSerializer(serializers.ModelSerializer):
|
||||
@@ -10,9 +10,6 @@ class ContactSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'email', 'phone',
|
||||
'organization', 'position', 'notes',
|
||||
'life_sphere', 'network_circle', 'importance',
|
||||
'include_on_network_map',
|
||||
'map_angle', 'map_radius_ratio',
|
||||
'created_at', 'updated_at', 'relations_count',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count']
|
||||
@@ -46,6 +43,36 @@ class RelationSerializer(serializers.ModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class NetworkMapSerializer(serializers.ModelSerializer):
|
||||
memberships_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = NetworkMap
|
||||
fields = [
|
||||
'id', 'name', 'description',
|
||||
'created_at', 'updated_at', 'memberships_count',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count']
|
||||
|
||||
def get_memberships_count(self, obj):
|
||||
return obj.memberships.count()
|
||||
|
||||
|
||||
class NetworkMapMembershipSerializer(serializers.ModelSerializer):
|
||||
contact_name = serializers.CharField(source='contact.name', read_only=True)
|
||||
map_name = serializers.CharField(source='map.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = NetworkMapMembership
|
||||
fields = [
|
||||
'id', 'map', 'map_name', 'contact', 'contact_name',
|
||||
'life_sphere', 'network_circle', 'importance',
|
||||
'map_angle', 'map_radius_ratio',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'contact_name', 'map_name']
|
||||
|
||||
|
||||
class GraphSerializer(serializers.Serializer):
|
||||
"""Граф для vis.js: nodes + edges."""
|
||||
|
||||
@@ -60,9 +87,6 @@ class GraphSerializer(serializers.Serializer):
|
||||
'label': c.name,
|
||||
'title': f'{c.organization}\n{c.position}'.strip() or c.name,
|
||||
'group': c.organization or 'default',
|
||||
'life_sphere': c.life_sphere,
|
||||
'network_circle': c.network_circle,
|
||||
'importance': c.importance,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
|
||||
@@ -5,9 +5,25 @@ from . import views
|
||||
router = DefaultRouter()
|
||||
router.register('contacts', views.ContactViewSet)
|
||||
router.register('relations', views.RelationViewSet)
|
||||
router.register('network-maps', views.NetworkMapViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path(
|
||||
'network-maps/<int:map_pk>/memberships/',
|
||||
views.NetworkMapMembershipViewSet.as_view({'get': 'list', 'post': 'create'}),
|
||||
name='network-map-memberships-list',
|
||||
),
|
||||
path(
|
||||
'network-maps/<int:map_pk>/memberships/<int:pk>/',
|
||||
views.NetworkMapMembershipViewSet.as_view({
|
||||
'get': 'retrieve',
|
||||
'patch': 'partial_update',
|
||||
'put': 'update',
|
||||
'delete': 'destroy',
|
||||
}),
|
||||
name='network-map-memberships-detail',
|
||||
),
|
||||
path('graph/', views.graph_data),
|
||||
path('network-map-graph/', views.network_map_graph),
|
||||
path('relation-types/', views.relation_types),
|
||||
|
||||
+72
-46
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user