diff --git a/config/conf/logs.py b/config/conf/logs.py index 954362c..b0afeea 100644 --- a/config/conf/logs.py +++ b/config/conf/logs.py @@ -18,43 +18,25 @@ LOGGING = { "disable_existing_loggers": False, "formatters": { "verbose": { - "format": "%(asctime)s %(name)s %(levelname)s %(pathname)s:%(lineno)d - %(message)s", - }, - }, - "filters": { - "exclude_errors": { - "()": ExcludeErrorsFilter, + "format": "%(asctime)s %(name)s %(levelname)s - %(message)s", }, }, "handlers": { - "daily_rotating_file": { - "level": "INFO", - "class": "logging.handlers.TimedRotatingFileHandler", - "filename": LOG_DIR / "django.log", - "when": "midnight", - "backupCount": 30, - "formatter": "verbose", - "filters": ["exclude_errors"], - }, - "error_file": { - "level": "ERROR", - "class": "logging.handlers.TimedRotatingFileHandler", - "filename": LOG_DIR / "django_error.log", - "when": "midnight", - "backupCount": 30, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", "formatter": "verbose", }, }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, "loggers": { "django": { - "handlers": ["daily_rotating_file", "error_file"], + "handlers": ["console"], "level": "INFO", - "propagate": True, - }, - "root": { - "handlers": ["daily_rotating_file", "error_file"], - "level": "INFO", - "propagate": True, + "propagate": False, }, }, } diff --git a/core/apps/shared/management/__init__.py b/core/apps/shared/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/management/commands/__init__.py b/core/apps/shared/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/management/commands/sync_external_images.py b/core/apps/shared/management/commands/sync_external_images.py new file mode 100644 index 0000000..24b4ea9 --- /dev/null +++ b/core/apps/shared/management/commands/sync_external_images.py @@ -0,0 +1,60 @@ +import requests +from django.core.management.base import BaseCommand +from django.core.files.base import ContentFile +from core.apps.vendors.models import VendorproductModel, CategoryModel, VendorModel +import os + +class Command(BaseCommand): + help = 'Syncs images from photo_url/photos_json to ImageField if they are empty' + + def handle(self, *args, **options): + self.sync_categories() + self.sync_vendors() + self.sync_products() + + def download_image(self, url): + if not url or not url.startswith('http'): + return None + try: + response = requests.get(url, timeout=10) + if response.status_code == 200: + name = os.path.basename(url.split('?')[0]) + if not name: + name = "image.png" + return ContentFile(response.content, name=name) + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error downloading {url}: {e}")) + return None + + def sync_categories(self): + self.stdout.write("Syncing Category images...") + for obj in CategoryModel.objects.filter(photo__isnull=True).exclude(photo_url__isnull=True): + if not obj.photo_url.startswith('http'): continue + file_content = self.download_image(obj.photo_url) + if file_content: + obj.photo.save(file_content.name, file_content, save=True) + self.stdout.write(self.style.SUCCESS(f"Saved photo for category: {obj.title}")) + + def sync_vendors(self): + self.stdout.write("Syncing Vendor images...") + for obj in VendorModel.objects.filter(photo__isnull=True).exclude(photo_url__isnull=True): + if not obj.photo_url.startswith('http'): continue + file_content = self.download_image(obj.photo_url) + if file_content: + obj.photo.save(file_content.name, file_content, save=True) + self.stdout.write(self.style.SUCCESS(f"Saved photo for vendor: {obj.title}")) + + def sync_products(self): + self.stdout.write("Syncing Product images...") + for obj in VendorproductModel.objects.filter(image__isnull=True).exclude(photos_json__isnull=True): + if not isinstance(obj.photos_json, list) or len(obj.photos_json) == 0: + continue + + url = obj.photos_json[0] + if not isinstance(url, str) or not url.startswith('http'): + continue + + file_content = self.download_image(url) + if file_content: + obj.image.save(file_content.name, file_content, save=True) + self.stdout.write(self.style.SUCCESS(f"Saved image for product: {obj.name}")) diff --git a/core/apps/vendors/admin/category.py b/core/apps/vendors/admin/category.py index 13b30b5..72945ed 100644 --- a/core/apps/vendors/admin/category.py +++ b/core/apps/vendors/admin/category.py @@ -8,6 +8,13 @@ from core.apps.vendors.models import CategoryModel class CategoryAdmin(ModelAdmin): list_display = ( "id", - "__str__", + "title", + "section", + "photo", + "photo_url", + "is_publish", + "order", ) + search_fields = ("title", "firestore_id") + list_filter = ("section", "is_publish") diff --git a/core/apps/vendors/admin/vendor_product.py b/core/apps/vendors/admin/vendor_product.py index 91621d9..5168a32 100644 --- a/core/apps/vendors/admin/vendor_product.py +++ b/core/apps/vendors/admin/vendor_product.py @@ -8,13 +8,26 @@ from core.apps.vendors.models import ProductimageModel, VendorproductModel class VendorproductAdmin(ModelAdmin): list_display = ( "id", - "__str__", + "name", + "vendor", + "category", + "section", + "price", + "quantity", + "is_publish", + "image", ) + search_fields = ("name", "firestore_id", "vendor") + list_filter = ("is_publish", "category", "section") + autocomplete_fields = ("category", "section") @admin.register(ProductimageModel) class ProductimageAdmin(ModelAdmin): list_display = ( "id", - "__str__", + "product", + "image", + "order", ) + list_filter = ("product",) diff --git a/core/apps/vendors/serializers/category/Category.py b/core/apps/vendors/serializers/category/Category.py index 488055a..f49f8ff 100644 --- a/core/apps/vendors/serializers/category/Category.py +++ b/core/apps/vendors/serializers/category/Category.py @@ -19,23 +19,58 @@ class BaseCategorySerializer(serializers.ModelSerializer): "is_publish", "order", ] + def to_representation(self, instance): + ret = super().to_representation(instance) + request = self.context.get("request") + + photo_url = ret.get("photo_url") + if photo_url and isinstance(photo_url, str): + if not photo_url.startswith("http"): + path = f"/resources/media/{photo_url.lstrip('/')}" + if request: + ret["photo_url"] = request.build_absolute_uri(path) + else: + ret["photo_url"] = path + elif "localhost" in photo_url and request and "localhost" not in request.get_host(): + path = photo_url.split("/resources/media/")[-1] + ret["photo_url"] = request.build_absolute_uri(f"/resources/media/{path}") + + # Fallback for main photo if it's null + if not ret.get("photo") and ret.get("photo_url"): + ret["photo"] = ret["photo_url"] + + # Ensure main photo is absolute if it's a relative path + photo = ret.get("photo") + if photo and isinstance(photo, str) and not photo.startswith("http"): + if not photo.startswith("/resources/"): + photo = f"/resources/media/{photo.lstrip('/')}" + + if request: + ret["photo"] = request.build_absolute_uri(photo) + else: + ret["photo"] = photo + return ret class ListCategorySerializer(BaseCategorySerializer): - class Meta(BaseCategorySerializer.Meta): ... + class Meta(BaseCategorySerializer.Meta): + pass class RetrieveCategorySerializer(BaseCategorySerializer): - class Meta(BaseCategorySerializer.Meta): ... + class Meta(BaseCategorySerializer.Meta): + pass class CreateCategorySerializer(BaseCategorySerializer): class Meta(BaseCategorySerializer.Meta): fields = [ + "id", "firestore_id", "section", "title", "description", + "photo", "photo_url", "is_publish", "order", diff --git a/core/apps/vendors/serializers/section/Section.py b/core/apps/vendors/serializers/section/Section.py index 1fa603a..3fdc627 100644 --- a/core/apps/vendors/serializers/section/Section.py +++ b/core/apps/vendors/serializers/section/Section.py @@ -15,6 +15,39 @@ class BaseSectionSerializer(serializers.ModelSerializer): "service_type", ] + def to_representation(self, instance): + ret = super().to_representation(instance) + request = self.context.get("request") + + image_url = ret.get("image_url") + if image_url and isinstance(image_url, str): + if not image_url.startswith("http"): + path = f"/resources/media/{image_url.lstrip('/')}" + if request: + ret["image_url"] = request.build_absolute_uri(path) + else: + ret["image_url"] = path + elif "localhost" in image_url and request and "localhost" not in request.get_host(): + path = image_url.split("/resources/media/")[-1] + ret["image_url"] = request.build_absolute_uri(f"/resources/media/{path}") + + # Fallback for main image if it's null + if not ret.get("image") and ret.get("image_url"): + ret["image"] = ret["image_url"] + + # Ensure main image is absolute if it's a relative path + image = ret.get("image") + if image and isinstance(image, str) and not image.startswith("http"): + if not image.startswith("/resources/"): + image = f"/resources/media/{image.lstrip('/')}" + + if request: + ret["image"] = request.build_absolute_uri(image) + else: + ret["image"] = image + + return ret + class ListSectionSerializer(BaseSectionSerializer): pass diff --git a/core/apps/vendors/serializers/vendor/vendor.py b/core/apps/vendors/serializers/vendor/vendor.py index f5b725e..c4e88b9 100644 --- a/core/apps/vendors/serializers/vendor/vendor.py +++ b/core/apps/vendors/serializers/vendor/vendor.py @@ -20,25 +20,60 @@ class BaseVendorSerializer(serializers.ModelSerializer): "photo_url", "is_active", ] + def to_representation(self, instance): + ret = super().to_representation(instance) + request = self.context.get("request") + + photo_url = ret.get("photo_url") + if photo_url and isinstance(photo_url, str): + if not photo_url.startswith("http"): + path = f"/resources/media/{photo_url.lstrip('/')}" + if request: + ret["photo_url"] = request.build_absolute_uri(path) + else: + ret["photo_url"] = path + elif "localhost" in photo_url and request and "localhost" not in request.get_host(): + path = photo_url.split("/resources/media/")[-1] + ret["photo_url"] = request.build_absolute_uri(f"/resources/media/{path}") + + # Fallback for main photo if it's null + if not ret.get("photo") and ret.get("photo_url"): + ret["photo"] = ret["photo_url"] + + # Ensure main photo is absolute if it's a relative path + photo = ret.get("photo") + if photo and isinstance(photo, str) and not photo.startswith("http"): + if not photo.startswith("/resources/"): + photo = f"/resources/media/{photo.lstrip('/')}" + + if request: + ret["photo"] = request.build_absolute_uri(photo) + else: + ret["photo"] = photo + return ret class ListVendorSerializer(BaseVendorSerializer): - class Meta(BaseVendorSerializer.Meta): ... + class Meta(BaseVendorSerializer.Meta): + pass class RetrieveVendorSerializer(BaseVendorSerializer): - class Meta(BaseVendorSerializer.Meta): ... + class Meta(BaseVendorSerializer.Meta): + pass class CreateVendorSerializer(BaseVendorSerializer): class Meta(BaseVendorSerializer.Meta): fields = [ + "id", "firestore_id", "section", "title", "description", "phone", "location", + "photo", "photo_url", "is_active", ] diff --git a/core/apps/vendors/serializers/vendor_product/ProductImage.py b/core/apps/vendors/serializers/vendor_product/ProductImage.py index dc8a1e8..6553fb8 100644 --- a/core/apps/vendors/serializers/vendor_product/ProductImage.py +++ b/core/apps/vendors/serializers/vendor_product/ProductImage.py @@ -8,7 +8,9 @@ class BaseProductimageSerializer(serializers.ModelSerializer): model = ProductimageModel fields = [ "id", - "name", + "product", + "image", + "order", ] @@ -23,6 +25,7 @@ class RetrieveProductimageSerializer(BaseProductimageSerializer): class CreateProductimageSerializer(BaseProductimageSerializer): class Meta(BaseProductimageSerializer.Meta): fields = [ - "id", - "name", + "product", + "image", + "order", ] diff --git a/core/apps/vendors/serializers/vendor_product/VendorProduct.py b/core/apps/vendors/serializers/vendor_product/VendorProduct.py index edade0b..8108fb8 100644 --- a/core/apps/vendors/serializers/vendor_product/VendorProduct.py +++ b/core/apps/vendors/serializers/vendor_product/VendorProduct.py @@ -3,9 +3,13 @@ from rest_framework import serializers from core.apps.vendors.models import VendorproductModel, VendorModel, CategoryModel, SectionModel +from core.apps.vendors.serializers.vendor_product.ProductImage import ListProductimageSerializer + + class BaseVendorproductSerializer(serializers.ModelSerializer): category = serializers.SlugRelatedField(slug_field='firestore_id', queryset=CategoryModel.objects.all(), required=False, allow_null=True) section = serializers.SlugRelatedField(slug_field='firestore_id', queryset=SectionModel.objects.all(), required=False, allow_null=True) + images = ListProductimageSerializer(many=True, read_only=True) class Meta: model = VendorproductModel @@ -22,30 +26,106 @@ class BaseVendorproductSerializer(serializers.ModelSerializer): "quantity", "is_publish", "image", + "images", "photos_json", ] + def to_representation(self, instance): + ret = super().to_representation(instance) + request = self.context.get("request") + photos = ret.get("photos_json") + + # Handle photos_json list + processed_photos = [] + if photos and isinstance(photos, list): + for photo in photos: + if isinstance(photo, str): + # Agar URL to'liq bo'lmasa yoki localhost bo'lsa, uni dinamik qilamiz + if not photo.startswith("http"): + path = f"/resources/media/{photo}" + if request: + processed_photos.append(request.build_absolute_uri(path)) + else: + processed_photos.append(path) + elif "localhost" in photo and request and "localhost" not in request.get_host(): + # Agar bazada localhost saqlangan bo'lsa-yu, biz boshqa domenda bo'lsak + path = photo.split("/resources/media/")[-1] + processed_photos.append(request.build_absolute_uri(f"/resources/media/{path}")) + else: + processed_photos.append(photo) + else: + processed_photos.append(photo) + + ret["photos_json"] = processed_photos + + # Fallback for main image if it's null + if not ret.get("image") and processed_photos: + ret["image"] = processed_photos[0] + + # Ensure main image is absolute if it's a relative path (fallback for custom storage) + image = ret.get("image") + if image and isinstance(image, str) and not image.startswith("http"): + # Check if it already has resources prefix + if not image.startswith("/resources/"): + image = f"/resources/media/{image.lstrip('/')}" + + if request: + ret["image"] = request.build_absolute_uri(image) + else: + ret["image"] = image + + return ret + + class ListVendorproductSerializer(BaseVendorproductSerializer): - class Meta(BaseVendorproductSerializer.Meta): ... + class Meta(BaseVendorproductSerializer.Meta): + pass class RetrieveVendorproductSerializer(BaseVendorproductSerializer): - class Meta(BaseVendorproductSerializer.Meta): ... + class Meta(BaseVendorproductSerializer.Meta): + pass +from core.apps.vendors.models import VendorproductModel, ProductimageModel, VendorModel, CategoryModel, SectionModel + class CreateVendorproductSerializer(BaseVendorproductSerializer): + uploaded_images = serializers.ListField( + child=serializers.ImageField(max_length=1000000, allow_empty_file=False, use_url=False), + write_only=True, + required=False + ) + class Meta(BaseVendorproductSerializer.Meta): - fields = [ - "firestore_id", - "vendor", - "category", - "section", - "name", - "description", - "price", - "discount_price", - "quantity", - "is_publish", - "photos_json", + fields = BaseVendorproductSerializer.Meta.fields + [ + "uploaded_images", ] + + def create(self, validated_data): + uploaded_images = validated_data.pop("uploaded_images", []) + instance = super().create(validated_data) + + # Save additional gallery images + for i, img in enumerate(uploaded_images): + ProductimageModel.objects.create( + product=instance, + image=img, + order=i + ) + return instance + + def update(self, instance, validated_data): + uploaded_images = validated_data.pop("uploaded_images", []) + instance = super().update(instance, validated_data) + + if uploaded_images: + # Optionally clear existing gallery or append? + # I'll append for now based on user's "how to upload multiple" + for i, img in enumerate(uploaded_images): + ProductimageModel.objects.create( + product=instance, + image=img, + order=instance.images.count() + i + ) + return instance diff --git a/core/apps/vendors/views/vendor_product.py b/core/apps/vendors/views/vendor_product.py index f4fbc53..63182a1 100644 --- a/core/apps/vendors/views/vendor_product.py +++ b/core/apps/vendors/views/vendor_product.py @@ -61,7 +61,7 @@ class VendorproductView(BaseViewSetMixin, ModelViewSet): @extend_schema(tags=["ProductImage"]) -class ProductimageView(BaseViewSetMixin, ReadOnlyModelViewSet): +class ProductimageView(BaseViewSetMixin, ModelViewSet): queryset = ProductimageModel.objects.all() serializer_class = ListProductimageSerializer permission_classes = [AllowAny] diff --git a/core/utils/storage.py b/core/utils/storage.py index 50e6d33..ba3be66 100644 --- a/core/utils/storage.py +++ b/core/utils/storage.py @@ -1,8 +1,20 @@ from typing import Optional, Union - +from storages.backends.s3boto3 import S3Boto3Storage from config.env import env +class CustomMediaStorage(S3Boto3Storage): + """Media fayllar uchun nisbiy URL qaytaradigan storage.""" + def url(self, name, parameters=None, expire=None, http_method=None): + # Name: products/img.png -> /resources/media/products/img.png + return f"/resources/media/{name}" + +class CustomStaticStorage(S3Boto3Storage): + """Static fayllar uchun nisbiy URL qaytaradigan storage.""" + def url(self, name, parameters=None, expire=None, http_method=None): + return f"/resources/static/{name}" + + class Storage: storages = ["AWS", "MINIO", "FILE", "STATIC"] @@ -16,13 +28,15 @@ class Storage: def get_backend(self) -> Optional[str]: match self.storage: case "AWS" | "MINIO": - return "storages.backends.s3boto3.S3Boto3Storage" + if self.sorage_type == "default": + return "core.utils.storage.CustomMediaStorage" + return "core.utils.storage.CustomStaticStorage" case "FILE": return "django.core.files.storage.FileSystemStorage" case "STATIC": return "django.contrib.staticfiles.storage.StaticFilesStorage" - def get_options(self) -> Optional[str]: + def get_options(self) -> Optional[dict]: match self.storage: case "AWS" | "MINIO": if self.sorage_type == "default": @@ -31,3 +45,4 @@ class Storage: return {"bucket_name": env.str("STORAGE_BUCKET_STATIC")} case _: return {} +