Add plugin platform with modular backend and frontend registry.
Split graph/import/meta into Django apps, add API v1 with OpenAPI and pytest, and introduce plugin registry with the tags reference plugin on both FE and BE. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from contacts import views as contact_views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('contacts', contact_views.ContactViewSet)
|
||||
router.register('relations', contact_views.RelationViewSet)
|
||||
router.register('network-maps', contact_views.NetworkMapViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path(
|
||||
'network-maps/<int:map_pk>/memberships/',
|
||||
contact_views.NetworkMapMembershipViewSet.as_view({'get': 'list', 'post': 'create'}),
|
||||
name='network-map-memberships-list',
|
||||
),
|
||||
path(
|
||||
'network-maps/<int:map_pk>/memberships/<int:pk>/',
|
||||
contact_views.NetworkMapMembershipViewSet.as_view({
|
||||
'get': 'retrieve',
|
||||
'patch': 'partial_update',
|
||||
'put': 'update',
|
||||
'delete': 'destroy',
|
||||
}),
|
||||
name='network-map-memberships-detail',
|
||||
),
|
||||
path('', include('core.urls')),
|
||||
path('', include('graph.urls')),
|
||||
path('', include('import_export.urls')),
|
||||
path('', include('plugins.urls')),
|
||||
]
|
||||
@@ -12,13 +12,25 @@ ALLOWED_HOSTS = [
|
||||
h.strip() for h in os.environ.get('ALLOWED_HOSTS', '*').split(',') if h.strip()
|
||||
]
|
||||
|
||||
ENABLED_PLUGINS = [
|
||||
p.strip()
|
||||
for p in os.environ.get('ENABLED_PLUGINS', 'tags').split(',')
|
||||
if p.strip()
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'corsheaders',
|
||||
'drf_spectacular',
|
||||
'core',
|
||||
'contacts',
|
||||
'graph',
|
||||
'import_export',
|
||||
'plugins',
|
||||
'plugins_tags',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -41,11 +53,30 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 100,
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'Social Graph API',
|
||||
'DESCRIPTION': 'API for Social Graph Builder (local-first with optional remote backend)',
|
||||
'VERSION': '1.0.0',
|
||||
}
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
# Лимит тела запроса для импорта больших JSON (например, экспорт Monica с сотнями контактов)
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024 # 20 MB
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Phase C: optional JWT auth (disabled by default)
|
||||
USE_JWT_AUTH = os.environ.get('USE_JWT_AUTH', 'false').lower() in ('1', 'true', 'yes')
|
||||
|
||||
if USE_JWT_AUTH:
|
||||
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
]
|
||||
REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
]
|
||||
|
||||
DEFAULT_WORKSPACE_ID = os.environ.get('DEFAULT_WORKSPACE_ID', 'personal')
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
|
||||
urlpatterns = [
|
||||
path('api/v1/', include('api.v1.urls')),
|
||||
path('api/', include('contacts.urls')),
|
||||
path('api/v1/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/v1/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
path('api/v1/', include('core.auth_urls')),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import pytest
|
||||
from contacts.models import Contact, Relation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
from rest_framework.test import APIClient
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_contact(db):
|
||||
return Contact.objects.create(name='Иван Иванов', email='ivan@test.com')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_contacts(db):
|
||||
a = Contact.objects.create(name='Алиса')
|
||||
b = Contact.objects.create(name='Боб')
|
||||
return a, b
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_relation(db, two_contacts):
|
||||
a, b = two_contacts
|
||||
return Relation.objects.create(source=a, target=b, relation_type='friend')
|
||||
@@ -1,27 +1,12 @@
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
LIFE_SPHERES = [
|
||||
('work', 'Работа'),
|
||||
('study', 'Учёба'),
|
||||
('hobby', 'Хобби'),
|
||||
('family', 'Семья'),
|
||||
('health', 'Здоровье'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
NETWORK_CIRCLES = [
|
||||
('support', 'Круг поддержки'),
|
||||
('productivity', 'Круг продуктивности'),
|
||||
('development', 'Круг развития'),
|
||||
]
|
||||
|
||||
INTERACTION_INTENSITY = [
|
||||
('intense', 'Интенсивные контакты'),
|
||||
('periodic', 'Периодические контакты'),
|
||||
('sparse', 'Редкие контакты'),
|
||||
]
|
||||
from core.choices import (
|
||||
LIFE_SPHERES,
|
||||
NETWORK_CIRCLES,
|
||||
INTERACTION_INTENSITY,
|
||||
RELATION_TYPES,
|
||||
)
|
||||
|
||||
|
||||
class Contact(models.Model):
|
||||
@@ -113,16 +98,6 @@ class NetworkMapMembership(models.Model):
|
||||
return f'{self.contact} на {self.map}'
|
||||
|
||||
|
||||
RELATION_TYPES = [
|
||||
('colleague', 'Коллега'),
|
||||
('friend', 'Друг'),
|
||||
('family', 'Родственник'),
|
||||
('acquaintance', 'Знакомый'),
|
||||
('business', 'Деловой партнёр'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
|
||||
class Relation(models.Model):
|
||||
"""Связь между двумя контактами."""
|
||||
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
"""Legacy /api/ routes — mirrors v1 for backward compatibility."""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('contacts', views.ContactViewSet)
|
||||
router.register('relations', views.RelationViewSet)
|
||||
router.register('network-maps', views.NetworkMapViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path(
|
||||
'network-maps/<int:map_pk>/memberships/',
|
||||
views.NetworkMapMembershipViewSet.as_view({'get': 'list', 'post': 'create'}),
|
||||
name='network-map-memberships-list',
|
||||
),
|
||||
path(
|
||||
'network-maps/<int:map_pk>/memberships/<int:pk>/',
|
||||
views.NetworkMapMembershipViewSet.as_view({
|
||||
'get': 'retrieve',
|
||||
'patch': 'partial_update',
|
||||
'put': 'update',
|
||||
'delete': 'destroy',
|
||||
}),
|
||||
name='network-map-memberships-detail',
|
||||
),
|
||||
path('graph/', views.graph_data),
|
||||
path('network-map-graph/', views.network_map_graph),
|
||||
path('relation-types/', views.relation_types),
|
||||
path('network-map-choices/', views.network_map_choices),
|
||||
path('import/', views.import_contacts),
|
||||
path('', include('api.v1.urls')),
|
||||
]
|
||||
|
||||
+2
-375
@@ -1,21 +1,6 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from rest_framework import viewsets
|
||||
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import (
|
||||
Contact,
|
||||
Relation,
|
||||
NetworkMap,
|
||||
NetworkMapMembership,
|
||||
RELATION_TYPES,
|
||||
LIFE_SPHERES,
|
||||
NETWORK_CIRCLES,
|
||||
INTERACTION_INTENSITY,
|
||||
)
|
||||
from .models import Contact, Relation, NetworkMap, NetworkMapMembership
|
||||
from .serializers import (
|
||||
ContactSerializer,
|
||||
RelationSerializer,
|
||||
@@ -58,361 +43,3 @@ class NetworkMapMembershipViewSet(viewsets.ModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
map_id = self.kwargs.get('map_pk')
|
||||
serializer.save(map_id=map_id)
|
||||
|
||||
|
||||
def _node_from_membership(membership):
|
||||
contact = membership.contact
|
||||
return {
|
||||
'id': contact.id,
|
||||
'label': contact.name,
|
||||
'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])),
|
||||
'group': contact.organization or 'default',
|
||||
'life_sphere': membership.life_sphere,
|
||||
'network_circle': membership.network_circle,
|
||||
'importance': membership.importance,
|
||||
'map_angle': membership.map_angle,
|
||||
'map_radius_ratio': membership.map_radius_ratio,
|
||||
'membership_id': membership.id,
|
||||
}
|
||||
|
||||
|
||||
def _edge_from_relation(r):
|
||||
return {
|
||||
'id': r.id,
|
||||
'from': r.source_id,
|
||||
'to': r.target_id,
|
||||
'label': r.get_relation_type_display(),
|
||||
'title': r.description or r.get_relation_type_display(),
|
||||
'relation_type': r.relation_type,
|
||||
'interaction_intensity': r.interaction_intensity,
|
||||
}
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def graph_data(request):
|
||||
"""Возвращает граф: nodes + edges для vis.js."""
|
||||
contacts = Contact.objects.all()
|
||||
nodes = [
|
||||
{
|
||||
'id': c.id,
|
||||
'label': c.name,
|
||||
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
|
||||
'group': c.organization or 'default',
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
relations = Relation.objects.select_related('source', 'target').all()
|
||||
edges = [_edge_from_relation(r) for r in relations]
|
||||
return Response({'nodes': nodes, 'edges': edges})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def network_map_graph(request):
|
||||
"""Граф для конкретной карты сети: участники и связи между ними."""
|
||||
map_id = request.query_params.get('map_id')
|
||||
if not map_id:
|
||||
default_map = NetworkMap.objects.order_by('id').first()
|
||||
if not default_map:
|
||||
return Response({'nodes': [], 'edges': []})
|
||||
map_id = default_map.id
|
||||
|
||||
memberships = list(
|
||||
NetworkMapMembership.objects.filter(map_id=map_id)
|
||||
.select_related('contact')
|
||||
.order_by('contact__name')
|
||||
)
|
||||
allowed_ids = {m.contact_id for m in memberships}
|
||||
nodes = [_node_from_membership(m) for m in memberships]
|
||||
relations = Relation.objects.select_related('source', 'target').all()
|
||||
edges = [
|
||||
_edge_from_relation(r)
|
||||
for r in relations
|
||||
if r.source_id in allowed_ids and r.target_id in allowed_ids
|
||||
]
|
||||
return Response({'nodes': nodes, 'edges': edges})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def relation_types(request):
|
||||
"""Список допустимых типов связей."""
|
||||
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def network_map_choices(request):
|
||||
"""Подписи для карты сети: сферы, круги, интенсивность связей."""
|
||||
return Response({
|
||||
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
|
||||
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
|
||||
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
|
||||
})
|
||||
|
||||
|
||||
def _monica_contact_fields(contact_data):
|
||||
"""Из вложенного data контакта Monica (экспорт account.data) извлекает телефон, email, заметки."""
|
||||
phone = ''
|
||||
email = ''
|
||||
notes_parts = []
|
||||
for block in contact_data or []:
|
||||
if block.get('type') == 'contact_field':
|
||||
for val in block.get('values') or []:
|
||||
props = val.get('properties') or {}
|
||||
value = str(props.get('data') or '').strip()
|
||||
if not value:
|
||||
continue
|
||||
if '@' in value and '.' in value:
|
||||
email = email or value
|
||||
else:
|
||||
phone = phone or value
|
||||
elif block.get('type') == 'note':
|
||||
for val in block.get('values') or []:
|
||||
body = str((val.get('properties') or {}).get('body') or '').strip()
|
||||
if body:
|
||||
notes_parts.append(body)
|
||||
return phone, email, '\n'.join(notes_parts)
|
||||
|
||||
|
||||
def _normalize_rows(data):
|
||||
"""
|
||||
Нормализует различные форматы JSON в плоский список словарей.
|
||||
|
||||
Поддерживает:
|
||||
- Плоский массив: [{"name": ...}, ...]
|
||||
- Monica CRM (экспорт): {"account": {"data": [{"type": "contact", "values": [...]}]}}
|
||||
- Monica CRM (старый): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
|
||||
- Обёртка results: {"results": [...]}
|
||||
- Обёртка data: {"data": [...]}
|
||||
"""
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
# Monica CRM полный экспорт: account.data, блоки type=contact, values[].properties + data
|
||||
account = data.get('account')
|
||||
if isinstance(account, dict):
|
||||
account_data = account.get('data')
|
||||
if isinstance(account_data, list):
|
||||
rows = []
|
||||
for block in account_data:
|
||||
if block.get('type') != 'contact':
|
||||
continue
|
||||
for c in block.get('values') or []:
|
||||
props = c.get('properties') or {}
|
||||
first = str(props.get('first_name') or '').strip()
|
||||
last = str(props.get('last_name') or '').strip()
|
||||
middle = str(props.get('middle_name') or '').strip()
|
||||
name = ' '.join(filter(None, [first, middle, last])) or ' '.join(
|
||||
filter(None, [first, last])
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
phone, email, notes = _monica_contact_fields(c.get('data'))
|
||||
rows.append({
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'organization': '',
|
||||
'position': '',
|
||||
'notes': notes,
|
||||
})
|
||||
if rows:
|
||||
return rows
|
||||
# Monica CRM: ключ "contacts" с first_name/last_name (старый формат API)
|
||||
if 'contacts' in data:
|
||||
rows = []
|
||||
for c in data['contacts']:
|
||||
first = str(c.get('first_name') or '').strip()
|
||||
last = str(c.get('last_name') or '').strip()
|
||||
name = ' '.join(filter(None, [first, last]))
|
||||
# Телефоны Monica хранятся в списке phone_numbers
|
||||
phone = ''
|
||||
for ph in c.get('phone_numbers') or []:
|
||||
phone = str(ph.get('number') or ph.get('content') or '')
|
||||
if phone:
|
||||
break
|
||||
# Email Monica — список emails
|
||||
email = ''
|
||||
for em in c.get('emails') or []:
|
||||
email = str(em.get('email') or em.get('content') or '')
|
||||
if email:
|
||||
break
|
||||
# Организации Monica — список companies
|
||||
org = ''
|
||||
position = ''
|
||||
for comp in c.get('companies') or []:
|
||||
org = str(comp.get('name') or comp.get('company_name') or '')
|
||||
position = str(comp.get('job') or comp.get('position') or comp.get('title') or '')
|
||||
if org:
|
||||
break
|
||||
# Также бывает прямое поле company
|
||||
if not org:
|
||||
org = str(c.get('company') or c.get('company_name') or '').strip()
|
||||
position = str(c.get('job') or c.get('position') or '').strip()
|
||||
rows.append({
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'organization': org,
|
||||
'position': position,
|
||||
'notes': str(c.get('information') or c.get('description') or c.get('notes') or '').strip(),
|
||||
})
|
||||
return rows
|
||||
# Другие обёртки
|
||||
for key in ('results', 'data', 'items', 'people', 'persons'):
|
||||
if key in data and isinstance(data[key], list):
|
||||
return data[key]
|
||||
return []
|
||||
|
||||
|
||||
def _unfold_vcard_lines(text):
|
||||
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
||||
unfolded = []
|
||||
for line in lines:
|
||||
if line.startswith((' ', '\t')) and unfolded:
|
||||
unfolded[-1] += line[1:]
|
||||
else:
|
||||
unfolded.append(line)
|
||||
return unfolded
|
||||
|
||||
|
||||
def _unescape_vcard_value(value):
|
||||
return (
|
||||
str(value or '')
|
||||
.replace('\\n', '\n')
|
||||
.replace('\\N', '\n')
|
||||
.replace('\\,', ',')
|
||||
.replace('\\;', ';')
|
||||
.replace('\\\\', '\\')
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def _name_from_vcard_n(value):
|
||||
parts = _unescape_vcard_value(value).split(';')
|
||||
family = (parts[0] if len(parts) > 0 else '').strip()
|
||||
given = (parts[1] if len(parts) > 1 else '').strip()
|
||||
additional = (parts[2] if len(parts) > 2 else '').strip()
|
||||
return ' '.join(filter(None, [given, additional, family])).strip()
|
||||
|
||||
|
||||
def _parse_vcf_rows(text):
|
||||
props = None
|
||||
rows = []
|
||||
for line in _unfold_vcard_lines(text):
|
||||
trimmed = line.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
upper = trimmed.upper()
|
||||
if upper == 'BEGIN:VCARD':
|
||||
props = {}
|
||||
continue
|
||||
if upper == 'END:VCARD':
|
||||
if props:
|
||||
name = (
|
||||
(props.get('FN') or [''])[0]
|
||||
or _name_from_vcard_n((props.get('N') or [''])[0])
|
||||
).strip()
|
||||
org_raw = (props.get('ORG') or [''])[0]
|
||||
org = org_raw.split(';')[0].strip() if org_raw else ''
|
||||
email = (props.get('EMAIL') or [''])[0].replace('mailto:', '').strip()
|
||||
phone = (props.get('TEL') or [''])[0].replace('tel:', '').strip()
|
||||
rows.append({
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'organization': org,
|
||||
'position': (props.get('TITLE') or [''])[0].strip(),
|
||||
'notes': '\n'.join(props.get('NOTE') or []).strip(),
|
||||
})
|
||||
props = None
|
||||
continue
|
||||
if props is None:
|
||||
continue
|
||||
if ':' not in trimmed:
|
||||
continue
|
||||
raw_key, value = trimmed.split(':', 1)
|
||||
key = raw_key.split(';')[0].upper()
|
||||
decoded = _unescape_vcard_value(value.replace('mailto:', '').replace('tel:', ''))
|
||||
props.setdefault(key, []).append(decoded)
|
||||
return rows
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def import_contacts(request):
|
||||
"""
|
||||
Импорт контактов из CSV, JSON или vCard (.vcf).
|
||||
|
||||
CSV: name,email,phone,organization,position,notes
|
||||
JSON (плоский): [{"name": "...", ...}, ...]
|
||||
JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
|
||||
vCard: экспорт из телефона / Google Contacts (.vcf)
|
||||
"""
|
||||
file = request.FILES.get('file')
|
||||
if not file:
|
||||
return Response({'error': 'Файл не передан.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
filename = file.name.lower()
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
if filename.endswith('.csv'):
|
||||
text = file.read().decode('utf-8-sig')
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
rows = list(reader)
|
||||
elif filename.endswith('.json'):
|
||||
raw = json.loads(file.read().decode('utf-8'))
|
||||
rows = _normalize_rows(raw)
|
||||
if not rows:
|
||||
return Response(
|
||||
{'error': 'Не удалось распознать формат JSON. Ожидается массив контактов или экспорт Monica CRM.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif filename.endswith('.vcf') or filename.endswith('.vcard'):
|
||||
text = file.read().decode('utf-8-sig')
|
||||
rows = _parse_vcf_rows(text)
|
||||
if not rows:
|
||||
return Response(
|
||||
{'error': 'В файле vCard не найдено контактов.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Поддерживаются только CSV, JSON и vCard (.vcf) файлы.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response({'error': f'Ошибка разбора файла: {e}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
total_rows = len(rows)
|
||||
for i, row in enumerate(rows):
|
||||
name = str(
|
||||
row.get('name') or row.get('Name') or row.get('ФИО') or
|
||||
' '.join(filter(None, [
|
||||
str(row.get('first_name') or '').strip(),
|
||||
str(row.get('last_name') or '').strip(),
|
||||
]))
|
||||
).strip()
|
||||
if not name:
|
||||
errors.append(f'Строка {i + 1}: отсутствует поле "name"')
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
Contact.objects.get_or_create(
|
||||
name=name,
|
||||
defaults={
|
||||
'email': str(row.get('email') or '').strip(),
|
||||
'phone': str(row.get('phone') or '').strip(),
|
||||
'organization': str(row.get('organization') or '').strip(),
|
||||
'position': str(row.get('position') or '').strip(),
|
||||
'notes': str(row.get('notes') or '').strip(),
|
||||
},
|
||||
)
|
||||
created += 1
|
||||
|
||||
return Response({
|
||||
'total': total_rows,
|
||||
'created': created,
|
||||
'skipped': skipped,
|
||||
'errors': errors,
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = []
|
||||
|
||||
try:
|
||||
from django.conf import settings
|
||||
if getattr(settings, 'USE_JWT_AUTH', False):
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
|
||||
urlpatterns = [
|
||||
path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
]
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Shared domain choice constants for API meta and models."""
|
||||
|
||||
LIFE_SPHERES = [
|
||||
('work', 'Работа'),
|
||||
('study', 'Учёба'),
|
||||
('hobby', 'Хобби'),
|
||||
('family', 'Семья'),
|
||||
('health', 'Здоровье'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
NETWORK_CIRCLES = [
|
||||
('support', 'Круг поддержки'),
|
||||
('productivity', 'Круг продуктивности'),
|
||||
('development', 'Круг развития'),
|
||||
]
|
||||
|
||||
INTERACTION_INTENSITY = [
|
||||
('intense', 'Интенсивные контакты'),
|
||||
('periodic', 'Периодические контакты'),
|
||||
('sparse', 'Редкие контакты'),
|
||||
]
|
||||
|
||||
RELATION_TYPES = [
|
||||
('colleague', 'Коллега'),
|
||||
('friend', 'Друг'),
|
||||
('family', 'Родственник'),
|
||||
('acquaintance', 'Знакомый'),
|
||||
('business', 'Деловой партнёр'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
|
||||
def choices_payload():
|
||||
return {
|
||||
'relation_types': [{'value': v, 'label': l} for v, l in RELATION_TYPES],
|
||||
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
|
||||
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
|
||||
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('meta/choices/', views.meta_choices, name='meta-choices'),
|
||||
path('relation-types/', views.relation_types, name='relation-types'),
|
||||
path('network-map-choices/', views.network_map_choices, name='network-map-choices'),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .choices import (
|
||||
RELATION_TYPES,
|
||||
LIFE_SPHERES,
|
||||
NETWORK_CIRCLES,
|
||||
INTERACTION_INTENSITY,
|
||||
choices_payload,
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def meta_choices(request):
|
||||
"""Unified meta endpoint for all domain enums."""
|
||||
return Response(choices_payload())
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def relation_types(request):
|
||||
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def network_map_choices(request):
|
||||
return Response({
|
||||
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
|
||||
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
|
||||
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GraphConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'graph'
|
||||
@@ -0,0 +1,66 @@
|
||||
from contacts.models import Contact, Relation, NetworkMap, NetworkMapMembership
|
||||
|
||||
|
||||
def node_from_contact(contact):
|
||||
return {
|
||||
'id': contact.id,
|
||||
'label': contact.name,
|
||||
'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])),
|
||||
'group': contact.organization or 'default',
|
||||
}
|
||||
|
||||
|
||||
def node_from_membership(membership):
|
||||
contact = membership.contact
|
||||
return {
|
||||
**node_from_contact(contact),
|
||||
'life_sphere': membership.life_sphere,
|
||||
'network_circle': membership.network_circle,
|
||||
'importance': membership.importance,
|
||||
'map_angle': membership.map_angle,
|
||||
'map_radius_ratio': membership.map_radius_ratio,
|
||||
'membership_id': membership.id,
|
||||
}
|
||||
|
||||
|
||||
def edge_from_relation(relation):
|
||||
return {
|
||||
'id': relation.id,
|
||||
'from': relation.source_id,
|
||||
'to': relation.target_id,
|
||||
'label': relation.get_relation_type_display(),
|
||||
'title': relation.description or relation.get_relation_type_display(),
|
||||
'relation_type': relation.relation_type,
|
||||
'interaction_intensity': relation.interaction_intensity,
|
||||
}
|
||||
|
||||
|
||||
def build_full_graph():
|
||||
contacts = Contact.objects.all()
|
||||
nodes = [node_from_contact(c) for c in contacts]
|
||||
relations = Relation.objects.select_related('source', 'target').all()
|
||||
edges = [edge_from_relation(r) for r in relations]
|
||||
return {'nodes': nodes, 'edges': edges}
|
||||
|
||||
|
||||
def build_network_map_graph(map_id=None):
|
||||
if not map_id:
|
||||
default_map = NetworkMap.objects.order_by('id').first()
|
||||
if not default_map:
|
||||
return {'nodes': [], 'edges': []}
|
||||
map_id = default_map.id
|
||||
|
||||
memberships = list(
|
||||
NetworkMapMembership.objects.filter(map_id=map_id)
|
||||
.select_related('contact')
|
||||
.order_by('contact__name')
|
||||
)
|
||||
allowed_ids = {m.contact_id for m in memberships}
|
||||
nodes = [node_from_membership(m) for m in memberships]
|
||||
relations = Relation.objects.select_related('source', 'target').all()
|
||||
edges = [
|
||||
edge_from_relation(r)
|
||||
for r in relations
|
||||
if r.source_id in allowed_ids and r.target_id in allowed_ids
|
||||
]
|
||||
return {'nodes': nodes, 'edges': edges}
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('graph/', views.graph_data, name='graph-data'),
|
||||
path('network-map-graph/', views.network_map_graph, name='network-map-graph'),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .services import build_full_graph, build_network_map_graph
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def graph_data(request):
|
||||
return Response(build_full_graph())
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def network_map_graph(request):
|
||||
map_id = request.query_params.get('map_id')
|
||||
return Response(build_network_map_graph(map_id))
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ImportExportConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'import_export'
|
||||
@@ -0,0 +1,231 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from contacts.models import Contact
|
||||
|
||||
|
||||
def monica_contact_fields(contact_data):
|
||||
phone = ''
|
||||
email = ''
|
||||
notes_parts = []
|
||||
for block in contact_data or []:
|
||||
if block.get('type') == 'contact_field':
|
||||
for val in block.get('values') or []:
|
||||
props = val.get('properties') or {}
|
||||
value = str(props.get('data') or '').strip()
|
||||
if not value:
|
||||
continue
|
||||
if '@' in value and '.' in value:
|
||||
email = email or value
|
||||
else:
|
||||
phone = phone or value
|
||||
elif block.get('type') == 'note':
|
||||
for val in block.get('values') or []:
|
||||
body = str((val.get('properties') or {}).get('body') or '').strip()
|
||||
if body:
|
||||
notes_parts.append(body)
|
||||
return phone, email, '\n'.join(notes_parts)
|
||||
|
||||
|
||||
def normalize_rows(data):
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
account = data.get('account')
|
||||
if isinstance(account, dict):
|
||||
account_data = account.get('data')
|
||||
if isinstance(account_data, list):
|
||||
rows = []
|
||||
for block in account_data:
|
||||
if block.get('type') != 'contact':
|
||||
continue
|
||||
for c in block.get('values') or []:
|
||||
props = c.get('properties') or {}
|
||||
first = str(props.get('first_name') or '').strip()
|
||||
last = str(props.get('last_name') or '').strip()
|
||||
middle = str(props.get('middle_name') or '').strip()
|
||||
name = ' '.join(filter(None, [first, middle, last])) or ' '.join(
|
||||
filter(None, [first, last])
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
phone, email, notes = monica_contact_fields(c.get('data'))
|
||||
rows.append({
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'organization': '',
|
||||
'position': '',
|
||||
'notes': notes,
|
||||
})
|
||||
if rows:
|
||||
return rows
|
||||
if 'contacts' in data:
|
||||
rows = []
|
||||
for c in data['contacts']:
|
||||
first = str(c.get('first_name') or '').strip()
|
||||
last = str(c.get('last_name') or '').strip()
|
||||
name = ' '.join(filter(None, [first, last]))
|
||||
phone = ''
|
||||
for ph in c.get('phone_numbers') or []:
|
||||
phone = str(ph.get('number') or ph.get('content') or '')
|
||||
if phone:
|
||||
break
|
||||
email = ''
|
||||
for em in c.get('emails') or []:
|
||||
email = str(em.get('email') or em.get('content') or '')
|
||||
if email:
|
||||
break
|
||||
org = ''
|
||||
position = ''
|
||||
for comp in c.get('companies') or []:
|
||||
org = str(comp.get('name') or comp.get('company_name') or '')
|
||||
position = str(comp.get('job') or comp.get('position') or comp.get('title') or '')
|
||||
if org:
|
||||
break
|
||||
if not org:
|
||||
org = str(c.get('company') or c.get('company_name') or '').strip()
|
||||
position = str(c.get('job') or c.get('position') or '').strip()
|
||||
rows.append({
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'organization': org,
|
||||
'position': position,
|
||||
'notes': str(c.get('information') or c.get('description') or c.get('notes') or '').strip(),
|
||||
})
|
||||
return rows
|
||||
for key in ('results', 'data', 'items', 'people', 'persons'):
|
||||
if key in data and isinstance(data[key], list):
|
||||
return data[key]
|
||||
return []
|
||||
|
||||
|
||||
def unfold_vcard_lines(text):
|
||||
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
||||
unfolded = []
|
||||
for line in lines:
|
||||
if line.startswith((' ', '\t')) and unfolded:
|
||||
unfolded[-1] += line[1:]
|
||||
else:
|
||||
unfolded.append(line)
|
||||
return unfolded
|
||||
|
||||
|
||||
def unescape_vcard_value(value):
|
||||
return (
|
||||
str(value or '')
|
||||
.replace('\\n', '\n')
|
||||
.replace('\\N', '\n')
|
||||
.replace('\\,', ',')
|
||||
.replace('\\;', ';')
|
||||
.replace('\\\\', '\\')
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def name_from_vcard_n(value):
|
||||
parts = unescape_vcard_value(value).split(';')
|
||||
family = (parts[0] if len(parts) > 0 else '').strip()
|
||||
given = (parts[1] if len(parts) > 1 else '').strip()
|
||||
additional = (parts[2] if len(parts) > 2 else '').strip()
|
||||
return ' '.join(filter(None, [given, additional, family])).strip()
|
||||
|
||||
|
||||
def parse_vcf_rows(text):
|
||||
props = None
|
||||
rows = []
|
||||
for line in unfold_vcard_lines(text):
|
||||
trimmed = line.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
upper = trimmed.upper()
|
||||
if upper == 'BEGIN:VCARD':
|
||||
props = {}
|
||||
continue
|
||||
if upper == 'END:VCARD':
|
||||
if props:
|
||||
name = (
|
||||
(props.get('FN') or [''])[0]
|
||||
or name_from_vcard_n((props.get('N') or [''])[0])
|
||||
).strip()
|
||||
org_raw = (props.get('ORG') or [''])[0]
|
||||
org = org_raw.split(';')[0].strip() if org_raw else ''
|
||||
email = (props.get('EMAIL') or [''])[0].replace('mailto:', '').strip()
|
||||
phone = (props.get('TEL') or [''])[0].replace('tel:', '').strip()
|
||||
rows.append({
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'organization': org,
|
||||
'position': (props.get('TITLE') or [''])[0].strip(),
|
||||
'notes': '\n'.join(props.get('NOTE') or []).strip(),
|
||||
})
|
||||
props = None
|
||||
continue
|
||||
if props is None:
|
||||
continue
|
||||
if ':' not in trimmed:
|
||||
continue
|
||||
raw_key, value = trimmed.split(':', 1)
|
||||
key = raw_key.split(';')[0].upper()
|
||||
decoded = unescape_vcard_value(value.replace('mailto:', '').replace('tel:', ''))
|
||||
props.setdefault(key, []).append(decoded)
|
||||
return rows
|
||||
|
||||
|
||||
def parse_upload_file(file):
|
||||
filename = file.name.lower()
|
||||
if filename.endswith('.csv'):
|
||||
text = file.read().decode('utf-8-sig')
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
return list(reader), None
|
||||
if filename.endswith('.json'):
|
||||
raw = json.loads(file.read().decode('utf-8'))
|
||||
rows = normalize_rows(raw)
|
||||
if not rows:
|
||||
return None, 'Не удалось распознать формат JSON. Ожидается массив контактов или экспорт Monica CRM.'
|
||||
return rows, None
|
||||
if filename.endswith('.vcf') or filename.endswith('.vcard'):
|
||||
text = file.read().decode('utf-8-sig')
|
||||
rows = parse_vcf_rows(text)
|
||||
if not rows:
|
||||
return None, 'В файле vCard не найдено контактов.'
|
||||
return rows, None
|
||||
return None, 'Поддерживаются только CSV, JSON и vCard (.vcf) файлы.'
|
||||
|
||||
|
||||
def import_contacts_from_rows(rows):
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
for i, row in enumerate(rows):
|
||||
name = str(
|
||||
row.get('name') or row.get('Name') or row.get('ФИО') or
|
||||
' '.join(filter(None, [
|
||||
str(row.get('first_name') or '').strip(),
|
||||
str(row.get('last_name') or '').strip(),
|
||||
]))
|
||||
).strip()
|
||||
if not name:
|
||||
errors.append(f'Строка {i + 1}: отсутствует поле "name"')
|
||||
skipped += 1
|
||||
continue
|
||||
Contact.objects.get_or_create(
|
||||
name=name,
|
||||
defaults={
|
||||
'email': str(row.get('email') or '').strip(),
|
||||
'phone': str(row.get('phone') or '').strip(),
|
||||
'organization': str(row.get('organization') or '').strip(),
|
||||
'position': str(row.get('position') or '').strip(),
|
||||
'notes': str(row.get('notes') or '').strip(),
|
||||
},
|
||||
)
|
||||
created += 1
|
||||
return {
|
||||
'total': len(rows),
|
||||
'created': created,
|
||||
'skipped': skipped,
|
||||
'errors': errors,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('import/', views.import_contacts, name='import-contacts'),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .services import parse_upload_file, import_contacts_from_rows
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def import_contacts(request):
|
||||
file = request.FILES.get('file')
|
||||
if not file:
|
||||
return Response({'error': 'Файл не передан.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
rows, error = parse_upload_file(file)
|
||||
if error:
|
||||
return Response({'error': error}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(import_contacts_from_rows(rows))
|
||||
except Exception as e:
|
||||
return Response({'error': f'Ошибка разбора файла: {e}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PluginsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'plugins'
|
||||
|
||||
def ready(self):
|
||||
import plugins.registry # noqa: F401
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Plugin platform base classes and registry."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import URLPattern
|
||||
|
||||
|
||||
class Plugin(ABC):
|
||||
id: str = ''
|
||||
version: str = '1.0.0'
|
||||
min_core_version: str = '1.0.0'
|
||||
permissions: List[str] = []
|
||||
|
||||
@abstractmethod
|
||||
def urlpatterns(self) -> List[URLPattern]:
|
||||
pass
|
||||
|
||||
def installed_apps(self) -> List[str]:
|
||||
return []
|
||||
|
||||
|
||||
_REGISTRY: dict[str, Plugin] = {}
|
||||
|
||||
|
||||
def register_plugin(plugin: Plugin) -> None:
|
||||
_REGISTRY[plugin.id] = plugin
|
||||
|
||||
|
||||
def get_plugin(plugin_id: str) -> Plugin | None:
|
||||
return _REGISTRY.get(plugin_id)
|
||||
|
||||
|
||||
def get_enabled_plugins() -> List[Plugin]:
|
||||
enabled = getattr(settings, 'ENABLED_PLUGINS', [])
|
||||
return [_REGISTRY[pid] for pid in enabled if pid in _REGISTRY]
|
||||
|
||||
|
||||
def plugin_urlpatterns() -> List[URLPattern]:
|
||||
from django.urls import path, include
|
||||
|
||||
patterns: List[URLPattern] = []
|
||||
for plugin in get_enabled_plugins():
|
||||
patterns.append(
|
||||
path(f'plugins/{plugin.id}/', include((plugin.urlpatterns(), plugin.id)))
|
||||
)
|
||||
return patterns
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Register built-in plugins."""
|
||||
|
||||
from plugins.base import register_plugin
|
||||
from plugins_tags.plugin import TagsPlugin
|
||||
|
||||
register_plugin(TagsPlugin())
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from .base import plugin_urlpatterns
|
||||
|
||||
urlpatterns = [
|
||||
path('plugins/', views.plugin_manifest, name='plugin-manifest'),
|
||||
*plugin_urlpatterns(),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.urls import path
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plugins.base import get_enabled_plugins
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def plugin_manifest(request):
|
||||
"""List enabled plugins and their metadata."""
|
||||
return Response([
|
||||
{
|
||||
'id': p.id,
|
||||
'version': p.version,
|
||||
'min_core_version': p.min_core_version,
|
||||
'permissions': p.permissions,
|
||||
}
|
||||
for p in get_enabled_plugins()
|
||||
])
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PluginsTagsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'plugins_tags'
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contacts', '0006_network_maps'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContactTag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('label', models.CharField(max_length=100, verbose_name='Тег')),
|
||||
('workspace_id', models.CharField(db_index=True, default='personal', max_length=64)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='contacts.contact', verbose_name='Контакт')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тег контакта',
|
||||
'verbose_name_plural': 'Теги контактов',
|
||||
'ordering': ['label'],
|
||||
'unique_together': {('contact', 'label', 'workspace_id')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
# Generated migration placeholder - run: python manage.py makemigrations plugins_tags
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.db import models
|
||||
|
||||
from contacts.models import Contact
|
||||
|
||||
|
||||
class ContactTag(models.Model):
|
||||
"""Tag assigned to a contact (reference plugin)."""
|
||||
|
||||
contact = models.ForeignKey(
|
||||
Contact,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='tags',
|
||||
verbose_name='Контакт',
|
||||
)
|
||||
label = models.CharField(max_length=100, verbose_name='Тег')
|
||||
workspace_id = models.CharField(max_length=64, default='personal', db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('contact', 'label', 'workspace_id')
|
||||
ordering = ['label']
|
||||
verbose_name = 'Тег контакта'
|
||||
verbose_name_plural = 'Теги контактов'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.label} → {self.contact}'
|
||||
@@ -0,0 +1,15 @@
|
||||
from plugins.base import Plugin
|
||||
from plugins_tags.urls import urlpatterns
|
||||
|
||||
|
||||
class TagsPlugin(Plugin):
|
||||
id = 'tags'
|
||||
version = '1.0.0'
|
||||
min_core_version = '1.0.0'
|
||||
permissions = ['read:contacts', 'write:contacts']
|
||||
|
||||
def urlpatterns(self):
|
||||
return urlpatterns
|
||||
|
||||
def installed_apps(self):
|
||||
return ['plugins_tags']
|
||||
@@ -0,0 +1,12 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import ContactTag
|
||||
|
||||
|
||||
class ContactTagSerializer(serializers.ModelSerializer):
|
||||
contact_name = serializers.CharField(source='contact.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ContactTag
|
||||
fields = ['id', 'contact', 'contact_name', 'label', 'workspace_id', 'created_at']
|
||||
read_only_fields = ['id', 'created_at', 'contact_name']
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import ContactTagViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('contact-tags', ContactTagViewSet, basename='contact-tag')
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,22 @@
|
||||
from rest_framework import viewsets
|
||||
from django.conf import settings
|
||||
|
||||
from .models import ContactTag
|
||||
from .serializers import ContactTagSerializer
|
||||
|
||||
|
||||
class ContactTagViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ContactTagSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = ContactTag.objects.select_related('contact').all()
|
||||
workspace = self.request.query_params.get('workspace_id') or settings.DEFAULT_WORKSPACE_ID
|
||||
qs = qs.filter(workspace_id=workspace)
|
||||
contact_id = self.request.query_params.get('contact_id')
|
||||
if contact_id:
|
||||
qs = qs.filter(contact_id=contact_id)
|
||||
return qs
|
||||
|
||||
def perform_create(self, serializer):
|
||||
workspace = self.request.data.get('workspace_id') or settings.DEFAULT_WORKSPACE_ID
|
||||
serializer.save(workspace_id=workspace)
|
||||
@@ -0,0 +1,41 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "social-graph-backend"
|
||||
version = "1.0.0"
|
||||
description = "Social Graph Builder API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Django==4.2.9",
|
||||
"djangorestframework==3.14.0",
|
||||
"django-cors-headers==4.3.1",
|
||||
"drf-spectacular==0.27.1",
|
||||
"djangorestframework-simplejwt==5.3.1",
|
||||
"Pillow==10.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest==8.0.0", "pytest-django==4.8.0"]
|
||||
|
||||
[project.entry-points."social_graph.plugins"]
|
||||
tags = "plugins_tags.plugin:TagsPlugin"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = [
|
||||
"config*",
|
||||
"contacts*",
|
||||
"core*",
|
||||
"graph*",
|
||||
"import_export*",
|
||||
"plugins*",
|
||||
"plugins_tags*",
|
||||
"api*",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "config.settings"
|
||||
python_files = ["tests.py", "test_*.py", "*_tests.py"]
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = config.settings
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
@@ -1,4 +1,8 @@
|
||||
Django==4.2.9
|
||||
djangorestframework==3.14.0
|
||||
django-cors-headers==4.3.1
|
||||
drf-spectacular==0.27.1
|
||||
djangorestframework-simplejwt==5.3.1
|
||||
Pillow==10.2.0
|
||||
pytest==8.0.0
|
||||
pytest-django==4.8.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_contacts_list(api_client, sample_contact):
|
||||
response = api_client.get('/api/v1/contacts/')
|
||||
assert response.status_code == 200
|
||||
assert response.data['count'] == 1
|
||||
assert response.data['results'][0]['name'] == 'Иван Иванов'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_contact_create(api_client):
|
||||
response = api_client.post('/api/v1/contacts/', {'name': 'Новый контакт'}, format='json')
|
||||
assert response.status_code == 201
|
||||
assert response.data['name'] == 'Новый контакт'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_relations_list(api_client, sample_relation):
|
||||
response = api_client.get('/api/v1/relations/')
|
||||
assert response.status_code == 200
|
||||
assert response.data['count'] == 1
|
||||
@@ -0,0 +1,22 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_graph_empty(api_client):
|
||||
response = api_client.get('/api/v1/graph/')
|
||||
assert response.status_code == 200
|
||||
assert response.data == {'nodes': [], 'edges': []}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_graph_with_data(api_client, sample_contact, sample_relation):
|
||||
response = api_client.get('/api/v1/graph/')
|
||||
assert response.status_code == 200
|
||||
assert len(response.data['nodes']) == 2
|
||||
assert len(response.data['edges']) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_legacy_api_path(api_client):
|
||||
response = api_client.get('/api/graph/')
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_meta_choices(api_client):
|
||||
response = api_client.get('/api/v1/meta/choices/')
|
||||
assert response.status_code == 200
|
||||
data = response.data
|
||||
assert 'relation_types' in data
|
||||
assert 'life_spheres' in data
|
||||
assert 'network_circles' in data
|
||||
assert 'interaction_intensities' in data
|
||||
assert any(x['value'] == 'friend' for x in data['relation_types'])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_relation_types_legacy(api_client):
|
||||
response = api_client.get('/api/v1/relation-types/')
|
||||
assert response.status_code == 200
|
||||
assert len(response.data) >= 1
|
||||
@@ -0,0 +1,27 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_plugin_manifest(api_client):
|
||||
response = api_client.get('/api/v1/plugins/')
|
||||
assert response.status_code == 200
|
||||
ids = [p['id'] for p in response.data]
|
||||
assert 'tags' in ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_tags_crud(api_client, sample_contact):
|
||||
create = api_client.post(
|
||||
'/api/v1/plugins/tags/contact-tags/',
|
||||
{'contact': sample_contact.id, 'label': 'коллеги'},
|
||||
format='json',
|
||||
)
|
||||
assert create.status_code == 201
|
||||
tag_id = create.data['id']
|
||||
|
||||
listing = api_client.get(f'/api/v1/plugins/tags/contact-tags/?contact_id={sample_contact.id}')
|
||||
assert listing.status_code == 200
|
||||
assert listing.data['count'] == 1
|
||||
|
||||
delete = api_client.delete(f'/api/v1/plugins/tags/contact-tags/{tag_id}/')
|
||||
assert delete.status_code == 204
|
||||
Reference in New Issue
Block a user