Add network map UX and theme support.
Introduce network map view/components with filtering and positioning, expand contacts/map backend fields and migrations, and add a persistent light/dark theme toggle with graph label color updates for readability. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='importance',
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=3,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(5),
|
||||
],
|
||||
verbose_name='Важность (1–5)',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='life_sphere',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('work', 'Работа'),
|
||||
('study', 'Учёба'),
|
||||
('hobby', 'Хобби'),
|
||||
('family', 'Семья'),
|
||||
('health', 'Здоровье'),
|
||||
('other', 'Другое'),
|
||||
],
|
||||
default='other',
|
||||
max_length=32,
|
||||
verbose_name='Сфера жизни',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='network_circle',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('support', 'Круг поддержки'),
|
||||
('productivity', 'Круг продуктивности'),
|
||||
('development', 'Круг развития'),
|
||||
],
|
||||
default='productivity',
|
||||
max_length=32,
|
||||
verbose_name='Круг сети',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='relation',
|
||||
name='interaction_intensity',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('intense', 'Интенсивные контакты'),
|
||||
('sparse', 'Редкие контакты'),
|
||||
],
|
||||
default='intense',
|
||||
max_length=32,
|
||||
verbose_name='Интенсивность общения',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_map_true_for_existing(apps, schema_editor):
|
||||
Contact = apps.get_model('contacts', 'Contact')
|
||||
Contact.objects.all().update(include_on_network_map=True)
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0002_contact_importance_contact_life_sphere_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='include_on_network_map',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Показывать на карте сети',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_map_true_for_existing, noop_reverse),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_map_false_for_all(apps, schema_editor):
|
||||
Contact = apps.get_model('contacts', 'Contact')
|
||||
Contact.objects.all().update(include_on_network_map=False)
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0003_contact_include_on_network_map'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_map_false_for_all, noop_reverse),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0004_contact_map_default_off'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='map_angle',
|
||||
field=models.FloatField(blank=True, null=True, verbose_name='Угол позиции на карте'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='map_radius_ratio',
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)],
|
||||
verbose_name='Радиус позиции на карте (доля)',
|
||||
),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,28 @@
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
LIFE_SPHERES = [
|
||||
('work', 'Работа'),
|
||||
('study', 'Учёба'),
|
||||
('hobby', 'Хобби'),
|
||||
('family', 'Семья'),
|
||||
('health', 'Здоровье'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
NETWORK_CIRCLES = [
|
||||
('support', 'Круг поддержки'),
|
||||
('productivity', 'Круг продуктивности'),
|
||||
('development', 'Круг развития'),
|
||||
]
|
||||
|
||||
INTERACTION_INTENSITY = [
|
||||
('intense', 'Интенсивные контакты'),
|
||||
('sparse', 'Редкие контакты'),
|
||||
]
|
||||
|
||||
|
||||
class Contact(models.Model):
|
||||
"""Контакт в социальном графе."""
|
||||
|
||||
@@ -10,6 +32,34 @@ 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='Заметки')
|
||||
life_sphere = models.CharField(
|
||||
max_length=32,
|
||||
choices=LIFE_SPHERES,
|
||||
default='other',
|
||||
verbose_name='Сфера жизни',
|
||||
)
|
||||
network_circle = models.CharField(
|
||||
max_length=32,
|
||||
choices=NETWORK_CIRCLES,
|
||||
default='productivity',
|
||||
verbose_name='Круг сети',
|
||||
)
|
||||
importance = models.PositiveSmallIntegerField(
|
||||
default=3,
|
||||
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,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(0), MaxValueValidator(1)],
|
||||
verbose_name='Радиус позиции на карте (доля)',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -54,6 +104,12 @@ class Relation(models.Model):
|
||||
verbose_name='Тип связи',
|
||||
)
|
||||
description = models.CharField(max_length=255, blank=True, verbose_name='Описание')
|
||||
interaction_intensity = models.CharField(
|
||||
max_length=32,
|
||||
choices=INTERACTION_INTENSITY,
|
||||
default='intense',
|
||||
verbose_name='Интенсивность общения',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -10,6 +10,9 @@ 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']
|
||||
@@ -30,7 +33,8 @@ class RelationSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'source', 'source_name',
|
||||
'target', 'target_name',
|
||||
'relation_type', 'description', 'created_at',
|
||||
'relation_type', 'description', 'interaction_intensity',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'source_name', 'target_name']
|
||||
|
||||
@@ -56,6 +60,9 @@ 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
|
||||
]
|
||||
@@ -69,6 +76,8 @@ class GraphSerializer(serializers.Serializer):
|
||||
'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
|
||||
]
|
||||
|
||||
@@ -9,6 +9,8 @@ router.register('relations', views.RelationViewSet)
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('graph/', views.graph_data),
|
||||
path('network-map-graph/', views.network_map_graph),
|
||||
path('relation-types/', views.relation_types),
|
||||
path('network-map-choices/', views.network_map_choices),
|
||||
path('import/', views.import_contacts),
|
||||
]
|
||||
|
||||
@@ -6,7 +6,14 @@ from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import api_view, action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Contact, Relation, RELATION_TYPES
|
||||
from .models import (
|
||||
Contact,
|
||||
Relation,
|
||||
RELATION_TYPES,
|
||||
LIFE_SPHERES,
|
||||
NETWORK_CIRCLES,
|
||||
INTERACTION_INTENSITY,
|
||||
)
|
||||
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer
|
||||
|
||||
|
||||
@@ -37,6 +44,11 @@ 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
|
||||
]
|
||||
@@ -49,18 +61,67 @@ def graph_data(request):
|
||||
'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
|
||||
]
|
||||
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')
|
||||
)
|
||||
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
|
||||
]
|
||||
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
|
||||
if r.source_id in allowed_ids and r.target_id in allowed_ids
|
||||
]
|
||||
return Response({'nodes': nodes, 'edges': edges})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def relation_types(request):
|
||||
"""Список допустимых типов связей."""
|
||||
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def network_map_choices(request):
|
||||
"""Подписи для карты сети: сферы, круги, интенсивность связей."""
|
||||
return Response({
|
||||
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
|
||||
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
|
||||
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
|
||||
})
|
||||
|
||||
|
||||
def _monica_contact_fields(contact_data):
|
||||
"""Из вложенного data контакта Monica (экспорт account.data) извлекает телефон, email, заметки."""
|
||||
phone = ''
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user