- seed_demo_data: every seeded Store.logo/cover_image and Product.image now points at a shared placeholder MinIO object_key (uploads/c1508b9e-c01f-4892-b24c-c1a949b5b5f5.png) instead of null, so demo data exercises the image_url/logo_url/etc. fields end-to-end. Note: <field>_url will 404 until that object is actually uploaded to the bucket. - .env.example: drop the redundant MINIO_EXTERNAL_ENDPOINT* lines — they already default to MINIO_ENDPOINT/MINIO_USE_HTTPS, and aren't read anywhere in this app's actual presign/read path. - Add MEDIA_INTEGRATION.md: reference doc for the MinIO/presigned-media work on this branch (architecture, settings, API shape, existing-data caveats, what was verified).
9.4 KiB
Media storage — MinIO integration
How Winofy stores and serves product/store images. This matches the pattern used across the rest of the Winsoo ecosystem (campaign, advertising, promotions): presigned direct-to-MinIO uploads, object keys stored on the model, presigned reads minted per-response. Branch: feature/minio-integration.
Why this shape
The first pass at this integration used a Django ImageField + django_minio_backend.MinioBackend as the default storage, relying on normal DRF multipart uploads and a public-bucket direct URL. That worked, but didn't match how campaign/advertising/promotions do it, so it was reworked to the presign/object_key pattern below. See git history on this branch for both stages if you want the contrast.
Architecture
1. Client: POST /api/media/presign/ {"filename": "photo.jpg"}
Server: generates object_key, returns a presigned MinIO PUT url (30 min expiry)
2. Client: PUT <upload_url> <raw file bytes>
(goes straight to MinIO — the Django backend never sees the bytes)
3. Client: POST/PATCH the owning resource with the object_key
e.g. PATCH /api/v1/seller/store/ {"logo": "uploads/<uuid>.jpg"}
4. Any read of that resource returns both the raw object_key AND a
freshly presigned GET url (1 hour expiry), e.g. "logo" + "logo_url"
Reads never trust a cached/stored URL — every serialization mints a new presigned GET URL via apps/core/media.py::presigned_media_url(). This is deliberate: it means bucket privacy can be tightened later (private bucket + short-lived signed links) without any API shape change, and callers can't accidentally cache a URL past its expiry without noticing (it 404s).
Files added
| File | Purpose |
|---|---|
utils/clients/minio_client.py |
Raw Minio SDK client (same shape as advertising/campaign's equivalent) — used only for presigned_put_object/presigned_get_object. |
apps/core/media.py |
presigned_media_url(object_key, expires=1h) — shared helper every serializer's <field>_url calls. Swallows/logs errors and returns None rather than raising, so a MinIO blip doesn't break the whole response. |
apps/core/serializers.py |
MediaPresignInputSerializer (filename), MediaPresignOutputSerializer (upload_url, object_key). |
apps/core/views/media.py |
MediaPresignView — POST /api/media/presign/. |
Files changed
config/settings.py—django_minio_backendadded toINSTALLED_APPS;STORAGES["default"]points atMinioBackend;MINIO_*settings read from env (see below);"Media"tag added toSPECTACULAR_SETTINGS.apps/core/urls.py— registersmedia/presign/.apps/catalog/models.py,apps/stores/models.py—icon/image/logo/cover_imagechanged fromImageFieldtoCharField(max_length=500, null=True, blank=True)storing a MinIO object key, not a Django-managed file.apps/catalog/serializers.py,apps/stores/serializers.py— every serializer exposing one of those fields gained a<field>_urlSerializerMethodField(icon_url,image_url,logo_url,cover_image_url), typed via@extend_schema_field(serializers.URLField(allow_null=True))for clean OpenAPI output.docker-compose.yml— added a localminioservice (ports9000/9001,miniodatavolume), wired as awinofydependency..env.example,CLAUDE.md— documented the newMINIO_*vars.requirements.in/requirements.txt—django-minio-backend,minio,pycryptodome,argon2-cffi(-bindings).FRONTEND_GUIDE.md— §4.7 added (the upload flow), plus every stale "image field is a URL" / "no upload endpoint yet" reference updated across §4.5, Stores, Seller · Store, Seller · Products, §8, §9.
Migrations
apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.pyapps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py
Both are pure AlterField (type/metadata change on an existing string column) — no data is dropped or moved. See Existing data below for what that means in practice.
Settings / env vars
MINIO_ENDPOINT=localhost:9000
MINIO_USE_HTTPS=False
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_MEDIA_FILES_BUCKET=winofy-media
Defaults target the local minio container in docker-compose.yml. No shared bucket has been provisioned centrally yet (unlike advertising, which points at drive.gooyal.com) — MINIO_MEDIA_FILES_BUCKET just needs to match whatever this service's bucket ends up being called once one exists.
MINIO_EXTERNAL_ENDPOINT / MINIO_EXTERNAL_ENDPOINT_USE_HTTPS (not set — intentionally omitted) exist in django_minio_backend's MinioBackend for deployments where Django reaches MinIO through a different address than external clients do (e.g. an internal Docker/VPC hostname for Django vs. a public domain for browsers/apps) — they default to MINIO_ENDPOINT/MINIO_USE_HTTPS when unset, which is correct as long as Django and clients reach MinIO the same way (confirmed as the case here). Worth knowing if that topology ever changes: utils/clients/minio_client.py — the client actually used for every presign/read in this app — only reads MINIO_ENDPOINT and has no external-vs-internal split at all, since STORAGES["default"]'s MinioBackend (the only thing that respects MINIO_EXTERNAL_ENDPOINT) isn't exercised by any model field anymore now that icon/image/logo/cover_image are plain CharFields rather than ImageFields. Splitting internal/external addresses later would require adding that logic to minio_client.py directly, not just setting the env var.
First-run setup against a fresh MinIO instance: the bucket doesn't exist until something creates it. Run:
python manage.py initialize_buckets
(a django_minio_backend management command) before the presign flow will work — otherwise uploads fail with S3Error: NoSuchBucket.
API reference
POST /api/media/presign/
Auth required (any authenticated user — permission checks for the resource still happen at the resource's own endpoint, e.g. IsStoreOwner on /seller/store/).
POST /api/media/presign/
Authorization: Bearer <token>
Content-Type: application/json
{"filename": "photo.jpg"}
201
{
"upload_url": "http://localhost:9000/winofy-media/uploads/<uuid>.jpg?X-Amz-Algorithm=...&X-Amz-Expires=1800&...",
"object_key": "uploads/<uuid>.jpg"
}
upload_url expires in 30 minutes.
Direct upload
PUT <upload_url>
<raw file bytes>
200, empty body. No auth header, no JSON — the signature in the URL is the auth.
Attach to a resource
PATCH /api/v1/seller/store/
{"logo": "uploads/<uuid>.jpg"}
Works identically via POST (create) or PATCH/PUT (update) on any endpoint that owns one of these fields — the object_key is just an ordinary writable string field, not special-cased per HTTP method.
Reading
Every serializer with an image field always includes both:
{
"logo": "uploads/<uuid>.jpg",
"logo_url": "http://localhost:9000/winofy-media/uploads/<uuid>.jpg?X-Amz-...&X-Amz-Expires=3600&...",
"cover_image": null,
"cover_image_url": null
}
<field>_url is re-signed fresh on every request (1 hour expiry) — don't cache it past that.
Fields covered: ProductCategory.icon, Product.image, StoreCategory.icon, Store.logo, Store.cover_image (each with its _url sibling).
Existing data — read before deploying to an environment with real uploads
The migrations are schema-only, so no row is deleted and no string value is rewritten. But any value written before this branch (a path under the old local FileSystemStorage, e.g. products/2026/03/01/photo.jpg) is now meaningless: the app reads that same string as a MinIO object key and mints a presigned URL for it — which will 404 on fetch, because the actual file bytes were never copied into MinIO. image_url/logo_url/etc. will look populated in the response but not resolve.
Nothing here deletes the original files either — they're still sitting wherever they were (local disk media/ folder under the old storage), just no longer referenced by the app.
Before this branch reaches an environment with real uploaded images: write a one-off backfill (upload each existing file into the winofy-media bucket under its existing relative path as the key, so the already-stored DB value keeps resolving without a bulk rewrite) or accept that sellers/customers will need to re-upload. Ask backend/infra whether the target environment has any real uploads before merging — this repo's local dev has none (no media/ directory exists), so it's untested against real legacy data.
Verification performed
Ran against a live local MinIO container (not just unit tests):
manage.py check/manage.py testclean (pre-existing, unrelated failures inapps/reviewsaside — see PR/branch notes).initialize_buckets→ bucket created with public read policy.- Presign → raw
PUTupload →object_keystored on a real model instance → serializer-minted presigned GET URL → fetched and got the uploaded bytes back. - Hit the actual
POST /api/media/presign/view through Django's test client:201authenticated,401anonymous. manage.py spectacularschema generation:/api/media/presign/correctly taggedMedia, request/response schemas resolve,logo/cover_image/image/iconshow as writable strings (not binary) in both create and patch schemas,_urlfields typed as nullablestring($uri).