Files
social-graph/backend/contacts/views.py
T
gitrusprusandCursor b668ee8507 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>
2026-05-28 13:24:48 +03:00

311 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import csv
import io
import json
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,
LIFE_SPHERES,
NETWORK_CIRCLES,
INTERACTION_INTENSITY,
)
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer
class ContactViewSet(viewsets.ModelViewSet):
queryset = Contact.objects.all()
serializer_class = ContactSerializer
def get_queryset(self):
qs = super().get_queryset()
q = self.request.query_params.get('search', '')
if q:
qs = qs.filter(name__icontains=q)
return qs
class RelationViewSet(viewsets.ModelViewSet):
queryset = Relation.objects.select_related('source', 'target').all()
serializer_class = RelationSerializer
@api_view(['GET'])
def graph_data(request):
"""Возвращает граф: nodes + edges для vis.js."""
contacts = Contact.objects.all()
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
]
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 = ''
email = ''
notes_parts = []
for block in contact_data or []:
if block.get('type') == 'contact_field':
for val in block.get('values') or []:
props = val.get('properties') or {}
value = str(props.get('data') or '').strip()
if not value:
continue
if '@' in value and '.' in value:
email = email or value
else:
phone = phone or value
elif block.get('type') == 'note':
for val in block.get('values') or []:
body = str((val.get('properties') or {}).get('body') or '').strip()
if body:
notes_parts.append(body)
return phone, email, '\n'.join(notes_parts)
def _normalize_rows(data):
"""
Нормализует различные форматы JSON в плоский список словарей.
Поддерживает:
- Плоский массив: [{"name": ...}, ...]
- Monica CRM (экспорт): {"account": {"data": [{"type": "contact", "values": [...]}]}}
- Monica CRM (старый): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
- Обёртка results: {"results": [...]}
- Обёртка data: {"data": [...]}
"""
if isinstance(data, list):
return data
if isinstance(data, dict):
# Monica CRM полный экспорт: account.data, блоки type=contact, values[].properties + data
account = data.get('account')
if isinstance(account, dict):
account_data = account.get('data')
if isinstance(account_data, list):
rows = []
for block in account_data:
if block.get('type') != 'contact':
continue
for c in block.get('values') or []:
props = c.get('properties') or {}
first = str(props.get('first_name') or '').strip()
last = str(props.get('last_name') or '').strip()
middle = str(props.get('middle_name') or '').strip()
name = ' '.join(filter(None, [first, middle, last])) or ' '.join(
filter(None, [first, last])
)
if not name:
continue
phone, email, notes = _monica_contact_fields(c.get('data'))
rows.append({
'name': name,
'email': email,
'phone': phone,
'organization': '',
'position': '',
'notes': notes,
})
if rows:
return rows
# Monica CRM: ключ "contacts" с first_name/last_name (старый формат API)
if 'contacts' in data:
rows = []
for c in data['contacts']:
first = str(c.get('first_name') or '').strip()
last = str(c.get('last_name') or '').strip()
name = ' '.join(filter(None, [first, last]))
# Телефоны Monica хранятся в списке phone_numbers
phone = ''
for ph in c.get('phone_numbers') or []:
phone = str(ph.get('number') or ph.get('content') or '')
if phone:
break
# Email Monica — список emails
email = ''
for em in c.get('emails') or []:
email = str(em.get('email') or em.get('content') or '')
if email:
break
# Организации Monica — список companies
org = ''
position = ''
for comp in c.get('companies') or []:
org = str(comp.get('name') or comp.get('company_name') or '')
position = str(comp.get('job') or comp.get('position') or comp.get('title') or '')
if org:
break
# Также бывает прямое поле company
if not org:
org = str(c.get('company') or c.get('company_name') or '').strip()
position = str(c.get('job') or c.get('position') or '').strip()
rows.append({
'name': name,
'email': email,
'phone': phone,
'organization': org,
'position': position,
'notes': str(c.get('information') or c.get('description') or c.get('notes') or '').strip(),
})
return rows
# Другие обёртки
for key in ('results', 'data', 'items', 'people', 'persons'):
if key in data and isinstance(data[key], list):
return data[key]
return []
@api_view(['POST'])
def import_contacts(request):
"""
Импорт контактов из CSV или JSON.
CSV: name,email,phone,organization,position,notes
JSON (плоский): [{"name": "...", ...}, ...]
JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
"""
file = request.FILES.get('file')
if not file:
return Response({'error': 'Файл не передан.'}, status=status.HTTP_400_BAD_REQUEST)
filename = file.name.lower()
created = 0
skipped = 0
errors = []
try:
if filename.endswith('.csv'):
text = file.read().decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(text))
rows = list(reader)
elif filename.endswith('.json'):
raw = json.loads(file.read().decode('utf-8'))
rows = _normalize_rows(raw)
if not rows:
return Response(
{'error': 'Не удалось распознать формат JSON. Ожидается массив контактов или экспорт Monica CRM.'},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{'error': 'Поддерживаются только CSV и JSON файлы.'},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
return Response({'error': f'Ошибка разбора файла: {e}'}, status=status.HTTP_400_BAD_REQUEST)
total_rows = len(rows)
for i, row in enumerate(rows):
name = str(
row.get('name') or row.get('Name') or row.get('ФИО') or
' '.join(filter(None, [
str(row.get('first_name') or '').strip(),
str(row.get('last_name') or '').strip(),
]))
).strip()
if not name:
errors.append(f'Строка {i + 1}: отсутствует поле "name"')
skipped += 1
continue
Contact.objects.get_or_create(
name=name,
defaults={
'email': str(row.get('email') or '').strip(),
'phone': str(row.get('phone') or '').strip(),
'organization': str(row.get('organization') or '').strip(),
'position': str(row.get('position') or '').strip(),
'notes': str(row.get('notes') or '').strip(),
},
)
created += 1
return Response({
'total': total_rows,
'created': created,
'skipped': skipped,
'errors': errors,
})