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.
@@ -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 = ''
|
||||
|
||||
Reference in New Issue
Block a user