winofy-backend/apps/stores/models.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

120 lines
4.8 KiB
Python

from django.conf import settings
from django.contrib.gis.db import models as gis_models
from django.core.validators import RegexValidator
from django.db import models
from apps.locations.models import City, Neighborhood
from utils.models import BaseModel
class StoreCategory(BaseModel):
name = models.CharField(max_length=100, unique=True)
icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
order = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ['order', 'name']
verbose_name = 'دسته‌بندی فروشگاه'
verbose_name_plural = 'دسته‌بندی‌های فروشگاه'
def __str__(self):
return self.name
class Store(BaseModel):
class Status(models.TextChoices):
PENDING = 'pending', 'در انتظار تایید'
APPROVED = 'approved', 'تایید شده'
SUSPENDED = 'suspended', 'معلق شده'
owner = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='store')
category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores')
name = models.CharField(max_length=150)
description = models.TextField(blank=True)
logo = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
cover_image = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
phone_number = models.CharField(max_length=20, blank=True)
city = models.ForeignKey(City, on_delete=models.PROTECT, related_name='stores')
address = models.TextField()
location = gis_models.PointField(geography=True, null=True, blank=True)
service_neighborhoods = models.ManyToManyField(
Neighborhood, related_name='stores', blank=True,
help_text='محله‌هایی که این فروشگاه به آن‌ها ارسال دارد.',
)
delivery_radius_km = models.DecimalField(max_digits=5, decimal_places=2, default=5)
min_order_amount = models.PositiveBigIntegerField(default=0)
delivery_fee = models.PositiveBigIntegerField(default=0)
free_delivery_threshold = models.PositiveBigIntegerField(null=True, blank=True)
commission_percent = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text='در صورت خالی بودن، از DEFAULT_COMMISSION_PERCENT استفاده می‌شود.',
)
accepts_wallet = models.BooleanField(default=True)
accepts_online = models.BooleanField(default=True)
accepts_cash_on_delivery = models.BooleanField(default=True)
rating_avg = models.DecimalField(max_digits=3, decimal_places=2, default=0)
rating_count = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
is_open = models.BooleanField(default=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'فروشگاه'
verbose_name_plural = 'فروشگاه‌ها'
def __str__(self):
return self.name
@property
def commission_percent_effective(self):
if self.commission_percent is not None:
return self.commission_percent
from django.conf import settings as dj_settings
return dj_settings.DEFAULT_COMMISSION_PERCENT
class StoreWorkingHours(BaseModel):
class Weekday(models.IntegerChoices):
SATURDAY = 0, 'شنبه'
SUNDAY = 1, 'یکشنبه'
MONDAY = 2, 'دوشنبه'
TUESDAY = 3, 'سه‌شنبه'
WEDNESDAY = 4, 'چهارشنبه'
THURSDAY = 5, 'پنجشنبه'
FRIDAY = 6, 'جمعه'
store = models.ForeignKey(Store, on_delete=models.CASCADE, related_name='working_hours')
weekday = models.PositiveSmallIntegerField(choices=Weekday.choices)
opens_at = models.TimeField(null=True, blank=True)
closes_at = models.TimeField(null=True, blank=True)
is_closed = models.BooleanField(default=False)
class Meta:
ordering = ['weekday']
unique_together = ('store', 'weekday')
verbose_name = 'ساعت کاری فروشگاه'
verbose_name_plural = 'ساعات کاری فروشگاه'
def __str__(self):
return f'{self.store.name} - {self.get_weekday_display()}'
class StoreBankAccount(BaseModel):
store = models.OneToOneField(Store, on_delete=models.CASCADE, related_name='bank_account')
iban = models.CharField(
max_length=26,
validators=[RegexValidator(r'^IR\d{24}$', 'شماره شبا معتبر نیست.')],
)
account_holder_name = models.CharField(max_length=150)
class Meta:
verbose_name = 'حساب بانکی فروشگاه'
verbose_name_plural = 'حساب‌های بانکی فروشگاه'
def __str__(self):
return f'{self.store.name} - {self.iban}'