Cleanup: remove tracked pycache and db.sqlite3; add map membership backfill, new components, backups, docs

This commit is contained in:
gitrusprus
2026-06-28 22:16:01 +03:00
parent 4eb4c145b6
commit ce4e9ac5e6
46 changed files with 12125 additions and 176 deletions
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.
Binary file not shown.
Binary file not shown.
@@ -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,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),
]
+1 -1
View File
@@ -142,7 +142,7 @@ class NetworkMapMembershipSerializer(serializers.ModelSerializer):
'map_angle', 'map_radius_ratio', 'map_angle', 'map_radius_ratio',
'created_at', 'updated_at', 'created_at', 'updated_at',
] ]
read_only_fields = ['id', 'created_at', 'updated_at', 'contact_name', 'map_name'] read_only_fields = ['id', 'map', 'created_at', 'updated_at', 'contact_name', 'map_name']
class GraphSerializer(serializers.Serializer): class GraphSerializer(serializers.Serializer):
File diff suppressed because it is too large Load Diff
Binary file not shown.
+22 -17
View File
@@ -29,19 +29,20 @@ def _network_maps_qs(user):
return qs return qs
def node_from_contact(contact): def node_from_contact(contact, defaults=None):
defaults = defaults or {}
return { return {
'id': contact.id, 'id': contact.id,
'label': contact.name, 'label': contact.name,
'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])), 'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])),
'group': contact.organization or 'default', 'group': contact.organization or 'default',
**defaults,
} }
def node_from_membership(membership): def node_from_membership(membership):
contact = membership.contact contact = membership.contact
return { return node_from_contact(contact, {
**node_from_contact(contact),
'life_sphere': membership.life_sphere, 'life_sphere': membership.life_sphere,
'network_circle': membership.network_circle, 'network_circle': membership.network_circle,
'importance': membership.importance, 'importance': membership.importance,
@@ -49,7 +50,22 @@ def node_from_membership(membership):
'map_angle': membership.map_angle, 'map_angle': membership.map_angle,
'map_radius_ratio': membership.map_radius_ratio, 'map_radius_ratio': membership.map_radius_ratio,
'membership_id': membership.id, '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): def edge_from_relation(relation):
@@ -85,19 +101,8 @@ def build_network_map_graph(map_id=None, user=None):
if not network_map: if not network_map:
return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''} return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''}
memberships = list( nodes = nodes_for_network_map(network_map, user)
NetworkMapMembership.objects.filter(map_id=map_id) allowed_ids = {n['id'] for n in nodes}
.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
]
allowed_ids = {m.contact_id for m in memberships}
nodes = [node_from_membership(m) for m in memberships]
relations = _relations_qs(user) relations = _relations_qs(user)
edges = [ edges = [
edge_from_relation(r) edge_from_relation(r)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -9,4 +9,5 @@ BACKEND_PORT=8000
# Только при profile with-backend (VITE_DATA_MODE=remote) # Только при 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
View File
@@ -1,87 +1,130 @@
# Production deploy (контейнеры + прокси на хосте) # Развёртывание Social Graph на сервере
Инструкция для Linux-сервера (Ubuntu/Debian). Приложение публикуется через Docker; снаружи доступен только веб-прокси (80/443).
> **Production:** [deploy/social.deepfishing.ru.md](./social.deepfishing.ru.md) — инструкция для https://social.deepfishing.ru
## Схема ## Схема
```text ```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 / **vCard (.vcf)** | 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).
+359
View File
@@ -0,0 +1,359 @@
# Развёртывание 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)
```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 up -d --build frontend
docker compose -f docker-compose.prod.yml --env-file deploy/.env.prod --profile with-backend up -d --build backend
```
### Только 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. Устранение неполадок
| Симптом | Решение |
|---------|---------|
| 502 на https://social.deepfishing.ru | `docker ps`, логи `sg_frontend` / `sg_backend` |
| Страница открывается, API 404 | В прокси включён `ProxyPass /api``:8000` |
| 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
+1
View File
@@ -21,6 +21,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
@@ -83,4 +83,19 @@ describe('getGraphBundle', () => {
expect(bundle.edges).toHaveLength(1) expect(bundle.edges).toHaveLength(1)
expect(bundle.edges[0].id).toBe('10') expect(bundle.edges[0].id).toBe('10')
}) })
it('returns empty graph when map has no memberships', async () => {
listContacts.mockResolvedValue([
{ id: '1', name: 'Анна' },
{ id: '2', name: 'Борис' },
])
listRelations.mockResolvedValue([
{ id: '10', source: '1', target: '2', relation_type: 'friend' },
])
listMembershipsByMap.mockResolvedValue([])
const bundle = await getGraphBundle({ mapId: 'map-1' })
expect(bundle.nodes).toHaveLength(0)
expect(bundle.edges).toHaveLength(0)
})
}) })
@@ -7,6 +7,8 @@ import {
const mapRepo = () => getNetworkMapRepository() const mapRepo = () => getNetworkMapRepository()
const membershipRepo = () => getNetworkMapMembershipRepository() const membershipRepo = () => getNetworkMapMembershipRepository()
export const DEFAULT_NETWORK_MAP_NAME = 'Основная карта'
export async function listNetworkMaps() { export async function listNetworkMaps() {
return mapRepo().list() return mapRepo().list()
} }
@@ -26,6 +28,19 @@ export async function createNetworkMap(payload) {
return created return created
} }
export async function ensureDefaultNetworkMap() {
const maps = await listNetworkMaps()
if (maps.length) return maps[0]
const { getDefaultNetworkMapType } = await import('./networkMapTypes')
const defaultType = await getDefaultNetworkMapType()
return createNetworkMap({
name: DEFAULT_NETWORK_MAP_NAME,
description: '',
mapTypeId: defaultType?.id,
})
}
export async function updateNetworkMap(id, payload) { export async function updateNetworkMap(id, payload) {
const updated = await mapRepo().update(id, payload) const updated = await mapRepo().update(id, payload)
await appendChange({ await appendChange({
+223 -38
View File
@@ -1,72 +1,257 @@
<template> <template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')"> <Teleport to="body">
<div class="modal"> <div v-if="open" class="modal-overlay add-contact-modal" @click.self="onClose">
<div class="modal-header"> <div class="modal" role="dialog" aria-labelledby="add-contact-title">
<h3>Добавить на карту</h3> <div class="modal-header">
<button class="btn btn-secondary btn-sm" type="button" @click="$emit('close')"></button> <h3 id="add-contact-title">Добавить на карту</h3>
</div> <button class="btn btn-secondary btn-sm" type="button" @click="onClose"></button>
<div class="form-group"> </div>
<label>Контакт</label>
<SearchableSelect <div class="form-group">
v-model="selectedContactId" <label for="add-contact-search">Поиск контакта</label>
:options="contactOptions" <input
placeholder="Выберите контакт..." id="add-contact-search"
/> ref="searchInputRef"
</div> v-model="searchQuery"
<div class="modal-footer"> type="search"
<button type="button" class="btn btn-secondary" @click="$emit('close')">Отмена</button> class="form-control"
<button placeholder="Введите имя..."
type="button" autocomplete="off"
class="btn btn-primary" @keydown.enter.prevent="selectFirstResult"
:disabled="!selectedContactId" />
@click="onAdd" <p v-if="!searchQuery.trim()" class="field-hint text-muted">
> Начните вводить имя появится список контактов.
Добавить </p>
</button> </div>
<div v-if="searchQuery.trim()" class="contact-results">
<p v-if="searching" class="text-muted results-status">Поиск</p>
<p v-else-if="!searchResults.length" class="text-muted results-status">Ничего не найдено</p>
<button
v-for="contact in searchResults"
:key="contact.id"
type="button"
class="contact-result"
:class="{ 'is-selected': String(selectedContactId) === String(contact.id) }"
@click="selectContact(contact)"
>
<span class="contact-result__name">{{ contact.name }}</span>
<span v-if="contact.organization" class="contact-result__meta">{{ contact.organization }}</span>
</button>
</div>
<p v-if="selectedContact" class="selected-summary">
Выбран: <strong>{{ selectedContact.name }}</strong>
</p>
<p v-if="error" class="form-error">{{ error }}</p>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onClose">Отмена</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedContactId || saving"
@click="submit"
>
{{ saving ? 'Добавление…' : 'Добавить' }}
</button>
</div>
</div> </div>
</div> </div>
</div> </Teleport>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import SearchableSelect from './SearchableSelect.vue' import { listContacts } from '../application/usecases/contacts'
import { normalizeApiError } from '../lib/api/errors'
const props = defineProps({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
contacts: { type: Array, default: () => [] },
memberContactIds: { type: Array, default: () => [] }, memberContactIds: { type: Array, default: () => [] },
onAdd: { type: Function, required: true },
}) })
const emit = defineEmits(['close', 'add'])
const emit = defineEmits(['close'])
const searchInputRef = ref(null)
const searchQuery = ref('')
const searchResults = ref([])
const selectedContactId = ref('') const selectedContactId = ref('')
const searching = ref(false)
const saving = ref(false)
const error = ref('')
let searchTimer = null
let searchRequestId = 0
const memberSet = computed(() => new Set(props.memberContactIds.map(String))) const memberSet = computed(() => new Set(props.memberContactIds.map(String)))
const contactOptions = computed(() => const selectedContact = computed(() =>
props.contacts searchResults.value.find((c) => String(c.id) === String(selectedContactId.value)) || null
.filter((c) => !memberSet.value.has(String(c.id)))
.map((c) => ({ value: String(c.id), label: c.name }))
) )
function resetState() {
searchQuery.value = ''
searchResults.value = []
selectedContactId.value = ''
searching.value = false
saving.value = false
error.value = ''
}
function onClose() {
emit('close')
}
function selectContact(contact) {
selectedContactId.value = String(contact.id)
error.value = ''
}
function selectFirstResult() {
const first = searchResults.value[0]
if (first) selectContact(first)
}
async function runSearch(query) {
const requestId = ++searchRequestId
searching.value = true
error.value = ''
try {
const rows = await listContacts(query)
if (requestId !== searchRequestId) return
searchResults.value = rows.filter((c) => !memberSet.value.has(String(c.id)))
if (
selectedContactId.value
&& !searchResults.value.some((c) => String(c.id) === String(selectedContactId.value))
) {
selectedContactId.value = ''
}
} catch (e) {
if (requestId !== searchRequestId) return
searchResults.value = []
error.value = normalizeApiError(e).message
} finally {
if (requestId === searchRequestId) {
searching.value = false
}
}
}
watch( watch(
() => props.open, () => props.open,
(isOpen) => { async (isOpen) => {
if (isOpen) selectedContactId.value = '' if (!isOpen) {
resetState()
return
}
resetState()
await nextTick()
searchInputRef.value?.focus()
} }
) )
function onAdd() { watch(searchQuery, (value) => {
if (!selectedContactId.value) return clearTimeout(searchTimer)
emit('add', selectedContactId.value) selectedContactId.value = ''
error.value = ''
const query = value.trim()
if (!query) {
searchResults.value = []
searching.value = false
return
}
searchTimer = setTimeout(() => {
runSearch(query)
}, 250)
})
async function submit() {
if (!selectedContactId.value || saving.value) return
saving.value = true
error.value = ''
try {
await props.onAdd(selectedContactId.value)
} catch (e) {
error.value = normalizeApiError(e).message
} finally {
saving.value = false
}
} }
</script> </script>
<style scoped> <style scoped>
.add-contact-modal {
z-index: 1100;
}
.field-hint,
.results-status {
margin: 8px 0 0;
font-size: 13px;
}
.contact-results {
max-height: 260px;
overflow-y: auto;
margin-top: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-alt);
}
.contact-result {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 10px 12px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.contact-result:last-child {
border-bottom: none;
}
.contact-result:hover,
.contact-result.is-selected {
background: rgba(91, 141, 238, 0.12);
}
.contact-result__name {
font-size: 14px;
font-weight: 500;
}
.contact-result__meta {
font-size: 12px;
color: var(--text-muted);
}
.selected-summary {
margin: 14px 0 0;
font-size: 13px;
}
.modal-footer { .modal-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
margin-top: 20px; margin-top: 20px;
} }
.form-error {
margin: 12px 0 0;
font-size: 13px;
color: var(--red, #e74c3c);
}
</style> </style>
@@ -0,0 +1,35 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="onCancel">
<div class="modal">
<div class="modal-header">
<h3>Новый контакт</h3>
<button type="button" class="btn btn-secondary btn-sm" @click="onCancel"></button>
</div>
<ContactForm
:initial="{}"
:initial-map-ids="initialMapIds"
@submit="onSubmit"
@cancel="onCancel"
/>
</div>
</div>
</template>
<script setup>
import ContactForm from './ContactForm.vue'
defineProps({
open: { type: Boolean, default: false },
initialMapIds: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'created'])
function onCancel() {
emit('close')
}
function onSubmit(contactData, mapIds, pluginPayload) {
emit('created', contactData, mapIds, pluginPayload)
}
</script>
@@ -0,0 +1,92 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="graph-node-menu-overlay"
@click="close"
@contextmenu.prevent="close"
/>
<div
v-if="open"
class="graph-node-menu"
:style="{ left: `${x}px`, top: `${y}px` }"
role="menu"
@click.stop
@contextmenu.prevent
>
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onCreateContact">
Добавить контакт
</button>
</div>
</Teleport>
</template>
<script setup>
import { onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
})
const emit = defineEmits(['close', 'create-contact'])
function close() {
emit('close')
}
function onCreateContact() {
emit('create-contact')
close()
}
function onKeyDown(event) {
if (event.key === 'Escape' && props.open) close()
}
watch(() => props.open, (isOpen) => {
if (isOpen) window.addEventListener('keydown', onKeyDown)
else window.removeEventListener('keydown', onKeyDown)
})
onMounted(() => {
if (props.open) window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
</script>
<style scoped>
.graph-node-menu-overlay {
position: fixed;
inset: 0;
z-index: 900;
}
.graph-node-menu {
position: fixed;
z-index: 901;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 6px 0;
}
.graph-node-menu__item {
display: block;
width: 100%;
text-align: left;
padding: 8px 14px;
font-size: 13px;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
}
.graph-node-menu__item:hover {
background: var(--accent-dim);
}
</style>
+5 -2
View File
@@ -16,7 +16,7 @@
@blur="onBlur" @blur="onBlur"
@keydown="onKeydown" @keydown="onKeydown"
/> />
<ul v-if="open && filteredOptions.length" class="searchable-select__list" role="listbox"> <ul v-if="open && query.trim() && filteredOptions.length" class="searchable-select__list" role="listbox">
<li <li
v-for="(opt, index) in filteredOptions" v-for="(opt, index) in filteredOptions"
:key="String(opt.value)" :key="String(opt.value)"
@@ -32,6 +32,9 @@
{{ opt.label }} {{ opt.label }}
</li> </li>
</ul> </ul>
<p v-else-if="open && !query.trim()" class="searchable-select__empty">
Начните вводить имя для поиска
</p>
<p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty"> <p v-else-if="open && query.trim() && !filteredOptions.length" class="searchable-select__empty">
Ничего не найдено Ничего не найдено
</p> </p>
@@ -62,7 +65,7 @@ const allOptions = computed(() =>
const filteredOptions = computed(() => { const filteredOptions = computed(() => {
const q = query.value.trim().toLocaleLowerCase('ru') const q = query.value.trim().toLocaleLowerCase('ru')
if (!q) return allOptions.value if (!q) return []
return allOptions.value.filter((o) => return allOptions.value.filter((o) =>
o.label.toLocaleLowerCase('ru').includes(q) o.label.toLocaleLowerCase('ru').includes(q)
) )
@@ -9,11 +9,15 @@ export function useGraphNodeContextMenu() {
const contextMenuEdge = ref(null) const contextMenuEdge = ref(null)
const edgeContextMenuX = ref(0) const edgeContextMenuX = ref(0)
const edgeContextMenuY = ref(0) const edgeContextMenuY = ref(0)
const canvasContextMenuOpen = ref(false)
const canvasContextMenuX = ref(0)
const canvasContextMenuY = ref(0)
function openContextMenu(node, event) { function openContextMenu(node, event) {
if (!node || !event) return if (!node || !event) return
edgeContextMenuOpen.value = false edgeContextMenuOpen.value = false
contextMenuEdge.value = null contextMenuEdge.value = null
canvasContextMenuOpen.value = false
contextMenuNode.value = node contextMenuNode.value = node
contextMenuX.value = event.clientX contextMenuX.value = event.clientX
contextMenuY.value = event.clientY contextMenuY.value = event.clientY
@@ -24,17 +28,30 @@ export function useGraphNodeContextMenu() {
if (!edge || !event) return if (!edge || !event) return
contextMenuOpen.value = false contextMenuOpen.value = false
contextMenuNode.value = null contextMenuNode.value = null
canvasContextMenuOpen.value = false
contextMenuEdge.value = edge contextMenuEdge.value = edge
edgeContextMenuX.value = event.clientX edgeContextMenuX.value = event.clientX
edgeContextMenuY.value = event.clientY edgeContextMenuY.value = event.clientY
edgeContextMenuOpen.value = true edgeContextMenuOpen.value = true
} }
function openCanvasContextMenu(event) {
if (!event) return
contextMenuOpen.value = false
contextMenuNode.value = null
edgeContextMenuOpen.value = false
contextMenuEdge.value = null
canvasContextMenuX.value = event.clientX
canvasContextMenuY.value = event.clientY
canvasContextMenuOpen.value = true
}
function closeContextMenu() { function closeContextMenu() {
contextMenuOpen.value = false contextMenuOpen.value = false
contextMenuNode.value = null contextMenuNode.value = null
edgeContextMenuOpen.value = false edgeContextMenuOpen.value = false
contextMenuEdge.value = null contextMenuEdge.value = null
canvasContextMenuOpen.value = false
} }
function closeEdgeContextMenu() { function closeEdgeContextMenu() {
@@ -42,6 +59,10 @@ export function useGraphNodeContextMenu() {
contextMenuEdge.value = null contextMenuEdge.value = null
} }
function closeCanvasContextMenu() {
canvasContextMenuOpen.value = false
}
function resolveEdgeAtPointer(network, domEvent, getEdges) { function resolveEdgeAtPointer(network, domEvent, getEdges) {
let edgeId = null let edgeId = null
if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') { if (domEvent && typeof network.getPointer === 'function' && typeof network.getEdgeAt === 'function') {
@@ -82,7 +103,7 @@ export function useGraphNodeContextMenu() {
} }
} }
closeContextMenu() openCanvasContextMenu(domEvent)
} }
network.on('oncontext', onContext) network.on('oncontext', onContext)
@@ -98,10 +119,15 @@ export function useGraphNodeContextMenu() {
contextMenuEdge, contextMenuEdge,
edgeContextMenuX, edgeContextMenuX,
edgeContextMenuY, edgeContextMenuY,
canvasContextMenuOpen,
canvasContextMenuX,
canvasContextMenuY,
openContextMenu, openContextMenu,
openEdgeContextMenu, openEdgeContextMenu,
openCanvasContextMenu,
closeContextMenu, closeContextMenu,
closeEdgeContextMenu, closeEdgeContextMenu,
closeCanvasContextMenu,
attachNodeContextHandlers, attachNodeContextHandlers,
} }
} }
@@ -23,7 +23,10 @@ export const remoteNetworkMapMembershipRepository = {
return data return data
}, },
async create(mapId, payload) { async create(mapId, payload) {
const { data } = await api.post(`/network-maps/${mapId}/memberships/`, payload) const { data } = await api.post(`/network-maps/${mapId}/memberships/`, {
map: mapId,
...payload,
})
return data return data
}, },
async update(mapId, id, payload) { async update(mapId, id, payload) {
+9 -6
View File
@@ -3,6 +3,7 @@ import {
radiusRatioFromInvolvement, radiusRatioFromInvolvement,
involvementFromRadiusRatio, involvementFromRadiusRatio,
} from '../../domain/conflictology' } from '../../domain/conflictology'
import { hasStoredMapPlacement } from './positioning'
export { CONFLICT_CENTER_NODE_ID } export { CONFLICT_CENTER_NODE_ID }
@@ -23,12 +24,14 @@ export function computeConflictPositions(nodes, layout) {
} }
export function conflictNodeXY(node, layout, posById) { export function conflictNodeXY(node, layout, posById) {
const ratio = Number(node.map_radius_ratio) if (hasStoredMapPlacement(node)) {
const storedAngle = Number(node.map_angle) const ratio = Number(node.map_radius_ratio)
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) { const storedAngle = Number(node.map_angle)
const safeRatio = Math.max(0, Math.min(1, ratio)) if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
const r = safeRatio * layout.rOuter const safeRatio = Math.max(0, Math.min(1, ratio))
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) } const r = safeRatio * layout.rOuter
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
}
} }
const p = posById.get(node.id) const p = posById.get(node.id)
if (p) return p if (p) return p
+14 -6
View File
@@ -53,6 +53,12 @@ export function normalizedCircle(n, geometry = defaultGeometry()) {
return circleKeys[Math.floor(circleKeys.length / 2)] || circleKeys[0] || 'productivity' return circleKeys[Math.floor(circleKeys.length / 2)] || circleKeys[0] || 'productivity'
} }
export function hasStoredMapPlacement(node) {
const angle = node?.map_angle
const ratio = node?.map_radius_ratio
return angle != null && ratio != null && angle !== '' && ratio !== ''
}
export function computePolarPositions(rawNodes, layout, geometry = defaultGeometry()) { export function computePolarPositions(rawNodes, layout, geometry = defaultGeometry()) {
const { sectorKeys } = geometry const { sectorKeys } = geometry
const groups = new Map() const groups = new Map()
@@ -87,12 +93,14 @@ export function computePolarPositions(rawNodes, layout, geometry = defaultGeomet
export function nodeXY(n, layout, posById, geometry = defaultGeometry()) { export function nodeXY(n, layout, posById, geometry = defaultGeometry()) {
const { sectorKeys } = geometry const { sectorKeys } = geometry
const ratio = Number(n.map_radius_ratio) if (hasStoredMapPlacement(n)) {
const storedAngle = Number(n.map_angle) const ratio = Number(n.map_radius_ratio)
if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) { const storedAngle = Number(n.map_angle)
const safeRatio = Math.max(0, Math.min(1, ratio)) if (Number.isFinite(ratio) && Number.isFinite(storedAngle)) {
const r = safeRatio * layout.rOuter const safeRatio = Math.max(0, Math.min(1, ratio))
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) } const r = safeRatio * layout.rOuter
return { x: r * Math.cos(storedAngle), y: r * Math.sin(storedAngle) }
}
} }
const p = posById.get(n.id) const p = posById.get(n.id)
if (p) return p if (p) return p
+14
View File
@@ -4,6 +4,7 @@ import {
sphereByAngle, sphereByAngle,
ringByRadius, ringByRadius,
nodeXY, nodeXY,
computePolarPositions,
defaultGeometry, defaultGeometry,
} from './positioning' } from './positioning'
@@ -40,6 +41,19 @@ describe('map positioning', () => {
expect(p.y).toBeCloseTo(0) expect(p.y).toBeCloseTo(0)
}) })
it('ignores null persisted coordinates and spreads nodes in sector', () => {
const layout = { rInner: 10, rMid: 20, rOuter: 100, cx: 0, cy: 0 }
const nodes = [
{ id: 1, life_sphere: 'other', network_circle: 'productivity', map_angle: null, map_radius_ratio: null },
{ id: 2, life_sphere: 'other', network_circle: 'productivity', map_angle: null, map_radius_ratio: null },
]
const posById = computePolarPositions(nodes, layout)
const p1 = nodeXY(nodes[0], layout, posById)
const p2 = nodeXY(nodes[1], layout, posById)
expect(p1.x).not.toBeCloseTo(p2.x)
expect(Math.hypot(p1.x, p1.y)).toBeGreaterThan(0)
})
it('defaultGeometry matches SPHERE_ORDER', () => { it('defaultGeometry matches SPHERE_ORDER', () => {
expect(defaultGeometry().sectorKeys).toEqual(SPHERE_ORDER) expect(defaultGeometry().sectorKeys).toEqual(SPHERE_ORDER)
}) })
+47 -2
View File
@@ -34,7 +34,7 @@
</div> </div>
</div> </div>
<div class="graph-area" ref="graphArea"> <div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
<div class="graph-chrome-bar"> <div class="graph-chrome-bar">
<button <button
type="button" type="button"
@@ -77,7 +77,7 @@
</svg> </svg>
</button> </button>
<div v-if="loading" class="spinner"></div> <div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card"> <div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/> <circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/>
</svg> </svg>
@@ -139,6 +139,20 @@
@edit="openEditRelation" @edit="openEditRelation"
/> />
<GraphCanvasContextMenu
:open="canvasContextMenuOpen"
:x="canvasContextMenuX"
:y="canvasContextMenuY"
@close="closeContextMenu"
@create-contact="openCreateContact"
/>
<CreateContactModal
:open="createContactOpen"
@close="createContactOpen = false"
@created="onContactCreated"
/>
<EditRelationModal <EditRelationModal
:open="editRelationOpen" :open="editRelationOpen"
:relation="editRelationTarget" :relation="editRelationTarget"
@@ -177,8 +191,11 @@ import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue' import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue' import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue' import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
import CreateContactModal from '../components/CreateContactModal.vue'
import EditRelationModal from '../components/EditRelationModal.vue' import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js' import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { getGraphToolbarActions } from '../core/pluginRegistry' import { getGraphToolbarActions } from '../core/pluginRegistry'
let themeObserver = null let themeObserver = null
@@ -193,16 +210,44 @@ const {
contextMenuEdge, contextMenuEdge,
edgeContextMenuX, edgeContextMenuX,
edgeContextMenuY, edgeContextMenuY,
canvasContextMenuOpen,
canvasContextMenuX,
canvasContextMenuY,
openCanvasContextMenu,
closeContextMenu, closeContextMenu,
closeEdgeContextMenu, closeEdgeContextMenu,
attachNodeContextHandlers, attachNodeContextHandlers,
} = useGraphNodeContextMenu() } = useGraphNodeContextMenu()
const createContactOpen = ref(false)
function openNodeInfo(node) { function openNodeInfo(node) {
selectedNode.value = node || null selectedNode.value = node || null
} }
function openCreateContact() {
closeContextMenu()
createContactOpen.value = true
}
function onGraphAreaContextMenu(event) {
if (nodes.value.length > 0) return
openCanvasContextMenu(event)
}
async function onContactCreated(data, mapIds, pluginPayload) {
const created = await store.createContact(data)
if (mapIds?.length) {
await mapsStore.setContactMapMemberships(created.id, mapIds)
}
const { saveContactPluginData } = await import('../application/services/contactPluginService')
await saveContactPluginData(created.id, pluginPayload)
createContactOpen.value = false
await ensureGraphReady({ showSpinner: false })
}
const store = useContactsStore() const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const router = useRouter() const router = useRouter()
const graphToolbarActions = getGraphToolbarActions() const graphToolbarActions = getGraphToolbarActions()
const graphArea = ref(null) const graphArea = ref(null)
+10 -5
View File
@@ -7,6 +7,7 @@
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
import { useNetworkMapsStore } from '../stores/networkMaps' import { useNetworkMapsStore } from '../stores/networkMaps'
const router = useRouter() const router = useRouter()
@@ -14,12 +15,16 @@ const mapsStore = useNetworkMapsStore()
onMounted(async () => { onMounted(async () => {
await mapsStore.fetchMaps() await mapsStore.fetchMaps()
const id = mapsStore.activeMapId || mapsStore.maps[0]?.id const existing = mapsStore.maps.find(
if (id) { (m) => String(m.id) === String(mapsStore.activeMapId),
router.replace({ name: 'NetworkMap', params: { mapId: id } }) ) || mapsStore.maps[0]
} else {
router.replace({ name: 'Contacts' }) const map = existing || await ensureDefaultNetworkMap()
if (!existing) {
await mapsStore.fetchMaps()
} }
mapsStore.setActiveMapId(map.id)
router.replace({ name: 'NetworkMap', params: { mapId: map.id } })
}) })
</script> </script>
+93 -11
View File
@@ -15,7 +15,7 @@
@create="openCreateMap" @create="openCreateMap"
@manage="openEditMap" @manage="openEditMap"
/> />
<button type="button" class="btn btn-secondary btn-sm" @click="showAddContact = true"> <button type="button" class="btn btn-secondary btn-sm" @click="openAddContact">
+ Участник + Участник
</button> </button>
</template> </template>
@@ -42,15 +42,18 @@
</NetworkMapTopPanel> </NetworkMapTopPanel>
<div class="network-map-body"> <div class="network-map-body" @contextmenu.prevent="onMapBodyContextMenu">
<div v-if="loading" class="spinner"></div> <div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card"> <div v-else-if="nodes.length === 0" class="empty-state card">
<p> <p>
На карте «{{ activeMap?.name || 'сети' }}» никого нет. На карте «{{ activeMap?.name || 'сети' }}» никого нет.
<button type="button" class="btn btn-link" @click="showAddContact = true">Добавьте участников</button>
из общего списка контактов или создайте новых в
<RouterLink to="/contacts">карточках контактов</RouterLink>.
</p> </p>
<div class="empty-state-actions">
<button type="button" class="btn btn-primary btn-sm" @click="openAddContact">
+ Добавить участника
</button>
<span class="text-muted">или создайте контакт в <RouterLink to="/contacts">карточках контактов</RouterLink></span>
</div>
</div> </div>
<div v-else class="map-stack" ref="mapStack"> <div v-else class="map-stack" ref="mapStack">
<div id="network-map-container" ref="graphContainer" class="map-vis"></div> <div id="network-map-container" ref="graphContainer" class="map-vis"></div>
@@ -112,6 +115,21 @@
@edit="openEditRelation" @edit="openEditRelation"
/> />
<GraphCanvasContextMenu
:open="canvasContextMenuOpen"
:x="canvasContextMenuX"
:y="canvasContextMenuY"
@close="closeContextMenu"
@create-contact="openCreateContact"
/>
<CreateContactModal
:open="createContactOpen"
:initial-map-ids="createContactMapIds"
@close="createContactOpen = false"
@created="onContactCreated"
/>
<EditRelationModal <EditRelationModal
:open="editRelationOpen" :open="editRelationOpen"
:relation="editRelationTarget" :relation="editRelationTarget"
@@ -143,10 +161,9 @@
<AddContactToMapModal <AddContactToMapModal
:open="showAddContact" :open="showAddContact"
:contacts="store.contacts"
:member-contact-ids="memberContactIds" :member-contact-ids="memberContactIds"
:on-add="onAddContactToMap"
@close="showAddContact = false" @close="showAddContact = false"
@add="onAddContactToMap"
/> />
</div> </div>
</template> </template>
@@ -174,6 +191,7 @@ import {
involvementNodeSize, involvementNodeSize,
} from '../domain/conflictology' } from '../domain/conflictology'
import { fetchGraphBundle } from '../composables/useGraphData' import { fetchGraphBundle } from '../composables/useGraphData'
import { ensureDefaultNetworkMap } from '../application/usecases/networkMaps'
import { edgeFromRelation } from '../application/usecases/graph' import { edgeFromRelation } from '../application/usecases/graph'
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue' import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue' import NetworkMapSwitcher from '../components/NetworkMapSwitcher.vue'
@@ -182,6 +200,8 @@ import AddContactToMapModal from '../components/AddContactToMapModal.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue' import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue' import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue' import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
import CreateContactModal from '../components/CreateContactModal.vue'
import EditRelationModal from '../components/EditRelationModal.vue' import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js' import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
import { useNetworkMapsStore } from '../stores/networkMaps' import { useNetworkMapsStore } from '../stores/networkMaps'
@@ -200,16 +220,46 @@ const {
contextMenuEdge, contextMenuEdge,
edgeContextMenuX, edgeContextMenuX,
edgeContextMenuY, edgeContextMenuY,
canvasContextMenuOpen,
canvasContextMenuX,
canvasContextMenuY,
openCanvasContextMenu,
closeContextMenu, closeContextMenu,
closeEdgeContextMenu, closeEdgeContextMenu,
attachNodeContextHandlers, attachNodeContextHandlers,
} = useGraphNodeContextMenu() } = useGraphNodeContextMenu()
const createContactOpen = ref(false)
function openNodeInfo(node) { function openNodeInfo(node) {
selectedNode.value = node || null selectedNode.value = node || null
selectedInvolvement.value = Number(node?.conflict_involvement) || 3 selectedInvolvement.value = Number(node?.conflict_involvement) || 3
} }
function openCreateContact() {
closeContextMenu()
createContactOpen.value = true
}
function onMapBodyContextMenu(event) {
if (loading.value || nodes.value.length > 0) return
openCanvasContextMenu(event)
}
async function onContactCreated(data, mapIds, pluginPayload) {
const created = await store.createContact(data)
const targetMapIds = mapIds?.length
? mapIds
: (mapId.value ? [String(mapId.value)] : [])
if (targetMapIds.length) {
await mapsStore.setContactMapMemberships(created.id, targetMapIds)
}
const { saveContactPluginData } = await import('../application/services/contactPluginService')
await saveContactPluginData(created.id, pluginPayload)
createContactOpen.value = false
await load()
}
async function saveInvolvement() { async function saveInvolvement() {
const node = selectedNode.value const node = selectedNode.value
if (!node?.membership_id) return if (!node?.membership_id) return
@@ -334,6 +384,9 @@ const mapsStore = useNetworkMapsStore()
const typesStore = useNetworkMapTypesStore() const typesStore = useNetworkMapTypesStore()
const mapId = computed(() => String(route.params.mapId || '')) const mapId = computed(() => String(route.params.mapId || ''))
const createContactMapIds = computed(() => (
mapId.value ? [String(mapId.value)] : []
))
const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null) const activeMap = computed(() => mapsStore.maps.find((m) => String(m.id) === mapId.value) || null)
const activeMapType = computed(() => { const activeMapType = computed(() => {
const typeId = activeMap.value?.mapTypeId const typeId = activeMap.value?.mapTypeId
@@ -691,7 +744,12 @@ function buildVisNodes() {
} }
function initNetwork() { function initNetwork() {
if (!graphContainer.value) return if (!graphContainer.value) {
if (initRetryCount >= INIT_RETRY_MAX) return
initRetryCount += 1
initRetryTimer = setTimeout(initNetwork, 80)
return
}
measureLayout() measureLayout()
const el = graphContainer.value const el = graphContainer.value
let { w, h } = layout.value let { w, h } = layout.value
@@ -877,8 +935,9 @@ async function load() {
} finally { } finally {
loading.value = false loading.value = false
} }
await store.fetchContacts()
if (nodes.value.length > 0) { if (nodes.value.length > 0) {
await store.fetchContacts() initRetryCount = 0
await nextTick() await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
initNetwork() initNetwork()
@@ -890,6 +949,10 @@ async function load() {
} }
} }
async function openAddContact() {
showAddContact.value = true
}
function switchMap(nextId) { function switchMap(nextId) {
if (!nextId || String(nextId) === mapId.value) return if (!nextId || String(nextId) === mapId.value) return
router.push({ name: 'NetworkMap', params: { mapId: nextId } }) router.push({ name: 'NetworkMap', params: { mapId: nextId } })
@@ -930,8 +993,13 @@ async function onMapDelete() {
closeMapForm() closeMapForm()
if (String(mapId.value) === String(deletingId)) { if (String(mapId.value) === String(deletingId)) {
const nextId = mapsStore.maps[0]?.id const nextId = mapsStore.maps[0]?.id
if (nextId) router.replace({ name: 'NetworkMap', params: { mapId: nextId } }) if (nextId) {
else router.replace({ name: 'Contacts' }) router.replace({ name: 'NetworkMap', params: { mapId: nextId } })
return
}
const created = await ensureDefaultNetworkMap()
await mapsStore.fetchMaps()
router.replace({ name: 'NetworkMap', params: { mapId: created.id } })
} }
} }
@@ -941,6 +1009,13 @@ async function onAddContactToMap(contactId) {
await load() await load()
} }
watch(graphContainer, (el) => {
if (el && nodes.value.length > 0 && !network.value) {
initRetryCount = 0
initNetwork()
}
})
watch(mapId, async (next, prev) => { watch(mapId, async (next, prev) => {
if (!next || next === prev) return if (!next || next === prev) return
network.value?.destroy() network.value?.destroy()
@@ -1039,4 +1114,11 @@ onUnmounted(() => {
.legend-tension { color: #f1c40f; } .legend-tension { color: #f1c40f; }
.legend-alliance { color: #2ecc71; } .legend-alliance { color: #2ecc71; }
.legend-neutral { color: #95a5a6; } .legend-neutral { color: #95a5a6; }
.empty-state-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 12px;
}
</style> </style>