Add relations export and remote full backup/restore.
Expose GET/POST /api/v1/export/ for server dumps and add import UI for relations (CSV/JSON) plus remote backup controls. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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).'
|
||||||
@@ -4,4 +4,5 @@ from . import views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('import/', views.ImportContactsView.as_view(), name='import-contacts'),
|
path('import/', views.ImportContactsView.as_view(), name='import-contacts'),
|
||||||
|
path('export/', views.ExportDumpView.as_view(), name='export-dump'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,9 +4,38 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from core.access import use_jwt_auth
|
from core.access import use_jwt_auth
|
||||||
from core.drf_mixins import JwtAuthMixin
|
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
|
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):
|
class ImportContactsView(JwtAuthMixin, APIView):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
file = request.FILES.get('file')
|
file = request.FILES.get('file')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,9 +5,12 @@ import { generateId } from '../../lib/uuid'
|
|||||||
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||||
import { parseVcf } from '../../lib/import/vcard'
|
import { parseVcf } from '../../lib/import/vcard'
|
||||||
import { serializeContactsExport } from '../../lib/export/contacts'
|
import { serializeContactsExport } from '../../lib/export/contacts'
|
||||||
|
import { serializeRelationsExport } from '../../lib/export/relations'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
import { localContactRepository } from '../../infrastructure/repositories/contactRepository.local'
|
import { localContactRepository } from '../../infrastructure/repositories/contactRepository.local'
|
||||||
import { remoteContactRepository } from '../../infrastructure/repositories/contactRepository.remote'
|
import { remoteContactRepository } from '../../infrastructure/repositories/contactRepository.remote'
|
||||||
|
import { localRelationRepository } from '../../infrastructure/repositories/relationRepository.local'
|
||||||
|
import { remoteRelationRepository } from '../../infrastructure/repositories/relationRepository.remote'
|
||||||
|
|
||||||
function isLikelyEmail(value) {
|
function isLikelyEmail(value) {
|
||||||
return value.includes('@') && value.includes('.')
|
return value.includes('@') && value.includes('.')
|
||||||
@@ -157,11 +160,12 @@ async function parseContactRowsFromFile(file, { allowLocalDump = false } = {}) {
|
|||||||
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.')
|
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) {
|
function contactsExportResult(contacts, format) {
|
||||||
const normalized = String(format || 'csv').toLowerCase()
|
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).')
|
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
|
||||||
}
|
}
|
||||||
const { filename, mime, content } = serializeContactsExport(contacts, normalized)
|
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) {
|
export async function importContactsToLocalFromFile(file) {
|
||||||
const parsed = await parseContactRowsFromFile(file, { allowLocalDump: true })
|
const parsed = await parseContactRowsFromFile(file, { allowLocalDump: true })
|
||||||
if (parsed.kind === 'dump') {
|
if (parsed.kind === 'dump') {
|
||||||
@@ -213,9 +231,21 @@ export async function exportContactsFromRemote({ format = 'csv' } = {}) {
|
|||||||
return contactsExportResult(contacts, format)
|
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' } = {}) {
|
export async function exportContacts({ format = 'csv' } = {}) {
|
||||||
const normalized = String(format || 'csv').toLowerCase()
|
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).')
|
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 = '' } = {}) {
|
export async function exportLocalData({ passphrase = '' } = {}) {
|
||||||
const payload = {
|
const payload = {
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<select :id="inputId" :value="modelValue" class="form-control" @change="onChange">
|
<select :id="inputId" :value="modelValue" class="form-control" @change="onChange">
|
||||||
<option value="csv">CSV (.csv)</option>
|
<option value="csv">CSV (.csv)</option>
|
||||||
<option value="json">JSON (.json)</option>
|
<option value="json">JSON (.json)</option>
|
||||||
<option value="vcf">vCard (.vcf)</option>
|
<option v-if="variant === 'contacts'" value="vcf">vCard (.vcf)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -20,26 +20,29 @@
|
|||||||
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
|
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
|
||||||
<span v-if="result.error">{{ result.error }}</span>
|
<span v-if="result.error">{{ result.error }}</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
Экспортировано контактов: <strong>{{ result.count }}</strong>
|
Экспортировано {{ entityLabel }}: <strong>{{ result.count }}</strong>
|
||||||
({{ result.formatLabel }}).
|
({{ result.formatLabel }}).
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useId } from 'vue'
|
import { computed, useId } from 'vue'
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: 'csv' },
|
modelValue: { type: String, default: 'csv' },
|
||||||
exporting: { type: Boolean, default: false },
|
exporting: { type: Boolean, default: false },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
result: { type: Object, default: null },
|
result: { type: Object, default: null },
|
||||||
buttonLabel: { type: String, default: 'Экспортировать' },
|
buttonLabel: { type: String, default: 'Экспортировать' },
|
||||||
|
variant: { type: String, default: 'contacts' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'export'])
|
const emit = defineEmits(['update:modelValue', 'export'])
|
||||||
const inputId = useId()
|
const inputId = useId()
|
||||||
|
|
||||||
|
const entityLabel = computed(() => (props.variant === 'relations' ? 'связей' : 'контактов'))
|
||||||
|
|
||||||
function onChange(event) {
|
function onChange(event) {
|
||||||
emit('update:modelValue', event.target.value)
|
emit('update:modelValue', event.target.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const CSV_HEADERS = [
|
||||||
|
'source',
|
||||||
|
'target',
|
||||||
|
'source_name',
|
||||||
|
'target_name',
|
||||||
|
'relation_type',
|
||||||
|
'description',
|
||||||
|
'interaction_intensity',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function relationToExportRow(relation = {}) {
|
||||||
|
return {
|
||||||
|
source: relation.source ?? '',
|
||||||
|
target: relation.target ?? '',
|
||||||
|
source_name: String(relation.source_name || '').trim(),
|
||||||
|
target_name: String(relation.target_name || '').trim(),
|
||||||
|
relation_type: String(relation.relation_type || 'acquaintance').trim(),
|
||||||
|
description: String(relation.description || '').trim(),
|
||||||
|
interaction_intensity: String(relation.interaction_intensity || 'intense').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvField(value) {
|
||||||
|
const text = String(value ?? '')
|
||||||
|
if (/[",\n\r]/.test(text)) {
|
||||||
|
return `"${text.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationsToCsv(relations = []) {
|
||||||
|
const rows = relations.map(relationToExportRow)
|
||||||
|
const lines = [CSV_HEADERS.join(',')]
|
||||||
|
for (const row of rows) {
|
||||||
|
lines.push(CSV_HEADERS.map((key) => escapeCsvField(row[key])).join(','))
|
||||||
|
}
|
||||||
|
return `${lines.join('\n')}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationsToJson(relations = []) {
|
||||||
|
return JSON.stringify(relations.map(relationToExportRow), null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPORT_FORMATS = {
|
||||||
|
csv: {
|
||||||
|
ext: 'csv',
|
||||||
|
mime: 'text/csv;charset=utf-8',
|
||||||
|
serialize: relationsToCsv,
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
ext: 'json',
|
||||||
|
mime: 'application/json;charset=utf-8',
|
||||||
|
serialize: relationsToJson,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRelationsExport(relations = [], format = 'csv') {
|
||||||
|
const config = EXPORT_FORMATS[format] || EXPORT_FORMATS.csv
|
||||||
|
return {
|
||||||
|
format,
|
||||||
|
filename: `relations-export-${Date.now()}.${config.ext}`,
|
||||||
|
mime: config.mime,
|
||||||
|
content: config.serialize(relations),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,12 @@ import {
|
|||||||
exportContacts as exportContactsUseCase,
|
exportContacts as exportContactsUseCase,
|
||||||
exportContactsFromLocal,
|
exportContactsFromLocal,
|
||||||
exportContactsFromRemote,
|
exportContactsFromRemote,
|
||||||
|
exportRelationsFromLocal,
|
||||||
|
exportRelationsFromRemote,
|
||||||
exportLocalData,
|
exportLocalData,
|
||||||
|
exportRemoteData,
|
||||||
importLocalDump,
|
importLocalDump,
|
||||||
|
importRemoteDump,
|
||||||
} from '../application/usecases/importExport'
|
} from '../application/usecases/importExport'
|
||||||
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
||||||
import { syncPendingChanges } from '../application/usecases/sync'
|
import { syncPendingChanges } from '../application/usecases/sync'
|
||||||
@@ -252,15 +256,36 @@ export const useContactsStore = defineStore('contacts', {
|
|||||||
return exportContactsFromRemote({ format })
|
return exportContactsFromRemote({ format })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async exportLocalRelations(format = 'csv') {
|
||||||
|
return exportRelationsFromLocal({ format })
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportRemoteRelations(format = 'csv') {
|
||||||
|
return exportRelationsFromRemote({ format })
|
||||||
|
},
|
||||||
|
|
||||||
async exportData(passphrase = '') {
|
async exportData(passphrase = '') {
|
||||||
return exportLocalData({ passphrase })
|
return exportLocalData({ passphrase })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async exportRemoteData() {
|
||||||
|
return exportRemoteData()
|
||||||
|
},
|
||||||
|
|
||||||
async importDataDump(file, passphrase = '') {
|
async importDataDump(file, passphrase = '') {
|
||||||
const result = await importLocalDump(file, passphrase)
|
const result = await importLocalDump(file, passphrase)
|
||||||
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
||||||
this.bumpDataRevision()
|
this.bumpDataRevision()
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async importRemoteDataDump(file, { replace = true, passphrase = '' } = {}) {
|
||||||
|
const result = await importRemoteDump(file, { replace, passphrase })
|
||||||
|
if (isRemoteMode()) {
|
||||||
|
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
||||||
|
}
|
||||||
|
this.bumpDataRevision()
|
||||||
|
return result
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
<div class="card format-examples">
|
<div class="card format-examples">
|
||||||
<h3 class="section-title">Поддерживаемые форматы</h3>
|
<h3 class="section-title">Поддерживаемые форматы</h3>
|
||||||
<p class="text-muted section-subtitle">
|
<p class="text-muted section-subtitle">
|
||||||
CSV, JSON и vCard (.vcf). Полный бэкап приложения (контакты, связи, карты) — только JSON в разделе локальной базы.
|
CSV, JSON и vCard (.vcf) для контактов; CSV и JSON для связей.
|
||||||
|
Полный бэкап приложения (контакты, связи, карты) — JSON в разделах локальной и удалённой базы.
|
||||||
</p>
|
</p>
|
||||||
<pre class="format-pre">name,email,phone,organization,position,notes
|
<pre class="format-pre">name,email,phone,organization,position,notes
|
||||||
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
|
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
|
||||||
@@ -54,6 +55,25 @@
|
|||||||
|
|
||||||
<hr class="import-divider" />
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Экспорт связей</h4>
|
||||||
|
<ExportFormatRow
|
||||||
|
v-model="localRelationsExportFormat"
|
||||||
|
variant="relations"
|
||||||
|
:exporting="localRelationsExporting"
|
||||||
|
:disabled="localRelationCount === 0"
|
||||||
|
:result="localRelationsExportResult"
|
||||||
|
button-label="Экспортировать связи локально"
|
||||||
|
@export="doLocalRelationsExport"
|
||||||
|
/>
|
||||||
|
<p v-if="localRelationCount === 0" class="text-muted import-empty-hint">
|
||||||
|
В локальной базе нет связей.
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-muted import-empty-hint">
|
||||||
|
Связей в локальной базе: {{ localRelationCount }}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
<h4 class="subsection-title">Полный бэкап</h4>
|
<h4 class="subsection-title">Полный бэкап</h4>
|
||||||
<p class="text-muted section-subtitle">
|
<p class="text-muted section-subtitle">
|
||||||
Экспорт или восстановление всей локальной базы (контакты, связи, карты). Пароль необязателен.
|
Экспорт или восстановление всей локальной базы (контакты, связи, карты). Пароль необязателен.
|
||||||
@@ -124,6 +144,83 @@
|
|||||||
На сервере нет контактов.
|
На сервере нет контактов.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Экспорт связей</h4>
|
||||||
|
<ExportFormatRow
|
||||||
|
v-model="remoteRelationsExportFormat"
|
||||||
|
variant="relations"
|
||||||
|
:exporting="remoteRelationsExporting"
|
||||||
|
:disabled="!canUseRemote || serverProbe.relationCount === 0"
|
||||||
|
:result="remoteRelationsExportResult"
|
||||||
|
button-label="Экспортировать связи с сервера"
|
||||||
|
@export="doRemoteRelationsExport"
|
||||||
|
/>
|
||||||
|
<p v-if="canUseRemote && serverProbe.relationCount === 0" class="text-muted import-empty-hint">
|
||||||
|
На сервере нет связей.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Полный бэкап</h4>
|
||||||
|
<p class="text-muted section-subtitle">
|
||||||
|
Экспорт или восстановление всей серверной базы (контакты, связи, карты).
|
||||||
|
При импорте с заменой существующие данные аккаунта удаляются.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input v-model="remoteBackupReplace" type="checkbox" />
|
||||||
|
Заменить данные на сервере при импорте
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль (только для зашифрованного .sgpkg)</label>
|
||||||
|
<input
|
||||||
|
v-model="remoteBackupPassphrase"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Оставьте пустым для обычного JSON"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="import-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canUseRemote || busyRemoteBackup"
|
||||||
|
@click="doExportRemoteBackup"
|
||||||
|
>
|
||||||
|
{{ busyRemoteBackup ? 'Экспорт...' : 'Экспорт бэкапа' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canUseRemote || busyRemoteBackup"
|
||||||
|
@click="$refs.remoteBackupInput.click()"
|
||||||
|
>
|
||||||
|
Импорт бэкапа
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="remoteBackupInput"
|
||||||
|
type="file"
|
||||||
|
accept=".json,.sgpkg"
|
||||||
|
style="display:none"
|
||||||
|
@change="onRemoteBackupFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="remoteBackupResult"
|
||||||
|
class="alert"
|
||||||
|
:class="remoteBackupResult.error ? 'alert-error' : 'alert-success'"
|
||||||
|
style="margin-top:12px;"
|
||||||
|
>
|
||||||
|
<span v-if="remoteBackupResult.error">{{ remoteBackupResult.error }}</span>
|
||||||
|
<span v-else>
|
||||||
|
Импортировано контактов: <strong>{{ remoteBackupResult.importedContacts }}</strong>,
|
||||||
|
связей: <strong>{{ remoteBackupResult.importedRelations }}</strong>,
|
||||||
|
карт: <strong>{{ remoteBackupResult.importedMaps }}</strong>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-muted migration-link">
|
<p class="text-muted migration-link">
|
||||||
Перенос между локальной и серверной базой — в
|
Перенос между локальной и серверной базой — в
|
||||||
<RouterLink to="/settings">настройках</RouterLink>.
|
<RouterLink to="/settings">настройках</RouterLink>.
|
||||||
@@ -143,6 +240,7 @@ import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
|||||||
import { getStoredAccessToken } from '../stores/auth'
|
import { getStoredAccessToken } from '../stores/auth'
|
||||||
import { probeRemoteServer } from '../application/usecases/dataMigration'
|
import { probeRemoteServer } from '../application/usecases/dataMigration'
|
||||||
import { localContactRepository } from '../infrastructure/repositories/contactRepository.local'
|
import { localContactRepository } from '../infrastructure/repositories/contactRepository.local'
|
||||||
|
import { localRelationRepository } from '../infrastructure/repositories/relationRepository.local'
|
||||||
import ImportFileBlock from '../components/import/ImportFileBlock.vue'
|
import ImportFileBlock from '../components/import/ImportFileBlock.vue'
|
||||||
import ExportFormatRow from '../components/import/ExportFormatRow.vue'
|
import ExportFormatRow from '../components/import/ExportFormatRow.vue'
|
||||||
|
|
||||||
@@ -156,14 +254,25 @@ const remoteImporting = ref(false)
|
|||||||
const localResult = ref(null)
|
const localResult = ref(null)
|
||||||
const remoteResult = ref(null)
|
const remoteResult = ref(null)
|
||||||
const backupPassphrase = ref('')
|
const backupPassphrase = ref('')
|
||||||
|
const remoteBackupPassphrase = ref('')
|
||||||
|
const remoteBackupReplace = ref(true)
|
||||||
const busyBackup = ref(false)
|
const busyBackup = ref(false)
|
||||||
|
const busyRemoteBackup = ref(false)
|
||||||
|
const remoteBackupResult = ref(null)
|
||||||
const localExportFormat = ref('csv')
|
const localExportFormat = ref('csv')
|
||||||
const remoteExportFormat = ref('csv')
|
const remoteExportFormat = ref('csv')
|
||||||
|
const localRelationsExportFormat = ref('csv')
|
||||||
|
const remoteRelationsExportFormat = ref('csv')
|
||||||
const localExporting = ref(false)
|
const localExporting = ref(false)
|
||||||
const remoteExporting = ref(false)
|
const remoteExporting = ref(false)
|
||||||
|
const localRelationsExporting = ref(false)
|
||||||
|
const remoteRelationsExporting = ref(false)
|
||||||
const localExportResult = ref(null)
|
const localExportResult = ref(null)
|
||||||
const remoteExportResult = ref(null)
|
const remoteExportResult = ref(null)
|
||||||
|
const localRelationsExportResult = ref(null)
|
||||||
|
const remoteRelationsExportResult = ref(null)
|
||||||
const localContactCount = ref(0)
|
const localContactCount = ref(0)
|
||||||
|
const localRelationCount = ref(0)
|
||||||
const serverProbeLoading = ref(true)
|
const serverProbeLoading = ref(true)
|
||||||
const serverProbe = ref({ ok: false, message: '', contactCount: 0, relationCount: 0 })
|
const serverProbe = ref({ ok: false, message: '', contactCount: 0, relationCount: 0 })
|
||||||
|
|
||||||
@@ -177,8 +286,12 @@ const exportFormatLabels = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshLocalCount() {
|
async function refreshLocalCount() {
|
||||||
const contacts = await localContactRepository.list()
|
const [contacts, relations] = await Promise.all([
|
||||||
|
localContactRepository.list(),
|
||||||
|
localRelationRepository.list(),
|
||||||
|
])
|
||||||
localContactCount.value = contacts.length
|
localContactCount.value = contacts.length
|
||||||
|
localRelationCount.value = relations.length
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshServerProbe() {
|
async function refreshServerProbe() {
|
||||||
@@ -275,6 +388,41 @@ async function doRemoteExport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doLocalRelationsExport() {
|
||||||
|
localRelationsExporting.value = true
|
||||||
|
localRelationsExportResult.value = null
|
||||||
|
try {
|
||||||
|
const { blob, filename, count, format } = await store.exportLocalRelations(localRelationsExportFormat.value)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
localRelationsExportResult.value = {
|
||||||
|
count,
|
||||||
|
formatLabel: exportFormatLabels[format] || format,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
localRelationsExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||||
|
} finally {
|
||||||
|
localRelationsExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRemoteRelationsExport() {
|
||||||
|
if (!canUseRemote.value) return
|
||||||
|
remoteRelationsExporting.value = true
|
||||||
|
remoteRelationsExportResult.value = null
|
||||||
|
try {
|
||||||
|
const { blob, filename, count, format } = await store.exportRemoteRelations(remoteRelationsExportFormat.value)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
remoteRelationsExportResult.value = {
|
||||||
|
count,
|
||||||
|
formatLabel: exportFormatLabels[format] || format,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
remoteRelationsExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||||
|
} finally {
|
||||||
|
remoteRelationsExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function downloadBlob(blob, filename) {
|
function downloadBlob(blob, filename) {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
@@ -318,6 +466,41 @@ async function onBackupFileSelect(e) {
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doExportRemoteBackup() {
|
||||||
|
if (!canUseRemote.value) return
|
||||||
|
busyRemoteBackup.value = true
|
||||||
|
remoteBackupResult.value = null
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await store.exportRemoteData()
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
} catch (error) {
|
||||||
|
remoteBackupResult.value = { error: error?.message || 'Ошибка экспорта бэкапа' }
|
||||||
|
} finally {
|
||||||
|
busyRemoteBackup.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemoteBackupFileSelect(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
busyRemoteBackup.value = true
|
||||||
|
remoteBackupResult.value = null
|
||||||
|
try {
|
||||||
|
const summary = await store.importRemoteDataDump(file, {
|
||||||
|
replace: remoteBackupReplace.value,
|
||||||
|
passphrase: remoteBackupPassphrase.value,
|
||||||
|
})
|
||||||
|
await mapsStore.fetchMaps()
|
||||||
|
remoteBackupResult.value = summary
|
||||||
|
await refreshServerProbe()
|
||||||
|
} catch (error) {
|
||||||
|
remoteBackupResult.value = { error: error?.message || 'Ошибка импорта бэкапа' }
|
||||||
|
} finally {
|
||||||
|
busyRemoteBackup.value = false
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -391,6 +574,13 @@ async function onBackupFileSelect(e) {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
}
|
}
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.server-status {
|
.server-status {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user