diff --git a/.env.example b/.env.example index cd005e5..6c6bb66 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,18 @@ CSRF_TRUSTED_ORIGINS= NEARBY_RADIUS_METERS=5000 DEFAULT_COMMISSION_PERCENT=4.0 +# MinIO — object storage for media (product/store images), same pattern as the +# rest of the Winsoo ecosystem. Defaults point at the local `minio` container +# from docker-compose.yml. No bucket has been provisioned centrally yet — +# django-minio-backend will create MINIO_MEDIA_FILES_BUCKET on first save. +MINIO_ENDPOINT=localhost:9000 +MINIO_USE_HTTPS=False +MINIO_EXTERNAL_ENDPOINT=localhost:9000 +MINIO_EXTERNAL_ENDPOINT_USE_HTTPS=False +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_MEDIA_FILES_BUCKET=winofy-media + # OAuth2 provider (Gooyal accounts) — resource-server introspection + this # service's own client-credentials grant for outbound calls to Gooyal services. OAUTH2_PROVIDER_BASE_PUBLIC_URL= diff --git a/CLAUDE.md b/CLAUDE.md index 364ce4a..bb15c56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ Key `.env` variables (see `.env.example`): - `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` — comma-separated lists - `OAUTH2_PROVIDER_*` — Gooyal accounts OAuth2 resource-server + this app's own client credentials (see below) - `DEFAULT_COMMISSION_PERCENT` — platform commission on delivered orders (overridable per `Store`) +- `MINIO_*` — MinIO object storage for media (`STORAGES['default']`), same pattern as the rest of the Winsoo ecosystem; defaults target the local `minio` container in `docker-compose.yml` ## Authentication — Gooyal accounts, resource-server pattern diff --git a/FRONTEND_GUIDE.md b/FRONTEND_GUIDE.md index d10cb30..5fb4ed3 100644 --- a/FRONTEND_GUIDE.md +++ b/FRONTEND_GUIDE.md @@ -97,7 +97,7 @@ Default page size is 50 if `limit` is omitted. - Every object's primary key is a **UUID string** (field name `uuid`), not an integer. Use it in URLs: `/api/v1/products/{uuid}/`. - All money fields (`price`, `delivery_fee`, `items_subtotal`, `total_amount`, `amount`, etc.) are **integers in Toman**, no decimals, no currency string. - All timestamps are ISO 8601 with timezone (`created_at`, `placed_at`, `scheduled_at`, ...). -- Image fields (`image`, `logo`, `cover_image`, `icon`) are either `null` or an absolute/relative media URL — never assume they're set. +- Image fields (`image`, `logo`, `cover_image`, `icon`) hold an opaque MinIO **object key** (or `null`), not a URL — read the sibling `_url` (e.g. `logo_url`) for a fetchable, presigned, time-limited URL. See §4.7. ### 4.6 Enums @@ -112,6 +112,16 @@ Default page size is 50 if `limit` is omitted. | `OrderGroup.payment_status` | `pending`, `paid`, `failed` | در انتظار پرداخت, پرداخت شده, ناموفق | | `Order.status` | `placed`, `preparing`, `ready_to_ship`, `handed_to_courier`, `delivered`, `cancelled` | سفارش ثبت شد → در حال آماده‌سازی → آماده ارسال → تحویل سفیر شد → تحویل داده شد (+ لغو شده) | | `SellerWithdrawalRequest.status` | `pending`, `processing`, `paid`, `rejected` | در انتظار بررسی, در حال پردازش, واریز شده, رد شده | + +### 4.7 Image uploads (presigned MinIO) + +Every image field (`ProductCategory.icon`, `Product.image`, `StoreCategory.icon`, `Store.logo`, `Store.cover_image`) is a **MinIO object key**, not a Django-served file. Uploading is a two-step flow — the backend never receives the file bytes: + +1. `POST /api/media/presign/` (auth required) with `{"filename": "photo.jpg"}` → `201 {"upload_url": "...", "object_key": "uploads/.jpg"}`. `upload_url` is a presigned MinIO `PUT` URL valid for 30 minutes. +2. Upload the raw file bytes directly to `upload_url` (a plain `PUT`, no auth header, no JSON body — just the file as the request body). +3. Send the `object_key` from step 1 as the field's value in the resource's create/update body (e.g. `{"logo": "uploads/.jpg", ...}` on `PATCH /api/v1/seller/store/`). + +On read, every serializer that has an image field also returns a `_url` sibling (`icon_url`, `image_url`, `logo_url`, `cover_image_url`) — a freshly presigned `GET` URL valid for 1 hour. Always re-fetch the resource (or re-request) rather than caching these URLs past an hour; they expire. | `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز | | `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش | @@ -155,7 +165,7 @@ Response mirrors this but with `city`/`neighborhood` as nested objects (not `*_u | GET | `/api/v1/stores/?neighborhood={uuid}&category={uuid}&search=text&lat=..&lng=..` | Public | Home feed / search. `lat`+`lng` sorts by distance (15 km radius). Only `status=approved` stores are ever returned. | | GET | `/api/v1/stores/{uuid}/` | Public | Store page — includes `working_hours`, `accepts_wallet/online/cash_on_delivery`, `description`, `address`, `phone_number` (fields the list endpoint omits). | -Store list item shape: `uuid, name, category{uuid,name,icon,order}, logo, cover_image, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`. +Store list item shape: `uuid, name, category{uuid,name,icon,icon_url,order}, logo, logo_url, cover_image, cover_image_url, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`. See §4.7 for `icon`/`logo`/`cover_image` vs. their `_url` siblings. ### Catalog (`Catalog` tag) @@ -363,7 +373,7 @@ Store create/update body: "is_open": true } ``` -`rating_avg`, `rating_count`, `status` are read-only (server-computed; `status` starts `pending` — there's no admin-approval endpoint in this API yet, that's a manual/admin-panel step today). `logo`/`cover_image` aren't settable through this JSON body — that needs a multipart upload endpoint, which doesn't exist yet; flag this to backend if the store-branding screen needs it. +`rating_avg`, `rating_count`, `status` are read-only (server-computed; `status` starts `pending` — there's no admin-approval endpoint in this API yet, that's a manual/admin-panel step today). To set `logo`/`cover_image`, run the presign flow (§4.7) first and include the resulting `object_key`s in this JSON body — e.g. add `"logo": "uploads/.png"`. ### Seller · Products @@ -374,7 +384,7 @@ Store create/update body: | GET/PATCH/DELETE | `/api/v1/seller/products/{uuid}/` | | | PATCH | `/api/v1/seller/products/{uuid}/stock/` | Inventory-only quick update (S08): `{"stock_quantity": 12}`. | -Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. Response adds `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`). +Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. `image` is a MinIO object key from the presign flow (§4.7), not a file upload. Response adds `image_url` (presigned, §4.7), `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`). ### Seller · Orders @@ -432,12 +442,11 @@ Response: ## 8. Local development / testing - The backend ships a management command that seeds a realistic dataset: 3 cities, 13 neighborhoods, ~19 stores across 6 categories, ~370 products, 30 customers with addresses, 150+ historical orders in every status (placed/preparing/delivered/cancelled), and reviews. Ask backend to run `python manage.py seed_demo_data` (or `--flush` to reset it) against your dev environment before you start wiring up screens — there's no need to hand-create fixtures. -- Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token. +- Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Media, Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token. - `GET /api/health/` needs no auth and is useful as a "is the backend even up" smoke check. ## 9. Known gaps to flag back to backend if you hit them -- No multipart/image-upload endpoint yet for store logo/cover or product images (JSON `PATCH` bodies can't set binary fields). - No store-approval endpoint — new stores sit in `status: "pending"` until changed via Django admin. - No admin/public endpoint to see a store's `service_neighborhoods` from the customer-facing store serializer (needed if you want to pre-warn about delivery coverage before checkout — see §6.1). - The `ipg` online-payment callback contract (`/api/v1/payments/ipg/callback/`) is a best-effort integration pending confirmation against a live `ipg` environment — if online payments seem to hang in `pending`, that's the first place to check. diff --git a/apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py b/apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py new file mode 100644 index 0000000..dd05421 --- /dev/null +++ b/apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.2 on 2026-08-18 07:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalog', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='image', + field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True), + ), + migrations.AlterField( + model_name='productcategory', + name='icon', + field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True), + ), + ] diff --git a/apps/catalog/models.py b/apps/catalog/models.py index 4d3f896..d449696 100644 --- a/apps/catalog/models.py +++ b/apps/catalog/models.py @@ -9,7 +9,7 @@ class ProductCategory(BaseModel): parent = models.ForeignKey( 'self', on_delete=models.CASCADE, related_name='children', null=True, blank=True, ) - icon = models.ImageField(upload_to='product_categories/', null=True, blank=True) + icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.') order = models.PositiveSmallIntegerField(default=0) class Meta: @@ -31,7 +31,7 @@ class Product(BaseModel): category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT, related_name='products') name = models.CharField(max_length=200) description = models.TextField(blank=True) - image = models.ImageField(upload_to='products/', null=True, blank=True) + image = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.') price = models.PositiveBigIntegerField() unit_type = models.CharField(max_length=10, choices=UnitType.choices, default=UnitType.PIECE) diff --git a/apps/catalog/serializers.py b/apps/catalog/serializers.py index f0160ac..2349b75 100644 --- a/apps/catalog/serializers.py +++ b/apps/catalog/serializers.py @@ -1,26 +1,39 @@ +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from apps.core.media import presigned_media_url from apps.stores.serializers import StoreListSerializer from .models import Product, ProductCategory class ProductCategorySerializer(serializers.ModelSerializer): + icon_url = serializers.SerializerMethodField() + class Meta: model = ProductCategory - fields = ('uuid', 'name', 'parent', 'icon', 'order') + fields = ('uuid', 'name', 'parent', 'icon', 'icon_url', 'order') + + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_icon_url(self, obj): + return presigned_media_url(obj.icon) class ProductListSerializer(serializers.ModelSerializer): is_out_of_stock = serializers.BooleanField(read_only=True) + image_url = serializers.SerializerMethodField() class Meta: model = Product fields = ( - 'uuid', 'name', 'image', 'price', 'unit_type', 'unit_value', + 'uuid', 'name', 'image', 'image_url', 'price', 'unit_type', 'unit_value', 'is_active', 'is_out_of_stock', ) + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_image_url(self, obj): + return presigned_media_url(obj.image) + class ProductDetailSerializer(ProductListSerializer): category = ProductCategorySerializer(read_only=True) @@ -39,16 +52,21 @@ class SellerProductSerializer(serializers.ModelSerializer): ) is_out_of_stock = serializers.BooleanField(read_only=True) is_low_stock = serializers.BooleanField(read_only=True) + image_url = serializers.SerializerMethodField() class Meta: model = Product fields = ( - 'uuid', 'name', 'description', 'image', 'category', 'category_uuid', + 'uuid', 'name', 'description', 'image', 'image_url', 'category', 'category_uuid', 'price', 'unit_type', 'unit_value', 'stock_quantity', 'low_stock_threshold', 'is_active', 'sold_count', 'is_out_of_stock', 'is_low_stock', 'created_at', ) read_only_fields = ('sold_count',) + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_image_url(self, obj): + return presigned_media_url(obj.image) + def create(self, validated_data): validated_data['store'] = self.context['request'].user.store return super().create(validated_data) diff --git a/apps/core/media.py b/apps/core/media.py new file mode 100644 index 0000000..0ced976 --- /dev/null +++ b/apps/core/media.py @@ -0,0 +1,21 @@ +import logging +from datetime import timedelta + +from django.conf import settings + +from utils.clients.minio_client import minio_client + +logger = logging.getLogger('winofy.media') + + +def presigned_media_url(object_key, expires=timedelta(hours=1)): + """Mints a fresh presigned GET URL for a stored MinIO object_key, or None if unset/unreachable.""" + if not object_key: + return None + try: + return minio_client.presigned_get_object( + settings.MINIO_MEDIA_FILES_BUCKET, object_key, expires=expires, + ) + except Exception: + logger.exception('Failed to presign media url for object_key=%s', object_key) + return None diff --git a/apps/core/serializers.py b/apps/core/serializers.py new file mode 100644 index 0000000..9383bdc --- /dev/null +++ b/apps/core/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + + +class MediaPresignInputSerializer(serializers.Serializer): + filename = serializers.CharField(max_length=255) + + +class MediaPresignOutputSerializer(serializers.Serializer): + upload_url = serializers.URLField() + object_key = serializers.CharField() diff --git a/apps/core/urls.py b/apps/core/urls.py index 6452106..32b8b9d 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -1,9 +1,10 @@ from django.urls import path -from .views import HealthCheckView +from .views import HealthCheckView, MediaPresignView app_name = "core" urlpatterns = [ path("health/", HealthCheckView.as_view(), name="health"), + path("media/presign/", MediaPresignView.as_view(), name="media-presign"), ] diff --git a/apps/core/views/__init__.py b/apps/core/views/__init__.py index 6155f2b..bf1f3c4 100644 --- a/apps/core/views/__init__.py +++ b/apps/core/views/__init__.py @@ -1,3 +1,4 @@ from .health import HealthCheckView +from .media import MediaPresignView -__all__ = ["HealthCheckView"] +__all__ = ["HealthCheckView", "MediaPresignView"] diff --git a/apps/core/views/media.py b/apps/core/views/media.py new file mode 100644 index 0000000..b1a4899 --- /dev/null +++ b/apps/core/views/media.py @@ -0,0 +1,51 @@ +import uuid +from datetime import timedelta + +from django.conf import settings +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.core.serializers import MediaPresignInputSerializer, MediaPresignOutputSerializer +from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements +from utils.clients.minio_client import minio_client +from utils.exceptions import ServiceUnavailable + + +class MediaPresignView(APIView): + """Presigned MinIO upload URL for product/store images (icon, logo, cover, ...). + + The client uploads the file bytes directly to MinIO with the returned + upload_url, then submits the returned object_key as the field value when + creating/updating the owning resource (e.g. Store.logo, Product.image). + """ + + schema_tags = ['Media'] + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "POST": [[]], + } + + @extend_schema(request=MediaPresignInputSerializer, responses={201: MediaPresignOutputSerializer}) + def post(self, request): + serializer = MediaPresignInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + filename = serializer.validated_data['filename'] + extension = filename.rsplit('.', 1)[-1] if '.' in filename else 'bin' + object_key = f'uploads/{uuid.uuid4()}.{extension}' + + try: + upload_url = minio_client.presigned_put_object( + settings.MINIO_MEDIA_FILES_BUCKET, + object_key, + expires=timedelta(minutes=30), + ) + except Exception: + raise ServiceUnavailable('خطا در دریافت لینک آپلود. لطفاً دوباره تلاش کنید.') + + return Response( + MediaPresignOutputSerializer({'upload_url': upload_url, 'object_key': object_key}).data, + status=status.HTTP_201_CREATED, + ) diff --git a/apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py b/apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py new file mode 100644 index 0000000..62e2404 --- /dev/null +++ b/apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.2 on 2026-08-18 07:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stores', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='store', + name='cover_image', + field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True), + ), + migrations.AlterField( + model_name='store', + name='logo', + field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True), + ), + migrations.AlterField( + model_name='storecategory', + name='icon', + field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True), + ), + ] diff --git a/apps/stores/models.py b/apps/stores/models.py index 94c91eb..686483e 100644 --- a/apps/stores/models.py +++ b/apps/stores/models.py @@ -9,7 +9,7 @@ from utils.models import BaseModel class StoreCategory(BaseModel): name = models.CharField(max_length=100, unique=True) - icon = models.ImageField(upload_to='store_categories/', null=True, blank=True) + icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.') order = models.PositiveSmallIntegerField(default=0) class Meta: @@ -31,8 +31,8 @@ class Store(BaseModel): category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores') name = models.CharField(max_length=150) description = models.TextField(blank=True) - logo = models.ImageField(upload_to='store_logos/', null=True, blank=True) - cover_image = models.ImageField(upload_to='store_covers/', null=True, 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') diff --git a/apps/stores/serializers.py b/apps/stores/serializers.py index ce40149..262fde7 100644 --- a/apps/stores/serializers.py +++ b/apps/stores/serializers.py @@ -1,6 +1,8 @@ from django.contrib.gis.geos import Point +from drf_spectacular.utils import extend_schema_field 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 @@ -8,9 +10,15 @@ from .models import Store, StoreBankAccount, StoreCategory, StoreWorkingHours class StoreCategorySerializer(serializers.ModelSerializer): + icon_url = serializers.SerializerMethodField() + class Meta: model = StoreCategory - fields = ('uuid', 'name', 'icon', 'order') + fields = ('uuid', 'name', 'icon', 'icon_url', 'order') + + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_icon_url(self, obj): + return presigned_media_url(obj.icon) class StoreWorkingHoursSerializer(serializers.ModelSerializer): @@ -21,15 +29,25 @@ class StoreWorkingHoursSerializer(serializers.ModelSerializer): 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', 'cover_image', + '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', ) + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_logo_url(self, obj): + return presigned_media_url(obj.logo) + + @extend_schema_field(serializers.URLField(allow_null=True)) + 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) @@ -87,11 +105,14 @@ class SellerStoreSerializer(serializers.ModelSerializer): 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', 'cover_image', + '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', @@ -100,6 +121,14 @@ class SellerStoreSerializer(serializers.ModelSerializer): ) read_only_fields = ('rating_avg', 'rating_count', 'status') + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_logo_url(self, obj): + return presigned_media_url(obj.logo) + + @extend_schema_field(serializers.URLField(allow_null=True)) + 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) diff --git a/config/settings.py b/config/settings.py index 1e93470..cd2914d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,3 +1,4 @@ +from datetime import timedelta from pathlib import Path import environ @@ -28,6 +29,7 @@ INSTALLED_APPS = [ 'rest_framework_gis', 'django_filters', 'corsheaders', + 'django_minio_backend.apps.DjangoMinioBackendConfig', # local apps 'apps.core', @@ -137,9 +139,50 @@ STATIC_URL = "{}static/".format(API_BASE_PATH) STATIC_ROOT = BASE_DIR / "static" MEDIA_URL = "{}media/".format(API_BASE_PATH) MEDIA_ROOT = BASE_DIR / "media" + +# MinIO — same object-storage pattern as the rest of the Winsoo ecosystem +# (campaign, wallet, advertising, ...). Points at a local MinIO instance by +# default; no bucket has been provisioned centrally yet, so MINIO_MEDIA_FILES_BUCKET +# just needs to be set to whatever this service's bucket ends up being called. +MINIO_ENDPOINT = env("MINIO_ENDPOINT", default="localhost:9000") +MINIO_USE_HTTPS = env.bool("MINIO_USE_HTTPS", default=False) +MINIO_EXTERNAL_ENDPOINT = env("MINIO_EXTERNAL_ENDPOINT", default=MINIO_ENDPOINT) +MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = env.bool("MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", default=MINIO_USE_HTTPS) +MINIO_REGION = None +MINIO_ACCESS_KEY = env("MINIO_ACCESS_KEY", default="minioadmin") +MINIO_SECRET_KEY = env("MINIO_SECRET_KEY", default="minioadmin") +MINIO_URL_EXPIRY_HOURS = timedelta(days=1) +MINIO_CONSISTENCY_CHECK_ON_START = False +MINIO_MEDIA_FILES_BUCKET = env("MINIO_MEDIA_FILES_BUCKET", default="winofy-media") # replacement for MEDIA_ROOT +MINIO_PRIVATE_BUCKETS = [ + 'default', +] +MINIO_PUBLIC_BUCKETS = [ + 'default-public', + MINIO_MEDIA_FILES_BUCKET, +] +MINIO_POLICY_HOOKS = [] +MINIO_BUCKET_CHECK_ON_SAVE = True # creates the bucket if missing, then saves + STORAGES = { "default": { - "BACKEND": "django.core.files.storage.FileSystemStorage", + "BACKEND": "django_minio_backend.models.MinioBackend", + "OPTIONS": { + "MINIO_ENDPOINT": MINIO_ENDPOINT, + "MINIO_USE_HTTPS": MINIO_USE_HTTPS, + "MINIO_EXTERNAL_ENDPOINT": MINIO_EXTERNAL_ENDPOINT, + "MINIO_EXTERNAL_ENDPOINT_USE_HTTPS": MINIO_EXTERNAL_ENDPOINT_USE_HTTPS, + "MINIO_REGION": MINIO_REGION, + "MINIO_ACCESS_KEY": MINIO_ACCESS_KEY, + "MINIO_SECRET_KEY": MINIO_SECRET_KEY, + "MINIO_URL_EXPIRY_HOURS": MINIO_URL_EXPIRY_HOURS, + "MINIO_CONSISTENCY_CHECK_ON_START": MINIO_CONSISTENCY_CHECK_ON_START, + "MINIO_DEFAULT_BUCKET": MINIO_MEDIA_FILES_BUCKET, + "MINIO_PRIVATE_BUCKETS": MINIO_PRIVATE_BUCKETS, + "MINIO_PUBLIC_BUCKETS": MINIO_PUBLIC_BUCKETS, + "MINIO_POLICY_HOOKS": MINIO_POLICY_HOOKS, + "MINIO_BUCKET_CHECK_ON_SAVE": MINIO_BUCKET_CHECK_ON_SAVE, + }, }, "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", @@ -185,6 +228,7 @@ SPECTACULAR_SETTINGS = { "VERSION": "1.0.0", "SERVE_INCLUDE_SCHEMA": False, "TAGS": [ + {"name": "Media", "description": "Presigned MinIO upload URLs for product/store images."}, {"name": "Locations", "description": "Cities and neighborhoods (public reference data)."}, {"name": "Addresses", "description": "The authenticated customer's saved addresses."}, {"name": "Stores", "description": "Customer-facing store browsing (home feed, store page)."}, diff --git a/docker-compose.yml b/docker-compose.yml index 2e966b5..b551f61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: depends_on: - db - redis + - minio networks: - app - monitoring @@ -32,6 +33,20 @@ services: networks: - app + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + networks: + - app + promtail: image: grafana/promtail:2.8.0 volumes: @@ -43,6 +58,7 @@ services: volumes: pgdata: + miniodata: networks: app: diff --git a/requirements.in b/requirements.in index e188981..f754835 100644 --- a/requirements.in +++ b/requirements.in @@ -41,6 +41,10 @@ python-dateutil==2.9.0.post0 whitenoise==6.9.0 Pillow==12.3.0 # ImageField support (product/store images) +# Media storage — MinIO, same pattern as the rest of the Winsoo ecosystem +django-minio-backend==4.5.0 +minio==7.2.20 + # App server gunicorn==26.0.0 gevent==26.7.0 diff --git a/requirements.txt b/requirements.txt index 5337fef..9931cde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ anyio==4.14.2 +argon2-cffi==25.1.0 +argon2-cffi-bindings==25.1.0 asgiref==3.11.1 attrs==25.4.0 certifi==2026.7.22 @@ -9,6 +11,7 @@ Django==6.0.2 django-cors-headers==4.9.0 django-environ==0.13.0 django-filter==25.2 +django-minio-backend==4.5.0 django-oauth-toolkit==3.2.0 django-redis==6.0.0 djangorestframework==3.16.1 @@ -23,12 +26,14 @@ inflection==0.5.1 jsonschema==4.26.0 jsonschema-specifications==2025.9.1 jwcrypto==1.5.8 +minio==7.2.20 oauthlib==3.3.1 pillow==12.3.0 psycopg==3.3.3 psycopg-binary==3.3.3 psycopg-pool==3.3.1 pycparser==3.0 +pycryptodome==3.23.0 python-dateutil==2.9.0.post0 python-decouple==3.8 PyYAML==6.0.3 diff --git a/utils/clients/minio_client.py b/utils/clients/minio_client.py new file mode 100644 index 0000000..09c1a16 --- /dev/null +++ b/utils/clients/minio_client.py @@ -0,0 +1,9 @@ +from django.conf import settings +from minio import Minio + +minio_client = Minio( + settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=settings.MINIO_USE_HTTPS, +)