Compare commits

...
12 Commits
Author SHA1 Message Date
gitrusprusandCursor f6eff89642 Add relations export and remote full backup/restore.
Expose GET/POST /api/v1/export/ for server dumps and add import UI for relations (CSV/JSON) plus remote backup controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:05:13 +03:00
gitrusprusandCursor f5937040fa Split import page into separate local and remote database workflows.
Route IndexedDB and server import/export through dedicated APIs and UI panels so each storage target can be managed independently of the active data mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 15:43:09 +03:00
gitrusprusandCursor 986d36ff51 Improve graph/map UX and move contact editing to dedicated pages.
Add viewport controls, collapsible panels with persisted state, filters modal, and refined context menus; preserve map layout on navigation and let contacts be edited with relations on /contacts/:id instead of list modals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 15:15:45 +03:00
gitrusprusandCursor 10e7d65a00 Fix production auth by proxying /api to backend in remote builds.
Remote frontend nginx now forwards API requests to Django so login and registration work when the host proxy only routes to port 8080.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 09:33:54 +03:00
gitrusprus ce4e9ac5e6 Cleanup: remove tracked pycache and db.sqlite3; add map membership backfill, new components, backups, docs 2026-06-28 22:16:01 +03:00
gitrusprusandCursor 4eb4c145b6 Add JWT auth with per-user data isolation and account settings.
Users can register, log in, and manage profile/password in a personal account page; server data is scoped by owner across contacts, maps, tags, and import.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 21:10:16 +03:00
gitrusprusandCursor 85b8b2e9b8 Add conflictology map mode and fix graph rendering reliability.
Support conflict types, involvement sizing, and map-type toggles while keeping polar sector layout; improve GraphView init retries and edge matching on network maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 11:51:06 +03:00
gitrusprusandCursor e80d0d0e88 Add settings page and configurable network map types.
Let users define sector/circle labels and geometry per map type, choose a type when creating maps, and sync types through local backup and API migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:45:34 +03:00
gitrusprusandCursor cafc7335ad 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>
2026-06-25 09:35:51 +03:00
gitrusprusandCursor b694a596b8 Add graph fullscreen mode, collapsible panels, and relation row styling.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:03:01 +03:00
gitrusprusandCursor 5155b3a37a Add multiple network maps, graph UX improvements, and full backup import.
Support per-map contact membership with scoped graph views, relation editing on edges, layout caching, and automatic detection of full JSON backups so contacts and relations import together.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 17:54:51 +03:00
gitrusprusandCursor 35ec4c81ec Add import/export, graph UX improvements, and contact management fixes.
Support vCard import and multi-format export, bulk delete with reliable local persistence, Ctrl+link relation creation, cluster-colored graph visualization, and right-click context menus for node details.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:36:13 +03:00
213 changed files with 25766 additions and 1315 deletions
+15 -6
View File
@@ -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,13 +100,15 @@ social-graph/
| GET | `/api/network-map-graph/` | Граф карты сети |
| GET | `/api/relation-types/` | Типы связей |
| GET | `/api/network-map-choices/` | Справочники карты |
| POST | `/api/import/` | Импорт CSV/JSON (legacy) |
| GET | `/api/v1/meta/choices/` | Все справочники (типы связей, сферы, круги) |
| GET | `/api/v1/plugins/` | Манифест включённых плагинов |
| POST | `/api/import/` | Импорт CSV/JSON/vCard (legacy) |
В режиме `local` импорт и бэкап выполняются в браузере (экран **Импорт**).
## Импорт и бэкап (local-first)
- **CSV / JSON** — экран «Импорт», парсинг на клиенте.
- **CSV / JSON / vCard (.vcf)** — экран «Импорт», парсинг на клиенте.
- **Экспорт / импорт бэкапа** — JSON или зашифрованный `.sgpkg` (WebCrypto, пароль опционален).
## Production
@@ -153,5 +163,4 @@ docker compose exec frontend npm test -- --run
- [ ] Синхронизация и shared-workspace
- [ ] Поиск по организации и должности
- [ ] История изменений контакта
- [ ] Импорт из vCard (.vcf)
- [ ] Уведомления / дни рождения
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+33
View File
@@ -0,0 +1,33 @@
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)
router.register('network-map-types', contact_views.NetworkMapTypeViewSet)
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')),
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+39 -2
View File
@@ -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,36 @@ 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:
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=12),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
}
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')
+5
View File
@@ -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')),
]
+26
View File
@@ -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')
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
DEFAULT_SECTORS = [
{'key': 'work', 'label': 'Работа'},
{'key': 'study', 'label': 'Учёба'},
{'key': 'hobby', 'label': 'Хобби'},
{'key': 'family', 'label': 'Семья'},
{'key': 'health', 'label': 'Здоровье'},
{'key': 'other', 'label': 'Другое'},
]
DEFAULT_CIRCLES = [
{'key': 'support', 'label': 'Круг поддержки'},
{'key': 'productivity', 'label': 'Круг продуктивности'},
{'key': 'development', 'label': 'Круг развития'},
]
def ensure_user_defaults(user):
"""Создаёт тип карты по умолчанию для нового пользователя."""
from contacts.models import NetworkMapType
if NetworkMapType.objects.filter(owner=user, is_default=True).exists():
return
NetworkMapType.objects.create(
owner=user,
is_default=True,
name='Стандартная',
sectors=DEFAULT_SECTORS,
circles=DEFAULT_CIRCLES,
)
@@ -0,0 +1,62 @@
from contacts.models import Contact, NetworkMap, NetworkMapMembership, Relation
def contact_ids_from_relations(relations_qs):
contact_ids = set()
for relation in relations_qs.only('source_id', 'target_id'):
contact_ids.add(relation.source_id)
contact_ids.add(relation.target_id)
return contact_ids
def backfill_map_memberships_for_owner(owner):
"""Добавляет на первую карту владельца контакты из его связей, если участников ещё нет."""
if owner is None:
maps = NetworkMap.objects.filter(owner__isnull=True).order_by('id')
relations = Relation.objects.filter(owner__isnull=True)
else:
maps = NetworkMap.objects.filter(owner=owner).order_by('id')
relations = Relation.objects.filter(owner=owner)
if not maps.exists():
return 0
if NetworkMapMembership.objects.filter(map__in=maps).exists():
return 0
contact_ids = contact_ids_from_relations(relations)
if not contact_ids:
return 0
if owner is None:
contacts = Contact.objects.filter(id__in=contact_ids, owner__isnull=True)
else:
contacts = Contact.objects.filter(id__in=contact_ids, owner=owner)
target_map = maps.first()
created = 0
for contact in contacts:
_, was_created = NetworkMapMembership.objects.get_or_create(
map=target_map,
contact=contact,
defaults={
'life_sphere': 'other',
'network_circle': 'productivity',
'importance': 3,
'conflict_involvement': 3,
},
)
if was_created:
created += 1
return created
def backfill_all_map_memberships():
from django.contrib.auth import get_user_model
User = get_user_model()
total = 0
total += backfill_map_memberships_for_owner(None)
for user in User.objects.all():
total += backfill_map_memberships_for_owner(user)
return total
+39
View File
@@ -0,0 +1,39 @@
def validate_map_type_payload(sectors, circles):
errors = {}
if not sectors or not isinstance(sectors, list):
errors['sectors'] = 'Нужен хотя бы один сектор.'
if not circles or not isinstance(circles, list):
errors['circles'] = 'Нужен хотя бы один круг.'
if errors:
return errors
sector_keys = []
for i, item in enumerate(sectors):
if not isinstance(item, dict):
errors['sectors'] = f'Сектор {i + 1}: неверный формат.'
break
key = str(item.get('key', '')).strip()
label = str(item.get('label', '')).strip()
if not key or not label:
errors['sectors'] = f'Сектор {i + 1}: укажите ключ и подпись.'
break
sector_keys.append(key)
circle_keys = []
for i, item in enumerate(circles):
if not isinstance(item, dict):
errors['circles'] = f'Круг {i + 1}: неверный формат.'
break
key = str(item.get('key', '')).strip()
label = str(item.get('label', '')).strip()
if not key or not label:
errors['circles'] = f'Круг {i + 1}: укажите ключ и подпись.'
break
circle_keys.append(key)
if len(set(sector_keys)) != len(sector_keys):
errors['sectors'] = 'Ключи секторов должны быть уникальными.'
if len(set(circle_keys)) != len(circle_keys):
errors['circles'] = 'Ключи кругов должны быть уникальными.'
return errors
@@ -0,0 +1,119 @@
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
def migrate_contact_map_data(apps, schema_editor):
Contact = apps.get_model('contacts', 'Contact')
NetworkMap = apps.get_model('contacts', 'NetworkMap')
NetworkMapMembership = apps.get_model('contacts', 'NetworkMapMembership')
default_map, _ = NetworkMap.objects.get_or_create(
name='Основная карта',
defaults={'description': 'Мигрировано из прежней единой карты сети'},
)
for contact in Contact.objects.filter(include_on_network_map=True):
NetworkMapMembership.objects.create(
map=default_map,
contact=contact,
life_sphere=contact.life_sphere,
network_circle=contact.network_circle,
importance=contact.importance,
map_angle=contact.map_angle,
map_radius_ratio=contact.map_radius_ratio,
)
class Migration(migrations.Migration):
dependencies = [
('contacts', '0005_contact_map_angle_contact_map_radius_ratio'),
]
operations = [
migrations.CreateModel(
name='NetworkMap',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Карта сети',
'verbose_name_plural': 'Карты сети',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='NetworkMapMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('life_sphere', models.CharField(
choices=[
('work', 'Работа'), ('study', 'Учёба'), ('hobby', 'Хобби'),
('family', 'Семья'), ('health', 'Здоровье'), ('other', 'Другое'),
],
default='other',
max_length=32,
verbose_name='Сфера жизни',
)),
('network_circle', models.CharField(
choices=[
('support', 'Круг поддержки'),
('productivity', 'Круг продуктивности'),
('development', 'Круг развития'),
],
default='productivity',
max_length=32,
verbose_name='Круг сети',
)),
('importance', models.PositiveSmallIntegerField(
default=3,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
verbose_name='Важность (15)',
)),
('map_angle', models.FloatField(blank=True, null=True, verbose_name='Угол позиции на карте')),
('map_radius_ratio', models.FloatField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(1),
],
verbose_name='Радиус позиции на карте (доля)',
)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('contact', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='map_memberships',
to='contacts.contact',
verbose_name='Контакт',
)),
('map', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='memberships',
to='contacts.networkmap',
verbose_name='Карта',
)),
],
options={
'verbose_name': 'Участие на карте',
'verbose_name_plural': 'Участия на картах',
'unique_together': {('map', 'contact')},
},
),
migrations.RunPython(migrate_contact_map_data, migrations.RunPython.noop),
migrations.RemoveField(model_name='contact', name='include_on_network_map'),
migrations.RemoveField(model_name='contact', name='life_sphere'),
migrations.RemoveField(model_name='contact', name='network_circle'),
migrations.RemoveField(model_name='contact', name='importance'),
migrations.RemoveField(model_name='contact', name='map_angle'),
migrations.RemoveField(model_name='contact', name='map_radius_ratio'),
]
@@ -0,0 +1,92 @@
from django.db import migrations, models
import django.db.models.deletion
DEFAULT_SECTORS = [
{'key': 'work', 'label': 'Работа'},
{'key': 'study', 'label': 'Учёба'},
{'key': 'hobby', 'label': 'Хобби'},
{'key': 'family', 'label': 'Семья'},
{'key': 'health', 'label': 'Здоровье'},
{'key': 'other', 'label': 'Другое'},
]
DEFAULT_CIRCLES = [
{'key': 'support', 'label': 'Круг поддержки'},
{'key': 'productivity', 'label': 'Круг продуктивности'},
{'key': 'development', 'label': 'Круг развития'},
]
def seed_default_map_type(apps, schema_editor):
NetworkMapType = apps.get_model('contacts', 'NetworkMapType')
NetworkMap = apps.get_model('contacts', 'NetworkMap')
default_type, _ = NetworkMapType.objects.get_or_create(
is_default=True,
defaults={
'name': 'Стандартная',
'sectors': DEFAULT_SECTORS,
'circles': DEFAULT_CIRCLES,
},
)
NetworkMap.objects.filter(map_type__isnull=True).update(map_type=default_type)
class Migration(migrations.Migration):
dependencies = [
('contacts', '0006_network_maps'),
]
operations = [
migrations.CreateModel(
name='NetworkMapType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Название типа')),
('sectors', models.JSONField(default=list, verbose_name='Секторы')),
('circles', models.JSONField(default=list, verbose_name='Круги')),
('is_default', models.BooleanField(default=False, verbose_name='Тип по умолчанию')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Тип карты сети',
'verbose_name_plural': 'Типы карт сети',
'ordering': ['name'],
},
),
migrations.AddField(
model_name='networkmap',
name='map_type',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='maps',
to='contacts.networkmaptype',
verbose_name='Тип карты',
),
),
migrations.RunPython(seed_default_map_type, migrations.RunPython.noop),
migrations.AlterField(
model_name='networkmap',
name='map_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='maps',
to='contacts.networkmaptype',
verbose_name='Тип карты',
),
),
migrations.AlterField(
model_name='networkmapmembership',
name='life_sphere',
field=models.CharField(default='other', max_length=32, verbose_name='Сфера жизни'),
),
migrations.AlterField(
model_name='networkmapmembership',
name='network_circle',
field=models.CharField(default='productivity', max_length=32, verbose_name='Круг сети'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.9 on 2026-06-25 02:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contacts', '0007_network_map_types'),
]
operations = [
migrations.AlterField(
model_name='relation',
name='interaction_intensity',
field=models.CharField(choices=[('intense', 'Интенсивные контакты'), ('periodic', 'Периодические контакты'), ('sparse', 'Редкие контакты')], default='intense', max_length=32, verbose_name='Интенсивность общения'),
),
]
@@ -0,0 +1,52 @@
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contacts', '0008_alter_relation_interaction_intensity'),
]
operations = [
migrations.AddField(
model_name='networkmap',
name='conflictology_enabled',
field=models.BooleanField(default=False, verbose_name='Режим конфликтологии'),
),
migrations.AddField(
model_name='networkmap',
name='conflict_subject',
field=models.CharField(blank=True, max_length=255, verbose_name='Предмет конфликта (центр карты)'),
),
migrations.AddField(
model_name='networkmapmembership',
name='conflict_involvement',
field=models.PositiveSmallIntegerField(
default=3,
validators=[MinValueValidator(1), MaxValueValidator(5)],
verbose_name='Вовлечённость в конфликт (1–5)',
),
),
migrations.AlterField(
model_name='relation',
name='relation_type',
field=models.CharField(
choices=[
('colleague', 'Коллега'),
('friend', 'Друг'),
('family', 'Родственник'),
('acquaintance', 'Знакомый'),
('business', 'Деловой партнёр'),
('other', 'Другое'),
('conflict_open', 'Открытый конфликт'),
('conflict_tension', 'Напряжение'),
('conflict_alliance', 'Союз / поддержка'),
('conflict_neutral', 'Нейтральная связь'),
],
default='acquaintance',
max_length=50,
verbose_name='Тип связи',
),
),
]
@@ -0,0 +1,32 @@
from django.db import migrations, models
def migrate_conflictology_to_map_type(apps, schema_editor):
NetworkMap = apps.get_model('contacts', 'NetworkMap')
NetworkMapType = apps.get_model('contacts', 'NetworkMapType')
for network_map in NetworkMap.objects.filter(conflictology_enabled=True).select_related('map_type'):
map_type = network_map.map_type
if map_type and not map_type.conflictology_enabled:
map_type.conflictology_enabled = True
map_type.save(update_fields=['conflictology_enabled'])
class Migration(migrations.Migration):
dependencies = [
('contacts', '0009_conflictology'),
]
operations = [
migrations.AddField(
model_name='networkmaptype',
name='conflictology_enabled',
field=models.BooleanField(default=False, verbose_name='Режим конфликтологии'),
),
migrations.RunPython(migrate_conflictology_to_map_type, migrations.RunPython.noop),
migrations.RemoveField(
model_name='networkmap',
name='conflictology_enabled',
),
]
@@ -0,0 +1,111 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
DEFAULT_SECTORS = [
{'key': 'work', 'label': 'Работа'},
{'key': 'study', 'label': 'Учёба'},
{'key': 'hobby', 'label': 'Хобби'},
{'key': 'family', 'label': 'Семья'},
{'key': 'health', 'label': 'Здоровье'},
{'key': 'other', 'label': 'Другое'},
]
DEFAULT_CIRCLES = [
{'key': 'support', 'label': 'Круг поддержки'},
{'key': 'productivity', 'label': 'Круг продуктивности'},
{'key': 'development', 'label': 'Круг развития'},
]
def assign_legacy_owner(apps, schema_editor):
User = apps.get_model('auth', 'User')
Contact = apps.get_model('contacts', 'Contact')
Relation = apps.get_model('contacts', 'Relation')
NetworkMap = apps.get_model('contacts', 'NetworkMap')
NetworkMapType = apps.get_model('contacts', 'NetworkMapType')
user, created = User.objects.get_or_create(
username='legacy',
defaults={'email': 'legacy@local.invalid', 'password': '!'},
)
Contact.objects.filter(owner__isnull=True).update(owner=user)
Relation.objects.filter(owner__isnull=True).update(owner=user)
NetworkMap.objects.filter(owner__isnull=True).update(owner=user)
NetworkMapType.objects.filter(owner__isnull=True).update(owner=user)
if not NetworkMapType.objects.filter(owner=user, is_default=True).exists():
default = NetworkMapType.objects.filter(is_default=True).first()
if default:
default.owner = user
default.save(update_fields=['owner'])
else:
NetworkMapType.objects.create(
owner=user,
is_default=True,
name='Стандартная',
sectors=DEFAULT_SECTORS,
circles=DEFAULT_CIRCLES,
)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contacts', '0010_conflictology_on_map_type'),
]
operations = [
migrations.AddField(
model_name='contact',
name='owner',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='contacts',
to=settings.AUTH_USER_MODEL,
verbose_name='Владелец',
),
),
migrations.AddField(
model_name='networkmap',
name='owner',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='network_maps',
to=settings.AUTH_USER_MODEL,
verbose_name='Владелец',
),
),
migrations.AddField(
model_name='networkmaptype',
name='owner',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='network_map_types',
to=settings.AUTH_USER_MODEL,
verbose_name='Владелец',
),
),
migrations.AddField(
model_name='relation',
name='owner',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='relations',
to=settings.AUTH_USER_MODEL,
verbose_name='Владелец',
),
),
migrations.RunPython(assign_legacy_owner, migrations.RunPython.noop),
]
@@ -0,0 +1,68 @@
from django.conf import settings
from django.db import migrations
def backfill_memberships(apps, schema_editor):
NetworkMap = apps.get_model('contacts', 'NetworkMap')
NetworkMapMembership = apps.get_model('contacts', 'NetworkMapMembership')
Relation = apps.get_model('contacts', 'Relation')
Contact = apps.get_model('contacts', 'Contact')
User = apps.get_model('auth', 'User')
def backfill_for_owner(owner_id):
if owner_id is None:
maps = NetworkMap.objects.filter(owner__isnull=True).order_by('id')
relations = Relation.objects.filter(owner__isnull=True)
contact_owner_filter = {'owner__isnull': True}
else:
maps = NetworkMap.objects.filter(owner_id=owner_id).order_by('id')
relations = Relation.objects.filter(owner_id=owner_id)
contact_owner_filter = {'owner_id': owner_id}
if not maps.exists():
return 0
if NetworkMapMembership.objects.filter(map__in=maps).exists():
return 0
contact_ids = set()
for relation in relations.only('source_id', 'target_id'):
contact_ids.add(relation.source_id)
contact_ids.add(relation.target_id)
if not contact_ids:
return 0
contacts = Contact.objects.filter(id__in=contact_ids, **contact_owner_filter)
target_map = maps.first()
created = 0
for contact in contacts:
_, was_created = NetworkMapMembership.objects.get_or_create(
map=target_map,
contact=contact,
defaults={
'life_sphere': 'other',
'network_circle': 'productivity',
'importance': 3,
'conflict_involvement': 3,
},
)
if was_created:
created += 1
return created
total = backfill_for_owner(None)
for user in User.objects.all():
total += backfill_for_owner(user.id)
if total:
print(f'Backfilled {total} network map memberships')
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contacts', '0011_add_owner'),
]
operations = [
migrations.RunPython(backfill_memberships, migrations.RunPython.noop),
]
+25
View File
@@ -0,0 +1,25 @@
from rest_framework.exceptions import PermissionDenied
from core.access import use_jwt_auth
class OwnerScopedMixin:
owner_field = 'owner'
def get_queryset(self):
qs = super().get_queryset()
if not use_jwt_auth():
return qs
user = self.request.user
if user and user.is_authenticated:
return qs.filter(**{self.owner_field: user})
return qs.none()
def perform_create(self, serializer):
if use_jwt_auth():
user = self.request.user
if not user or not user.is_authenticated:
raise PermissionDenied()
serializer.save(**{self.owner_field: user})
return
serializer.save()
+141 -57
View File
@@ -1,65 +1,30 @@
from django.conf import settings
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', 'Интенсивные контакты'),
('sparse', 'Редкие контакты'),
]
from core.choices import (
ALL_RELATION_TYPES,
INTERACTION_INTENSITY,
)
class Contact(models.Model):
"""Контакт в социальном графе."""
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='contacts',
null=True,
blank=True,
verbose_name='Владелец',
)
name = models.CharField(max_length=255, verbose_name='Имя')
email = models.EmailField(blank=True, verbose_name='Email')
phone = models.CharField(max_length=50, blank=True, verbose_name='Телефон')
organization = models.CharField(max_length=255, blank=True, verbose_name='Организация')
position = models.CharField(max_length=255, blank=True, verbose_name='Должность')
notes = models.TextField(blank=True, verbose_name='Заметки')
life_sphere = models.CharField(
max_length=32,
choices=LIFE_SPHERES,
default='other',
verbose_name='Сфера жизни',
)
network_circle = models.CharField(
max_length=32,
choices=NETWORK_CIRCLES,
default='productivity',
verbose_name='Круг сети',
)
importance = models.PositiveSmallIntegerField(
default=3,
validators=[MinValueValidator(1), MaxValueValidator(5)],
verbose_name='Важность (15)',
)
include_on_network_map = models.BooleanField(
default=False,
verbose_name='Показывать на карте сети',
)
map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте')
map_radius_ratio = models.FloatField(
null=True,
blank=True,
validators=[MinValueValidator(0), MaxValueValidator(1)],
verbose_name='Радиус позиции на карте (доля)',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -72,19 +37,138 @@ class Contact(models.Model):
return self.name
RELATION_TYPES = [
('colleague', 'Коллега'),
('friend', 'Друг'),
('family', 'Родственник'),
('acquaintance', 'Знакомый'),
('business', 'Деловой партнёр'),
('other', 'Другое'),
]
class NetworkMapType(models.Model):
"""Тип карты сети: настраиваемые секторы и концентрические круги."""
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='network_map_types',
null=True,
blank=True,
verbose_name='Владелец',
)
name = models.CharField(max_length=255, verbose_name='Название типа')
sectors = models.JSONField(default=list, verbose_name='Секторы')
circles = models.JSONField(default=list, verbose_name='Круги')
is_default = models.BooleanField(default=False, verbose_name='Тип по умолчанию')
conflictology_enabled = models.BooleanField(
default=False,
verbose_name='Режим конфликтологии',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
verbose_name = 'Тип карты сети'
verbose_name_plural = 'Типы карт сети'
def __str__(self):
return self.name
class NetworkMap(models.Model):
"""Карта сети — отдельный контекст для визуализации подмножества контактов."""
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='network_maps',
null=True,
blank=True,
verbose_name='Владелец',
)
name = models.CharField(max_length=255, verbose_name='Название')
description = models.TextField(blank=True, verbose_name='Описание')
map_type = models.ForeignKey(
NetworkMapType,
on_delete=models.PROTECT,
related_name='maps',
verbose_name='Тип карты',
)
conflict_subject = models.CharField(
max_length=255,
blank=True,
verbose_name='Предмет конфликта (центр карты)',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
verbose_name = 'Карта сети'
verbose_name_plural = 'Карты сети'
def __str__(self):
return self.name
class NetworkMapMembership(models.Model):
"""Участие контакта на конкретной карте сети."""
map = models.ForeignKey(
NetworkMap,
on_delete=models.CASCADE,
related_name='memberships',
verbose_name='Карта',
)
contact = models.ForeignKey(
Contact,
on_delete=models.CASCADE,
related_name='map_memberships',
verbose_name='Контакт',
)
life_sphere = models.CharField(
max_length=32,
default='other',
verbose_name='Сфера жизни',
)
network_circle = models.CharField(
max_length=32,
default='productivity',
verbose_name='Круг сети',
)
importance = models.PositiveSmallIntegerField(
default=3,
validators=[MinValueValidator(1), MaxValueValidator(5)],
verbose_name='Важность (15)',
)
conflict_involvement = models.PositiveSmallIntegerField(
default=3,
validators=[MinValueValidator(1), MaxValueValidator(5)],
verbose_name='Вовлечённость в конфликт (1–5)',
)
map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте')
map_radius_ratio = models.FloatField(
null=True,
blank=True,
validators=[MinValueValidator(0), MaxValueValidator(1)],
verbose_name='Радиус позиции на карте (доля)',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('map', 'contact')
verbose_name = 'Участие на карте'
verbose_name_plural = 'Участия на картах'
def __str__(self):
return f'{self.contact} на {self.map}'
class Relation(models.Model):
"""Связь между двумя контактами."""
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='relations',
null=True,
blank=True,
verbose_name='Владелец',
)
source = models.ForeignKey(
Contact,
on_delete=models.CASCADE,
@@ -99,7 +183,7 @@ class Relation(models.Model):
)
relation_type = models.CharField(
max_length=50,
choices=RELATION_TYPES,
choices=ALL_RELATION_TYPES,
default='acquaintance',
verbose_name='Тип связи',
)
+104 -8
View File
@@ -1,5 +1,15 @@
from rest_framework import serializers
from .models import Contact, Relation
from core.access import use_jwt_auth
from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType
from .map_type_validation import validate_map_type_payload
def scoped_map_types_queryset(request):
qs = NetworkMapType.objects.all()
if use_jwt_auth() and request and request.user.is_authenticated:
return qs.filter(owner=request.user)
return qs
class ContactSerializer(serializers.ModelSerializer):
@@ -10,9 +20,6 @@ class ContactSerializer(serializers.ModelSerializer):
fields = [
'id', 'name', 'email', 'phone',
'organization', 'position', 'notes',
'life_sphere', 'network_circle', 'importance',
'include_on_network_map',
'map_angle', 'map_radius_ratio',
'created_at', 'updated_at', 'relations_count',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count']
@@ -39,13 +46,105 @@ class RelationSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'created_at', 'source_name', 'target_name']
def validate(self, data):
if data.get('source') == data.get('target'):
request = self.context.get('request')
source = data.get('source') or getattr(self.instance, 'source', None)
target = data.get('target') or getattr(self.instance, 'target', None)
if source and target and source == target:
raise serializers.ValidationError(
'Нельзя создать связь контакта с самим собой.'
)
if use_jwt_auth() and request and request.user.is_authenticated:
user = request.user
for contact in (source, target):
if contact and contact.owner_id != user.id:
raise serializers.ValidationError(
'Контакт не принадлежит текущему пользователю.'
)
return data
class NetworkMapTypeSerializer(serializers.ModelSerializer):
class Meta:
model = NetworkMapType
fields = [
'id', 'name', 'sectors', 'circles', 'is_default', 'conflictology_enabled',
'created_at', 'updated_at',
]
read_only_fields = ['id', 'is_default', 'created_at', 'updated_at']
def validate(self, data):
sectors = data.get('sectors', getattr(self.instance, 'sectors', None))
circles = data.get('circles', getattr(self.instance, 'circles', None))
errors = validate_map_type_payload(sectors, circles)
if errors:
raise serializers.ValidationError(errors)
return data
def _default_exists(self):
request = self.context.get('request')
qs = scoped_map_types_queryset(request)
return qs.filter(is_default=True).exists()
def create(self, validated_data):
if self._default_exists():
validated_data['is_default'] = False
return super().create(validated_data)
class NetworkMapSerializer(serializers.ModelSerializer):
memberships_count = serializers.SerializerMethodField()
map_type = serializers.PrimaryKeyRelatedField(
queryset=NetworkMapType.objects.all(),
required=False,
)
class Meta:
model = NetworkMap
fields = [
'id', 'name', 'description', 'map_type', 'conflict_subject',
'created_at', 'updated_at', 'memberships_count',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get('request')
self.fields['map_type'].queryset = scoped_map_types_queryset(request)
def validate(self, data):
request = self.context.get('request')
if not data.get('map_type') and not getattr(self.instance, 'map_type_id', None):
default_type = scoped_map_types_queryset(request).filter(is_default=True).first()
if not default_type:
raise serializers.ValidationError({'map_type': 'Нет типа карты по умолчанию.'})
data['map_type'] = default_type
map_type = data.get('map_type') or getattr(self.instance, 'map_type', None)
if use_jwt_auth() and request and request.user.is_authenticated and map_type:
if map_type.owner_id != request.user.id:
raise serializers.ValidationError({'map_type': 'Тип карты не принадлежит текущему пользователю.'})
return data
def get_memberships_count(self, obj):
return obj.memberships.count()
class NetworkMapMembershipSerializer(serializers.ModelSerializer):
contact_name = serializers.CharField(source='contact.name', read_only=True)
map_name = serializers.CharField(source='map.name', read_only=True)
class Meta:
model = NetworkMapMembership
fields = [
'id', 'map', 'map_name', 'contact', 'contact_name',
'life_sphere', 'network_circle', 'importance', 'conflict_involvement',
'map_angle', 'map_radius_ratio',
'created_at', 'updated_at',
]
read_only_fields = ['id', 'map', 'created_at', 'updated_at', 'contact_name', 'map_name']
class GraphSerializer(serializers.Serializer):
"""Граф для vis.js: nodes + edges."""
@@ -60,9 +159,6 @@ class GraphSerializer(serializers.Serializer):
'label': c.name,
'title': f'{c.organization}\n{c.position}'.strip() or c.name,
'group': c.organization or 'default',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
}
for c in contacts
]
+2 -12
View File
@@ -1,16 +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)
urlpatterns = [
path('', include(router.urls)),
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')),
]
+60 -284
View File
@@ -1,23 +1,20 @@
import csv
import io
import json
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework import viewsets, status
from rest_framework.decorators import api_view, action
from rest_framework.response import Response
from .models import (
Contact,
Relation,
RELATION_TYPES,
LIFE_SPHERES,
NETWORK_CIRCLES,
INTERACTION_INTENSITY,
from core.access import use_jwt_auth
from core.drf_mixins import JwtAuthMixin
from .mixins import OwnerScopedMixin
from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType
from .serializers import (
ContactSerializer,
RelationSerializer,
NetworkMapSerializer,
NetworkMapMembershipSerializer,
NetworkMapTypeSerializer,
)
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer
class ContactViewSet(viewsets.ModelViewSet):
class ContactViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet):
queryset = Contact.objects.all()
serializer_class = ContactSerializer
@@ -29,282 +26,61 @@ class ContactViewSet(viewsets.ModelViewSet):
return qs
class RelationViewSet(viewsets.ModelViewSet):
class RelationViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet):
queryset = Relation.objects.select_related('source', 'target').all()
serializer_class = RelationSerializer
@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',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
'map_angle': c.map_angle,
'map_radius_ratio': c.map_radius_ratio,
}
for c in contacts
]
relations = Relation.objects.select_related('source', 'target').all()
edges = [
{
'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,
}
for r in relations
]
return Response({'nodes': nodes, 'edges': edges})
class NetworkMapViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet):
queryset = NetworkMap.objects.select_related('map_type').all()
serializer_class = NetworkMapSerializer
@api_view(['GET'])
def network_map_graph(request):
"""Граф только для карты сети: контакты с include_on_network_map и связи между ними."""
contacts = list(
Contact.objects.filter(include_on_network_map=True).order_by('name')
)
allowed_ids = {c.id for c in contacts}
nodes = [
{
'id': c.id,
'label': c.name,
'title': '\n'.join(filter(None, [c.organization, c.position, c.email])),
'group': c.organization or 'default',
'life_sphere': c.life_sphere,
'network_circle': c.network_circle,
'importance': c.importance,
'map_angle': c.map_angle,
'map_radius_ratio': c.map_radius_ratio,
}
for c in contacts
]
relations = Relation.objects.select_related('source', 'target').all()
edges = [
{
'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,
}
for r in relations
if r.source_id in allowed_ids and r.target_id in allowed_ids
]
return Response({'nodes': nodes, 'edges': edges})
class NetworkMapTypeViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet):
queryset = NetworkMapType.objects.all()
serializer_class = NetworkMapTypeSerializer
def perform_destroy(self, instance):
if instance.is_default:
raise ValidationError({'detail': 'Нельзя удалить тип карты по умолчанию.'})
if instance.maps.exists():
raise ValidationError({'detail': 'Тип используется картами сети. Сначала смените тип у карт.'})
instance.delete()
@api_view(['GET'])
def relation_types(request):
"""Список допустимых типов связей."""
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
class NetworkMapMembershipViewSet(JwtAuthMixin, viewsets.ModelViewSet):
serializer_class = NetworkMapMembershipSerializer
def get_queryset(self):
map_id = self.kwargs.get('map_pk')
qs = NetworkMapMembership.objects.filter(
map_id=map_id
).select_related('contact', 'map')
if use_jwt_auth():
user = self.request.user
if user and user.is_authenticated:
return qs.filter(map__owner=user, contact__owner=user)
return qs.none()
return qs
@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 perform_create(self, serializer):
map_id = self.kwargs.get('map_pk')
if use_jwt_auth():
user = self.request.user
if not user or not user.is_authenticated:
raise PermissionDenied()
network_map = NetworkMap.objects.filter(pk=map_id, owner=user).first()
if not network_map:
raise PermissionDenied()
contact = serializer.validated_data.get('contact')
if contact.owner_id != user.id:
raise ValidationError({'contact': 'Контакт не принадлежит текущему пользователю.'})
serializer.save(map_id=map_id)
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 []
@api_view(['POST'])
def import_contacts(request):
"""
Импорт контактов из CSV или JSON.
CSV: name,email,phone,organization,position,notes
JSON (плоский): [{"name": "...", ...}, ...]
JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
"""
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,
)
else:
return Response(
{'error': 'Поддерживаются только CSV и JSON файлы.'},
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,
})
def perform_update(self, serializer):
if use_jwt_auth():
user = self.request.user
contact = serializer.validated_data.get('contact', serializer.instance.contact)
if contact.owner_id != user.id:
raise ValidationError({'contact': 'Контакт не принадлежит текущему пользователю.'})
serializer.save()
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
+19
View File
@@ -0,0 +1,19 @@
from django.conf import settings
def use_jwt_auth():
return getattr(settings, 'USE_JWT_AUTH', False)
def scope_by_owner(queryset, user, owner_field='owner'):
if not use_jwt_auth():
return queryset
if user and user.is_authenticated:
return queryset.filter(**{owner_field: user})
return queryset.none()
def user_workspace_id(user):
if user and user.is_authenticated:
return str(user.pk)
return settings.DEFAULT_WORKSPACE_ID
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'
+94
View File
@@ -0,0 +1,94 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from rest_framework import serializers
User = get_user_model()
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
email = serializers.EmailField(required=False, allow_blank=True)
password = serializers.CharField(write_only=True, min_length=8)
def validate_username(self, value):
username = value.strip()
if not username:
raise serializers.ValidationError('Укажите имя пользователя.')
if User.objects.filter(username__iexact=username).exists():
raise serializers.ValidationError('Это имя пользователя уже занято.')
return username
def validate_password(self, value):
validate_password(value)
return value
def create(self, validated_data):
email = (validated_data.get('email') or '').strip()
return User.objects.create_user(
username=validated_data['username'],
email=email,
password=validated_data['password'],
)
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email']
read_only_fields = fields
class UpdateProfileSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150, required=False)
email = serializers.EmailField(required=False, allow_blank=True)
current_password = serializers.CharField(write_only=True)
def validate_current_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError('Неверный текущий пароль.')
return value
def validate_username(self, value):
username = value.strip()
if not username:
raise serializers.ValidationError('Укажите имя пользователя.')
user = self.context['request'].user
if User.objects.filter(username__iexact=username).exclude(pk=user.pk).exists():
raise serializers.ValidationError('Это имя пользователя уже занято.')
return username
def validate(self, data):
if 'username' not in data and 'email' not in data:
raise serializers.ValidationError('Укажите новое имя пользователя или email.')
return data
def save(self):
user = self.context['request'].user
if 'username' in self.validated_data:
user.username = self.validated_data['username']
if 'email' in self.validated_data:
user.email = (self.validated_data.get('email') or '').strip()
user.save(update_fields=['username', 'email'])
return user
class ChangePasswordSerializer(serializers.Serializer):
current_password = serializers.CharField(write_only=True)
new_password = serializers.CharField(write_only=True, min_length=8)
def validate_current_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError('Неверный текущий пароль.')
return value
def validate_new_password(self, value):
validate_password(value, self.context['request'].user)
return value
def save(self):
user = self.context['request'].user
user.set_password(self.validated_data['new_password'])
user.save(update_fields=['password'])
return user
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .auth_views import ChangePasswordView, MeView, RegisterView
urlpatterns = [
path('auth/register/', RegisterView.as_view(), name='auth_register'),
path('auth/me/', MeView.as_view(), name='auth_me'),
path('auth/me/password/', ChangePasswordView.as_view(), name='auth_change_password'),
path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
+66
View File
@@ -0,0 +1,66 @@
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from contacts.bootstrap import ensure_user_defaults
from core.drf_mixins import JwtAuthMixin
from .auth_serializers import (
ChangePasswordSerializer,
RegisterSerializer,
UpdateProfileSerializer,
UserSerializer,
)
def tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return {
'refresh': str(refresh),
'access': str(refresh.access_token),
'user': UserSerializer(user).data,
}
class RegisterView(JwtAuthMixin, APIView):
def get_permissions(self):
return [AllowAny()]
def post(self, request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
ensure_user_defaults(user)
return Response(tokens_for_user(user), status=status.HTTP_201_CREATED)
class MeView(JwtAuthMixin, APIView):
def get_permissions(self):
return [IsAuthenticated()]
def get(self, request):
return Response(UserSerializer(request.user).data)
def patch(self, request):
serializer = UpdateProfileSerializer(
data=request.data,
context={'request': request},
)
serializer.is_valid(raise_exception=True)
user = serializer.save()
return Response(UserSerializer(user).data)
class ChangePasswordView(JwtAuthMixin, APIView):
def get_permissions(self):
return [IsAuthenticated()]
def post(self, request):
serializer = ChangePasswordSerializer(
data=request.data,
context={'request': request},
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response({'detail': 'Пароль изменён.'})
+59
View File
@@ -0,0 +1,59 @@
"""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', 'Другое'),
]
CONFLICT_RELATION_TYPES = [
('conflict_open', 'Открытый конфликт'),
('conflict_tension', 'Напряжение'),
('conflict_alliance', 'Союз / поддержка'),
('conflict_neutral', 'Нейтральная связь'),
]
ALL_RELATION_TYPES = RELATION_TYPES + CONFLICT_RELATION_TYPES
def choices_payload():
return {
'relation_types': [{'value': v, 'label': l} for v, l in RELATION_TYPES],
'conflict_relation_types': [{'value': v, 'label': l} for v, l in CONFLICT_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],
}
DEFAULT_MAP_TYPE_SECTORS = [
{'key': v, 'label': l} for v, l in LIFE_SPHERES
]
DEFAULT_MAP_TYPE_CIRCLES = [
{'key': v, 'label': l} for v, l in NETWORK_CIRCLES
]
+16
View File
@@ -0,0 +1,16 @@
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework_simplejwt.authentication import JWTAuthentication
from core.access import use_jwt_auth
class JwtAuthMixin:
def get_permissions(self):
if use_jwt_auth():
return [IsAuthenticated()]
return [AllowAny()]
def get_authenticators(self):
if use_jwt_auth():
return [JWTAuthentication()]
return super().get_authenticators()
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from . import views
urlpatterns = [
path('meta/choices/', views.MetaChoicesView.as_view(), name='meta-choices'),
path('relation-types/', views.RelationTypesView.as_view(), name='relation-types'),
path('network-map-choices/', views.NetworkMapChoicesView.as_view(), name='network-map-choices'),
]
+31
View File
@@ -0,0 +1,31 @@
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from core.drf_mixins import JwtAuthMixin
from .choices import (
RELATION_TYPES,
LIFE_SPHERES,
NETWORK_CIRCLES,
INTERACTION_INTENSITY,
choices_payload,
)
class MetaChoicesView(JwtAuthMixin, APIView):
def get(self, request):
return Response(choices_payload())
class RelationTypesView(JwtAuthMixin, APIView):
def get(self, request):
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
class NetworkMapChoicesView(JwtAuthMixin, APIView):
def get(self, 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],
})
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class GraphConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'graph'
+117
View File
@@ -0,0 +1,117 @@
from contacts.models import Contact, Relation, NetworkMap, NetworkMapMembership
from core.access import use_jwt_auth
def _contacts_qs(user):
qs = Contact.objects.all()
if use_jwt_auth():
if user and user.is_authenticated:
return qs.filter(owner=user)
return qs.none()
return qs
def _relations_qs(user):
qs = Relation.objects.select_related('source', 'target').all()
if use_jwt_auth():
if user and user.is_authenticated:
return qs.filter(owner=user)
return qs.none()
return qs
def _network_maps_qs(user):
qs = NetworkMap.objects.select_related('map_type').all()
if use_jwt_auth():
if user and user.is_authenticated:
return qs.filter(owner=user)
return qs.none()
return qs
def node_from_contact(contact, defaults=None):
defaults = defaults or {}
return {
'id': contact.id,
'label': contact.name,
'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])),
'group': contact.organization or 'default',
**defaults,
}
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,
'conflict_involvement': membership.conflict_involvement,
'map_angle': membership.map_angle,
'map_radius_ratio': membership.map_radius_ratio,
'membership_id': membership.id,
})
def nodes_for_network_map(network_map, user):
memberships = list(
NetworkMapMembership.objects.filter(map_id=network_map.id)
.select_related('contact', 'map')
.order_by('contact__name')
)
if use_jwt_auth() and user and user.is_authenticated:
memberships = [
m for m in memberships
if m.map.owner_id == user.id and m.contact.owner_id == user.id
]
return [node_from_membership(m) for m in memberships]
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(user=None):
contacts = _contacts_qs(user)
nodes = [node_from_contact(c) for c in contacts]
relations = _relations_qs(user)
edges = [edge_from_relation(r) for r in relations]
return {'nodes': nodes, 'edges': edges}
def build_network_map_graph(map_id=None, user=None):
maps_qs = _network_maps_qs(user)
if not map_id:
default_map = maps_qs.order_by('id').first()
if not default_map:
return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''}
map_id = default_map.id
network_map = maps_qs.filter(pk=map_id).first()
if not network_map:
return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''}
nodes = nodes_for_network_map(network_map, user)
allowed_ids = {n['id'] for n in nodes}
relations = _relations_qs(user)
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,
'conflictology': network_map.map_type.conflictology_enabled,
'conflict_subject': network_map.conflict_subject or network_map.name,
}
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path('graph/', views.GraphDataView.as_view(), name='graph-data'),
path('network-map-graph/', views.NetworkMapGraphView.as_view(), name='network-map-graph'),
]
+23
View File
@@ -0,0 +1,23 @@
from rest_framework.response import Response
from rest_framework.views import APIView
from core.drf_mixins import JwtAuthMixin
from core.access import use_jwt_auth
from .services import build_full_graph, build_network_map_graph
def _graph_user(request):
if use_jwt_auth():
return request.user if request.user.is_authenticated else None
return None
class GraphDataView(JwtAuthMixin, APIView):
def get(self, request):
return Response(build_full_graph(user=_graph_user(request)))
class NetworkMapGraphView(JwtAuthMixin, APIView):
def get(self, request):
map_id = request.query_params.get('map_id')
return Response(build_network_map_graph(map_id, user=_graph_user(request)))
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ImportExportConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'import_export'
+387
View File
@@ -0,0 +1,387 @@
import json
from datetime import datetime, timezone
from django.db import transaction
from contacts.models import (
Contact,
NetworkMap,
NetworkMapMembership,
NetworkMapType,
Relation,
)
from core.choices import ALL_RELATION_TYPES, INTERACTION_INTENSITY
VALID_RELATION_TYPES = {choice[0] for choice in ALL_RELATION_TYPES}
VALID_INTENSITY = {choice[0] for choice in INTERACTION_INTENSITY}
def is_data_dump(data):
return (
isinstance(data, dict)
and isinstance(data.get('contacts'), list)
and isinstance(data.get('relations'), list)
)
def _owner_filter(owner):
return {'owner': owner} if owner is not None else {}
def _contacts_qs(owner):
qs = Contact.objects.all()
if owner is not None:
qs = qs.filter(owner=owner)
return qs.order_by('name')
def _relations_qs(owner):
qs = Relation.objects.select_related('source', 'target').all()
if owner is not None:
qs = qs.filter(owner=owner)
return qs.order_by('id')
def _map_types_qs(owner):
qs = NetworkMapType.objects.all()
if owner is not None:
qs = qs.filter(owner=owner)
return qs.order_by('name')
def _maps_qs(owner):
qs = NetworkMap.objects.select_related('map_type').all()
if owner is not None:
qs = qs.filter(owner=owner)
return qs.order_by('name')
def _serialize_contact(contact):
return {
'id': contact.id,
'name': contact.name,
'email': contact.email or '',
'phone': contact.phone or '',
'organization': contact.organization or '',
'position': contact.position or '',
'notes': contact.notes or '',
'created_at': contact.created_at.isoformat() if contact.created_at else None,
'updated_at': contact.updated_at.isoformat() if contact.updated_at else None,
}
def _serialize_relation(relation):
return {
'id': relation.id,
'source': relation.source_id,
'source_name': relation.source.name if relation.source_id else '',
'target': relation.target_id,
'target_name': relation.target.name if relation.target_id else '',
'relation_type': relation.relation_type,
'description': relation.description or '',
'interaction_intensity': relation.interaction_intensity,
'created_at': relation.created_at.isoformat() if relation.created_at else None,
}
def _serialize_map_type(map_type):
return {
'id': map_type.id,
'name': map_type.name,
'sectors': map_type.sectors or [],
'circles': map_type.circles or [],
'isDefault': map_type.is_default,
'conflictologyEnabled': map_type.conflictology_enabled,
'createdAt': map_type.created_at.isoformat() if map_type.created_at else None,
'updatedAt': map_type.updated_at.isoformat() if map_type.updated_at else None,
}
def _serialize_map(network_map):
return {
'id': network_map.id,
'name': network_map.name,
'description': network_map.description or '',
'mapTypeId': network_map.map_type_id,
'conflictSubject': network_map.conflict_subject or '',
'createdAt': network_map.created_at.isoformat() if network_map.created_at else None,
'updatedAt': network_map.updated_at.isoformat() if network_map.updated_at else None,
}
def _serialize_membership(membership):
return {
'id': membership.id,
'mapId': membership.map_id,
'contactId': membership.contact_id,
'life_sphere': membership.life_sphere,
'network_circle': membership.network_circle,
'importance': membership.importance,
'conflict_involvement': membership.conflict_involvement,
'map_angle': membership.map_angle,
'map_radius_ratio': membership.map_radius_ratio,
'createdAt': membership.created_at.isoformat() if membership.created_at else None,
'updatedAt': membership.updated_at.isoformat() if membership.updated_at else None,
}
def build_owner_dump(owner):
contacts = [_serialize_contact(c) for c in _contacts_qs(owner)]
relations = [_serialize_relation(r) for r in _relations_qs(owner)]
map_types = [_serialize_map_type(t) for t in _map_types_qs(owner)]
maps = [_serialize_map(m) for m in _maps_qs(owner)]
map_ids = [m.id for m in _maps_qs(owner)]
memberships = NetworkMapMembership.objects.filter(map_id__in=map_ids).select_related('map', 'contact')
membership_rows = [_serialize_membership(m) for m in memberships]
return {
'version': 2,
'exportedAt': datetime.now(timezone.utc).isoformat(),
'contacts': contacts,
'relations': relations,
'networkMapTypes': map_types,
'networkMaps': maps,
'networkMapMemberships': membership_rows,
}
def _read_map_types(data):
raw = data.get('networkMapTypes') or data.get('network_map_types') or []
return raw if isinstance(raw, list) else []
def _read_maps(data):
raw = data.get('networkMaps') or data.get('network_maps') or []
return raw if isinstance(raw, list) else []
def _read_memberships(data):
raw = data.get('networkMapMemberships') or data.get('network_map_memberships') or []
return raw if isinstance(raw, list) else []
def _contact_payload(row):
return {
'name': str(row.get('name') or '').strip() or 'Без имени',
'email': str(row.get('email') or '').strip(),
'phone': str(row.get('phone') or '').strip(),
'organization': str(row.get('organization') or '').strip(),
'position': str(row.get('position') or '').strip(),
'notes': str(row.get('notes') or '').strip(),
}
def _normalize_relation_type(value):
raw = str(value or 'acquaintance').strip()
return raw if raw in VALID_RELATION_TYPES else 'other'
def _normalize_intensity(value):
raw = str(value or 'intense').strip()
return raw if raw in VALID_INTENSITY else 'intense'
def _relation_payload(row, source_id, target_id):
return {
'source_id': source_id,
'target_id': target_id,
'relation_type': _normalize_relation_type(row.get('relation_type')),
'description': str(row.get('description') or '')[:255],
'interaction_intensity': _normalize_intensity(row.get('interaction_intensity')),
}
def _map_type_payload(row):
return {
'name': str(row.get('name') or '').strip() or 'Тип карты',
'sectors': row.get('sectors') or [],
'circles': row.get('circles') or [],
'is_default': bool(row.get('isDefault') if 'isDefault' in row else row.get('is_default')),
'conflictology_enabled': bool(
row.get('conflictologyEnabled') if 'conflictologyEnabled' in row
else row.get('conflictology_enabled')
),
}
def _map_payload(row, map_type_id):
return {
'name': str(row.get('name') or '').strip() or 'Карта',
'description': str(row.get('description') or '').strip(),
'map_type_id': map_type_id,
'conflict_subject': str(
row.get('conflictSubject') if 'conflictSubject' in row else row.get('conflict_subject') or ''
).strip(),
}
def _membership_payload(row, map_id, contact_id):
return {
'map_id': map_id,
'contact_id': contact_id,
'life_sphere': str(row.get('life_sphere') or 'other'),
'network_circle': str(row.get('network_circle') or 'productivity'),
'importance': int(row.get('importance') or 3),
'conflict_involvement': int(row.get('conflict_involvement') or 3),
'map_angle': row.get('map_angle'),
'map_radius_ratio': row.get('map_radius_ratio'),
}
def clear_owner_data(owner):
maps_qs = _maps_qs(owner)
map_ids = list(maps_qs.values_list('id', flat=True))
deleted_memberships = NetworkMapMembership.objects.filter(map_id__in=map_ids).count()
deleted_maps = maps_qs.count()
deleted_relations = _relations_qs(owner).count()
deleted_contacts = _contacts_qs(owner).count()
deleted_map_types = _map_types_qs(owner).count()
NetworkMapMembership.objects.filter(map_id__in=map_ids).delete()
maps_qs.delete()
_relations_qs(owner).delete()
_contacts_qs(owner).delete()
_map_types_qs(owner).delete()
return {
'deletedContacts': deleted_contacts,
'deletedRelations': deleted_relations,
'deletedMaps': deleted_maps,
'deletedMemberships': deleted_memberships,
'deletedMapTypes': deleted_map_types,
}
@transaction.atomic
def import_owner_dump(owner, data, replace=False):
if not is_data_dump(data):
raise ValueError('Некорректный формат бэкапа: ожидаются массивы contacts и relations.')
cleared = None
if replace:
cleared = clear_owner_data(owner)
contacts = data.get('contacts') or []
relations = data.get('relations') or []
map_types = _read_map_types(data)
maps = _read_maps(data)
memberships = _read_memberships(data)
contact_id_map = {}
contacts_created = 0
contacts_skipped = 0
for row in contacts:
payload = _contact_payload(row)
if not payload['name']:
contacts_skipped += 1
continue
create_kwargs = {**payload, **_owner_filter(owner)}
contact = Contact.objects.create(**create_kwargs)
old_id = row.get('id')
if old_id is not None:
contact_id_map[str(old_id)] = contact.id
contacts_created += 1
if not contact_id_map and contacts:
raise ValueError('Не удалось импортировать ни одного контакта.')
type_id_map = {}
existing_types = {t.name: t for t in _map_types_qs(owner)}
default_type_id = None
for row in map_types:
payload = _map_type_payload(row)
found = existing_types.get(payload['name'])
if found:
type_id_map[str(row.get('id'))] = found.id
if payload['is_default']:
default_type_id = found.id
continue
create_kwargs = {**payload, **_owner_filter(owner)}
created = NetworkMapType.objects.create(**create_kwargs)
existing_types[payload['name']] = created
type_id_map[str(row.get('id'))] = created.id
if payload['is_default']:
default_type_id = created.id
if not default_type_id:
fallback = _map_types_qs(owner).filter(is_default=True).first() or _map_types_qs(owner).first()
default_type_id = fallback.id if fallback else None
map_id_map = {}
maps_created = 0
for row in maps:
old_type_id = row.get('mapTypeId') if 'mapTypeId' in row else row.get('map_type')
map_type_id = type_id_map.get(str(old_type_id)) or default_type_id
if not map_type_id:
continue
payload = _map_payload(row, map_type_id)
create_kwargs = {**payload, **_owner_filter(owner)}
created = NetworkMap.objects.create(**create_kwargs)
map_id_map[str(row.get('id'))] = created.id
maps_created += 1
relation_pairs = set()
relations_created = 0
relations_skipped = 0
for row in relations:
source = contact_id_map.get(str(row.get('source')))
target = contact_id_map.get(str(row.get('target')))
if not source or not target or source == target:
relations_skipped += 1
continue
pair_key = f'{source}:{target}'
if pair_key in relation_pairs:
relations_skipped += 1
continue
payload = _relation_payload(row, source, target)
create_kwargs = {**payload, **_owner_filter(owner)}
Relation.objects.create(**create_kwargs)
relation_pairs.add(pair_key)
relations_created += 1
memberships_created = 0
memberships_skipped = 0
for row in memberships:
map_id = map_id_map.get(str(row.get('mapId') if 'mapId' in row else row.get('map')))
contact_id = contact_id_map.get(str(row.get('contactId') if 'contactId' in row else row.get('contact')))
if not map_id or not contact_id:
memberships_skipped += 1
continue
payload = _membership_payload(row, map_id, contact_id)
NetworkMapMembership.objects.create(**payload)
memberships_created += 1
return {
'replaced': bool(replace),
'cleared': cleared,
'importedContacts': contacts_created,
'contactsSkipped': contacts_skipped,
'importedRelations': relations_created,
'relationsSkipped': relations_skipped,
'importedMaps': maps_created,
'importedMemberships': memberships_created,
'membershipsSkipped': memberships_skipped,
'importedMapTypes': len(type_id_map),
}
def parse_dump_request(request):
if request.content_type and 'application/json' in request.content_type:
try:
body = request.body.decode('utf-8') if request.body else '{}'
return json.loads(body or '{}'), None
except json.JSONDecodeError as exc:
return None, f'Некорректный JSON: {exc}'
dump_file = request.FILES.get('file')
if dump_file:
try:
return json.loads(dump_file.read().decode('utf-8')), None
except json.JSONDecodeError as exc:
return None, f'Некорректный JSON в файле: {exc}'
return None, 'Передайте JSON-бэкап в теле запроса или файлом (поле file).'
+233
View File
@@ -0,0 +1,233 @@
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, owner=None):
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
lookup = {'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(),
}
if owner is not None:
lookup['owner'] = owner
defaults['owner'] = owner
Contact.objects.get_or_create(**lookup, defaults=defaults)
created += 1
return {
'total': len(rows),
'created': created,
'skipped': skipped,
'errors': errors,
}
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path('import/', views.ImportContactsView.as_view(), name='import-contacts'),
path('export/', views.ExportDumpView.as_view(), name='export-dump'),
]
+54
View File
@@ -0,0 +1,54 @@
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from core.access import use_jwt_auth
from core.drf_mixins import JwtAuthMixin
from .dump_services import build_owner_dump, import_owner_dump, is_data_dump, parse_dump_request
from .services import parse_upload_file, import_contacts_from_rows
class ExportDumpView(JwtAuthMixin, APIView):
def get(self, request):
owner = request.user if use_jwt_auth() and request.user.is_authenticated else None
return Response(build_owner_dump(owner))
def post(self, request):
data, error = parse_dump_request(request)
if error:
return Response({'error': error}, status=status.HTTP_400_BAD_REQUEST)
if not is_data_dump(data):
return Response(
{'error': 'Некорректный формат бэкапа: ожидаются массивы contacts и relations.'},
status=status.HTTP_400_BAD_REQUEST,
)
replace = str(request.query_params.get('replace', '')).lower() in ('1', 'true', 'yes')
owner = request.user if use_jwt_auth() and request.user.is_authenticated else None
try:
summary = import_owner_dump(owner, data, replace=replace)
return Response(summary)
except ValueError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc:
return Response(
{'error': f'Ошибка импорта бэкапа: {exc}'},
status=status.HTTP_400_BAD_REQUEST,
)
class ImportContactsView(JwtAuthMixin, APIView):
def post(self, request):
file = request.FILES.get('file')
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)
owner = request.user if use_jwt_auth() and request.user.is_authenticated else None
return Response(import_contacts_from_rows(rows, owner=owner))
except Exception as e:
return Response(
{'error': f'Ошибка разбора файла: {e}'},
status=status.HTTP_400_BAD_REQUEST,
)
+1
View File
@@ -0,0 +1 @@
+9
View File
@@ -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
+48
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
"""Register built-in plugins."""
from plugins.base import register_plugin
from plugins_tags.plugin import TagsPlugin
register_plugin(TagsPlugin())
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from . import views
from .base import plugin_urlpatterns
urlpatterns = [
path('plugins/', views.PluginManifestView.as_view(), name='plugin-manifest'),
*plugin_urlpatterns(),
]
+18
View File
@@ -0,0 +1,18 @@
from rest_framework.response import Response
from rest_framework.views import APIView
from core.drf_mixins import JwtAuthMixin
from plugins.base import get_enabled_plugins
class PluginManifestView(JwtAuthMixin, APIView):
def get(self, request):
return Response([
{
'id': p.id,
'version': p.version,
'min_core_version': p.min_core_version,
'permissions': p.permissions,
}
for p in get_enabled_plugins()
])
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -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,39 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def assign_tag_owner(apps, schema_editor):
ContactTag = apps.get_model('plugins_tags', 'ContactTag')
Contact = apps.get_model('contacts', 'Contact')
for tag in ContactTag.objects.filter(owner__isnull=True).select_related('contact'):
if tag.contact_id:
contact = Contact.objects.filter(pk=tag.contact_id).first()
if contact and contact.owner_id:
tag.owner_id = contact.owner_id
tag.save(update_fields=['owner_id'])
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contacts', '0011_add_owner'),
('plugins_tags', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='contacttag',
name='owner',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='contact_tags',
to=settings.AUTH_USER_MODEL,
verbose_name='Владелец',
),
),
migrations.RunPython(assign_tag_owner, migrations.RunPython.noop),
]
@@ -0,0 +1 @@
# Generated migration placeholder - run: python manage.py makemigrations plugins_tags
+35
View File
@@ -0,0 +1,35 @@
from django.conf import settings
from django.db import models
from contacts.models import Contact
class ContactTag(models.Model):
"""Tag assigned to a contact (reference plugin)."""
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='contact_tags',
null=True,
blank=True,
verbose_name='Владелец',
)
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}'
+15
View File
@@ -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']
+12
View File
@@ -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']
+9
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied, ValidationError
from django.conf import settings
from core.access import use_jwt_auth, user_workspace_id
from core.drf_mixins import JwtAuthMixin
from .models import ContactTag
from .serializers import ContactTagSerializer
class ContactTagViewSet(JwtAuthMixin, viewsets.ModelViewSet):
serializer_class = ContactTagSerializer
def get_queryset(self):
qs = ContactTag.objects.select_related('contact').all()
if use_jwt_auth():
user = self.request.user
if user and user.is_authenticated:
qs = qs.filter(owner=user)
else:
return qs.none()
else:
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):
if use_jwt_auth():
user = self.request.user
if not user or not user.is_authenticated:
raise PermissionDenied()
contact = serializer.validated_data.get('contact')
if contact.owner_id != user.id:
raise ValidationError({'contact': 'Контакт не принадлежит текущему пользователю.'})
serializer.save(owner=user, workspace_id=user_workspace_id(user))
return
workspace = self.request.data.get('workspace_id') or settings.DEFAULT_WORKSPACE_ID
serializer.save(workspace_id=workspace)
+41
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
+4
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+113
View File
@@ -0,0 +1,113 @@
import pytest
from django.test import override_settings
from rest_framework.settings import api_settings
from rest_framework.test import APIClient
JWT_REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100,
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
@pytest.fixture
def jwt_api_client():
with override_settings(USE_JWT_AUTH=True, REST_FRAMEWORK=JWT_REST_FRAMEWORK):
api_settings.reload()
client = APIClient()
yield client
api_settings.reload()
@pytest.mark.django_db
def test_register_and_isolated_contacts(jwt_api_client):
reg = jwt_api_client.post(
'/api/v1/auth/register/',
{'username': 'alice', 'password': 'strong-pass-1'},
format='json',
)
assert reg.status_code == 201
assert reg.data['user']['username'] == 'alice'
token = reg.data['access']
create = jwt_api_client.post(
'/api/v1/contacts/',
{'name': 'Контакт Alice'},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
assert create.status_code == 201
reg_b = jwt_api_client.post(
'/api/v1/auth/register/',
{'username': 'bob', 'password': 'strong-pass-2'},
format='json',
)
token_b = reg_b.data['access']
alice_list = jwt_api_client.get(
'/api/v1/contacts/',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
bob_list = jwt_api_client.get(
'/api/v1/contacts/',
HTTP_AUTHORIZATION=f'Bearer {token_b}',
)
assert alice_list.data['count'] == 1
assert bob_list.data['count'] == 0
@pytest.mark.django_db
def test_unauthenticated_api_denied(jwt_api_client, sample_contact):
response = jwt_api_client.get('/api/v1/contacts/')
assert response.status_code == 401
@pytest.mark.django_db
def test_update_profile_and_password(jwt_api_client):
reg = jwt_api_client.post(
'/api/v1/auth/register/',
{'username': 'carol', 'email': 'carol@test.com', 'password': 'strong-pass-1'},
format='json',
)
token = reg.data['access']
auth = {'HTTP_AUTHORIZATION': f'Bearer {token}'}
profile = jwt_api_client.patch(
'/api/v1/auth/me/',
{
'username': 'carol_new',
'email': 'new@test.com',
'current_password': 'strong-pass-1',
},
format='json',
**auth,
)
assert profile.status_code == 200
assert profile.data['username'] == 'carol_new'
assert profile.data['email'] == 'new@test.com'
password = jwt_api_client.post(
'/api/v1/auth/me/password/',
{
'current_password': 'strong-pass-1',
'new_password': 'strong-pass-9',
},
format='json',
**auth,
)
assert password.status_code == 200
login = jwt_api_client.post(
'/api/v1/auth/token/',
{'username': 'carol_new', 'password': 'strong-pass-9'},
format='json',
)
assert login.status_code == 200
+24
View File
@@ -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
+94
View File
@@ -0,0 +1,94 @@
import pytest
from contacts.models import Contact, NetworkMap, NetworkMapMembership, NetworkMapType, Relation
@pytest.mark.django_db
def test_export_dump_empty(api_client):
response = api_client.get('/api/v1/export/')
assert response.status_code == 200
data = response.json()
assert data['version'] == 2
assert data['contacts'] == []
assert data['relations'] == []
assert isinstance(data['networkMapTypes'], list)
assert isinstance(data['networkMaps'], list)
assert isinstance(data['networkMapMemberships'], list)
@pytest.mark.django_db
def test_export_dump_with_data(api_client, two_contacts, sample_relation):
response = api_client.get('/api/v1/export/')
assert response.status_code == 200
data = response.json()
assert len(data['contacts']) == 2
assert len(data['relations']) == 1
assert data['relations'][0]['source'] == sample_relation.source_id
assert data['relations'][0]['target'] == sample_relation.target_id
@pytest.mark.django_db
def test_import_dump_replace(api_client, two_contacts, sample_relation):
export_response = api_client.get('/api/v1/export/')
dump = export_response.json()
assert len(dump['contacts']) == 2
Contact.objects.all().delete()
assert Contact.objects.count() == 0
import_response = api_client.post('/api/v1/export/?replace=true', dump, format='json')
assert import_response.status_code == 200
result = import_response.json()
assert result['importedContacts'] == 2
assert result['importedRelations'] == 1
assert Contact.objects.count() == 2
assert Relation.objects.count() == 1
@pytest.mark.django_db
def test_import_dump_invalid(api_client):
response = api_client.post('/api/v1/export/', {'contacts': []}, format='json')
assert response.status_code == 400
assert 'error' in response.json()
@pytest.mark.django_db
def test_import_dump_with_maps(api_client, db):
NetworkMapMembership.objects.all().delete()
NetworkMap.objects.all().delete()
NetworkMapType.objects.all().delete()
map_type = NetworkMapType.objects.create(
name='Базовый',
sectors=[{'id': 'work', 'label': 'Работа'}],
circles=[{'id': 'close', 'label': 'Близкий'}],
is_default=True,
)
contact = Contact.objects.create(name='Алиса')
network_map = NetworkMap.objects.create(
name='Основная',
description='Тест',
map_type=map_type,
)
network_map.memberships.create(
contact=contact,
life_sphere='work',
network_circle='close',
importance=4,
)
dump = api_client.get('/api/v1/export/').json()
assert len(dump['contacts']) == 1
assert len(dump['networkMaps']) == 1
assert len(dump['networkMapMemberships']) == 1
Contact.objects.all().delete()
NetworkMap.objects.all().delete()
NetworkMapType.objects.all().delete()
result = api_client.post('/api/v1/export/?replace=true', dump, format='json').json()
assert result['importedContacts'] == 1
assert result['importedMaps'] == 1
assert result['importedMemberships'] == 1
assert NetworkMapType.objects.count() == 1
assert NetworkMap.objects.count() == 1
+22
View File
@@ -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_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
+20
View File
@@ -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,69 @@
import pytest
from contacts.models import NetworkMap, NetworkMapType
@pytest.mark.django_db
def test_network_map_types_list(api_client):
response = api_client.get('/api/v1/network-map-types/')
assert response.status_code == 200
assert response.data['count'] >= 1
default = next(r for r in response.data['results'] if r['is_default'])
assert len(default['sectors']) == 6
assert len(default['circles']) == 3
@pytest.mark.django_db
def test_network_map_type_create(api_client):
response = api_client.post('/api/v1/network-map-types/', {
'name': 'Корпоративная',
'sectors': [{'key': 'team', 'label': 'Команда'}],
'circles': [{'key': 'inner', 'label': 'Ядро'}],
}, format='json')
assert response.status_code == 201
assert response.data['name'] == 'Корпоративная'
@pytest.mark.django_db
def test_network_map_type_create_validation(api_client):
response = api_client.post('/api/v1/network-map-types/', {
'name': 'Пустая',
'sectors': [],
'circles': [{'key': 'a', 'label': 'A'}],
}, format='json')
assert response.status_code == 400
@pytest.mark.django_db
def test_network_map_type_delete_default_forbidden(api_client):
default_type = NetworkMapType.objects.filter(is_default=True).first()
response = api_client.delete(f'/api/v1/network-map-types/{default_type.id}/')
assert response.status_code == 400
@pytest.mark.django_db
def test_network_map_create_with_type(api_client):
map_type = NetworkMapType.objects.create(
name='Тестовая',
sectors=[{'key': 'a', 'label': 'A'}],
circles=[{'key': 'b', 'label': 'B'}],
)
response = api_client.post('/api/v1/network-maps/', {
'name': 'Карта 1',
'description': '',
'map_type': map_type.id,
}, format='json')
assert response.status_code == 201
assert response.data['map_type'] == map_type.id
@pytest.mark.django_db
def test_network_map_type_delete_in_use(api_client):
map_type = NetworkMapType.objects.create(
name='Занятая',
sectors=[{'key': 'a', 'label': 'A'}],
circles=[{'key': 'b', 'label': 'B'}],
)
NetworkMap.objects.create(name='Карта', map_type=map_type)
response = api_client.delete(f'/api/v1/network-map-types/{map_type.id}/')
assert response.status_code == 400
+27
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -9,4 +9,5 @@ BACKEND_PORT=8000
# Только при profile with-backend (VITE_DATA_MODE=remote)
DJANGO_SECRET_KEY=replace-with-long-random-string
ALLOWED_HOSTS=your-domain.com,www.your-domain.com
ALLOWED_HOSTS=social.deepfishing.ru
USE_JWT_AUTH=true
+295 -85
View File
@@ -1,87 +1,130 @@
# Production deploy (контейнеры + прокси на хосте)
# Развёртывание Social Graph на сервере
Инструкция для Linux-сервера (Ubuntu/Debian). Приложение публикуется через Docker; снаружи доступен только веб-прокси (80/443).
> **Production:** [deploy/social.deepfishing.ru.md](./social.deepfishing.ru.md) — инструкция для https://social.deepfishing.ru
## Схема
```text
[Прокси на хосте :80/:443]
→ frontend-контейнер (nginx, 127.0.0.1:8080)
→ backend-контейнер (gunicorn, 127.0.0.1:8000 — только при remote)
Интернет :80 / :443
Прокси на хосте (Apache или nginx)
┌───────────────────────────────────────┐
│ frontend-контейнер (nginx) │
│ 127.0.0.1:8080 → SPA + статика │
└───────────────────────────────────────┘
↓ (только в режиме remote)
┌───────────────────────────────────────┐
│ backend-контейнер (gunicorn) │
│ 127.0.0.1:8000 → Django API │
│ SQLite в Docker volume │
└───────────────────────────────────────┘
```
SPA-маршрутизация (`try_files``index.html`) — **внутри** frontend-контейнера (`frontend/nginx.conf`).
| Режим | Где данные | Backend на сервере |
|-------|------------|--------------------|
| **local** (по умолчанию) | IndexedDB в браузере каждого пользователя | не нужен |
| **remote** | SQLite на сервере, общая БД | обязателен |
## 1. Клонирование и подготовка
Режим задаётся при **сборке** фронтенда (`VITE_DATA_MODE`).
---
## 1. Требования к серверу
- Linux (Ubuntu 22.04+ / Debian 12+)
- Docker Engine + Docker Compose plugin
- Git
- Домен, указывающий на IP сервера (для HTTPS)
- Apache2 **или** nginx на хосте (reverse proxy)
Установка Docker (если ещё нет):
```bash
git clone <repo-url> /opt/social-graph
cd /opt/social-graph
git checkout main # или нужная ветка
cp deploy/.env.prod.example deploy/.env.prod
# отредактируйте deploy/.env.prod
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
# перелогиньтесь, чтобы группа docker применилась
docker compose version
```
`deploy/.env.prod` в git не коммитится (секреты и локальные порты).
---
## 2. Local-first (рекомендуется)
## 2. Клонирование проекта
Данные пользователя — в браузере (IndexedDB). Backend на сервере **не поднимать**.
```bash
sudo mkdir -p /opt/social-graph
sudo chown "$USER:$USER" /opt/social-graph
git clone <URL-репозитория> /opt/social-graph
cd /opt/social-graph
git checkout main # или нужная ветка
```
В `deploy/.env.prod`:
---
## 3. Конфигурация окружения
```bash
cp deploy/.env.prod.example deploy/.env.prod
nano deploy/.env.prod
```
### Вариант A — local-first (данные только в браузере)
Подходит, если сервер — просто «хостинг интерфейса», без общей базы.
```env
VITE_DATA_MODE=local
FRONTEND_BIND=127.0.0.1
FRONTEND_PORT=8080
```
Backend **не поднимается**. Каждый пользователь хранит данные локально; при смене браузера или устройства данные не переносятся автоматически (экспорт/импорт — через UI → Импорт).
### Вариант B — remote (данные на сервере, вход по логину)
Подходит для команды или одного аккаунта с доступом с разных устройств.
```env
VITE_DATA_MODE=remote
FRONTEND_BIND=127.0.0.1
FRONTEND_PORT=8080
BACKEND_BIND=127.0.0.1
BACKEND_PORT=8000
DJANGO_SECRET_KEY=сгенерируйте-длинную-случайную-строку
ALLOWED_HOSTS=your-domain.com,www.your-domain.com
USE_JWT_AUTH=true
```
Сгенерировать секретный ключ:
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(50))"
```
Файл `deploy/.env.prod` **не коммитить** — в нём секреты.
---
## 4. Сборка и запуск контейнеров
Перейдите в каталог проекта:
```bash
cd /opt/social-graph
```
### Только frontend (режим local)
```bash
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
```
Проверка:
```bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
# ожидается 200
```
## 3. Прокси на хосте
### Apache2
```bash
sudo a2enmod proxy proxy_http headers
sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf
```
Отредактируйте `ServerName` и при необходимости порты (`FRONTEND_PORT` / `BACKEND_PORT` из `.env.prod`).
```bash
sudo a2ensite social-graph.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
```
HTTPS:
```bash
sudo certbot --apache -d your-domain.com
```
### nginx на хосте
См. `deploy/proxy/nginx-host.conf.example`.
## 4. Remote mode (frontend + backend)
Общая БД на сервере. В `deploy/.env.prod`:
```env
VITE_DATA_MODE=remote
DJANGO_SECRET_KEY=длинный-случайный-ключ
ALLOWED_HOSTS=your-domain.com,www.your-domain.com
```
Пересоберите frontend (режим зашивается при build) и поднимите оба сервиса:
### Frontend + backend (режим remote)
```bash
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build
@@ -89,48 +132,215 @@ docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --bu
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
```
В `deploy/apache/social-graph.conf` раскомментируйте `ProxyPass /api ...` **выше** блока `ProxyPass /`.
Проверка:
```bash
sudo a2enmod proxy proxy_http headers
docker compose -f docker-compose.prod.yml ps
curl -s -o /dev/null -w "frontend: %{http_code}\n" http://127.0.0.1:8080/
curl -s -o /dev/null -w "backend: %{http_code}\n" http://127.0.0.1:8000/api/v1/meta/choices/
```
Ожидается `200` (backend в режиме remote с JWT может вернуть `401` без токена — это нормально, главное не `502`).
Логи:
```bash
docker logs sg_frontend --tail 50
docker logs sg_backend --tail 50
```
> **Конфликт с dev:** если в том же каталоге запускали `docker compose up`, сначала выполните `docker compose down`.
---
## 5. Прокси на хосте
Контейнеры слушают только `127.0.0.1`. Наружу открывается прокси.
### Apache2
```bash
sudo apt install apache2
sudo a2enmod proxy proxy_http headers rewrite
sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf
sudo nano /etc/apache2/sites-available/social-graph.conf
```
Измените `ServerName` / `ServerAlias` на ваш домен.
**При `VITE_DATA_MODE=remote`** раскомментируйте блок API **выше** блока frontend:
```apache
ProxyPass /api http://127.0.0.1:8000/api
ProxyPassReverse /api http://127.0.0.1:8000/api
```
Включите сайт:
```bash
sudo a2ensite social-graph.conf
sudo a2dissite 000-default.conf # опционально
sudo apache2ctl configtest
sudo systemctl reload apache2
```
## 5. Обновление
HTTPS:
```bash
sudo apt install certbot python3-certbot-apache
sudo certbot --apache -d your-domain.com -d www.your-domain.com
```
### nginx на хосте
```bash
sudo apt install nginx
sudo cp deploy/proxy/nginx-host.conf.example /etc/nginx/sites-available/social-graph
sudo nano /etc/nginx/sites-available/social-graph
```
Укажите `server_name` и при remote-режиме раскомментируйте `location /api/`.
```bash
sudo ln -s /etc/nginx/sites-available/social-graph /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
HTTPS:
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
```
---
## 6. Первый вход (режим remote)
1. Откройте `https://your-domain.com`
2. Перейдите на **Регистрация** (`/register`) и создайте аккаунт
3. Либо войдите под существующим пользователем
Если на сервере уже есть данные от пользователя `legacy` (миграция), задайте ему пароль:
```bash
docker compose -f docker-compose.prod.yml --profile with-backend exec backend python manage.py shell
```
```python
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.get(username='legacy')
u.set_password('ваш-пароль')
u.save()
exit()
```
---
## 7. Обновление версии
```bash
cd /opt/social-graph
git pull
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
# при remote:
# docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
sudo systemctl reload apache2 # или: sudo nginx -s reload
```
## Порты по умолчанию
При изменении `VITE_DATA_MODE` нужна **пересборка** frontend (`--build`).
| Переменная | Значение | Назначение |
|------------|----------|------------|
| `FRONTEND_PORT` | 8080 | nginx в контейнере `sg_frontend` |
| `BACKEND_PORT` | 8000 | gunicorn в контейнере `sg_backend` |
| `FRONTEND_BIND` | 127.0.0.1 | только loopback на хосте |
---
Наружу открыт только прокси (80/443).
## 8. Резервное копирование (remote)
## Миграция данных
| Источник | Действие |
|----------|----------|
| CSV/JSON | UI → Импорт |
| Старый Django SQLite | экспорт JSON → Импорт |
| Локальный бэкап `.json` / `.sgpkg` | UI → «Импорт бэкапа» |
## Устранение неполадок
База — SQLite в Docker volume `sqlite_data`.
```bash
docker compose -f docker-compose.prod.yml ps
docker logs sg_frontend --tail 50
docker logs sg_backend --tail 50 # если поднят
docker compose -f docker-compose.prod.yml --profile with-backend exec backend \
python manage.py dumpdata contacts --indent 2 > backup-contacts-$(date +%F).json
```
Конфликт имени контейнера с dev: остановите `docker compose down` в том же каталоге перед prod-запуском.
Полный дамп через UI: **Импорт** → экспорт бэкапа (если включён в интерфейсе).
Копия файла БД (осторожно — только при остановленном backend):
```bash
docker compose -f docker-compose.prod.yml --profile with-backend stop backend
docker run --rm -v social-graph_sqlite_data:/data -v "$PWD":/backup alpine \
cp /data/db.sqlite3 /backup/db.sqlite3-$(date +%F)
docker compose -f docker-compose.prod.yml --profile with-backend start backend
```
---
## 9. Порты и безопасность
| Переменная | По умолчанию | Назначение |
|------------|--------------|------------|
| `FRONTEND_PORT` | 8080 | nginx в контейнере |
| `BACKEND_PORT` | 8000 | gunicorn |
| `FRONTEND_BIND` | 127.0.0.1 | только localhost |
Рекомендуется:
```bash
sudo ufw allow OpenSSH
sudo ufw allow 'Apache Full' # или 'Nginx Full'
sudo ufw enable
```
Порты 8080 и 8000 **не** открывать наружу — только через прокси.
---
## 10. Устранение неполадок
| Симптом | Что проверить |
|---------|----------------|
| 502 Bad Gateway | Контейнеры запущены? `docker ps`, логи `sg_frontend` / `sg_backend` |
| Белая страница после деплоя | Пересобран frontend? `VITE_DATA_MODE` совпадает с ожиданиями |
| API не отвечает в remote | Раскомментирован `ProxyPass /api` в Apache/nginx; backend в profile `with-backend` |
| «Сессия истекла» / 401 | `USE_JWT_AUTH=true` на backend; перелогин |
| Данные не общие между ПК | Нужен `VITE_DATA_MODE=remote`, не `local` |
Полезные команды:
```bash
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod ps
docker logs sg_frontend --tail 100
docker logs sg_backend --tail 100
docker compose -f docker-compose.prod.yml --profile with-backend exec backend python manage.py migrate
```
---
## 11. Краткая шпаргалка
**Local (только UI):**
```bash
cd /opt/social-graph
cp deploy/.env.prod.example deploy/.env.prod # VITE_DATA_MODE=local
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
# настроить Apache/nginx → 127.0.0.1:8080
```
**Remote (серверная БД + авторизация):**
```bash
cd /opt/social-graph
cp deploy/.env.prod.example deploy/.env.prod
# VITE_DATA_MODE=remote, DJANGO_SECRET_KEY, ALLOWED_HOSTS, USE_JWT_AUTH=true
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
# прокси: / → :8080, /api → :8000
```
Дополнительно: [README.md](../README.md), разработка — `docker compose up --build` (порты 5173 и 8000).
+10 -8
View File
@@ -11,21 +11,23 @@
# sudo apache2ctl configtest && sudo systemctl reload apache2
<VirtualHost *:80>
ServerName your-domain.com
ServerAlias www.your-domain.com
ServerName social.deepfishing.ru
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "http"
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
# Backend API (раскомментируйте при VITE_DATA_MODE=remote и profile with-backend)
# ProxyPass /api http://127.0.0.1:8000/api
# ProxyPassReverse /api http://127.0.0.1:8000/api
# Frontend SPA (nginx в контейнере sg_frontend)
# Вариант A (рекомендуется): весь трафик на frontend-контейнер.
# В remote-сборке frontend сам проксирует /api → backend.
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
# Вариант B: проксировать /api напрямую на backend (если frontend без nginx.remote.conf)
# ProxyPass /api http://127.0.0.1:8000/api
# ProxyPassReverse /api http://127.0.0.1:8000/api
# ProxyPass / http://127.0.0.1:8080/
# ProxyPassReverse / http://127.0.0.1:8080/
ErrorLog ${APACHE_LOG_DIR}/social-graph-error.log
CustomLog ${APACHE_LOG_DIR}/social-graph-access.log combined
</VirtualHost>
+363
View File
@@ -0,0 +1,363 @@
# Развёртывание Social Graph на [social.deepfishing.ru](https://social.deepfishing.ru)
Инструкция для production-сервера **social.deepfishing.ru**. Приложение публикуется через Docker; снаружи доступен только веб-прокси (80/443).
**Адрес приложения:** https://social.deepfishing.ru
---
## Схема
```text
https://social.deepfishing.ru (:443)
Прокси на хосте (Apache или nginx)
┌───────────────────────────────────────┐
│ frontend-контейнер (nginx) │
│ 127.0.0.1:8080 → SPA + статика │
└───────────────────────────────────────┘
↓ (режим remote)
┌───────────────────────────────────────┐
│ backend-контейнер (gunicorn) │
│ 127.0.0.1:8000 → Django API │
│ SQLite в Docker volume │
└───────────────────────────────────────┘
```
| Режим | Где данные | Backend |
|-------|------------|---------|
| **local** | IndexedDB в браузере | не нужен |
| **remote** | SQLite на сервере | обязателен |
Для **social.deepfishing.ru** рекомендуется **remote** + `USE_JWT_AUTH=true` (регистрация, вход, общая база).
---
## 1. Требования
- Linux (Ubuntu 22.04+ / Debian 12+)
- Docker Engine + Docker Compose
- Git
- DNS: запись **A** (или **AAAA**) `social.deepfishing.ru` → IP сервера
- Apache2 **или** nginx на хосте
Установка Docker:
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
# перелогиньтесь
docker compose version
```
---
## 2. Клонирование
```bash
sudo mkdir -p /opt/social-graph
sudo chown "$USER:$USER" /opt/social-graph
git clone <URL-репозитория> /opt/social-graph
cd /opt/social-graph
git checkout main
```
---
## 3. Конфигурация `deploy/.env.prod`
```bash
cd /opt/social-graph
cp deploy/.env.prod.example deploy/.env.prod
nano deploy/.env.prod
```
### Рекомендуемый конфиг для social.deepfishing.ru (remote)
```env
VITE_DATA_MODE=remote
FRONTEND_BIND=127.0.0.1
FRONTEND_PORT=8080
BACKEND_BIND=127.0.0.1
BACKEND_PORT=8000
DJANGO_SECRET_KEY=<сгенерируйте-длинную-случайную-строку>
ALLOWED_HOSTS=social.deepfishing.ru
USE_JWT_AUTH=true
```
Сгенерировать `DJANGO_SECRET_KEY`:
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(50))"
```
### Альтернатива — только UI (local)
Если backend не нужен, данные останутся в браузере каждого пользователя:
```env
VITE_DATA_MODE=local
FRONTEND_BIND=127.0.0.1
FRONTEND_PORT=8080
```
Файл `deploy/.env.prod` **не коммитить**.
---
## 4. Запуск контейнеров
```bash
cd /opt/social-graph
```
### Remote (frontend + backend)
> Обязательно: `VITE_DATA_MODE=remote` в `.env.prod` и профиль `with-backend` — иначе вход/регистрация не работают (ошибка 405).
```bash
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build
```
Команда поднимает **оба** контейнера. Frontend в remote-сборке проксирует `/api` → backend внутри Docker.
### Только frontend (local)
```bash
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
```
### Проверка на сервере
```bash
docker compose -f docker-compose.prod.yml ps
curl -s -o /dev/null -w "frontend: %{http_code}\n" http://127.0.0.1:8080/
curl -s -o /dev/null -w "backend: %{http_code}\n" http://127.0.0.1:8000/api/v1/meta/choices/
curl -s -o /dev/null -w "public: %{http_code}\n" https://social.deepfishing.ru/
```
Логи:
```bash
docker logs sg_frontend --tail 50
docker logs sg_backend --tail 50
```
> Перед prod-запуском остановите dev-контейнеры: `docker compose down`
---
## 5. Прокси на хосте
Контейнеры слушают **только** `127.0.0.1`. Снаружи — прокси на 80/443.
### Apache2
```bash
sudo apt install apache2
sudo a2enmod proxy proxy_http headers rewrite ssl
sudo cp deploy/apache/social-graph.conf /etc/apache2/sites-available/social-graph.conf
sudo nano /etc/apache2/sites-available/social-graph.conf
```
Пример VirtualHost:
```apache
<VirtualHost *:80>
ServerName social.deepfishing.ru
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
# API (режим remote)
ProxyPass /api http://127.0.0.1:8000/api
ProxyPassReverse /api http://127.0.0.1:8000/api
# Frontend SPA
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
ErrorLog ${APACHE_LOG_DIR}/social-graph-error.log
CustomLog ${APACHE_LOG_DIR}/social-graph-access.log combined
</VirtualHost>
```
```bash
sudo a2ensite social-graph.conf
sudo a2dissite 000-default.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
```
HTTPS (Let's Encrypt):
```bash
sudo apt install certbot python3-certbot-apache
sudo certbot --apache -d social.deepfishing.ru
```
После certbot сайт будет доступен по https://social.deepfishing.ru .
### nginx на хосте
```bash
sudo apt install nginx
sudo nano /etc/nginx/sites-available/social-graph
```
```nginx
server {
listen 80;
server_name social.deepfishing.ru;
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
```bash
sudo ln -s /etc/nginx/sites-available/social-graph /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d social.deepfishing.ru
```
---
## 6. Первый вход
1. Откройте https://social.deepfishing.ru
2. **Регистрация:** https://social.deepfishing.ru/register
3. Или **вход:** https://social.deepfishing.ru/login
Если на сервере есть пользователь `legacy` (данные после миграции), задайте пароль:
```bash
cd /opt/social-graph
docker compose -f docker-compose.prod.yml --profile with-backend exec backend python manage.py shell
```
```python
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.get(username='legacy')
u.set_password('ваш-надёжный-пароль')
u.save()
exit()
```
Вход: https://social.deepfishing.ru/login → логин `legacy`.
---
## 7. Обновление
```bash
cd /opt/social-graph
git pull
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
sudo systemctl reload apache2 # или: sudo nginx -s reload
```
Проверка: https://social.deepfishing.ru
---
## 8. Резервное копирование
```bash
cd /opt/social-graph
docker compose -f docker-compose.prod.yml --profile with-backend exec backend \
python manage.py dumpdata contacts --indent 2 \
> backup-social-deepfishing-$(date +%F).json
```
Копия SQLite (backend должен быть остановлен):
```bash
docker compose -f docker-compose.prod.yml --profile with-backend stop backend
docker run --rm -v social-graph_sqlite_data:/data -v "$PWD":/backup alpine \
cp /data/db.sqlite3 /backup/db-social-deepfishing-$(date +%F).sqlite3
docker compose -f docker-compose.prod.yml --profile with-backend start backend
```
---
## 9. Безопасность
```bash
sudo ufw allow OpenSSH
sudo ufw allow 'Apache Full' # или 'Nginx Full'
sudo ufw enable
```
| Порт | Доступ |
|------|--------|
| 443, 80 | открыт (прокси) |
| 8080, 8000 | только 127.0.0.1 |
---
## 10. Устранение неполадок
| Симптом | Решение |
|---------|---------|
| **405 Not Allowed** при входе/регистрации | Запросы `/api` попали во frontend вместо backend. Пересоберите с `VITE_DATA_MODE=remote` (в образе включится `nginx.remote.conf`) **и** поднимите backend: `--profile with-backend` |
| 502 на https://social.deepfishing.ru | `docker ps`, логи `sg_frontend` / `sg_backend` |
| Страница открывается, API 404 | Backend не запущен или нет прокси `/api` |
| 401 / не пускает | `USE_JWT_AUTH=true` в `.env.prod`, пересобрать backend |
| Данные не сохраняются между устройствами | `VITE_DATA_MODE=remote`, пересобрать frontend |
| Белый экран | `docker logs sg_frontend`, пересборка с `--build` |
```bash
cd /opt/social-graph
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod ps
docker logs sg_frontend --tail 100
docker logs sg_backend --tail 100
docker compose -f docker-compose.prod.yml --profile with-backend exec backend python manage.py migrate
```
---
## 11. Шпаргалка (social.deepfishing.ru, remote)
```bash
cd /opt/social-graph
cp deploy/.env.prod.example deploy/.env.prod
# ALLOWED_HOSTS=social.deepfishing.ru
# VITE_DATA_MODE=remote
# USE_JWT_AUTH=true
# DJANGO_SECRET_KEY=...
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod build
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod up -d --build frontend
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
# Apache: ServerName social.deepfishing.ru
# ProxyPass /api → 127.0.0.1:8000
# ProxyPass / → 127.0.0.1:8080
# certbot --apache -d social.deepfishing.ru
```
**Проверка:** https://social.deepfishing.ru
+5
View File
@@ -9,6 +9,10 @@ services:
ports:
- "${FRONTEND_BIND:-127.0.0.1}:${FRONTEND_PORT:-8080}:80"
restart: unless-stopped
depends_on:
backend:
condition: service_started
required: false
backend:
profiles: ["with-backend"]
@@ -21,6 +25,7 @@ services:
SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me-in-production}
DEBUG: "False"
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
USE_JWT_AUTH: ${USE_JWT_AUTH:-false}
DATABASE_PATH: /app/data/db.sqlite3
volumes:
- sqlite_data:/app/data
+1
View File
@@ -11,6 +11,7 @@ services:
- sqlite_data:/app/data
environment:
- DJANGO_SETTINGS_MODULE=config.settings
- USE_JWT_AUTH=true
ports:
- "8000:8000"
restart: unless-stopped

Some files were not shown because too many files have changed in this diff Show More