Compare commits
12
Commits
2a32c61934
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6eff89642 | ||
|
|
f5937040fa | ||
|
|
986d36ff51 | ||
|
|
10e7d65a00 | ||
|
|
ce4e9ac5e6 | ||
|
|
4eb4c145b6 | ||
|
|
85b8b2e9b8 | ||
|
|
e80d0d0e88 | ||
|
|
cafc7335ad | ||
|
|
b694a596b8 | ||
|
|
5155b3a37a | ||
|
|
35ec4c81ec |
@@ -44,17 +44,25 @@ docker compose up --build
|
|||||||
## Архитектура frontend
|
## Архитектура frontend
|
||||||
|
|
||||||
```text
|
```text
|
||||||
views / components
|
views / features / components
|
||||||
↓
|
↓
|
||||||
Pinia store (orchestration)
|
Pinia store (orchestration)
|
||||||
↓
|
↓
|
||||||
application/usecases
|
application/usecases + application/services
|
||||||
↓
|
↓
|
||||||
infrastructure/repositories → local (IndexedDB) | remote (REST)
|
infrastructure/repositories → local (IndexedDB) | remote (REST)
|
||||||
↓
|
↓
|
||||||
changelog + syncAdapter (noop, подготовка к sync)
|
core/pluginRegistry + plugins/* (optional extensions)
|
||||||
|
↓
|
||||||
|
changelog + syncAdapter (hybrid-ready)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
API v1: `/api/v1/` (legacy `/api/` сохранён). OpenAPI: `/api/v1/docs/`.
|
||||||
|
|
||||||
|
Плагины: `VITE_ENABLED_PLUGINS=tags` (frontend), `ENABLED_PLUGINS=tags` (backend).
|
||||||
|
|
||||||
|
Документация для авторов плагинов: [docs/PLUGIN_AUTHOR_GUIDE.md](docs/PLUGIN_AUTHOR_GUIDE.md).
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -92,13 +100,15 @@ social-graph/
|
|||||||
| GET | `/api/network-map-graph/` | Граф карты сети |
|
| GET | `/api/network-map-graph/` | Граф карты сети |
|
||||||
| GET | `/api/relation-types/` | Типы связей |
|
| GET | `/api/relation-types/` | Типы связей |
|
||||||
| GET | `/api/network-map-choices/` | Справочники карты |
|
| GET | `/api/network-map-choices/` | Справочники карты |
|
||||||
| POST | `/api/import/` | Импорт CSV/JSON (legacy) |
|
| GET | `/api/v1/meta/choices/` | Все справочники (типы связей, сферы, круги) |
|
||||||
|
| GET | `/api/v1/plugins/` | Манифест включённых плагинов |
|
||||||
|
| POST | `/api/import/` | Импорт CSV/JSON/vCard (legacy) |
|
||||||
|
|
||||||
В режиме `local` импорт и бэкап выполняются в браузере (экран **Импорт**).
|
В режиме `local` импорт и бэкап выполняются в браузере (экран **Импорт**).
|
||||||
|
|
||||||
## Импорт и бэкап (local-first)
|
## Импорт и бэкап (local-first)
|
||||||
|
|
||||||
- **CSV / JSON** — экран «Импорт», парсинг на клиенте.
|
- **CSV / JSON / vCard (.vcf)** — экран «Импорт», парсинг на клиенте.
|
||||||
- **Экспорт / импорт бэкапа** — JSON или зашифрованный `.sgpkg` (WebCrypto, пароль опционален).
|
- **Экспорт / импорт бэкапа** — JSON или зашифрованный `.sgpkg` (WebCrypto, пароль опционален).
|
||||||
|
|
||||||
## Production
|
## Production
|
||||||
@@ -153,5 +163,4 @@ docker compose exec frontend npm test -- --run
|
|||||||
- [ ] Синхронизация и shared-workspace
|
- [ ] Синхронизация и shared-workspace
|
||||||
- [ ] Поиск по организации и должности
|
- [ ] Поиск по организации и должности
|
||||||
- [ ] История изменений контакта
|
- [ ] История изменений контакта
|
||||||
- [ ] Импорт из vCard (.vcf)
|
|
||||||
- [ ] Уведомления / дни рождения
|
- [ ] Уведомления / дни рождения
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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.
@@ -12,13 +12,25 @@ ALLOWED_HOSTS = [
|
|||||||
h.strip() for h in os.environ.get('ALLOWED_HOSTS', '*').split(',') if h.strip()
|
h.strip() for h in os.environ.get('ALLOWED_HOSTS', '*').split(',') if h.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ENABLED_PLUGINS = [
|
||||||
|
p.strip()
|
||||||
|
for p in os.environ.get('ENABLED_PLUGINS', 'tags').split(',')
|
||||||
|
if p.strip()
|
||||||
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
|
'drf_spectacular',
|
||||||
|
'core',
|
||||||
'contacts',
|
'contacts',
|
||||||
|
'graph',
|
||||||
|
'import_export',
|
||||||
|
'plugins',
|
||||||
|
'plugins_tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -41,11 +53,36 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||||
'PAGE_SIZE': 100,
|
'PAGE_SIZE': 100,
|
||||||
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
}
|
||||||
|
|
||||||
|
SPECTACULAR_SETTINGS = {
|
||||||
|
'TITLE': 'Social Graph API',
|
||||||
|
'DESCRIPTION': 'API for Social Graph Builder (local-first with optional remote backend)',
|
||||||
|
'VERSION': '1.0.0',
|
||||||
}
|
}
|
||||||
|
|
||||||
CORS_ALLOW_ALL_ORIGINS = True
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
|
|
||||||
# Лимит тела запроса для импорта больших JSON (например, экспорт Monica с сотнями контактов)
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024 # 20 MB
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# Phase C: optional JWT auth (disabled by default)
|
||||||
|
USE_JWT_AUTH = os.environ.get('USE_JWT_AUTH', 'false').lower() in ('1', 'true', 'yes')
|
||||||
|
|
||||||
|
if USE_JWT_AUTH:
|
||||||
|
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')
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('api/v1/', include('api.v1.urls')),
|
||||||
path('api/', include('contacts.urls')),
|
path('api/', include('contacts.urls')),
|
||||||
|
path('api/v1/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
path('api/v1/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||||
|
path('api/v1/', include('core.auth_urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import pytest
|
||||||
|
from contacts.models import Contact, Relation
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client():
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
return APIClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_contact(db):
|
||||||
|
return Contact.objects.create(name='Иван Иванов', email='ivan@test.com')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def two_contacts(db):
|
||||||
|
a = Contact.objects.create(name='Алиса')
|
||||||
|
b = Contact.objects.create(name='Боб')
|
||||||
|
return a, b
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_relation(db, two_contacts):
|
||||||
|
a, b = two_contacts
|
||||||
|
return Relation.objects.create(source=a, target=b, relation_type='friend')
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -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='Важность (1–5)',
|
||||||
|
)),
|
||||||
|
('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),
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -1,65 +1,30 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from core.choices import (
|
||||||
LIFE_SPHERES = [
|
ALL_RELATION_TYPES,
|
||||||
('work', 'Работа'),
|
INTERACTION_INTENSITY,
|
||||||
('study', 'Учёба'),
|
)
|
||||||
('hobby', 'Хобби'),
|
|
||||||
('family', 'Семья'),
|
|
||||||
('health', 'Здоровье'),
|
|
||||||
('other', 'Другое'),
|
|
||||||
]
|
|
||||||
|
|
||||||
NETWORK_CIRCLES = [
|
|
||||||
('support', 'Круг поддержки'),
|
|
||||||
('productivity', 'Круг продуктивности'),
|
|
||||||
('development', 'Круг развития'),
|
|
||||||
]
|
|
||||||
|
|
||||||
INTERACTION_INTENSITY = [
|
|
||||||
('intense', 'Интенсивные контакты'),
|
|
||||||
('sparse', 'Редкие контакты'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Contact(models.Model):
|
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='Имя')
|
name = models.CharField(max_length=255, verbose_name='Имя')
|
||||||
email = models.EmailField(blank=True, verbose_name='Email')
|
email = models.EmailField(blank=True, verbose_name='Email')
|
||||||
phone = models.CharField(max_length=50, blank=True, verbose_name='Телефон')
|
phone = models.CharField(max_length=50, blank=True, verbose_name='Телефон')
|
||||||
organization = models.CharField(max_length=255, blank=True, verbose_name='Организация')
|
organization = models.CharField(max_length=255, blank=True, verbose_name='Организация')
|
||||||
position = 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='Заметки')
|
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='Важность (1–5)',
|
|
||||||
)
|
|
||||||
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -72,19 +37,138 @@ class Contact(models.Model):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
RELATION_TYPES = [
|
class NetworkMapType(models.Model):
|
||||||
('colleague', 'Коллега'),
|
"""Тип карты сети: настраиваемые секторы и концентрические круги."""
|
||||||
('friend', 'Друг'),
|
|
||||||
('family', 'Родственник'),
|
owner = models.ForeignKey(
|
||||||
('acquaintance', 'Знакомый'),
|
settings.AUTH_USER_MODEL,
|
||||||
('business', 'Деловой партнёр'),
|
on_delete=models.CASCADE,
|
||||||
('other', 'Другое'),
|
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='Важность (1–5)',
|
||||||
|
)
|
||||||
|
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):
|
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(
|
source = models.ForeignKey(
|
||||||
Contact,
|
Contact,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@@ -99,7 +183,7 @@ class Relation(models.Model):
|
|||||||
)
|
)
|
||||||
relation_type = models.CharField(
|
relation_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=RELATION_TYPES,
|
choices=ALL_RELATION_TYPES,
|
||||||
default='acquaintance',
|
default='acquaintance',
|
||||||
verbose_name='Тип связи',
|
verbose_name='Тип связи',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
from rest_framework import serializers
|
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):
|
class ContactSerializer(serializers.ModelSerializer):
|
||||||
@@ -10,9 +20,6 @@ class ContactSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'email', 'phone',
|
'id', 'name', 'email', 'phone',
|
||||||
'organization', 'position', 'notes',
|
'organization', 'position', 'notes',
|
||||||
'life_sphere', 'network_circle', 'importance',
|
|
||||||
'include_on_network_map',
|
|
||||||
'map_angle', 'map_radius_ratio',
|
|
||||||
'created_at', 'updated_at', 'relations_count',
|
'created_at', 'updated_at', 'relations_count',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', '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']
|
read_only_fields = ['id', 'created_at', 'source_name', 'target_name']
|
||||||
|
|
||||||
def validate(self, data):
|
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(
|
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
|
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):
|
class GraphSerializer(serializers.Serializer):
|
||||||
"""Граф для vis.js: nodes + edges."""
|
"""Граф для vis.js: nodes + edges."""
|
||||||
|
|
||||||
@@ -60,9 +159,6 @@ class GraphSerializer(serializers.Serializer):
|
|||||||
'label': c.name,
|
'label': c.name,
|
||||||
'title': f'{c.organization}\n{c.position}'.strip() or c.name,
|
'title': f'{c.organization}\n{c.position}'.strip() or c.name,
|
||||||
'group': c.organization or 'default',
|
'group': c.organization or 'default',
|
||||||
'life_sphere': c.life_sphere,
|
|
||||||
'network_circle': c.network_circle,
|
|
||||||
'importance': c.importance,
|
|
||||||
}
|
}
|
||||||
for c in contacts
|
for c in contacts
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
|
"""Legacy /api/ routes — mirrors v1 for backward compatibility."""
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register('contacts', views.ContactViewSet)
|
|
||||||
router.register('relations', views.RelationViewSet)
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include('api.v1.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),
|
|
||||||
]
|
]
|
||||||
|
|||||||
+60
-284
@@ -1,23 +1,20 @@
|
|||||||
import csv
|
from rest_framework import viewsets
|
||||||
import io
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
import json
|
|
||||||
|
|
||||||
from rest_framework import viewsets, status
|
from core.access import use_jwt_auth
|
||||||
from rest_framework.decorators import api_view, action
|
from core.drf_mixins import JwtAuthMixin
|
||||||
from rest_framework.response import Response
|
from .mixins import OwnerScopedMixin
|
||||||
|
from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType
|
||||||
from .models import (
|
from .serializers import (
|
||||||
Contact,
|
ContactSerializer,
|
||||||
Relation,
|
RelationSerializer,
|
||||||
RELATION_TYPES,
|
NetworkMapSerializer,
|
||||||
LIFE_SPHERES,
|
NetworkMapMembershipSerializer,
|
||||||
NETWORK_CIRCLES,
|
NetworkMapTypeSerializer,
|
||||||
INTERACTION_INTENSITY,
|
|
||||||
)
|
)
|
||||||
from .serializers import ContactSerializer, RelationSerializer, GraphSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class ContactViewSet(viewsets.ModelViewSet):
|
class ContactViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet):
|
||||||
queryset = Contact.objects.all()
|
queryset = Contact.objects.all()
|
||||||
serializer_class = ContactSerializer
|
serializer_class = ContactSerializer
|
||||||
|
|
||||||
@@ -29,282 +26,61 @@ class ContactViewSet(viewsets.ModelViewSet):
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class RelationViewSet(viewsets.ModelViewSet):
|
class RelationViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet):
|
||||||
queryset = Relation.objects.select_related('source', 'target').all()
|
queryset = Relation.objects.select_related('source', 'target').all()
|
||||||
serializer_class = RelationSerializer
|
serializer_class = RelationSerializer
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
class NetworkMapViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet):
|
||||||
def graph_data(request):
|
queryset = NetworkMap.objects.select_related('map_type').all()
|
||||||
"""Возвращает граф: nodes + edges для vis.js."""
|
serializer_class = NetworkMapSerializer
|
||||||
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})
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
class NetworkMapTypeViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet):
|
||||||
def network_map_graph(request):
|
queryset = NetworkMapType.objects.all()
|
||||||
"""Граф только для карты сети: контакты с include_on_network_map и связи между ними."""
|
serializer_class = NetworkMapTypeSerializer
|
||||||
contacts = list(
|
|
||||||
Contact.objects.filter(include_on_network_map=True).order_by('name')
|
def perform_destroy(self, instance):
|
||||||
)
|
if instance.is_default:
|
||||||
allowed_ids = {c.id for c in contacts}
|
raise ValidationError({'detail': 'Нельзя удалить тип карты по умолчанию.'})
|
||||||
nodes = [
|
if instance.maps.exists():
|
||||||
{
|
raise ValidationError({'detail': 'Тип используется картами сети. Сначала смените тип у карт.'})
|
||||||
'id': c.id,
|
instance.delete()
|
||||||
'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})
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
class NetworkMapMembershipViewSet(JwtAuthMixin, viewsets.ModelViewSet):
|
||||||
def relation_types(request):
|
serializer_class = NetworkMapMembershipSerializer
|
||||||
"""Список допустимых типов связей."""
|
|
||||||
return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES])
|
|
||||||
|
|
||||||
|
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 perform_create(self, serializer):
|
||||||
def network_map_choices(request):
|
map_id = self.kwargs.get('map_pk')
|
||||||
"""Подписи для карты сети: сферы, круги, интенсивность связей."""
|
if use_jwt_auth():
|
||||||
return Response({
|
user = self.request.user
|
||||||
'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES],
|
if not user or not user.is_authenticated:
|
||||||
'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES],
|
raise PermissionDenied()
|
||||||
'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY],
|
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 perform_update(self, serializer):
|
||||||
def _monica_contact_fields(contact_data):
|
if use_jwt_auth():
|
||||||
"""Из вложенного data контакта Monica (экспорт account.data) извлекает телефон, email, заметки."""
|
user = self.request.user
|
||||||
phone = ''
|
contact = serializer.validated_data.get('contact', serializer.instance.contact)
|
||||||
email = ''
|
if contact.owner_id != user.id:
|
||||||
notes_parts = []
|
raise ValidationError({'contact': 'Контакт не принадлежит текущему пользователю.'})
|
||||||
for block in contact_data or []:
|
serializer.save()
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'core'
|
||||||
@@ -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
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -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': 'Пароль изменён.'})
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -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.
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GraphConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'graph'
|
||||||
@@ -0,0 +1,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,
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -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)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ImportExportConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'import_export'
|
||||||
@@ -0,0 +1,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).'
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PluginsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'plugins'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import plugins.registry # noqa: F401
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Plugin platform base classes and registry."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import URLPattern
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(ABC):
|
||||||
|
id: str = ''
|
||||||
|
version: str = '1.0.0'
|
||||||
|
min_core_version: str = '1.0.0'
|
||||||
|
permissions: List[str] = []
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def urlpatterns(self) -> List[URLPattern]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def installed_apps(self) -> List[str]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_REGISTRY: dict[str, Plugin] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_plugin(plugin: Plugin) -> None:
|
||||||
|
_REGISTRY[plugin.id] = plugin
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin(plugin_id: str) -> Plugin | None:
|
||||||
|
return _REGISTRY.get(plugin_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_enabled_plugins() -> List[Plugin]:
|
||||||
|
enabled = getattr(settings, 'ENABLED_PLUGINS', [])
|
||||||
|
return [_REGISTRY[pid] for pid in enabled if pid in _REGISTRY]
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_urlpatterns() -> List[URLPattern]:
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
patterns: List[URLPattern] = []
|
||||||
|
for plugin in get_enabled_plugins():
|
||||||
|
patterns.append(
|
||||||
|
path(f'plugins/{plugin.id}/', include((plugin.urlpatterns(), plugin.id)))
|
||||||
|
)
|
||||||
|
return patterns
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Register built-in plugins."""
|
||||||
|
|
||||||
|
from plugins.base import register_plugin
|
||||||
|
from plugins_tags.plugin import TagsPlugin
|
||||||
|
|
||||||
|
register_plugin(TagsPlugin())
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
from .base import plugin_urlpatterns
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('plugins/', views.PluginManifestView.as_view(), name='plugin-manifest'),
|
||||||
|
*plugin_urlpatterns(),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PluginsTagsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'plugins_tags'
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contacts', '0006_network_maps'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContactTag',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('label', models.CharField(max_length=100, verbose_name='Тег')),
|
||||||
|
('workspace_id', models.CharField(db_index=True, default='personal', max_length=64)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='contacts.contact', verbose_name='Контакт')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Тег контакта',
|
||||||
|
'verbose_name_plural': 'Теги контактов',
|
||||||
|
'ordering': ['label'],
|
||||||
|
'unique_together': {('contact', 'label', 'workspace_id')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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}'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from plugins.base import Plugin
|
||||||
|
from plugins_tags.urls import urlpatterns
|
||||||
|
|
||||||
|
|
||||||
|
class TagsPlugin(Plugin):
|
||||||
|
id = 'tags'
|
||||||
|
version = '1.0.0'
|
||||||
|
min_core_version = '1.0.0'
|
||||||
|
permissions = ['read:contacts', 'write:contacts']
|
||||||
|
|
||||||
|
def urlpatterns(self):
|
||||||
|
return urlpatterns
|
||||||
|
|
||||||
|
def installed_apps(self):
|
||||||
|
return ['plugins_tags']
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import ContactTag
|
||||||
|
|
||||||
|
|
||||||
|
class ContactTagSerializer(serializers.ModelSerializer):
|
||||||
|
contact_name = serializers.CharField(source='contact.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ContactTag
|
||||||
|
fields = ['id', 'contact', 'contact_name', 'label', 'workspace_id', 'created_at']
|
||||||
|
read_only_fields = ['id', 'created_at', 'contact_name']
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import ContactTagViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('contact-tags', ContactTagViewSet, basename='contact-tag')
|
||||||
|
|
||||||
|
urlpatterns = router.urls
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "social-graph-backend"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Social Graph Builder API"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"Django==4.2.9",
|
||||||
|
"djangorestframework==3.14.0",
|
||||||
|
"django-cors-headers==4.3.1",
|
||||||
|
"drf-spectacular==0.27.1",
|
||||||
|
"djangorestframework-simplejwt==5.3.1",
|
||||||
|
"Pillow==10.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest==8.0.0", "pytest-django==4.8.0"]
|
||||||
|
|
||||||
|
[project.entry-points."social_graph.plugins"]
|
||||||
|
tags = "plugins_tags.plugin:TagsPlugin"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = [
|
||||||
|
"config*",
|
||||||
|
"contacts*",
|
||||||
|
"core*",
|
||||||
|
"graph*",
|
||||||
|
"import_export*",
|
||||||
|
"plugins*",
|
||||||
|
"plugins_tags*",
|
||||||
|
"api*",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "config.settings"
|
||||||
|
python_files = ["tests.py", "test_*.py", "*_tests.py"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
DJANGO_SETTINGS_MODULE = config.settings
|
||||||
|
python_files = tests.py test_*.py *_tests.py
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
Django==4.2.9
|
Django==4.2.9
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
django-cors-headers==4.3.1
|
django-cors-headers==4.3.1
|
||||||
|
drf-spectacular==0.27.1
|
||||||
|
djangorestframework-simplejwt==5.3.1
|
||||||
Pillow==10.2.0
|
Pillow==10.2.0
|
||||||
|
pytest==8.0.0
|
||||||
|
pytest-django==4.8.0
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_contacts_list(api_client, sample_contact):
|
||||||
|
response = api_client.get('/api/v1/contacts/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data['count'] == 1
|
||||||
|
assert response.data['results'][0]['name'] == 'Иван Иванов'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_contact_create(api_client):
|
||||||
|
response = api_client.post('/api/v1/contacts/', {'name': 'Новый контакт'}, format='json')
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.data['name'] == 'Новый контакт'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_relations_list(api_client, sample_relation):
|
||||||
|
response = api_client.get('/api/v1/relations/')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data['count'] == 1
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -9,4 +9,5 @@ BACKEND_PORT=8000
|
|||||||
|
|
||||||
# Только при profile with-backend (VITE_DATA_MODE=remote)
|
# Только при profile with-backend (VITE_DATA_MODE=remote)
|
||||||
DJANGO_SECRET_KEY=replace-with-long-random-string
|
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
@@ -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
|
```text
|
||||||
[Прокси на хосте :80/:443]
|
Интернет :80 / :443
|
||||||
→ frontend-контейнер (nginx, 127.0.0.1:8080)
|
↓
|
||||||
→ backend-контейнер (gunicorn, 127.0.0.1:8000 — только при remote)
|
Прокси на хосте (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
|
```bash
|
||||||
git clone <repo-url> /opt/social-graph
|
curl -fsSL https://get.docker.com | sh
|
||||||
cd /opt/social-graph
|
sudo usermod -aG docker "$USER"
|
||||||
git checkout main # или нужная ветка
|
# перелогиньтесь, чтобы группа docker применилась
|
||||||
|
docker compose version
|
||||||
cp deploy/.env.prod.example deploy/.env.prod
|
|
||||||
# отредактируйте deploy/.env.prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`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
|
```env
|
||||||
VITE_DATA_MODE=local
|
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
|
```bash
|
||||||
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 up -d --build frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Проверка:
|
### Frontend + backend (режим remote)
|
||||||
|
|
||||||
```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) и поднимите оба сервиса:
|
|
||||||
|
|
||||||
```bash
|
```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 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
|
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
|
```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
|
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
|
```bash
|
||||||
cd /opt/social-graph
|
cd /opt/social-graph
|
||||||
git pull
|
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 up -d --build frontend
|
||||||
|
|
||||||
# при remote:
|
# при 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
|
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)
|
||||||
|
|
||||||
## Миграция данных
|
База — SQLite в Docker volume `sqlite_data`.
|
||||||
|
|
||||||
| Источник | Действие |
|
|
||||||
|----------|----------|
|
|
||||||
| CSV/JSON | UI → Импорт |
|
|
||||||
| Старый Django SQLite | экспорт JSON → Импорт |
|
|
||||||
| Локальный бэкап `.json` / `.sgpkg` | UI → «Импорт бэкапа» |
|
|
||||||
|
|
||||||
## Устранение неполадок
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml ps
|
docker compose -f docker-compose.prod.yml --profile with-backend exec backend \
|
||||||
docker logs sg_frontend --tail 50
|
python manage.py dumpdata contacts --indent 2 > backup-contacts-$(date +%F).json
|
||||||
docker logs sg_backend --tail 50 # если поднят
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Конфликт имени контейнера с 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).
|
||||||
|
|||||||
@@ -11,21 +11,23 @@
|
|||||||
# sudo apache2ctl configtest && sudo systemctl reload apache2
|
# sudo apache2ctl configtest && sudo systemctl reload apache2
|
||||||
|
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
ServerName your-domain.com
|
ServerName social.deepfishing.ru
|
||||||
ServerAlias www.your-domain.com
|
|
||||||
|
|
||||||
ProxyPreserveHost On
|
ProxyPreserveHost On
|
||||||
RequestHeader set X-Forwarded-Proto "http"
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
|
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
|
||||||
|
|
||||||
# Backend API (раскомментируйте при VITE_DATA_MODE=remote и profile with-backend)
|
# Вариант A (рекомендуется): весь трафик на frontend-контейнер.
|
||||||
# ProxyPass /api http://127.0.0.1:8000/api
|
# В remote-сборке frontend сам проксирует /api → backend.
|
||||||
# ProxyPassReverse /api http://127.0.0.1:8000/api
|
|
||||||
|
|
||||||
# Frontend SPA (nginx в контейнере sg_frontend)
|
|
||||||
ProxyPass / http://127.0.0.1:8080/
|
ProxyPass / http://127.0.0.1:8080/
|
||||||
ProxyPassReverse / 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
|
ErrorLog ${APACHE_LOG_DIR}/social-graph-error.log
|
||||||
CustomLog ${APACHE_LOG_DIR}/social-graph-access.log combined
|
CustomLog ${APACHE_LOG_DIR}/social-graph-access.log combined
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,6 +9,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_BIND:-127.0.0.1}:${FRONTEND_PORT:-8080}:80"
|
- "${FRONTEND_BIND:-127.0.0.1}:${FRONTEND_PORT:-8080}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
required: false
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
profiles: ["with-backend"]
|
profiles: ["with-backend"]
|
||||||
@@ -21,6 +25,7 @@ services:
|
|||||||
SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me-in-production}
|
||||||
DEBUG: "False"
|
DEBUG: "False"
|
||||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||||
|
USE_JWT_AUTH: ${USE_JWT_AUTH:-false}
|
||||||
DATABASE_PATH: /app/data/db.sqlite3
|
DATABASE_PATH: /app/data/db.sqlite3
|
||||||
volumes:
|
volumes:
|
||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_SETTINGS_MODULE=config.settings
|
- DJANGO_SETTINGS_MODULE=config.settings
|
||||||
|
- USE_JWT_AUTH=true
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user