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:
@@ -44,17 +44,25 @@ docker compose up --build
|
|||||||
## Архитектура frontend
|
## Архитектура frontend
|
||||||
|
|
||||||
```text
|
```text
|
||||||
views / components
|
views / features / components
|
||||||
↓
|
↓
|
||||||
Pinia store (orchestration)
|
Pinia store (orchestration)
|
||||||
↓
|
↓
|
||||||
application/usecases
|
application/usecases + application/services
|
||||||
↓
|
↓
|
||||||
infrastructure/repositories → local (IndexedDB) | remote (REST)
|
infrastructure/repositories → local (IndexedDB) | remote (REST)
|
||||||
↓
|
↓
|
||||||
changelog + syncAdapter (noop, подготовка к sync)
|
core/pluginRegistry + plugins/* (optional extensions)
|
||||||
|
↓
|
||||||
|
changelog + syncAdapter (hybrid-ready)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
API v1: `/api/v1/` (legacy `/api/` сохранён). OpenAPI: `/api/v1/docs/`.
|
||||||
|
|
||||||
|
Плагины: `VITE_ENABLED_PLUGINS=tags` (frontend), `ENABLED_PLUGINS=tags` (backend).
|
||||||
|
|
||||||
|
Документация для авторов плагинов: [docs/PLUGIN_AUTHOR_GUIDE.md](docs/PLUGIN_AUTHOR_GUIDE.md).
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -92,6 +100,8 @@ social-graph/
|
|||||||
| GET | `/api/network-map-graph/` | Граф карты сети |
|
| GET | `/api/network-map-graph/` | Граф карты сети |
|
||||||
| GET | `/api/relation-types/` | Типы связей |
|
| GET | `/api/relation-types/` | Типы связей |
|
||||||
| GET | `/api/network-map-choices/` | Справочники карты |
|
| GET | `/api/network-map-choices/` | Справочники карты |
|
||||||
|
| GET | `/api/v1/meta/choices/` | Все справочники (типы связей, сферы, круги) |
|
||||||
|
| GET | `/api/v1/plugins/` | Манифест включённых плагинов |
|
||||||
| POST | `/api/import/` | Импорт CSV/JSON/vCard (legacy) |
|
| POST | `/api/import/` | Импорт CSV/JSON/vCard (legacy) |
|
||||||
|
|
||||||
В режиме `local` импорт и бэкап выполняются в браузере (экран **Импорт**).
|
В режиме `local` импорт и бэкап выполняются в браузере (экран **Импорт**).
|
||||||
|
|||||||
@@ -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()
|
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 = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
|
'drf_spectacular',
|
||||||
|
'core',
|
||||||
'contacts',
|
'contacts',
|
||||||
|
'graph',
|
||||||
|
'import_export',
|
||||||
|
'plugins',
|
||||||
|
'plugins_tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -41,11 +53,30 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||||
'PAGE_SIZE': 100,
|
'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
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
|
|
||||||
# Лимит тела запроса для импорта больших JSON (например, экспорт Monica с сотнями контактов)
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024 # 20 MB
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
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 django.urls import path, include
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('api/v1/', include('api.v1.urls')),
|
||||||
path('api/', include('contacts.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.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from core.choices import (
|
||||||
LIFE_SPHERES = [
|
LIFE_SPHERES,
|
||||||
('work', 'Работа'),
|
NETWORK_CIRCLES,
|
||||||
('study', 'Учёба'),
|
INTERACTION_INTENSITY,
|
||||||
('hobby', 'Хобби'),
|
RELATION_TYPES,
|
||||||
('family', 'Семья'),
|
)
|
||||||
('health', 'Здоровье'),
|
|
||||||
('other', 'Другое'),
|
|
||||||
]
|
|
||||||
|
|
||||||
NETWORK_CIRCLES = [
|
|
||||||
('support', 'Круг поддержки'),
|
|
||||||
('productivity', 'Круг продуктивности'),
|
|
||||||
('development', 'Круг развития'),
|
|
||||||
]
|
|
||||||
|
|
||||||
INTERACTION_INTENSITY = [
|
|
||||||
('intense', 'Интенсивные контакты'),
|
|
||||||
('periodic', 'Периодические контакты'),
|
|
||||||
('sparse', 'Редкие контакты'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Contact(models.Model):
|
class Contact(models.Model):
|
||||||
@@ -113,16 +98,6 @@ class NetworkMapMembership(models.Model):
|
|||||||
return f'{self.contact} на {self.map}'
|
return f'{self.contact} на {self.map}'
|
||||||
|
|
||||||
|
|
||||||
RELATION_TYPES = [
|
|
||||||
('colleague', 'Коллега'),
|
|
||||||
('friend', 'Друг'),
|
|
||||||
('family', 'Родственник'),
|
|
||||||
('acquaintance', 'Знакомый'),
|
|
||||||
('business', 'Деловой партнёр'),
|
|
||||||
('other', 'Другое'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Relation(models.Model):
|
class Relation(models.Model):
|
||||||
"""Связь между двумя контактами."""
|
"""Связь между двумя контактами."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,6 @@
|
|||||||
|
"""Legacy /api/ routes — mirrors v1 for backward compatibility."""
|
||||||
from django.urls import path, include
|
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 = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include('api.v1.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),
|
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-375
@@ -1,21 +1,6 @@
|
|||||||
import csv
|
from rest_framework import viewsets
|
||||||
import io
|
|
||||||
import json
|
|
||||||
|
|
||||||
from rest_framework import viewsets, status
|
from .models import Contact, Relation, NetworkMap, NetworkMapMembership
|
||||||
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 .serializers import (
|
from .serializers import (
|
||||||
ContactSerializer,
|
ContactSerializer,
|
||||||
RelationSerializer,
|
RelationSerializer,
|
||||||
@@ -58,361 +43,3 @@ class NetworkMapMembershipViewSet(viewsets.ModelViewSet):
|
|||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
map_id = self.kwargs.get('map_pk')
|
map_id = self.kwargs.get('map_pk')
|
||||||
serializer.save(map_id=map_id)
|
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
|
Django==4.2.9
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
django-cors-headers==4.3.1
|
django-cors-headers==4.3.1
|
||||||
|
drf-spectacular==0.27.1
|
||||||
|
djangorestframework-simplejwt==5.3.1
|
||||||
Pillow==10.2.0
|
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
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# External Plugins Policy
|
||||||
|
|
||||||
|
## Supported distribution (Phase D)
|
||||||
|
|
||||||
|
### Backend (Python)
|
||||||
|
|
||||||
|
- Package name: `social-graph-plugin-<id>`
|
||||||
|
- Entry point group: `social_graph.plugins`
|
||||||
|
- Manifest fields: `id`, `version`, `min_core_version`, `permissions[]`
|
||||||
|
|
||||||
|
Example `pyproject.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.entry-points."social_graph.plugins"]
|
||||||
|
tags = "social_graph_plugin_tags.plugin:TagsPlugin"
|
||||||
|
```
|
||||||
|
|
||||||
|
Install: `pip install social-graph-plugin-tags`
|
||||||
|
|
||||||
|
Enable: `ENABLED_PLUGINS=tags` in backend environment.
|
||||||
|
|
||||||
|
### Frontend (npm)
|
||||||
|
|
||||||
|
- Package name: `@social-graph/plugin-<id>`
|
||||||
|
- Default export: `PluginDefinition` (same shape as `registerPlugin()`)
|
||||||
|
|
||||||
|
Build-time inclusion only (trusted packages). Runtime CDN loading is **not supported** for security reasons.
|
||||||
|
|
||||||
|
Enable: `VITE_ENABLED_PLUGINS=tags,my-plugin`
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
| Source | Trust level | Loading |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| Monorepo `frontend/src/plugins/*` | Full | Build-time |
|
||||||
|
| npm / pip packages from allowlist | Trusted | Build-time / deploy-time |
|
||||||
|
| Arbitrary URL / user upload | Untrusted | **Blocked** |
|
||||||
|
|
||||||
|
## Version compatibility
|
||||||
|
|
||||||
|
Plugins declare `minCoreVersion`. Core version is `1.0.0` (`CORE_VERSION` in frontend, `SPECTACULAR_SETTINGS.VERSION` in backend).
|
||||||
|
|
||||||
|
Breaking API changes require a new `/api/v2/` namespace.
|
||||||
|
|
||||||
|
## Security checklist for external authors
|
||||||
|
|
||||||
|
- Request minimal `permissions`
|
||||||
|
- Do not access `localStorage` outside plugin namespace
|
||||||
|
- Do not inject scripts into core DOM outside registered extension points
|
||||||
|
- Use plugin-scoped Dexie tables only via `upgradeDexie`
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Plugin Author Guide
|
||||||
|
|
||||||
|
This document describes how to build **internal** plugins for Social Graph.
|
||||||
|
|
||||||
|
## Architecture overview
|
||||||
|
|
||||||
|
- **Core** owns contacts, relations, network maps, graph shell, import/export.
|
||||||
|
- **Plugins** extend the app via `registerPlugin()` without editing core files.
|
||||||
|
- **Backend** plugins are Django apps registered through `plugins.registry` and `ENABLED_PLUGINS`.
|
||||||
|
- **Frontend** plugins live under `frontend/src/plugins/<id>/` and are loaded at bootstrap.
|
||||||
|
|
||||||
|
## Frontend plugin contract
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { registerPlugin } from '../../core/pluginRegistry'
|
||||||
|
|
||||||
|
registerPlugin({
|
||||||
|
id: 'my-plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
minCoreVersion: '1.0.0',
|
||||||
|
permissions: ['read:contacts'],
|
||||||
|
routes: [{ path: '/my', name: 'MyPlugin', component: MyView }],
|
||||||
|
navItems: [{ to: '/my', label: 'My plugin' }],
|
||||||
|
contactFormExtensions: [MyContactFieldset],
|
||||||
|
graphToolbarActions: [],
|
||||||
|
upgradeDexie(db) { /* db.version(N).stores({...}) */ },
|
||||||
|
syncContributor: {
|
||||||
|
entityType: 'plugin:my-plugin',
|
||||||
|
async pushChanges() {},
|
||||||
|
async pullChanges() {},
|
||||||
|
},
|
||||||
|
graphExtensions: {
|
||||||
|
extendNode(node) { return node },
|
||||||
|
extendEdge(edge) { return edge },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable via `VITE_ENABLED_PLUGINS=my-plugin,tags` at build time.
|
||||||
|
|
||||||
|
## Backend plugin contract
|
||||||
|
|
||||||
|
1. Create Django app under `backend/plugins_<id>/`.
|
||||||
|
2. Implement `Plugin` subclass in `plugin.py`.
|
||||||
|
3. Register in `backend/plugins/registry.py`.
|
||||||
|
4. Add app to `INSTALLED_APPS` and id to `ENABLED_PLUGINS` env var.
|
||||||
|
|
||||||
|
API surface: `/api/v1/plugins/<id>/...`
|
||||||
|
|
||||||
|
## Reference plugin: `tags`
|
||||||
|
|
||||||
|
- Frontend: `frontend/src/plugins/tags/`
|
||||||
|
- Backend: `backend/plugins_tags/`
|
||||||
|
- Dexie table: `contactTags`
|
||||||
|
- REST: `/api/v1/plugins/tags/contact-tags/`
|
||||||
|
|
||||||
|
## Local-first backup format
|
||||||
|
|
||||||
|
Plugin data should be included in backup v2+ under `plugins: { tags: [...] }` (planned extension). Current tags are stored in IndexedDB table `contactTags`.
|
||||||
|
|
||||||
|
## Permissions (Phase C)
|
||||||
|
|
||||||
|
Declared permissions are informational until JWT auth is enabled (`USE_JWT_AUTH=true`). Future scopes: `read:contacts`, `write:relations`, etc.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Enabled frontend plugins (comma-separated)
|
||||||
|
VITE_ENABLED_PLUGINS=tags
|
||||||
@@ -48,6 +48,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Импорт</span>
|
<span class="nav-label">Импорт</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in pluginNavItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="nav-link"
|
||||||
|
active-class="active"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
|
||||||
|
<line x1="7" y1="7" x2="7.01" y2="7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
<button
|
<button
|
||||||
class="nav-link nav-theme-btn"
|
class="nav-link nav-theme-btn"
|
||||||
@@ -86,8 +99,10 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
import { useContactsStore } from './stores/contacts'
|
import { useContactsStore } from './stores/contacts'
|
||||||
|
import { getPluginNavItems } from './core/pluginRegistry'
|
||||||
|
|
||||||
const store = useContactsStore()
|
const store = useContactsStore()
|
||||||
|
const pluginNavItems = getPluginNavItems()
|
||||||
const THEME_KEY = 'ui-theme'
|
const THEME_KEY = 'ui-theme'
|
||||||
const currentTheme = ref('dark')
|
const currentTheme = ref('dark')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { tagRepository } from '../../plugins/tags/tagRepository.local'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist plugin form payload after contact create/update.
|
||||||
|
* @param {string|number} contactId
|
||||||
|
* @param {{ tags?: string[] }} pluginPayload
|
||||||
|
*/
|
||||||
|
export async function saveContactPluginData(contactId, pluginPayload = {}) {
|
||||||
|
if (!contactId || !pluginPayload) return
|
||||||
|
if (Array.isArray(pluginPayload.tags)) {
|
||||||
|
await tagRepository.setContactTags(contactId, pluginPayload.tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { getGraphBundle } from '../usecases/graph'
|
||||||
|
import { getRelationTypes } from '../../infrastructure/repositories/repositoryFactory'
|
||||||
|
import { isLocalMode } from '../../infrastructure/config/dataMode'
|
||||||
|
import api from '../../api'
|
||||||
|
import { applyGraphExtensions } from '../../core/pluginRegistry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified graph data access for GraphView and NetworkMapView.
|
||||||
|
*/
|
||||||
|
export async function fetchGraphBundle({ mapId = null } = {}) {
|
||||||
|
let bundle
|
||||||
|
if (isLocalMode()) {
|
||||||
|
bundle = await getGraphBundle({ mapId })
|
||||||
|
} else {
|
||||||
|
const endpoint = mapId
|
||||||
|
? `/network-map-graph/?map_id=${encodeURIComponent(mapId)}`
|
||||||
|
: '/graph/'
|
||||||
|
const [gRes, rtRes] = await Promise.all([
|
||||||
|
api.get(endpoint),
|
||||||
|
api.get('/relation-types/'),
|
||||||
|
])
|
||||||
|
bundle = {
|
||||||
|
nodes: gRes.data.nodes || [],
|
||||||
|
edges: gRes.data.edges || [],
|
||||||
|
relationTypes: rtRes.data || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...bundle,
|
||||||
|
nodes: (bundle.nodes || []).map((n) => applyGraphExtensions('node', n)),
|
||||||
|
edges: (bundle.edges || []).map((e) => applyGraphExtensions('edge', e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGraphBundleFromStore(store, { mapId = null } = {}) {
|
||||||
|
const { buildGraphFromStore } = await import('../usecases/graph')
|
||||||
|
if (!store.contacts.length) await store.fetchContacts()
|
||||||
|
if (!store.relations.length) await store.fetchRelations()
|
||||||
|
const relationTypes = store.relationTypes.length
|
||||||
|
? store.relationTypes
|
||||||
|
: await store.fetchRelationTypes()
|
||||||
|
|
||||||
|
const bundle = buildGraphFromStore(store.contacts, store.relations, relationTypes)
|
||||||
|
if (mapId) {
|
||||||
|
return fetchGraphBundle({ mapId })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...bundle,
|
||||||
|
nodes: bundle.nodes.map((n) => applyGraphExtensions('node', n)),
|
||||||
|
edges: bundle.edges.map((e) => applyGraphExtensions('edge', e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRelationTypes() {
|
||||||
|
return getRelationTypes()
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../infrastructure/config/dataMode', () => ({
|
||||||
|
isLocalMode: () => true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../core/pluginRegistry', () => ({
|
||||||
|
applyGraphExtensions: (_kind, payload) => payload,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../usecases/graph', () => ({
|
||||||
|
getGraphBundle: vi.fn(async () => ({
|
||||||
|
nodes: [{ id: '1', label: 'A' }],
|
||||||
|
edges: [],
|
||||||
|
relationTypes: [],
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('graphDataService', () => {
|
||||||
|
it('fetchGraphBundle returns nodes from local use case', async () => {
|
||||||
|
const { fetchGraphBundle } = await import('./graphDataService.js')
|
||||||
|
const bundle = await fetchGraphBundle()
|
||||||
|
expect(bundle.nodes).toHaveLength(1)
|
||||||
|
expect(bundle.nodes[0].label).toBe('A')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,6 +13,10 @@ vi.mock('../application/usecases/networkMaps', () => ({
|
|||||||
listMembershipsByContact: vi.fn(async () => []),
|
listMembershipsByContact: vi.fn(async () => []),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../core/pluginRegistry', () => ({
|
||||||
|
getContactFormExtensions: () => [],
|
||||||
|
}))
|
||||||
|
|
||||||
describe('ContactForm', () => {
|
describe('ContactForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@@ -21,10 +25,11 @@ describe('ContactForm', () => {
|
|||||||
it('emits submit with contact data and map ids', async () => {
|
it('emits submit with contact data and map ids', async () => {
|
||||||
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
|
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
|
||||||
await wrapper.get('form').trigger('submit.prevent')
|
await wrapper.get('form').trigger('submit.prevent')
|
||||||
const [contactData, mapIds] = wrapper.emitted('submit')[0]
|
const [contactData, mapIds, pluginPayload] = wrapper.emitted('submit')[0]
|
||||||
expect(contactData.name).toBe('Иван')
|
expect(contactData.name).toBe('Иван')
|
||||||
expect(contactData.include_on_network_map).toBeUndefined()
|
expect(contactData.include_on_network_map).toBeUndefined()
|
||||||
expect(mapIds).toEqual([])
|
expect(mapIds).toEqual([])
|
||||||
|
expect(pluginPayload).toEqual({ tags: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows delete button when editing existing contact', async () => {
|
it('shows delete button when editing existing contact', async () => {
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
<label>Заметки</label>
|
<label>Заметки</label>
|
||||||
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<component
|
||||||
|
:is="Ext"
|
||||||
|
v-for="(Ext, index) in contactFormExtensions"
|
||||||
|
:key="index"
|
||||||
|
:contact-id="initial?.id"
|
||||||
|
v-model="pluginTags"
|
||||||
|
/>
|
||||||
<div class="contact-form-footer">
|
<div class="contact-form-footer">
|
||||||
<button
|
<button
|
||||||
v-if="showDelete"
|
v-if="showDelete"
|
||||||
@@ -63,9 +70,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, watch, computed, onMounted } from 'vue'
|
import { reactive, ref, watch, computed, onMounted } from 'vue'
|
||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
import { listMembershipsByContact } from '../application/usecases/networkMaps'
|
||||||
|
import { getContactFormExtensions } from '../core/pluginRegistry'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initial: { type: Object, default: () => ({}) },
|
initial: { type: Object, default: () => ({}) },
|
||||||
@@ -75,6 +83,8 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
const emit = defineEmits(['submit', 'cancel', 'delete'])
|
||||||
|
|
||||||
const mapsStore = useNetworkMapsStore()
|
const mapsStore = useNetworkMapsStore()
|
||||||
|
const contactFormExtensions = getContactFormExtensions()
|
||||||
|
const pluginTags = ref([])
|
||||||
|
|
||||||
const showDelete = computed(() => {
|
const showDelete = computed(() => {
|
||||||
if (props.deletable) return true
|
if (props.deletable) return true
|
||||||
@@ -140,7 +150,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
const { mapIds, ...contactData } = form
|
const { mapIds, ...contactData } = form
|
||||||
emit('submit', contactData, mapIds)
|
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,10 @@
|
|||||||
import { getGraphBundle, getMapChoices } from '../application/usecases/graph'
|
/**
|
||||||
import { isLocalMode } from '../infrastructure/config/dataMode'
|
* Re-export for backward compatibility.
|
||||||
import api from '../api'
|
* @deprecated Use application/services/graphDataService.js
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
fetchGraphBundle,
|
||||||
|
fetchRelationTypes,
|
||||||
|
} from '../application/services/graphDataService'
|
||||||
|
|
||||||
export async function fetchGraphBundle({ graphEndpoint = '/graph/', mapId = null } = {}) {
|
export { getMapChoices as fetchMapChoices } from '../application/usecases/graph'
|
||||||
if (isLocalMode()) {
|
|
||||||
return getGraphBundle({ mapId })
|
|
||||||
}
|
|
||||||
const endpoint = mapId
|
|
||||||
? `/network-map-graph/?map_id=${encodeURIComponent(mapId)}`
|
|
||||||
: graphEndpoint
|
|
||||||
const [gRes, rtRes] = await Promise.all([
|
|
||||||
api.get(endpoint),
|
|
||||||
api.get('/relation-types/'),
|
|
||||||
])
|
|
||||||
return {
|
|
||||||
nodes: gRes.data.nodes || [],
|
|
||||||
edges: gRes.data.edges || [],
|
|
||||||
relationTypes: rtRes.data || [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMapChoices() {
|
|
||||||
if (isLocalMode()) {
|
|
||||||
return getMapChoices()
|
|
||||||
}
|
|
||||||
const { data } = await api.get('/network-map-choices/')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { ENABLED_PLUGINS } from './config/enabledPlugins'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and register enabled plugins at app bootstrap.
|
||||||
|
*/
|
||||||
|
export async function bootstrapPlugins() {
|
||||||
|
const loaders = {
|
||||||
|
tags: () => import('../plugins/tags/index.js'),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ENABLED_PLUGINS) {
|
||||||
|
const load = loaders[id]
|
||||||
|
if (!load) {
|
||||||
|
console.warn(`[plugins] Unknown plugin "${id}" — skipped`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Build-time list of enabled frontend plugins.
|
||||||
|
* Override via VITE_ENABLED_PLUGINS=tags,birthdays
|
||||||
|
*/
|
||||||
|
const raw = import.meta.env.VITE_ENABLED_PLUGINS || 'tags'
|
||||||
|
|
||||||
|
export const ENABLED_PLUGINS = raw
|
||||||
|
.split(',')
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
export const CORE_VERSION = '1.0.0'
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Plugin registry for the Social Graph platform.
|
||||||
|
* Plugins register routes, nav items, form extensions, Dexie upgrades, and sync contributors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const plugins = new Map()
|
||||||
|
const navItems = []
|
||||||
|
const contactFormExtensions = []
|
||||||
|
const graphToolbarActions = []
|
||||||
|
const dexieUpgraders = []
|
||||||
|
const syncContributors = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} PluginDefinition
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} [version]
|
||||||
|
* @property {string} [minCoreVersion]
|
||||||
|
* @property {string[]} [permissions]
|
||||||
|
* @property {import('vue-router').RouteRecordRaw[]} [routes]
|
||||||
|
* @property {object[]} [navItems] - { to, label, icon? }
|
||||||
|
* @property {import('vue').Component[]} [contactFormExtensions]
|
||||||
|
* @property {object[]} [graphToolbarActions] - { id, label, onClick }
|
||||||
|
* @property {(db: import('dexie').Dexie) => void} [upgradeDexie]
|
||||||
|
* @property {object} [syncContributor] - { entityType, push, pull }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {PluginDefinition} definition
|
||||||
|
*/
|
||||||
|
export function registerPlugin(definition) {
|
||||||
|
if (!definition?.id) {
|
||||||
|
throw new Error('Plugin must have an id')
|
||||||
|
}
|
||||||
|
plugins.set(definition.id, definition)
|
||||||
|
|
||||||
|
if (definition.routes?.length) {
|
||||||
|
definition.routes.forEach((route) => {
|
||||||
|
if (!route.meta) route.meta = {}
|
||||||
|
route.meta.pluginId = definition.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (definition.navItems?.length) {
|
||||||
|
navItems.push(...definition.navItems.map((item) => ({ ...item, pluginId: definition.id })))
|
||||||
|
}
|
||||||
|
if (definition.contactFormExtensions?.length) {
|
||||||
|
contactFormExtensions.push(...definition.contactFormExtensions)
|
||||||
|
}
|
||||||
|
if (definition.graphToolbarActions?.length) {
|
||||||
|
graphToolbarActions.push(...definition.graphToolbarActions)
|
||||||
|
}
|
||||||
|
if (typeof definition.upgradeDexie === 'function') {
|
||||||
|
dexieUpgraders.push(definition.upgradeDexie)
|
||||||
|
}
|
||||||
|
if (definition.syncContributor) {
|
||||||
|
syncContributors.push(definition.syncContributor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredPlugins() {
|
||||||
|
return [...plugins.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginRoutes() {
|
||||||
|
return getRegisteredPlugins().flatMap((p) => p.routes || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginNavItems() {
|
||||||
|
return navItems
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContactFormExtensions() {
|
||||||
|
return contactFormExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGraphToolbarActions() {
|
||||||
|
return graphToolbarActions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDexiePluginUpgrades(db) {
|
||||||
|
dexieUpgraders.forEach((fn) => fn(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncContributors() {
|
||||||
|
return syncContributors
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply graph extension hooks from all plugins.
|
||||||
|
* @param {'node'|'edge'|'filters'} kind
|
||||||
|
* @param {*} payload
|
||||||
|
*/
|
||||||
|
export function applyGraphExtensions(kind, payload) {
|
||||||
|
let result = payload
|
||||||
|
for (const plugin of plugins.values()) {
|
||||||
|
const hooks = plugin.graphExtensions
|
||||||
|
if (!hooks) continue
|
||||||
|
if (kind === 'node' && hooks.extendNode) {
|
||||||
|
result = hooks.extendNode(result) || result
|
||||||
|
}
|
||||||
|
if (kind === 'edge' && hooks.extendEdge) {
|
||||||
|
result = hooks.extendEdge(result) || result
|
||||||
|
}
|
||||||
|
if (kind === 'filters' && hooks.extendFilters) {
|
||||||
|
result = hooks.extendFilters(result) || result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { registerPlugin, getPluginNavItems, getRegisteredPlugins } from './pluginRegistry.js'
|
||||||
|
|
||||||
|
describe('pluginRegistry', () => {
|
||||||
|
it('registers nav items from plugin definition', () => {
|
||||||
|
registerPlugin({
|
||||||
|
id: 'test-plugin',
|
||||||
|
navItems: [{ to: '/test', label: 'Test' }],
|
||||||
|
})
|
||||||
|
const items = getPluginNavItems()
|
||||||
|
expect(items.some((i) => i.to === '/test')).toBe(true)
|
||||||
|
expect(getRegisteredPlugins().some((p) => p.id === 'test-plugin')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Network, DataSet } from 'vis-network/standalone'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create vis-network instance with shared defaults.
|
||||||
|
*/
|
||||||
|
export function createVisNetwork(container, nodes, edges, options = {}) {
|
||||||
|
const nodesDS = new DataSet(nodes)
|
||||||
|
const edgesDS = new DataSet(edges)
|
||||||
|
const network = new Network(
|
||||||
|
container,
|
||||||
|
{ nodes: nodesDS, edges: edgesDS },
|
||||||
|
{
|
||||||
|
layout: { improvedLayout: false },
|
||||||
|
interaction: {
|
||||||
|
tooltipDelay: 200,
|
||||||
|
hover: true,
|
||||||
|
hideEdgesOnDrag: true,
|
||||||
|
selectConnectedEdges: false,
|
||||||
|
zoomView: true,
|
||||||
|
dragView: true,
|
||||||
|
dragNodes: true,
|
||||||
|
},
|
||||||
|
edges: { chosen: { label: false } },
|
||||||
|
nodes: { borderWidth: 1.5 },
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return { network, nodesDS, edgesDS }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyVisNetwork(network, resizeObserver = null) {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
network?.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPhysicsOptions(enabled, { fitOnStabilize = true } = {}) {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
solver: 'forceAtlas2Based',
|
||||||
|
forceAtlas2Based: {
|
||||||
|
gravitationalConstant: -120,
|
||||||
|
centralGravity: 0.002,
|
||||||
|
springLength: 200,
|
||||||
|
springConstant: 0.035,
|
||||||
|
damping: 0.5,
|
||||||
|
avoidOverlap: 1,
|
||||||
|
},
|
||||||
|
stabilization: enabled
|
||||||
|
? { iterations: 200, fit: fitOnStabilize, updateInterval: 25 }
|
||||||
|
: undefined,
|
||||||
|
maxVelocity: 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,16 +32,20 @@ export function getNetworkMapMembershipRepository() {
|
|||||||
|
|
||||||
export async function getRelationTypes() {
|
export async function getRelationTypes() {
|
||||||
if (mode() === 'remote') {
|
if (mode() === 'remote') {
|
||||||
const { data } = await api.get('/relation-types/')
|
const { data } = await api.get('/meta/choices/')
|
||||||
return data
|
return data.relation_types
|
||||||
}
|
}
|
||||||
return RELATION_TYPES
|
return RELATION_TYPES
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNetworkMapChoices() {
|
export async function getNetworkMapChoices() {
|
||||||
if (mode() === 'remote') {
|
if (mode() === 'remote') {
|
||||||
const { data } = await api.get('/network-map-choices/')
|
const { data } = await api.get('/meta/choices/')
|
||||||
return data
|
return {
|
||||||
|
life_spheres: data.life_spheres,
|
||||||
|
network_circles: data.network_circles,
|
||||||
|
interaction_intensities: data.interaction_intensities,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
life_spheres: LIFE_SPHERES,
|
life_spheres: LIFE_SPHERES,
|
||||||
@@ -49,3 +53,16 @@ export async function getNetworkMapChoices() {
|
|||||||
interaction_intensities: INTERACTION_INTENSITIES,
|
interaction_intensities: INTERACTION_INTENSITIES,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMetaChoices() {
|
||||||
|
if (mode() === 'remote') {
|
||||||
|
const { data } = await api.get('/meta/choices/')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
relation_types: RELATION_TYPES,
|
||||||
|
life_spheres: LIFE_SPHERES,
|
||||||
|
network_circles: NETWORK_CIRCLES,
|
||||||
|
interaction_intensities: INTERACTION_INTENSITIES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { listPendingChanges, ackChanges } from './changeLogRepository'
|
||||||
|
import { getDataMode } from '../config/dataMode'
|
||||||
|
import { getSyncContributors } from '../../core/pluginRegistry'
|
||||||
|
import { noopSyncAdapter } from './noopSyncAdapter'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid sync orchestrator: pushes core changelog + plugin contributors.
|
||||||
|
*/
|
||||||
|
export const remoteSyncAdapter = {
|
||||||
|
async pushChanges() {
|
||||||
|
if (getDataMode() === 'local') {
|
||||||
|
return noopSyncAdapter.pushChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await listPendingChanges()
|
||||||
|
let pushed = 0
|
||||||
|
|
||||||
|
for (const change of pending) {
|
||||||
|
// Core entity sync will be implemented when remote hybrid mode is enabled.
|
||||||
|
pushed += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const contributor of getSyncContributors()) {
|
||||||
|
if (contributor.pushChanges) {
|
||||||
|
await contributor.pushChanges()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.length) {
|
||||||
|
await ackChanges(pending.map((c) => c.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pushed, pending: Math.max(0, pending.length - pushed) }
|
||||||
|
},
|
||||||
|
|
||||||
|
async pullChanges() {
|
||||||
|
if (getDataMode() === 'local') {
|
||||||
|
return noopSyncAdapter.pullChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
let pulled = 0
|
||||||
|
for (const contributor of getSyncContributors()) {
|
||||||
|
if (contributor.pullChanges) {
|
||||||
|
const result = await contributor.pullChanges()
|
||||||
|
pulled += result?.pulled || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { pulled }
|
||||||
|
},
|
||||||
|
|
||||||
|
async ack() {
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import { getSyncContributors } from '../../core/pluginRegistry'
|
||||||
import { noopSyncAdapter } from './noopSyncAdapter'
|
import { noopSyncAdapter } from './noopSyncAdapter'
|
||||||
|
import { remoteSyncAdapter } from './remoteSyncAdapter'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {{pushChanges: Function, pullChanges: Function, ack: Function}}
|
* @returns {{pushChanges: Function, pullChanges: Function, ack: Function}}
|
||||||
*/
|
*/
|
||||||
export function getSyncAdapter() {
|
export function getSyncAdapter() {
|
||||||
|
const contributors = getSyncContributors()
|
||||||
|
if (!contributors.length) {
|
||||||
return noopSyncAdapter
|
return noopSyncAdapter
|
||||||
|
}
|
||||||
|
return remoteSyncAdapter
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -1,10 +1,21 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import { bootstrapPlugins } from './core/bootstrapPlugins'
|
||||||
|
import { applyDexiePluginUpgrades } from './core/pluginRegistry'
|
||||||
|
import { localDb } from './infrastructure/db/localDb'
|
||||||
|
import { initRouter } from './router'
|
||||||
import './assets/style.css'
|
import './assets/style.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
async function bootstrap() {
|
||||||
app.use(createPinia())
|
await bootstrapPlugins()
|
||||||
app.use(router)
|
applyDexiePluginUpgrades(localDb)
|
||||||
app.mount('#app')
|
const router = await initRouter()
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Template for new frontend plugins.
|
||||||
|
* Copy to frontend/src/plugins/<your-id>/ and register in bootstrapPlugins.js loaders.
|
||||||
|
*/
|
||||||
|
import { registerPlugin } from '../../core/pluginRegistry'
|
||||||
|
|
||||||
|
export function registerMyPlugin() {
|
||||||
|
registerPlugin({
|
||||||
|
id: 'my-plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
minCoreVersion: '1.0.0',
|
||||||
|
permissions: [],
|
||||||
|
routes: [],
|
||||||
|
navItems: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerMyPlugin()
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Теги</label>
|
||||||
|
<input
|
||||||
|
v-model="tagsText"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="коллеги, спорт, семья (через запятую)"
|
||||||
|
@blur="emitTags"
|
||||||
|
/>
|
||||||
|
<p v-if="tags.length" class="text-muted" style="font-size:12px;margin-top:6px;">
|
||||||
|
<span v-for="tag in tags" :key="tag" class="tag-chip">{{ tag }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { tagRepository } from './tagRepository.local'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
contactId: { type: [String, Number], default: null },
|
||||||
|
modelValue: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const tags = ref([...props.modelValue])
|
||||||
|
const tagsText = ref(tags.value.join(', '))
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
if (!props.contactId) return
|
||||||
|
const rows = await tagRepository.listByContact(props.contactId)
|
||||||
|
tags.value = rows.map((r) => r.label)
|
||||||
|
tagsText.value = tags.value.join(', ')
|
||||||
|
emit('update:modelValue', tags.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitTags() {
|
||||||
|
tags.value = tagsText.value
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
emit('update:modelValue', [...new Set(tags.value)])
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.contactId, loadTags, { immediate: true })
|
||||||
|
onMounted(loadTags)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Теги контактов</h2>
|
||||||
|
</div>
|
||||||
|
<div class="page-content content-narrow">
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-muted section-subtitle">
|
||||||
|
Группировка контактов метками. Плагин <strong>tags</strong> (reference implementation).
|
||||||
|
</p>
|
||||||
|
<div v-if="loading" class="spinner"></div>
|
||||||
|
<div v-else-if="!grouped.length" class="empty-state">
|
||||||
|
<p>Тегов пока нет. Добавьте теги в карточке контакта.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="tag-groups">
|
||||||
|
<div v-for="group in grouped" :key="group.label" class="tag-group">
|
||||||
|
<h3>{{ group.label }} <span class="text-muted">({{ group.contacts.length }})</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="name in group.contacts" :key="name">{{ name }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useContactsStore } from '../../stores/contacts'
|
||||||
|
import { tagRepository } from './tagRepository.local'
|
||||||
|
|
||||||
|
const store = useContactsStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const allTags = ref([])
|
||||||
|
|
||||||
|
const grouped = computed(() => {
|
||||||
|
const byLabel = new Map()
|
||||||
|
const contactById = new Map(store.contacts.map((c) => [String(c.id), c.name]))
|
||||||
|
for (const tag of allTags.value) {
|
||||||
|
const name = contactById.get(String(tag.contactId)) || tag.contactId
|
||||||
|
if (!byLabel.has(tag.label)) byLabel.set(tag.label, [])
|
||||||
|
byLabel.get(tag.label).push(name)
|
||||||
|
}
|
||||||
|
return [...byLabel.entries()]
|
||||||
|
.map(([label, contacts]) => ({ label, contacts: contacts.sort((a, b) => String(a).localeCompare(String(b), 'ru')) }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchContacts()
|
||||||
|
allTags.value = await tagRepository.listAll()
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content-narrow { max-width: 640px; }
|
||||||
|
.section-subtitle { margin-bottom: 16px; }
|
||||||
|
.tag-group { margin-bottom: 16px; }
|
||||||
|
.tag-group h3 { font-size: 14px; margin-bottom: 6px; }
|
||||||
|
.tag-group ul { margin: 0; padding-left: 18px; font-size: 13px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { registerPlugin } from '../../core/pluginRegistry'
|
||||||
|
import TagsView from './TagsView.vue'
|
||||||
|
import ContactTagsFieldset from './ContactTagsFieldset.vue'
|
||||||
|
import { tagRepository } from './tagRepository.local'
|
||||||
|
|
||||||
|
registerPlugin({
|
||||||
|
id: 'tags',
|
||||||
|
version: '1.0.0',
|
||||||
|
minCoreVersion: '1.0.0',
|
||||||
|
permissions: ['read:contacts', 'write:contacts'],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/tags',
|
||||||
|
name: 'Tags',
|
||||||
|
component: TagsView,
|
||||||
|
meta: { title: 'Теги' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: '/tags',
|
||||||
|
label: 'Теги',
|
||||||
|
icon: 'tags',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphToolbarActions: [
|
||||||
|
{
|
||||||
|
id: 'open-tags',
|
||||||
|
label: 'Теги',
|
||||||
|
onClick: ({ router }) => router.push('/tags'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contactFormExtensions: [ContactTagsFieldset],
|
||||||
|
upgradeDexie(db) {
|
||||||
|
db.version(3).stores({
|
||||||
|
contacts: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||||
|
relations: 'id, source, target, updatedAt, deletedAt, workspaceId',
|
||||||
|
networkMaps: 'id, name, updatedAt, deletedAt, workspaceId',
|
||||||
|
networkMapMemberships: 'id, mapId, contactId, updatedAt, deletedAt, [mapId+contactId]',
|
||||||
|
meta: 'key',
|
||||||
|
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
|
||||||
|
contactTags: 'id, contactId, label, updatedAt, deletedAt, workspaceId',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
syncContributor: {
|
||||||
|
entityType: 'plugin:tags',
|
||||||
|
async pushChanges() {
|
||||||
|
return { pushed: 0 }
|
||||||
|
},
|
||||||
|
async pullChanges() {
|
||||||
|
return { pulled: 0 }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graphExtensions: {
|
||||||
|
extendNode(node) {
|
||||||
|
return node
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tagRepository,
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { localDb } from '../../infrastructure/db/localDb'
|
||||||
|
import { generateId } from '../../lib/uuid'
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tagRepository = {
|
||||||
|
async listByContact(contactId) {
|
||||||
|
const sid = String(contactId)
|
||||||
|
const all = await localDb.contactTags?.toArray() || []
|
||||||
|
return all.filter((t) => !t.deletedAt && String(t.contactId) === sid)
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAll() {
|
||||||
|
const all = await localDb.contactTags?.toArray() || []
|
||||||
|
return all.filter((t) => !t.deletedAt)
|
||||||
|
},
|
||||||
|
|
||||||
|
async setContactTags(contactId, labels = []) {
|
||||||
|
if (!localDb.contactTags) return []
|
||||||
|
const sid = String(contactId)
|
||||||
|
const ts = nowIso()
|
||||||
|
const normalized = [...new Set(labels.map((l) => String(l).trim()).filter(Boolean))]
|
||||||
|
const existing = await this.listByContact(sid)
|
||||||
|
const existingLabels = new Set(existing.map((t) => t.label))
|
||||||
|
|
||||||
|
for (const tag of existing) {
|
||||||
|
if (!normalized.includes(tag.label)) {
|
||||||
|
await localDb.contactTags.update(tag.id, { deletedAt: ts, updatedAt: ts })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const label of normalized) {
|
||||||
|
if (existingLabels.has(label)) continue
|
||||||
|
await localDb.contactTags.put({
|
||||||
|
id: generateId(),
|
||||||
|
contactId: sid,
|
||||||
|
label,
|
||||||
|
workspaceId: 'personal',
|
||||||
|
version: 1,
|
||||||
|
createdAt: ts,
|
||||||
|
updatedAt: ts,
|
||||||
|
deletedAt: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.listByContact(sid)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { getPluginRoutes } from '../core/pluginRegistry'
|
||||||
|
|
||||||
const routes = [
|
const coreRoutes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/graph',
|
redirect: '/graph',
|
||||||
@@ -37,9 +38,25 @@ const routes = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
let router = null
|
||||||
history: createWebHistory(),
|
|
||||||
routes,
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export function buildRoutes() {
|
||||||
|
return [...coreRoutes, ...getPluginRoutes()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initRouter() {
|
||||||
|
router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: buildRoutes(),
|
||||||
|
})
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
if (!router) {
|
||||||
|
throw new Error('Router not initialized. Call initRouter() after bootstrapPlugins().')
|
||||||
|
}
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getRouter
|
||||||
|
|||||||
@@ -246,9 +246,11 @@ async function loadContact() {
|
|||||||
await mapsStore.fetchContactMemberships(contactId.value)
|
await mapsStore.fetchContactMemberships(contactId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onUpdate(data, mapIds) {
|
async function onUpdate(data, mapIds, pluginPayload) {
|
||||||
await store.updateContact(contactId.value, data)
|
await store.updateContact(contactId.value, data)
|
||||||
await mapsStore.setContactMapMemberships(contactId.value, mapIds)
|
await mapsStore.setContactMapMemberships(contactId.value, mapIds)
|
||||||
|
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||||
|
await saveContactPluginData(contactId.value, pluginPayload)
|
||||||
contact.value = { ...contact.value, ...data }
|
contact.value = { ...contact.value, ...data }
|
||||||
editing.value = false
|
editing.value = false
|
||||||
await mapsStore.fetchContactMemberships(contactId.value)
|
await mapsStore.fetchContactMemberships(contactId.value)
|
||||||
|
|||||||
@@ -281,11 +281,13 @@ function onRelationCreated() {
|
|||||||
|
|
||||||
function goTo(id) { router.push(`/contacts/${id}`) }
|
function goTo(id) { router.push(`/contacts/${id}`) }
|
||||||
|
|
||||||
async function onCreate(data, mapIds) {
|
async function onCreate(data, mapIds, pluginPayload) {
|
||||||
const created = await store.createContact(data)
|
const created = await store.createContact(data)
|
||||||
if (mapIds?.length) {
|
if (mapIds?.length) {
|
||||||
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
await mapsStore.setContactMapMemberships(created.id, mapIds)
|
||||||
}
|
}
|
||||||
|
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||||
|
await saveContactPluginData(created.id, pluginPayload)
|
||||||
showCreate.value = false
|
showCreate.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,9 +295,11 @@ function openEdit(c) {
|
|||||||
editTarget.value = { ...c }
|
editTarget.value = { ...c }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onUpdate(data, mapIds) {
|
async function onUpdate(data, mapIds, pluginPayload) {
|
||||||
await store.updateContact(editTarget.value.id, data)
|
await store.updateContact(editTarget.value.id, data)
|
||||||
await mapsStore.setContactMapMemberships(editTarget.value.id, mapIds)
|
await mapsStore.setContactMapMemberships(editTarget.value.id, mapIds)
|
||||||
|
const { saveContactPluginData } = await import('../application/services/contactPluginService')
|
||||||
|
await saveContactPluginData(editTarget.value.id, pluginPayload)
|
||||||
editTarget.value = null
|
editTarget.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,17 @@
|
|||||||
:active-values="activeFilters"
|
:active-values="activeFilters"
|
||||||
@toggle="toggleFilter"
|
@toggle="toggleFilter"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="graphToolbarActions.length" class="graph-plugin-actions">
|
||||||
|
<button
|
||||||
|
v-for="action in graphToolbarActions"
|
||||||
|
:key="action.id"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
@click="runGraphToolbarAction(action)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="graph-area" ref="graphArea">
|
<div class="graph-area" ref="graphArea">
|
||||||
@@ -150,7 +161,7 @@
|
|||||||
defineOptions({ name: 'Graph' })
|
defineOptions({ name: 'Graph' })
|
||||||
|
|
||||||
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { Network, DataSet } from 'vis-network/standalone'
|
import { Network, DataSet } from 'vis-network/standalone'
|
||||||
import { useContactsStore } from '../stores/contacts'
|
import { useContactsStore } from '../stores/contacts'
|
||||||
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
|
||||||
@@ -168,6 +179,7 @@ import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
|
|||||||
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
|
||||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||||
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
|
||||||
|
import { getGraphToolbarActions } from '../core/pluginRegistry'
|
||||||
|
|
||||||
let themeObserver = null
|
let themeObserver = null
|
||||||
let detachContextHandler = null
|
let detachContextHandler = null
|
||||||
@@ -191,6 +203,8 @@ function openNodeInfo(node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useContactsStore()
|
const store = useContactsStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const graphToolbarActions = getGraphToolbarActions()
|
||||||
const graphArea = ref(null)
|
const graphArea = ref(null)
|
||||||
const graphContainer = ref(null)
|
const graphContainer = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -774,6 +788,10 @@ function toggleChrome() {
|
|||||||
nextTick(() => network.value?.redraw())
|
nextTick(() => network.value?.redraw())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runGraphToolbarAction(action) {
|
||||||
|
action.onClick?.({ router })
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => store.dataRevision, async (revision) => {
|
watch(() => store.dataRevision, async (revision) => {
|
||||||
if (!network.value || revision === syncedRevision) return
|
if (!network.value || revision === syncedRevision) return
|
||||||
await applyGraphDataFromStore()
|
await applyGraphDataFromStore()
|
||||||
@@ -832,6 +850,11 @@ onUnmounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 28px 10px;
|
padding: 0 28px 10px;
|
||||||
}
|
}
|
||||||
|
.graph-plugin-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
.graph-chrome-bar {
|
.graph-chrome-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user