from dataclasses import dataclass from decimal import Decimal, InvalidOperation from urllib.parse import urlencode from django.core.paginator import Paginator from django.http import HttpRequest, HttpResponse, Http404 from django.shortcuts import redirect, render from favorites.services import get_user_favorite_item_ids from items.models import Category, Color, Item, Subcategory from orders.services import get_user_cart_item_ids @dataclass class SelectedCategory: name: str | None id: str | None @dataclass class SelectedSubcategory: name: str | None id: str | None def _parse_price(value: str | None) -> Decimal | None: if value is None: return None normalized_value = value.strip() if not normalized_value: return None try: return Decimal(normalized_value) except InvalidOperation: return None def _build_catalog_url( category_slug: str | None = None, subcategory_slug: str | None = None, *, search_query: str = "", min_price: str = "", max_price: str = "", sale: bool = False, new: bool = False, color: str = "", ) -> str: base_url = "/catalog/" if category_slug: base_url = f"/catalog/{category_slug}/" query_params: dict[str, str] = {} if subcategory_slug: query_params["subcategory"] = subcategory_slug if search_query: query_params["q"] = search_query if min_price: query_params["min_price"] = min_price if max_price: query_params["max_price"] = max_price if sale: query_params["sale"] = "1" if new: query_params["new"] = "1" if color: query_params["color"] = color if not query_params: return base_url return f"{base_url}?{urlencode(query_params)}" def _resolve_catalog_scope(scope_value: str | None) -> tuple[str | None, str | None]: if not scope_value: return None, None parts = [part.strip().lower() for part in scope_value.split(":")] if len(parts) == 2 and parts[0] == "category" and parts[1]: return parts[1], None if len(parts) == 3 and parts[0] == "subcategory" and parts[1] and parts[2]: return parts[1], parts[2] return None, None def _format_price_value(value: Decimal) -> str: return format(value, "f") def index(request: HttpRequest, category: str | None = None) -> HttpResponse: selected_category = SelectedCategory(name=None, id=None) selected_subcategory = SelectedSubcategory(name=None, id=None) subcategories = Subcategory.objects.none() subcategory_slug = request.GET.get('subcategory') search_query = request.GET.get('q', '').strip() min_price_raw = request.GET.get('min_price', '').strip() max_price_raw = request.GET.get('max_price', '').strip() selected_scope = request.GET.get('category', '').strip() filter_sale = request.GET.get('sale') == '1' filter_new = request.GET.get('new') == '1' selected_color_id_raw = request.GET.get('color', '').strip() if category is None and selected_scope: category_slug, selected_subcategory_slug = _resolve_catalog_scope(selected_scope) if category_slug: return redirect( _build_catalog_url( category_slug, selected_subcategory_slug, search_query=search_query, min_price=min_price_raw, max_price=max_price_raw, sale=filter_sale, new=filter_new, color=selected_color_id_raw, ) ) items = ( Item.objects.select_related('category', 'subcategory') .prefetch_related('images', 'colors') .order_by('category__name', 'name') ) if category is None and subcategory_slug: raise Http404() if category is not None: try: cat = Category.objects.get(slug=category.lower()) selected_category = SelectedCategory(name=cat.name, id=cat.slug) except Category.DoesNotExist: raise Http404() subcategories = Subcategory.objects.filter(category=cat).order_by('name') items = items.filter(category=cat) if subcategory_slug: try: subcategory_obj = subcategories.get(slug=subcategory_slug.lower()) selected_subcategory = SelectedSubcategory(name=subcategory_obj.name, id=subcategory_obj.slug) except Subcategory.DoesNotExist: raise Http404() items = items.filter(subcategory=subcategory_obj) items = list(items) if search_query: normalized_search_query = search_query.casefold() items = [ item for item in items if normalized_search_query in item.name.casefold() ] if filter_sale: items = [item for item in items if item.discount_price is not None] if filter_new: items = [item for item in items if item.is_new] color_item_ids = {c.id for item in items for c in item.colors.all()} available_colors = list(Color.objects.filter(id__in=color_item_ids)) any_color_url = _build_catalog_url( selected_category.id, selected_subcategory.id, search_query=search_query, min_price=min_price_raw, max_price=max_price_raw, sale=filter_sale, new=filter_new, ) for color in available_colors: color.catalog_url = _build_catalog_url( selected_category.id, selected_subcategory.id, search_query=search_query, min_price=min_price_raw, max_price=max_price_raw, sale=filter_sale, new=filter_new, color=str(color.id), ) if selected_color_id_raw: try: selected_color_id_int = int(selected_color_id_raw) items = [item for item in items if any(c.id == selected_color_id_int for c in item.colors.all())] except ValueError: pass item_prices = [item.display_price for item in items] slider_min_bound = min(item_prices, default=Decimal('0')) slider_max_bound = max(item_prices, default=slider_min_bound) min_price = _parse_price(min_price_raw) if min_price is not None: items = [item for item in items if item.display_price >= min_price] max_price = _parse_price(max_price_raw) if max_price is not None: items = [item for item in items if item.display_price <= max_price] slider_selected_min = min_price if min_price is not None else slider_min_bound slider_selected_max = max_price if max_price is not None else slider_max_bound slider_selected_min = min(max(slider_selected_min, slider_min_bound), slider_max_bound) slider_selected_max = min(max(slider_selected_max, slider_min_bound), slider_max_bound) page_number = request.GET.get('page', 1) paginator = Paginator(items, 24) page_obj = paginator.get_page(page_number) pagination_params = {k: v for k, v in request.GET.items() if k != 'page'} pagination_query_string = urlencode(pagination_params) categories = list(Category.objects.order_by('name')) all_items_url = _build_catalog_url( search_query=search_query, min_price=min_price_raw, max_price=max_price_raw, sale=filter_sale, new=filter_new, color=selected_color_id_raw, ) for catalog_category in categories: catalog_category.catalog_url = _build_catalog_url( catalog_category.slug, search_query=search_query, min_price=min_price_raw, max_price=max_price_raw, sale=filter_sale, new=filter_new, color=selected_color_id_raw, ) subcategories = list(subcategories) selected_category_url = None if selected_category.id is not None: selected_category_url = _build_catalog_url( selected_category.id, search_query=search_query, min_price=min_price_raw, max_price=max_price_raw, sale=filter_sale, new=filter_new, color=selected_color_id_raw, ) for catalog_subcategory in subcategories: catalog_subcategory.catalog_url = _build_catalog_url( selected_category.id, catalog_subcategory.slug, search_query=search_query, min_price=min_price_raw, max_price=max_price_raw, sale=filter_sale, new=filter_new, color=selected_color_id_raw, ) price_filter_action = _build_catalog_url(selected_category.id) sidebar_sale_items = ( Item.objects.filter(discount_price__isnull=False, is_available=True) .prefetch_related('images') .order_by('-created_at')[:3] ) sidebar_new_items = ( Item.objects.filter(is_new=True, is_available=True) .prefetch_related('images') .order_by('-created_at')[:3] ) sale_all_url = _build_catalog_url(sale=True) new_all_url = _build_catalog_url(new=True) return render(request, 'catalog/index.html', { 'categories': categories, 'subcategories': subcategories, 'items': items, 'page_obj': page_obj, 'paginator': paginator, 'pagination_query_string': pagination_query_string, 'available_colors': available_colors, 'any_color_url': any_color_url, 'selected_color_id': selected_color_id_raw, 'selected_category': selected_category, 'selected_subcategory': selected_subcategory, 'all_items_url': all_items_url, 'selected_category_url': selected_category_url, 'price_filter_action': price_filter_action, 'current_search_query': search_query, 'current_min_price': min_price_raw, 'current_max_price': max_price_raw, 'price_slider_min_bound': _format_price_value(slider_min_bound), 'price_slider_max_bound': _format_price_value(slider_max_bound), 'price_slider_selected_min': _format_price_value(slider_selected_min), 'price_slider_selected_max': _format_price_value(slider_selected_max), 'favorite_item_ids': get_user_favorite_item_ids(request.user), 'filter_sale': filter_sale, 'filter_new': filter_new, 'sidebar_sale_items': sidebar_sale_items, 'sidebar_new_items': sidebar_new_items, 'sale_all_url': sale_all_url, 'new_all_url': new_all_url, }) def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse: try: item = ( Item.objects.select_related('category', 'subcategory') .prefetch_related('images', 'colors') .get(category__slug=category.lower(), slug=slug) ) except Item.DoesNotExist: raise Http404() images = list(item.images.all()) return render(request, 'catalog/detail.html', { 'categories': Category.objects.order_by('name'), 'item': item, 'images': images, 'primary_image': images[0] if images else None, 'cart_item_ids': get_user_cart_item_ids(request.user), 'favorite_item_ids': get_user_favorite_item_ids(request.user), })