winofy-backend/config/settings.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

249 lines
9.9 KiB
Python

from datetime import timedelta
from pathlib import Path
import environ
from decouple import config
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env()
environ.Env.read_env(BASE_DIR / ".env")
SECRET_KEY = env("SECRET_KEY")
DEBUG = env.bool("DEBUG", default=False)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.gis",
# pip installed apps
'drf_spectacular',
'oauth2_provider',
'rest_framework',
'rest_framework_gis',
'django_filters',
'corsheaders',
'django_minio_backend.apps.DjangoMinioBackendConfig',
# local apps
'apps.core',
'apps.users',
'apps.gooyal_oauth2',
'apps.locations',
'apps.stores',
'apps.catalog',
'apps.orders',
'apps.reviews',
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
'corsheaders.middleware.CorsMiddleware',
"django.middleware.common.CommonMiddleware",
'django.middleware.locale.LocaleMiddleware',
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
'oauth2_provider.middleware.OAuth2TokenMiddleware',
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
'utils.exceptions.ErrorMiddleware',
]
OAUTH2_PROVIDER_APPLICATION_MODEL = "gooyal_oauth2.Application"
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "gooyal_oauth2.AccessToken"
OAUTH2_PROVIDER_ID_TOKEN_MODEL = "gooyal_oauth2.IDToken"
OAUTH2_PROVIDER_GRANT_MODEL = "gooyal_oauth2.Grant"
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "gooyal_oauth2.RefreshToken"
OAUTH2_PROVIDER_BASE_PUBLIC_URL = config("OAUTH2_PROVIDER_BASE_PUBLIC_URL", default="")
OAUTH2_PROVIDER_BASE_PRIVATE_URL = config("OAUTH2_PROVIDER_BASE_PRIVATE_URL", default="")
OAUTH2_PROVIDER_CLIENT_ID = config('OAUTH2_PROVIDER_CLIENT_ID', default="")
OAUTH2_PROVIDER_CLIENT_SECRET = config('OAUTH2_PROVIDER_CLIENT_SECRET', default="")
OAUTH2_PROVIDER_SCOPES = config('OAUTH2_PROVIDER_SCOPES', default='')
OAUTH2_PROVIDER = {
'OAUTH2_VALIDATOR_CLASS': 'apps.gooyal_oauth2.validators.OAuth2Validator',
'RESOURCE_SERVER_INTROSPECTION_URL': (OAUTH2_PROVIDER_BASE_PRIVATE_URL or OAUTH2_PROVIDER_BASE_PUBLIC_URL) + '/introspect/',
'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': (OAUTH2_PROVIDER_CLIENT_ID, OAUTH2_PROVIDER_CLIENT_SECRET),
'RESOURCE_SERVER_TOKEN_CACHING_SECONDS': 600,
}
AUTHENTICATION_BACKENDS = (
'oauth2_provider.backends.OAuth2Backend',
'django.contrib.auth.backends.ModelBackend',
)
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.contrib.gis.db.backends.postgis",
"NAME": env("DB_NAME", default="winofy_dev"),
"USER": env("DB_USER", default=""),
"PASSWORD": env("DB_PASSWORD", default=""),
"HOST": env("DB_HOST", default="localhost"),
"PORT": env("DB_PORT", default="5432"),
}
}
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": env("REDIS_URL", default="redis://localhost:6379/1"),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
AUTH_USER_MODEL = 'users.User'
LANGUAGE_CODE = "fa"
TIME_ZONE = "Asia/Tehran"
USE_I18N = True
USE_TZ = True
LOCALE_PATHS = [BASE_DIR / 'locale']
API_BASE_PATH = config('API_BASE_PATH', default='')
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_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",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[])
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
if _gdal := env("GDAL_LIBRARY_PATH", default=""):
GDAL_LIBRARY_PATH = _gdal
if _geos := env("GEOS_LIBRARY_PATH", default=""):
GEOS_LIBRARY_PATH = _geos
# Platform commission taken from each delivered order's items subtotal (S10 mock: 4%).
# Overridable per-store via Store.commission_percent.
DEFAULT_COMMISSION_PERCENT = env.float("DEFAULT_COMMISSION_PERCENT", default=4.0)
# Fallback delivery-coverage radius (meters) used when a store has no explicit
# service_neighborhoods assigned yet.
NEARBY_RADIUS_METERS = env.int("NEARBY_RADIUS_METERS", default=5000)
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 50,
'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend',),
"DEFAULT_SCHEMA_CLASS": "apps.core.schema.TaggedAutoSchema",
'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler',
}
SPECTACULAR_SETTINGS = {
"TITLE": "Winofy API",
"DESCRIPTION": "Winofy — neighborhood multi-vendor marketplace (customer app + seller panel).",
"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)."},
{"name": "Catalog", "description": "Customer-facing product browsing and search."},
{"name": "Cart", "description": "The authenticated customer's multi-store cart."},
{"name": "Checkout", "description": "Splits the cart into per-store orders and starts payment."},
{"name": "Orders", "description": "Customer order history and tracking."},
{"name": "Reviews", "description": "Customer reviews of stores/products."},
{"name": "Notifications", "description": "Shared notification feed (customer app + seller panel)."},
{"name": "Seller · Store", "description": "The seller's own store profile, settings, working hours."},
{"name": "Seller · Products", "description": "The seller's product catalog and inventory."},
{"name": "Seller · Orders", "description": "Seller order management and status stepper."},
{"name": "Seller · Analytics", "description": "Seller sales dashboard."},
{"name": "Seller · Reviews", "description": "Reviews left for the seller's store, with reply support."},
],
}
from config.other_settings.logging import LOGGING # noqa: E402