Add plugin platform with modular backend and frontend registry.

Split graph/import/meta into Django apps, add API v1 with OpenAPI and pytest, and introduce plugin registry with the tags reference plugin on both FE and BE.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 09:35:51 +03:00
co-authored by Cursor
parent b694a596b8
commit cafc7335ad
77 changed files with 1801 additions and 490 deletions
+6 -31
View File
@@ -1,27 +1,12 @@
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', 'Интенсивные контакты'),
('periodic', 'Периодические контакты'),
('sparse', 'Редкие контакты'),
]
from core.choices import (
LIFE_SPHERES,
NETWORK_CIRCLES,
INTERACTION_INTENSITY,
RELATION_TYPES,
)
class Contact(models.Model):
@@ -113,16 +98,6 @@ class NetworkMapMembership(models.Model):
return f'{self.contact} на {self.map}'
RELATION_TYPES = [
('colleague', 'Коллега'),
('friend', 'Друг'),
('family', 'Родственник'),
('acquaintance', 'Знакомый'),
('business', 'Деловой партнёр'),
('other', 'Другое'),
]
class Relation(models.Model):
"""Связь между двумя контактами."""
+2 -28
View File
@@ -1,32 +1,6 @@
"""Legacy /api/ routes — mirrors v1 for backward compatibility."""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
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),
path('network-map-choices/', views.network_map_choices),
path('import/', views.import_contacts),
path('', include('api.v1.urls')),
]
+2 -375
View File
@@ -1,21 +1,6 @@
import csv
import io
import json
from rest_framework import viewsets
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 .models import Contact, Relation, NetworkMap, NetworkMapMembership
from .serializers import (
ContactSerializer,
RelationSerializer,
@@ -58,361 +43,3 @@ class NetworkMapMembershipViewSet(viewsets.ModelViewSet):
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,
})