diff --git a/backend/config/settings.py b/backend/config/settings.py index 4425f05..446b14d 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -72,6 +72,12 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' USE_JWT_AUTH = os.environ.get('USE_JWT_AUTH', 'false').lower() in ('1', 'true', 'yes') if USE_JWT_AUTH: + from datetime import timedelta + + SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=12), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + } REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ] diff --git a/backend/contacts/bootstrap.py b/backend/contacts/bootstrap.py new file mode 100644 index 0000000..91b0410 --- /dev/null +++ b/backend/contacts/bootstrap.py @@ -0,0 +1,29 @@ +DEFAULT_SECTORS = [ + {'key': 'work', 'label': 'Работа'}, + {'key': 'study', 'label': 'Учёба'}, + {'key': 'hobby', 'label': 'Хобби'}, + {'key': 'family', 'label': 'Семья'}, + {'key': 'health', 'label': 'Здоровье'}, + {'key': 'other', 'label': 'Другое'}, +] + +DEFAULT_CIRCLES = [ + {'key': 'support', 'label': 'Круг поддержки'}, + {'key': 'productivity', 'label': 'Круг продуктивности'}, + {'key': 'development', 'label': 'Круг развития'}, +] + + +def ensure_user_defaults(user): + """Создаёт тип карты по умолчанию для нового пользователя.""" + from contacts.models import NetworkMapType + + if NetworkMapType.objects.filter(owner=user, is_default=True).exists(): + return + NetworkMapType.objects.create( + owner=user, + is_default=True, + name='Стандартная', + sectors=DEFAULT_SECTORS, + circles=DEFAULT_CIRCLES, + ) diff --git a/backend/contacts/migrations/0011_add_owner.py b/backend/contacts/migrations/0011_add_owner.py new file mode 100644 index 0000000..37c4087 --- /dev/null +++ b/backend/contacts/migrations/0011_add_owner.py @@ -0,0 +1,111 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +DEFAULT_SECTORS = [ + {'key': 'work', 'label': 'Работа'}, + {'key': 'study', 'label': 'Учёба'}, + {'key': 'hobby', 'label': 'Хобби'}, + {'key': 'family', 'label': 'Семья'}, + {'key': 'health', 'label': 'Здоровье'}, + {'key': 'other', 'label': 'Другое'}, +] + +DEFAULT_CIRCLES = [ + {'key': 'support', 'label': 'Круг поддержки'}, + {'key': 'productivity', 'label': 'Круг продуктивности'}, + {'key': 'development', 'label': 'Круг развития'}, +] + + +def assign_legacy_owner(apps, schema_editor): + User = apps.get_model('auth', 'User') + Contact = apps.get_model('contacts', 'Contact') + Relation = apps.get_model('contacts', 'Relation') + NetworkMap = apps.get_model('contacts', 'NetworkMap') + NetworkMapType = apps.get_model('contacts', 'NetworkMapType') + + user, created = User.objects.get_or_create( + username='legacy', + defaults={'email': 'legacy@local.invalid', 'password': '!'}, + ) + + Contact.objects.filter(owner__isnull=True).update(owner=user) + Relation.objects.filter(owner__isnull=True).update(owner=user) + NetworkMap.objects.filter(owner__isnull=True).update(owner=user) + NetworkMapType.objects.filter(owner__isnull=True).update(owner=user) + + if not NetworkMapType.objects.filter(owner=user, is_default=True).exists(): + default = NetworkMapType.objects.filter(is_default=True).first() + if default: + default.owner = user + default.save(update_fields=['owner']) + else: + NetworkMapType.objects.create( + owner=user, + is_default=True, + name='Стандартная', + sectors=DEFAULT_SECTORS, + circles=DEFAULT_CIRCLES, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contacts', '0010_conflictology_on_map_type'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='owner', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='contacts', + to=settings.AUTH_USER_MODEL, + verbose_name='Владелец', + ), + ), + migrations.AddField( + model_name='networkmap', + name='owner', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='network_maps', + to=settings.AUTH_USER_MODEL, + verbose_name='Владелец', + ), + ), + migrations.AddField( + model_name='networkmaptype', + name='owner', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='network_map_types', + to=settings.AUTH_USER_MODEL, + verbose_name='Владелец', + ), + ), + migrations.AddField( + model_name='relation', + name='owner', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='relations', + to=settings.AUTH_USER_MODEL, + verbose_name='Владелец', + ), + ), + migrations.RunPython(assign_legacy_owner, migrations.RunPython.noop), + ] diff --git a/backend/contacts/mixins.py b/backend/contacts/mixins.py new file mode 100644 index 0000000..816f2e7 --- /dev/null +++ b/backend/contacts/mixins.py @@ -0,0 +1,25 @@ +from rest_framework.exceptions import PermissionDenied + +from core.access import use_jwt_auth + + +class OwnerScopedMixin: + owner_field = 'owner' + + def get_queryset(self): + qs = super().get_queryset() + if not use_jwt_auth(): + return qs + user = self.request.user + if user and user.is_authenticated: + return qs.filter(**{self.owner_field: user}) + return qs.none() + + def perform_create(self, serializer): + if use_jwt_auth(): + user = self.request.user + if not user or not user.is_authenticated: + raise PermissionDenied() + serializer.save(**{self.owner_field: user}) + return + serializer.save() diff --git a/backend/contacts/models.py b/backend/contacts/models.py index b9f79fb..9837de9 100644 --- a/backend/contacts/models.py +++ b/backend/contacts/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -10,6 +11,14 @@ from core.choices import ( class Contact(models.Model): """Контакт в социальном графе.""" + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='contacts', + null=True, + blank=True, + verbose_name='Владелец', + ) name = models.CharField(max_length=255, verbose_name='Имя') email = models.EmailField(blank=True, verbose_name='Email') phone = models.CharField(max_length=50, blank=True, verbose_name='Телефон') @@ -31,6 +40,14 @@ class Contact(models.Model): class NetworkMapType(models.Model): """Тип карты сети: настраиваемые секторы и концентрические круги.""" + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='network_map_types', + null=True, + blank=True, + verbose_name='Владелец', + ) name = models.CharField(max_length=255, verbose_name='Название типа') sectors = models.JSONField(default=list, verbose_name='Секторы') circles = models.JSONField(default=list, verbose_name='Круги') @@ -54,6 +71,14 @@ class NetworkMapType(models.Model): class NetworkMap(models.Model): """Карта сети — отдельный контекст для визуализации подмножества контактов.""" + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='network_maps', + null=True, + blank=True, + verbose_name='Владелец', + ) name = models.CharField(max_length=255, verbose_name='Название') description = models.TextField(blank=True, verbose_name='Описание') map_type = models.ForeignKey( @@ -136,6 +161,14 @@ class NetworkMapMembership(models.Model): class Relation(models.Model): """Связь между двумя контактами.""" + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='relations', + null=True, + blank=True, + verbose_name='Владелец', + ) source = models.ForeignKey( Contact, on_delete=models.CASCADE, diff --git a/backend/contacts/serializers.py b/backend/contacts/serializers.py index 3221ff1..3cc779d 100644 --- a/backend/contacts/serializers.py +++ b/backend/contacts/serializers.py @@ -1,8 +1,17 @@ from rest_framework import serializers + +from core.access import use_jwt_auth from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType from .map_type_validation import validate_map_type_payload +def scoped_map_types_queryset(request): + qs = NetworkMapType.objects.all() + if use_jwt_auth() and request and request.user.is_authenticated: + return qs.filter(owner=request.user) + return qs + + class ContactSerializer(serializers.ModelSerializer): relations_count = serializers.SerializerMethodField() @@ -37,10 +46,22 @@ class RelationSerializer(serializers.ModelSerializer): read_only_fields = ['id', 'created_at', 'source_name', 'target_name'] def validate(self, data): - if data.get('source') == data.get('target'): + request = self.context.get('request') + source = data.get('source') or getattr(self.instance, 'source', None) + target = data.get('target') or getattr(self.instance, 'target', None) + + if source and target and source == target: raise serializers.ValidationError( 'Нельзя создать связь контакта с самим собой.' ) + + if use_jwt_auth() and request and request.user.is_authenticated: + user = request.user + for contact in (source, target): + if contact and contact.owner_id != user.id: + raise serializers.ValidationError( + 'Контакт не принадлежит текущему пользователю.' + ) return data @@ -61,8 +82,13 @@ class NetworkMapTypeSerializer(serializers.ModelSerializer): raise serializers.ValidationError(errors) return data + def _default_exists(self): + request = self.context.get('request') + qs = scoped_map_types_queryset(request) + return qs.filter(is_default=True).exists() + def create(self, validated_data): - if NetworkMapType.objects.filter(is_default=True).exists(): + if self._default_exists(): validated_data['is_default'] = False return super().create(validated_data) @@ -82,12 +108,22 @@ class NetworkMapSerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count'] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get('request') + self.fields['map_type'].queryset = scoped_map_types_queryset(request) + def validate(self, data): + request = self.context.get('request') if not data.get('map_type') and not getattr(self.instance, 'map_type_id', None): - default_type = NetworkMapType.objects.filter(is_default=True).first() + default_type = scoped_map_types_queryset(request).filter(is_default=True).first() if not default_type: raise serializers.ValidationError({'map_type': 'Нет типа карты по умолчанию.'}) data['map_type'] = default_type + map_type = data.get('map_type') or getattr(self.instance, 'map_type', None) + if use_jwt_auth() and request and request.user.is_authenticated and map_type: + if map_type.owner_id != request.user.id: + raise serializers.ValidationError({'map_type': 'Тип карты не принадлежит текущему пользователю.'}) return data def get_memberships_count(self, obj): diff --git a/backend/contacts/views.py b/backend/contacts/views.py index f1d8ba6..9a00ca1 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -1,6 +1,9 @@ from rest_framework import viewsets -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError +from core.access import use_jwt_auth +from core.drf_mixins import JwtAuthMixin +from .mixins import OwnerScopedMixin from .models import Contact, Relation, NetworkMap, NetworkMapMembership, NetworkMapType from .serializers import ( ContactSerializer, @@ -11,7 +14,7 @@ from .serializers import ( ) -class ContactViewSet(viewsets.ModelViewSet): +class ContactViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet): queryset = Contact.objects.all() serializer_class = ContactSerializer @@ -23,17 +26,17 @@ class ContactViewSet(viewsets.ModelViewSet): return qs -class RelationViewSet(viewsets.ModelViewSet): +class RelationViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet): queryset = Relation.objects.select_related('source', 'target').all() serializer_class = RelationSerializer -class NetworkMapViewSet(viewsets.ModelViewSet): +class NetworkMapViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet): queryset = NetworkMap.objects.select_related('map_type').all() serializer_class = NetworkMapSerializer -class NetworkMapTypeViewSet(viewsets.ModelViewSet): +class NetworkMapTypeViewSet(JwtAuthMixin, OwnerScopedMixin, viewsets.ModelViewSet): queryset = NetworkMapType.objects.all() serializer_class = NetworkMapTypeSerializer @@ -45,15 +48,39 @@ class NetworkMapTypeViewSet(viewsets.ModelViewSet): instance.delete() -class NetworkMapMembershipViewSet(viewsets.ModelViewSet): +class NetworkMapMembershipViewSet(JwtAuthMixin, viewsets.ModelViewSet): serializer_class = NetworkMapMembershipSerializer def get_queryset(self): map_id = self.kwargs.get('map_pk') - return NetworkMapMembership.objects.filter( + qs = NetworkMapMembership.objects.filter( map_id=map_id ).select_related('contact', 'map') + if use_jwt_auth(): + user = self.request.user + if user and user.is_authenticated: + return qs.filter(map__owner=user, contact__owner=user) + return qs.none() + return qs def perform_create(self, serializer): map_id = self.kwargs.get('map_pk') + if use_jwt_auth(): + user = self.request.user + if not user or not user.is_authenticated: + raise PermissionDenied() + network_map = NetworkMap.objects.filter(pk=map_id, owner=user).first() + if not network_map: + raise PermissionDenied() + contact = serializer.validated_data.get('contact') + if contact.owner_id != user.id: + raise ValidationError({'contact': 'Контакт не принадлежит текущему пользователю.'}) serializer.save(map_id=map_id) + + def perform_update(self, serializer): + if use_jwt_auth(): + user = self.request.user + contact = serializer.validated_data.get('contact', serializer.instance.contact) + if contact.owner_id != user.id: + raise ValidationError({'contact': 'Контакт не принадлежит текущему пользователю.'}) + serializer.save() diff --git a/backend/core/access.py b/backend/core/access.py new file mode 100644 index 0000000..258f04f --- /dev/null +++ b/backend/core/access.py @@ -0,0 +1,19 @@ +from django.conf import settings + + +def use_jwt_auth(): + return getattr(settings, 'USE_JWT_AUTH', False) + + +def scope_by_owner(queryset, user, owner_field='owner'): + if not use_jwt_auth(): + return queryset + if user and user.is_authenticated: + return queryset.filter(**{owner_field: user}) + return queryset.none() + + +def user_workspace_id(user): + if user and user.is_authenticated: + return str(user.pk) + return settings.DEFAULT_WORKSPACE_ID diff --git a/backend/core/auth_serializers.py b/backend/core/auth_serializers.py new file mode 100644 index 0000000..18cd09b --- /dev/null +++ b/backend/core/auth_serializers.py @@ -0,0 +1,94 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import validate_password +from rest_framework import serializers + +User = get_user_model() + + +class RegisterSerializer(serializers.Serializer): + username = serializers.CharField(max_length=150) + email = serializers.EmailField(required=False, allow_blank=True) + password = serializers.CharField(write_only=True, min_length=8) + + def validate_username(self, value): + username = value.strip() + if not username: + raise serializers.ValidationError('Укажите имя пользователя.') + if User.objects.filter(username__iexact=username).exists(): + raise serializers.ValidationError('Это имя пользователя уже занято.') + return username + + def validate_password(self, value): + validate_password(value) + return value + + def create(self, validated_data): + email = (validated_data.get('email') or '').strip() + return User.objects.create_user( + username=validated_data['username'], + email=email, + password=validated_data['password'], + ) + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'email'] + read_only_fields = fields + + +class UpdateProfileSerializer(serializers.Serializer): + username = serializers.CharField(max_length=150, required=False) + email = serializers.EmailField(required=False, allow_blank=True) + current_password = serializers.CharField(write_only=True) + + def validate_current_password(self, value): + user = self.context['request'].user + if not user.check_password(value): + raise serializers.ValidationError('Неверный текущий пароль.') + return value + + def validate_username(self, value): + username = value.strip() + if not username: + raise serializers.ValidationError('Укажите имя пользователя.') + user = self.context['request'].user + if User.objects.filter(username__iexact=username).exclude(pk=user.pk).exists(): + raise serializers.ValidationError('Это имя пользователя уже занято.') + return username + + def validate(self, data): + if 'username' not in data and 'email' not in data: + raise serializers.ValidationError('Укажите новое имя пользователя или email.') + return data + + def save(self): + user = self.context['request'].user + if 'username' in self.validated_data: + user.username = self.validated_data['username'] + if 'email' in self.validated_data: + user.email = (self.validated_data.get('email') or '').strip() + user.save(update_fields=['username', 'email']) + return user + + +class ChangePasswordSerializer(serializers.Serializer): + current_password = serializers.CharField(write_only=True) + new_password = serializers.CharField(write_only=True, min_length=8) + + def validate_current_password(self, value): + user = self.context['request'].user + if not user.check_password(value): + raise serializers.ValidationError('Неверный текущий пароль.') + return value + + def validate_new_password(self, value): + validate_password(value, self.context['request'].user) + return value + + def save(self): + user = self.context['request'].user + user.set_password(self.validated_data['new_password']) + user.save(update_fields=['password']) + return user diff --git a/backend/core/auth_urls.py b/backend/core/auth_urls.py index 6d30fb3..4f0164b 100644 --- a/backend/core/auth_urls.py +++ b/backend/core/auth_urls.py @@ -1,15 +1,12 @@ from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView -urlpatterns = [] +from .auth_views import ChangePasswordView, MeView, RegisterView -try: - from django.conf import settings - if getattr(settings, 'USE_JWT_AUTH', False): - from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView - - urlpatterns = [ - path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), - path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), - ] -except ImportError: - pass +urlpatterns = [ + path('auth/register/', RegisterView.as_view(), name='auth_register'), + path('auth/me/', MeView.as_view(), name='auth_me'), + path('auth/me/password/', ChangePasswordView.as_view(), name='auth_change_password'), + path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), +] diff --git a/backend/core/auth_views.py b/backend/core/auth_views.py new file mode 100644 index 0000000..0cf2274 --- /dev/null +++ b/backend/core/auth_views.py @@ -0,0 +1,66 @@ +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken + +from contacts.bootstrap import ensure_user_defaults +from core.drf_mixins import JwtAuthMixin +from .auth_serializers import ( + ChangePasswordSerializer, + RegisterSerializer, + UpdateProfileSerializer, + UserSerializer, +) + + +def tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + 'user': UserSerializer(user).data, + } + + +class RegisterView(JwtAuthMixin, APIView): + def get_permissions(self): + return [AllowAny()] + + def post(self, request): + serializer = RegisterSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + ensure_user_defaults(user) + return Response(tokens_for_user(user), status=status.HTTP_201_CREATED) + + +class MeView(JwtAuthMixin, APIView): + def get_permissions(self): + return [IsAuthenticated()] + + def get(self, request): + return Response(UserSerializer(request.user).data) + + def patch(self, request): + serializer = UpdateProfileSerializer( + data=request.data, + context={'request': request}, + ) + serializer.is_valid(raise_exception=True) + user = serializer.save() + return Response(UserSerializer(user).data) + + +class ChangePasswordView(JwtAuthMixin, APIView): + def get_permissions(self): + return [IsAuthenticated()] + + def post(self, request): + serializer = ChangePasswordSerializer( + data=request.data, + context={'request': request}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response({'detail': 'Пароль изменён.'}) diff --git a/backend/core/drf_mixins.py b/backend/core/drf_mixins.py new file mode 100644 index 0000000..8667427 --- /dev/null +++ b/backend/core/drf_mixins.py @@ -0,0 +1,16 @@ +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication + +from core.access import use_jwt_auth + + +class JwtAuthMixin: + def get_permissions(self): + if use_jwt_auth(): + return [IsAuthenticated()] + return [AllowAny()] + + def get_authenticators(self): + if use_jwt_auth(): + return [JWTAuthentication()] + return super().get_authenticators() diff --git a/backend/core/urls.py b/backend/core/urls.py index 750891d..975f59f 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -3,7 +3,7 @@ from django.urls import path from . import views urlpatterns = [ - path('meta/choices/', views.meta_choices, name='meta-choices'), - path('relation-types/', views.relation_types, name='relation-types'), - path('network-map-choices/', views.network_map_choices, name='network-map-choices'), + path('meta/choices/', views.MetaChoicesView.as_view(), name='meta-choices'), + path('relation-types/', views.RelationTypesView.as_view(), name='relation-types'), + path('network-map-choices/', views.NetworkMapChoicesView.as_view(), name='network-map-choices'), ] diff --git a/backend/core/views.py b/backend/core/views.py index 56c6317..98a8349 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1,6 +1,8 @@ -from rest_framework.decorators import api_view +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from rest_framework.views import APIView +from core.drf_mixins import JwtAuthMixin from .choices import ( RELATION_TYPES, LIFE_SPHERES, @@ -10,21 +12,20 @@ from .choices import ( ) -@api_view(['GET']) -def meta_choices(request): - """Unified meta endpoint for all domain enums.""" - return Response(choices_payload()) +class MetaChoicesView(JwtAuthMixin, APIView): + def get(self, request): + return Response(choices_payload()) -@api_view(['GET']) -def relation_types(request): - return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES]) +class RelationTypesView(JwtAuthMixin, APIView): + def get(self, request): + return Response([{'value': v, 'label': l} for v, l in RELATION_TYPES]) -@api_view(['GET']) -def network_map_choices(request): - return Response({ - 'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES], - 'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES], - 'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY], - }) +class NetworkMapChoicesView(JwtAuthMixin, APIView): + def get(self, request): + return Response({ + 'life_spheres': [{'value': v, 'label': l} for v, l in LIFE_SPHERES], + 'network_circles': [{'value': v, 'label': l} for v, l in NETWORK_CIRCLES], + 'interaction_intensities': [{'value': v, 'label': l} for v, l in INTERACTION_INTENSITY], + }) diff --git a/backend/graph/services.py b/backend/graph/services.py index dead212..837b1ef 100644 --- a/backend/graph/services.py +++ b/backend/graph/services.py @@ -1,4 +1,32 @@ from contacts.models import Contact, Relation, NetworkMap, NetworkMapMembership +from core.access import use_jwt_auth + + +def _contacts_qs(user): + qs = Contact.objects.all() + if use_jwt_auth(): + if user and user.is_authenticated: + return qs.filter(owner=user) + return qs.none() + return qs + + +def _relations_qs(user): + qs = Relation.objects.select_related('source', 'target').all() + if use_jwt_auth(): + if user and user.is_authenticated: + return qs.filter(owner=user) + return qs.none() + return qs + + +def _network_maps_qs(user): + qs = NetworkMap.objects.select_related('map_type').all() + if use_jwt_auth(): + if user and user.is_authenticated: + return qs.filter(owner=user) + return qs.none() + return qs def node_from_contact(contact): @@ -36,33 +64,41 @@ def edge_from_relation(relation): } -def build_full_graph(): - contacts = Contact.objects.all() +def build_full_graph(user=None): + contacts = _contacts_qs(user) nodes = [node_from_contact(c) for c in contacts] - relations = Relation.objects.select_related('source', 'target').all() + relations = _relations_qs(user) edges = [edge_from_relation(r) for r in relations] return {'nodes': nodes, 'edges': edges} -def build_network_map_graph(map_id=None): +def build_network_map_graph(map_id=None, user=None): + maps_qs = _network_maps_qs(user) + if not map_id: - default_map = NetworkMap.objects.select_related('map_type').order_by('id').first() + default_map = maps_qs.order_by('id').first() if not default_map: return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''} map_id = default_map.id - network_map = NetworkMap.objects.select_related('map_type').filter(pk=map_id).first() + network_map = maps_qs.filter(pk=map_id).first() if not network_map: return {'nodes': [], 'edges': [], 'conflictology': False, 'conflict_subject': ''} memberships = list( NetworkMapMembership.objects.filter(map_id=map_id) - .select_related('contact') + .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 = Relation.objects.select_related('source', 'target').all() + relations = _relations_qs(user) edges = [ edge_from_relation(r) for r in relations diff --git a/backend/graph/urls.py b/backend/graph/urls.py index e2d7ea3..da034ae 100644 --- a/backend/graph/urls.py +++ b/backend/graph/urls.py @@ -3,6 +3,6 @@ from django.urls import path from . import views urlpatterns = [ - path('graph/', views.graph_data, name='graph-data'), - path('network-map-graph/', views.network_map_graph, name='network-map-graph'), + path('graph/', views.GraphDataView.as_view(), name='graph-data'), + path('network-map-graph/', views.NetworkMapGraphView.as_view(), name='network-map-graph'), ] diff --git a/backend/graph/views.py b/backend/graph/views.py index 0b788a8..ab4ccec 100644 --- a/backend/graph/views.py +++ b/backend/graph/views.py @@ -1,15 +1,23 @@ -from rest_framework.decorators import api_view from rest_framework.response import Response +from rest_framework.views import APIView +from core.drf_mixins import JwtAuthMixin +from core.access import use_jwt_auth from .services import build_full_graph, build_network_map_graph -@api_view(['GET']) -def graph_data(request): - return Response(build_full_graph()) +def _graph_user(request): + if use_jwt_auth(): + return request.user if request.user.is_authenticated else None + return None -@api_view(['GET']) -def network_map_graph(request): - map_id = request.query_params.get('map_id') - return Response(build_network_map_graph(map_id)) +class GraphDataView(JwtAuthMixin, APIView): + def get(self, request): + return Response(build_full_graph(user=_graph_user(request))) + + +class NetworkMapGraphView(JwtAuthMixin, APIView): + def get(self, request): + map_id = request.query_params.get('map_id') + return Response(build_network_map_graph(map_id, user=_graph_user(request))) diff --git a/backend/import_export/services.py b/backend/import_export/services.py index 2cd48ff..d2cc654 100644 --- a/backend/import_export/services.py +++ b/backend/import_export/services.py @@ -196,7 +196,7 @@ def parse_upload_file(file): return None, 'Поддерживаются только CSV, JSON и vCard (.vcf) файлы.' -def import_contacts_from_rows(rows): +def import_contacts_from_rows(rows, owner=None): created = 0 skipped = 0 errors = [] @@ -212,16 +212,18 @@ def import_contacts_from_rows(rows): 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(), - }, - ) + lookup = {'name': name} + defaults = { + 'email': str(row.get('email') or '').strip(), + 'phone': str(row.get('phone') or '').strip(), + 'organization': str(row.get('organization') or '').strip(), + 'position': str(row.get('position') or '').strip(), + 'notes': str(row.get('notes') or '').strip(), + } + if owner is not None: + lookup['owner'] = owner + defaults['owner'] = owner + Contact.objects.get_or_create(**lookup, defaults=defaults) created += 1 return { 'total': len(rows), diff --git a/backend/import_export/urls.py b/backend/import_export/urls.py index 8858e51..7ae7b80 100644 --- a/backend/import_export/urls.py +++ b/backend/import_export/urls.py @@ -3,5 +3,5 @@ from django.urls import path from . import views urlpatterns = [ - path('import/', views.import_contacts, name='import-contacts'), + path('import/', views.ImportContactsView.as_view(), name='import-contacts'), ] diff --git a/backend/import_export/views.py b/backend/import_export/views.py index c5b869c..21d3bc7 100644 --- a/backend/import_export/views.py +++ b/backend/import_export/views.py @@ -1,19 +1,25 @@ from rest_framework import status -from rest_framework.decorators import api_view from rest_framework.response import Response +from rest_framework.views import APIView +from core.access import use_jwt_auth +from core.drf_mixins import JwtAuthMixin from .services import parse_upload_file, import_contacts_from_rows -@api_view(['POST']) -def import_contacts(request): - file = request.FILES.get('file') - if not file: - return Response({'error': 'Файл не передан.'}, status=status.HTTP_400_BAD_REQUEST) - try: - rows, error = parse_upload_file(file) - if error: - return Response({'error': error}, status=status.HTTP_400_BAD_REQUEST) - return Response(import_contacts_from_rows(rows)) - except Exception as e: - return Response({'error': f'Ошибка разбора файла: {e}'}, status=status.HTTP_400_BAD_REQUEST) +class ImportContactsView(JwtAuthMixin, APIView): + def post(self, request): + file = request.FILES.get('file') + if not file: + return Response({'error': 'Файл не передан.'}, status=status.HTTP_400_BAD_REQUEST) + try: + rows, error = parse_upload_file(file) + if error: + return Response({'error': error}, status=status.HTTP_400_BAD_REQUEST) + owner = request.user if use_jwt_auth() and request.user.is_authenticated else None + return Response(import_contacts_from_rows(rows, owner=owner)) + except Exception as e: + return Response( + {'error': f'Ошибка разбора файла: {e}'}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/backend/plugins/urls.py b/backend/plugins/urls.py index e87c416..199a810 100644 --- a/backend/plugins/urls.py +++ b/backend/plugins/urls.py @@ -4,6 +4,6 @@ from . import views from .base import plugin_urlpatterns urlpatterns = [ - path('plugins/', views.plugin_manifest, name='plugin-manifest'), + path('plugins/', views.PluginManifestView.as_view(), name='plugin-manifest'), *plugin_urlpatterns(), ] diff --git a/backend/plugins/views.py b/backend/plugins/views.py index d52b24f..72a4506 100644 --- a/backend/plugins/views.py +++ b/backend/plugins/views.py @@ -1,19 +1,18 @@ -from django.urls import path -from rest_framework.decorators import api_view from rest_framework.response import Response +from rest_framework.views import APIView +from core.drf_mixins import JwtAuthMixin from plugins.base import get_enabled_plugins -@api_view(['GET']) -def plugin_manifest(request): - """List enabled plugins and their metadata.""" - return Response([ - { - 'id': p.id, - 'version': p.version, - 'min_core_version': p.min_core_version, - 'permissions': p.permissions, - } - for p in get_enabled_plugins() - ]) +class PluginManifestView(JwtAuthMixin, APIView): + def get(self, request): + return Response([ + { + 'id': p.id, + 'version': p.version, + 'min_core_version': p.min_core_version, + 'permissions': p.permissions, + } + for p in get_enabled_plugins() + ]) diff --git a/backend/plugins_tags/migrations/0002_contacttag_owner.py b/backend/plugins_tags/migrations/0002_contacttag_owner.py new file mode 100644 index 0000000..4b5a55b --- /dev/null +++ b/backend/plugins_tags/migrations/0002_contacttag_owner.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def assign_tag_owner(apps, schema_editor): + ContactTag = apps.get_model('plugins_tags', 'ContactTag') + Contact = apps.get_model('contacts', 'Contact') + for tag in ContactTag.objects.filter(owner__isnull=True).select_related('contact'): + if tag.contact_id: + contact = Contact.objects.filter(pk=tag.contact_id).first() + if contact and contact.owner_id: + tag.owner_id = contact.owner_id + tag.save(update_fields=['owner_id']) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contacts', '0011_add_owner'), + ('plugins_tags', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='contacttag', + name='owner', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='contact_tags', + to=settings.AUTH_USER_MODEL, + verbose_name='Владелец', + ), + ), + migrations.RunPython(assign_tag_owner, migrations.RunPython.noop), + ] diff --git a/backend/plugins_tags/models.py b/backend/plugins_tags/models.py index 7394e33..ef7b1c6 100644 --- a/backend/plugins_tags/models.py +++ b/backend/plugins_tags/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import models from contacts.models import Contact @@ -6,6 +7,14 @@ from contacts.models import Contact class ContactTag(models.Model): """Tag assigned to a contact (reference plugin).""" + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='contact_tags', + null=True, + blank=True, + verbose_name='Владелец', + ) contact = models.ForeignKey( Contact, on_delete=models.CASCADE, diff --git a/backend/plugins_tags/views.py b/backend/plugins_tags/views.py index e630127..8f6c3a8 100644 --- a/backend/plugins_tags/views.py +++ b/backend/plugins_tags/views.py @@ -1,22 +1,41 @@ from rest_framework import viewsets +from rest_framework.exceptions import PermissionDenied, ValidationError from django.conf import settings +from core.access import use_jwt_auth, user_workspace_id +from core.drf_mixins import JwtAuthMixin from .models import ContactTag from .serializers import ContactTagSerializer -class ContactTagViewSet(viewsets.ModelViewSet): +class ContactTagViewSet(JwtAuthMixin, viewsets.ModelViewSet): serializer_class = ContactTagSerializer def get_queryset(self): qs = ContactTag.objects.select_related('contact').all() - workspace = self.request.query_params.get('workspace_id') or settings.DEFAULT_WORKSPACE_ID - qs = qs.filter(workspace_id=workspace) + if use_jwt_auth(): + user = self.request.user + if user and user.is_authenticated: + qs = qs.filter(owner=user) + else: + return qs.none() + else: + workspace = self.request.query_params.get('workspace_id') or settings.DEFAULT_WORKSPACE_ID + qs = qs.filter(workspace_id=workspace) contact_id = self.request.query_params.get('contact_id') if contact_id: qs = qs.filter(contact_id=contact_id) return qs def perform_create(self, serializer): + if use_jwt_auth(): + user = self.request.user + if not user or not user.is_authenticated: + raise PermissionDenied() + contact = serializer.validated_data.get('contact') + if contact.owner_id != user.id: + raise ValidationError({'contact': 'Контакт не принадлежит текущему пользователю.'}) + serializer.save(owner=user, workspace_id=user_workspace_id(user)) + return workspace = self.request.data.get('workspace_id') or settings.DEFAULT_WORKSPACE_ID serializer.save(workspace_id=workspace) diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py new file mode 100644 index 0000000..2357ed2 --- /dev/null +++ b/backend/tests/test_auth_api.py @@ -0,0 +1,113 @@ +import pytest +from django.test import override_settings +from rest_framework.settings import api_settings +from rest_framework.test import APIClient + + +JWT_REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 100, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], +} + + +@pytest.fixture +def jwt_api_client(): + with override_settings(USE_JWT_AUTH=True, REST_FRAMEWORK=JWT_REST_FRAMEWORK): + api_settings.reload() + client = APIClient() + yield client + api_settings.reload() + + +@pytest.mark.django_db +def test_register_and_isolated_contacts(jwt_api_client): + reg = jwt_api_client.post( + '/api/v1/auth/register/', + {'username': 'alice', 'password': 'strong-pass-1'}, + format='json', + ) + assert reg.status_code == 201 + assert reg.data['user']['username'] == 'alice' + token = reg.data['access'] + + create = jwt_api_client.post( + '/api/v1/contacts/', + {'name': 'Контакт Alice'}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + assert create.status_code == 201 + + reg_b = jwt_api_client.post( + '/api/v1/auth/register/', + {'username': 'bob', 'password': 'strong-pass-2'}, + format='json', + ) + token_b = reg_b.data['access'] + + alice_list = jwt_api_client.get( + '/api/v1/contacts/', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + bob_list = jwt_api_client.get( + '/api/v1/contacts/', + HTTP_AUTHORIZATION=f'Bearer {token_b}', + ) + assert alice_list.data['count'] == 1 + assert bob_list.data['count'] == 0 + + +@pytest.mark.django_db +def test_unauthenticated_api_denied(jwt_api_client, sample_contact): + response = jwt_api_client.get('/api/v1/contacts/') + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_update_profile_and_password(jwt_api_client): + reg = jwt_api_client.post( + '/api/v1/auth/register/', + {'username': 'carol', 'email': 'carol@test.com', 'password': 'strong-pass-1'}, + format='json', + ) + token = reg.data['access'] + auth = {'HTTP_AUTHORIZATION': f'Bearer {token}'} + + profile = jwt_api_client.patch( + '/api/v1/auth/me/', + { + 'username': 'carol_new', + 'email': 'new@test.com', + 'current_password': 'strong-pass-1', + }, + format='json', + **auth, + ) + assert profile.status_code == 200 + assert profile.data['username'] == 'carol_new' + assert profile.data['email'] == 'new@test.com' + + password = jwt_api_client.post( + '/api/v1/auth/me/password/', + { + 'current_password': 'strong-pass-1', + 'new_password': 'strong-pass-9', + }, + format='json', + **auth, + ) + assert password.status_code == 200 + + login = jwt_api_client.post( + '/api/v1/auth/token/', + {'username': 'carol_new', 'password': 'strong-pass-9'}, + format='json', + ) + assert login.status_code == 200 diff --git a/backend/tests/test_graph_api.py b/backend/tests/test_graph_api.py index 7daba9d..369132d 100644 --- a/backend/tests/test_graph_api.py +++ b/backend/tests/test_graph_api.py @@ -9,7 +9,7 @@ def test_graph_empty(api_client): @pytest.mark.django_db -def test_graph_with_data(api_client, sample_contact, sample_relation): +def test_graph_with_data(api_client, sample_relation): response = api_client.get('/api/v1/graph/') assert response.status_code == 200 assert len(response.data['nodes']) == 2 diff --git a/docker-compose.yml b/docker-compose.yml index 7bb7901..6482304 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - sqlite_data:/app/data environment: - DJANGO_SETTINGS_MODULE=config.settings + - USE_JWT_AUTH=true ports: - "8000:8000" restart: unless-stopped diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c537599..f2ef18f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,8 @@