diff --git a/auth_app/api/serializers.py b/auth_app/api/serializers.py index c843c27..61cc25c 100644 --- a/auth_app/api/serializers.py +++ b/auth_app/api/serializers.py @@ -11,7 +11,17 @@ class RegistrationSerializer(serializers.Serializer): email = serializers.EmailField() password = serializers.CharField(write_only=True) repeated_password = serializers.CharField(write_only=True) - type = serializers.CharField(write_only=True) + type = serializers.ChoiceField( + choices=UserProfile.ProfileType.choices, + write_only=True, + ) + + def validate_username(self, value): + if get_user_model().objects.filter(username=value).exists(): + raise serializers.ValidationError( + 'A user with that username already exists.' + ) + return value def validate(self, attrs): if attrs['password'] != attrs['repeated_password']: @@ -21,6 +31,7 @@ def validate(self, attrs): return attrs def create(self, validated_data): + validated_data.pop('repeated_password', None) user = get_user_model().objects.create_user( username=validated_data['username'], email=validated_data['email'], diff --git a/auth_app/api/test_auth_endpoints.py b/auth_app/api/test_auth_endpoints.py index 3a19e60..8054399 100644 --- a/auth_app/api/test_auth_endpoints.py +++ b/auth_app/api/test_auth_endpoints.py @@ -29,6 +29,31 @@ def test_registration_creates_customer_user_and_returns_token(): assert profile.type == UserProfile.ProfileType.CUSTOMER +@pytest.mark.django_db +def test_registration_creates_business_user_and_returns_token(): + client = APIClient() + url = reverse('registration') + payload = { + 'username': 'business_user', + 'email': 'business@example.com', + 'password': 'StrongPass123!', + 'repeated_password': 'StrongPass123!', + 'type': 'business', + } + + response = client.post(url, data=payload, format='json') + + assert response.status_code == 201 + assert response.data == { + 'username': 'business_user', + 'email': payload['email'], + 'user_id': response.data['user_id'], + 'token': response.data['token'], + } + profile = UserProfile.objects.get(user_id=response.data['user_id']) + assert profile.type == UserProfile.ProfileType.BUSINESS + + @pytest.mark.django_db def test_registration_rejects_password_mismatch(): client = APIClient() @@ -47,6 +72,62 @@ def test_registration_rejects_password_mismatch(): assert not get_user_model().objects.filter(username='customer_user').exists() +@pytest.mark.django_db +def test_registration_rejects_invalid_user_type(): + client = APIClient() + url = reverse('registration') + payload = { + 'username': 'invalid_type_user', + 'email': 'invalid@example.com', + 'password': 'StrongPass123!', + 'repeated_password': 'StrongPass123!', + 'type': 'admin', + } + + response = client.post(url, data=payload, format='json') + + assert response.status_code == 400 + assert 'type' in response.data + assert not get_user_model().objects.filter(username='invalid_type_user').exists() + + +@pytest.mark.django_db +def test_registration_rejects_duplicate_username(): + get_user_model().objects.create_user( + username='customer_user', + email='existing@example.com', + password='StrongPass123!', + ) + client = APIClient() + url = reverse('registration') + payload = { + 'username': 'customer_user', + 'email': 'customer@example.com', + 'password': 'StrongPass123!', + 'repeated_password': 'StrongPass123!', + 'type': 'customer', + } + + response = client.post(url, data=payload, format='json') + + assert response.status_code == 400 + assert 'username' in response.data + + +@pytest.mark.django_db +def test_registration_rejects_missing_required_fields(): + response = APIClient().post(reverse('registration'), data={}, format='json') + + assert response.status_code == 400 + assert set(response.data) == { + 'username', + 'email', + 'password', + 'repeated_password', + 'type', + } + + @pytest.mark.django_db def test_login_returns_token_and_user_data(): user = get_user_model().objects.create_user( @@ -87,3 +168,27 @@ def test_login_rejects_invalid_password(): response = client.post(url, data=payload, format='json') assert response.status_code == 400 + assert 'detail' in response.data + + +@pytest.mark.django_db +def test_login_rejects_unknown_username(): + client = APIClient() + url = reverse('login') + payload = { + 'username': 'missing_user', + 'password': 'StrongPass123!', + } + + response = client.post(url, data=payload, format='json') + + assert response.status_code == 400 + assert 'detail' in response.data + + +@pytest.mark.django_db +def test_login_rejects_missing_required_fields(): + response = APIClient().post(reverse('login'), data={}, format='json') + + assert response.status_code == 400 + assert set(response.data) == {'username', 'password'} diff --git a/core/settings.py b/core/settings.py index 95e501a..6c69bf9 100644 --- a/core/settings.py +++ b/core/settings.py @@ -97,6 +97,7 @@ def _env_list(name, default=''): # https://www.django-rest-framework.org/ REST_FRAMEWORK = { + 'DATETIME_FORMAT': '%Y-%m-%dT%H:%M:%SZ', 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.SessionAuthentication', @@ -158,7 +159,7 @@ def _env_list(name, default=''): LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'Europe/Berlin' +TIME_ZONE = 'UTC' USE_I18N = True diff --git a/offers_app/api/serializers.py b/offers_app/api/serializers.py index 85df0e6..861a299 100644 --- a/offers_app/api/serializers.py +++ b/offers_app/api/serializers.py @@ -7,6 +7,8 @@ class OfferDetailSerializer(serializers.ModelSerializer): """Serialize full offer detail data for writes and detail retrieval.""" + price = serializers.FloatField() + class Meta: model = OfferDetail fields = [ @@ -21,7 +23,25 @@ class Meta: class OfferDetailLinkSerializer(serializers.ModelSerializer): - """Serialize offer detail references for offer list and detail responses.""" + """Serialize offer detail references for offer detail responses.""" + + url = serializers.SerializerMethodField() + + class Meta: + model = OfferDetail + fields = [ + 'id', + 'url', + ] + + def get_url(self, obj): + url = reverse('offerdetail-detail', kwargs={'pk': obj.id}) + request = self.context.get('request') + return request.build_absolute_uri(url) if request else url + + +class OfferListDetailLinkSerializer(serializers.ModelSerializer): + """Serialize offer detail references for offer list responses.""" url = serializers.SerializerMethodField() @@ -33,7 +53,7 @@ class Meta: ] def get_url(self, obj): - return reverse('offerdetail-detail', kwargs={'pk': obj.id}) + return f'/offerdetails/{obj.id}/' class UserDetailsSerializer(serializers.Serializer): @@ -45,43 +65,24 @@ class UserDetailsSerializer(serializers.Serializer): class OfferSerializer(serializers.ModelSerializer): - """Serialize offer writes with nested details and computed summary fields.""" + """Serialize offer writes with nested details.""" details = OfferDetailSerializer(many=True, required=False) - min_price = serializers.SerializerMethodField() - min_delivery_time = serializers.SerializerMethodField() + image = serializers.ImageField(required=False, allow_null=True) class Meta: model = Offer fields = [ 'id', - 'user', 'title', - 'description', 'image', + 'description', 'details', - 'min_price', - 'min_delivery_time', - 'created_at', - 'updated_at', ] read_only_fields = [ 'id', - 'user', - 'min_price', - 'min_delivery_time', - 'created_at', - 'updated_at', ] - def get_min_price(self, obj): - prices = [detail.price for detail in obj.details.all()] - return float(min(prices)) if prices else None - - def get_min_delivery_time(self, obj): - times = [detail.delivery_time_in_days for detail in obj.details.all()] - return min(times) if times else None - def validate(self, attrs): if self.instance is None and 'details' not in attrs: raise serializers.ValidationError( @@ -104,15 +105,23 @@ def validate_details(self, value): return value def _validate_update_details(self, value): + existing_types = set(self.instance.details.values_list('offer_type', flat=True)) for detail in value: - if 'offer_type' not in detail: + offer_type = detail.get('offer_type') + if offer_type is None: raise serializers.ValidationError( 'Offer type is required to update a detail.', ) + if offer_type not in existing_types: + raise serializers.ValidationError( + f'No detail with offer type "{offer_type}" exists for this offer.', + ) return value def create(self, validated_data): details_data = validated_data.pop('details', None) + if 'image' in validated_data and validated_data['image'] is None: + validated_data['image'] = '' offer = Offer.objects.create( user=self.context['request'].user, **validated_data, @@ -122,6 +131,8 @@ def create(self, validated_data): def update(self, instance, validated_data): details_data = validated_data.pop('details', []) + if 'image' in validated_data and validated_data['image'] is None: + validated_data['image'] = '' offer = super().update(instance, validated_data) self._update_details(offer, details_data) return offer @@ -143,11 +154,37 @@ class OfferReadSerializer(OfferSerializer): """Serialize offers for read endpoints with linked details.""" details = OfferDetailLinkSerializer(many=True) + min_price = serializers.SerializerMethodField() + min_delivery_time = serializers.SerializerMethodField() + + class Meta(OfferSerializer.Meta): + fields = [ + 'id', + 'user', + 'title', + 'image', + 'description', + 'created_at', + 'updated_at', + 'details', + 'min_price', + 'min_delivery_time', + ] + read_only_fields = fields + + def get_min_price(self, obj): + prices = [detail.price for detail in obj.details.all()] + return float(min(prices)) if prices else None + + def get_min_delivery_time(self, obj): + times = [detail.delivery_time_in_days for detail in obj.details.all()] + return min(times) if times else None class OfferListSerializer(OfferReadSerializer): """Serialize paginated offer list entries with compact creator details.""" + details = OfferListDetailLinkSerializer(many=True) user_details = UserDetailsSerializer(source='user') class Meta(OfferReadSerializer.Meta): diff --git a/offers_app/api/test_offer_endpoints.py b/offers_app/api/test_offer_endpoints.py index 6a731fd..ca38b04 100644 --- a/offers_app/api/test_offer_endpoints.py +++ b/offers_app/api/test_offer_endpoints.py @@ -1,7 +1,10 @@ +from datetime import datetime + import pytest from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +from django.utils import timezone from rest_framework.authtoken.models import Token from rest_framework.test import APIClient @@ -136,10 +139,43 @@ def test_business_user_can_create_offer_with_three_details(): ) assert response.status_code == 201 + assert set(response.data) == { + 'id', + 'title', + 'image', + 'description', + 'details', + } assert response.data['title'] == payload['title'] assert response.data['description'] == payload['description'] - assert response.data['user'] == user.id + assert response.data['image'] is None assert len(response.data['details']) == 3 + assert set(response.data['details'][0]) == { + 'id', + 'title', + 'revisions', + 'delivery_time_in_days', + 'price', + 'features', + 'offer_type', + } + assert response.data['details'][0]['price'] == 50.0 + + +@pytest.mark.django_db +def test_business_user_can_create_offer_with_null_image(): + user = create_user() + payload = offer_payload() + payload['image'] = None + + response = authenticated_client(user).post( + reverse('offer-list'), + data=payload, + format='json', + ) + + assert response.status_code == 201 + assert response.data['image'] is None @pytest.mark.django_db @@ -186,7 +222,7 @@ def test_offer_create_requires_basic_standard_and_premium_details(): @pytest.mark.django_db -def test_offer_create_returns_min_price_and_min_delivery_time(): +def test_offer_create_does_not_return_list_summary_fields(): user = create_user() response = authenticated_client(user).post( @@ -196,8 +232,11 @@ def test_offer_create_returns_min_price_and_min_delivery_time(): ) assert response.status_code == 201 - assert response.data['min_price'] == 50.0 - assert response.data['min_delivery_time'] == 3 + assert 'user' not in response.data + assert 'min_price' not in response.data + assert 'min_delivery_time' not in response.data + assert 'created_at' not in response.data + assert 'updated_at' not in response.data @pytest.mark.django_db @@ -211,11 +250,27 @@ def test_offer_list_returns_detail_links_only(): assert response.status_code == 200 assert response.data['results'][0]['details'][0] == { 'id': detail.id, - 'url': f'/api/offerdetails/{detail.id}/', + 'url': f'/offerdetails/{detail.id}/', } assert 'title' not in response.data['results'][0]['details'][0] +@pytest.mark.django_db +def test_offer_list_returns_documented_timestamp_format(): + user = create_user() + offer = create_offer_with_details(user) + Offer.objects.filter(id=offer.id).update( + created_at=datetime(2026, 7, 7, 12, 30, 45, 123456, tzinfo=timezone.UTC), + updated_at=datetime(2026, 7, 7, 12, 31, 45, 123456, tzinfo=timezone.UTC), + ) + + response = APIClient().get(reverse('offer-list')) + + assert response.status_code == 200 + assert response.data['results'][0]['created_at'] == '2026-07-07T12:30:45Z' + assert response.data['results'][0]['updated_at'] == '2026-07-07T12:31:45Z' + + @pytest.mark.django_db def test_authenticated_user_can_get_offer_detail_data(): user = create_user() @@ -239,11 +294,20 @@ def test_authenticated_user_can_get_offer_detail_data(): ) assert response.status_code == 200 + assert set(response.data) == { + 'id', + 'title', + 'revisions', + 'delivery_time_in_days', + 'price', + 'features', + 'offer_type', + } assert response.data['id'] == detail.id assert response.data['title'] == detail.title assert response.data['revisions'] == -1 assert response.data['delivery_time_in_days'] == 3 - assert response.data['price'] == '50.00' + assert response.data['price'] == 50.0 assert response.data['features'] == ['one concept'] assert response.data['offer_type'] == 'basic' @@ -300,7 +364,37 @@ def test_business_user_can_update_own_offer(): offer.refresh_from_db() assert response.status_code == 200 + assert set(response.data) == { + 'id', + 'title', + 'image', + 'description', + 'details', + } + assert response.data['id'] == offer.id assert response.data['title'] == 'Updated Logo Design' + assert response.data['description'] == offer.description + assert response.data['image'] is None + assert len(response.data['details']) == 3 + assert offer.title == 'Updated Logo Design' + + +@pytest.mark.django_db +def test_offer_patch_without_image_keeps_existing_image(): + user = create_user() + offer = create_offer_with_details(user) + offer.image = 'offers/existing.png' + offer.save(update_fields=['image']) + + response = authenticated_client(user).patch( + reverse('offer-detail', kwargs={'pk': offer.id}), + data={'title': 'Updated Logo Design'}, + format='json', + ) + + offer.refresh_from_db() + assert response.status_code == 200 + assert offer.image.name == 'offers/existing.png' assert offer.title == 'Updated Logo Design' @@ -345,6 +439,21 @@ def test_offer_update_invalid_data_returns_400(): assert response.status_code == 400 +@pytest.mark.django_db +def test_offer_update_unknown_detail_type_returns_400(): + user = create_user() + offer = create_offer_with_details(user) + + response = authenticated_client(user).patch( + reverse('offer-detail', kwargs={'pk': offer.id}), + data={'details': [{'offer_type': 'enterprise', 'title': 'Enterprise'}]}, + format='json', + ) + + assert response.status_code == 400 + assert 'details' in response.data + + @pytest.mark.django_db def test_business_user_cannot_delete_another_users_offer(): owner = create_user('owner_user') @@ -377,6 +486,7 @@ def test_business_user_can_delete_own_offer(): ) assert response.status_code == 204 + assert response.content == b'' assert not Offer.objects.filter(id=offer.id).exists() @@ -427,6 +537,41 @@ def test_customer_user_can_get_offer_detail(): assert response.data['id'] == offer.id +@pytest.mark.django_db +def test_offer_detail_matches_documented_shape_with_absolute_detail_urls(): + business_user = create_user('business_user') + customer_user = create_user('customer_user', UserProfile.ProfileType.CUSTOMER) + offer = create_offer_with_details(business_user, 'Grafikdesign-Paket', 50, 5) + detail = offer.details.get(offer_type='basic') + + response = authenticated_client(customer_user).get( + reverse('offer-detail', kwargs={'pk': offer.id}), + ) + + assert response.status_code == 200 + assert set(response.data) == { + 'id', + 'user', + 'title', + 'image', + 'description', + 'created_at', + 'updated_at', + 'details', + 'min_price', + 'min_delivery_time', + } + assert response.data['id'] == offer.id + assert response.data['user'] == business_user.id + assert response.data['image'] is None + assert response.data['min_price'] == 50.0 + assert response.data['min_delivery_time'] == 5 + assert response.data['details'][0] == { + 'id': detail.id, + 'url': f'http://testserver/api/offerdetails/{detail.id}/', + } + + @pytest.mark.django_db def test_offer_detail_requires_authentication(): user = create_user() @@ -489,11 +634,15 @@ def test_business_user_can_update_single_offer_detail_by_type(): user = create_user() offer = create_offer_with_details(user) basic_detail = offer.details.get(offer_type='basic') + original_detail_ids = set(offer.details.values_list('id', flat=True)) payload = { 'details': [ { 'title': 'Basic Logo Updated', 'revisions': 2, + 'delivery_time_in_days': 6, + 'price': 120, + 'features': ['Logo Design', 'Flyer'], 'offer_type': 'basic', }, ], @@ -506,9 +655,20 @@ def test_business_user_can_update_single_offer_detail_by_type(): ) basic_detail.refresh_from_db() + response_detail_ids = {detail['id'] for detail in response.data['details']} + updated_basic = next( + detail for detail in response.data['details'] if detail['offer_type'] == 'basic' + ) assert response.status_code == 200 + assert response_detail_ids == original_detail_ids + assert len(response.data['details']) == 3 assert basic_detail.title == 'Basic Logo Updated' assert basic_detail.revisions == 2 + assert basic_detail.delivery_time_in_days == 6 + assert float(basic_detail.price) == 120.0 + assert basic_detail.features == ['Logo Design', 'Flyer'] + assert updated_basic['id'] == basic_detail.id + assert updated_basic['price'] == 120.0 @pytest.mark.django_db @@ -522,6 +682,58 @@ def test_offer_list_response_is_paginated(): assert set(response.data) == {'count', 'next', 'previous', 'results'} +@pytest.mark.django_db +def test_offer_list_result_matches_documented_shape(): + user = create_user('business_user', first_name='John', last_name='Doe') + offer = create_offer_with_details(user, 'Website Design', 100, 7) + + response = APIClient().get(reverse('offer-list')) + + assert response.status_code == 200 + result = response.data['results'][0] + assert set(result) == { + 'id', + 'user', + 'title', + 'image', + 'description', + 'created_at', + 'updated_at', + 'details', + 'min_price', + 'min_delivery_time', + 'user_details', + } + assert result['id'] == offer.id + assert result['user'] == user.id + assert result['image'] is None + assert result['min_price'] == 100.0 + assert result['min_delivery_time'] == 7 + assert result['user_details'] == { + 'first_name': 'John', + 'last_name': 'Doe', + 'username': 'business_user', + } + assert len(result['details']) == 3 + assert set(result['details'][0]) == {'id', 'url'} + assert result['details'][0]['url'].startswith('/offerdetails/') + + +@pytest.mark.django_db +def test_offer_list_respects_page_size_query_parameter(): + user = create_user() + create_offer_with_details(user, 'First Offer', 50, 3) + create_offer_with_details(user, 'Second Offer', 60, 4) + url = f'{reverse("offer-list")}?page_size=1' + + response = APIClient().get(url) + + assert response.status_code == 200 + assert response.data['count'] == 2 + assert len(response.data['results']) == 1 + assert response.data['next'] is not None + + @pytest.mark.django_db def test_offer_list_contains_user_details(): user = create_user('business_user', first_name='Max', last_name='Mustermann') @@ -638,6 +850,16 @@ def test_offer_list_search_matches_description(): assert response.data['results'][0]['id'] == matching_offer.id +@pytest.mark.django_db +def test_offer_list_invalid_ordering_returns_400(): + url = f'{reverse("offer-list")}?ordering=title' + + response = APIClient().get(url) + + assert response.status_code == 400 + assert 'ordering' in response.data + + @pytest.mark.django_db def test_offer_list_invalid_creator_id_returns_400(): url = f'{reverse("offer-list")}?creator_id=abc' diff --git a/offers_app/api/views.py b/offers_app/api/views.py index e975289..e394219 100644 --- a/offers_app/api/views.py +++ b/offers_app/api/views.py @@ -103,7 +103,11 @@ def _apply_ordering(self, queryset): 'min_price': 'min_price_value', '-min_price': '-min_price_value', } - return queryset.order_by(allowed_fields.get(ordering, '-updated_at')) + if ordering in [None, '']: + return queryset.order_by('-updated_at') + if ordering not in allowed_fields: + raise ValidationError({'ordering': 'Unsupported ordering field.'}) + return queryset.order_by(allowed_fields[ordering]) def _get_int_param(self, name): value = self.request.query_params.get(name) diff --git a/orders_app/api/serializers.py b/orders_app/api/serializers.py index f3bbb7d..96d016a 100644 --- a/orders_app/api/serializers.py +++ b/orders_app/api/serializers.py @@ -45,6 +45,15 @@ class Meta: def get_price(self, obj): return float(obj.price) + def validate(self, attrs): + if self.instance is not None: + invalid_fields = set(self.initial_data) - {'status'} + if invalid_fields: + raise serializers.ValidationError( + {field: 'This field cannot be updated.' for field in invalid_fields} + ) + return super().validate(attrs) + def create(self, validated_data): offer_detail = self._get_offer_detail(validated_data) return Order.objects.create( diff --git a/orders_app/api/test_order_endpoints.py b/orders_app/api/test_order_endpoints.py index 6555b49..60bc484 100644 --- a/orders_app/api/test_order_endpoints.py +++ b/orders_app/api/test_order_endpoints.py @@ -137,6 +137,20 @@ def test_order_create_requires_offer_detail_id(): assert 'offer_detail_id' in response.data +@pytest.mark.django_db +def test_order_create_invalid_offer_detail_id_returns_400(): + customer_user = create_user() + + response = authenticated_client(customer_user).post( + reverse('order-list'), + data={'offer_detail_id': 'abc'}, + format='json', + ) + + assert response.status_code == 400 + assert 'offer_detail_id' in response.data + + @pytest.mark.django_db def test_order_create_unknown_offer_detail_returns_404(): customer_user = create_user() @@ -180,7 +194,32 @@ def test_customer_user_can_list_only_own_orders(): assert response.status_code == 200 assert len(response.data) == 1 + assert set(response.data[0]) == { + 'id', + 'customer_user', + 'business_user', + 'title', + 'revisions', + 'delivery_time_in_days', + 'price', + 'features', + 'offer_type', + 'status', + 'created_at', + 'updated_at', + } assert response.data[0]['id'] == own_order.id + assert response.data[0]['customer_user'] == customer_user.id + assert response.data[0]['business_user'] == offer_detail.offer.user.id + assert response.data[0]['title'] == 'Basic Logo' + assert response.data[0]['revisions'] == 3 + assert response.data[0]['delivery_time_in_days'] == 5 + assert response.data[0]['price'] == 150.0 + assert response.data[0]['features'] == ['Logo Design', 'Visitenkarten'] + assert response.data[0]['offer_type'] == 'basic' + assert response.data[0]['status'] == Order.Status.IN_PROGRESS + assert response.data[0]['created_at'].endswith('Z') + assert response.data[0]['updated_at'].endswith('Z') @pytest.mark.django_db @@ -271,6 +310,25 @@ def test_order_status_update_rejects_invalid_status(): assert response.status_code == 400 +@pytest.mark.django_db +def test_order_status_update_rejects_unallowed_fields(): + customer_user = create_user('customer_user') + business_user = create_user('business_user', UserProfile.ProfileType.BUSINESS) + order = create_order(customer_user, business_user) + + response = authenticated_client(business_user).patch( + reverse('order-detail', kwargs={'pk': order.id}), + data={'status': 'completed', 'title': 'Changed Title'}, + format='json', + ) + + order.refresh_from_db() + assert response.status_code == 400 + assert 'title' in response.data + assert order.title == 'Logo Design' + assert order.status == Order.Status.IN_PROGRESS + + @pytest.mark.django_db def test_order_status_update_unknown_returns_404(): business_user = create_user('business_user', UserProfile.ProfileType.BUSINESS) @@ -296,6 +354,7 @@ def test_staff_user_can_delete_order(): ) assert response.status_code == 204 + assert response.content == b'' assert not Order.objects.filter(id=order.id).exists() @@ -449,16 +508,19 @@ def test_business_user_cannot_update_another_users_order(): @pytest.mark.django_db -def test_order_list_does_not_crash_for_user_without_profile(): +def test_order_list_returns_empty_list_for_user_without_profile(): user = get_user_model().objects.create_user( username='no_profile_user', email='no_profile@example.com', password='StrongPass123!', ) + business_user = create_user('business_user', UserProfile.ProfileType.BUSINESS) + create_order(user, business_user) response = authenticated_client(user).get(reverse('order-list')) assert response.status_code == 200 + assert response.data == [] @pytest.mark.django_db diff --git a/orders_app/api/views.py b/orders_app/api/views.py index d66741d..ce06cec 100644 --- a/orders_app/api/views.py +++ b/orders_app/api/views.py @@ -23,7 +23,9 @@ def get_queryset(self): return queryset if self._is_customer(user): return queryset.filter(customer_user=user) - return queryset.filter(business_user=user) + if self._is_business(user): + return queryset.filter(business_user=user) + return queryset.none() def get_permissions(self): if self.action == 'create': @@ -40,6 +42,12 @@ def _is_customer(self, user): type=UserProfile.ProfileType.CUSTOMER, ).exists() + def _is_business(self, user): + return UserProfile.objects.filter( + user=user, + type=UserProfile.ProfileType.BUSINESS, + ).exists() + class BaseOrderCountView(APIView): """Return an order count for a business user filtered by status.""" diff --git a/profiles_app/api/serializers.py b/profiles_app/api/serializers.py index d18d2c9..cb9f9f5 100644 --- a/profiles_app/api/serializers.py +++ b/profiles_app/api/serializers.py @@ -6,8 +6,8 @@ class UserProfileSerializer(serializers.ModelSerializer): """Serialize profile data including related user fields.""" - user = serializers.IntegerField(source='user.id') - username = serializers.CharField(source='user.username') + user = serializers.IntegerField(source='user.id', read_only=True) + username = serializers.CharField(source='user.username', read_only=True) email = serializers.EmailField(source='user.email') class Meta: @@ -26,6 +26,10 @@ class Meta: 'email', 'created_at', ] + read_only_fields = [ + 'type', + 'created_at', + ] def update(self, instance, validated_data): user_data = validated_data.pop('user', {}) @@ -34,3 +38,45 @@ def update(self, instance, validated_data): instance.user.save(update_fields=['email']) return super().update(instance, validated_data) + + +class UserProfileListSerializer(serializers.ModelSerializer): + """Serialize profile list entries without private detail fields.""" + + user = serializers.IntegerField(source='user.id', read_only=True) + username = serializers.CharField(source='user.username', read_only=True) + + class Meta: + model = UserProfile + fields = [ + 'user', + 'username', + 'first_name', + 'last_name', + 'file', + 'location', + 'tel', + 'description', + 'working_hours', + 'type', + ] + + +class CustomerProfileListSerializer(serializers.ModelSerializer): + """Serialize customer profile list entries.""" + + user = serializers.IntegerField(source='user.id', read_only=True) + username = serializers.CharField(source='user.username', read_only=True) + uploaded_at = serializers.DateTimeField(source='created_at', read_only=True) + + class Meta: + model = UserProfile + fields = [ + 'user', + 'username', + 'first_name', + 'last_name', + 'file', + 'uploaded_at', + 'type', + ] diff --git a/profiles_app/api/test_profile_endpoints.py b/profiles_app/api/test_profile_endpoints.py index ff87361..79f5a6d 100644 --- a/profiles_app/api/test_profile_endpoints.py +++ b/profiles_app/api/test_profile_endpoints.py @@ -1,7 +1,10 @@ +from datetime import datetime + import pytest from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +from django.utils import timezone from rest_framework.authtoken.models import Token from rest_framework.test import APIClient @@ -27,6 +30,20 @@ def test_authenticated_user_can_get_profile_detail(): response = client.get(url) assert response.status_code == 200 + assert set(response.data) == { + 'user', + 'username', + 'first_name', + 'last_name', + 'file', + 'location', + 'tel', + 'description', + 'working_hours', + 'type', + 'email', + 'created_at', + } assert response.data['user'] == user.id assert response.data['username'] == user.username assert response.data['email'] == user.email @@ -37,6 +54,77 @@ def test_authenticated_user_can_get_profile_detail(): assert response.data['tel'] == '' assert response.data['description'] == '' assert response.data['working_hours'] == '' + assert response.data['first_name'] is not None + assert response.data['last_name'] is not None + assert response.data['location'] is not None + assert response.data['tel'] is not None + assert response.data['description'] is not None + assert response.data['working_hours'] is not None + + +@pytest.mark.django_db +def test_authenticated_user_can_get_another_users_profile_detail(): + owner = get_user_model().objects.create_user( + username='business_user', + email='business@example.com', + password='StrongPass123!', + ) + UserProfile.objects.create( + user=owner, + type=UserProfile.ProfileType.BUSINESS, + ) + requesting_user = get_user_model().objects.create_user( + username='customer_user', + email='customer@example.com', + password='StrongPass123!', + ) + UserProfile.objects.create( + user=requesting_user, + type=UserProfile.ProfileType.CUSTOMER, + ) + token, _ = Token.objects.get_or_create(user=requesting_user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}') + url = reverse('profile-detail', kwargs={'pk': owner.id}) + + response = client.get(url) + + assert response.status_code == 200 + assert response.data['user'] == owner.id + assert response.data['username'] == owner.username + + +@pytest.mark.django_db +def test_profile_detail_returns_documented_timestamp_format(): + user = get_user_model().objects.create_user( + username='business_user', + email='business@example.com', + password='StrongPass123!', + ) + profile = UserProfile.objects.create( + user=user, + type=UserProfile.ProfileType.BUSINESS, + ) + profile.created_at = datetime( + 2026, + 7, + 7, + 12, + 30, + 45, + 123456, + tzinfo=timezone.UTC, + ) + profile.save(update_fields=['created_at']) + token, _ = Token.objects.get_or_create(user=user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}') + url = reverse('profile-detail', kwargs={'pk': user.id}) + + response = client.get(url) + + assert response.status_code == 200 + assert response.data['created_at'] == '2026-07-07T12:30:45Z' @pytest.mark.django_db @@ -102,7 +190,22 @@ def test_authenticated_user_can_update_own_profile(): response = client.patch(url, data=payload, format='json') + user.refresh_from_db() assert response.status_code == 200 + assert set(response.data) == { + 'user', + 'username', + 'first_name', + 'last_name', + 'file', + 'location', + 'tel', + 'description', + 'working_hours', + 'type', + 'email', + 'created_at', + } assert response.data['first_name'] == payload['first_name'] assert response.data['last_name'] == payload['last_name'] assert response.data['location'] == payload['location'] @@ -110,6 +213,81 @@ def test_authenticated_user_can_update_own_profile(): assert response.data['description'] == payload['description'] assert response.data['working_hours'] == payload['working_hours'] assert response.data['email'] == payload['email'] + assert user.email == payload['email'] + assert response.data['type'] == UserProfile.ProfileType.BUSINESS + + +@pytest.mark.django_db +def test_profile_update_does_not_change_read_only_fields(): + user = get_user_model().objects.create_user( + username='business_user', + email='business@example.com', + password='StrongPass123!', + ) + profile = UserProfile.objects.create( + user=user, + type=UserProfile.ProfileType.BUSINESS, + ) + token, _ = Token.objects.get_or_create(user=user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}') + url = reverse('profile-detail', kwargs={'pk': user.id}) + + response = client.patch( + url, + data={ + 'user': 999, + 'username': 'changed_user', + 'type': UserProfile.ProfileType.CUSTOMER, + 'first_name': 'Max', + }, + format='json', + ) + + user.refresh_from_db() + profile.refresh_from_db() + assert response.status_code == 200 + assert response.data['user'] == user.id + assert response.data['username'] == 'business_user' + assert response.data['type'] == UserProfile.ProfileType.BUSINESS + assert user.username == 'business_user' + assert profile.type == UserProfile.ProfileType.BUSINESS + assert profile.first_name == 'Max' + + +@pytest.mark.django_db +def test_profile_update_requires_authentication(): + user = get_user_model().objects.create_user( + username='business_user', + email='business@example.com', + password='StrongPass123!', + ) + UserProfile.objects.create( + user=user, + type=UserProfile.ProfileType.BUSINESS, + ) + url = reverse('profile-detail', kwargs={'pk': user.id}) + + response = APIClient().patch(url, data={'first_name': 'Max'}, format='json') + + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_profile_update_returns_404_for_missing_profile(): + user = get_user_model().objects.create_user( + username='business_user', + email='business@example.com', + password='StrongPass123!', + ) + token, _ = Token.objects.get_or_create(user=user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}') + url = reverse('profile-detail', kwargs={'pk': user.id}) + + response = client.patch(url, data={'first_name': 'Max'}, format='json') + + assert response.status_code == 404 @pytest.mark.django_db @@ -290,15 +468,23 @@ def test_authenticated_user_can_get_customer_profile_list(): assert response.status_code == 200 assert len(response.data) == 1 + assert set(response.data[0]) == { + 'user', + 'username', + 'first_name', + 'last_name', + 'file', + 'uploaded_at', + 'type', + } assert response.data[0]['user'] == customer_user.id assert response.data[0]['username'] == customer_user.username assert response.data[0]['type'] == UserProfile.ProfileType.CUSTOMER assert response.data[0]['first_name'] == '' assert response.data[0]['last_name'] == '' - assert response.data[0]['location'] == '' - assert response.data[0]['tel'] == '' - assert response.data[0]['description'] == '' - assert response.data[0]['working_hours'] == '' + assert response.data[0]['first_name'] is not None + assert response.data[0]['last_name'] is not None + assert response.data[0]['uploaded_at'] @pytest.mark.django_db diff --git a/profiles_app/api/views.py b/profiles_app/api/views.py index 5b52686..5f3a3d0 100644 --- a/profiles_app/api/views.py +++ b/profiles_app/api/views.py @@ -6,7 +6,11 @@ from profiles_app.models import UserProfile -from .serializers import UserProfileSerializer +from .serializers import ( + CustomerProfileListSerializer, + UserProfileListSerializer, + UserProfileSerializer, +) class ProfileDetailView(APIView): @@ -39,7 +43,7 @@ class BusinessProfileListView(APIView): """List all business user profiles.""" permission_classes = [IsAuthenticated] - serializer_class = UserProfileSerializer + serializer_class = UserProfileListSerializer def get(self, request): profiles = UserProfile.objects.filter( @@ -53,7 +57,7 @@ class CustomerProfileListView(APIView): """List all customer user profiles.""" permission_classes = [IsAuthenticated] - serializer_class = UserProfileSerializer + serializer_class = CustomerProfileListSerializer def get(self, request): profiles = UserProfile.objects.filter( diff --git a/reviews_app/api/permissions.py b/reviews_app/api/permissions.py index 9102cde..8cc93f5 100644 --- a/reviews_app/api/permissions.py +++ b/reviews_app/api/permissions.py @@ -7,7 +7,10 @@ class IsCustomerUser(BasePermission): """Allow access only to authenticated customer users.""" def has_permission(self, request, view): - return request.user.profile.type == UserProfile.ProfileType.CUSTOMER + return UserProfile.objects.filter( + user=request.user, + type=UserProfile.ProfileType.CUSTOMER, + ).exists() class IsReviewOwner(BasePermission): diff --git a/reviews_app/api/test_review_endpoints.py b/reviews_app/api/test_review_endpoints.py index 7bae9f9..617ce27 100644 --- a/reviews_app/api/test_review_endpoints.py +++ b/reviews_app/api/test_review_endpoints.py @@ -82,6 +82,28 @@ def test_business_user_cannot_create_review(): assert response.status_code == 403 +@pytest.mark.django_db +def test_user_without_profile_cannot_create_review(): + user_without_profile = get_user_model().objects.create_user( + username='no_profile', + email='no_profile@example.com', + password='StrongPass123!', + ) + business_user = create_user('business_user', UserProfile.ProfileType.BUSINESS) + + response = authenticated_client(user_without_profile).post( + reverse('review-list'), + data={ + 'business_user': business_user.id, + 'rating': 4, + 'description': 'Alles war toll!', + }, + format='json', + ) + + assert response.status_code == 403 + + @pytest.mark.django_db def test_review_create_requires_authentication(): business_user = create_user( @@ -264,6 +286,42 @@ def test_review_list_orders_by_updated_at(): assert response.data[1]['id'] == second_review.id +@pytest.mark.django_db +def test_review_list_invalid_business_user_id_returns_400(): + reviewer = create_user('reviewer') + + response = authenticated_client(reviewer).get( + f'{reverse("review-list")}?business_user_id=abc', + ) + + assert response.status_code == 400 + assert 'business_user_id' in response.data + + +@pytest.mark.django_db +def test_review_list_invalid_reviewer_id_returns_400(): + reviewer = create_user('reviewer') + + response = authenticated_client(reviewer).get( + f'{reverse("review-list")}?reviewer_id=abc', + ) + + assert response.status_code == 400 + assert 'reviewer_id' in response.data + + +@pytest.mark.django_db +def test_review_list_invalid_ordering_returns_400(): + reviewer = create_user('reviewer') + + response = authenticated_client(reviewer).get( + f'{reverse("review-list")}?ordering=description', + ) + + assert response.status_code == 400 + assert 'ordering' in response.data + + @pytest.mark.django_db def test_review_owner_can_update_rating_and_description(): reviewer = create_user('reviewer') diff --git a/reviews_app/api/views.py b/reviews_app/api/views.py index 8a208b2..1cf7ac1 100644 --- a/reviews_app/api/views.py +++ b/reviews_app/api/views.py @@ -1,3 +1,4 @@ +from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet @@ -15,7 +16,8 @@ class ReviewViewSet(ModelViewSet): def get_queryset(self): queryset = Review.objects.select_related('business_user', 'reviewer') queryset = self._filter_by_business_user(queryset) - return self._filter_by_reviewer(queryset) + queryset = self._filter_by_reviewer(queryset) + return self._apply_ordering(queryset) def get_serializer_class(self): if self.action in ['update', 'partial_update']: @@ -30,13 +32,36 @@ def get_permissions(self): return [IsAuthenticated()] def _filter_by_business_user(self, queryset): - business_user_id = self.request.query_params.get('business_user_id') - if business_user_id: + business_user_id = self._get_int_param('business_user_id') + if business_user_id is not None: return queryset.filter(business_user_id=business_user_id) return queryset def _filter_by_reviewer(self, queryset): - reviewer_id = self.request.query_params.get('reviewer_id') - if reviewer_id: + reviewer_id = self._get_int_param('reviewer_id') + if reviewer_id is not None: return queryset.filter(reviewer_id=reviewer_id) return queryset + + def _apply_ordering(self, queryset): + ordering = self.request.query_params.get('ordering') + allowed_fields = { + 'updated_at': 'updated_at', + '-updated_at': '-updated_at', + 'rating': 'rating', + '-rating': '-rating', + } + if ordering in [None, '']: + return queryset.order_by('-updated_at') + if ordering not in allowed_fields: + raise ValidationError({'ordering': 'Unsupported ordering field.'}) + return queryset.order_by(allowed_fields[ordering]) + + def _get_int_param(self, name): + value = self.request.query_params.get(name) + if value in [None, '']: + return None + try: + return int(value) + except ValueError as error: + raise ValidationError({name: 'Must be an integer.'}) from error