Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion auth_app/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']:
Expand All @@ -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'],
Expand Down
105 changes: 105 additions & 0 deletions auth_app/api/test_auth_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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'}
3 changes: 2 additions & 1 deletion core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -158,7 +159,7 @@ def _env_list(name, default=''):

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'Europe/Berlin'
TIME_ZONE = 'UTC'

USE_I18N = True

Expand Down
87 changes: 62 additions & 25 deletions offers_app/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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()

Expand All @@ -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):
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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):
Expand Down
Loading