from dataclasses import dataclass from decimal import Decimal, InvalidOperation, ROUND_HALF_UP 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.models import CartItem 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.quantize(Decimal("1"), rounding=ROUND_HALF_UP), "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) filters_active = any([ search_query, min_price_raw, max_price_raw, filter_sale, filter_new, selected_color_id_raw, ]) reset_filters_url = _build_catalog_url(selected_category.id, selected_subcategory.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, 'filters_active': filters_active, 'reset_filters_url': reset_filters_url, '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()) cart_color_id = None if request.user.is_authenticated: cart_item = CartItem.objects.filter( cart__user=request.user, item=item ).select_related('selected_color').first() if cart_item and cart_item.selected_color_id: cart_color_id = cart_item.selected_color_id 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), 'cart_color_id': cart_color_id, 'favorite_item_ids': get_user_favorite_item_ids(request.user), })