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>
419 lines
15 KiB
Python
419 lines
15 KiB
Python
import csv
|
||
import io
|
||
import json
|
||
|
||
from rest_framework import viewsets, status
|
||
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,
|
||
NetworkMapSerializer,
|
||
NetworkMapMembershipSerializer,
|
||
)
|
||
|
||
|
||
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
|
||
|
||
|
||
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."""
|
||
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',
|
||
}
|
||
for c in contacts
|
||
]
|
||
relations = Relation.objects.select_related('source', 'target').all()
|
||
edges = [_edge_from_relation(r) for r in relations]
|
||
return Response({'nodes': nodes, 'edges': edges})
|
||
|
||
|
||
@api_view(['GET'])
|
||
def network_map_graph(request):
|
||
"""Граф для конкретной карты сети: участники и связи между ними."""
|
||
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 = {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 = [
|
||
_edge_from_relation(r)
|
||
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 []
|
||
|
||
|
||
def _unfold_vcard_lines(text):
|
||
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
||
unfolded = []
|
||
for line in lines:
|
||
if line.startswith((' ', '\t')) and unfolded:
|
||
unfolded[-1] += line[1:]
|
||
else:
|
||
unfolded.append(line)
|
||
return unfolded
|
||
|
||
|
||
def _unescape_vcard_value(value):
|
||
return (
|
||
str(value or '')
|
||
.replace('\\n', '\n')
|
||
.replace('\\N', '\n')
|
||
.replace('\\,', ',')
|
||
.replace('\\;', ';')
|
||
.replace('\\\\', '\\')
|
||
.strip()
|
||
)
|
||
|
||
|
||
def _name_from_vcard_n(value):
|
||
parts = _unescape_vcard_value(value).split(';')
|
||
family = (parts[0] if len(parts) > 0 else '').strip()
|
||
given = (parts[1] if len(parts) > 1 else '').strip()
|
||
additional = (parts[2] if len(parts) > 2 else '').strip()
|
||
return ' '.join(filter(None, [given, additional, family])).strip()
|
||
|
||
|
||
def _parse_vcf_rows(text):
|
||
props = None
|
||
rows = []
|
||
for line in _unfold_vcard_lines(text):
|
||
trimmed = line.strip()
|
||
if not trimmed:
|
||
continue
|
||
upper = trimmed.upper()
|
||
if upper == 'BEGIN:VCARD':
|
||
props = {}
|
||
continue
|
||
if upper == 'END:VCARD':
|
||
if props:
|
||
name = (
|
||
(props.get('FN') or [''])[0]
|
||
or _name_from_vcard_n((props.get('N') or [''])[0])
|
||
).strip()
|
||
org_raw = (props.get('ORG') or [''])[0]
|
||
org = org_raw.split(';')[0].strip() if org_raw else ''
|
||
email = (props.get('EMAIL') or [''])[0].replace('mailto:', '').strip()
|
||
phone = (props.get('TEL') or [''])[0].replace('tel:', '').strip()
|
||
rows.append({
|
||
'name': name,
|
||
'email': email,
|
||
'phone': phone,
|
||
'organization': org,
|
||
'position': (props.get('TITLE') or [''])[0].strip(),
|
||
'notes': '\n'.join(props.get('NOTE') or []).strip(),
|
||
})
|
||
props = None
|
||
continue
|
||
if props is None:
|
||
continue
|
||
if ':' not in trimmed:
|
||
continue
|
||
raw_key, value = trimmed.split(':', 1)
|
||
key = raw_key.split(';')[0].upper()
|
||
decoded = _unescape_vcard_value(value.replace('mailto:', '').replace('tel:', ''))
|
||
props.setdefault(key, []).append(decoded)
|
||
return rows
|
||
|
||
|
||
@api_view(['POST'])
|
||
def import_contacts(request):
|
||
"""
|
||
Импорт контактов из CSV, JSON или vCard (.vcf).
|
||
|
||
CSV: name,email,phone,organization,position,notes
|
||
JSON (плоский): [{"name": "...", ...}, ...]
|
||
JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
|
||
vCard: экспорт из телефона / Google Contacts (.vcf)
|
||
"""
|
||
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,
|
||
)
|
||
elif filename.endswith('.vcf') or filename.endswith('.vcard'):
|
||
text = file.read().decode('utf-8-sig')
|
||
rows = _parse_vcf_rows(text)
|
||
if not rows:
|
||
return Response(
|
||
{'error': 'В файле vCard не найдено контактов.'},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
else:
|
||
return Response(
|
||
{'error': 'Поддерживаются только CSV, JSON и vCard (.vcf) файлы.'},
|
||
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,
|
||
})
|