import json from datetime import datetime, timezone from django.db import transaction from contacts.models import ( Contact, NetworkMap, NetworkMapMembership, NetworkMapType, Relation, ) from core.choices import ALL_RELATION_TYPES, INTERACTION_INTENSITY VALID_RELATION_TYPES = {choice[0] for choice in ALL_RELATION_TYPES} VALID_INTENSITY = {choice[0] for choice in INTERACTION_INTENSITY} def is_data_dump(data): return ( isinstance(data, dict) and isinstance(data.get('contacts'), list) and isinstance(data.get('relations'), list) ) def _owner_filter(owner): return {'owner': owner} if owner is not None else {} def _contacts_qs(owner): qs = Contact.objects.all() if owner is not None: qs = qs.filter(owner=owner) return qs.order_by('name') def _relations_qs(owner): qs = Relation.objects.select_related('source', 'target').all() if owner is not None: qs = qs.filter(owner=owner) return qs.order_by('id') def _map_types_qs(owner): qs = NetworkMapType.objects.all() if owner is not None: qs = qs.filter(owner=owner) return qs.order_by('name') def _maps_qs(owner): qs = NetworkMap.objects.select_related('map_type').all() if owner is not None: qs = qs.filter(owner=owner) return qs.order_by('name') def _serialize_contact(contact): return { 'id': contact.id, 'name': contact.name, 'email': contact.email or '', 'phone': contact.phone or '', 'organization': contact.organization or '', 'position': contact.position or '', 'notes': contact.notes or '', 'created_at': contact.created_at.isoformat() if contact.created_at else None, 'updated_at': contact.updated_at.isoformat() if contact.updated_at else None, } def _serialize_relation(relation): return { 'id': relation.id, 'source': relation.source_id, 'source_name': relation.source.name if relation.source_id else '', 'target': relation.target_id, 'target_name': relation.target.name if relation.target_id else '', 'relation_type': relation.relation_type, 'description': relation.description or '', 'interaction_intensity': relation.interaction_intensity, 'created_at': relation.created_at.isoformat() if relation.created_at else None, } def _serialize_map_type(map_type): return { 'id': map_type.id, 'name': map_type.name, 'sectors': map_type.sectors or [], 'circles': map_type.circles or [], 'isDefault': map_type.is_default, 'conflictologyEnabled': map_type.conflictology_enabled, 'createdAt': map_type.created_at.isoformat() if map_type.created_at else None, 'updatedAt': map_type.updated_at.isoformat() if map_type.updated_at else None, } def _serialize_map(network_map): return { 'id': network_map.id, 'name': network_map.name, 'description': network_map.description or '', 'mapTypeId': network_map.map_type_id, 'conflictSubject': network_map.conflict_subject or '', 'createdAt': network_map.created_at.isoformat() if network_map.created_at else None, 'updatedAt': network_map.updated_at.isoformat() if network_map.updated_at else None, } def _serialize_membership(membership): return { 'id': membership.id, 'mapId': membership.map_id, 'contactId': membership.contact_id, 'life_sphere': membership.life_sphere, 'network_circle': membership.network_circle, 'importance': membership.importance, 'conflict_involvement': membership.conflict_involvement, 'map_angle': membership.map_angle, 'map_radius_ratio': membership.map_radius_ratio, 'createdAt': membership.created_at.isoformat() if membership.created_at else None, 'updatedAt': membership.updated_at.isoformat() if membership.updated_at else None, } def build_owner_dump(owner): contacts = [_serialize_contact(c) for c in _contacts_qs(owner)] relations = [_serialize_relation(r) for r in _relations_qs(owner)] map_types = [_serialize_map_type(t) for t in _map_types_qs(owner)] maps = [_serialize_map(m) for m in _maps_qs(owner)] map_ids = [m.id for m in _maps_qs(owner)] memberships = NetworkMapMembership.objects.filter(map_id__in=map_ids).select_related('map', 'contact') membership_rows = [_serialize_membership(m) for m in memberships] return { 'version': 2, 'exportedAt': datetime.now(timezone.utc).isoformat(), 'contacts': contacts, 'relations': relations, 'networkMapTypes': map_types, 'networkMaps': maps, 'networkMapMemberships': membership_rows, } def _read_map_types(data): raw = data.get('networkMapTypes') or data.get('network_map_types') or [] return raw if isinstance(raw, list) else [] def _read_maps(data): raw = data.get('networkMaps') or data.get('network_maps') or [] return raw if isinstance(raw, list) else [] def _read_memberships(data): raw = data.get('networkMapMemberships') or data.get('network_map_memberships') or [] return raw if isinstance(raw, list) else [] def _contact_payload(row): return { 'name': str(row.get('name') or '').strip() or 'Без имени', '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(), } def _normalize_relation_type(value): raw = str(value or 'acquaintance').strip() return raw if raw in VALID_RELATION_TYPES else 'other' def _normalize_intensity(value): raw = str(value or 'intense').strip() return raw if raw in VALID_INTENSITY else 'intense' def _relation_payload(row, source_id, target_id): return { 'source_id': source_id, 'target_id': target_id, 'relation_type': _normalize_relation_type(row.get('relation_type')), 'description': str(row.get('description') or '')[:255], 'interaction_intensity': _normalize_intensity(row.get('interaction_intensity')), } def _map_type_payload(row): return { 'name': str(row.get('name') or '').strip() or 'Тип карты', 'sectors': row.get('sectors') or [], 'circles': row.get('circles') or [], 'is_default': bool(row.get('isDefault') if 'isDefault' in row else row.get('is_default')), 'conflictology_enabled': bool( row.get('conflictologyEnabled') if 'conflictologyEnabled' in row else row.get('conflictology_enabled') ), } def _map_payload(row, map_type_id): return { 'name': str(row.get('name') or '').strip() or 'Карта', 'description': str(row.get('description') or '').strip(), 'map_type_id': map_type_id, 'conflict_subject': str( row.get('conflictSubject') if 'conflictSubject' in row else row.get('conflict_subject') or '' ).strip(), } def _membership_payload(row, map_id, contact_id): return { 'map_id': map_id, 'contact_id': contact_id, 'life_sphere': str(row.get('life_sphere') or 'other'), 'network_circle': str(row.get('network_circle') or 'productivity'), 'importance': int(row.get('importance') or 3), 'conflict_involvement': int(row.get('conflict_involvement') or 3), 'map_angle': row.get('map_angle'), 'map_radius_ratio': row.get('map_radius_ratio'), } def clear_owner_data(owner): maps_qs = _maps_qs(owner) map_ids = list(maps_qs.values_list('id', flat=True)) deleted_memberships = NetworkMapMembership.objects.filter(map_id__in=map_ids).count() deleted_maps = maps_qs.count() deleted_relations = _relations_qs(owner).count() deleted_contacts = _contacts_qs(owner).count() deleted_map_types = _map_types_qs(owner).count() NetworkMapMembership.objects.filter(map_id__in=map_ids).delete() maps_qs.delete() _relations_qs(owner).delete() _contacts_qs(owner).delete() _map_types_qs(owner).delete() return { 'deletedContacts': deleted_contacts, 'deletedRelations': deleted_relations, 'deletedMaps': deleted_maps, 'deletedMemberships': deleted_memberships, 'deletedMapTypes': deleted_map_types, } @transaction.atomic def import_owner_dump(owner, data, replace=False): if not is_data_dump(data): raise ValueError('Некорректный формат бэкапа: ожидаются массивы contacts и relations.') cleared = None if replace: cleared = clear_owner_data(owner) contacts = data.get('contacts') or [] relations = data.get('relations') or [] map_types = _read_map_types(data) maps = _read_maps(data) memberships = _read_memberships(data) contact_id_map = {} contacts_created = 0 contacts_skipped = 0 for row in contacts: payload = _contact_payload(row) if not payload['name']: contacts_skipped += 1 continue create_kwargs = {**payload, **_owner_filter(owner)} contact = Contact.objects.create(**create_kwargs) old_id = row.get('id') if old_id is not None: contact_id_map[str(old_id)] = contact.id contacts_created += 1 if not contact_id_map and contacts: raise ValueError('Не удалось импортировать ни одного контакта.') type_id_map = {} existing_types = {t.name: t for t in _map_types_qs(owner)} default_type_id = None for row in map_types: payload = _map_type_payload(row) found = existing_types.get(payload['name']) if found: type_id_map[str(row.get('id'))] = found.id if payload['is_default']: default_type_id = found.id continue create_kwargs = {**payload, **_owner_filter(owner)} created = NetworkMapType.objects.create(**create_kwargs) existing_types[payload['name']] = created type_id_map[str(row.get('id'))] = created.id if payload['is_default']: default_type_id = created.id if not default_type_id: fallback = _map_types_qs(owner).filter(is_default=True).first() or _map_types_qs(owner).first() default_type_id = fallback.id if fallback else None map_id_map = {} maps_created = 0 for row in maps: old_type_id = row.get('mapTypeId') if 'mapTypeId' in row else row.get('map_type') map_type_id = type_id_map.get(str(old_type_id)) or default_type_id if not map_type_id: continue payload = _map_payload(row, map_type_id) create_kwargs = {**payload, **_owner_filter(owner)} created = NetworkMap.objects.create(**create_kwargs) map_id_map[str(row.get('id'))] = created.id maps_created += 1 relation_pairs = set() relations_created = 0 relations_skipped = 0 for row in relations: source = contact_id_map.get(str(row.get('source'))) target = contact_id_map.get(str(row.get('target'))) if not source or not target or source == target: relations_skipped += 1 continue pair_key = f'{source}:{target}' if pair_key in relation_pairs: relations_skipped += 1 continue payload = _relation_payload(row, source, target) create_kwargs = {**payload, **_owner_filter(owner)} Relation.objects.create(**create_kwargs) relation_pairs.add(pair_key) relations_created += 1 memberships_created = 0 memberships_skipped = 0 for row in memberships: map_id = map_id_map.get(str(row.get('mapId') if 'mapId' in row else row.get('map'))) contact_id = contact_id_map.get(str(row.get('contactId') if 'contactId' in row else row.get('contact'))) if not map_id or not contact_id: memberships_skipped += 1 continue payload = _membership_payload(row, map_id, contact_id) NetworkMapMembership.objects.create(**payload) memberships_created += 1 return { 'replaced': bool(replace), 'cleared': cleared, 'importedContacts': contacts_created, 'contactsSkipped': contacts_skipped, 'importedRelations': relations_created, 'relationsSkipped': relations_skipped, 'importedMaps': maps_created, 'importedMemberships': memberships_created, 'membershipsSkipped': memberships_skipped, 'importedMapTypes': len(type_id_map), } def parse_dump_request(request): if request.content_type and 'application/json' in request.content_type: try: body = request.body.decode('utf-8') if request.body else '{}' return json.loads(body or '{}'), None except json.JSONDecodeError as exc: return None, f'Некорректный JSON: {exc}' dump_file = request.FILES.get('file') if dump_file: try: return json.loads(dump_file.read().decode('utf-8')), None except json.JSONDecodeError as exc: return None, f'Некорректный JSON в файле: {exc}' return None, 'Передайте JSON-бэкап в теле запроса или файлом (поле file).'