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 = [
|
||||
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.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')
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user