diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef6af7f --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Social Graph Builder + +Построитель социального графа: импорт контактов, визуализация связей, CRUD. + +## Стек + +| Слой | Технология | +|------|-----------| +| Backend API | Django 4.2 + Django REST Framework | +| База данных | SQLite | +| Frontend | Vue.js 3 + Vite | +| Граф | vis-network | +| Состояние | Pinia | +| Запуск | Docker Compose | + +## Быстрый старт + +```bash +git clone +cd social-graph +docker-compose up --build +``` + +- Frontend: http://localhost:5173 +- Backend API: http://localhost:8000/api/ + +## API endpoints + +| Метод | URL | Описание | +|-------|-----|----------| +| GET/POST | `/api/contacts/` | Список / создание контактов | +| GET/PATCH/DELETE | `/api/contacts/{id}/` | Контакт по ID | +| GET/POST | `/api/relations/` | Список / создание связей | +| DELETE | `/api/relations/{id}/` | Удалить связь | +| GET | `/api/graph/` | Граф (nodes + edges для vis.js) | +| GET | `/api/relation-types/` | Типы связей | +| POST | `/api/import/` | Импорт CSV/JSON | + +## Формат CSV для импорта + +```csv +name,email,phone,organization,position,notes +Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор, +Мария Петрова,maria@example.com,+7-900-000-0002,Газпром,Аналитик, +``` + +## Формат JSON для импорта + +```json +[ + {"name": "Иван Иванов", "email": "ivan@example.com", "organization": "ООО Ромашка"}, + {"name": "Мария Петрова", "phone": "+7-900-000-0002"} +] +``` + +## Структура проекта + +``` +social-graph/ +├── backend/ +│ ├── config/ # Django settings, urls +│ ├── contacts/ # models, serializers, views, urls +│ ├── manage.py +│ ├── requirements.txt +│ └── Dockerfile +├── frontend/ +│ ├── src/ +│ │ ├── views/ # GraphView, ContactsView, ContactDetailView, ImportView +│ │ ├── components/ # ContactForm +│ │ ├── stores/ # Pinia store (contacts) +│ │ ├── router/ # Vue Router +│ │ ├── api.js # Axios instance +│ │ └── App.vue +│ ├── vite.config.js +│ └── Dockerfile +└── docker-compose.yml +``` + +## Запуск без Docker + +**Backend:** +```bash +cd backend +pip install -r requirements.txt +python manage.py migrate +python manage.py runserver +``` + +**Frontend:** +```bash +cd frontend +npm install +npm run dev +``` + +> В `vite.config.js` прокси настроен на `http://backend:8000`. +> При локальном запуске без Docker замените на `http://localhost:8000`. + +## Планируемые фичи (следующие итерации) + +- [ ] Авторизация (Django auth + JWT) +- [ ] Теги/группы контактов +- [ ] Экспорт в CSV/JSON +- [ ] Поиск по организации и должности +- [ ] История изменений контакта +- [ ] Импорт из vCard (.vcf) +- [ ] Уведомления / дни рождения diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..41f5a3a --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +*.pyo +db.sqlite3 +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e90d5c3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Миграции и запуск +CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"] diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/__pycache__/__init__.cpython-311.pyc b/backend/config/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..db475c6 Binary files /dev/null and b/backend/config/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/config/__pycache__/__init__.cpython-313.pyc b/backend/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..2bea1eb Binary files /dev/null and b/backend/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/config/__pycache__/settings.cpython-311.pyc b/backend/config/__pycache__/settings.cpython-311.pyc new file mode 100644 index 0000000..26234f0 Binary files /dev/null and b/backend/config/__pycache__/settings.cpython-311.pyc differ diff --git a/backend/config/__pycache__/settings.cpython-313.pyc b/backend/config/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000..0fb7b1e Binary files /dev/null and b/backend/config/__pycache__/settings.cpython-313.pyc differ diff --git a/backend/config/__pycache__/urls.cpython-311.pyc b/backend/config/__pycache__/urls.cpython-311.pyc new file mode 100644 index 0000000..2396867 Binary files /dev/null and b/backend/config/__pycache__/urls.cpython-311.pyc differ diff --git a/backend/config/__pycache__/urls.cpython-313.pyc b/backend/config/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000..c109547 Binary files /dev/null and b/backend/config/__pycache__/urls.cpython-313.pyc differ diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..5199339 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,45 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = 'django-insecure-social-graph-dev-key-change-in-production' +DEBUG = True +ALLOWED_HOSTS = ['*'] + +INSTALLED_APPS = [ + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.staticfiles', + 'rest_framework', + 'corsheaders', + 'contacts', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 100, +} + +CORS_ALLOW_ALL_ORIGINS = True + +# Лимит тела запроса для импорта больших JSON (например, экспорт Monica с сотнями контактов) +DATA_UPLOAD_MAX_MEMORY_SIZE = 20 * 1024 * 1024 # 20 MB + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..51f4042 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path('api/', include('contacts.urls')), +] diff --git a/backend/contacts/__init__.py b/backend/contacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/contacts/__pycache__/__init__.cpython-311.pyc b/backend/contacts/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..953438a Binary files /dev/null and b/backend/contacts/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/contacts/__pycache__/__init__.cpython-313.pyc b/backend/contacts/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b4a7ce6 Binary files /dev/null and b/backend/contacts/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/contacts/__pycache__/models.cpython-311.pyc b/backend/contacts/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..b8f0581 Binary files /dev/null and b/backend/contacts/__pycache__/models.cpython-311.pyc differ diff --git a/backend/contacts/__pycache__/models.cpython-313.pyc b/backend/contacts/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..57083dc Binary files /dev/null and b/backend/contacts/__pycache__/models.cpython-313.pyc differ diff --git a/backend/contacts/__pycache__/serializers.cpython-311.pyc b/backend/contacts/__pycache__/serializers.cpython-311.pyc new file mode 100644 index 0000000..bbec576 Binary files /dev/null and b/backend/contacts/__pycache__/serializers.cpython-311.pyc differ diff --git a/backend/contacts/__pycache__/serializers.cpython-313.pyc b/backend/contacts/__pycache__/serializers.cpython-313.pyc new file mode 100644 index 0000000..d0deb89 Binary files /dev/null and b/backend/contacts/__pycache__/serializers.cpython-313.pyc differ diff --git a/backend/contacts/__pycache__/urls.cpython-311.pyc b/backend/contacts/__pycache__/urls.cpython-311.pyc new file mode 100644 index 0000000..44a9513 Binary files /dev/null and b/backend/contacts/__pycache__/urls.cpython-311.pyc differ diff --git a/backend/contacts/__pycache__/urls.cpython-313.pyc b/backend/contacts/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000..07ad401 Binary files /dev/null and b/backend/contacts/__pycache__/urls.cpython-313.pyc differ diff --git a/backend/contacts/__pycache__/views.cpython-311.pyc b/backend/contacts/__pycache__/views.cpython-311.pyc new file mode 100644 index 0000000..5efed7a Binary files /dev/null and b/backend/contacts/__pycache__/views.cpython-311.pyc differ diff --git a/backend/contacts/__pycache__/views.cpython-313.pyc b/backend/contacts/__pycache__/views.cpython-313.pyc new file mode 100644 index 0000000..bc822aa Binary files /dev/null and b/backend/contacts/__pycache__/views.cpython-313.pyc differ diff --git a/backend/contacts/migrations/0001_initial.py b/backend/contacts/migrations/0001_initial.py new file mode 100644 index 0000000..c9e3461 --- /dev/null +++ b/backend/contacts/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.9 on 2026-03-14 09:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Имя')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=50, verbose_name='Телефон')), + ('organization', models.CharField(blank=True, max_length=255, verbose_name='Организация')), + ('position', models.CharField(blank=True, max_length=255, verbose_name='Должность')), + ('notes', 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='Relation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('relation_type', models.CharField(choices=[('colleague', 'Коллега'), ('friend', 'Друг'), ('family', 'Родственник'), ('acquaintance', 'Знакомый'), ('business', 'Деловой партнёр'), ('other', 'Другое')], default='acquaintance', max_length=50, verbose_name='Тип связи')), + ('description', models.CharField(blank=True, max_length=255, verbose_name='Описание')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='relations_as_source', to='contacts.contact', verbose_name='Источник')), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='relations_as_target', to='contacts.contact', verbose_name='Цель')), + ], + options={ + 'verbose_name': 'Связь', + 'verbose_name_plural': 'Связи', + 'unique_together': {('source', 'target')}, + }, + ), + ] diff --git a/backend/contacts/migrations/__init__.py b/backend/contacts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/contacts/migrations/__pycache__/0001_initial.cpython-311.pyc b/backend/contacts/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..655ccc7 Binary files /dev/null and b/backend/contacts/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/backend/contacts/migrations/__pycache__/0001_initial.cpython-313.pyc b/backend/contacts/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000..7840bc6 Binary files /dev/null and b/backend/contacts/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/backend/contacts/migrations/__pycache__/__init__.cpython-311.pyc b/backend/contacts/migrations/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..09a6ac4 Binary files /dev/null and b/backend/contacts/migrations/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/contacts/migrations/__pycache__/__init__.cpython-313.pyc b/backend/contacts/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..e2942d4 Binary files /dev/null and b/backend/contacts/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/contacts/models.py b/backend/contacts/models.py new file mode 100644 index 0000000..866216a --- /dev/null +++ b/backend/contacts/models.py @@ -0,0 +1,65 @@ +from django.db import models + + +class Contact(models.Model): + """Контакт в социальном графе.""" + + name = models.CharField(max_length=255, verbose_name='Имя') + email = models.EmailField(blank=True, verbose_name='Email') + phone = models.CharField(max_length=50, blank=True, verbose_name='Телефон') + organization = models.CharField(max_length=255, blank=True, verbose_name='Организация') + position = models.CharField(max_length=255, blank=True, verbose_name='Должность') + notes = models.TextField(blank=True, verbose_name='Заметки') + 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 + + +RELATION_TYPES = [ + ('colleague', 'Коллега'), + ('friend', 'Друг'), + ('family', 'Родственник'), + ('acquaintance', 'Знакомый'), + ('business', 'Деловой партнёр'), + ('other', 'Другое'), +] + + +class Relation(models.Model): + """Связь между двумя контактами.""" + + source = models.ForeignKey( + Contact, + on_delete=models.CASCADE, + related_name='relations_as_source', + verbose_name='Источник', + ) + target = models.ForeignKey( + Contact, + on_delete=models.CASCADE, + related_name='relations_as_target', + verbose_name='Цель', + ) + relation_type = models.CharField( + max_length=50, + choices=RELATION_TYPES, + default='acquaintance', + verbose_name='Тип связи', + ) + description = models.CharField(max_length=255, blank=True, verbose_name='Описание') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('source', 'target') + verbose_name = 'Связь' + verbose_name_plural = 'Связи' + + def __str__(self): + return f'{self.source} → {self.target} ({self.relation_type})' diff --git a/backend/contacts/serializers.py b/backend/contacts/serializers.py new file mode 100644 index 0000000..96723d4 --- /dev/null +++ b/backend/contacts/serializers.py @@ -0,0 +1,74 @@ +from rest_framework import serializers +from .models import Contact, Relation + + +class ContactSerializer(serializers.ModelSerializer): + relations_count = serializers.SerializerMethodField() + + class Meta: + model = Contact + fields = [ + 'id', 'name', 'email', 'phone', + 'organization', 'position', 'notes', + 'created_at', 'updated_at', 'relations_count', + ] + read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count'] + + def get_relations_count(self, obj): + return ( + obj.relations_as_source.count() + + obj.relations_as_target.count() + ) + + +class RelationSerializer(serializers.ModelSerializer): + source_name = serializers.CharField(source='source.name', read_only=True) + target_name = serializers.CharField(source='target.name', read_only=True) + + class Meta: + model = Relation + fields = [ + 'id', 'source', 'source_name', + 'target', 'target_name', + 'relation_type', 'description', 'created_at', + ] + read_only_fields = ['id', 'created_at', 'source_name', 'target_name'] + + def validate(self, data): + if data.get('source') == data.get('target'): + raise serializers.ValidationError( + 'Нельзя создать связь контакта с самим собой.' + ) + return data + + +class GraphSerializer(serializers.Serializer): + """Граф для vis.js: nodes + edges.""" + + nodes = serializers.SerializerMethodField() + edges = serializers.SerializerMethodField() + + def get_nodes(self, obj): + contacts = Contact.objects.all() + return [ + { + 'id': c.id, + 'label': c.name, + 'title': f'{c.organization}\n{c.position}'.strip() or c.name, + 'group': c.organization or 'default', + } + for c in contacts + ] + + def get_edges(self, obj): + relations = Relation.objects.select_related('source', 'target').all() + return [ + { + 'id': r.id, + 'from': r.source_id, + 'to': r.target_id, + 'label': r.get_relation_type_display(), + 'title': r.description or r.get_relation_type_display(), + } + for r in relations + ] diff --git a/backend/contacts/urls.py b/backend/contacts/urls.py new file mode 100644 index 0000000..1dad790 --- /dev/null +++ b/backend/contacts/urls.py @@ -0,0 +1,14 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from . import views + +router = DefaultRouter() +router.register('contacts', views.ContactViewSet) +router.register('relations', views.RelationViewSet) + +urlpatterns = [ + path('', include(router.urls)), + path('graph/', views.graph_data), + path('relation-types/', views.relation_types), + path('import/', views.import_contacts), +] diff --git a/backend/contacts/views.py b/backend/contacts/views.py new file mode 100644 index 0000000..337e30e --- /dev/null +++ b/backend/contacts/views.py @@ -0,0 +1,249 @@ +import csv +import io +import json + +from rest_framework import viewsets, status +from rest_framework.decorators import api_view, action +from rest_framework.response import Response + +from .models import Contact, Relation, RELATION_TYPES +from .serializers import ContactSerializer, RelationSerializer, GraphSerializer + + +class ContactViewSet(viewsets.ModelViewSet): + queryset = Contact.objects.all() + serializer_class = ContactSerializer + + def get_queryset(self): + qs = super().get_queryset() + q = self.request.query_params.get('search', '') + if q: + qs = qs.filter(name__icontains=q) + return qs + + +class RelationViewSet(viewsets.ModelViewSet): + queryset = Relation.objects.select_related('source', 'target').all() + serializer_class = RelationSerializer + + +@api_view(['GET']) +def graph_data(request): + """Возвращает граф: nodes + edges для vis.js.""" + contacts = Contact.objects.all() + nodes = [ + { + 'id': c.id, + 'label': c.name, + 'title': '\n'.join(filter(None, [c.organization, c.position, c.email])), + 'group': c.organization or 'default', + } + 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, + } + for r in relations + ] + return Response({'nodes': nodes, 'edges': edges}) + + +@api_view(['GET']) +def relation_types(request): + """Список допустимых типов связей.""" + return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES]) + + +def _monica_contact_fields(contact_data): + """Из вложенного data контакта Monica (экспорт account.data) извлекает телефон, email, заметки.""" + phone = '' + email = '' + notes_parts = [] + for block in contact_data or []: + if block.get('type') == 'contact_field': + for val in block.get('values') or []: + props = val.get('properties') or {} + value = str(props.get('data') or '').strip() + if not value: + continue + if '@' in value and '.' in value: + email = email or value + else: + phone = phone or value + elif block.get('type') == 'note': + for val in block.get('values') or []: + body = str((val.get('properties') or {}).get('body') or '').strip() + if body: + notes_parts.append(body) + return phone, email, '\n'.join(notes_parts) + + +def _normalize_rows(data): + """ + Нормализует различные форматы JSON в плоский список словарей. + + Поддерживает: + - Плоский массив: [{"name": ...}, ...] + - Monica CRM (экспорт): {"account": {"data": [{"type": "contact", "values": [...]}]}} + - Monica CRM (старый): {"contacts": [{"first_name": ..., "last_name": ...}, ...]} + - Обёртка results: {"results": [...]} + - Обёртка data: {"data": [...]} + """ + if isinstance(data, list): + return data + if isinstance(data, dict): + # Monica CRM полный экспорт: account.data, блоки type=contact, values[].properties + data + account = data.get('account') + if isinstance(account, dict): + account_data = account.get('data') + if isinstance(account_data, list): + rows = [] + for block in account_data: + if block.get('type') != 'contact': + continue + for c in block.get('values') or []: + props = c.get('properties') or {} + first = str(props.get('first_name') or '').strip() + last = str(props.get('last_name') or '').strip() + middle = str(props.get('middle_name') or '').strip() + name = ' '.join(filter(None, [first, middle, last])) or ' '.join( + filter(None, [first, last]) + ) + if not name: + continue + phone, email, notes = _monica_contact_fields(c.get('data')) + rows.append({ + 'name': name, + 'email': email, + 'phone': phone, + 'organization': '', + 'position': '', + 'notes': notes, + }) + if rows: + return rows + # Monica CRM: ключ "contacts" с first_name/last_name (старый формат API) + if 'contacts' in data: + rows = [] + for c in data['contacts']: + first = str(c.get('first_name') or '').strip() + last = str(c.get('last_name') or '').strip() + name = ' '.join(filter(None, [first, last])) + # Телефоны Monica хранятся в списке phone_numbers + phone = '' + for ph in c.get('phone_numbers') or []: + phone = str(ph.get('number') or ph.get('content') or '') + if phone: + break + # Email Monica — список emails + email = '' + for em in c.get('emails') or []: + email = str(em.get('email') or em.get('content') or '') + if email: + break + # Организации Monica — список companies + org = '' + position = '' + for comp in c.get('companies') or []: + org = str(comp.get('name') or comp.get('company_name') or '') + position = str(comp.get('job') or comp.get('position') or comp.get('title') or '') + if org: + break + # Также бывает прямое поле company + if not org: + org = str(c.get('company') or c.get('company_name') or '').strip() + position = str(c.get('job') or c.get('position') or '').strip() + rows.append({ + 'name': name, + 'email': email, + 'phone': phone, + 'organization': org, + 'position': position, + 'notes': str(c.get('information') or c.get('description') or c.get('notes') or '').strip(), + }) + return rows + # Другие обёртки + for key in ('results', 'data', 'items', 'people', 'persons'): + if key in data and isinstance(data[key], list): + return data[key] + return [] + + +@api_view(['POST']) +def import_contacts(request): + """ + Импорт контактов из CSV или JSON. + + CSV: name,email,phone,organization,position,notes + JSON (плоский): [{"name": "...", ...}, ...] + JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]} + """ + file = request.FILES.get('file') + if not file: + return Response({'error': 'Файл не передан.'}, status=status.HTTP_400_BAD_REQUEST) + + filename = file.name.lower() + created = 0 + skipped = 0 + errors = [] + + try: + if filename.endswith('.csv'): + text = file.read().decode('utf-8-sig') + reader = csv.DictReader(io.StringIO(text)) + rows = list(reader) + elif filename.endswith('.json'): + raw = json.loads(file.read().decode('utf-8')) + rows = _normalize_rows(raw) + if not rows: + return Response( + {'error': 'Не удалось распознать формат JSON. Ожидается массив контактов или экспорт Monica CRM.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + {'error': 'Поддерживаются только CSV и JSON файлы.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + return Response({'error': f'Ошибка разбора файла: {e}'}, status=status.HTTP_400_BAD_REQUEST) + + total_rows = len(rows) + for i, row in enumerate(rows): + name = str( + row.get('name') or row.get('Name') or row.get('ФИО') or + ' '.join(filter(None, [ + str(row.get('first_name') or '').strip(), + str(row.get('last_name') or '').strip(), + ])) + ).strip() + if not name: + errors.append(f'Строка {i + 1}: отсутствует поле "name"') + skipped += 1 + continue + + Contact.objects.get_or_create( + name=name, + defaults={ + 'email': str(row.get('email') or '').strip(), + 'phone': str(row.get('phone') or '').strip(), + 'organization': str(row.get('organization') or '').strip(), + 'position': str(row.get('position') or '').strip(), + 'notes': str(row.get('notes') or '').strip(), + }, + ) + created += 1 + + return Response({ + 'total': total_rows, + 'created': created, + 'skipped': skipped, + 'errors': errors, + }) diff --git a/backend/db.sqlite3 b/backend/db.sqlite3 new file mode 100644 index 0000000..935f2df Binary files /dev/null and b/backend/db.sqlite3 differ diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..c75a4f2 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == '__main__': + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..34b6945 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +Django==4.2.9 +djangorestframework==3.14.0 +django-cors-headers==4.3.1 +Pillow==10.2.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7bb7901 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.9" + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: sg_backend + volumes: + - ./backend:/app + - sqlite_data:/app/data + environment: + - DJANGO_SETTINGS_MODULE=config.settings + ports: + - "8000:8000" + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: sg_frontend + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "5173:5173" + depends_on: + - backend + restart: unless-stopped + +volumes: + sqlite_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..597940a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json . +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fe9b98 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Social Graph Builder + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7c1f718 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "social-graph-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "pinia": "^2.1.7", + "axios": "^1.6.7", + "vis-network": "^9.1.9", + "vis-data": "^7.1.9" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.3", + "vite": "^5.1.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..9d242db --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..61c9f2e --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,67 @@ + + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..d41a0a8 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,8 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + headers: { 'Content-Type': 'application/json' }, +}) + +export default api diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css new file mode 100644 index 0000000..d30941d --- /dev/null +++ b/frontend/src/assets/style.css @@ -0,0 +1,235 @@ +/* ===== Reset & base ===== */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface-alt: #22263a; + --border: #2e3354; + --accent: #5b8dee; + --accent-hover: #7aa5f5; + --accent-dim: rgba(91, 141, 238, 0.15); + --text: #e2e6f3; + --text-muted: #7b82a6; + --text-dim: #4a5075; + --red: #e05c6b; + --green: #4ecca3; + --orange: #f4a261; + --radius: 10px; + --radius-sm: 6px; + --shadow: 0 4px 20px rgba(0,0,0,0.4); + --font: 'Inter', system-ui, -apple-system, sans-serif; +} + +html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; } +#app { display: flex; flex-direction: column; height: 100%; } + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hover); } + +/* ===== Layout ===== */ +.layout { display: flex; height: 100vh; overflow: hidden; } + +.sidebar { + width: 220px; + flex-shrink: 0; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 20px 0; +} +.sidebar-logo { + padding: 0 20px 20px; + border-bottom: 1px solid var(--border); + margin-bottom: 12px; +} +.sidebar-logo h1 { font-size: 15px; font-weight: 700; color: var(--text); } +.sidebar-logo span { font-size: 11px; color: var(--text-muted); display: block; margin-top: 2px; } + +.sidebar nav { flex: 1; } +.nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + transition: all 0.15s; + cursor: pointer; + border-left: 2px solid transparent; +} +.nav-link:hover { color: var(--text); background: var(--surface-alt); } +.nav-link.active { color: var(--accent); border-left-color: var(--accent); background: var(--accent-dim); } +.nav-link svg { width: 16px; height: 16px; flex-shrink: 0; } + +.sidebar-stats { + padding: 16px 20px; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-muted); +} +.sidebar-stats .stat { margin-bottom: 4px; } +.sidebar-stats strong { color: var(--accent); } + +.main { flex: 1; overflow: auto; display: flex; flex-direction: column; } +.page-header { + padding: 20px 28px 0; + display: flex; + align-items: center; + justify-content: space-between; +} +.page-header h2 { font-size: 18px; font-weight: 600; } +.page-content { padding: 20px 28px; flex: 1; } + +/* ===== Buttons ===== */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.15s; + line-height: 1.4; +} +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-hover); } +.btn-secondary { background: var(--surface-alt); color: var(--text); border: 1px solid var(--border); } +.btn-secondary:hover { border-color: var(--accent); color: var(--accent); } +.btn-danger { background: transparent; color: var(--red); border: 1px solid transparent; } +.btn-danger:hover { background: rgba(224,92,107,0.12); border-color: var(--red); } +.btn-sm { padding: 4px 10px; font-size: 12px; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ===== Forms ===== */ +.form-group { margin-bottom: 14px; } +.form-group label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; } +.form-control { + width: 100%; + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + padding: 8px 12px; + font-size: 13px; + outline: none; + transition: border-color 0.15s; +} +.form-control:focus { border-color: var(--accent); } +.form-control::placeholder { color: var(--text-dim); } +textarea.form-control { resize: vertical; min-height: 80px; } + +/* ===== Cards / Table ===== */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; } +.table th { color: var(--text-muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; } +.table tr:hover td { background: var(--surface-alt); cursor: pointer; } +.table tr:last-child td { border-bottom: none; } + +/* ===== Badge ===== */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 20px; + font-size: 11px; + font-weight: 500; +} +.badge-colleague { background: rgba(79,172,254,0.15); color: #4facfe; } +.badge-friend { background: rgba(78,204,163,0.15); color: #4ecca3; } +.badge-family { background: rgba(244,162,97,0.15); color: #f4a261; } +.badge-business { background: rgba(91,141,238,0.15); color: #5b8dee; } +.badge-acquaintance { background: rgba(123,130,166,0.15); color: #7b82a6; } +.badge-other { background: rgba(255,255,255,0.06); color: #9ba3c5; } + +/* ===== Modal ===== */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + display: flex; align-items: center; justify-content: center; + z-index: 1000; +} +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + width: 460px; + max-width: 95vw; + box-shadow: var(--shadow); +} +.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } +.modal-header h3 { font-size: 16px; font-weight: 600; } +.modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; } + +/* ===== Search ===== */ +.search-bar { + position: relative; + margin-bottom: 16px; +} +.search-bar input { padding-left: 34px; } +.search-bar .search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-dim); + pointer-events: none; +} + +/* ===== Alert ===== */ +.alert { padding: 10px 14px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 14px; } +.alert-error { background: rgba(224,92,107,0.12); border: 1px solid rgba(224,92,107,0.3); color: var(--red); } +.alert-success { background: rgba(78,204,163,0.12); border: 1px solid rgba(78,204,163,0.3); color: var(--green); } +.alert-info { background: var(--accent-dim); border: 1px solid rgba(91,141,238,0.3); color: var(--accent-hover); } + +/* ===== Empty state ===== */ +.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); } +.empty-state svg { width: 40px; height: 40px; opacity: 0.3; margin: 0 auto 12px; display: block; } +.empty-state p { font-size: 13px; } + +/* ===== Loading ===== */ +.spinner { + width: 22px; height: 22px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + margin: 60px auto; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ===== Graph container ===== */ +#graph-container { + width: 100%; + height: calc(100vh - 80px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +/* ===== Scrollbar ===== */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +/* ===== Utils ===== */ +.flex { display: flex; } +.gap-2 { gap: 8px; } +.gap-3 { gap: 12px; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.text-muted { color: var(--text-muted); font-size: 12px; } +.mt-1 { margin-top: 4px; } +.mt-2 { margin-top: 8px; } +.mt-3 { margin-top: 14px; } diff --git a/frontend/src/components/ContactForm.vue b/frontend/src/components/ContactForm.vue new file mode 100644 index 0000000..76bd457 --- /dev/null +++ b/frontend/src/components/ContactForm.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..296cb09 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './assets/style.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..27cef8c --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,35 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + redirect: '/graph', + }, + { + path: '/graph', + name: 'Graph', + component: () => import('../views/GraphView.vue'), + }, + { + path: '/contacts', + name: 'Contacts', + component: () => import('../views/ContactsView.vue'), + }, + { + path: '/contacts/:id', + name: 'ContactDetail', + component: () => import('../views/ContactDetailView.vue'), + }, + { + path: '/import', + name: 'Import', + component: () => import('../views/ImportView.vue'), + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +export default router diff --git a/frontend/src/stores/contacts.js b/frontend/src/stores/contacts.js new file mode 100644 index 0000000..dd8de93 --- /dev/null +++ b/frontend/src/stores/contacts.js @@ -0,0 +1,67 @@ +import { defineStore } from 'pinia' +import api from '../api' + +export const useContactsStore = defineStore('contacts', { + state: () => ({ + contacts: [], + relations: [], + loading: false, + error: null, + }), + + getters: { + contactById: (state) => (id) => state.contacts.find((c) => c.id === id), + totalContacts: (state) => state.contacts.length, + totalRelations: (state) => state.relations.length, + }, + + actions: { + async fetchContacts(search = '') { + this.loading = true + this.error = null + try { + const params = search ? { search } : {} + const { data } = await api.get('/contacts/', { params }) + this.contacts = data.results ?? data + } catch (e) { + this.error = e.message + } finally { + this.loading = false + } + }, + + async fetchRelations() { + const { data } = await api.get('/relations/') + this.relations = data.results ?? data + }, + + async createContact(payload) { + const { data } = await api.post('/contacts/', payload) + this.contacts.push(data) + return data + }, + + async updateContact(id, payload) { + const { data } = await api.patch(`/contacts/${id}/`, payload) + const idx = this.contacts.findIndex((c) => c.id === id) + if (idx !== -1) this.contacts[idx] = data + return data + }, + + async deleteContact(id) { + await api.delete(`/contacts/${id}/`) + this.contacts = this.contacts.filter((c) => c.id !== id) + }, + + async createRelation(payload) { + const { data } = await api.post('/relations/', payload) + this.relations.push(data) + return data + }, + + async deleteRelation(id) { + await api.delete(`/relations/${id}/`) + this.relations = this.relations.filter((r) => r.id !== id) + }, + }, +}) diff --git a/frontend/src/views/ContactDetailView.vue b/frontend/src/views/ContactDetailView.vue new file mode 100644 index 0000000..1f22597 --- /dev/null +++ b/frontend/src/views/ContactDetailView.vue @@ -0,0 +1,181 @@ + + + diff --git a/frontend/src/views/ContactsView.vue b/frontend/src/views/ContactsView.vue new file mode 100644 index 0000000..73ea561 --- /dev/null +++ b/frontend/src/views/ContactsView.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue new file mode 100644 index 0000000..e963cf6 --- /dev/null +++ b/frontend/src/views/GraphView.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/frontend/src/views/ImportView.vue b/frontend/src/views/ImportView.vue new file mode 100644 index 0000000..4186e8c --- /dev/null +++ b/frontend/src/views/ImportView.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..3b15456 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + optimizeDeps: { + esbuildOptions: { + sourcemap: false, + }, + }, + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/sample_contacts.csv b/sample_contacts.csv new file mode 100644 index 0000000..4946bf38 --- /dev/null +++ b/sample_contacts.csv @@ -0,0 +1,9 @@ +name,email,phone,organization,position,notes +Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Генеральный директор,Познакомились на конференции 2023 +Мария Петрова,maria@company.ru,+7-900-000-0002,Газпром,Аналитик данных,Коллега по проекту X +Алексей Смирнов,alex.smirnov@mail.ru,+7-921-000-0003,Яндекс,Разработчик, +Ольга Кузнецова,olga.k@tech.ru,+7-931-000-0004,Сбербанк,Менеджер проектов,Знакомы через Марию +Дмитрий Волков,d.volkov@startup.io,+7-950-000-0005,ИП Волков,Предприниматель,Потенциальный партнёр +Анна Соколова,anna.s@design.ru,,Freelance,UI/UX Designer,Дизайнер сайта +Сергей Новиков,s.novikov@gov.ru,+7-812-000-0006,Администрация СПб,Советник, +Екатерина Морозова,katya@fintech.ru,+7-962-000-0007,ФинТех,Финансовый директор,Знакомы с 2019 diff --git a/social-graph.code-workspace b/social-graph.code-workspace new file mode 100644 index 0000000..362d7c2 --- /dev/null +++ b/social-graph.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file