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, })