WIP: local changes
This commit is contained in:
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,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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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})'
|
||||
@@ -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
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user