From cafc7335adbd8c8d70683e4b085e074399d6cc64 Mon Sep 17 00:00:00 2001 From: gitrusprus Date: Thu, 25 Jun 2026 09:35:51 +0300 Subject: [PATCH] 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 --- README.md | 16 +- backend/api/__init__.py | 1 + backend/api/v1/__init__.py | 1 + backend/api/v1/urls.py | 32 ++ backend/config/settings.py | 35 +- backend/config/urls.py | 5 + backend/conftest.py | 26 ++ backend/contacts/models.py | 37 +- backend/contacts/urls.py | 30 +- backend/contacts/views.py | 377 +----------------- backend/core/__init__.py | 1 + backend/core/apps.py | 6 + backend/core/auth_urls.py | 15 + backend/core/choices.py | 40 ++ backend/core/urls.py | 9 + backend/core/views.py | 30 ++ backend/graph/__init__.py | 1 + backend/graph/apps.py | 6 + backend/graph/services.py | 66 +++ backend/graph/urls.py | 8 + backend/graph/views.py | 15 + backend/import_export/__init__.py | 1 + backend/import_export/apps.py | 6 + backend/import_export/services.py | 231 +++++++++++ backend/import_export/urls.py | 7 + backend/import_export/views.py | 19 + backend/plugins/__init__.py | 1 + backend/plugins/apps.py | 9 + backend/plugins/base.py | 48 +++ backend/plugins/registry.py | 6 + backend/plugins/urls.py | 9 + backend/plugins/views.py | 19 + backend/plugins_tags/__init__.py | 1 + backend/plugins_tags/apps.py | 6 + .../plugins_tags/migrations/0001_initial.py | 30 ++ backend/plugins_tags/migrations/__init__.py | 1 + backend/plugins_tags/models.py | 26 ++ backend/plugins_tags/plugin.py | 15 + backend/plugins_tags/serializers.py | 12 + backend/plugins_tags/urls.py | 9 + backend/plugins_tags/views.py | 22 + backend/pyproject.toml | 41 ++ backend/pytest.ini | 3 + backend/requirements.txt | 4 + backend/tests/__init__.py | 1 + backend/tests/test_contacts_api.py | 24 ++ backend/tests/test_graph_api.py | 22 + backend/tests/test_meta_api.py | 20 + backend/tests/test_plugins_api.py | 27 ++ docs/EXTERNAL_PLUGINS.md | 50 +++ docs/PLUGIN_AUTHOR_GUIDE.md | 63 +++ frontend/.env.example | 2 + frontend/src/App.vue | 15 + .../services/contactPluginService.js | 13 + .../application/services/graphDataService.js | 57 +++ .../services/graphDataService.test.js | 26 ++ frontend/src/components/ContactForm.test.js | 7 +- frontend/src/components/ContactForm.vue | 14 +- frontend/src/composables/useGraphData.js | 37 +- frontend/src/core/bootstrapPlugins.js | 19 + frontend/src/core/config/enabledPlugins.js | 12 + frontend/src/core/pluginRegistry.js | 108 +++++ frontend/src/core/pluginRegistry.test.js | 14 + .../graph/composables/useVisNetwork.js | 53 +++ .../repositories/repositoryFactory.js | 25 +- .../infrastructure/sync/remoteSyncAdapter.js | 54 +++ .../src/infrastructure/sync/syncAdapter.js | 8 +- frontend/src/main.js | 21 +- frontend/src/plugins/_template/index.js | 18 + .../src/plugins/tags/ContactTagsFieldset.vue | 60 +++ frontend/src/plugins/tags/TagsView.vue | 63 +++ frontend/src/plugins/tags/index.js | 60 +++ .../src/plugins/tags/tagRepository.local.js | 49 +++ frontend/src/router/index.js | 29 +- frontend/src/views/ContactDetailView.vue | 4 +- frontend/src/views/ContactsView.vue | 8 +- frontend/src/views/GraphView.vue | 25 +- 77 files changed, 1801 insertions(+), 490 deletions(-) create mode 100644 backend/api/__init__.py create mode 100644 backend/api/v1/__init__.py create mode 100644 backend/api/v1/urls.py create mode 100644 backend/conftest.py create mode 100644 backend/core/__init__.py create mode 100644 backend/core/apps.py create mode 100644 backend/core/auth_urls.py create mode 100644 backend/core/choices.py create mode 100644 backend/core/urls.py create mode 100644 backend/core/views.py create mode 100644 backend/graph/__init__.py create mode 100644 backend/graph/apps.py create mode 100644 backend/graph/services.py create mode 100644 backend/graph/urls.py create mode 100644 backend/graph/views.py create mode 100644 backend/import_export/__init__.py create mode 100644 backend/import_export/apps.py create mode 100644 backend/import_export/services.py create mode 100644 backend/import_export/urls.py create mode 100644 backend/import_export/views.py create mode 100644 backend/plugins/__init__.py create mode 100644 backend/plugins/apps.py create mode 100644 backend/plugins/base.py create mode 100644 backend/plugins/registry.py create mode 100644 backend/plugins/urls.py create mode 100644 backend/plugins/views.py create mode 100644 backend/plugins_tags/__init__.py create mode 100644 backend/plugins_tags/apps.py create mode 100644 backend/plugins_tags/migrations/0001_initial.py create mode 100644 backend/plugins_tags/migrations/__init__.py create mode 100644 backend/plugins_tags/models.py create mode 100644 backend/plugins_tags/plugin.py create mode 100644 backend/plugins_tags/serializers.py create mode 100644 backend/plugins_tags/urls.py create mode 100644 backend/plugins_tags/views.py create mode 100644 backend/pyproject.toml create mode 100644 backend/pytest.ini create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_contacts_api.py create mode 100644 backend/tests/test_graph_api.py create mode 100644 backend/tests/test_meta_api.py create mode 100644 backend/tests/test_plugins_api.py create mode 100644 docs/EXTERNAL_PLUGINS.md create mode 100644 docs/PLUGIN_AUTHOR_GUIDE.md create mode 100644 frontend/.env.example create mode 100644 frontend/src/application/services/contactPluginService.js create mode 100644 frontend/src/application/services/graphDataService.js create mode 100644 frontend/src/application/services/graphDataService.test.js create mode 100644 frontend/src/core/bootstrapPlugins.js create mode 100644 frontend/src/core/config/enabledPlugins.js create mode 100644 frontend/src/core/pluginRegistry.js create mode 100644 frontend/src/core/pluginRegistry.test.js create mode 100644 frontend/src/features/graph/composables/useVisNetwork.js create mode 100644 frontend/src/infrastructure/sync/remoteSyncAdapter.js create mode 100644 frontend/src/plugins/_template/index.js create mode 100644 frontend/src/plugins/tags/ContactTagsFieldset.vue create mode 100644 frontend/src/plugins/tags/TagsView.vue create mode 100644 frontend/src/plugins/tags/index.js create mode 100644 frontend/src/plugins/tags/tagRepository.local.js diff --git a/README.md b/README.md index 03b5ec7..d456324 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,25 @@ docker compose up --build ## Архитектура frontend ```text -views / components +views / features / components ↓ Pinia store (orchestration) ↓ -application/usecases +application/usecases + application/services ↓ 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 @@ -92,6 +100,8 @@ social-graph/ | GET | `/api/network-map-graph/` | Граф карты сети | | GET | `/api/relation-types/` | Типы связей | | GET | `/api/network-map-choices/` | Справочники карты | +| GET | `/api/v1/meta/choices/` | Все справочники (типы связей, сферы, круги) | +| GET | `/api/v1/plugins/` | Манифест включённых плагинов | | POST | `/api/import/` | Импорт CSV/JSON/vCard (legacy) | В режиме `local` импорт и бэкап выполняются в браузере (экран **Импорт**). diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/api/v1/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py new file mode 100644 index 0000000..ca7918a --- /dev/null +++ b/backend/api/v1/urls.py @@ -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//memberships/', + contact_views.NetworkMapMembershipViewSet.as_view({'get': 'list', 'post': 'create'}), + name='network-map-memberships-list', + ), + path( + 'network-maps//memberships//', + 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')), +] diff --git a/backend/config/settings.py b/backend/config/settings.py index 5e2d4dc..4425f05 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -12,13 +12,25 @@ ALLOWED_HOSTS = [ h.strip() for h in os.environ.get('ALLOWED_HOSTS', '*').split(',') if h.strip() ] +ENABLED_PLUGINS = [ + p.strip() + for p in os.environ.get('ENABLED_PLUGINS', 'tags').split(',') + if p.strip() +] + INSTALLED_APPS = [ 'django.contrib.contenttypes', 'django.contrib.auth', 'django.contrib.staticfiles', 'rest_framework', 'corsheaders', + 'drf_spectacular', + 'core', 'contacts', + 'graph', + 'import_export', + 'plugins', + 'plugins_tags', ] MIDDLEWARE = [ @@ -41,11 +53,30 @@ STATIC_ROOT = BASE_DIR / 'staticfiles' REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 100, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Social Graph API', + 'DESCRIPTION': 'API for Social Graph Builder (local-first with optional remote backend)', + 'VERSION': '1.0.0', } CORS_ALLOW_ALL_ORIGINS = True -# Лимит тела запроса для импорта больших JSON (например, экспорт Monica с сотнями контактов) -DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024 # 20 MB +DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Phase C: optional JWT auth (disabled by default) +USE_JWT_AUTH = os.environ.get('USE_JWT_AUTH', 'false').lower() in ('1', 'true', 'yes') + +if USE_JWT_AUTH: + REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ] + REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = [ + 'rest_framework.permissions.IsAuthenticated', + ] + +DEFAULT_WORKSPACE_ID = os.environ.get('DEFAULT_WORKSPACE_ID', 'personal') diff --git a/backend/config/urls.py b/backend/config/urls.py index 51f4042..5a17330 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -1,5 +1,10 @@ from django.urls import path, include +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns = [ + path('api/v1/', include('api.v1.urls')), path('api/', include('contacts.urls')), + path('api/v1/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/v1/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/v1/', include('core.auth_urls')), ] diff --git a/backend/conftest.py b/backend/conftest.py new file mode 100644 index 0000000..03d0a46 --- /dev/null +++ b/backend/conftest.py @@ -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') diff --git a/backend/contacts/models.py b/backend/contacts/models.py index 588aaef..2dfaadf 100644 --- a/backend/contacts/models.py +++ b/backend/contacts/models.py @@ -1,27 +1,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models - -LIFE_SPHERES = [ - ('work', 'Работа'), - ('study', 'Учёба'), - ('hobby', 'Хобби'), - ('family', 'Семья'), - ('health', 'Здоровье'), - ('other', 'Другое'), -] - -NETWORK_CIRCLES = [ - ('support', 'Круг поддержки'), - ('productivity', 'Круг продуктивности'), - ('development', 'Круг развития'), -] - -INTERACTION_INTENSITY = [ - ('intense', 'Интенсивные контакты'), - ('periodic', 'Периодические контакты'), - ('sparse', 'Редкие контакты'), -] +from core.choices import ( + LIFE_SPHERES, + NETWORK_CIRCLES, + INTERACTION_INTENSITY, + RELATION_TYPES, +) class Contact(models.Model): @@ -113,16 +98,6 @@ class NetworkMapMembership(models.Model): return f'{self.contact} на {self.map}' -RELATION_TYPES = [ - ('colleague', 'Коллега'), - ('friend', 'Друг'), - ('family', 'Родственник'), - ('acquaintance', 'Знакомый'), - ('business', 'Деловой партнёр'), - ('other', 'Другое'), -] - - class Relation(models.Model): """Связь между двумя контактами.""" diff --git a/backend/contacts/urls.py b/backend/contacts/urls.py index 66d789a..3900a90 100644 --- a/backend/contacts/urls.py +++ b/backend/contacts/urls.py @@ -1,32 +1,6 @@ +"""Legacy /api/ routes — mirrors v1 for backward compatibility.""" from django.urls import path, include -from rest_framework.routers import DefaultRouter -from . import views - -router = DefaultRouter() -router.register('contacts', views.ContactViewSet) -router.register('relations', views.RelationViewSet) -router.register('network-maps', views.NetworkMapViewSet) urlpatterns = [ - path('', include(router.urls)), - path( - 'network-maps//memberships/', - views.NetworkMapMembershipViewSet.as_view({'get': 'list', 'post': 'create'}), - name='network-map-memberships-list', - ), - path( - 'network-maps//memberships//', - views.NetworkMapMembershipViewSet.as_view({ - 'get': 'retrieve', - 'patch': 'partial_update', - 'put': 'update', - 'delete': 'destroy', - }), - name='network-map-memberships-detail', - ), - path('graph/', views.graph_data), - path('network-map-graph/', views.network_map_graph), - path('relation-types/', views.relation_types), - path('network-map-choices/', views.network_map_choices), - path('import/', views.import_contacts), + path('', include('api.v1.urls')), ] diff --git a/backend/contacts/views.py b/backend/contacts/views.py index 3b9f555..1a93c6b 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -1,21 +1,6 @@ -import csv -import io -import json +from rest_framework import viewsets -from rest_framework import viewsets, status -from rest_framework.decorators import api_view -from rest_framework.response import Response - -from .models import ( - Contact, - Relation, - NetworkMap, - NetworkMapMembership, - RELATION_TYPES, - LIFE_SPHERES, - NETWORK_CIRCLES, - INTERACTION_INTENSITY, -) +from .models import Contact, Relation, NetworkMap, NetworkMapMembership from .serializers import ( ContactSerializer, RelationSerializer, @@ -58,361 +43,3 @@ class NetworkMapMembershipViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): map_id = self.kwargs.get('map_pk') serializer.save(map_id=map_id) - - -def _node_from_membership(membership): - contact = membership.contact - return { - 'id': contact.id, - 'label': contact.name, - 'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])), - 'group': contact.organization or 'default', - 'life_sphere': membership.life_sphere, - 'network_circle': membership.network_circle, - 'importance': membership.importance, - 'map_angle': membership.map_angle, - 'map_radius_ratio': membership.map_radius_ratio, - 'membership_id': membership.id, - } - - -def _edge_from_relation(r): - return { - 'id': r.id, - 'from': r.source_id, - 'to': r.target_id, - 'label': r.get_relation_type_display(), - 'title': r.description or r.get_relation_type_display(), - 'relation_type': r.relation_type, - 'interaction_intensity': r.interaction_intensity, - } - - -@api_view(['GET']) -def graph_data(request): - """Возвращает граф: nodes + edges для vis.js.""" - contacts = Contact.objects.all() - nodes = [ - { - 'id': c.id, - 'label': c.name, - 'title': '\n'.join(filter(None, [c.organization, c.position, c.email])), - 'group': c.organization or 'default', - } - for c in contacts - ] - relations = Relation.objects.select_related('source', 'target').all() - edges = [_edge_from_relation(r) for r in relations] - return Response({'nodes': nodes, 'edges': edges}) - - -@api_view(['GET']) -def network_map_graph(request): - """Граф для конкретной карты сети: участники и связи между ними.""" - map_id = request.query_params.get('map_id') - if not map_id: - default_map = NetworkMap.objects.order_by('id').first() - if not default_map: - return Response({'nodes': [], 'edges': []}) - map_id = default_map.id - - memberships = list( - NetworkMapMembership.objects.filter(map_id=map_id) - .select_related('contact') - .order_by('contact__name') - ) - allowed_ids = {m.contact_id for m in memberships} - nodes = [_node_from_membership(m) for m in memberships] - relations = Relation.objects.select_related('source', 'target').all() - edges = [ - _edge_from_relation(r) - for r in relations - if r.source_id in allowed_ids and r.target_id in allowed_ids - ] - return Response({'nodes': nodes, 'edges': edges}) - - -@api_view(['GET']) -def relation_types(request): - """Список допустимых типов связей.""" - return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES]) - - -@api_view(['GET']) -def network_map_choices(request): - """Подписи для карты сети: сферы, круги, интенсивность связей.""" - return Response({ - 'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES], - 'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES], - 'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY], - }) - - -def _monica_contact_fields(contact_data): - """Из вложенного data контакта Monica (экспорт account.data) извлекает телефон, email, заметки.""" - phone = '' - email = '' - notes_parts = [] - for block in contact_data or []: - if block.get('type') == 'contact_field': - for val in block.get('values') or []: - props = val.get('properties') or {} - value = str(props.get('data') or '').strip() - if not value: - continue - if '@' in value and '.' in value: - email = email or value - else: - phone = phone or value - elif block.get('type') == 'note': - for val in block.get('values') or []: - body = str((val.get('properties') or {}).get('body') or '').strip() - if body: - notes_parts.append(body) - return phone, email, '\n'.join(notes_parts) - - -def _normalize_rows(data): - """ - Нормализует различные форматы JSON в плоский список словарей. - - Поддерживает: - - Плоский массив: [{"name": ...}, ...] - - Monica CRM (экспорт): {"account": {"data": [{"type": "contact", "values": [...]}]}} - - Monica CRM (старый): {"contacts": [{"first_name": ..., "last_name": ...}, ...]} - - Обёртка results: {"results": [...]} - - Обёртка data: {"data": [...]} - """ - if isinstance(data, list): - return data - if isinstance(data, dict): - # Monica CRM полный экспорт: account.data, блоки type=contact, values[].properties + data - account = data.get('account') - if isinstance(account, dict): - account_data = account.get('data') - if isinstance(account_data, list): - rows = [] - for block in account_data: - if block.get('type') != 'contact': - continue - for c in block.get('values') or []: - props = c.get('properties') or {} - first = str(props.get('first_name') or '').strip() - last = str(props.get('last_name') or '').strip() - middle = str(props.get('middle_name') or '').strip() - name = ' '.join(filter(None, [first, middle, last])) or ' '.join( - filter(None, [first, last]) - ) - if not name: - continue - phone, email, notes = _monica_contact_fields(c.get('data')) - rows.append({ - 'name': name, - 'email': email, - 'phone': phone, - 'organization': '', - 'position': '', - 'notes': notes, - }) - if rows: - return rows - # Monica CRM: ключ "contacts" с first_name/last_name (старый формат API) - if 'contacts' in data: - rows = [] - for c in data['contacts']: - first = str(c.get('first_name') or '').strip() - last = str(c.get('last_name') or '').strip() - name = ' '.join(filter(None, [first, last])) - # Телефоны Monica хранятся в списке phone_numbers - phone = '' - for ph in c.get('phone_numbers') or []: - phone = str(ph.get('number') or ph.get('content') or '') - if phone: - break - # Email Monica — список emails - email = '' - for em in c.get('emails') or []: - email = str(em.get('email') or em.get('content') or '') - if email: - break - # Организации Monica — список companies - org = '' - position = '' - for comp in c.get('companies') or []: - org = str(comp.get('name') or comp.get('company_name') or '') - position = str(comp.get('job') or comp.get('position') or comp.get('title') or '') - if org: - break - # Также бывает прямое поле company - if not org: - org = str(c.get('company') or c.get('company_name') or '').strip() - position = str(c.get('job') or c.get('position') or '').strip() - rows.append({ - 'name': name, - 'email': email, - 'phone': phone, - 'organization': org, - 'position': position, - 'notes': str(c.get('information') or c.get('description') or c.get('notes') or '').strip(), - }) - return rows - # Другие обёртки - for key in ('results', 'data', 'items', 'people', 'persons'): - if key in data and isinstance(data[key], list): - return data[key] - return [] - - -def _unfold_vcard_lines(text): - lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n') - unfolded = [] - for line in lines: - if line.startswith((' ', '\t')) and unfolded: - unfolded[-1] += line[1:] - else: - unfolded.append(line) - return unfolded - - -def _unescape_vcard_value(value): - return ( - str(value or '') - .replace('\\n', '\n') - .replace('\\N', '\n') - .replace('\\,', ',') - .replace('\\;', ';') - .replace('\\\\', '\\') - .strip() - ) - - -def _name_from_vcard_n(value): - parts = _unescape_vcard_value(value).split(';') - family = (parts[0] if len(parts) > 0 else '').strip() - given = (parts[1] if len(parts) > 1 else '').strip() - additional = (parts[2] if len(parts) > 2 else '').strip() - return ' '.join(filter(None, [given, additional, family])).strip() - - -def _parse_vcf_rows(text): - props = None - rows = [] - for line in _unfold_vcard_lines(text): - trimmed = line.strip() - if not trimmed: - continue - upper = trimmed.upper() - if upper == 'BEGIN:VCARD': - props = {} - continue - if upper == 'END:VCARD': - if props: - name = ( - (props.get('FN') or [''])[0] - or _name_from_vcard_n((props.get('N') or [''])[0]) - ).strip() - org_raw = (props.get('ORG') or [''])[0] - org = org_raw.split(';')[0].strip() if org_raw else '' - email = (props.get('EMAIL') or [''])[0].replace('mailto:', '').strip() - phone = (props.get('TEL') or [''])[0].replace('tel:', '').strip() - rows.append({ - 'name': name, - 'email': email, - 'phone': phone, - 'organization': org, - 'position': (props.get('TITLE') or [''])[0].strip(), - 'notes': '\n'.join(props.get('NOTE') or []).strip(), - }) - props = None - continue - if props is None: - continue - if ':' not in trimmed: - continue - raw_key, value = trimmed.split(':', 1) - key = raw_key.split(';')[0].upper() - decoded = _unescape_vcard_value(value.replace('mailto:', '').replace('tel:', '')) - props.setdefault(key, []).append(decoded) - return rows - - -@api_view(['POST']) -def import_contacts(request): - """ - Импорт контактов из CSV, JSON или vCard (.vcf). - - CSV: name,email,phone,organization,position,notes - JSON (плоский): [{"name": "...", ...}, ...] - JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]} - vCard: экспорт из телефона / Google Contacts (.vcf) - """ - file = request.FILES.get('file') - if not file: - return Response({'error': 'Файл не передан.'}, status=status.HTTP_400_BAD_REQUEST) - - filename = file.name.lower() - created = 0 - skipped = 0 - errors = [] - - try: - if filename.endswith('.csv'): - text = file.read().decode('utf-8-sig') - reader = csv.DictReader(io.StringIO(text)) - rows = list(reader) - elif filename.endswith('.json'): - raw = json.loads(file.read().decode('utf-8')) - rows = _normalize_rows(raw) - if not rows: - return Response( - {'error': 'Не удалось распознать формат JSON. Ожидается массив контактов или экспорт Monica CRM.'}, - status=status.HTTP_400_BAD_REQUEST, - ) - elif filename.endswith('.vcf') or filename.endswith('.vcard'): - text = file.read().decode('utf-8-sig') - rows = _parse_vcf_rows(text) - if not rows: - return Response( - {'error': 'В файле vCard не найдено контактов.'}, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - return Response( - {'error': 'Поддерживаются только CSV, JSON и vCard (.vcf) файлы.'}, - status=status.HTTP_400_BAD_REQUEST, - ) - except Exception as e: - return Response({'error': f'Ошибка разбора файла: {e}'}, status=status.HTTP_400_BAD_REQUEST) - - total_rows = len(rows) - for i, row in enumerate(rows): - name = str( - row.get('name') or row.get('Name') or row.get('ФИО') or - ' '.join(filter(None, [ - str(row.get('first_name') or '').strip(), - str(row.get('last_name') or '').strip(), - ])) - ).strip() - if not name: - errors.append(f'Строка {i + 1}: отсутствует поле "name"') - skipped += 1 - continue - - Contact.objects.get_or_create( - name=name, - defaults={ - 'email': str(row.get('email') or '').strip(), - 'phone': str(row.get('phone') or '').strip(), - 'organization': str(row.get('organization') or '').strip(), - 'position': str(row.get('position') or '').strip(), - 'notes': str(row.get('notes') or '').strip(), - }, - ) - created += 1 - - return Response({ - 'total': total_rows, - 'created': created, - 'skipped': skipped, - 'errors': errors, - }) diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/core/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/core/apps.py b/backend/core/apps.py new file mode 100644 index 0000000..8115ae6 --- /dev/null +++ b/backend/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/backend/core/auth_urls.py b/backend/core/auth_urls.py new file mode 100644 index 0000000..6d30fb3 --- /dev/null +++ b/backend/core/auth_urls.py @@ -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 diff --git a/backend/core/choices.py b/backend/core/choices.py new file mode 100644 index 0000000..512468b --- /dev/null +++ b/backend/core/choices.py @@ -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], + } diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..750891d --- /dev/null +++ b/backend/core/urls.py @@ -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'), +] diff --git a/backend/core/views.py b/backend/core/views.py new file mode 100644 index 0000000..56c6317 --- /dev/null +++ b/backend/core/views.py @@ -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], + }) diff --git a/backend/graph/__init__.py b/backend/graph/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/graph/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/graph/apps.py b/backend/graph/apps.py new file mode 100644 index 0000000..7012829 --- /dev/null +++ b/backend/graph/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GraphConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'graph' diff --git a/backend/graph/services.py b/backend/graph/services.py new file mode 100644 index 0000000..b224db8 --- /dev/null +++ b/backend/graph/services.py @@ -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} diff --git a/backend/graph/urls.py b/backend/graph/urls.py new file mode 100644 index 0000000..e2d7ea3 --- /dev/null +++ b/backend/graph/urls.py @@ -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'), +] diff --git a/backend/graph/views.py b/backend/graph/views.py new file mode 100644 index 0000000..0b788a8 --- /dev/null +++ b/backend/graph/views.py @@ -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)) diff --git a/backend/import_export/__init__.py b/backend/import_export/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/import_export/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/import_export/apps.py b/backend/import_export/apps.py new file mode 100644 index 0000000..167a447 --- /dev/null +++ b/backend/import_export/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ImportExportConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'import_export' diff --git a/backend/import_export/services.py b/backend/import_export/services.py new file mode 100644 index 0000000..2cd48ff --- /dev/null +++ b/backend/import_export/services.py @@ -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, + } diff --git a/backend/import_export/urls.py b/backend/import_export/urls.py new file mode 100644 index 0000000..8858e51 --- /dev/null +++ b/backend/import_export/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('import/', views.import_contacts, name='import-contacts'), +] diff --git a/backend/import_export/views.py b/backend/import_export/views.py new file mode 100644 index 0000000..c5b869c --- /dev/null +++ b/backend/import_export/views.py @@ -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) diff --git a/backend/plugins/__init__.py b/backend/plugins/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/plugins/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/plugins/apps.py b/backend/plugins/apps.py new file mode 100644 index 0000000..d66852f --- /dev/null +++ b/backend/plugins/apps.py @@ -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 diff --git a/backend/plugins/base.py b/backend/plugins/base.py new file mode 100644 index 0000000..ce1ee89 --- /dev/null +++ b/backend/plugins/base.py @@ -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 diff --git a/backend/plugins/registry.py b/backend/plugins/registry.py new file mode 100644 index 0000000..4872e90 --- /dev/null +++ b/backend/plugins/registry.py @@ -0,0 +1,6 @@ +"""Register built-in plugins.""" + +from plugins.base import register_plugin +from plugins_tags.plugin import TagsPlugin + +register_plugin(TagsPlugin()) diff --git a/backend/plugins/urls.py b/backend/plugins/urls.py new file mode 100644 index 0000000..e87c416 --- /dev/null +++ b/backend/plugins/urls.py @@ -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(), +] diff --git a/backend/plugins/views.py b/backend/plugins/views.py new file mode 100644 index 0000000..d52b24f --- /dev/null +++ b/backend/plugins/views.py @@ -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() + ]) diff --git a/backend/plugins_tags/__init__.py b/backend/plugins_tags/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/plugins_tags/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/plugins_tags/apps.py b/backend/plugins_tags/apps.py new file mode 100644 index 0000000..6dee412 --- /dev/null +++ b/backend/plugins_tags/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PluginsTagsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'plugins_tags' diff --git a/backend/plugins_tags/migrations/0001_initial.py b/backend/plugins_tags/migrations/0001_initial.py new file mode 100644 index 0000000..cb742f2 --- /dev/null +++ b/backend/plugins_tags/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/backend/plugins_tags/migrations/__init__.py b/backend/plugins_tags/migrations/__init__.py new file mode 100644 index 0000000..bf1c37a --- /dev/null +++ b/backend/plugins_tags/migrations/__init__.py @@ -0,0 +1 @@ +# Generated migration placeholder - run: python manage.py makemigrations plugins_tags diff --git a/backend/plugins_tags/models.py b/backend/plugins_tags/models.py new file mode 100644 index 0000000..7394e33 --- /dev/null +++ b/backend/plugins_tags/models.py @@ -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}' diff --git a/backend/plugins_tags/plugin.py b/backend/plugins_tags/plugin.py new file mode 100644 index 0000000..a2a624f --- /dev/null +++ b/backend/plugins_tags/plugin.py @@ -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'] diff --git a/backend/plugins_tags/serializers.py b/backend/plugins_tags/serializers.py new file mode 100644 index 0000000..af4b7e6 --- /dev/null +++ b/backend/plugins_tags/serializers.py @@ -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'] diff --git a/backend/plugins_tags/urls.py b/backend/plugins_tags/urls.py new file mode 100644 index 0000000..3a8d2e9 --- /dev/null +++ b/backend/plugins_tags/urls.py @@ -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 diff --git a/backend/plugins_tags/views.py b/backend/plugins_tags/views.py new file mode 100644 index 0000000..e630127 --- /dev/null +++ b/backend/plugins_tags/views.py @@ -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) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..4955737 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..7a4fb9b --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings +python_files = tests.py test_*.py *_tests.py diff --git a/backend/requirements.txt b/backend/requirements.txt index 34b6945..8f13120 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,8 @@ Django==4.2.9 djangorestframework==3.14.0 django-cors-headers==4.3.1 +drf-spectacular==0.27.1 +djangorestframework-simplejwt==5.3.1 Pillow==10.2.0 +pytest==8.0.0 +pytest-django==4.8.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tests/test_contacts_api.py b/backend/tests/test_contacts_api.py new file mode 100644 index 0000000..c3b5df1 --- /dev/null +++ b/backend/tests/test_contacts_api.py @@ -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 diff --git a/backend/tests/test_graph_api.py b/backend/tests/test_graph_api.py new file mode 100644 index 0000000..7daba9d --- /dev/null +++ b/backend/tests/test_graph_api.py @@ -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 diff --git a/backend/tests/test_meta_api.py b/backend/tests/test_meta_api.py new file mode 100644 index 0000000..41eb13f --- /dev/null +++ b/backend/tests/test_meta_api.py @@ -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 diff --git a/backend/tests/test_plugins_api.py b/backend/tests/test_plugins_api.py new file mode 100644 index 0000000..1d385bd --- /dev/null +++ b/backend/tests/test_plugins_api.py @@ -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 diff --git a/docs/EXTERNAL_PLUGINS.md b/docs/EXTERNAL_PLUGINS.md new file mode 100644 index 0000000..0fe1f4d --- /dev/null +++ b/docs/EXTERNAL_PLUGINS.md @@ -0,0 +1,50 @@ +# External Plugins Policy + +## Supported distribution (Phase D) + +### Backend (Python) + +- Package name: `social-graph-plugin-` +- 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-` +- 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` diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md new file mode 100644 index 0000000..26014a0 --- /dev/null +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -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//` 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_/`. +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//...` + +## 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. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..124f71c --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Enabled frontend plugins (comma-separated) +VITE_ENABLED_PLUGINS=tags diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2a2fb0b..7557091 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -48,6 +48,19 @@ Импорт + + + + + + {{ item.label }} + +
@@ -150,7 +161,7 @@ defineOptions({ name: 'Graph' }) 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 { useContactsStore } from '../stores/contacts' import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection' @@ -168,6 +179,7 @@ import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue' import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue' import EditRelationModal from '../components/EditRelationModal.vue' import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js' +import { getGraphToolbarActions } from '../core/pluginRegistry' let themeObserver = null let detachContextHandler = null @@ -191,6 +203,8 @@ function openNodeInfo(node) { } const store = useContactsStore() +const router = useRouter() +const graphToolbarActions = getGraphToolbarActions() const graphArea = ref(null) const graphContainer = ref(null) const loading = ref(true) @@ -774,6 +788,10 @@ function toggleChrome() { nextTick(() => network.value?.redraw()) } +function runGraphToolbarAction(action) { + action.onClick?.({ router }) +} + watch(() => store.dataRevision, async (revision) => { if (!network.value || revision === syncedRevision) return await applyGraphDataFromStore() @@ -832,6 +850,11 @@ onUnmounted(() => { flex-shrink: 0; padding: 0 28px 10px; } +.graph-plugin-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} .graph-chrome-bar { display: flex; justify-content: center;