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

138 lines
9.4 KiB
Markdown

# 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 `CharField`s rather than `ImageField`s. 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:
```bash
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/`).
```http
POST /api/media/presign/
Authorization: Bearer <token>
Content-Type: application/json
{"filename": "photo.jpg"}
```
```json
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
```http
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
```http
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:
```json
{
"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)`.