diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index ca7918a..cb3f59f 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -7,6 +7,7 @@ router = DefaultRouter() router.register('contacts', contact_views.ContactViewSet) router.register('relations', contact_views.RelationViewSet) router.register('network-maps', contact_views.NetworkMapViewSet) +router.register('network-map-types', contact_views.NetworkMapTypeViewSet) urlpatterns = [ path('', include(router.urls)), diff --git a/backend/contacts/map_type_validation.py b/backend/contacts/map_type_validation.py new file mode 100644 index 0000000..63fdcbf --- /dev/null +++ b/backend/contacts/map_type_validation.py @@ -0,0 +1,39 @@ +def validate_map_type_payload(sectors, circles): + errors = {} + if not sectors or not isinstance(sectors, list): + errors['sectors'] = 'Нужен хотя бы один сектор.' + if not circles or not isinstance(circles, list): + errors['circles'] = 'Нужен хотя бы один круг.' + if errors: + return errors + + sector_keys = [] + for i, item in enumerate(sectors): + if not isinstance(item, dict): + errors['sectors'] = f'Сектор {i + 1}: неверный формат.' + break + key = str(item.get('key', '')).strip() + label = str(item.get('label', '')).strip() + if not key or not label: + errors['sectors'] = f'Сектор {i + 1}: укажите ключ и подпись.' + break + sector_keys.append(key) + + circle_keys = [] + for i, item in enumerate(circles): + if not isinstance(item, dict): + errors['circles'] = f'Круг {i + 1}: неверный формат.' + break + key = str(item.get('key', '')).strip() + label = str(item.get('label', '')).strip() + if not key or not label: + errors['circles'] = f'Круг {i + 1}: укажите ключ и подпись.' + break + circle_keys.append(key) + + if len(set(sector_keys)) != len(sector_keys): + errors['sectors'] = 'Ключи секторов должны быть уникальными.' + if len(set(circle_keys)) != len(circle_keys): + errors['circles'] = 'Ключи кругов должны быть уникальными.' + + return errors diff --git a/backend/contacts/migrations/0007_network_map_types.py b/backend/contacts/migrations/0007_network_map_types.py new file mode 100644 index 0000000..076e8a9 --- /dev/null +++ b/backend/contacts/migrations/0007_network_map_types.py @@ -0,0 +1,92 @@ +from django.db import migrations, models +import django.db.models.deletion + + +DEFAULT_SECTORS = [ + {'key': 'work', 'label': 'Работа'}, + {'key': 'study', 'label': 'Учёба'}, + {'key': 'hobby', 'label': 'Хобби'}, + {'key': 'family', 'label': 'Семья'}, + {'key': 'health', 'label': 'Здоровье'}, + {'key': 'other', 'label': 'Другое'}, +] + +DEFAULT_CIRCLES = [ + {'key': 'support', 'label': 'Круг поддержки'}, + {'key': 'productivity', 'label': 'Круг продуктивности'}, + {'key': 'development', 'label': 'Круг развития'}, +] + + +def seed_default_map_type(apps, schema_editor): + NetworkMapType = apps.get_model('contacts', 'NetworkMapType') + NetworkMap = apps.get_model('contacts', 'NetworkMap') + + default_type, _ = NetworkMapType.objects.get_or_create( + is_default=True, + defaults={ + 'name': 'Стандартная', + 'sectors': DEFAULT_SECTORS, + 'circles': DEFAULT_CIRCLES, + }, + ) + NetworkMap.objects.filter(map_type__isnull=True).update(map_type=default_type) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contacts', '0006_network_maps'), + ] + + operations = [ + migrations.CreateModel( + name='NetworkMapType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Название типа')), + ('sectors', models.JSONField(default=list, verbose_name='Секторы')), + ('circles', models.JSONField(default=list, verbose_name='Круги')), + ('is_default', models.BooleanField(default=False, verbose_name='Тип по умолчанию')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Тип карты сети', + 'verbose_name_plural': 'Типы карт сети', + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='networkmap', + name='map_type', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='maps', + to='contacts.networkmaptype', + verbose_name='Тип карты', + ), + ), + migrations.RunPython(seed_default_map_type, migrations.RunPython.noop), + migrations.AlterField( + model_name='networkmap', + name='map_type', + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name='maps', + to='contacts.networkmaptype', + verbose_name='Тип карты', + ), + ), + migrations.AlterField( + model_name='networkmapmembership', + name='life_sphere', + field=models.CharField(default='other', max_length=32, verbose_name='Сфера жизни'), + ), + migrations.AlterField( + model_name='networkmapmembership', + name='network_circle', + field=models.CharField(default='productivity', max_length=32, verbose_name='Круг сети'), + ), + ] diff --git a/backend/contacts/migrations/0008_alter_relation_interaction_intensity.py b/backend/contacts/migrations/0008_alter_relation_interaction_intensity.py new file mode 100644 index 0000000..7e575c1 --- /dev/null +++ b/backend/contacts/migrations/0008_alter_relation_interaction_intensity.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2026-06-25 02:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contacts', '0007_network_map_types'), + ] + + operations = [ + migrations.AlterField( + model_name='relation', + name='interaction_intensity', + field=models.CharField(choices=[('intense', 'Интенсивные контакты'), ('periodic', 'Периодические контакты'), ('sparse', 'Редкие контакты')], default='intense', max_length=32, verbose_name='Интенсивность общения'), + ), + ] diff --git a/backend/contacts/models.py b/backend/contacts/models.py index 2dfaadf..6344b79 100644 --- a/backend/contacts/models.py +++ b/backend/contacts/models.py @@ -2,8 +2,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from core.choices import ( - LIFE_SPHERES, - NETWORK_CIRCLES, INTERACTION_INTENSITY, RELATION_TYPES, ) @@ -30,11 +28,36 @@ class Contact(models.Model): return self.name +class NetworkMapType(models.Model): + """Тип карты сети: настраиваемые секторы и концентрические круги.""" + + name = models.CharField(max_length=255, verbose_name='Название типа') + sectors = models.JSONField(default=list, verbose_name='Секторы') + circles = models.JSONField(default=list, verbose_name='Круги') + is_default = models.BooleanField(default=False, verbose_name='Тип по умолчанию') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['name'] + verbose_name = 'Тип карты сети' + verbose_name_plural = 'Типы карт сети' + + def __str__(self): + return self.name + + class NetworkMap(models.Model): """Карта сети — отдельный контекст для визуализации подмножества контактов.""" name = models.CharField(max_length=255, verbose_name='Название') description = models.TextField(blank=True, verbose_name='Описание') + map_type = models.ForeignKey( + NetworkMapType, + on_delete=models.PROTECT, + related_name='maps', + verbose_name='Тип карты', + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -64,13 +87,11 @@ class NetworkMapMembership(models.Model): ) life_sphere = models.CharField( max_length=32, - choices=LIFE_SPHERES, default='other', verbose_name='Сфера жизни', ) network_circle = models.CharField( max_length=32, - choices=NETWORK_CIRCLES, default='productivity', verbose_name='Круг сети', ) diff --git a/backend/contacts/serializers.py b/backend/contacts/serializers.py index 5504e40..32cfbe0 100644 --- a/backend/contacts/serializers.py +++ b/backend/contacts/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers -from .models import Contact, Relation, NetworkMap, NetworkMapMembership +from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType +from .map_type_validation import validate_map_type_payload class ContactSerializer(serializers.ModelSerializer): @@ -43,17 +44,52 @@ class RelationSerializer(serializers.ModelSerializer): return data +class NetworkMapTypeSerializer(serializers.ModelSerializer): + class Meta: + model = NetworkMapType + fields = [ + 'id', 'name', 'sectors', 'circles', 'is_default', + 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'is_default', 'created_at', 'updated_at'] + + def validate(self, data): + sectors = data.get('sectors', getattr(self.instance, 'sectors', None)) + circles = data.get('circles', getattr(self.instance, 'circles', None)) + errors = validate_map_type_payload(sectors, circles) + if errors: + raise serializers.ValidationError(errors) + return data + + def create(self, validated_data): + if NetworkMapType.objects.filter(is_default=True).exists(): + validated_data['is_default'] = False + return super().create(validated_data) + + class NetworkMapSerializer(serializers.ModelSerializer): memberships_count = serializers.SerializerMethodField() + map_type = serializers.PrimaryKeyRelatedField( + queryset=NetworkMapType.objects.all(), + required=False, + ) class Meta: model = NetworkMap fields = [ - 'id', 'name', 'description', + 'id', 'name', 'description', 'map_type', 'created_at', 'updated_at', 'memberships_count', ] read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count'] + def validate(self, data): + if not data.get('map_type') and not getattr(self.instance, 'map_type_id', None): + default_type = NetworkMapType.objects.filter(is_default=True).first() + if not default_type: + raise serializers.ValidationError({'map_type': 'Нет типа карты по умолчанию.'}) + data['map_type'] = default_type + return data + def get_memberships_count(self, obj): return obj.memberships.count() diff --git a/backend/contacts/views.py b/backend/contacts/views.py index 1a93c6b..f1d8ba6 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -1,11 +1,13 @@ from rest_framework import viewsets +from rest_framework.exceptions import ValidationError -from .models import Contact, Relation, NetworkMap, NetworkMapMembership +from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType from .serializers import ( ContactSerializer, RelationSerializer, NetworkMapSerializer, NetworkMapMembershipSerializer, + NetworkMapTypeSerializer, ) @@ -27,10 +29,22 @@ class RelationViewSet(viewsets.ModelViewSet): class NetworkMapViewSet(viewsets.ModelViewSet): - queryset = NetworkMap.objects.all() + queryset = NetworkMap.objects.select_related('map_type').all() serializer_class = NetworkMapSerializer +class NetworkMapTypeViewSet(viewsets.ModelViewSet): + queryset = NetworkMapType.objects.all() + serializer_class = NetworkMapTypeSerializer + + def perform_destroy(self, instance): + if instance.is_default: + raise ValidationError({'detail': 'Нельзя удалить тип карты по умолчанию.'}) + if instance.maps.exists(): + raise ValidationError({'detail': 'Тип используется картами сети. Сначала смените тип у карт.'}) + instance.delete() + + class NetworkMapMembershipViewSet(viewsets.ModelViewSet): serializer_class = NetworkMapMembershipSerializer diff --git a/backend/core/choices.py b/backend/core/choices.py index 512468b..49ceefe 100644 --- a/backend/core/choices.py +++ b/backend/core/choices.py @@ -38,3 +38,12 @@ def choices_payload(): 'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES], 'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY], } + + +DEFAULT_MAP_TYPE_SECTORS = [ + {'key': v, 'label': l} for v, l in LIFE_SPHERES +] + +DEFAULT_MAP_TYPE_CIRCLES = [ + {'key': v, 'label': l} for v, l in NETWORK_CIRCLES +] diff --git a/backend/tests/test_network_map_types_api.py b/backend/tests/test_network_map_types_api.py new file mode 100644 index 0000000..598d63b --- /dev/null +++ b/backend/tests/test_network_map_types_api.py @@ -0,0 +1,69 @@ +import pytest + +from contacts.models import NetworkMap, NetworkMapType + + +@pytest.mark.django_db +def test_network_map_types_list(api_client): + response = api_client.get('/api/v1/network-map-types/') + assert response.status_code == 200 + assert response.data['count'] >= 1 + default = next(r for r in response.data['results'] if r['is_default']) + assert len(default['sectors']) == 6 + assert len(default['circles']) == 3 + + +@pytest.mark.django_db +def test_network_map_type_create(api_client): + response = api_client.post('/api/v1/network-map-types/', { + 'name': 'Корпоративная', + 'sectors': [{'key': 'team', 'label': 'Команда'}], + 'circles': [{'key': 'inner', 'label': 'Ядро'}], + }, format='json') + assert response.status_code == 201 + assert response.data['name'] == 'Корпоративная' + + +@pytest.mark.django_db +def test_network_map_type_create_validation(api_client): + response = api_client.post('/api/v1/network-map-types/', { + 'name': 'Пустая', + 'sectors': [], + 'circles': [{'key': 'a', 'label': 'A'}], + }, format='json') + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_network_map_type_delete_default_forbidden(api_client): + default_type = NetworkMapType.objects.filter(is_default=True).first() + response = api_client.delete(f'/api/v1/network-map-types/{default_type.id}/') + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_network_map_create_with_type(api_client): + map_type = NetworkMapType.objects.create( + name='Тестовая', + sectors=[{'key': 'a', 'label': 'A'}], + circles=[{'key': 'b', 'label': 'B'}], + ) + response = api_client.post('/api/v1/network-maps/', { + 'name': 'Карта 1', + 'description': '', + 'map_type': map_type.id, + }, format='json') + assert response.status_code == 201 + assert response.data['map_type'] == map_type.id + + +@pytest.mark.django_db +def test_network_map_type_delete_in_use(api_client): + map_type = NetworkMapType.objects.create( + name='Занятая', + sectors=[{'key': 'a', 'label': 'A'}], + circles=[{'key': 'b', 'label': 'B'}], + ) + NetworkMap.objects.create(name='Карта', map_type=map_type) + response = api_client.delete(f'/api/v1/network-map-types/{map_type.id}/') + assert response.status_code == 400 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7557091..c537599 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -48,6 +48,13 @@ Импорт + + + + + + Настройки + = 2) { - return { - ...dump, - networkMaps: dump.networkMaps || [], - networkMapMemberships: dump.networkMapMemberships || [], - contacts: (dump.contacts || []).map(stripLegacyContactFields), - } + let next = dump + if ((dump.version || 1) < 2) { + next = migrateV1ToV2(dump) + } + return migrateV2Dump(next) +} + +function migrateV2Dump(dump) { + const base = { + ...dump, + networkMaps: dump.networkMaps || [], + networkMapMemberships: dump.networkMapMemberships || [], + networkMapTypes: dump.networkMapTypes || [], + contacts: (dump.contacts || []).map(stripLegacyContactFields), } + if (base.networkMapTypes.length) { + return base + } + + const ts = new Date().toISOString() + const defaultType = createDefaultMapTypeRecord('default-map-type', ts) + const maps = base.networkMaps.map((map) => ({ + ...map, + mapTypeId: map.mapTypeId || defaultType.id, + })) + + return { + ...base, + networkMapTypes: [defaultType], + networkMaps: maps, + } +} + +function migrateV1ToV2(dump) { const ts = new Date().toISOString() const mapId = generateId() const networkMaps = [{ @@ -278,6 +305,7 @@ export async function exportLocalData({ passphrase = '' } = {}) { relations: await listRelations(), networkMaps: await localDb.networkMaps.toArray(), networkMapMemberships: await localDb.networkMapMemberships.toArray(), + networkMapTypes: await localDb.networkMapTypes.toArray(), changes: await localDb.changelog.toArray(), } @@ -331,8 +359,14 @@ export async function importLocalDump(file, passphrase = '') { localDb.relations, localDb.networkMaps, localDb.networkMapMemberships, + localDb.networkMapTypes, localDb.changelog, async () => { + if (Array.isArray(dump.networkMapTypes)) { + for (const mapType of dump.networkMapTypes) { + await localDb.networkMapTypes.put(mapType) + } + } for (const contact of dump.contacts) { await localDb.contacts.put(normalizeDumpContact(contact)) } @@ -362,5 +396,6 @@ export async function importLocalDump(file, passphrase = '') { importedRelations: dump.relations.length, importedMaps: dump.networkMaps?.length || 0, importedMemberships: dump.networkMapMemberships?.length || 0, + importedMapTypes: dump.networkMapTypes?.length || 0, } } diff --git a/frontend/src/application/usecases/networkMapTypes.js b/frontend/src/application/usecases/networkMapTypes.js new file mode 100644 index 0000000..df4f220 --- /dev/null +++ b/frontend/src/application/usecases/networkMapTypes.js @@ -0,0 +1,48 @@ +import { appendChange } from '../../infrastructure/sync/changeLogRepository' +import { getNetworkMapTypeRepository } from '../../infrastructure/repositories/repositoryFactory' + +const typeRepo = () => getNetworkMapTypeRepository() + +export async function listNetworkMapTypes() { + return typeRepo().list() +} + +export async function getNetworkMapTypeById(id) { + return typeRepo().getById(id) +} + +export async function getDefaultNetworkMapType() { + return typeRepo().getDefault() +} + +export async function createNetworkMapType(payload) { + const created = await typeRepo().create(payload) + await appendChange({ + entityType: 'networkMapType', + entityId: created.id, + op: 'created', + payloadPatch: created, + }) + return created +} + +export async function updateNetworkMapType(id, payload) { + const updated = await typeRepo().update(id, payload) + await appendChange({ + entityType: 'networkMapType', + entityId: id, + op: 'updated', + payloadPatch: payload, + }) + return updated +} + +export async function deleteNetworkMapType(id) { + await typeRepo().remove(id) + await appendChange({ + entityType: 'networkMapType', + entityId: id, + op: 'deleted', + payloadPatch: {}, + }) +} diff --git a/frontend/src/components/NetworkMapFormModal.vue b/frontend/src/components/NetworkMapFormModal.vue index 92ed61f..116fcc0 100644 --- a/frontend/src/components/NetworkMapFormModal.vue +++ b/frontend/src/components/NetworkMapFormModal.vue @@ -10,6 +10,14 @@ +
+ + +