winofy-backend/apps/stores/serializers.py
Ali Asadi 85598d347a Switch media uploads to presigned MinIO, matching the rest of Winsoo
The prior commit wired MinIO as Django's default storage backend but
kept plain ImageField multipart uploads through the Django backend and
public-bucket direct URLs — not how campaign/advertising/promotions do
it. Rework to match: image fields (ProductCategory.icon, Product.image,
StoreCategory.icon, Store.logo, Store.cover_image) now store an opaque
MinIO object_key (CharField) instead of a Django-managed file.

- utils/clients/minio_client.py — raw Minio SDK client, same shape as
  the other services.
- apps/core/views/media.py — POST /api/media/presign/ mints a 30-minute
  presigned PUT URL + object_key; the client uploads bytes directly to
  MinIO, then submits the object_key on the owning resource.
- apps/core/media.py — presigned_media_url() helper; every serializer
  with an image field now also exposes a `<field>_url` computed from a
  fresh 1-hour presigned GET, rather than trusting a stored/direct URL.
- FRONTEND_GUIDE.md updated to document the new upload flow and field
  shapes (object_key vs `_url`), replacing the stale "no upload
  endpoint yet" note.

Verified against a live local MinIO container: presign → direct PUT
upload → object_key stored on the model → serializer mints a working
presigned GET URL → URL fetches the uploaded bytes. Also exercised the
actual DRF view (POST /api/media/presign/) end-to-end, including the
401 on an unauthenticated request.
2026-08-18 10:42:44 +03:30

139 lines
5.2 KiB
Python

from django.contrib.gis.geos import Point
from rest_framework import serializers
from apps.core.media import presigned_media_url
from apps.locations.models import City, Neighborhood
from apps.locations.serializers import CitySerializer, NeighborhoodSerializer
from .models import Store, StoreBankAccount, StoreCategory, StoreWorkingHours
class StoreCategorySerializer(serializers.ModelSerializer):
icon_url = serializers.SerializerMethodField()
class Meta:
model = StoreCategory
fields = ('uuid', 'name', 'icon', 'icon_url', 'order')
def get_icon_url(self, obj):
return presigned_media_url(obj.icon)
class StoreWorkingHoursSerializer(serializers.ModelSerializer):
class Meta:
model = StoreWorkingHours
fields = ('weekday', 'opens_at', 'closes_at', 'is_closed')
class StoreListSerializer(serializers.ModelSerializer):
category = StoreCategorySerializer(read_only=True)
logo_url = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField()
class Meta:
model = Store
fields = (
'uuid', 'name', 'category', 'logo', 'logo_url', 'cover_image', 'cover_image_url',
'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
'free_delivery_threshold', 'is_open',
)
def get_logo_url(self, obj):
return presigned_media_url(obj.logo)
def get_cover_image_url(self, obj):
return presigned_media_url(obj.cover_image)
class StoreDetailSerializer(StoreListSerializer):
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
class Meta(StoreListSerializer.Meta):
fields = StoreListSerializer.Meta.fields + (
'description', 'address', 'phone_number',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours',
)
class TopProductSerializer(serializers.Serializer):
name = serializers.CharField()
units_sold = serializers.IntegerField()
revenue = serializers.IntegerField()
class SalesChartPointSerializer(serializers.Serializer):
bucket = serializers.DateTimeField()
sales = serializers.IntegerField()
class SellerAnalyticsSerializer(serializers.Serializer):
period = serializers.CharField()
order_count = serializers.IntegerField()
total_sales = serializers.IntegerField()
cancellation_rate = serializers.FloatField()
average_order_value = serializers.IntegerField()
chart = SalesChartPointSerializer(many=True)
top_products = TopProductSerializer(many=True)
class StoreBankAccountSerializer(serializers.ModelSerializer):
class Meta:
model = StoreBankAccount
fields = ('iban', 'account_holder_name')
class SellerStoreSerializer(serializers.ModelSerializer):
"""Read/write serializer for the authenticated seller's own store."""
category = StoreCategorySerializer(read_only=True)
category_uuid = serializers.PrimaryKeyRelatedField(
source='category', queryset=StoreCategory.objects.all(), write_only=True,
)
city = CitySerializer(read_only=True)
city_uuid = serializers.PrimaryKeyRelatedField(
source='city', queryset=City.objects.filter(is_active=True), write_only=True,
)
service_neighborhoods = NeighborhoodSerializer(many=True, read_only=True)
service_neighborhood_uuids = serializers.PrimaryKeyRelatedField(
source='service_neighborhoods', queryset=Neighborhood.objects.filter(is_active=True),
write_only=True, many=True, required=False,
)
latitude = serializers.FloatField(write_only=True, required=False)
longitude = serializers.FloatField(write_only=True, required=False)
bank_account = StoreBankAccountSerializer(read_only=True)
logo_url = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField()
class Meta:
model = Store
fields = (
'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'logo_url',
'cover_image', 'cover_image_url',
'phone_number', 'city', 'city_uuid', 'address', 'latitude', 'longitude',
'service_neighborhoods', 'service_neighborhood_uuids', 'delivery_radius_km',
'min_order_amount', 'delivery_fee', 'free_delivery_threshold',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery',
'rating_avg', 'rating_count', 'status', 'is_open', 'bank_account', 'created_at',
)
read_only_fields = ('rating_avg', 'rating_count', 'status')
def get_logo_url(self, obj):
return presigned_media_url(obj.logo)
def get_cover_image_url(self, obj):
return presigned_media_url(obj.cover_image)
def _pop_location(self, validated_data):
lat = validated_data.pop('latitude', None)
lng = validated_data.pop('longitude', None)
if lat is not None and lng is not None:
validated_data['location'] = Point(lng, lat, srid=4326)
def create(self, validated_data):
self._pop_location(validated_data)
validated_data['owner'] = self.context['request'].user
return super().create(validated_data)
def update(self, instance, validated_data):
self._pop_location(validated_data)
return super().update(instance, validated_data)