diff --git a/backend/import_export/dump_services.py b/backend/import_export/dump_services.py new file mode 100644 index 0000000..064b759 --- /dev/null +++ b/backend/import_export/dump_services.py @@ -0,0 +1,387 @@ +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).' diff --git a/backend/import_export/urls.py b/backend/import_export/urls.py index 7ae7b80..b9ab549 100644 --- a/backend/import_export/urls.py +++ b/backend/import_export/urls.py @@ -4,4 +4,5 @@ from . import views urlpatterns = [ path('import/', views.ImportContactsView.as_view(), name='import-contacts'), + path('export/', views.ExportDumpView.as_view(), name='export-dump'), ] diff --git a/backend/import_export/views.py b/backend/import_export/views.py index 21d3bc7..373108e 100644 --- a/backend/import_export/views.py +++ b/backend/import_export/views.py @@ -4,9 +4,38 @@ from rest_framework.views import APIView from core.access import use_jwt_auth from core.drf_mixins import JwtAuthMixin +from .dump_services import build_owner_dump, import_owner_dump, is_data_dump, parse_dump_request from .services import parse_upload_file, import_contacts_from_rows +class ExportDumpView(JwtAuthMixin, APIView): + def get(self, request): + owner = request.user if use_jwt_auth() and request.user.is_authenticated else None + return Response(build_owner_dump(owner)) + + def post(self, request): + data, error = parse_dump_request(request) + if error: + return Response({'error': error}, status=status.HTTP_400_BAD_REQUEST) + if not is_data_dump(data): + return Response( + {'error': 'Некорректный формат бэкапа: ожидаются массивы contacts и relations.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + replace = str(request.query_params.get('replace', '')).lower() in ('1', 'true', 'yes') + owner = request.user if use_jwt_auth() and request.user.is_authenticated else None + try: + summary = import_owner_dump(owner, data, replace=replace) + return Response(summary) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: + return Response( + {'error': f'Ошибка импорта бэкапа: {exc}'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + class ImportContactsView(JwtAuthMixin, APIView): def post(self, request): file = request.FILES.get('file') diff --git a/backend/tests/test_export_dump_api.py b/backend/tests/test_export_dump_api.py new file mode 100644 index 0000000..d6565e3 --- /dev/null +++ b/backend/tests/test_export_dump_api.py @@ -0,0 +1,94 @@ +import pytest + +from contacts.models import Contact, NetworkMap, NetworkMapMembership, NetworkMapType, Relation + + +@pytest.mark.django_db +def test_export_dump_empty(api_client): + response = api_client.get('/api/v1/export/') + assert response.status_code == 200 + data = response.json() + assert data['version'] == 2 + assert data['contacts'] == [] + assert data['relations'] == [] + assert isinstance(data['networkMapTypes'], list) + assert isinstance(data['networkMaps'], list) + assert isinstance(data['networkMapMemberships'], list) + + +@pytest.mark.django_db +def test_export_dump_with_data(api_client, two_contacts, sample_relation): + response = api_client.get('/api/v1/export/') + assert response.status_code == 200 + data = response.json() + assert len(data['contacts']) == 2 + assert len(data['relations']) == 1 + assert data['relations'][0]['source'] == sample_relation.source_id + assert data['relations'][0]['target'] == sample_relation.target_id + + +@pytest.mark.django_db +def test_import_dump_replace(api_client, two_contacts, sample_relation): + export_response = api_client.get('/api/v1/export/') + dump = export_response.json() + assert len(dump['contacts']) == 2 + + Contact.objects.all().delete() + assert Contact.objects.count() == 0 + + import_response = api_client.post('/api/v1/export/?replace=true', dump, format='json') + assert import_response.status_code == 200 + result = import_response.json() + assert result['importedContacts'] == 2 + assert result['importedRelations'] == 1 + assert Contact.objects.count() == 2 + assert Relation.objects.count() == 1 + + +@pytest.mark.django_db +def test_import_dump_invalid(api_client): + response = api_client.post('/api/v1/export/', {'contacts': []}, format='json') + assert response.status_code == 400 + assert 'error' in response.json() + + +@pytest.mark.django_db +def test_import_dump_with_maps(api_client, db): + NetworkMapMembership.objects.all().delete() + NetworkMap.objects.all().delete() + NetworkMapType.objects.all().delete() + + map_type = NetworkMapType.objects.create( + name='Базовый', + sectors=[{'id': 'work', 'label': 'Работа'}], + circles=[{'id': 'close', 'label': 'Близкий'}], + is_default=True, + ) + contact = Contact.objects.create(name='Алиса') + network_map = NetworkMap.objects.create( + name='Основная', + description='Тест', + map_type=map_type, + ) + network_map.memberships.create( + contact=contact, + life_sphere='work', + network_circle='close', + importance=4, + ) + + dump = api_client.get('/api/v1/export/').json() + assert len(dump['contacts']) == 1 + assert len(dump['networkMaps']) == 1 + assert len(dump['networkMapMemberships']) == 1 + + Contact.objects.all().delete() + NetworkMap.objects.all().delete() + NetworkMapType.objects.all().delete() + + result = api_client.post('/api/v1/export/?replace=true', dump, format='json').json() + assert result['importedContacts'] == 1 + assert result['importedMaps'] == 1 + assert result['importedMemberships'] == 1 + assert NetworkMapType.objects.count() == 1 + assert NetworkMap.objects.count() == 1 diff --git a/frontend/src/application/usecases/importExport.js b/frontend/src/application/usecases/importExport.js index 8951c75..96fd0fd 100644 --- a/frontend/src/application/usecases/importExport.js +++ b/frontend/src/application/usecases/importExport.js @@ -5,9 +5,12 @@ import { generateId } from '../../lib/uuid' import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults' import { parseVcf } from '../../lib/import/vcard' import { serializeContactsExport } from '../../lib/export/contacts' +import { serializeRelationsExport } from '../../lib/export/relations' import api from '../../api' import { localContactRepository } from '../../infrastructure/repositories/contactRepository.local' import { remoteContactRepository } from '../../infrastructure/repositories/contactRepository.remote' +import { localRelationRepository } from '../../infrastructure/repositories/relationRepository.local' +import { remoteRelationRepository } from '../../infrastructure/repositories/relationRepository.remote' function isLikelyEmail(value) { return value.includes('@') && value.includes('.') @@ -157,11 +160,12 @@ async function parseContactRowsFromFile(file, { allowLocalDump = false } = {}) { throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.') } -const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf']) +const ALLOWED_CONTACT_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf']) +const ALLOWED_RELATION_EXPORT_FORMATS = new Set(['csv', 'json']) function contactsExportResult(contacts, format) { const normalized = String(format || 'csv').toLowerCase() - if (!ALLOWED_EXPORT_FORMATS.has(normalized)) { + if (!ALLOWED_CONTACT_EXPORT_FORMATS.has(normalized)) { throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).') } const { filename, mime, content } = serializeContactsExport(contacts, normalized) @@ -173,6 +177,20 @@ function contactsExportResult(contacts, format) { } } +function relationsExportResult(relations, format) { + const normalized = String(format || 'csv').toLowerCase() + if (!ALLOWED_RELATION_EXPORT_FORMATS.has(normalized)) { + throw new Error('Поддерживаются только CSV и JSON.') + } + const { filename, mime, content } = serializeRelationsExport(relations, normalized) + return { + filename, + blob: new Blob([content], { type: mime }), + count: relations.length, + format: normalized, + } +} + export async function importContactsToLocalFromFile(file) { const parsed = await parseContactRowsFromFile(file, { allowLocalDump: true }) if (parsed.kind === 'dump') { @@ -213,9 +231,21 @@ export async function exportContactsFromRemote({ format = 'csv' } = {}) { return contactsExportResult(contacts, format) } +export async function exportRelationsFromLocal({ format = 'csv' } = {}) { + const relations = await localRelationRepository.list() + if (!relations.length) throw new Error('Нет связей в локальной базе.') + return relationsExportResult(relations, format) +} + +export async function exportRelationsFromRemote({ format = 'csv' } = {}) { + const relations = await remoteRelationRepository.list() + if (!relations.length) throw new Error('Нет связей на сервере.') + return relationsExportResult(relations, format) +} + export async function exportContacts({ format = 'csv' } = {}) { const normalized = String(format || 'csv').toLowerCase() - if (!ALLOWED_EXPORT_FORMATS.has(normalized)) { + if (!ALLOWED_CONTACT_EXPORT_FORMATS.has(normalized)) { throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).') } @@ -399,6 +429,38 @@ function migrateV1ToV2(dump) { } } +export async function exportRemoteData() { + const { data } = await api.get('/v1/export/', { timeout: 120000 }) + return { + filename: `social-graph-export-${Date.now()}.json`, + blob: new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), + } +} + +async function parseDumpFile(file, passphrase = '') { + const raw = JSON.parse(await file.text()) + if (isEncryptedLocalDump(raw)) { + if (!passphrase) { + throw new Error('Для зашифрованного бэкапа укажите пароль.') + } + return decryptPayload(raw, passphrase) + } + return raw +} + +export async function importRemoteDump(file, { replace = true, passphrase = '' } = {}) { + const dump = await parseDumpFile(file, passphrase) + if (!isLocalDataDump(dump)) { + throw new Error('Некорректный формат бэкапа: ожидаются массивы contacts и relations.') + } + const { data } = await api.post('/v1/export/', dump, { + params: { replace: replace ? 'true' : 'false' }, + timeout: 300000, + }) + if (data?.error) throw new Error(data.error) + return data +} + export async function exportLocalData({ passphrase = '' } = {}) { const payload = { version: 2, diff --git a/frontend/src/components/import/ExportFormatRow.vue b/frontend/src/components/import/ExportFormatRow.vue index 42a3840..8daf79a 100644 --- a/frontend/src/components/import/ExportFormatRow.vue +++ b/frontend/src/components/import/ExportFormatRow.vue @@ -5,7 +5,7 @@