winofy-backend/MEDIA_INTEGRATION.md
Ali Asadi 56c07c3678 Seed demo stores/products with a placeholder image key; doc cleanup
- 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).
2026-08-18 16:51:19 +03:30

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_backend added to INSTALLED_APPS; STORAGES["default"] points at MinioBackend; MINIO_* settings read from env (see below); "Media" tag added to SPECTACULAR_SETTINGS.
  • apps/core/urls.py — registers media/presign/.
  • apps/catalog/models.py, apps/stores/models.py — icon/image/logo/cover_image changed from ImageField to CharField(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>_url SerializerMethodField (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 local minio service (ports 9000/9001, miniodata volume), wired as a winofy dependency.
  • .env.example, CLAUDE.md — documented the new MINIO_* 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.py
  • apps/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 test clean (pre-existing, unrelated failures in apps/reviews aside — see PR/branch notes).
  • initialize_buckets → bucket created with public read policy.
  • Presign → raw PUT upload → object_key stored 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: 201 authenticated, 401 anonymous.
  • manage.py spectacular schema generation: /api/media/presign/ correctly tagged Media, request/response schemas resolve, logo/cover_image/image/icon show as writable strings (not binary) in both create and patch schemas, _url fields typed as nullable string($uri).