From 1f5da95659c32e17e5f47a2dfe6d443d9a4a7c96 Mon Sep 17 00:00:00 2001 From: sandovaldavid Date: Wed, 3 Sep 2025 20:29:02 -0500 Subject: [PATCH 001/102] feat(api): add auctions API with views, serializers and permissions Implement REST API endpoints for auctions app including: - User authentication with JWT - Listings with bidding functionality - Watchlist management - Comments system - Custom permissions for resource access control - Advanced filtering for listings, bids and comments --- auctions/api/__init__.py | 1 + auctions/api/filters.py | 183 +++++++++++++++ auctions/api/permissions.py | 103 ++++++++ auctions/api/serializers.py | 279 ++++++++++++++++++++++ auctions/api/urls.py | 33 +++ auctions/api/views.py | 452 ++++++++++++++++++++++++++++++++++++ 6 files changed, 1051 insertions(+) create mode 100644 auctions/api/__init__.py create mode 100644 auctions/api/filters.py create mode 100644 auctions/api/permissions.py create mode 100644 auctions/api/serializers.py create mode 100644 auctions/api/urls.py create mode 100644 auctions/api/views.py diff --git a/auctions/api/__init__.py b/auctions/api/__init__.py new file mode 100644 index 0000000..66d6554 --- /dev/null +++ b/auctions/api/__init__.py @@ -0,0 +1 @@ +# API package for auctions app diff --git a/auctions/api/filters.py b/auctions/api/filters.py new file mode 100644 index 0000000..1185d55 --- /dev/null +++ b/auctions/api/filters.py @@ -0,0 +1,183 @@ +import django_filters +from django.db.models import Q +from auctions.models import Listing, Bid, Comment + + +class ListingFilter(django_filters.FilterSet): + """ + Filter for Listing model with advanced search capabilities. + """ + + title = django_filters.CharFilter(lookup_expr="icontains") + description = django_filters.CharFilter(lookup_expr="icontains") + category = django_filters.CharFilter(lookup_expr="icontains") + min_price = django_filters.NumberFilter( + field_name="starting_bid", lookup_expr="gte" + ) + max_price = django_filters.NumberFilter( + field_name="starting_bid", lookup_expr="lte" + ) + active = django_filters.BooleanFilter() + user = django_filters.CharFilter( + field_name="user__username", lookup_expr="icontains" + ) + + # Date filters + created_after = django_filters.DateTimeFilter( + field_name="created", lookup_expr="gte" + ) + created_before = django_filters.DateTimeFilter( + field_name="created", lookup_expr="lte" + ) + + # Search across multiple fields + search = django_filters.CharFilter(method="filter_search") + + # Filter by bid count range + min_bids = django_filters.NumberFilter(method="filter_min_bids") + max_bids = django_filters.NumberFilter(method="filter_max_bids") + + # Filter by current price range (highest bid or starting bid) + min_current_price = django_filters.NumberFilter(method="filter_min_current_price") + max_current_price = django_filters.NumberFilter(method="filter_max_current_price") + + class Meta: + model = Listing + fields = [ + "title", + "description", + "category", + "min_price", + "max_price", + "active", + "user", + "created_after", + "created_before", + "search", + "min_bids", + "max_bids", + "min_current_price", + "max_current_price", + ] + + def filter_search(self, queryset, name, value): + """ + Search across title, description, and category. + """ + return queryset.filter( + Q(title__icontains=value) + | Q(description__icontains=value) + | Q(category__icontains=value) + ) + + def filter_min_bids(self, queryset, name, value): + """ + Filter by minimum number of bids. + """ + return queryset.annotate(bid_count=django_filters.Count("bids")).filter( + bid_count__gte=value + ) + + def filter_max_bids(self, queryset, name, value): + """ + Filter by maximum number of bids. + """ + return queryset.annotate(bid_count=django_filters.Count("bids")).filter( + bid_count__lte=value + ) + + def filter_min_current_price(self, queryset, name, value): + """ + Filter by minimum current price (highest bid or starting bid). + """ + from django.db.models import Max, Case, When, F + + return queryset.annotate( + current_price=Case( + When(bids__isnull=True, then=F("starting_bid")), + default=Max("bids__amount"), + ) + ).filter(current_price__gte=value) + + def filter_max_current_price(self, queryset, name, value): + """ + Filter by maximum current price (highest bid or starting bid). + """ + from django.db.models import Max, Case, When, F + + return queryset.annotate( + current_price=Case( + When(bids__isnull=True, then=F("starting_bid")), + default=Max("bids__amount"), + ) + ).filter(current_price__lte=value) + + +class BidFilter(django_filters.FilterSet): + """ + Filter for Bid model. + """ + + listing = django_filters.NumberFilter(field_name="listing__id") + listing_title = django_filters.CharFilter( + field_name="listing__title", lookup_expr="icontains" + ) + user = django_filters.CharFilter( + field_name="user__username", lookup_expr="icontains" + ) + min_amount = django_filters.NumberFilter(field_name="amount", lookup_expr="gte") + max_amount = django_filters.NumberFilter(field_name="amount", lookup_expr="lte") + + # Date filters + created_after = django_filters.DateTimeFilter( + field_name="created", lookup_expr="gte" + ) + created_before = django_filters.DateTimeFilter( + field_name="created", lookup_expr="lte" + ) + + class Meta: + model = Bid + fields = [ + "listing", + "listing_title", + "user", + "min_amount", + "max_amount", + "created_after", + "created_before", + ] + + +class CommentFilter(django_filters.FilterSet): + """ + Filter for Comment model. + """ + + listing = django_filters.NumberFilter(field_name="listing__id") + listing_title = django_filters.CharFilter( + field_name="listing__title", lookup_expr="icontains" + ) + user = django_filters.CharFilter( + field_name="user__username", lookup_expr="icontains" + ) + text = django_filters.CharFilter(lookup_expr="icontains") + + # Date filters + created_after = django_filters.DateTimeFilter( + field_name="created", lookup_expr="gte" + ) + created_before = django_filters.DateTimeFilter( + field_name="created", lookup_expr="lte" + ) + + class Meta: + model = Comment + fields = [ + "listing", + "listing_title", + "user", + "text", + "created_after", + "created_before", + ] diff --git a/auctions/api/permissions.py b/auctions/api/permissions.py new file mode 100644 index 0000000..1050710 --- /dev/null +++ b/auctions/api/permissions.py @@ -0,0 +1,103 @@ +from rest_framework import permissions + + +class IsOwnerOrReadOnly(permissions.BasePermission): + """ + Custom permission to only allow owners of an object to edit it. + """ + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request, + # so we'll always allow GET, HEAD or OPTIONS requests. + if request.method in permissions.SAFE_METHODS: + return True + + # Write permissions are only allowed to the owner of the object. + return obj.user == request.user + + +class IsListingOwnerOrReadOnly(permissions.BasePermission): + """ + Custom permission for listings - only allow listing owners to edit/delete. + Anyone can view active listings. + """ + + def has_permission(self, request, view): + # Allow read access to anyone + if request.method in permissions.SAFE_METHODS: + return True + + # For write operations, user must be authenticated + return request.user and request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request + if request.method in permissions.SAFE_METHODS: + return True + + # Write permissions are only allowed to the owner of the listing + return obj.user == request.user + + +class IsCommentOwnerOrReadOnly(permissions.BasePermission): + """ + Custom permission for comments - only allow comment authors to edit/delete. + """ + + def has_permission(self, request, view): + # Allow read access to anyone + if request.method in permissions.SAFE_METHODS: + return True + + # For write operations, user must be authenticated + return request.user and request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request + if request.method in permissions.SAFE_METHODS: + return True + + # Write permissions are only allowed to the owner of the comment + return obj.user == request.user + + +class IsBidOwnerOrListingOwner(permissions.BasePermission): + """ + Custom permission for bids - only allow bid owners and listing owners to view. + """ + + def has_permission(self, request, view): + # User must be authenticated + return request.user and request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Allow access to bid owner or listing owner + return obj.user == request.user or obj.listing.user == request.user + + +class IsWatchlistOwner(permissions.BasePermission): + """ + Custom permission for watchlist - only allow watchlist owners to access. + """ + + def has_permission(self, request, view): + # User must be authenticated + return request.user and request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Only allow access to watchlist owner + return obj.user == request.user + + +class IsAdminOrReadOnly(permissions.BasePermission): + """ + Custom permission to only allow admin users to edit. + """ + + def has_permission(self, request, view): + # Read permissions are allowed to any request + if request.method in permissions.SAFE_METHODS: + return True + + # Write permissions are only allowed to admin users + return request.user and request.user.is_staff diff --git a/auctions/api/serializers.py b/auctions/api/serializers.py new file mode 100644 index 0000000..bb5b2a9 --- /dev/null +++ b/auctions/api/serializers.py @@ -0,0 +1,279 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model +from auctions.models import Listing, Bid, Watchlist, Comment + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + """Serializer for User model""" + + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ( + "id", + "username", + "email", + "first_name", + "last_name", + "password", + "date_joined", + ) + read_only_fields = ("id", "date_joined") + extra_kwargs = {"password": {"write_only": True}} + + def create(self, validated_data): + """Create user with encrypted password""" + password = validated_data.pop("password") + user = User.objects.create_user(**validated_data) + user.set_password(password) + user.save() + return user + + def update(self, instance, validated_data): + """Update user, handling password separately""" + password = validated_data.pop("password", None) + user = super().update(instance, validated_data) + + if password: + user.set_password(password) + user.save() + + return user + + +class UserPublicSerializer(serializers.ModelSerializer): + """Public serializer for User model (limited fields)""" + + class Meta: + model = User + fields = ("id", "username", "first_name", "last_name", "date_joined") + read_only_fields = ("id", "username", "first_name", "last_name", "date_joined") + + +class BidSerializer(serializers.ModelSerializer): + """Serializer for Bid model""" + + user = UserPublicSerializer(read_only=True) + listing_title = serializers.CharField(source="listing.title", read_only=True) + + class Meta: + model = Bid + fields = ("id", "user", "listing", "listing_title", "amount", "created_at") + read_only_fields = ("id", "user", "created_at") + + def validate_amount(self, value): + """Validate bid amount""" + if value <= 0: + raise serializers.ValidationError("Bid amount must be positive.") + + # Check if listing exists and bid is higher than current highest bid + listing_id = self.context["view"].kwargs.get("pk") or self.initial_data.get( + "listing" + ) + if listing_id: + try: + listing = Listing.objects.get(id=listing_id) + if not listing.active: + raise serializers.ValidationError("Cannot bid on inactive listing.") + + current_highest = listing.current_bid + if current_highest and value <= current_highest: + raise serializers.ValidationError( + f"Bid must be higher than current highest bid of ${current_highest}." + ) + elif not current_highest and value < listing.starting_bid: + raise serializers.ValidationError( + f"Bid must be at least ${listing.starting_bid}." + ) + except Listing.DoesNotExist: + raise serializers.ValidationError("Invalid listing.") + + return value + + +class CommentSerializer(serializers.ModelSerializer): + """Serializer for Comment model""" + + user = UserPublicSerializer(read_only=True) + listing_title = serializers.CharField(source="listing.title", read_only=True) + + class Meta: + model = Comment + fields = ("id", "user", "listing", "listing_title", "text", "created") + read_only_fields = ("id", "user", "created") + + def validate_text(self, value): + """Validate comment text""" + if len(value.strip()) < 5: + raise serializers.ValidationError( + "Comment must be at least 5 characters long." + ) + return value.strip() + + +class ListingSerializer(serializers.ModelSerializer): + """Serializer for Listing model""" + + user = UserPublicSerializer(read_only=True) + current_bid = serializers.DecimalField( + max_digits=10, decimal_places=2, read_only=True + ) + bid_count = serializers.SerializerMethodField() + time_remaining = serializers.SerializerMethodField() + is_watched = serializers.SerializerMethodField() + winner = UserPublicSerializer(read_only=True) + latest_bids = BidSerializer(many=True, read_only=True, source="bids") + comments = CommentSerializer(many=True, read_only=True) + + class Meta: + model = Listing + fields = ( + "id", + "title", + "description", + "starting_bid", + "current_bid", + "image", + "category", + "active", + "created", + "user", + "winner", + "bid_count", + "time_remaining", + "is_watched", + "latest_bids", + "comments", + ) + read_only_fields = ("id", "created", "user", "current_bid", "winner") + + def get_bid_count(self, obj): + """Get number of bids for this listing""" + return obj.bids.count() + + def get_time_remaining(self, obj): + """Get time remaining for auction (placeholder - would need end_time field)""" + # This would require adding an end_time field to the Listing model + return None + + def get_is_watched(self, obj): + """Check if current user is watching this listing""" + request = self.context.get("request") + if request and request.user.is_authenticated: + return Watchlist.objects.filter(user=request.user, listing=obj).exists() + return False + + def validate_starting_bid(self, value): + """Validate starting bid amount""" + if value <= 0: + raise serializers.ValidationError("Starting bid must be positive.") + return value + + def validate_image(self, value): + """Validate image URL format""" + if value and not (value.startswith("http://") or value.startswith("https://")): + raise serializers.ValidationError( + "Image URL must start with http:// or https://" + ) + return value + + +class ListingCreateSerializer(serializers.ModelSerializer): + """Simplified serializer for creating listings""" + + class Meta: + model = Listing + fields = ("title", "description", "starting_bid", "image", "category") + + def validate_starting_bid(self, value): + """Validate starting bid amount""" + if value <= 0: + raise serializers.ValidationError("Starting bid must be positive.") + return value + + +class BidCreateSerializer(serializers.ModelSerializer): + """Simplified serializer for creating bids through listing endpoint""" + + user = UserPublicSerializer(read_only=True) + + class Meta: + model = Bid + fields = ("id", "user", "amount", "created_at") + read_only_fields = ("id", "user", "created_at") + + def validate_amount(self, value): + """Validate bid amount against listing requirements""" + if value <= 0: + raise serializers.ValidationError("Bid amount must be positive.") + + # Get listing from view context + view = self.context.get("view") + if view and hasattr(view, "get_object"): + try: + listing_id = view.kwargs.get("pk") + if listing_id: + listing = Listing.objects.get(pk=listing_id) + + # Check against starting bid + if value < listing.starting_bid: + raise serializers.ValidationError( + f"Bid must be at least {listing.starting_bid} (starting bid)." + ) + + # Check against current highest bid + if listing.current_bid and value <= listing.current_bid: + raise serializers.ValidationError( + f"Bid must be higher than current bid of {listing.current_bid}." + ) + except Listing.DoesNotExist: + raise serializers.ValidationError("Invalid listing.") + + return value + + +class WatchlistSerializer(serializers.ModelSerializer): + """Serializer for Watchlist model""" + + listing = ListingSerializer(read_only=True) + listing_id = serializers.IntegerField(write_only=True) + + class Meta: + model = Watchlist + fields = ("id", "listing", "listing_id", "created_at") + read_only_fields = ("id", "created_at") + + def validate_listing_id(self, value): + """Validate that listing exists and is active""" + try: + listing = Listing.objects.get(id=value) + if not listing.active: + raise serializers.ValidationError("Cannot watch inactive listing.") + return value + except Listing.DoesNotExist: + raise serializers.ValidationError("Listing does not exist.") + + def create(self, validated_data): + """Create watchlist entry""" + listing_id = validated_data.pop("listing_id") + listing = Listing.objects.get(id=listing_id) + user = self.context["request"].user + + # Check if already watching + if Watchlist.objects.filter(user=user, listing=listing).exists(): + raise serializers.ValidationError("Already watching this listing.") + + return Watchlist.objects.create(user=user, listing=listing) + + +class ListingStatsSerializer(serializers.Serializer): + """Serializer for listing statistics""" + + total_listings = serializers.IntegerField() + active_listings = serializers.IntegerField() + total_bids = serializers.IntegerField() + total_value = serializers.DecimalField(max_digits=15, decimal_places=2) + categories = serializers.DictField() diff --git a/auctions/api/urls.py b/auctions/api/urls.py new file mode 100644 index 0000000..72764f9 --- /dev/null +++ b/auctions/api/urls.py @@ -0,0 +1,33 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenVerifyView + +from .views import ( + UserViewSet, + ListingViewSet, + BidViewSet, + CommentViewSet, + WatchlistViewSet, + CustomTokenObtainPairView, + CustomTokenRefreshView, +) + +# Create a router and register our viewsets with it. +router = DefaultRouter() +router.register(r"users", UserViewSet) +router.register(r"listings", ListingViewSet) +router.register(r"bids", BidViewSet) +router.register(r"comments", CommentViewSet) +router.register(r"watchlist", WatchlistViewSet) + +# The API URLs are now determined automatically by the router. +urlpatterns = [ + # API endpoints + path("", include(router.urls)), + # Authentication endpoints + path("auth/login", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("auth/refresh/", CustomTokenRefreshView.as_view(), name="token_refresh"), + path("auth/verify/", TokenVerifyView.as_view(), name="token_verify"), + # DRF browsable API authentication + path("auth/", include("rest_framework.urls")), +] diff --git a/auctions/api/views.py b/auctions/api/views.py new file mode 100644 index 0000000..72e6a13 --- /dev/null +++ b/auctions/api/views.py @@ -0,0 +1,452 @@ +from rest_framework import viewsets, status, permissions, filters +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from django_filters.rest_framework import DjangoFilterBackend +from django.db.models import Count, Sum, Q +from django.contrib.auth import get_user_model +from auctions.models import Listing, Bid, Watchlist, Comment +from .serializers import ( + UserSerializer, + UserPublicSerializer, + ListingSerializer, + ListingCreateSerializer, + BidSerializer, + BidCreateSerializer, + WatchlistSerializer, + CommentSerializer, + ListingStatsSerializer, +) +from .permissions import ( + IsOwnerOrReadOnly, + IsListingOwnerOrReadOnly, + IsCommentOwnerOrReadOnly, + IsBidOwnerOrListingOwner, + IsWatchlistOwner, +) +from .filters import ListingFilter, BidFilter, CommentFilter + +User = get_user_model() + + +class UserViewSet(viewsets.ReadOnlyModelViewSet): + """ + ViewSet for User model - read-only access. + """ + + queryset = User.objects.all() + serializer_class = UserPublicSerializer + permission_classes = [permissions.IsAuthenticated] + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + search_fields = ["username", "first_name", "last_name"] + ordering_fields = ["username", "date_joined"] + ordering = ["username"] + + def get_serializer_class(self): + """Return appropriate serializer based on action""" + if self.action == "list": + return UserPublicSerializer + elif self.action == "retrieve" and self.request.user != self.get_object(): + return UserPublicSerializer + return UserSerializer + + def get_permissions(self): + """Set permissions based on action""" + if self.action == "create": + permission_classes = [permissions.AllowAny] + elif self.action in ["update", "partial_update", "destroy"]: + permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] + else: + permission_classes = [IsAuthenticated] + + return [permission() for permission in permission_classes] + + @action(detail=False, methods=["get", "put", "patch"]) + def me(self, request): + """Get or update current user profile""" + if request.method == "GET": + serializer = self.get_serializer(request.user) + return Response(serializer.data) + + serializer = self.get_serializer(request.user, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["get"]) + def listings(self, request, pk=None): + """Get user's listings""" + user = self.get_object() + listings = Listing.objects.filter(user=user) + + # Apply filters + active_only = request.query_params.get("active_only", "false").lower() == "true" + if active_only: + listings = listings.filter(active=True) + + page = self.paginate_queryset(listings) + if page is not None: + serializer = ListingSerializer( + page, many=True, context={"request": request} + ) + return self.get_paginated_response(serializer.data) + + serializer = ListingSerializer( + listings, many=True, context={"request": request} + ) + return Response(serializer.data) + + @action(detail=True, methods=["get"]) + def bids(self, request, pk=None): + """Get user's bids""" + user = self.get_object() + bids = ( + Bid.objects.filter(user=user) + .select_related("listing") + .order_by("-created_at") + ) + + page = self.paginate_queryset(bids) + if page is not None: + serializer = BidSerializer(page, many=True, context={"request": request}) + return self.get_paginated_response(serializer.data) + + serializer = BidSerializer(bids, many=True, context={"request": request}) + return Response(serializer.data) + + +class ListingViewSet(viewsets.ModelViewSet): + """ViewSet for Listing model""" + + queryset = Listing.objects.select_related("user").prefetch_related( + "bids", "comments" + ) + permission_classes = [IsListingOwnerOrReadOnly] + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + filterset_class = ListingFilter + search_fields = ["title", "description", "category"] + ordering_fields = ["created", "starting_bid", "title"] + ordering = ["-created"] + + def get_permissions(self): + """Return appropriate permissions based on action""" + if self.action in ["bid", "comment"]: + # For bidding and commenting, only require authentication + permission_classes = [IsAuthenticated] + else: + # For other actions, use default permissions + permission_classes = self.permission_classes + return [permission() for permission in permission_classes] + + def get_serializer_class(self): + """Return appropriate serializer based on action""" + if self.action == "create": + return ListingCreateSerializer + return ListingSerializer + + def get_queryset(self): + """Filter queryset based on user permissions""" + queryset = self.queryset + + # Show only active listings for non-owners + if self.action == "list": + show_inactive = ( + self.request.query_params.get("show_inactive", "false").lower() + == "true" + ) + if not show_inactive: + queryset = queryset.filter(active=True) + + return queryset + + def perform_create(self, serializer): + """Set the listing owner to the current user""" + serializer.save(user=self.request.user) + + def create(self, request, *args, **kwargs): + """Create a listing and return full serialized data""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + # Use ListingSerializer for the response to include user data + instance = serializer.instance + response_serializer = ListingSerializer(instance, context={"request": request}) + headers = self.get_success_headers(serializer.data) + return Response( + response_serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + @action(detail=True, methods=["post"]) + def bid(self, request, pk=None): + """Place a bid on a listing""" + listing = self.get_object() + + if not listing.active: + return Response( + {"error": "Cannot bid on inactive listing"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if listing.user == request.user: + return Response( + {"error": "Cannot bid on your own listing"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = BidCreateSerializer( + data=request.data, context={"request": request, "view": self} + ) + if serializer.is_valid(): + bid = serializer.save(user=request.user, listing=listing) + + # Update listing's current bid + listing.current_bid = serializer.validated_data["amount"] + listing.save() + + return Response(BidSerializer(bid).data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["post"]) + def close(self, request, pk=None): + """Close a listing (only owner can do this)""" + listing = self.get_object() + + if listing.user != request.user: + return Response( + {"error": "Only the listing owner can close the auction"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if not listing.active: + return Response( + {"error": "Listing is already closed"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Find the highest bidder + highest_bid = listing.bids.order_by("-amount").first() + if highest_bid: + listing.winner = highest_bid.user + + listing.active = False + listing.save() + + serializer = self.get_serializer(listing) + return Response(serializer.data) + + @action(detail=True, methods=["get"]) + def bids(self, request, pk=None): + """Get all bids for a listing""" + listing = self.get_object() + bids = listing.bids.select_related("user").order_by("-created_at") + + page = self.paginate_queryset(bids) + if page is not None: + serializer = BidSerializer(page, many=True, context={"request": request}) + return self.get_paginated_response(serializer.data) + + serializer = BidSerializer(bids, many=True, context={"request": request}) + return Response(serializer.data) + + @action(detail=True, methods=["get"]) + def comments(self, request, pk=None): + """Get all comments for a listing""" + listing = self.get_object() + comments = listing.comments.select_related("user").order_by("-created") + + page = self.paginate_queryset(comments) + if page is not None: + serializer = CommentSerializer( + page, many=True, context={"request": request} + ) + return self.get_paginated_response(serializer.data) + + serializer = CommentSerializer( + comments, many=True, context={"request": request} + ) + return Response(serializer.data) + + @action(detail=True, methods=["post"]) + def comment(self, request, pk=None): + """Add a comment to a listing""" + listing = self.get_object() + + serializer = CommentSerializer(data=request.data, context={"request": request}) + if serializer.is_valid(): + serializer.save(user=request.user, listing=listing) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=["get"]) + def stats(self, request): + """Get listing statistics""" + stats = { + "total_listings": Listing.objects.count(), + "active_listings": Listing.objects.filter(active=True).count(), + "total_bids": Bid.objects.count(), + "total_value": Listing.objects.aggregate(total=Sum("current_bid"))["total"] + or 0, + "categories": dict( + Listing.objects.values("category") + .annotate(count=Count("id")) + .values_list("category", "count") + ), + } + + serializer = ListingStatsSerializer(stats) + return Response(serializer.data) + + +class BidViewSet(viewsets.ModelViewSet): + """ViewSet for Bid model""" + + queryset = Bid.objects.select_related("user", "listing") + serializer_class = BidSerializer + permission_classes = [IsBidOwnerOrListingOwner] + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + filterset_class = BidFilter + search_fields = ["listing__title"] + ordering_fields = ["created_at", "amount"] + ordering = ["-created_at"] + + def get_queryset(self): + """ + Filter bids to only show user's own bids or bids on user's listings. + """ + user = self.request.user + return Bid.objects.filter(Q(user=user) | Q(listing__user=user)).select_related( + "user", "listing" + ) + + def perform_create(self, serializer): + """Create bid and update listing's current bid""" + bid = serializer.save(user=self.request.user) + + # Update listing's current bid + listing = bid.listing + listing.current_bid = bid.amount + listing.save() + + +class WatchlistViewSet(viewsets.ModelViewSet): + """ViewSet for Watchlist model""" + + queryset = Watchlist.objects.select_related("user", "listing") + serializer_class = WatchlistSerializer + permission_classes = [IsWatchlistOwner] + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ["listing"] + ordering_fields = ["created_at"] + ordering = ["-created_at"] + + def get_queryset(self): + """Return only current user's watchlist""" + return Watchlist.objects.filter(user=self.request.user).select_related( + "listing" + ) + + def perform_create(self, serializer): + """Add listing to user's watchlist""" + serializer.save(user=self.request.user) + + @action(detail=False, methods=["post"]) + def toggle(self, request): + """Toggle watchlist status for a listing""" + listing_id = request.data.get("listing_id") + + if not listing_id: + return Response( + {"error": "listing_id is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + try: + listing = Listing.objects.get(id=listing_id, active=True) + except Listing.DoesNotExist: + return Response( + {"error": "Listing not found or inactive"}, + status=status.HTTP_404_NOT_FOUND, + ) + + watchlist_item, created = Watchlist.objects.get_or_create( + user=request.user, listing=listing + ) + + if not created: + watchlist_item.delete() + return Response({"status": "removed", "watching": False}) + + serializer = self.get_serializer(watchlist_item) + return Response( + {"status": "added", "watching": True, "watchlist_item": serializer.data}, + status=status.HTTP_201_CREATED, + ) + + +class CommentViewSet(viewsets.ModelViewSet): + """ViewSet for Comment model""" + + queryset = Comment.objects.select_related("user", "listing") + serializer_class = CommentSerializer + permission_classes = [IsCommentOwnerOrReadOnly] + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + filterset_class = CommentFilter + search_fields = ["text"] + ordering_fields = ["created"] + ordering = ["-created"] + + def get_queryset(self): + """Return comments based on user permissions""" + return Comment.objects.select_related("user", "listing") + + def perform_create(self, serializer): + """Set the comment author to the current user""" + serializer.save(user=self.request.user) + + +# Custom JWT Views +class CustomTokenObtainPairView(TokenObtainPairView): + """Custom JWT token obtain view with additional user data""" + + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + + if response.status_code == 200: + # Add user data to response + from django.contrib.auth import authenticate + + username = request.data.get("username") + password = request.data.get("password") + user = authenticate(username=username, password=password) + + if user: + user_serializer = UserPublicSerializer(user) + response.data["user"] = user_serializer.data + + return response + + +class CustomTokenRefreshView(TokenRefreshView): + """Custom JWT token refresh view""" + + pass From cbb31d07c779cee4cd782087a63bbc710f42cb4c Mon Sep 17 00:00:00 2001 From: sandovaldavid Date: Wed, 3 Sep 2025 20:29:18 -0500 Subject: [PATCH 002/102] feat(notifications): implement real-time notification system add websocket consumer, notification service, and celery tasks for handling bid, outbid, and auction ending notifications --- auctions/notifications/__init__.py | 0 auctions/notifications/consumers.py | 116 ++++++++++++++++++++++++++++ auctions/notifications/routing.py | 6 ++ auctions/notifications/services.py | 94 ++++++++++++++++++++++ auctions/notifications/signals.py | 33 ++++++++ auctions/notifications/tasks.py | 81 +++++++++++++++++++ 6 files changed, 330 insertions(+) create mode 100644 auctions/notifications/__init__.py create mode 100644 auctions/notifications/consumers.py create mode 100644 auctions/notifications/routing.py create mode 100644 auctions/notifications/services.py create mode 100644 auctions/notifications/signals.py create mode 100644 auctions/notifications/tasks.py diff --git a/auctions/notifications/__init__.py b/auctions/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auctions/notifications/consumers.py b/auctions/notifications/consumers.py new file mode 100644 index 0000000..67cb73e --- /dev/null +++ b/auctions/notifications/consumers.py @@ -0,0 +1,116 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from django.contrib.auth import get_user_model +from auctions.models import Notification + +User = get_user_model() + + +class NotificationConsumer(AsyncWebsocketConsumer): + async def connect(self): + """Handle WebSocket connection""" + self.user = self.scope["user"] + + if self.user.is_anonymous: + await self.close() + return + + # Join user-specific notification group + self.group_name = f"notifications_{self.user.id}" + await self.channel_layer.group_add(self.group_name, self.channel_name) + + await self.accept() + + # Send unread notifications count on connect + unread_count = await self.get_unread_count() + await self.send( + text_data=json.dumps({"type": "unread_count", "count": unread_count}) + ) + + async def disconnect(self, close_code): + """Handle WebSocket disconnection""" + if hasattr(self, "group_name"): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive(self, text_data): + """Handle messages from WebSocket""" + try: + data = json.loads(text_data) + message_type = data.get("type") + + if message_type == "mark_as_read": + notification_id = data.get("notification_id") + if notification_id: + await self.mark_notification_as_read(notification_id) + + elif message_type == "get_notifications": + notifications = await self.get_user_notifications() + await self.send( + text_data=json.dumps( + {"type": "notifications_list", "notifications": notifications} + ) + ) + + elif message_type == "mark_all_as_read": + await self.mark_all_notifications_as_read() + notifications = await self.get_user_notifications() + await self.send( + text_data=json.dumps( + {"type": "notifications_list", "notifications": notifications} + ) + ) + + except json.JSONDecodeError: + await self.send( + text_data=json.dumps({"type": "error", "message": "Invalid JSON"}) + ) + + async def notification_message(self, event): + """Handle notification messages from group""" + await self.send( + text_data=json.dumps( + {"type": "new_notification", "notification": event["notification"]} + ) + ) + + @database_sync_to_async + def get_unread_count(self): + """Get count of unread notifications for user""" + return Notification.objects.filter(user=self.user, is_read=False).count() + + @database_sync_to_async + def mark_notification_as_read(self, notification_id): + """Mark a notification as read""" + try: + notification = Notification.objects.get(id=notification_id, user=self.user) + notification.is_read = True + notification.save() + return True + except Notification.DoesNotExist: + return False + + @database_sync_to_async + def get_user_notifications(self, limit=20): + """Get user's recent notifications""" + notifications = Notification.objects.filter(user=self.user).order_by( + "-created_at" + )[:limit] + + return [ + { + "id": n.id, + "title": n.title, + "message": n.message, + "notification_type": n.notification_type, + "is_read": n.is_read, + "created_at": n.created_at.isoformat(), + "listing_id": n.listing.id if n.listing else None, + } + for n in notifications + ] + + @database_sync_to_async + def mark_all_notifications_as_read(self): + """Mark all notifications as read for the user""" + Notification.objects.filter(user=self.user, is_read=False).update(is_read=True) diff --git a/auctions/notifications/routing.py b/auctions/notifications/routing.py new file mode 100644 index 0000000..2b20823 --- /dev/null +++ b/auctions/notifications/routing.py @@ -0,0 +1,6 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r"ws/notifications/$", consumers.NotificationConsumer.as_asgi()), +] diff --git a/auctions/notifications/services.py b/auctions/notifications/services.py new file mode 100644 index 0000000..75c7b49 --- /dev/null +++ b/auctions/notifications/services.py @@ -0,0 +1,94 @@ +from django.contrib.auth import get_user_model +from auctions.models import Notification +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + +User = get_user_model() + + +class NotificationService: + """Service for creating and managing notifications""" + + @staticmethod + def create_bid_notification(listing, bid, bidder): + """Create notification when a new bid is placed""" + # Notify listing owner + if listing.user != bidder: + notification = Notification.objects.create( + user=listing.user, + notification_type="bid", + title=f"New bid on {listing.title}", + message=f"{bidder.username} placed a bid of ${bid.amount} on {listing.title}.", + listing=listing, + ) + NotificationService.send_real_time_notification(notification) + return notification + return None + + @staticmethod + def create_outbid_notification(previous_bidder, listing, new_bid): + """Create notification when a user is outbid""" + if previous_bidder and previous_bidder != new_bid.user: + notification = Notification.objects.create( + user=previous_bidder, + notification_type="outbid", + title=f"You have been outbid on {listing.title}", + message=f"You have been outbid on {listing.title}. Someone placed a higher bid of ${new_bid.amount}.", + listing=listing, + ) + NotificationService.send_real_time_notification(notification) + return notification + return None + + @staticmethod + def create_auction_ending_notification(user, listing, hours_remaining=24): + """Create notification when auction is ending soon""" + notification = Notification.objects.create( + user=user, + notification_type="auction_ending", + title=f"Auction ending soon: {listing.title}", + message=f"The auction for {listing.title} is ending soon.", + listing=listing, + ) + NotificationService.send_real_time_notification(notification) + return notification + + @staticmethod + def send_real_time_notification(notification): + """Send real-time notification via WebSocket""" + try: + channel_layer = get_channel_layer() + if channel_layer: + async_to_sync(channel_layer.group_send)( + f"notifications_{notification.user.id}", + { + "type": "notification_message", + "notification": { + "id": notification.id, + "title": notification.title, + "message": notification.message, + "type": notification.notification_type, + "created_at": notification.created_at.isoformat(), + "listing_id": ( + notification.listing.id + if notification.listing + else None + ), + }, + }, + ) + except Exception as e: + # Log error but don't fail the notification creation + print(f"Error sending real-time notification: {e}") + + @staticmethod + def mark_all_as_read(user): + """Mark all notifications as read for a user""" + return Notification.objects.filter(user=user, is_read=False).update( + is_read=True + ) + + @staticmethod + def get_user_notifications(user, limit=50): + """Get notifications for a user with pagination""" + return Notification.objects.filter(user=user).order_by("-created_at")[:limit] diff --git a/auctions/notifications/signals.py b/auctions/notifications/signals.py new file mode 100644 index 0000000..9a382e0 --- /dev/null +++ b/auctions/notifications/signals.py @@ -0,0 +1,33 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from auctions.models import Bid +from .services import NotificationService + + +@receiver(post_save, sender=Bid) +def create_bid_notification(sender, instance, created, **kwargs): + """Crear notificación cuando se crea una nueva puja""" + if created: + # Notificar al dueño de la subasta + NotificationService.create_bid_notification( + listing=instance.listing, bid=instance, bidder=instance.user + ) + + # Notificar a pujas anteriores que fueron superadas + previous_bidders = ( + Bid.objects.filter(listing=instance.listing, amount__lt=instance.amount) + .exclude(user=instance.user) + .values_list("user", flat=True) + .distinct() + ) + + for bidder_id in previous_bidders: + from django.contrib.auth import get_user_model + + User = get_user_model() + previous_bidder = User.objects.get(id=bidder_id) + NotificationService.create_outbid_notification( + previous_bidder=previous_bidder, + listing=instance.listing, + new_bid=instance, + ) diff --git a/auctions/notifications/tasks.py b/auctions/notifications/tasks.py new file mode 100644 index 0000000..14abc7d --- /dev/null +++ b/auctions/notifications/tasks.py @@ -0,0 +1,81 @@ +from celery import shared_task +from django.contrib.auth import get_user_model +from .services import NotificationService +from auctions.models import Listing, Bid +from django.utils import timezone +from datetime import timedelta + +User = get_user_model() + + +class NotificationTasks: + """Celery tasks for notifications""" + + @staticmethod + @shared_task + def send_bid_notification(listing_id, bid_id, bidder_id): + """Async task to send bid notification""" + try: + listing = Listing.objects.get(id=listing_id) + bid = Bid.objects.get(id=bid_id) + bidder = User.objects.get(id=bidder_id) + + # Create bid notification + NotificationService.create_bid_notification(listing, bid, bidder) + + # Check if there was a previous highest bidder to notify about outbid + previous_bids = ( + Bid.objects.filter(listing=listing, amount__lt=bid.amount) + .order_by("-amount") + .first() + ) + + if previous_bids: + NotificationService.create_outbid_notification( + listing, bid, previous_bids.user + ) + + return f"Bid notifications sent for listing {listing_id}" + except Exception as e: + return f"Error sending bid notification: {str(e)}" + + @staticmethod + @shared_task + def send_auction_ending_notifications(hours_before=24): + """Async task to send auction ending notifications""" + try: + # Find auctions ending in the specified hours + end_time = timezone.now() + timedelta(hours=hours_before) + start_time = timezone.now() + timedelta(hours=hours_before - 1) + + ending_listings = Listing.objects.filter( + active=True, created_at__range=[start_time, end_time] + ) + + notifications_sent = 0 + for listing in ending_listings: + notifications = NotificationService.create_auction_ending_notification( + listing, hours_before + ) + notifications_sent += len(notifications) + + return f"Sent {notifications_sent} auction ending notifications" + except Exception as e: + return f"Error sending auction ending notifications: {str(e)}" + + @staticmethod + @shared_task + def cleanup_old_notifications(days_old=30): + """Async task to cleanup old read notifications""" + try: + from .models import Notification + + cutoff_date = timezone.now() - timedelta(days=days_old) + + deleted_count = Notification.objects.filter( + is_read=True, created_at__lt=cutoff_date + ).delete()[0] + + return f"Cleaned up {deleted_count} old notifications" + except Exception as e: + return f"Error cleaning up notifications: {str(e)}" From a88f74e770c90831b13427fc83c6c7880c86557a Mon Sep 17 00:00:00 2001 From: sandovaldavid Date: Wed, 3 Sep 2025 20:29:35 -0500 Subject: [PATCH 003/102] feat(styles): add new profile, dashboard and search page styles with dark mode support - Implement responsive CSS for profile, dashboard and search pages - Add comprehensive dark mode support for all new components - Improve color contrast and accessibility across all themes - Add animations and hover effects for better user experience --- .../static/css/auctions/auctions/styles.css | 3 - .../static/css/auctions/dashboard/styles.css | 326 +++++++++++++++ auctions/static/css/auctions/index/styles.css | 8 - .../static/css/auctions/profile/styles.css | 272 ++++++++++++ .../static/css/auctions/search/styles.css | 392 ++++++++++++++++++ auctions/static/css/auctions/styles.css | 45 +- auctions/static/css/components/navbar.css | 134 +++++- 7 files changed, 1148 insertions(+), 32 deletions(-) create mode 100644 auctions/static/css/auctions/dashboard/styles.css create mode 100644 auctions/static/css/auctions/profile/styles.css create mode 100644 auctions/static/css/auctions/search/styles.css diff --git a/auctions/static/css/auctions/auctions/styles.css b/auctions/static/css/auctions/auctions/styles.css index 4f02e81..fb9a9ae 100644 --- a/auctions/static/css/auctions/auctions/styles.css +++ b/auctions/static/css/auctions/auctions/styles.css @@ -191,13 +191,11 @@ border-right: none; padding: 0.75rem 1rem; color: var(--text-primary); - background-color: var(--bg-main-form); } .form-control:focus { box-shadow: none; border-color: var(--primary-color); - background-color: var(--bg-main); } .form-text { @@ -486,7 +484,6 @@ } [data-theme='dark'] .form-control { - background-color: var(--bg-secondary-form); border-color: var(--primary-color); color: var(--text-primary); } diff --git a/auctions/static/css/auctions/dashboard/styles.css b/auctions/static/css/auctions/dashboard/styles.css new file mode 100644 index 0000000..6fd917c --- /dev/null +++ b/auctions/static/css/auctions/dashboard/styles.css @@ -0,0 +1,326 @@ +/* Dashboard Page Styles with Dark Mode Support */ + +/* Light Mode Styles */ +.dashboard-container { + background: var(--bg-main); + color: var(--text-primary); + min-height: 100vh; +} + +.dashboard-header h1 { + color: var(--text-primary); + font-weight: 600; +} + +.dashboard-header .text-primary { + color: var(--primary-color) !important; +} + +.dashboard-header .text-muted { + color: var(--text-secondary) !important; +} + +/* KPI Cards */ +.kpi-card { + background: var(--card-bg); + border: none; + border-radius: 15px; + box-shadow: var(--shadow-md); + transition: all 0.3s ease; + overflow: hidden; +} + +.kpi-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +/* Border Left Colors */ +.border-left-primary { + border-left: 0.25rem solid var(--primary-color) !important; +} + +.border-left-success { + border-left: 0.25rem solid #1cc88a !important; +} + +.border-left-info { + border-left: 0.25rem solid #36b9cc !important; +} + +.border-left-warning { + border-left: 0.25rem solid #f6c23e !important; +} + +/* Text Colors */ +.text-xs { + font-size: 0.7rem; +} + +.text-gray-800 { + color: var(--text-primary) !important; +} + +.text-gray-300 { + color: var(--text-secondary) !important; +} + +.font-weight-bold { + font-weight: 700 !important; +} + +/* Card Headers */ +.dashboard-card { + background: var(--card-bg); + border: 1px solid var(--border-light); + border-radius: 15px; + box-shadow: var(--shadow-md); + transition: all 0.3s ease; +} + +.dashboard-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.dashboard-card .card-header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-light); + color: var(--text-primary); + border-radius: 15px 15px 0 0 !important; +} + +.dashboard-card .card-header h6 { + color: var(--primary-color) !important; + font-weight: 600; +} + +/* Chart Container */ +.chart-container { + position: relative; + width: 100%; + background: var(--card-bg); + border-radius: 10px; + padding: 1rem; +} + +/* Tables */ +.dashboard-table { + background: var(--card-bg); + color: var(--text-primary); +} + +.dashboard-table thead th { + background: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border-light); + font-weight: 600; +} + +.dashboard-table tbody td { + background: var(--card-bg); + color: var(--text-primary); + border-color: var(--border-light); +} + +.dashboard-table tbody tr:hover { + background: var(--bg-hover) !important; +} + +.dashboard-table .text-muted { + color: var(--text-secondary) !important; +} + +/* List Groups */ +.dashboard-list-group { + background: var(--card-bg); +} + +.dashboard-list-group .list-group-item { + background: var(--card-bg); + color: var(--text-primary); + border-color: var(--border-light); + transition: all 0.3s ease; +} + +.dashboard-list-group .list-group-item:hover { + background: var(--bg-hover); + transform: translateX(5px); +} + +.dashboard-list-group .text-muted { + color: var(--text-secondary) !important; +} + +/* Badges */ +.badge.bg-success { + background-color: #198754 !important; +} + +.badge.bg-secondary { + background-color: #6c757d !important; +} + +.badge.bg-primary { + background-color: var(--primary-color) !important; +} + +/* Links */ +.dashboard-link { + color: var(--primary-color); + text-decoration: none; + transition: color 0.3s ease; +} + +.dashboard-link:hover { + color: var(--primary-light); + text-decoration: underline; +} + +/* Activity Section */ +.activity-section h6 { + color: var(--text-primary); + font-weight: 600; +} + +/* Dark Mode Specific Adjustments */ +[data-theme='dark'] .dashboard-container { + background: var(--bg-main); +} + +[data-theme='dark'] .kpi-card { + background: var(--card-bg); + border-color: var(--border-color); +} + +[data-theme='dark'] .dashboard-card { + background: var(--card-bg); + border-color: var(--border-color); +} + +[data-theme='dark'] .dashboard-card .card-header { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme='dark'] .chart-container { + background: var(--card-bg); +} + +[data-theme='dark'] .dashboard-table thead th { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme='dark'] .dashboard-table tbody td { + background: var(--card-bg); + border-color: var(--border-color); +} + +[data-theme='dark'] .dashboard-list-group .list-group-item { + background: var(--card-bg); + border-color: var(--border-color); +} + +[data-theme='dark'] .border-left-primary { + border-left-color: var(--primary-light) !important; +} + +[data-theme='dark'] .border-left-success { + border-left-color: #20c997 !important; +} + +[data-theme='dark'] .border-left-info { + border-left-color: #0dcaf0 !important; +} + +[data-theme='dark'] .border-left-warning { + border-left-color: #ffc107 !important; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .dashboard-container { + padding: 1rem; + } + + .kpi-card, + .dashboard-card { + margin-bottom: 1.5rem; + } + + .chart-container { + height: 250px !important; + } + + .dashboard-table { + font-size: 0.875rem; + } +} + +@media (max-width: 576px) { + .dashboard-header h1 { + font-size: 1.75rem; + } + + .text-xs { + font-size: 0.65rem; + } + + .kpi-card .h5 { + font-size: 1.5rem; + } + + .chart-container { + height: 200px !important; + padding: 0.5rem; + } +} + +/* Animation */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dashboard-container > * { + animation: fadeInUp 0.5s ease-out; +} + +.kpi-card:nth-child(1) { animation-delay: 0.1s; } +.kpi-card:nth-child(2) { animation-delay: 0.2s; } +.kpi-card:nth-child(3) { animation-delay: 0.3s; } +.kpi-card:nth-child(4) { animation-delay: 0.4s; } + +.dashboard-card:nth-child(1) { animation-delay: 0.5s; } +.dashboard-card:nth-child(2) { animation-delay: 0.6s; } + +/* Shadow utility */ +.shadow { + box-shadow: var(--shadow-md) !important; +} + +/* Custom scrollbar for tables */ +.table-responsive::-webkit-scrollbar { + height: 8px; +} + +.table-responsive::-webkit-scrollbar-track { + background: var(--bg-secondary); + border-radius: 4px; +} + +.table-responsive::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.table-responsive::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} \ No newline at end of file diff --git a/auctions/static/css/auctions/index/styles.css b/auctions/static/css/auctions/index/styles.css index 7107d1d..a1dd1cb 100644 --- a/auctions/static/css/auctions/index/styles.css +++ b/auctions/static/css/auctions/index/styles.css @@ -204,18 +204,10 @@ gap: 0.75rem; } -.card-body { - color: var(--text-primary); -} - .recently-viewed-title i { color: var(--primary-color); } -/* Form elements dark mode support */ -[data-theme='dark'] .card-body { - background: var(--card-bg); -} [data-theme='dark'] .page-header { background: linear-gradient( diff --git a/auctions/static/css/auctions/profile/styles.css b/auctions/static/css/auctions/profile/styles.css new file mode 100644 index 0000000..34e58c6 --- /dev/null +++ b/auctions/static/css/auctions/profile/styles.css @@ -0,0 +1,272 @@ +/* Profile Page Styles with Dark Mode Support */ + +/* Light Mode Styles */ +.profile-container { + background: var(--bg-main); + color: var(--text-primary); + min-height: 100vh; +} + +.profile-header h1 { + color: var(--text-primary); + font-weight: 600; +} + +.profile-header .text-primary { + color: var(--primary-color) !important; +} + +/* User Information Card */ +.user-info-card { + background: var(--card-bg); + border: 1px solid var(--border-light); + border-radius: 15px; + box-shadow: var(--shadow-md); + transition: all 0.3s ease; +} + +.user-info-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.user-info-card .card-header { + background: var(--primary-color) !important; + color: var(--text-light) !important; + border-bottom: 1px solid var(--border-light); + border-radius: 15px 15px 0 0 !important; +} + +.user-info-card .card-body { + background: var(--card-bg); + color: var(--text-primary); +} + +.user-info-card .text-muted { + color: var(--text-secondary) !important; +} + +.avatar-circle { + background: var(--primary-color) !important; + color: var(--text-light) !important; + width: 80px; + height: 80px; + border-radius: 50%; + font-size: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-sm); +} + +/* Statistics Cards */ +.stats-card { + border: none; + border-radius: 15px; + box-shadow: var(--shadow-md); + transition: all 0.3s ease; + overflow: hidden; +} + +.stats-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +/* Gradient Cards - Light Mode */ +.bg-gradient-primary { + background: linear-gradient(45deg, var(--primary-color), var(--primary-dark)) !important; + color: var(--text-light) !important; +} + +.bg-gradient-success { + background: linear-gradient(45deg, #28a745, #1e7e34) !important; + color: var(--text-light) !important; +} + +.bg-gradient-info { + background: linear-gradient(45deg, #17a2b8, #117a8b) !important; + color: var(--text-light) !important; +} + +.bg-gradient-warning { + background: linear-gradient(45deg, #ffc107, #e0a800) !important; + color: var(--text-dark) !important; +} + +/* Card Text Colors */ +.stats-card .card-title { + color: inherit !important; + font-weight: 600; + text-transform: uppercase; + font-size: 0.9rem; + letter-spacing: 0.5px; +} + +.stats-card h2 { + color: inherit !important; + font-weight: 700; + font-size: 2.5rem; +} + +.stats-card .-50 { + opacity: 0.7; +} + +/* Watchlist Section */ +.watchlist-section { + background: var(--card-bg); + border: 1px solid var(--border-light); + border-radius: 15px; + box-shadow: var(--shadow-md); +} + +.watchlist-section .card-header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-light); + color: var(--text-primary); +} + +.watchlist-section .card-body { + background: var(--card-bg); + color: var(--text-primary); +} + +.watchlist-item { + background: var(--bg-secondary); + border: 1px solid var(--border-light); + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; + transition: all 0.3s ease; +} + +.watchlist-item:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-sm); + border-color: var(--primary-color); +} + +.watchlist-item .text-muted { + color: var(--text-secondary) !important; +} + +/* Dark Mode Specific Adjustments */ +[data-theme='dark'] .profile-container { + background: var(--bg-main); +} + +[data-theme='dark'] .user-info-card { + background: var(--card-bg); + border-color: var(--border-color); +} + +[data-theme='dark'] .user-info-card .card-body { + background: var(--card-bg); +} + +[data-theme='dark'] .stats-card { + background: var(--card-bg); + border-color: var(--border-color); +} + +/* Dark mode gradient adjustments */ +[data-theme='dark'] .bg-gradient-primary { + background: linear-gradient(45deg, var(--primary-color), #1a4480) !important; +} + +[data-theme='dark'] .bg-gradient-success { + background: linear-gradient(45deg, #198754, #0f5132) !important; +} + +[data-theme='dark'] .bg-gradient-info { + background: linear-gradient(45deg, #0dcaf0, #055160) !important; +} + +[data-theme='dark'] .bg-gradient-warning { + background: linear-gradient(45deg, #ffc107, #664d03) !important; + color: var(--text-dark) !important; +} + +[data-theme='dark'] .watchlist-section { + background: var(--card-bg); + border-color: var(--border-color); +} + +[data-theme='dark'] .watchlist-section .card-header { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme='dark'] .watchlist-item { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme='dark'] .watchlist-item:hover { + border-color: var(--primary-light); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .profile-container { + padding: 1rem; + } + + .user-info-card, + .stats-card, + .watchlist-section { + margin-bottom: 1.5rem; + } + + .avatar-circle { + width: 60px; + height: 60px; + font-size: 1.5rem; + } + + .stats-card h2 { + font-size: 2rem; + } +} + +@media (max-width: 576px) { + .profile-header h1 { + font-size: 1.75rem; + } + + .stats-card .card-title { + font-size: 0.8rem; + } + + .stats-card h2 { + font-size: 1.75rem; + } + + .avatar-circle { + width: 50px; + height: 50px; + font-size: 1.25rem; + } +} + +/* Animation */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.profile-container > * { + animation: fadeInUp 0.5s ease-out; +} + +.stats-card:nth-child(1) { animation-delay: 0.1s; } +.stats-card:nth-child(2) { animation-delay: 0.2s; } +.stats-card:nth-child(3) { animation-delay: 0.3s; } +.stats-card:nth-child(4) { animation-delay: 0.4s; } \ No newline at end of file diff --git a/auctions/static/css/auctions/search/styles.css b/auctions/static/css/auctions/search/styles.css new file mode 100644 index 0000000..1f1b756 --- /dev/null +++ b/auctions/static/css/auctions/search/styles.css @@ -0,0 +1,392 @@ +/* Search Page Styles with Dark Mode Support */ + +/* Search Container */ +.search-container { + padding: 2rem 0; + min-height: calc(100vh - 200px); +} + +/* Search Header */ +.search-header { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--bs-border-color, #dee2e6); +} + +.search-header h1 { + font-weight: 600; +} + +.search-header .text-muted { + color: var(--bs-secondary, #6c757d) !important; +} + +/* Search Form */ +.search-form { + background: var(--bs-body-bg, #ffffff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + transition: all 0.3s ease; +} + +.search-form:hover { + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1); +} + +.search-form .form-control { + border-color: var(--bs-border-color, #ced4da); + color: var(--bs-body-color, #212529); + transition: all 0.3s ease; +} + +.search-form .form-control:focus { + border-color: var(--bs-primary, #0d6efd); + color: var(--bs-body-color, #212529); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +.search-form .form-select { + border-color: var(--bs-border-color, #ced4da); + color: var(--bs-body-color, #212529); +} + +.search-form .form-select:focus { + border-color: var(--bs-primary, #0d6efd); + color: var(--bs-body-color, #212529); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +/* Search Buttons */ +.search-btn { + background: linear-gradient(135deg, var(--bs-primary, #0d6efd), #0056b3); + border: none; + color: white; + padding: 0.75rem 2rem; + border-radius: 0.375rem; + font-weight: 500; + transition: all 0.3s ease; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +.search-btn:hover { + background: linear-gradient(135deg, #0056b3, #004085); + transform: translateY(-1px); + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15); + color: white; +} + +.search-btn:active { + transform: translateY(0); +} + +.clear-btn { + background: var(--bs-secondary, #6c757d); + border: none; + color: white; + padding: 0.75rem 1.5rem; + border-radius: 0.375rem; + font-weight: 500; + transition: all 0.3s ease; +} + +.clear-btn:hover { + background: var(--bs-dark, #495057); + color: white; +} + +/* Search Results */ +.search-results { + background: var(--bs-body-bg, #ffffff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +.search-results h3 { + color: var(--bs-body-color, #212529); + margin-bottom: 1.5rem; + font-weight: 600; +} + +.search-results .text-muted { + color: var(--bs-secondary, #6c757d) !important; +} + +/* Listing Cards */ +.listing-card { + background: var(--bs-body-bg, #ffffff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.5rem; + transition: all 0.3s ease; + overflow: hidden; + margin-bottom: 1.5rem; +} + +.listing-card:hover { + transform: translateY(-2px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + border-color: var(--bs-primary, #0d6efd); +} + +.listing-card .card-body { + padding: 1.5rem; +} + +.listing-card .card-title { + color: var(--bs-body-color, #212529); + font-weight: 600; + margin-bottom: 0.75rem; +} + +.listing-card .card-title a { + color: inherit; + text-decoration: none; + transition: color 0.3s ease; +} + +.listing-card .card-title a:hover { + color: var(--bs-primary, #0d6efd); +} + +.listing-card .card-text { + color: var(--bs-secondary, #6c757d); + margin-bottom: 1rem; +} + +.listing-card .price { + color: var(--bs-success, #198754); + font-weight: 700; + font-size: 1.25rem; +} + +.listing-card .badge { + font-size: 0.75rem; + padding: 0.375rem 0.75rem; +} + +/* Autocomplete Dropdown */ +.autocomplete-dropdown { + background: var(--bs-body-bg, #ffffff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + z-index: 1000; + max-height: 200px; + overflow-y: auto; +} + +.autocomplete-item { + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 1px solid var(--bs-border-color, #dee2e6); + color: var(--bs-body-color, #212529); + transition: background-color 0.2s ease; +} + +.autocomplete-item:hover, +.autocomplete-item.active { + background-color: var(--bs-primary, #0d6efd); + color: white; +} + +.autocomplete-item:last-child { + border-bottom: none; +} + +/* Pagination */ +.pagination { + margin-top: 2rem; + justify-content: center; +} + +.pagination .page-link { + background-color: var(--bs-body-bg, #ffffff); + border-color: var(--bs-border-color, #dee2e6); + color: var(--bs-primary, #0d6efd); + transition: all 0.3s ease; +} + +.pagination .page-link:hover { + background-color: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); + color: white; +} + +.pagination .page-item.active .page-link { + background-color: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); + color: white; +} + +/* No Results */ +.no-results { + text-align: center; + padding: 3rem 1rem; + color: var(--bs-secondary, #6c757d); +} + +.no-results i { + font-size: 4rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.no-results h4 { + color: var(--bs-body-color, #212529); + margin-bottom: 1rem; +} + +/* Dark Mode Styles */ +[data-theme='dark'] { + /* Search Form Dark Mode */ + .search-form { + background: var(--bs-dark, #212529); + border-color: var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)); + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3); + } + + .search-form:hover { + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.4); + } + + .search-form .form-control { + border-color: var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)); + color: var(--bs-body-color, #ffffff); + } + + .search-form .form-control:focus { + border-color: var(--bs-primary, #0d6efd); + color: var(--bs-body-color, #ffffff); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); + } + + .search-form .form-select { + border-color: var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)); + color: var(--bs-body-color, #ffffff); + } + + .search-form .form-select:focus { + border-color: var(--bs-primary, #0d6efd); + color: var(--bs-body-color, #ffffff); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); + } + + /* Search Results Dark Mode */ + .search-results { + background: var(--bs-dark, #212529); + border-color: var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)); + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3); + } + + /* Listing Cards Dark Mode */ + .listing-card { + background: var(--bs-dark, #212529); + border-color: var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)); + } + + .listing-card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); + border-color: var(--bs-primary, #0d6efd); + } + + /* Autocomplete Dark Mode */ + .autocomplete-dropdown { + background: var(--bs-dark, #212529); + border-color: var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); + } + + .autocomplete-item { + border-color: var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)); + color: var(--bs-body-color, #ffffff); + } + + /* Pagination Dark Mode */ + .pagination .page-link { + background-color: var(--bs-dark, #212529); + border-color: var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)); + color: var(--bs-primary, #0d6efd); + } + + .pagination .page-link:hover { + background-color: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); + color: white; + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .search-container { + padding: 1rem 0; + } + + .search-form { + padding: 1rem; + } + + .search-btn, + .clear-btn { + width: 100%; + margin-bottom: 0.5rem; + } + + .listing-card { + margin-bottom: 1rem; + } + + .listing-card .card-body { + padding: 1rem; + } +} + +@media (max-width: 576px) { + .search-header h1 { + font-size: 1.5rem; + } + + .search-form { + padding: 0.75rem; + } + + .listing-card .price { + font-size: 1.1rem; + } + + .no-results i { + font-size: 3rem; + } +} + +/* Animation for search results */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.listing-card { + animation: fadeInUp 0.3s ease-out; +} + +.listing-card:nth-child(2) { + animation-delay: 0.1s; +} +.listing-card:nth-child(3) { + animation-delay: 0.2s; +} +.listing-card:nth-child(4) { + animation-delay: 0.3s; +} +.listing-card:nth-child(5) { + animation-delay: 0.4s; +} diff --git a/auctions/static/css/auctions/styles.css b/auctions/static/css/auctions/styles.css index c081b06..d6cba1b 100644 --- a/auctions/static/css/auctions/styles.css +++ b/auctions/static/css/auctions/styles.css @@ -8,9 +8,9 @@ --secondary-light: #fff0ed; /* Tono claro naranja */ /* Colores específicos para subastas */ - --price-color: #16a34f; /* Verde brillante para precios */ - --bid-color: #d97706; /* Naranja ámbar para pujas */ - --timer-color: #c2410c; /* Naranja rojizo para temporizadores */ + --price-color: #15803d; /* Verde más oscuro para mejor contraste */ + --bid-color: #c2410c; /* Naranja más oscuro para pujas */ + --timer-color: #b91c1c; /* Rojo más oscuro para temporizadores */ --premium-item: #854d0e; /* Ámbar oscuro para items destacados */ /* Background Colors - Light */ @@ -37,7 +37,7 @@ /* Status Colors - comunicación clara en subastas */ --success-color: #16a34f; /* Verde brillante */ - --warning-color: #eab308; /* Amarillo ámbar */ + --warning-color: #d97706; /* Amarillo ámbar más oscuro para mejor contraste */ --danger-color: #dc2626; /* Rojo vivo */ --info-color: #0284c7; /* Azul cielo */ --toggle-icon: #f59e0b; /* Ámbar */ @@ -63,6 +63,14 @@ /* Dark Mode Colors - Contraste optimizado para subastas */ [data-theme='dark'] { + /* Primary Colors - Dark */ + --primary-color: #3b82f6; /* Azul más brillante para modo oscuro */ + --primary-dark: #60a5fa; /* Azul más claro */ + --primary-light: #1e293b; /* Azul oscuro para fondos */ + --secondary-color: #f97316; /* Naranja más brillante */ + --secondary-dark: #ea580c; /* Naranja oscuro */ + --secondary-light: #1c1917; /* Tono oscuro naranja */ + /* Background Colors - Dark */ --bg-main: rgba(22, 27, 34, 0.98); /* Azul-gris muy oscuro */ --bg-main-form: #374151; /* Gris oscuro */ @@ -78,12 +86,13 @@ --text-primary: #f8fafc; /* Blanco con tinte azul */ --text-secondary: #e2e8f0; /* Gris muy claro */ --text-muted: #cbd5e1; /* Gris claro medio */ + --text-light: #ffffff; /* Blanco */ --price-color: #4ade80; /* Verde más brillante */ --price-icon: #34d399; /* Verde turquesa */ /* Colores específicos para subastas - modo oscuro */ --bid-color: #fb923c; /* Naranja más brillante */ - --timer-color: #fb7185; /* Rojo rosado */ + --timer-color: #f87171; /* Rojo más suave */ --premium-item: #fcd34d; /* Amarillo ámbar claro */ /* Border & Shadow - Dark */ @@ -98,16 +107,25 @@ /* Status Colors - Dark con mejor visibilidad */ --success-color: #22c55e; /* Verde más brillante */ --warning-color: #fbbf24; /* Amarillo más brillante */ - --danger-color: #ef4444; /* Rojo más brillante */ - --info-color: #38bdf8; /* Azul brillante */ - --primary-dark: #60a5fa; /* Azul más claro */ + --danger-color: #f87171; /* Rojo más suave pero visible */ + --info-color: #60a5fa; /* Azul más brillante */ + --toggle-icon: #fde68a; /* Amarillo pálido */ + + /* Badges y etiquetas específicas de subastas - Dark */ + --badge-new: #60a5fa; /* Azul más brillante para items nuevos */ + --badge-ending: #f87171; /* Rojo más suave para subastas por terminar */ + --badge-popular: #a78bfa; /* Violeta más brillante para items populares */ + --badge-premium: linear-gradient( + 45deg, + #fbbf24, + #f59e0b + ); /* Dorado para premium */ /* Gradientes mejorados para modo oscuro */ --gradient-primary: linear-gradient(135deg, #3b82f6, #1d4ed8); --gradient-secondary: linear-gradient(135deg, #f43f5e, #881337); --bg-premium: linear-gradient(135deg, #fbbf24, #d97706); --bg-timer: linear-gradient(135deg, #f87171, #dc2626); - --toggle-icon: #fde68a; /* Amarillo pálido */ } /* Base Styles */ @@ -164,7 +182,8 @@ body { padding: 0.25rem 0.75rem; border-radius: 100px; font-size: 0.8rem; - font-weight: 600; + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); animation: pulse 2s infinite; } @@ -174,7 +193,8 @@ body { padding: 0.25rem 0.75rem; border-radius: 100px; font-size: 0.8rem; - font-weight: 600; + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); } /* Common Elements */ @@ -226,7 +246,8 @@ body { .alert-warning { background: var(--warning-color); - color: var(--text-primary); + color: var(--text-light); + font-weight: 600; } .alert-danger { diff --git a/auctions/static/css/components/navbar.css b/auctions/static/css/components/navbar.css index 98d04a9..d8d0c54 100644 --- a/auctions/static/css/components/navbar.css +++ b/auctions/static/css/components/navbar.css @@ -318,21 +318,12 @@ border-bottom-color: var(--border-color); } -[data-theme='dark'] .brand-title { - color: var(--secondary-light); -} - -[data-theme='dark'] .brand-title:hover { - color: var(--secondary-color); -} - [data-theme='dark'] .nav-link { color: var(--text-secondary); } [data-theme='dark'] .nav-link:hover { background: var(--bg-hover); - color: var(--primary-light); } [data-theme='dark'] .nav-link.active { @@ -404,3 +395,128 @@ outline-color: var(--primary-light); box-shadow: 0 0 0 4px rgba(227, 240, 255, 0.2); } + +/* User Dropdown Button Styles - Light Mode */ +.dropdown .btn-outline-light { + background: rgba(15, 91, 171, 0.1); + border: 2px solid rgba(15, 91, 171, 0.4); + color: #0f5bab; + font-weight: 600; + padding: 0.6rem 1.2rem; + border-radius: 8px; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(15, 91, 171, 0.15); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.dropdown .btn-outline-light:hover { + background: rgba(15, 91, 171, 0.15); + border-color: rgba(15, 91, 171, 0.6); + color: #0f5bab; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(15, 91, 171, 0.25); +} + +.dropdown .btn-outline-light:focus { + background: rgba(15, 91, 171, 0.15); + border-color: var(--primary-color); + color: #0f5bab; + outline: 2px solid var(--primary-color); + outline-offset: 2px; + box-shadow: 0 0 0 4px rgba(15, 91, 171, 0.3); +} + +.dropdown .btn-outline-light:active, +.dropdown .btn-outline-light.show { + background: rgba(15, 91, 171, 0.2); + border-color: rgba(15, 91, 171, 0.7); + color: #0f5bab; + transform: translateY(0); +} + +/* Dark Mode Adjustments for User Dropdown */ +[data-theme='dark'] .dropdown .btn-outline-light { + background: rgba(59, 130, 246, 0.15); + border-color: rgba(59, 130, 246, 0.4); + color: var(--text-light); +} + +[data-theme='dark'] .dropdown .btn-outline-light:hover { + background: rgba(59, 130, 246, 0.25); + border-color: rgba(59, 130, 246, 0.6); + color: var(--text-light); +} + +[data-theme='dark'] .dropdown .btn-outline-light:focus { + background: rgba(59, 130, 246, 0.25); + border-color: var(--primary-light); + color: var(--text-light); + outline-color: var(--primary-light); + box-shadow: 0 0 0 4px rgba(227, 240, 255, 0.3); +} + +[data-theme='dark'] .dropdown .btn-outline-light:active, +[data-theme='dark'] .dropdown .btn-outline-light.show { + background: rgba(59, 130, 246, 0.3); + border-color: rgba(59, 130, 246, 0.7); + color: var(--text-light); +} + +/* Dropdown Menu Improvements */ +.dropdown-menu { + border: 1px solid rgba(0, 0, 0, 0.1); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 0.5rem 0; + margin-top: 0.5rem; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.dropdown-item { + padding: 0.6rem 1.2rem; + font-weight: 500; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.dropdown-item:hover { + background: var(--bg-hover); + color: var(--primary-color); + transform: translateX(4px); +} + +.dropdown-item:focus { + background: var(--bg-hover); + color: var(--primary-color); + outline: 2px solid var(--primary-color); + outline-offset: -2px; +} + +.dropdown-item i { + width: 16px; + text-align: center; + transition: transform 0.2s ease; +} + +.dropdown-item:hover i { + transform: scale(1.1); +} + +[data-theme='dark'] .dropdown-menu { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme='dark'] .dropdown-item { + color: var(--text-primary); +} + +[data-theme='dark'] .dropdown-item:hover, +[data-theme='dark'] .dropdown-item:focus { + background: var(--bg-hover); + color: var(--primary-light); +} From 4f3db302d652971dc35f1ec324a53e2c0bbcf145 Mon Sep 17 00:00:00 2001 From: sandovaldavid Date: Wed, 3 Sep 2025 20:29:50 -0500 Subject: [PATCH 004/102] feat: add frontend javascript modules for alerts, dashboard and search - Implement alert auto-dismiss functionality with manual close option - Add dashboard charts for category distribution and monthly trends - Create advanced search with autocomplete, filters and view toggle --- auctions/static/js/alert.js | 58 +++++ auctions/static/js/dashboard.js | 110 +++++++++ auctions/static/js/search.js | 425 ++++++++++++++++++++++++++++++++ 3 files changed, 593 insertions(+) create mode 100644 auctions/static/js/alert.js create mode 100644 auctions/static/js/dashboard.js create mode 100644 auctions/static/js/search.js diff --git a/auctions/static/js/alert.js b/auctions/static/js/alert.js new file mode 100644 index 0000000..d3f4a10 --- /dev/null +++ b/auctions/static/js/alert.js @@ -0,0 +1,58 @@ +// Alert Auto-Dismiss JavaScript +document.addEventListener('DOMContentLoaded', function() { + initializeAlerts(); +}); + +function initializeAlerts() { + // Get all alert elements + const alerts = document.querySelectorAll('.custom-alert'); + + alerts.forEach(function(alert) { + // Set auto-dismiss timer for 5 seconds (matching CSS animation) + setTimeout(function() { + dismissAlert(alert); + }, 5000); + + // Handle manual close button + const closeBtn = alert.querySelector('.btn-close'); + if (closeBtn) { + closeBtn.addEventListener('click', function() { + dismissAlert(alert); + }); + } + }); +} + +function dismissAlert(alert) { + if (!alert || !alert.parentElement) return; + + const alertWrapper = alert.closest('.alert-wrapper'); + if (!alertWrapper) return; + + // Add fade out animation + alertWrapper.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; + alertWrapper.style.opacity = '0'; + alertWrapper.style.transform = 'translateX(100%)'; + + // Remove element after animation + setTimeout(function() { + if (alertWrapper.parentElement) { + alertWrapper.parentElement.removeChild(alertWrapper); + } + }, 300); +} + +// Function to manually dismiss all alerts +function dismissAllAlerts() { + const alerts = document.querySelectorAll('.custom-alert'); + alerts.forEach(function(alert) { + dismissAlert(alert); + }); +} + +// Export functions for external use +window.AlertManager = { + dismissAlert: dismissAlert, + dismissAllAlerts: dismissAllAlerts, + initializeAlerts: initializeAlerts +}; \ No newline at end of file diff --git a/auctions/static/js/dashboard.js b/auctions/static/js/dashboard.js new file mode 100644 index 0000000..7aa5f40 --- /dev/null +++ b/auctions/static/js/dashboard.js @@ -0,0 +1,110 @@ +// Dashboard Charts JavaScript + +// Initialize charts when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + // Get data from HTML data attributes + const dataContainer = document.getElementById('dashboard-data'); + const categoryData = JSON.parse(dataContainer.dataset.categoryStats || '[]'); + const monthlyListingsData = JSON.parse(dataContainer.dataset.monthlyListings || '[]'); + const monthlyBidsData = JSON.parse(dataContainer.dataset.monthlyBids || '[]'); + + // Category Distribution Chart + if (categoryData.length > 0) { + const categoryLabels = categoryData.map(item => item.category || 'Sin categoría'); + const categoryCounts = categoryData.map(item => item.count); + + const categoryCtx = document.getElementById('categoryChart').getContext('2d'); + const categoryChart = new Chart(categoryCtx, { + type: 'doughnut', + data: { + labels: categoryLabels, + datasets: [{ + data: categoryCounts, + backgroundColor: [ + '#4e73df', + '#1cc88a', + '#36b9cc', + '#f6c23e', + '#e74a3b' + ], + hoverBackgroundColor: [ + '#2e59d9', + '#17a673', + '#2c9faf', + '#f4b619', + '#e02d1b' + ], + hoverBorderColor: "rgba(234, 236, 244, 1)" + }] + }, + options: { + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' + } + } + } + }); + } + + // Monthly Trends Chart + if (monthlyListingsData.length > 0 || monthlyBidsData.length > 0) { + // Create a complete month range for the last 6 months + const months = []; + const listingCounts = []; + const bidCounts = []; + + for (let i = 5; i >= 0; i--) { + const date = new Date(); + date.setMonth(date.getMonth() - i); + const monthKey = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0'); + months.push(date.toLocaleDateString('es-ES', { month: 'short', year: 'numeric' })); + + const listingData = monthlyListingsData.find(item => item.month === monthKey); + const bidData = monthlyBidsData.find(item => item.month === monthKey); + + listingCounts.push(listingData ? listingData.count : 0); + bidCounts.push(bidData ? bidData.count : 0); + } + + const trendsCtx = document.getElementById('trendsChart').getContext('2d'); + const trendsChart = new Chart(trendsCtx, { + type: 'line', + data: { + labels: months, + datasets: [{ + label: 'Nuevas Subastas', + data: listingCounts, + borderColor: '#4e73df', + backgroundColor: 'rgba(78, 115, 223, 0.1)', + borderWidth: 2, + fill: true + }, { + label: 'Nuevas Pujas', + data: bidCounts, + borderColor: '#1cc88a', + backgroundColor: 'rgba(28, 200, 138, 0.1)', + borderWidth: 2, + fill: true + }] + }, + options: { + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + ticks: { + stepSize: 1 + } + } + }, + plugins: { + legend: { + position: 'top' + } + } + } + }); + } +}); \ No newline at end of file diff --git a/auctions/static/js/search.js b/auctions/static/js/search.js new file mode 100644 index 0000000..1d0ebdb --- /dev/null +++ b/auctions/static/js/search.js @@ -0,0 +1,425 @@ +// Advanced Search JavaScript +document.addEventListener('DOMContentLoaded', function() { + // Initialize search functionality + initializeSearch(); + initializeAutocomplete(); + initializeViewToggle(); + initializePriceRange(); + initializeFilters(); +}); + +function initializeSearch() { + const searchForm = document.getElementById('searchForm'); + const searchInput = document.getElementById('searchQuery'); + const clearBtn = document.getElementById('clearSearch'); + + if (searchForm) { + searchForm.addEventListener('submit', function(e) { + e.preventDefault(); + performSearch(); + }); + } + + if (clearBtn) { + clearBtn.addEventListener('click', function() { + clearSearch(); + }); + } + + // Auto-search on input change (debounced) + if (searchInput) { + let searchTimeout; + searchInput.addEventListener('input', function() { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + if (this.value.length >= 2) { + performSearch(); + } + }, 500); + }); + } +} + +function initializeAutocomplete() { + const searchInput = document.getElementById('searchQuery'); + const autocompleteContainer = document.getElementById('autocompleteResults'); + + if (!searchInput || !autocompleteContainer) return; + + let autocompleteTimeout; + let currentFocus = -1; + + searchInput.addEventListener('input', function() { + const query = this.value.trim(); + + clearTimeout(autocompleteTimeout); + + if (query.length < 2) { + hideAutocomplete(); + return; + } + + autocompleteTimeout = setTimeout(() => { + fetchAutocompleteResults(query); + }, 300); + }); + + searchInput.addEventListener('keydown', function(e) { + const items = autocompleteContainer.querySelectorAll('.autocomplete-item'); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + currentFocus++; + if (currentFocus >= items.length) currentFocus = 0; + setActiveItem(items, currentFocus); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + currentFocus--; + if (currentFocus < 0) currentFocus = items.length - 1; + setActiveItem(items, currentFocus); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (currentFocus > -1 && items[currentFocus]) { + selectAutocompleteItem(items[currentFocus]); + } else { + performSearch(); + } + } else if (e.key === 'Escape') { + hideAutocomplete(); + } + }); + + // Hide autocomplete when clicking outside + document.addEventListener('click', function(e) { + if (!searchInput.contains(e.target) && !autocompleteContainer.contains(e.target)) { + hideAutocomplete(); + } + }); + + function fetchAutocompleteResults(query) { + fetch(`/search/autocomplete/?q=${encodeURIComponent(query)}`, { + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + displayAutocompleteResults(data.suggestions); + }) + .catch(error => { + console.error('Autocomplete error:', error); + hideAutocomplete(); + }); + } + + function displayAutocompleteResults(suggestions) { + autocompleteContainer.innerHTML = ''; + currentFocus = -1; + + if (suggestions.length === 0) { + hideAutocomplete(); + return; + } + + suggestions.forEach((suggestion, index) => { + const item = document.createElement('div'); + item.className = 'autocomplete-item'; + item.innerHTML = ` +
${escapeHtml(suggestion.title)}
+ ${suggestion.category ? `
${escapeHtml(suggestion.category)}
` : ''} + `; + + item.addEventListener('click', function() { + selectAutocompleteItem(this); + }); + + autocompleteContainer.appendChild(item); + }); + + showAutocomplete(); + } + + function setActiveItem(items, index) { + items.forEach(item => item.classList.remove('active')); + if (items[index]) { + items[index].classList.add('active'); + } + } + + function selectAutocompleteItem(item) { + const title = item.querySelector('.item-title').textContent; + searchInput.value = title; + hideAutocomplete(); + performSearch(); + } + + function showAutocomplete() { + autocompleteContainer.style.display = 'block'; + } + + function hideAutocomplete() { + autocompleteContainer.style.display = 'none'; + currentFocus = -1; + } +} + +function initializeViewToggle() { + const gridBtn = document.getElementById('gridView'); + const listBtn = document.getElementById('listView'); + const resultsContainer = document.getElementById('searchResults'); + + if (!gridBtn || !listBtn || !resultsContainer) return; + + gridBtn.addEventListener('click', function() { + setView('grid'); + }); + + listBtn.addEventListener('click', function() { + setView('list'); + }); + + function setView(view) { + if (view === 'grid') { + gridBtn.classList.add('active'); + listBtn.classList.remove('active'); + resultsContainer.className = 'listings-grid'; + } else { + listBtn.classList.add('active'); + gridBtn.classList.remove('active'); + resultsContainer.className = 'listings-list'; + } + + // Save preference + localStorage.setItem('searchView', view); + } + + // Load saved preference + const savedView = localStorage.getItem('searchView') || 'grid'; + setView(savedView); +} + +function initializePriceRange() { + const minPriceInput = document.getElementById('minPrice'); + const maxPriceInput = document.getElementById('maxPrice'); + + if (!minPriceInput || !maxPriceInput) return; + + function validatePriceRange() { + const minPrice = parseFloat(minPriceInput.value) || 0; + const maxPrice = parseFloat(maxPriceInput.value) || 0; + + if (maxPrice > 0 && minPrice > maxPrice) { + maxPriceInput.value = minPrice; + } + } + + minPriceInput.addEventListener('change', validatePriceRange); + maxPriceInput.addEventListener('change', validatePriceRange); +} + +function initializeFilters() { + const sortSelect = document.getElementById('sortBy'); + + if (sortSelect) { + sortSelect.addEventListener('change', function() { + performSearch(); + }); + } + + // Initialize active filters display + updateActiveFilters(); +} + +function performSearch() { + const form = document.getElementById('searchForm'); + const resultsContainer = document.getElementById('searchResults'); + const loadingSpinner = document.getElementById('loadingSpinner'); + + if (!form || !resultsContainer) return; + + // Show loading state + if (loadingSpinner) { + loadingSpinner.style.display = 'block'; + } + resultsContainer.style.opacity = '0.5'; + + const formData = new FormData(form); + const params = new URLSearchParams(formData); + + fetch(`/search/?${params.toString()}`, { + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.text()) + .then(html => { + // Parse the response and update results + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newResults = doc.getElementById('searchResults'); + const newResultsHeader = doc.querySelector('.results-header'); + + if (newResults) { + resultsContainer.innerHTML = newResults.innerHTML; + } + + if (newResultsHeader) { + const currentHeader = document.querySelector('.results-header'); + if (currentHeader) { + currentHeader.innerHTML = newResultsHeader.innerHTML; + } + } + + // Update URL without page reload + const newUrl = `/search/?${params.toString()}`; + window.history.pushState({}, '', newUrl); + + // Update active filters + updateActiveFilters(); + }) + .catch(error => { + console.error('Search error:', error); + showErrorMessage('An error occurred while searching. Please try again.'); + }) + .finally(() => { + // Hide loading state + if (loadingSpinner) { + loadingSpinner.style.display = 'none'; + } + resultsContainer.style.opacity = '1'; + }); +} + +function clearSearch() { + const form = document.getElementById('searchForm'); + if (!form) return; + + // Reset form fields + form.reset(); + + // Clear URL parameters + window.history.pushState({}, '', '/search/'); + + // Perform empty search to show all results + performSearch(); +} + +function updateActiveFilters() { + const activeFiltersContainer = document.getElementById('activeFilters'); + if (!activeFiltersContainer) return; + + const filters = []; + const form = document.getElementById('searchForm'); + if (!form) return; + + const formData = new FormData(form); + + // Check each filter + if (formData.get('q')) { + filters.push({ + label: `Search: "${formData.get('q')}"`, + param: 'q' + }); + } + + if (formData.get('category')) { + const categorySelect = document.getElementById('category'); + const categoryText = categorySelect.options[categorySelect.selectedIndex].text; + filters.push({ + label: `Category: ${categoryText}`, + param: 'category' + }); + } + + if (formData.get('min_price')) { + filters.push({ + label: `Min Price: $${formData.get('min_price')}`, + param: 'min_price' + }); + } + + if (formData.get('max_price')) { + filters.push({ + label: `Max Price: $${formData.get('max_price')}`, + param: 'max_price' + }); + } + + // Display filters + if (filters.length > 0) { + activeFiltersContainer.innerHTML = filters.map(filter => ` + + ${escapeHtml(filter.label)} + + + `).join(''); + activeFiltersContainer.style.display = 'block'; + } else { + activeFiltersContainer.style.display = 'none'; + } +} + +function removeFilter(param) { + const form = document.getElementById('searchForm'); + if (!form) return; + + const input = form.querySelector(`[name="${param}"]`); + if (input) { + if (input.type === 'select-one') { + input.selectedIndex = 0; + } else { + input.value = ''; + } + } + + performSearch(); +} + +function showErrorMessage(message) { + const resultsContainer = document.getElementById('searchResults'); + if (!resultsContainer) return; + + resultsContainer.innerHTML = ` + + `; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Handle browser back/forward buttons +window.addEventListener('popstate', function(e) { + // Reload the page to handle back/forward navigation + window.location.reload(); +}); + +// Initialize search suggestions +function initializeSearchSuggestions() { + const suggestionsContainer = document.getElementById('searchSuggestions'); + if (!suggestionsContainer) return; + + const suggestions = suggestionsContainer.querySelectorAll('.suggestion-chip'); + suggestions.forEach(suggestion => { + suggestion.addEventListener('click', function(e) { + e.preventDefault(); + const searchInput = document.getElementById('searchQuery'); + if (searchInput) { + searchInput.value = this.textContent.trim(); + performSearch(); + } + }); + }); +} + +// Call after DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + initializeSearchSuggestions(); +}); \ No newline at end of file From e11513386ecb938c9a0fe81040b2f539b559fcd7 Mon Sep 17 00:00:00 2001 From: sandovaldavid Date: Wed, 3 Sep 2025 20:30:04 -0500 Subject: [PATCH 005/102] feat(templates): add list_item component and update alert/footer styles - Create new list_item.html component for auction listings with responsive design - Remove inline z-index from alert container as it's now handled by CSS - Update watchlist URL in footer to use named route 'my_watchlist' --- .../templates/auctions/components/alert.html | 2 +- .../templates/auctions/components/footer.html | 2 +- .../auctions/components/list_item.html | 171 ++++++++++++++++++ 3 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 auctions/templates/auctions/components/list_item.html diff --git a/auctions/templates/auctions/components/alert.html b/auctions/templates/auctions/components/alert.html index 75ab922..6a35b81 100644 --- a/auctions/templates/auctions/components/alert.html +++ b/auctions/templates/auctions/components/alert.html @@ -1,4 +1,4 @@ -
+
{% if messages %} {% for message in messages %}
Quick Links {% if user.is_authenticated %}
  • - + My Watchlist
  • diff --git a/auctions/templates/auctions/components/list_item.html b/auctions/templates/auctions/components/list_item.html new file mode 100644 index 0000000..3b38a20 --- /dev/null +++ b/auctions/templates/auctions/components/list_item.html @@ -0,0 +1,171 @@ +{% load auctions_filters %} + +
    +
    +
    + +
    +
    + {% if auction.image %} + {{ auction.title }} + {% else %} +
    + +
    + {% endif %} +
    +
    + + +
    +
    +
    + +
    +
    + + {{ auction.title|truncatechars:60 }} + +
    + {% if auction.category %} + + {{ auction.category }} + + {% endif %} +
    + + +

    + {{ auction.description|truncatechars:120 }} +

    + + +
    + + + {{ auction.user.username }} + + + + {{ auction.created|timesince }} ago + + {% if auction.bids.count > 0 %} + + + {{ auction.bids.count }} bid{{ auction.bids.count|pluralize }} + + {% endif %} +
    +
    + + +
    +
    + +
    + {% if auction.current_bid %} +
    + Current bid +
    + ${{ auction.current_bid|floatformat:2 }} +
    +
    + {% else %} +
    + Starting bid +
    + ${{ auction.starting_bid|floatformat:2 }} +
    +
    + {% endif %} +
    + + +
    + + + View Details + + + {% if user.is_authenticated %} + {% load auctions_filters %} + {% if auction|is_in_watchlist:user %} +
    + {% csrf_token %} + +
    + {% else %} +
    + {% csrf_token %} + +
    + {% endif %} + {% endif %} +
    +
    +
    +
    +
    +
    +
    +
    + + \ No newline at end of file From 5cabd93c942f96b500ca83e92d060c0433222503 Mon Sep 17 00:00:00 2001 From: sandovaldavid Date: Wed, 3 Sep 2025 20:30:24 -0500 Subject: [PATCH 006/102] refactor(auction): split watchlist button into separate forms for clarity Simplify watchlist button logic by separating add/remove actions into distinct forms --- auctions/templates/auctions/auction.html | 37 +++++++++++++++--------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/auctions/templates/auctions/auction.html b/auctions/templates/auctions/auction.html index b1ffc5f..19043ed 100644 --- a/auctions/templates/auctions/auction.html +++ b/auctions/templates/auctions/auction.html @@ -71,22 +71,33 @@

    {{ listing.title }}

    {% if user.is_authenticated %} + {% load auctions_filters %}
    -
    - {% csrf_token %} - +
    + {% else %} +
    + {% csrf_token %} + -
    + + + + {% endif %}
    {% endif %}
    From a2485ccab704b9f0011ec35cadaefcbe3fe3ba25 Mon Sep 17 00:00:00 2001 From: sandovaldavid Date: Wed, 3 Sep 2025 20:30:44 -0500 Subject: [PATCH 007/102] feat(layout): add search functionality and improve user dropdown - Add search page link and stylesheet - Replace watchlist URL with my_watchlist - Convert user info to dropdown menu with profile and dashboard links - Add notifications link with badge - Include search.js for search page --- auctions/templates/auctions/layout.html | 62 ++++++++++++++++++++----- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/auctions/templates/auctions/layout.html b/auctions/templates/auctions/layout.html index a1ccecc..c113b2d 100644 --- a/auctions/templates/auctions/layout.html +++ b/auctions/templates/auctions/layout.html @@ -43,6 +43,8 @@ {% elif request.resolver_match.url_name == 'watchlist' %} + {% elif request.resolver_match.url_name == 'search' %} + {% endif %} @@ -57,9 +59,6 @@ - - -
    {% if user.is_authenticated %} @@ -78,7 +78,7 @@

    No active listings at the moment

    Be the first to create an auction and start earning!

    {% if user.is_authenticated %} - + Create New Auction {% else %} diff --git a/auctions/templates/auctions/newAuctions.html b/auctions/templates/auctions/newAuctions.html index 73b810f..0ff5f8d 100644 --- a/auctions/templates/auctions/newAuctions.html +++ b/auctions/templates/auctions/newAuctions.html @@ -13,7 +13,7 @@

    Create New Auction

    -
    + {% csrf_token %}
    From 7ce1c483eca4abb9337384a4361a93e2beba7631 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Fri, 12 Sep 2025 19:22:42 -0500 Subject: [PATCH 047/102] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20refactor:=20imp?= =?UTF-8?q?rove=20variable=20naming=20and=20streamline=20watchlist=20funct?= =?UTF-8?q?ionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/views.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/auctions/views.py b/auctions/views.py index 70341fe..6df5e9a 100644 --- a/auctions/views.py +++ b/auctions/views.py @@ -79,9 +79,9 @@ def new_auctions(request): form = ListingForm(request.POST) if form.is_valid(): # Set the logged-in user - listing = form.save(commit=False) # Don't save yet - listing.user = request.user # Set the user - listing.save() # Now save the Listing} + new_listing = form.save(commit=False) # Don't save yet + new_listing.user = request.user # Set the user + new_listing.save() # Now save the Listing messages.success(request, "Your listing has been created.") return redirect("index") messages.error(request, "There was an error with created your listing.") @@ -112,7 +112,7 @@ def listing(request, listing_id): @login_required def bid(request, listing_id): auction = get_object_or_404(Listing, pk=listing_id) - comment_auction = auction.comments.all().count() + bid_count = auction.bids.count() if request.method == "POST": bid_form = BidForm(request.POST) if bid_form.is_valid(): @@ -122,7 +122,7 @@ def bid(request, listing_id): messages.success(request, "Your bid has been placed successfully.") messages.info( request, - f"({comment_auction}) bid(s) so far. Your bid is the current bid.", + f"({bid_count + 1}) bid(s) so far. Your bid is the current bid.", ) return redirect("listing", listing_id=listing_id) except ValidationError as e: @@ -143,15 +143,16 @@ def bid(request, listing_id): def watchlist(request, listing_id): user = request.user if request.method == "POST": - listings_in_watchlist = Watchlist.objects.filter( - user=user, listing__id=listing_id - ) - if listings_in_watchlist.exists(): - listings_in_watchlist.update(active=True) - return HttpResponseRedirect(reverse("watchlist", args=[user.id])) current_listing = Listing.objects.get(pk=listing_id) - Watchlist.objects.create(user=user, listing=current_listing, active=True) - return HttpResponseRedirect(reverse("watchlist", args=[user.id])) + watchlist_item, created = Watchlist.objects.get_or_create( + user=user, listing=current_listing + ) + if created: + watchlist_item.active = True + else: + watchlist_item.active = not watchlist_item.active + watchlist_item.save() + return HttpResponseRedirect(reverse("listing", args=[listing_id])) listings_in_watchlist = Listing.objects.filter( watchlist__user=user, watchlist__active=True ).order_by("-created") @@ -171,18 +172,18 @@ def watchlist_remove(request, listing_id): def close_auction(request, listing_id): - listing = get_object_or_404(Listing, id=listing_id) + auction_listing = get_object_or_404(Listing, id=listing_id) - if request.user != listing.user: + if request.user != auction_listing.user: messages.error(request, "You are not authorized to close this auction.") return redirect("listing", listing_id=listing_id) - highest_bid = listing.bids.order_by("-amount").first() + highest_bid = auction_listing.bids.order_by("-amount").first() if highest_bid: - listing.winner = highest_bid.user + auction_listing.winner = highest_bid.user else: messages.warning(request, "No bids were placed on this listing.") - listing.active = False - listing.save() + auction_listing.active = False + auction_listing.save() messages.success(request, "The auction has been closed.") return redirect("listing", listing_id=listing_id) From 67efae3623db9d20c1fdc91ee59a32072c68b8a0 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Fri, 12 Sep 2025 19:22:53 -0500 Subject: [PATCH 048/102] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20refactor:=20upd?= =?UTF-8?q?ate=20watchlist=20logic=20to=20use=20filtered=20active=20listin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/auction.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/auctions/templates/auctions/auction.html b/auctions/templates/auctions/auction.html index b1ffc5f..feabc76 100644 --- a/auctions/templates/auctions/auction.html +++ b/auctions/templates/auctions/auction.html @@ -75,11 +75,11 @@

    {{ listing.title }}

    {% csrf_token %} +
    + {% endfor %} + {% endif %} + + {% block admin_content %} + {% endblock %} +
    + +
    + + + + + + + + + + + +{% block extra_js %}{% endblock %} + + + From e1c44dd3dea9f61766593e25ff76cf3aa39e19db Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:10:24 -0500 Subject: [PATCH 065/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20comprehensive?= =?UTF-8?q?=20admin=20dashboard=20template=20for=20auction=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/auctions/admin/dashboard.html | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 auctions/templates/auctions/admin/dashboard.html diff --git a/auctions/templates/auctions/admin/dashboard.html b/auctions/templates/auctions/admin/dashboard.html new file mode 100644 index 0000000..38cea48 --- /dev/null +++ b/auctions/templates/auctions/admin/dashboard.html @@ -0,0 +1,349 @@ +{% extends "auctions/admin/base.html" %} +{% load static %} + +{% block admin_content %} + +
    +
    +
    +
    +
    + +
    +
    +
    {{ dashboard_data.metrics.total_listings }}
    +
    Total Subastas
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    {{ dashboard_data.metrics.active_listings }}
    +
    Subastas Activas
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    {{ dashboard_data.metrics.total_users }}
    +
    Usuarios
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    ${{ dashboard_data.metrics.total_auction_value|floatformat:0 }}
    +
    Valor Total
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    {{ dashboard_data.metrics.total_bids }}
    +
    Total Pujas
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    {{ dashboard_data.metrics.total_comments }}
    +
    Comentarios
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    {{ dashboard_data.metrics.total_watchlist_items }}
    +
    En Watchlist
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    {{ dashboard_data.metrics.conversion_rate|floatformat:1 }}%
    +
    Tasa Conversión
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + Tendencias de Actividad +
    +
    + {{ dashboard_data.charts.trends|safe }} +
    +
    +
    + +
    +
    +
    + + Subastas por Categoría +
    +
    + {{ dashboard_data.charts.categories|safe }} +
    +
    +
    +
    + + +
    +
    +
    +
    + + Análisis de Precios: Inicial vs Actual +
    +
    + {{ dashboard_data.charts.price_analysis|safe }} +
    +
    +
    +
    + + +
    +
    +
    +
    + + Subastas Recientes +
    +
    + + + + + + + + + + + {% for listing in recent_listings %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    TítuloUsuarioPrecioEstado
    + + {{ listing.title|truncatechars:30 }} + + {{ listing.user.username }} + ${{ listing.current_bid|default:listing.starting_bid }} + + {% if listing.active %} + Activa + {% else %} + Inactiva + {% endif %} +
    No hay subastas recientes
    +
    +
    +
    + +
    +
    +
    + + Pujas Recientes +
    +
    + + + + + + + + + + + {% for bid in recent_bids %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    UsuarioSubastaMontoFecha
    {{ bid.user.username }} + + {{ bid.listing.title|truncatechars:25 }} + + + ${{ bid.amount }} + + {{ bid.listing.created|date:"M d, H:i" }} +
    No hay pujas recientes
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + Usuarios Más Activos +
    +
    + + + + + + + + + + + + {% for user in top_users %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    UsuarioSubastas CreadasPujas RealizadasComentariosActividad Total
    +
    +
    + {{ user.username|first|upper }} +
    + {{ user.username }} +
    +
    {{ user.listings_count }}{{ user.bids_count }}{{ user.comments_count }} + {{ user.total_activity }} +
    No hay datos de usuarios
    +
    +
    +
    +
    + + +
    +
    + Actualizando... +
    +

    Actualizando métricas...

    +
    +{% endblock %} + +{% block extra_js %} +{{ block.super }} + +{% endblock %} From abe50ee22f4dd3906ee00ecdfdb6e935a28cffb9 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:10:38 -0500 Subject: [PATCH 066/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20detailed=20li?= =?UTF-8?q?sting=20detail=20template=20for=20auction=20admin=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auctions/admin/listing_detail.html | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 auctions/templates/auctions/admin/listing_detail.html diff --git a/auctions/templates/auctions/admin/listing_detail.html b/auctions/templates/auctions/admin/listing_detail.html new file mode 100644 index 0000000..3a8d1c9 --- /dev/null +++ b/auctions/templates/auctions/admin/listing_detail.html @@ -0,0 +1,328 @@ +{% extends "auctions/admin/base.html" %} +{% load static %} + +{% block admin_content %} + +
    +
    +
    +
    +
    +
    + {% if listing.image %} + {{ listing.title }} + {% else %} +
    + +
    + {% endif %} +
    +
    +

    {{ listing.title }}

    +

    {{ listing.description }}

    + +
    +
    +
    Información Básica
    + + + + + + + + + + + + + + + + + + + + + +
    ID:#{{ listing.id }}
    Usuario: +
    +
    + {{ listing.user.username|first|upper }} +
    + {{ listing.user.username }} +
    +
    Categoría: + {% if listing.category %} + {{ listing.category }} + {% else %} + Sin categoría + {% endif %} +
    Fecha Creación:{{ listing.created|date:"d/m/Y H:i" }}
    Estado: + {% if listing.active %} + Activa + {% else %} + Inactiva + {% endif %} +
    +
    +
    +
    Información Financiera
    + + + + + + + + + + + + + + + + + + + + + +
    Precio Inicial:${{ listing.starting_bid }}
    Precio Actual: + {% if listing.current_bid %} + ${{ listing.current_bid }} + {% else %} + Sin pujas + {% endif %} +
    Incremento: + {% if listing.current_bid and listing.starting_bid %} + + +${{ listing.current_bid|sub:listing.starting_bid|floatformat:2 }} + ({{ listing_stats.price_increase|floatformat:1 }}%) + + {% else %} + - + {% endif %} +
    Total Pujas:{{ listing_stats.total_bids }}
    Pujadores Únicos:{{ listing_stats.unique_bidders }}
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +{% if prediction %} +
    +
    +
    +
    +
    + + Análisis Predictivo +
    +
    +
    + {% if prediction.error %} +
    + + {{ prediction.error }} +
    + {% else %} +
    +
    +
    +
    + +{{ prediction.predicted_price_increase|floatformat:1 }}% +
    +
    Incremento Predicho
    +
    +
    +
    +
    +
    + {{ prediction.confidence|title }} +
    +
    Confianza
    +
    +
    +
    +
    +
    + {{ prediction.recommendations|length }} +
    +
    Recomendaciones
    +
    +
    +
    + + {% if prediction.recommendations %} +
    +
    Recomendaciones:
    +
      + {% for recommendation in prediction.recommendations %} +
    • + + {{ recommendation }} +
    • + {% endfor %} +
    +
    + {% endif %} + {% endif %} +
    +
    +
    +
    +{% endif %} + + +
    +
    +
    +
    + + Historial de Pujas +
    +
    + + + + + + + + + + + + {% for bid in bids %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    #UsuarioMontoFechaDiferencia
    {{ forloop.counter }} +
    +
    + {{ bid.user.username|first|upper }} +
    + {{ bid.user.username }} +
    +
    + ${{ bid.amount }} + + {{ bid.listing.created|date:"d/m/Y H:i" }} + + {% if forloop.first %} + - + {% else %} + {% with prev_bid=forloop.counter0|add:"-1" %} + {% for prev in bids %} + {% if forloop.counter0 == prev_bid %} + + +${{ bid.amount|sub:prev.amount|floatformat:2 }} + + {% endif %} + {% endfor %} + {% endwith %} + {% endif %} +
    + +
    No hay pujas en esta subasta +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + Comentarios +
    +
    + + + + + + + + + + {% for comment in comments %} + + + + + + {% empty %} + + + + {% endfor %} + +
    UsuarioComentarioFecha
    +
    +
    + {{ comment.user.username|first|upper }} +
    + {{ comment.user.username }} +
    +
    {{ comment.text }} + {{ comment.created|date:"d/m/Y H:i" }} +
    + +
    No hay comentarios en esta subasta +
    +
    +
    +
    +
    +{% endblock %} + +{% block extra_js %} +{{ block.super }} + +{% endblock %} From b1f5c3ccdacbf4611dbef5bbb10bbe8cba66b1a9 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:10:55 -0500 Subject: [PATCH 067/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20auction=20lis?= =?UTF-8?q?tings=20management=20template=20for=20admin=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/auctions/admin/listings.html | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 auctions/templates/auctions/admin/listings.html diff --git a/auctions/templates/auctions/admin/listings.html b/auctions/templates/auctions/admin/listings.html new file mode 100644 index 0000000..cd5f3e0 --- /dev/null +++ b/auctions/templates/auctions/admin/listings.html @@ -0,0 +1,252 @@ +{% extends "auctions/admin/base.html" %} +{% load static %} + +{% block admin_content %} + +
    +
    +
    +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + Limpiar + +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + Gestión de Subastas +
    + {{ page_obj.paginator.count }} subastas encontradas +
    + +
    + + + + + + + + + + + + + + + + + {% for listing in page_obj %} + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    IDTítuloUsuarioPrecio InicialPrecio ActualCategoríaPujasEstadoFechaAcciones
    + #{{ listing.id }} + +
    + {% if listing.image %} + {{ listing.title }} + {% else %} +
    + +
    + {% endif %} +
    + + {{ listing.title|truncatechars:30 }} + +
    + {{ listing.description|truncatechars:50 }} +
    +
    +
    +
    +
    + {{ listing.user.username|first|upper }} +
    + {{ listing.user.username }} +
    +
    + ${{ listing.starting_bid }} + + {% if listing.current_bid %} + ${{ listing.current_bid }} + {% if listing.current_bid > listing.starting_bid %} +
    + +{{ listing.current_bid|sub:listing.starting_bid|floatformat:2 }} + + {% endif %} + {% else %} + Sin pujas + {% endif %} +
    + {% if listing.category %} + {{ listing.category }} + {% else %} + Sin categoría + {% endif %} + + {{ listing.bids.count }} + + {% if listing.active %} + Activa + {% else %} + Inactiva + {% endif %} + + {{ listing.created|date:"M d, Y" }} +
    + {{ listing.created|time:"H:i" }} +
    + +
    + +
    No se encontraron subastas con los filtros aplicados +
    +
    + + + {% if page_obj.has_other_pages %} + + {% endif %} +
    +
    +
    +{% endblock %} + +{% block extra_js %} +{{ block.super }} + +{% endblock %} From 459919894571983bba6d86454756e975013f98ac Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:11:14 -0500 Subject: [PATCH 068/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20comprehensive?= =?UTF-8?q?=20reports=20template=20for=20auction=20admin=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/auctions/admin/reports.html | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 auctions/templates/auctions/admin/reports.html diff --git a/auctions/templates/auctions/admin/reports.html b/auctions/templates/auctions/admin/reports.html new file mode 100644 index 0000000..cc10f51 --- /dev/null +++ b/auctions/templates/auctions/admin/reports.html @@ -0,0 +1,386 @@ +{% extends "auctions/admin/base.html" %} +{% load static %} + +{% block admin_content %} + +
    +
    +
    +
    +
    + + Métricas Generales del Sistema +
    +
    +
    +
    +
    +
    +
    {{ basic_metrics.total_listings }}
    +
    Total Subastas
    +
    +
    +
    +
    +
    {{ basic_metrics.active_listings }}
    +
    Subastas Activas
    +
    +
    +
    +
    +
    {{ basic_metrics.total_users }}
    +
    Usuarios
    +
    +
    +
    +
    +
    {{ basic_metrics.total_bids }}
    +
    Total Pujas
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + Exportación de Datos +
    +
    +
    +

    Descarga los datos del sistema en formato CSV para análisis externos.

    +
    +
    +
    +
    + +
    Subastas
    +

    Exportar todas las subastas con sus detalles

    + + Descargar CSV + +
    +
    +
    +
    +
    +
    + +
    Pujas
    +

    Exportar historial completo de pujas

    + + Descargar CSV + +
    +
    +
    +
    +
    +
    + +
    Usuarios
    +

    Exportar información de usuarios

    + + Descargar CSV + +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + Análisis Temporal (Últimos 90 días) +
    + +
    +
    +
    + + +
    +
    +
    +
    + + Análisis por Categorías +
    +
    + + + + + + + + + + + + + + {% for category in category_analysis %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    CategoríaSubastasPrecio Promedio InicialPrecio Promedio ActualTotal PujasPujas por SubastaParticipación %
    + + {{ category.category|default:"Sin categoría" }} + + + {{ category.count }} + ${{ category.avg_starting_bid|floatformat:2 }}${{ category.avg_current_bid|floatformat:2 }}{{ category.total_bids }}{{ category.avg_bids_per_listing|floatformat:1 }} + {% widthratio category.count basic_metrics.total_listings 100 as percentage %} +
    +
    + {{ percentage|floatformat:1 }}% +
    +
    +
    No hay datos de categorías
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + Top 10 Pujadores +
    +
    + + + + + + + + + + + {% for user in user_analysis.top_bidders|slice:":10" %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    #UsuarioPujasMonto Total
    {{ forloop.counter }} +
    +
    + {{ user.username|first|upper }} +
    + {{ user.username }} +
    +
    + {{ user.bid_count }} + ${{ user.total_bid_amount|floatformat:2 }}
    No hay datos de pujadores
    +
    +
    +
    + +
    +
    +
    + + Usuarios Más Activos +
    +
    + + + + + + + + + + + {% for user in user_analysis.user_engagement|slice:":10" %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    #UsuarioActividadWatchlist
    {{ forloop.counter }}{{ user.username }} + {{ user.total_activity }} + {{ user.watchlist_count }}
    No hay datos de engagement
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + Resumen Ejecutivo +
    +
    +
    +
    +
    +
    Métricas de Rendimiento
    +
      +
    • + Tasa de Conversión + + {{ basic_metrics.conversion_rate|floatformat:1 }}% + +
    • +
    • + Valor Promedio por Subasta + + ${{ basic_metrics.avg_auction_value|floatformat:2 }} + +
    • +
    • + Total de Comentarios + + {{ basic_metrics.total_comments }} + +
    • +
    • + Items en Watchlist + + {{ basic_metrics.total_watchlist_items }} + +
    • +
    +
    +
    +
    Recomendaciones
    +
    + + Basado en el análisis de datos: +
    +
      +
    • + + {% if basic_metrics.conversion_rate > 50 %} + Excelente tasa de conversión. Considera expandir las categorías más exitosas. + {% else %} + La tasa de conversión puede mejorarse. Revisa las subastas sin pujas. + {% endif %} +
    • +
    • + + {% if basic_metrics.total_users > 100 %} + Gran base de usuarios. Considera implementar programas de fidelización. + {% else %} + Enfócate en el crecimiento de usuarios. Implementa estrategias de marketing. + {% endif %} +
    • +
    • + + Monitorea las tendencias mensuales para identificar patrones estacionales. +
    • +
    +
    +
    +
    +
    +
    +
    +{% endblock %} + +{% block extra_js %} +{{ block.super }} + +{% endblock %} From 1b552627b19afd058577ccc9cf05f3479c99dda0 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:11:28 -0500 Subject: [PATCH 069/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20user=20manage?= =?UTF-8?q?ment=20template=20for=20auction=20admin=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/admin/users.html | 313 +++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 auctions/templates/auctions/admin/users.html diff --git a/auctions/templates/auctions/admin/users.html b/auctions/templates/auctions/admin/users.html new file mode 100644 index 0000000..28285df --- /dev/null +++ b/auctions/templates/auctions/admin/users.html @@ -0,0 +1,313 @@ +{% extends "auctions/admin/base.html" %} +{% load static %} + +{% block admin_content %} + +
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + Gestión de Usuarios +
    + {{ page_obj.paginator.count }} usuarios encontrados +
    + +
    + + + + + + + + + + + + + + + + + {% for user in page_obj %} + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    UsuarioEmailNombreSubastasPujasComentariosWatchlistRegistroEstadoAcciones
    +
    +
    + {{ user.username|first|upper }} +
    +
    +
    {{ user.username }}
    + {% if user.is_superuser %} + Superusuario + {% elif user.is_staff %} + Staff + {% endif %} +
    +
    +
    + {% if user.email %} + + {{ user.email }} + + {% else %} + Sin email + {% endif %} + + {% if user.first_name or user.last_name %} + {{ user.first_name }} {{ user.last_name }} + {% else %} + - + {% endif %} + + {{ user.listings_count }} + + {{ user.bids_count }} + + {{ user.comments_count }} + + {{ user.watchlist_count }} + + {{ user.date_joined|date:"M d, Y" }} +
    + {{ user.date_joined|time:"H:i" }} +
    + {% if user.is_active %} + Activo + {% else %} + Inactivo + {% endif %} + +
    + + {% if not user.is_superuser %} + + + + {% endif %} +
    +
    + +
    No se encontraron usuarios con los filtros aplicados +
    +
    + + + {% if page_obj.has_other_pages %} + + {% endif %} +
    +
    +
    + + +{% for user in page_obj %} + +{% endfor %} +{% endblock %} + +{% block extra_js %} +{{ block.super }} + +{% endblock %} From ef83a434739060090d9ea53ddea97cd2511f9e3a Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:11:43 -0500 Subject: [PATCH 070/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20navigation=20?= =?UTF-8?q?component=20template=20for=20auction=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auctions/components/navigation.html | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 auctions/templates/auctions/components/navigation.html diff --git a/auctions/templates/auctions/components/navigation.html b/auctions/templates/auctions/components/navigation.html new file mode 100644 index 0000000..951386d --- /dev/null +++ b/auctions/templates/auctions/components/navigation.html @@ -0,0 +1,111 @@ +{% load static %} + + + From 350d202de097e90fa71489c0441efca122c17184 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:12:05 -0500 Subject: [PATCH 071/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20400=20error?= =?UTF-8?q?=20page=20template=20for=20auction=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/errors/400.html | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 auctions/templates/auctions/errors/400.html diff --git a/auctions/templates/auctions/errors/400.html b/auctions/templates/auctions/errors/400.html new file mode 100644 index 0000000..1f82da9 --- /dev/null +++ b/auctions/templates/auctions/errors/400.html @@ -0,0 +1,48 @@ +{% extends "auctions/layout.html" %} +{% load static %} + +{% block title %}Solicitud Incorrecta - Auctions{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block body %} +
    +
    +
    + +
    + +
    400
    +

    Solicitud Incorrecta

    +

    + La solicitud que enviaste no es válida. Verifica que todos los datos + estén correctos y vuelve a intentarlo. +

    + + + + {% if debug %} +
    +
    Información Técnica (Modo Desarrollo)
    +
    {{ debug_info|default:"Información de debug no disponible" }}
    +
    + {% endif %} +
    +
    +{% endblock %} + +{% block extra_js %} + +{% endblock %} From c63ce493470fc111a3adb58f08aff5ca1197f1c4 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:12:14 -0500 Subject: [PATCH 072/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20403=20error?= =?UTF-8?q?=20page=20template=20for=20auction=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/errors/403.html | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 auctions/templates/auctions/errors/403.html diff --git a/auctions/templates/auctions/errors/403.html b/auctions/templates/auctions/errors/403.html new file mode 100644 index 0000000..87dec8d --- /dev/null +++ b/auctions/templates/auctions/errors/403.html @@ -0,0 +1,48 @@ +{% extends "auctions/layout.html" %} +{% load static %} + +{% block title %}Acceso Denegado - Auctions{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block body %} +
    +
    +
    + +
    + +
    403
    +

    Acceso Denegado

    +

    + No tienes permisos para acceder a esta subasta. + Verifica que tengas la autorización necesaria o contacta al administrador. +

    + + + + {% if debug %} +
    +
    Información Técnica (Modo Desarrollo)
    +
    {{ debug_info|default:"Información de debug no disponible" }}
    +
    + {% endif %} +
    +
    +{% endblock %} + +{% block extra_js %} + +{% endblock %} From 1a0aa3bb77e2ea8fdabbd979ad55ecd331f241e0 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:12:26 -0500 Subject: [PATCH 073/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20404=20error?= =?UTF-8?q?=20page=20template=20for=20auction=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/errors/404.html | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 auctions/templates/auctions/errors/404.html diff --git a/auctions/templates/auctions/errors/404.html b/auctions/templates/auctions/errors/404.html new file mode 100644 index 0000000..12e3f12 --- /dev/null +++ b/auctions/templates/auctions/errors/404.html @@ -0,0 +1,70 @@ +{% extends "auctions/layout.html" %} +{% load static %} + +{% block title %}Página No Encontrada - Auctions{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block body %} +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + +
    404
    +

    ¡Oops! Subasta No Encontrada

    +

    + Parece que la página que buscas se ha "subastado" y ya no está disponible. + No te preocupes, ¡tenemos muchas otras subastas increíbles esperándote! +

    + + + +
    +
    ¿Qué puedes hacer ahora?
    +
      +
    • Buscar subastas activas
    • +
    • Explorar categorías
    • +
    • Crear nueva subasta
    • +
    • Ver favoritos
    • +
    +
    + + {% if debug %} +
    +
    Información Técnica (Modo Desarrollo)
    +
    {{ debug_info|default:"Información de debug no disponible" }}
    +
    + {% endif %} +
    +
    +{% endblock %} + +{% block extra_js %} + +{% endblock %} From 6dd61e0c53e1a57feb29de9e95a5f9a8aa5a0401 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:12:36 -0500 Subject: [PATCH 074/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20500=20error?= =?UTF-8?q?=20page=20template=20for=20auction=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/errors/500.html | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 auctions/templates/auctions/errors/500.html diff --git a/auctions/templates/auctions/errors/500.html b/auctions/templates/auctions/errors/500.html new file mode 100644 index 0000000..39e5d41 --- /dev/null +++ b/auctions/templates/auctions/errors/500.html @@ -0,0 +1,48 @@ +{% extends "auctions/layout.html" %} +{% load static %} + +{% block title %}Error del Servidor - Auctions{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block body %} +
    +
    +
    + +
    + +
    500
    +

    ¡Ups! Error del Servidor

    +

    + Algo salió mal en nuestro servidor de subastas. Nuestro equipo técnico + ya está trabajando para solucionarlo. ¡Vuelve pronto! +

    + + + + {% if debug %} +
    +
    Información Técnica (Modo Desarrollo)
    +
    {{ debug_info|default:"Información de debug no disponible" }}
    +
    + {% endif %} +
    +
    +{% endblock %} + +{% block extra_js %} + +{% endblock %} From 9a8bd0fcee0859290671bbdee6fe63fb3819681f Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:12:49 -0500 Subject: [PATCH 075/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20static=20CSS?= =?UTF-8?q?=20support=20and=20styling=20for=20auction=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/auction.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/auctions/templates/auctions/auction.html b/auctions/templates/auctions/auction.html index feabc76..ff0b626 100644 --- a/auctions/templates/auctions/auction.html +++ b/auctions/templates/auctions/auction.html @@ -1,5 +1,12 @@ {% extends "auctions/layout.html" %} +{% load static %} + {% block title %}{{ listing.title }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + {% block body %} {% include 'auctions/components/alert.html' %} From bdb1f0a7ef3dae82bb90928890dd30bc996db90f Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:13:04 -0500 Subject: [PATCH 076/102] =?UTF-8?q?=E2=9C=A8=20feat:=20include=20static=20?= =?UTF-8?q?CSS=20for=20categories=20page=20and=20enhance=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/categories.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/auctions/templates/auctions/categories.html b/auctions/templates/auctions/categories.html index 7ec7467..285b01d 100644 --- a/auctions/templates/auctions/categories.html +++ b/auctions/templates/auctions/categories.html @@ -1,7 +1,13 @@ {% extends "auctions/layout.html" %} {% load auctions_filters %} +{% load static %} {% block title %}Categories - Browse by Interest{% endblock %} + +{% block extra_css %} + +{% endblock %} + {% block body %}
    From 33c0dadcbc4ed668b820b05a1b8b138ac68ebe3b Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:13:14 -0500 Subject: [PATCH 077/102] =?UTF-8?q?=E2=9C=A8=20feat:=20include=20static=20?= =?UTF-8?q?CSS=20for=20auctions=20index=20page=20and=20update=20layout=20s?= =?UTF-8?q?tructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/index.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/auctions/templates/auctions/index.html b/auctions/templates/auctions/index.html index 885b0b7..ff5b760 100644 --- a/auctions/templates/auctions/index.html +++ b/auctions/templates/auctions/index.html @@ -1,10 +1,15 @@ {% extends "auctions/layout.html" %} {% load auctions_filters %} +{% load static %} + +{% block extra_css %} + +{% endblock %} {% block body %} {% include "auctions/components/alert.html" %} -
    +

    From f96ca1a0682b50b30533d13b19f9e25ffddba592 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:13:25 -0500 Subject: [PATCH 078/102] =?UTF-8?q?=E2=9C=A8=20feat:=20refactor=20layout?= =?UTF-8?q?=20template=20to=20improve=20structure=20and=20load=20performan?= =?UTF-8?q?ce,=20including=20navigation=20component=20and=20additional=20C?= =?UTF-8?q?SS=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/layout.html | 302 +++++++----------------- 1 file changed, 88 insertions(+), 214 deletions(-) diff --git a/auctions/templates/auctions/layout.html b/auctions/templates/auctions/layout.html index 8138ced..202dede 100644 --- a/auctions/templates/auctions/layout.html +++ b/auctions/templates/auctions/layout.html @@ -2,218 +2,92 @@ - - - - - - - {% block title %}Auctions{% endblock %} - - - - - - - - - - - - - - - - - - - {% if request.resolver_match.url_name == 'index' %} - - {% elif request.resolver_match.url_name == 'login' %} - - {% elif request.resolver_match.url_name == 'register' %} - - {% elif request.resolver_match.url_name == 'categories' %} - - {% elif request.resolver_match.url_name == 'new_auction' %} - - {% elif request.resolver_match.url_name == 'watchlist' %} - - {% elif request.resolver_match.url_name == 'listing' %} - - {% endif %} - - - - - - - - - - - - - - -
    - {% block body %}{% endblock %} -
    - - - - - - {% include "auctions/components/footer.html" %} - - - - - + + + + + + + {% block title %}Auctions{% endblock %} + + + + + + + + + + + + + + + + + + + + + + {% block extra_css %}{% endblock %} + + + + + + + + + + {% include "auctions/components/navigation.html" %} + + +
    + {% block body %}{% endblock %} +
    + + + + + + {% include "auctions/components/footer.html" %} + + + + + + + + + + {% block extra_js %}{% endblock %} + From 1ab06a1e97ee323b5354eb626fbe758583a71349 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:13:36 -0500 Subject: [PATCH 079/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20static=20CSS?= =?UTF-8?q?=20support=20for=20login=20page=20to=20enhance=20styling=20and?= =?UTF-8?q?=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/login.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/auctions/templates/auctions/login.html b/auctions/templates/auctions/login.html index 0fa6064..0bbc68e 100644 --- a/auctions/templates/auctions/login.html +++ b/auctions/templates/auctions/login.html @@ -1,6 +1,12 @@ {% extends "auctions/layout.html" %} +{% load static %} + {% block title %}Login{% endblock %} +{% block extra_css %} + +{% endblock %} + {% block body %}
    From 448753d39551e49429faadd585221781a1eea87f Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:13:47 -0500 Subject: [PATCH 080/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20static=20CSS?= =?UTF-8?q?=20support=20for=20new=20auction=20page=20to=20enhance=20stylin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/newAuctions.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/auctions/templates/auctions/newAuctions.html b/auctions/templates/auctions/newAuctions.html index 0ff5f8d..b101a41 100644 --- a/auctions/templates/auctions/newAuctions.html +++ b/auctions/templates/auctions/newAuctions.html @@ -2,6 +2,11 @@ {% load static %} {% block title %}Add Auction{% endblock %} + +{% block extra_css %} + +{% endblock %} + {% block body %} {% include "auctions/components/alert.html" %}
    From 4ea1013cbf2df68006dc23f97279e5f089631620 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:13:58 -0500 Subject: [PATCH 081/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20static=20CSS?= =?UTF-8?q?=20support=20for=20registration=20page=20to=20enhance=20styling?= =?UTF-8?q?=20and=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/register.html | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/auctions/templates/auctions/register.html b/auctions/templates/auctions/register.html index 7ecfad8..74bdf3c 100644 --- a/auctions/templates/auctions/register.html +++ b/auctions/templates/auctions/register.html @@ -1,4 +1,12 @@ -{% extends "auctions/layout.html" %} {% block title %}Register{% endblock %} +{% extends "auctions/layout.html" %} +{% load static %} + +{% block title %}Register{% endblock %} + +{% block extra_css %} + +{% endblock %} + {% block body %}
    From b2b8b3331421ab6dd1bd0ee39310e8119f12fae9 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:14:10 -0500 Subject: [PATCH 082/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20static=20CSS?= =?UTF-8?q?=20support=20for=20watch=20list=20page=20to=20enhance=20styling?= =?UTF-8?q?=20and=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templates/auctions/watchList.html | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/auctions/templates/auctions/watchList.html b/auctions/templates/auctions/watchList.html index 311c3db..bc25455 100644 --- a/auctions/templates/auctions/watchList.html +++ b/auctions/templates/auctions/watchList.html @@ -1,5 +1,14 @@ -{% extends "auctions/layout.html" %} {% block title %}Watch List{% endblock %} -{% block body %} {% include "auctions/components/alert.html" %} +{% extends "auctions/layout.html" %} +{% load static %} + +{% block title %}Watch List{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block body %} +{% include "auctions/components/alert.html" %}
    From 1dfc50c0dfef6efb996b8e925f8a0ef3c546e88d Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:14:21 -0500 Subject: [PATCH 083/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20custom=20temp?= =?UTF-8?q?late=20filters=20for=20arithmetic=20operations=20and=20CSS=20cl?= =?UTF-8?q?ass=20assignment=20in=20auction=20forms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/templatetags/auctions_filters.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/auctions/templatetags/auctions_filters.py b/auctions/templatetags/auctions_filters.py index 48c971d..775227a 100644 --- a/auctions/templatetags/auctions_filters.py +++ b/auctions/templatetags/auctions_filters.py @@ -10,3 +10,18 @@ def multiply(value, arg): return int(value) * int(arg) except (ValueError, TypeError): return value + + +@register.filter +def sub(value, arg): + """Subtract arg from value""" + try: + return float(value) - float(arg) + except (ValueError, TypeError): + return 0 + + +@register.filter +def addclass(field, css): + """Add CSS class to form field""" + return field.as_widget(attrs={"class": css}) \ No newline at end of file From 1cf334b6ced8bd4b53860db2a4d3ec86e5c6b145 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:14:47 -0500 Subject: [PATCH 084/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20admin=20confi?= =?UTF-8?q?guration=20file=20for=20dashboard=20settings,=20including=20cha?= =?UTF-8?q?rt=20colors,=20metrics,=20export=20options,=20predictive=20anal?= =?UTF-8?q?ysis,=20and=20alerts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/admin_config.py | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 auctions/admin_config.py diff --git a/auctions/admin_config.py b/auctions/admin_config.py new file mode 100644 index 0000000..88e6d75 --- /dev/null +++ b/auctions/admin_config.py @@ -0,0 +1,48 @@ +""" +Configuración del panel de administración +Configuraciones específicas para el dashboard de BI +""" + +# Configuración de colores para gráficos +CHART_COLORS = { + 'primary': '#667eea', + 'secondary': '#764ba2', + 'success': '#4facfe', + 'info': '#00f2fe', + 'warning': '#f093fb', + 'danger': '#f5576c', + 'light': '#a8edea', + 'dark': '#fed6e3' +} + +# Configuración de métricas +METRICS_CONFIG = { + 'refresh_interval': 30000, # 30 segundos + 'chart_height': 300, + 'table_page_size': 20, + 'max_recent_items': 10 +} + +# Configuración de exportación +EXPORT_CONFIG = { + 'supported_formats': ['csv', 'xlsx'], + 'max_records_per_export': 10000, + 'date_format': '%Y-%m-%d %H:%M:%S' +} + +# Configuración de análisis predictivo +PREDICTION_CONFIG = { + 'min_data_points': 10, + 'confidence_thresholds': { + 'high': 0.8, + 'medium': 0.6, + 'low': 0.4 + } +} + +# Configuración de alertas +ALERT_CONFIG = { + 'low_activity_threshold': 5, # días sin actividad + 'high_bid_threshold': 1000, # pujas muy altas + 'suspicious_activity_threshold': 50 # pujas por usuario por día +} From a81ce2fbcca394af0a1ef9f81c713e6203409b53 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:15:02 -0500 Subject: [PATCH 085/102] =?UTF-8?q?=E2=9C=A8=20feat:=20implement=20admin?= =?UTF-8?q?=20views=20for=20superusers,=20including=20dashboard,=20analyti?= =?UTF-8?q?cs,=20listings=20management,=20user=20management,=20and=20data?= =?UTF-8?q?=20export=20functionalities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/admin_views.py | 442 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 auctions/admin_views.py diff --git a/auctions/admin_views.py b/auctions/admin_views.py new file mode 100644 index 0000000..46aaac0 --- /dev/null +++ b/auctions/admin_views.py @@ -0,0 +1,442 @@ +""" +Vistas del panel de administración para superusuarios +Dashboard de Business Intelligence para gestión de subastas +""" + +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth.decorators import user_passes_test +from django.http import JsonResponse +from django.utils import timezone +from django.db.models import Q, Count, Sum, Avg +from django.core.paginator import Paginator +from django.contrib import messages +from .models import Listing, Bid, Comment, User, Watchlist +from .analytics import AuctionAnalytics +from .error_views import test_404_view, test_500_view, test_403_view +import json + + +def is_superuser(user): + """Verificar si el usuario es superusuario""" + return user.is_superuser + + +@user_passes_test(is_superuser) +def admin_dashboard(request): + """ + Dashboard principal de administración con métricas y gráficos + """ + try: + analytics = AuctionAnalytics() + dashboard_data = analytics.get_kpi_dashboard_data() + + # Obtener subastas recientes + recent_listings = Listing.objects.select_related("user").order_by("-created")[ + :10 + ] + + # Obtener pujas recientes + recent_bids = Bid.objects.select_related("user", "listing").order_by("-id")[:10] + + # Obtener usuarios más activos + top_users = ( + User.objects.annotate( + total_activity=Count("bids") + Count("listings") + Count("comments") + ) + .filter(total_activity__gt=0) + .order_by("-total_activity")[:5] + ) + + context = { + "dashboard_data": dashboard_data, + "recent_listings": recent_listings, + "recent_bids": recent_bids, + "top_users": top_users, + "page_title": "Dashboard de Administración", + } + + return render(request, "auctions/admin/dashboard.html", context) + except Exception as e: + # En caso de error, mostrar una página simple de administración + context = { + "page_title": "Dashboard de Administración", + "error": str(e), + "dashboard_data": { + "metrics": { + "total_listings": Listing.objects.count(), + "active_listings": Listing.objects.filter(active=True).count(), + "total_users": User.objects.count(), + "total_bids": Bid.objects.count(), + } + }, + "recent_listings": [], + "recent_bids": [], + "top_users": [], + } + return render(request, "auctions/admin/dashboard.html", context) + + +@user_passes_test(is_superuser) +def admin_analytics(request): + """ + Página de análisis avanzados y reportes + """ + analytics = AuctionAnalytics() + + # Análisis por categorías + category_analysis = analytics.get_category_analysis() + + # Análisis de comportamiento de usuarios + user_behavior = analytics.get_user_behavior_analysis() + + # Análisis de pujas + bid_analysis = analytics.get_bid_analysis() + + # Tendencias del mercado + market_trends = analytics.get_market_trends() + + context = { + "category_analysis": category_analysis, + "user_behavior": user_behavior, + "bid_analysis": bid_analysis, + "market_trends": market_trends, + "page_title": "Análisis Avanzados", + } + + return render(request, "auctions/admin/analytics.html", context) + + +@user_passes_test(is_superuser) +def admin_listings(request): + """ + Gestión de subastas con filtros y búsqueda + """ + listings = Listing.objects.select_related("user").prefetch_related("bids") + + # Filtros + search_query = request.GET.get("search", "") + category_filter = request.GET.get("category", "") + status_filter = request.GET.get("status", "") + sort_by = request.GET.get("sort", "-created") + + if search_query: + listings = listings.filter( + Q(title__icontains=search_query) + | Q(description__icontains=search_query) + | Q(user__username__icontains=search_query) + ) + + if category_filter: + listings = listings.filter(category=category_filter) + + if status_filter == "active": + listings = listings.filter(active=True) + elif status_filter == "inactive": + listings = listings.filter(active=False) + elif status_filter == "with_bids": + listings = listings.filter(bids__isnull=False).distinct() + + listings = listings.order_by(sort_by) + + # Paginación + paginator = Paginator(listings, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + # Obtener categorías únicas para el filtro + categories = ( + Listing.objects.values_list("category", flat=True) + .distinct() + .exclude(category="") + ) + + context = { + "page_obj": page_obj, + "search_query": search_query, + "category_filter": category_filter, + "status_filter": status_filter, + "sort_by": sort_by, + "categories": categories, + "page_title": "Gestión de Subastas", + } + + return render(request, "auctions/admin/listings.html", context) + + +@user_passes_test(is_superuser) +def admin_users(request): + """ + Gestión de usuarios con estadísticas + """ + users = User.objects.annotate( + listings_count=Count("listings"), + bids_count=Count("bids"), + comments_count=Count("comments"), + watchlist_count=Count("watchlist", filter=Q(watchlist__active=True)), + ).order_by("-date_joined") + + # Filtros + search_query = request.GET.get("search", "") + sort_by = request.GET.get("sort", "-date_joined") + + if search_query: + users = users.filter( + Q(username__icontains=search_query) + | Q(email__icontains=search_query) + | Q(first_name__icontains=search_query) + | Q(last_name__icontains=search_query) + ) + + users = users.order_by(sort_by) + + # Paginación + paginator = Paginator(users, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + "search_query": search_query, + "sort_by": sort_by, + "page_title": "Gestión de Usuarios", + } + + return render(request, "auctions/admin/users.html", context) + + +@user_passes_test(is_superuser) +def admin_listing_detail(request, listing_id): + """ + Detalle de una subasta con análisis predictivo + """ + listing = get_object_or_404(Listing, id=listing_id) + analytics = AuctionAnalytics() + + # Obtener pujas de la subasta + bids = listing.bids.select_related("user").order_by("-amount") + + # Obtener comentarios + comments = listing.comments.select_related("user").order_by("-created") + + # Análisis predictivo + prediction = analytics.predict_auction_success(listing_id) + + # Estadísticas de la subasta + listing_stats = { + "total_bids": bids.count(), + "unique_bidders": bids.values("user").distinct().count(), + "price_increase": 0, + "days_active": (timezone.now() - listing.created).days, + } + + if listing.current_bid and listing.starting_bid: + listing_stats["price_increase"] = ( + (listing.current_bid - listing.starting_bid) / listing.starting_bid * 100 + ) + + context = { + "listing": listing, + "bids": bids, + "comments": comments, + "prediction": prediction, + "listing_stats": listing_stats, + "page_title": f"Detalle: {listing.title}", + } + + return render(request, "auctions/admin/listing_detail.html", context) + + +@user_passes_test(is_superuser) +def admin_reports(request): + """ + Generación de reportes y exportación de datos + """ + analytics = AuctionAnalytics() + + # Métricas básicas + basic_metrics = analytics.get_basic_metrics() + + # Análisis temporal + time_analysis = analytics.get_time_series_data(90) # Últimos 90 días + + # Análisis de categorías + category_analysis = analytics.get_category_analysis() + + # Análisis de usuarios + user_analysis = analytics.get_user_behavior_analysis() + + context = { + "basic_metrics": basic_metrics, + "time_analysis": time_analysis, + "category_analysis": category_analysis, + "user_analysis": user_analysis, + "page_title": "Reportes y Exportación", + } + + return render(request, "auctions/admin/reports.html", context) + + +@user_passes_test(is_superuser) +def admin_api_metrics(request): + """ + API endpoint para métricas en tiempo real (AJAX) + """ + analytics = AuctionAnalytics() + metrics = analytics.get_basic_metrics() + + return JsonResponse(metrics) + + +@user_passes_test(is_superuser) +def admin_api_charts(request): + """ + API endpoint para datos de gráficos (AJAX) + """ + analytics = AuctionAnalytics() + chart_type = request.GET.get("type", "trends") + + if chart_type == "trends": + data = analytics.get_time_series_data(30) + elif chart_type == "categories": + data = analytics.get_category_analysis() + elif chart_type == "bids": + data = analytics.get_bid_analysis() + else: + data = {} + + return JsonResponse(data, safe=False) + + +@user_passes_test(is_superuser) +def admin_toggle_listing_status(request, listing_id): + """ + Activar/desactivar una subasta + """ + if request.method == "POST": + listing = get_object_or_404(Listing, id=listing_id) + listing.active = not listing.active + listing.save() + + status = "activada" if listing.active else "desactivada" + messages.success(request, f"Subasta {status} exitosamente.") + + return redirect("admin_listing_detail", listing_id=listing_id) + + +@user_passes_test(is_superuser) +def admin_export_data(request): + """ + Exportar datos del sistema + """ + import csv + from django.http import HttpResponse + + export_type = request.GET.get("type", "listings") + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="{export_type}_export.csv"' + + writer = csv.writer(response) + + if export_type == "listings": + writer.writerow( + [ + "ID", + "Título", + "Usuario", + "Precio Inicial", + "Precio Actual", + "Categoría", + "Activa", + "Fecha Creación", + ] + ) + for listing in Listing.objects.select_related("user"): + writer.writerow( + [ + listing.id, + listing.title, + listing.user.username, + listing.starting_bid, + listing.current_bid, + listing.category, + listing.active, + listing.created.strftime("%Y-%m-%d %H:%M:%S"), + ] + ) + + elif export_type == "bids": + writer.writerow(["ID", "Usuario", "Subasta", "Monto", "Fecha"]) + for bid in Bid.objects.select_related("user", "listing"): + writer.writerow( + [ + bid.id, + bid.user.username, + bid.listing.title, + bid.amount, + bid.listing.created.strftime("%Y-%m-%d %H:%M:%S"), + ] + ) + + elif export_type == "users": + writer.writerow( + [ + "ID", + "Usuario", + "Email", + "Nombre", + "Apellido", + "Fecha Registro", + "Es Staff", + "Es Superusuario", + ] + ) + for user in User.objects.all(): + writer.writerow( + [ + user.id, + user.username, + user.email, + user.first_name, + user.last_name, + user.date_joined.strftime("%Y-%m-%d %H:%M:%S"), + user.is_staff, + user.is_superuser, + ] + ) + + return response + + +def catch_all_404_view(request, path): + """ + Vista que captura todas las URLs no encontradas y muestra nuestra página 404 personalizada + """ + from .error_views import custom_404_view + + return custom_404_view(request, None) + + +def test_admin_dashboard(request): + """ + Vista de prueba para el dashboard de administración (sin autenticación) + """ + try: + context = { + "page_title": "Dashboard de Administración - Prueba", + "dashboard_data": { + "metrics": { + "total_listings": Listing.objects.count(), + "active_listings": Listing.objects.filter(active=True).count(), + "total_users": User.objects.count(), + "total_bids": Bid.objects.count(), + } + }, + "recent_listings": [], + "recent_bids": [], + "top_users": [], + } + return render(request, "auctions/admin/dashboard.html", context) + except Exception as e: + return render(request, "auctions/errors/500.html", {"error": str(e)}) From 653a0e8b517265d481f033a2f4643fea9f93ecf4 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:15:16 -0500 Subject: [PATCH 086/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20data=20analyt?= =?UTF-8?q?ics=20module=20for=20auction=20insights,=20including=20metrics,?= =?UTF-8?q?=20time=20series=20analysis,=20user=20behavior,=20bid=20analysi?= =?UTF-8?q?s,=20and=20market=20trends=20visualization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/analytics.py | 407 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 auctions/analytics.py diff --git a/auctions/analytics.py b/auctions/analytics.py new file mode 100644 index 0000000..efcba02 --- /dev/null +++ b/auctions/analytics.py @@ -0,0 +1,407 @@ +""" +Módulo de análisis de datos para el dashboard de Business Intelligence +Implementa métodos de data science para análisis de subastas +""" + +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from django.db.models import Count, Sum, Avg, Q, F +from django.utils import timezone +from django.contrib.auth.models import User +from .models import Listing, Bid, Comment, Watchlist +from sklearn.linear_model import LinearRegression +from sklearn.preprocessing import StandardScaler +import plotly.graph_objects as go +import plotly.express as px +from plotly.offline import plot +import json + + +class AuctionAnalytics: + """ + Clase principal para análisis de datos de subastas + Implementa métodos de data science para insights de negocio + """ + + def __init__(self): + self.timezone = timezone.now() + + def get_basic_metrics(self): + """ + Métricas básicas del sistema de subastas + """ + total_listings = Listing.objects.count() + active_listings = Listing.objects.filter(active=True).count() + total_users = User.objects.count() + total_bids = Bid.objects.count() + total_comments = Comment.objects.count() + total_watchlist_items = Watchlist.objects.filter(active=True).count() + + # Calcular valor total de subastas + total_auction_value = ( + Listing.objects.aggregate(total_value=Sum("current_bid"))["total_value"] + or 0 + ) + + # Calcular valor promedio de subastas + avg_auction_value = ( + Listing.objects.filter(current_bid__isnull=False).aggregate( + avg_value=Avg("current_bid") + )["avg_value"] + or 0 + ) + + return { + "total_listings": total_listings, + "active_listings": active_listings, + "total_users": total_users, + "total_bids": total_bids, + "total_comments": total_comments, + "total_watchlist_items": total_watchlist_items, + "total_auction_value": float(total_auction_value), + "avg_auction_value": float(avg_auction_value), + "conversion_rate": ( + (total_bids / total_listings * 100) if total_listings > 0 else 0 + ), + } + + def get_time_series_data(self, days=30): + """ + Datos de series temporales para análisis de tendencias + """ + end_date = self.timezone + start_date = end_date - timedelta(days=days) + + # Listings por día + listings_by_day = ( + Listing.objects.filter(created__gte=start_date) + .extra(select={"day": "date(created)"}) + .values("day") + .annotate(count=Count("id")) + .order_by("day") + ) + + # Bids por día + bids_by_day = ( + Bid.objects.filter(listing__created__gte=start_date) + .extra(select={"day": "date(listing__created)"}) + .values("day") + .annotate(count=Count("id"), total_amount=Sum("amount")) + .order_by("day") + ) + + # Usuarios registrados por día + users_by_day = ( + User.objects.filter(date_joined__gte=start_date) + .extra(select={"day": "date(date_joined)"}) + .values("day") + .annotate(count=Count("id")) + .order_by("day") + ) + + return { + "listings": list(listings_by_day), + "bids": list(bids_by_day), + "users": list(users_by_day), + } + + def get_category_analysis(self): + """ + Análisis por categorías de subastas + """ + category_data = ( + Listing.objects.values("category") + .annotate( + count=Count("id"), + avg_starting_bid=Avg("starting_bid"), + avg_current_bid=Avg("current_bid"), + total_bids=Count("bids"), + avg_bids_per_listing=Count("bids") / Count("id"), + ) + .order_by("-count") + ) + + return list(category_data) + + def get_user_behavior_analysis(self): + """ + Análisis del comportamiento de usuarios + """ + # Top usuarios por actividad + top_bidders = User.objects.annotate( + bid_count=Count("bids"), + total_bid_amount=Sum("bids__amount"), + listings_created=Count("listings"), + comments_made=Count("comments"), + ).order_by("-bid_count")[:10] + + # Análisis de engagement + user_engagement = ( + User.objects.annotate( + total_activity=Count("bids") + Count("listings") + Count("comments"), + watchlist_items=Count("watchlist", filter=Q(watchlist__active=True)), + ) + .filter(total_activity__gt=0) + .order_by("-total_activity") + ) + + return { + "top_bidders": list(top_bidders.values()), + "user_engagement": list(user_engagement.values()), + } + + def get_bid_analysis(self): + """ + Análisis detallado de pujas + """ + # Distribución de pujas por rango de valores + bid_ranges = [ + (0, 50, "0-50"), + (50, 100, "50-100"), + (100, 500, "100-500"), + (500, 1000, "500-1000"), + (1000, float("inf"), "1000+"), + ] + + bid_distribution = [] + for min_val, max_val, label in bid_ranges: + if max_val == float("inf"): + count = Bid.objects.filter(amount__gte=min_val).count() + else: + count = Bid.objects.filter( + amount__gte=min_val, amount__lt=max_val + ).count() + bid_distribution.append({"range": label, "count": count}) + + # Análisis de competencia por listing + listing_competition = ( + Listing.objects.annotate( + bid_count=Count("bids"), + bid_increase=( + (F("current_bid") - F("starting_bid")) / F("starting_bid") * 100 + ), + ) + .filter(bid_count__gt=0) + .order_by("-bid_count") + ) + + return { + "bid_distribution": bid_distribution, + "listing_competition": list(listing_competition.values()), + } + + def predict_auction_success(self, listing_id): + """ + Predicción de éxito de una subasta usando machine learning + """ + try: + # Obtener datos históricos para entrenar el modelo + historical_data = ( + Listing.objects.filter(current_bid__isnull=False) + .annotate( + bid_count=Count("bids"), + days_active=(self.timezone - F("created")).days, + price_increase=( + (F("current_bid") - F("starting_bid")) / F("starting_bid") * 100 + ), + ) + .values("starting_bid", "bid_count", "days_active", "price_increase") + ) + + if len(historical_data) < 10: + return {"error": "Datos insuficientes para predicción"} + + # Preparar datos para el modelo + df = pd.DataFrame(historical_data) + X = df[["starting_bid", "bid_count", "days_active"]] + y = df["price_increase"] + + # Entrenar modelo + scaler = StandardScaler() + X_scaled = scaler.fit_transform(X) + + model = LinearRegression() + model.fit(X_scaled, y) + + # Obtener datos de la subasta actual + listing = Listing.objects.get(id=listing_id) + current_bid_count = listing.bids.count() + days_active = (self.timezone - listing.created).days + + # Hacer predicción + prediction_data = np.array( + [[float(listing.starting_bid), current_bid_count, days_active]] + ) + prediction_scaled = scaler.transform(prediction_data) + predicted_increase = model.predict(prediction_scaled)[0] + + return { + "predicted_price_increase": float(predicted_increase), + "confidence": "medium", # Simplificado para este ejemplo + "recommendations": self._get_recommendations( + predicted_increase, current_bid_count + ), + } + + except Exception as e: + return {"error": f"Error en predicción: {str(e)}"} + + def _get_recommendations(self, price_increase, bid_count): + """ + Generar recomendaciones basadas en el análisis + """ + recommendations = [] + + if price_increase < 10: + recommendations.append("Considera ajustar el precio inicial") + elif price_increase > 100: + recommendations.append( + "Excelente rendimiento, considera estrategias similares" + ) + + if bid_count < 3: + recommendations.append( + "Promociona más la subasta para aumentar participación" + ) + elif bid_count > 10: + recommendations.append("Alta competencia, considera extender el tiempo") + + return recommendations + + def get_market_trends(self): + """ + Análisis de tendencias del mercado + """ + # Análisis mensual + monthly_data = ( + Listing.objects.extra(select={"month": 'strftime("%Y-%m", created)'}) + .values("month") + .annotate( + listings_count=Count("id"), + avg_starting_bid=Avg("starting_bid"), + avg_current_bid=Avg("current_bid"), + total_bids=Count("bids"), + ) + .order_by("month") + ) + + # Análisis de estacionalidad + seasonal_data = ( + Listing.objects.extra(select={"month": 'strftime("%m", created)'}) + .values("month") + .annotate(count=Count("id")) + .order_by("month") + ) + + return { + "monthly_trends": list(monthly_data), + "seasonal_patterns": list(seasonal_data), + } + + def generate_plotly_charts(self): + """ + Generar gráficos interactivos con Plotly + """ + charts = {} + + # 1. Gráfico de líneas - Tendencias temporales + time_data = self.get_time_series_data(30) + if time_data["listings"]: + fig_trends = go.Figure() + fig_trends.add_trace( + go.Scatter( + x=[item["day"] for item in time_data["listings"]], + y=[item["count"] for item in time_data["listings"]], + mode="lines+markers", + name="Listings", + line=dict(color="#007bff"), + ) + ) + fig_trends.add_trace( + go.Scatter( + x=[item["day"] for item in time_data["bids"]], + y=[item["count"] for item in time_data["bids"]], + mode="lines+markers", + name="Bids", + line=dict(color="#28a745"), + ) + ) + fig_trends.update_layout( + title="Tendencias de Actividad (30 días)", + xaxis_title="Fecha", + yaxis_title="Cantidad", + template="plotly_white", + ) + charts["trends"] = plot( + fig_trends, output_type="div", include_plotlyjs=False + ) + + # 2. Gráfico de barras - Categorías + category_data = self.get_category_analysis() + if category_data: + fig_categories = px.bar( + x=[item["category"] or "Sin categoría" for item in category_data], + y=[item["count"] for item in category_data], + title="Listings por Categoría", + labels={"x": "Categoría", "y": "Cantidad"}, + ) + charts["categories"] = plot( + fig_categories, output_type="div", include_plotlyjs=False + ) + + # 3. Gráfico de dispersión - Precio vs Pujas + listing_data = ( + Listing.objects.filter(current_bid__isnull=False) + .annotate(bid_count=Count("bids")) + .values("starting_bid", "current_bid", "bid_count") + ) + + if listing_data: + df = pd.DataFrame(list(listing_data)) + fig_scatter = px.scatter( + df, + x="starting_bid", + y="current_bid", + size="bid_count", + title="Precio Inicial vs Precio Actual", + labels={ + "starting_bid": "Precio Inicial", + "current_bid": "Precio Actual", + }, + hover_data=["bid_count"], + ) + charts["price_analysis"] = plot( + fig_scatter, output_type="div", include_plotlyjs=False + ) + + return charts + + def get_kpi_dashboard_data(self): + """ + Datos consolidados para el dashboard principal + """ + metrics = self.get_basic_metrics() + time_data = self.get_time_series_data(7) # Últimos 7 días + category_data = self.get_category_analysis() + + # Calcular métricas de crecimiento + current_week_listings = sum(item["count"] for item in time_data["listings"]) + previous_week = self.timezone - timedelta(days=14) + previous_week_data = Listing.objects.filter( + created__gte=previous_week, created__lt=self.timezone - timedelta(days=7) + ).count() + + growth_rate = ( + ((current_week_listings - previous_week_data) / previous_week_data * 100) + if previous_week_data > 0 + else 0 + ) + + return { + "metrics": metrics, + "growth_rate": growth_rate, + "top_categories": category_data[:5], + "recent_activity": time_data, + "charts": self.generate_plotly_charts(), + } From b1e88fef4c19ed19556bcb4272d1abf380e9da09 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:15:48 -0500 Subject: [PATCH 087/102] =?UTF-8?q?=E2=9C=A8=20feat:=20introduce=20data=20?= =?UTF-8?q?processing=20and=20reporting=20utilities=20for=20auction=20anal?= =?UTF-8?q?ytics,=20including=20user=20engagement=20scoring,=20market=20vo?= =?UTF-8?q?latility=20calculation,=20and=20anomaly=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/data_utils.py | 333 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 auctions/data_utils.py diff --git a/auctions/data_utils.py b/auctions/data_utils.py new file mode 100644 index 0000000..570c97f --- /dev/null +++ b/auctions/data_utils.py @@ -0,0 +1,333 @@ +""" +Utilidades para análisis de datos y Business Intelligence +Funciones auxiliares para el dashboard de administración +""" + +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from django.utils import timezone +from django.db.models import Q, Count, Sum, Avg, Max, Min +from .models import Listing, Bid, Comment, User, Watchlist + + +class DataProcessor: + """ + Clase para procesamiento y análisis de datos + """ + + @staticmethod + def calculate_growth_rate(current_value, previous_value): + """ + Calcular tasa de crecimiento entre dos valores + """ + if previous_value == 0: + return 0 + return ((current_value - previous_value) / previous_value) * 100 + + @staticmethod + def get_time_periods(days=30): + """ + Obtener períodos de tiempo para análisis + """ + end_date = timezone.now() + start_date = end_date - timedelta(days=days) + + return {"start": start_date, "end": end_date, "days": days} + + @staticmethod + def calculate_engagement_score(user): + """ + Calcular score de engagement de un usuario + """ + listings_count = user.listings.count() + bids_count = user.bids.count() + comments_count = user.comments.count() + watchlist_count = user.watchlist.filter(active=True).count() + + # Peso de cada actividad + weights = {"listings": 3, "bids": 2, "comments": 1, "watchlist": 1} + + score = ( + listings_count * weights["listings"] + + bids_count * weights["bids"] + + comments_count * weights["comments"] + + watchlist_count * weights["watchlist"] + ) + + return score + + @staticmethod + def detect_anomalies(data, threshold=2): + """ + Detectar anomalías en los datos usando desviación estándar + """ + if len(data) < 3: + return [] + + mean = np.mean(data) + std = np.std(data) + + anomalies = [] + for i, value in enumerate(data): + if abs(value - mean) > threshold * std: + anomalies.append( + {"index": i, "value": value, "deviation": abs(value - mean) / std} + ) + + return anomalies + + @staticmethod + def calculate_market_volatility(listings_data): + """ + Calcular volatilidad del mercado basada en precios + """ + if not listings_data: + return 0 + + prices = [ + listing["current_bid"] or listing["starting_bid"] + for listing in listings_data + ] + if len(prices) < 2: + return 0 + + returns = [] + for i in range(1, len(prices)): + if prices[i - 1] != 0: + returns.append((prices[i] - prices[i - 1]) / prices[i - 1]) + + if not returns: + return 0 + + return np.std(returns) * 100 # Volatilidad como porcentaje + + +class ReportGenerator: + """ + Generador de reportes y análisis + """ + + @staticmethod + def generate_user_activity_report(user_id=None, days=30): + """ + Generar reporte de actividad de usuarios + """ + time_period = DataProcessor.get_time_periods(days) + + if user_id: + users = User.objects.filter(id=user_id) + else: + users = User.objects.all() + + report_data = [] + for user in users: + # Actividad en el período + listings = user.listings.filter(created__gte=time_period["start"]) + bids = user.bids.filter(listing__created__gte=time_period["start"]) + comments = user.comments.filter(created__gte=time_period["start"]) + + # Métricas + total_activity = listings.count() + bids.count() + comments.count() + engagement_score = DataProcessor.calculate_engagement_score(user) + + report_data.append( + { + "user_id": user.id, + "username": user.username, + "email": user.email, + "listings_created": listings.count(), + "bids_made": bids.count(), + "comments_made": comments.count(), + "total_activity": total_activity, + "engagement_score": engagement_score, + "last_activity": user.last_login or user.date_joined, + } + ) + + return sorted(report_data, key=lambda x: x["engagement_score"], reverse=True) + + @staticmethod + def generate_market_analysis(days=30): + """ + Generar análisis del mercado + """ + time_period = DataProcessor.get_time_periods(days) + + # Datos de subastas + listings = Listing.objects.filter(created__gte=time_period["start"]) + + # Métricas básicas + total_listings = listings.count() + active_listings = listings.filter(active=True).count() + closed_listings = listings.filter(active=False).count() + + # Análisis de precios + price_data = listings.filter(current_bid__isnull=False).values( + "starting_bid", "current_bid" + ) + if price_data: + avg_starting = sum(item["starting_bid"] for item in price_data) / len( + price_data + ) + avg_current = sum(item["current_bid"] for item in price_data) / len( + price_data + ) + price_increase = ( + ((avg_current - avg_starting) / avg_starting * 100) + if avg_starting > 0 + else 0 + ) + else: + avg_starting = avg_current = price_increase = 0 + + # Análisis de competencia + competition_data = listings.annotate(bid_count=Count("bids")).filter( + bid_count__gt=0 + ) + + avg_competition = competition_data.aggregate(avg=Avg("bid_count"))["avg"] or 0 + + # Análisis de categorías + category_analysis = ( + listings.values("category") + .annotate(count=Count("id"), avg_price=Avg("current_bid")) + .order_by("-count") + ) + + return { + "period": f"{days} días", + "total_listings": total_listings, + "active_listings": active_listings, + "closed_listings": closed_listings, + "avg_starting_price": round(avg_starting, 2), + "avg_current_price": round(avg_current, 2), + "price_increase_percent": round(price_increase, 2), + "avg_competition": round(avg_competition, 2), + "category_breakdown": list(category_analysis), + "market_volatility": DataProcessor.calculate_market_volatility( + list(price_data) + ), + } + + @staticmethod + def generate_performance_metrics(days=30): + """ + Generar métricas de rendimiento + """ + time_period = DataProcessor.get_time_periods(days) + + # Métricas de conversión + total_listings = Listing.objects.filter( + created__gte=time_period["start"] + ).count() + listings_with_bids = ( + Listing.objects.filter( + created__gte=time_period["start"], bids__isnull=False + ) + .distinct() + .count() + ) + + conversion_rate = ( + (listings_with_bids / total_listings * 100) if total_listings > 0 else 0 + ) + + # Métricas de engagement + total_users = User.objects.filter(date_joined__gte=time_period["start"]).count() + active_users = ( + User.objects.filter( + Q(listings__created__gte=time_period["start"]) + | Q(bids__listing__created__gte=time_period["start"]) + | Q(comments__created__gte=time_period["start"]) + ) + .distinct() + .count() + ) + + user_engagement_rate = ( + (active_users / total_users * 100) if total_users > 0 else 0 + ) + + # Métricas de retención + returning_users = ( + User.objects.filter( + Q(listings__created__gte=time_period["start"]) + | Q(bids__listing__created__gte=time_period["start"]) + ) + .annotate(activity_count=Count("listings") + Count("bids")) + .filter(activity_count__gt=1) + .count() + ) + + retention_rate = ( + (returning_users / active_users * 100) if active_users > 0 else 0 + ) + + return { + "conversion_rate": round(conversion_rate, 2), + "user_engagement_rate": round(user_engagement_rate, 2), + "retention_rate": round(retention_rate, 2), + "total_listings": total_listings, + "active_users": active_users, + "returning_users": returning_users, + } + + +class AlertSystem: + """ + Sistema de alertas para el dashboard + """ + + @staticmethod + def check_low_activity_alert(): + """ + Verificar alerta de baja actividad + """ + threshold_days = 7 + cutoff_date = timezone.now() - timedelta(days=threshold_days) + + recent_listings = Listing.objects.filter(created__gte=cutoff_date).count() + recent_bids = Bid.objects.filter(listing__created__gte=cutoff_date).count() + + if recent_listings < 5 or recent_bids < 10: + return { + "type": "warning", + "message": f"Baja actividad detectada: {recent_listings} subastas, {recent_bids} pujas en los últimos {threshold_days} días", + "severity": "medium", + } + + return None + + @staticmethod + def check_high_value_alert(): + """ + Verificar alerta de pujas muy altas + """ + high_value_bids = Bid.objects.filter(amount__gt=10000).count() + + if high_value_bids > 0: + return { + "type": "info", + "message": f"{high_value_bids} pujas de alto valor (>$10,000) detectadas", + "severity": "low", + } + + return None + + @staticmethod + def get_all_alerts(): + """ + Obtener todas las alertas activas + """ + alerts = [] + + low_activity = AlertSystem.check_low_activity_alert() + if low_activity: + alerts.append(low_activity) + + high_value = AlertSystem.check_high_value_alert() + if high_value: + alerts.append(high_value) + + return alerts From b86ef12b4494a6a4144369ba0071f3e6543b4646 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:16:03 -0500 Subject: [PATCH 088/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20custom=20erro?= =?UTF-8?q?r=20handling=20views=20for=20400,=20403,=20404,=20and=20500=20e?= =?UTF-8?q?rrors,=20including=20debug=20information=20for=20development=20?= =?UTF-8?q?and=20user-friendly=20pages=20for=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/error_views.py | 192 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 auctions/error_views.py diff --git a/auctions/error_views.py b/auctions/error_views.py new file mode 100644 index 0000000..ecf3579 --- /dev/null +++ b/auctions/error_views.py @@ -0,0 +1,192 @@ +""" +Vistas personalizadas para manejo de errores +Incluye funcionalidad de debug en desarrollo y páginas personalizadas en producción +""" + +from django.shortcuts import render +from django.http import HttpResponse +from django.conf import settings +from django.views.decorators.http import require_http_methods +import traceback +import sys + + +def custom_404_view(request, exception=None): + """ + Vista personalizada para error 404 + Muestra información de debug en desarrollo y página personalizada en producción + """ + context = { + "debug": settings.DEBUG, + "request_path": request.path, + "request_method": request.method, + } + + # En modo desarrollo, agregar información técnica + if settings.DEBUG: + debug_info = f""" +Request Method: {request.method} +Request URL: {request.build_absolute_uri()} +Raised by: {getattr(exception, '__class__', 'Unknown')} + +Using the URLconf defined in {settings.ROOT_URLCONF}, Django tried these URL patterns: + +{_get_url_patterns_info()} + +The current path, {request.path}, matched the last one. + +You're seeing this error because you have DEBUG = True in your Django settings file. +Change that to False, and Django will display a standard 404 page. + """ + context["debug_info"] = debug_info.strip() + + return render(request, "auctions/errors/404.html", context, status=404) + + +def custom_500_view(request): + """ + Vista personalizada para error 500 + Muestra información de debug en desarrollo y página personalizada en producción + """ + context = { + "debug": settings.DEBUG, + "request_path": request.path, + "request_method": request.method, + } + + # En modo desarrollo, agregar información técnica + if settings.DEBUG: + exc_type, exc_value, exc_traceback = sys.exc_info() + if exc_traceback: + debug_info = f""" +Request Method: {request.method} +Request URL: {request.build_absolute_uri()} +Exception Type: {exc_type.__name__ if exc_type else 'Unknown'} +Exception Value: {str(exc_value) if exc_value else 'Unknown'} + +Traceback: +{traceback.format_exc()} + +You're seeing this error because you have DEBUG = True in your Django settings file. + """ + context["debug_info"] = debug_info.strip() + + return render(request, "auctions/errors/500.html", context, status=500) + + +def custom_403_view(request, exception=None): + """ + Vista personalizada para error 403 (Forbidden) + """ + context = { + "debug": settings.DEBUG, + "request_path": request.path, + "request_method": request.method, + } + + if settings.DEBUG: + debug_info = f""" +Request Method: {request.method} +Request URL: {request.build_absolute_uri()} +Exception: {getattr(exception, '__class__', 'Unknown')} + +You don't have permission to access this resource. + """ + context["debug_info"] = debug_info.strip() + + return render(request, "auctions/errors/403.html", context, status=403) + + +def custom_400_view(request, exception=None): + """ + Vista personalizada para error 400 (Bad Request) + """ + context = { + "debug": settings.DEBUG, + "request_path": request.path, + "request_method": request.method, + } + + if settings.DEBUG: + debug_info = f""" +Request Method: {request.method} +Request URL: {request.build_absolute_uri()} +Exception: {getattr(exception, '__class__', 'Unknown')} + +Bad Request - The request could not be understood by the server. + """ + context["debug_info"] = debug_info.strip() + + return render(request, "auctions/errors/400.html", context, status=400) + + +def _get_url_patterns_info(): + """ + Obtener información sobre los patrones de URL disponibles + """ + try: + from django.urls import get_resolver + + resolver = get_resolver() + patterns = [] + + def extract_patterns(url_patterns, prefix=""): + for pattern in url_patterns: + if hasattr(pattern, "url_patterns"): + # Es un include + extract_patterns( + pattern.url_patterns, prefix + str(pattern.pattern) + ) + else: + # Es un patrón de URL + patterns.append( + f"{prefix}{pattern.pattern} [{getattr(pattern, 'name', 'No name')}]" + ) + + extract_patterns(resolver.url_patterns) + return "\n".join(patterns) + except Exception: + return "No se pudieron obtener los patrones de URL" + + +@require_http_methods(["GET"]) +def test_404_view(request): + """ + Vista para probar el error 404 (solo en desarrollo) + """ + if not settings.DEBUG: + return HttpResponse( + "Esta vista solo está disponible en modo desarrollo", status=404 + ) + + from django.http import Http404 + + raise Http404("Esta es una página de prueba para el error 404") + + +@require_http_methods(["GET"]) +def test_500_view(request): + """ + Vista para probar el error 500 (solo en desarrollo) + """ + if not settings.DEBUG: + return HttpResponse( + "Esta vista solo está disponible en modo desarrollo", status=404 + ) + + raise Exception("Esta es una excepción de prueba para el error 500") + + +@require_http_methods(["GET"]) +def test_403_view(request): + """ + Vista para probar el error 403 (solo en desarrollo) + """ + if not settings.DEBUG: + return HttpResponse( + "Esta vista solo está disponible en modo desarrollo", status=404 + ) + + from django.core.exceptions import PermissionDenied + + raise PermissionDenied("Esta es una excepción de prueba para el error 403") From 783b4b5bc719c8db39b8ea5f7c50129867be1771 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:16:17 -0500 Subject: [PATCH 089/102] =?UTF-8?q?=E2=9C=A8=20feat:=20implement=20custom?= =?UTF-8?q?=20error=20handling=20middleware=20for=20404,=20403,=20and=2050?= =?UTF-8?q?0=20errors,=20enhancing=20user=20experience=20with=20personaliz?= =?UTF-8?q?ed=20error=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/middleware.py | 91 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 auctions/middleware.py diff --git a/auctions/middleware.py b/auctions/middleware.py new file mode 100644 index 0000000..9224781 --- /dev/null +++ b/auctions/middleware.py @@ -0,0 +1,91 @@ +""" +Middleware personalizado para manejo de errores +Intercepta errores 404 y usa nuestros handlers personalizados +""" + +from django.http import HttpResponseNotFound +from django.conf import settings +from django.urls import resolve, Resolver404 +from django.core.exceptions import PermissionDenied +from django.shortcuts import render +import traceback +import sys + + +class CustomErrorHandlerMiddleware: + """ + Middleware que intercepta errores y usa nuestros handlers personalizados + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_exception(self, request, exception): + """ + Procesar excepciones y usar nuestros handlers personalizados + """ + if ( + hasattr(settings, "USE_CUSTOM_ERROR_HANDLERS") + and settings.USE_CUSTOM_ERROR_HANDLERS + ): + if isinstance(exception, Resolver404): + return self._handle_404(request, exception) + elif isinstance(exception, PermissionDenied): + return self._handle_403(request, exception) + else: + return self._handle_500(request, exception) + return None + + def _handle_404(self, request, exception): + """ + Manejar error 404 con nuestro handler personalizado + """ + from .error_views import custom_404_view + + return custom_404_view(request, exception) + + def _handle_403(self, request, exception): + """ + Manejar error 403 con nuestro handler personalizado + """ + from .error_views import custom_403_view + + return custom_403_view(request, exception) + + def _handle_500(self, request, exception): + """ + Manejar error 500 con nuestro handler personalizado + """ + from .error_views import custom_500_view + + return custom_500_view(request, exception) + + +class Custom404Middleware: + """ + Middleware específico para interceptar errores 404 + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + # Si la respuesta es 404 y tenemos handlers personalizados habilitados + if ( + response.status_code == 404 + and hasattr(settings, "USE_CUSTOM_ERROR_HANDLERS") + and settings.USE_CUSTOM_ERROR_HANDLERS + ): + + # Usar nuestro handler personalizado + from .error_views import custom_404_view + + return custom_404_view(request, None) + + return response From 0e30fae208c54eeb2f4983c003ed532768ca8fd9 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:16:38 -0500 Subject: [PATCH 090/102] =?UTF-8?q?=E2=9C=A8=20feat:=20update=20auction=20?= =?UTF-8?q?URLs=20to=20include=20admin=20panel=20routes=20and=20modify=20e?= =?UTF-8?q?xisting=20paths=20for=20consistency,=20enhancing=20the=20struct?= =?UTF-8?q?ure=20and=20accessibility=20of=20the=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auctions/urls.py | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/auctions/urls.py b/auctions/urls.py index 159e019..b47c59a 100644 --- a/auctions/urls.py +++ b/auctions/urls.py @@ -1,13 +1,14 @@ from django.urls import path from . import views +from . import admin_views urlpatterns = [ path("", views.index, name="index"), - path("login/", views.login_view, name="login"), - path("logout/", views.logout_view, name="logout"), - path("register/", views.register, name="register"), - path("new_auction/", views.new_auctions, name="new_auction"), + path("login", views.login_view, name="login"), + path("logout", views.logout_view, name="logout"), + path("register", views.register, name="register"), + path("new_auction", views.new_auctions, name="new_auction"), path("listing/", views.listing, name="listing"), path("bid/", views.bid, name="bid"), path("watchlist/", views.watchlist, name="watchlist"), @@ -18,6 +19,33 @@ name="watchlist_remove", ), path("listing//close", views.close_auction, name="close_auction"), - path("categories/", views.categories, name="categories"), + path("categories", views.categories, name="categories"), path("comment/", views.comment, name="comment"), + # Admin Panel URLs + path("admin/dashboard", admin_views.admin_dashboard, name="admin_dashboard"), + path("admin/analytics", admin_views.admin_analytics, name="admin_analytics"), + path("admin/listings", admin_views.admin_listings, name="admin_listings"), + path("admin/users", admin_views.admin_users, name="admin_users"), + path("admin/reports", admin_views.admin_reports, name="admin_reports"), + path( + "admin/listing/", + admin_views.admin_listing_detail, + name="admin_listing_detail", + ), + path( + "admin/listing//toggle", + admin_views.admin_toggle_listing_status, + name="admin_toggle_listing_status", + ), + path("admin/export", admin_views.admin_export_data, name="admin_export_data"), + # API endpoints for AJAX + path("admin/api/metrics", admin_views.admin_api_metrics, name="admin_api_metrics"), + path("admin/api/charts", admin_views.admin_api_charts, name="admin_api_charts"), + # URLs de prueba para errores (solo en desarrollo) + path("test/404/", admin_views.test_404_view, name="test_404"), + path("test/500/", admin_views.test_500_view, name="test_500"), + path("test/403/", admin_views.test_403_view, name="test_403"), + path("test/admin/", admin_views.test_admin_dashboard, name="test_admin"), + # Capturar todas las URLs no encontradas (debe estar al final) + path("", admin_views.catch_all_404_view, name="catch_all_404"), ] From 3e991ab490e1c824778b95220eb6e2d8be78dcde Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:16:56 -0500 Subject: [PATCH 091/102] =?UTF-8?q?=E2=9C=A8=20feat:=20enhance=20settings?= =?UTF-8?q?=20configuration=20by=20adding=20django=5Fextensions=20and=20im?= =?UTF-8?q?port=5Fexport=20apps,=20implementing=20custom=20error=20handlin?= =?UTF-8?q?g=20settings,=20and=20refining=20static=20file=20management=20f?= =?UTF-8?q?or=20development?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commerce/settings.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/commerce/settings.py b/commerce/settings.py index d309e3c..7735d14 100644 --- a/commerce/settings.py +++ b/commerce/settings.py @@ -41,6 +41,8 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django_extensions", + "import_export", ] MIDDLEWARE = [ @@ -52,6 +54,7 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", + "auctions.middleware.Custom404Middleware", # Nuestro middleware personalizado ] ROOT_URLCONF = "commerce.urls" @@ -73,6 +76,13 @@ }, ] +# Configuración para templates de error personalizados +TEMPLATE_DEBUG = DEBUG + +# Deshabilitar la página de debug por defecto de Django para usar nuestros handlers personalizados +# Esto permite que nuestros handlers personalizados funcionen incluso en modo DEBUG +USE_CUSTOM_ERROR_HANDLERS = True + WSGI_APPLICATION = "commerce.wsgi.application" # Database @@ -135,11 +145,19 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" LOGIN_URL = "/login" # Cambia la ruta según tu configuración -# Extra places for collectstatic to find static files. +# Configuración de archivos estáticos STATICFILES_DIRS = [ os.path.join(BASE_DIR, "auctions/static"), ] -STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" django_heroku.settings(locals()) + +# Configuración de archivos estáticos +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") + +# Configuración específica para desarrollo (después de django_heroku) +if DEBUG: + # Asegurar que STATICFILES_DIRS esté configurado correctamente + STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "auctions/static"), + ] From 6c2049ee3bc4b17745aff072fcf026ba11b83d04 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:17:16 -0500 Subject: [PATCH 092/102] =?UTF-8?q?=E2=9C=A8=20feat:=20update=20commerce?= =?UTF-8?q?=20URL=20configuration=20to=20include=20custom=20error=20handle?= =?UTF-8?q?rs,=20static=20file=20management=20for=20development,=20and=20r?= =?UTF-8?q?efine=20admin=20panel=20routing=20for=20improved=20application?= =?UTF-8?q?=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commerce/urls.py | 64 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/commerce/urls.py b/commerce/urls.py index ce677b2..e7d75fe 100644 --- a/commerce/urls.py +++ b/commerce/urls.py @@ -1,20 +1,44 @@ -"""commerce URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/3.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" - -from django.contrib import admin -from django.urls import include, path - -urlpatterns = [path("admin/", admin.site.urls), path("", include("auctions.urls"))] +"""commerce URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import include, path +from django.conf import settings +from django.conf.urls.static import static +from auctions.error_views import ( + custom_404_view, + custom_500_view, + custom_403_view, + custom_400_view, +) + +urlpatterns = [ + path("", include("auctions.urls")), + path("admin", admin.site.urls), +] + +# Servir archivos estáticos en desarrollo +if settings.DEBUG: + urlpatterns += static( + settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0] + ) + +# Configurar manejadores de errores personalizados +# Estos deben estar definidos a nivel de módulo para que Django los reconozca +handler404 = custom_404_view +handler500 = custom_500_view +handler403 = custom_403_view +handler400 = custom_400_view From db14c5686b6bfc5bfab9eb194b716eadb5e4abdc Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:17:31 -0500 Subject: [PATCH 093/102] =?UTF-8?q?=F0=9F=99=88=20=20feat:=20update=20.git?= =?UTF-8?q?ignore=20to=20include=20static=20files=20directory,=20improving?= =?UTF-8?q?=20project=20cleanliness=20and=20build=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ea8e73d..749c55f 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,8 @@ dmypy.json # Cython debug symbols cython_debug/ +/staticfiles/ + # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore From 428ce4134270ec732a0bb29a7ea9dfac2ae260a6 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:18:09 -0500 Subject: [PATCH 094/102] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20admin=20panel?= =?UTF-8?q?=20configuration=20file=20with=20customizable=20settings=20for?= =?UTF-8?q?=20analytics,=20alerts,=20export=20options,=20caching,=20loggin?= =?UTF-8?q?g,=20and=20chart=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_panel_config.py | 130 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 admin_panel_config.py diff --git a/admin_panel_config.py b/admin_panel_config.py new file mode 100644 index 0000000..f0603b8 --- /dev/null +++ b/admin_panel_config.py @@ -0,0 +1,130 @@ +""" +Configuración del Panel de Administración +Variables de entorno y configuraciones personalizables +""" + +import os +from django.conf import settings + +# Configuración del Panel de Administración +ADMIN_PANEL_CONFIG = { + 'enabled': os.getenv('ADMIN_PANEL_ENABLED', 'True').lower() == 'true', + 'refresh_interval': int(os.getenv('ADMIN_REFRESH_INTERVAL', '30000')), + 'chart_height': int(os.getenv('ADMIN_CHART_HEIGHT', '300')), + 'table_page_size': int(os.getenv('ADMIN_TABLE_PAGE_SIZE', '20')), + 'max_recent_items': int(os.getenv('ADMIN_MAX_RECENT_ITEMS', '10')), +} + +# Configuración de Análisis +ANALYTICS_CONFIG = { + 'min_data_points': int(os.getenv('ANALYTICS_MIN_DATA_POINTS', '10')), + 'confidence_thresholds': { + 'high': float(os.getenv('ANALYTICS_CONFIDENCE_HIGH', '0.8')), + 'medium': float(os.getenv('ANALYTICS_CONFIDENCE_MEDIUM', '0.6')), + 'low': float(os.getenv('ANALYTICS_CONFIDENCE_LOW', '0.4')), + } +} + +# Configuración de Alertas +ALERT_CONFIG = { + 'low_activity_threshold': int(os.getenv('ALERT_LOW_ACTIVITY_THRESHOLD', '5')), + 'high_bid_threshold': int(os.getenv('ALERT_HIGH_BID_THRESHOLD', '1000')), + 'suspicious_activity_threshold': int(os.getenv('ALERT_SUSPICIOUS_ACTIVITY_THRESHOLD', '50')), +} + +# Configuración de Exportación +EXPORT_CONFIG = { + 'max_records': int(os.getenv('EXPORT_MAX_RECORDS', '10000')), + 'date_format': os.getenv('EXPORT_DATE_FORMAT', '%Y-%m-%d %H:%M:%S'), + 'supported_formats': ['csv', 'xlsx', 'json'], +} + +# Configuración de Colores +CHART_COLORS = { + 'primary': os.getenv('CHART_PRIMARY_COLOR', '#667eea'), + 'secondary': os.getenv('CHART_SECONDARY_COLOR', '#764ba2'), + 'success': os.getenv('CHART_SUCCESS_COLOR', '#4facfe'), + 'info': os.getenv('CHART_INFO_COLOR', '#00f2fe'), + 'warning': os.getenv('CHART_WARNING_COLOR', '#f093fb'), + 'danger': os.getenv('CHART_DANGER_COLOR', '#f5576c'), + 'light': os.getenv('CHART_LIGHT_COLOR', '#a8edea'), + 'dark': os.getenv('CHART_DARK_COLOR', '#fed6e3'), +} + +# Configuración de Base de Datos para Análisis +DATABASE_CONFIG = { + 'query_timeout': int(os.getenv('DB_QUERY_TIMEOUT', '30')), + 'max_connections': int(os.getenv('DB_MAX_CONNECTIONS', '20')), + 'enable_query_logging': os.getenv('DB_ENABLE_QUERY_LOGGING', 'False').lower() == 'true', +} + +# Configuración de Caché +CACHE_CONFIG = { + 'enabled': os.getenv('CACHE_ENABLED', 'True').lower() == 'true', + 'timeout': int(os.getenv('CACHE_TIMEOUT', '300')), # 5 minutos + 'key_prefix': os.getenv('CACHE_KEY_PREFIX', 'admin_panel'), +} + +# Configuración de Logging +LOGGING_CONFIG = { + 'level': os.getenv('LOG_LEVEL', 'INFO'), + 'file': os.getenv('LOG_FILE', 'admin_panel.log'), + 'max_size': int(os.getenv('LOG_MAX_SIZE', '10485760')), # 10MB + 'backup_count': int(os.getenv('LOG_BACKUP_COUNT', '5')), +} + +def get_config(section): + """ + Obtener configuración de una sección específica + """ + configs = { + 'admin_panel': ADMIN_PANEL_CONFIG, + 'analytics': ANALYTICS_CONFIG, + 'alerts': ALERT_CONFIG, + 'export': EXPORT_CONFIG, + 'colors': CHART_COLORS, + 'database': DATABASE_CONFIG, + 'cache': CACHE_CONFIG, + 'logging': LOGGING_CONFIG, + } + + return configs.get(section, {}) + +def is_feature_enabled(feature): + """ + Verificar si una funcionalidad está habilitada + """ + feature_configs = { + 'admin_panel': ADMIN_PANEL_CONFIG['enabled'], + 'analytics': True, + 'alerts': True, + 'export': True, + 'caching': CACHE_CONFIG['enabled'], + 'query_logging': DATABASE_CONFIG['enable_query_logging'], + } + + return feature_configs.get(feature, False) + +def get_chart_color(color_name): + """ + Obtener color para gráficos + """ + return CHART_COLORS.get(color_name, '#667eea') + +def get_alert_threshold(alert_type): + """ + Obtener umbral para alertas + """ + return ALERT_CONFIG.get(alert_type, 0) + +def get_export_limit(): + """ + Obtener límite de exportación + """ + return EXPORT_CONFIG['max_records'] + +def get_refresh_interval(): + """ + Obtener intervalo de actualización + """ + return ADMIN_PANEL_CONFIG['refresh_interval'] From f6e226f1dbc27f03b70184d9160c8802e1573a8a Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sat, 13 Sep 2025 12:18:25 -0500 Subject: [PATCH 095/102] =?UTF-8?q?=E2=9E=95=20=20feat:=20add=20business?= =?UTF-8?q?=20intelligence=20libraries=20to=20requirements=20for=20enhance?= =?UTF-8?q?d=20data=20analysis=20and=20visualization=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/requirements.txt b/requirements.txt index 67b1d6a..3d5bc11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,10 @@ typing_extensions==4.12.2 tzdata==2024.2 whitenoise==6.8.2 factory-boy==3.3.0 +# Business Intelligence Libraries +pandas==2.2.2 +numpy==1.26.4 +plotly==5.17.0 +scikit-learn==1.4.2 +django-extensions==3.2.3 +django-import-export==4.1.1 From c33320b50aae73a0ca7ca8033311bf94646ef889 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:11:24 +0000 Subject: [PATCH 096/102] Refactor CSS to unify styles and remove inline code This commit addresses the issue of scattered and repetitive CSS by establishing a single source of truth for design tokens and refactoring all stylesheets to use it. Key changes include: - Consolidated all CSS variables into `auctions/static/css/variables.css`. - Removed duplicate and conflicting variable definitions from `auctions/static/css/auctions/styles.css` and other stylesheets. - Refactored all page-specific and component-specific CSS files to use the unified variables from `variables.css`. - Extracted all inline ` + {% block extra_css %}{% endblock %} @@ -310,35 +132,7 @@

    {{ page_title|default:"Panel de Administración" }}

    - + {% block extra_js %}{% endblock %} diff --git a/auctions/templates/auctions/admin/dashboard.html b/auctions/templates/auctions/admin/dashboard.html index 38cea48..d07403a 100644 --- a/auctions/templates/auctions/admin/dashboard.html +++ b/auctions/templates/auctions/admin/dashboard.html @@ -7,7 +7,7 @@
    -
    +
    @@ -21,7 +21,7 @@
    -
    +
    @@ -35,7 +35,7 @@
    -
    +
    @@ -49,7 +49,7 @@
    -
    +
    @@ -66,7 +66,7 @@
    -
    +
    @@ -80,7 +80,7 @@
    -
    +
    @@ -93,7 +93,7 @@
    -
    +
    @@ -106,7 +106,7 @@
    -
    +
    @@ -277,8 +277,7 @@
    -
    +
    {{ user.username|first|upper }}
    {{ user.username }} diff --git a/auctions/templates/auctions/admin/listing_detail.html b/auctions/templates/auctions/admin/listing_detail.html index 3a8d1c9..8dbc465 100644 --- a/auctions/templates/auctions/admin/listing_detail.html +++ b/auctions/templates/auctions/admin/listing_detail.html @@ -11,7 +11,7 @@
    {% if listing.image %} {{ listing.title }} + class="img-fluid rounded img-cover" style="max-height: 300px; width: 100%;"> {% else %}
    @@ -35,8 +35,7 @@
    Información Básica
    Usuario:
    -
    +
    {{ listing.user.username|first|upper }}
    {{ listing.user.username }} @@ -219,8 +218,7 @@
    {{ forloop.counter }}
    -
    +
    {{ bid.user.username|first|upper }}
    {{ bid.user.username }} @@ -285,8 +283,7 @@
    -
    +
    {{ comment.user.username|first|upper }}
    {{ comment.user.username }} diff --git a/auctions/templates/auctions/admin/listings.html b/auctions/templates/auctions/admin/listings.html index cd5f3e0..d93f20b 100644 --- a/auctions/templates/auctions/admin/listings.html +++ b/auctions/templates/auctions/admin/listings.html @@ -96,10 +96,9 @@
    {% if listing.image %} {{ listing.title }} + class="rounded me-2 img-cover-sm"> {% else %} -
    +
    {% endif %} @@ -114,8 +113,7 @@
    -
    +
    {{ listing.user.username|first|upper }}
    {{ listing.user.username }} diff --git a/auctions/templates/auctions/admin/reports.html b/auctions/templates/auctions/admin/reports.html index cc10f51..c5770dd 100644 --- a/auctions/templates/auctions/admin/reports.html +++ b/auctions/templates/auctions/admin/reports.html @@ -196,8 +196,7 @@
    {{ forloop.counter }}
    -
    +
    {{ user.username|first|upper }}
    {{ user.username }} diff --git a/auctions/templates/auctions/admin/users.html b/auctions/templates/auctions/admin/users.html index 28285df..fac955d 100644 --- a/auctions/templates/auctions/admin/users.html +++ b/auctions/templates/auctions/admin/users.html @@ -76,8 +76,7 @@
    -
    +
    {{ user.username|first|upper }}
    diff --git a/auctions/templates/auctions/components/alert.html b/auctions/templates/auctions/components/alert.html index 75ab922..470aeed 100644 --- a/auctions/templates/auctions/components/alert.html +++ b/auctions/templates/auctions/components/alert.html @@ -1,30 +1,30 @@ -
    - {% if messages %} {% for message in messages %} - - {% endfor %} {% endif %} -
    +
    + {% if messages %} {% for message in messages %} + + {% endfor %} {% endif %} +
    diff --git a/auctions/templates/auctions/errors/404.html b/auctions/templates/auctions/errors/404.html index 12e3f12..d956035 100644 --- a/auctions/templates/auctions/errors/404.html +++ b/auctions/templates/auctions/errors/404.html @@ -11,13 +11,13 @@
    - +
    - +
    - +
    From adc2a82a28de7bebef1bb25c0a229fe4d655dcb2 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:11:41 +0000 Subject: [PATCH 097/102] style: format code with Black and Ruff Formatter This commit fixes the style issues introduced in c33320b according to the output from Black and Ruff Formatter. Details: https://github.com/sandovaldavid/project-02-auctions/pull/25 --- admin_panel_config.py | 117 +++++++++-------- auctions/admin_config.py | 44 +++---- auctions/error_views.py | 10 +- .../management/commands/generate_reports.py | 120 +++++++++--------- auctions/middleware.py | 1 - auctions/templatetags/auctions_filters.py | 2 +- 6 files changed, 149 insertions(+), 145 deletions(-) diff --git a/admin_panel_config.py b/admin_panel_config.py index f0603b8..5d22165 100644 --- a/admin_panel_config.py +++ b/admin_panel_config.py @@ -8,108 +8,115 @@ # Configuración del Panel de Administración ADMIN_PANEL_CONFIG = { - 'enabled': os.getenv('ADMIN_PANEL_ENABLED', 'True').lower() == 'true', - 'refresh_interval': int(os.getenv('ADMIN_REFRESH_INTERVAL', '30000')), - 'chart_height': int(os.getenv('ADMIN_CHART_HEIGHT', '300')), - 'table_page_size': int(os.getenv('ADMIN_TABLE_PAGE_SIZE', '20')), - 'max_recent_items': int(os.getenv('ADMIN_MAX_RECENT_ITEMS', '10')), + "enabled": os.getenv("ADMIN_PANEL_ENABLED", "True").lower() == "true", + "refresh_interval": int(os.getenv("ADMIN_REFRESH_INTERVAL", "30000")), + "chart_height": int(os.getenv("ADMIN_CHART_HEIGHT", "300")), + "table_page_size": int(os.getenv("ADMIN_TABLE_PAGE_SIZE", "20")), + "max_recent_items": int(os.getenv("ADMIN_MAX_RECENT_ITEMS", "10")), } # Configuración de Análisis ANALYTICS_CONFIG = { - 'min_data_points': int(os.getenv('ANALYTICS_MIN_DATA_POINTS', '10')), - 'confidence_thresholds': { - 'high': float(os.getenv('ANALYTICS_CONFIDENCE_HIGH', '0.8')), - 'medium': float(os.getenv('ANALYTICS_CONFIDENCE_MEDIUM', '0.6')), - 'low': float(os.getenv('ANALYTICS_CONFIDENCE_LOW', '0.4')), - } + "min_data_points": int(os.getenv("ANALYTICS_MIN_DATA_POINTS", "10")), + "confidence_thresholds": { + "high": float(os.getenv("ANALYTICS_CONFIDENCE_HIGH", "0.8")), + "medium": float(os.getenv("ANALYTICS_CONFIDENCE_MEDIUM", "0.6")), + "low": float(os.getenv("ANALYTICS_CONFIDENCE_LOW", "0.4")), + }, } # Configuración de Alertas ALERT_CONFIG = { - 'low_activity_threshold': int(os.getenv('ALERT_LOW_ACTIVITY_THRESHOLD', '5')), - 'high_bid_threshold': int(os.getenv('ALERT_HIGH_BID_THRESHOLD', '1000')), - 'suspicious_activity_threshold': int(os.getenv('ALERT_SUSPICIOUS_ACTIVITY_THRESHOLD', '50')), + "low_activity_threshold": int(os.getenv("ALERT_LOW_ACTIVITY_THRESHOLD", "5")), + "high_bid_threshold": int(os.getenv("ALERT_HIGH_BID_THRESHOLD", "1000")), + "suspicious_activity_threshold": int( + os.getenv("ALERT_SUSPICIOUS_ACTIVITY_THRESHOLD", "50") + ), } # Configuración de Exportación EXPORT_CONFIG = { - 'max_records': int(os.getenv('EXPORT_MAX_RECORDS', '10000')), - 'date_format': os.getenv('EXPORT_DATE_FORMAT', '%Y-%m-%d %H:%M:%S'), - 'supported_formats': ['csv', 'xlsx', 'json'], + "max_records": int(os.getenv("EXPORT_MAX_RECORDS", "10000")), + "date_format": os.getenv("EXPORT_DATE_FORMAT", "%Y-%m-%d %H:%M:%S"), + "supported_formats": ["csv", "xlsx", "json"], } # Configuración de Colores CHART_COLORS = { - 'primary': os.getenv('CHART_PRIMARY_COLOR', '#667eea'), - 'secondary': os.getenv('CHART_SECONDARY_COLOR', '#764ba2'), - 'success': os.getenv('CHART_SUCCESS_COLOR', '#4facfe'), - 'info': os.getenv('CHART_INFO_COLOR', '#00f2fe'), - 'warning': os.getenv('CHART_WARNING_COLOR', '#f093fb'), - 'danger': os.getenv('CHART_DANGER_COLOR', '#f5576c'), - 'light': os.getenv('CHART_LIGHT_COLOR', '#a8edea'), - 'dark': os.getenv('CHART_DARK_COLOR', '#fed6e3'), + "primary": os.getenv("CHART_PRIMARY_COLOR", "#667eea"), + "secondary": os.getenv("CHART_SECONDARY_COLOR", "#764ba2"), + "success": os.getenv("CHART_SUCCESS_COLOR", "#4facfe"), + "info": os.getenv("CHART_INFO_COLOR", "#00f2fe"), + "warning": os.getenv("CHART_WARNING_COLOR", "#f093fb"), + "danger": os.getenv("CHART_DANGER_COLOR", "#f5576c"), + "light": os.getenv("CHART_LIGHT_COLOR", "#a8edea"), + "dark": os.getenv("CHART_DARK_COLOR", "#fed6e3"), } # Configuración de Base de Datos para Análisis DATABASE_CONFIG = { - 'query_timeout': int(os.getenv('DB_QUERY_TIMEOUT', '30')), - 'max_connections': int(os.getenv('DB_MAX_CONNECTIONS', '20')), - 'enable_query_logging': os.getenv('DB_ENABLE_QUERY_LOGGING', 'False').lower() == 'true', + "query_timeout": int(os.getenv("DB_QUERY_TIMEOUT", "30")), + "max_connections": int(os.getenv("DB_MAX_CONNECTIONS", "20")), + "enable_query_logging": os.getenv("DB_ENABLE_QUERY_LOGGING", "False").lower() + == "true", } # Configuración de Caché CACHE_CONFIG = { - 'enabled': os.getenv('CACHE_ENABLED', 'True').lower() == 'true', - 'timeout': int(os.getenv('CACHE_TIMEOUT', '300')), # 5 minutos - 'key_prefix': os.getenv('CACHE_KEY_PREFIX', 'admin_panel'), + "enabled": os.getenv("CACHE_ENABLED", "True").lower() == "true", + "timeout": int(os.getenv("CACHE_TIMEOUT", "300")), # 5 minutos + "key_prefix": os.getenv("CACHE_KEY_PREFIX", "admin_panel"), } # Configuración de Logging LOGGING_CONFIG = { - 'level': os.getenv('LOG_LEVEL', 'INFO'), - 'file': os.getenv('LOG_FILE', 'admin_panel.log'), - 'max_size': int(os.getenv('LOG_MAX_SIZE', '10485760')), # 10MB - 'backup_count': int(os.getenv('LOG_BACKUP_COUNT', '5')), + "level": os.getenv("LOG_LEVEL", "INFO"), + "file": os.getenv("LOG_FILE", "admin_panel.log"), + "max_size": int(os.getenv("LOG_MAX_SIZE", "10485760")), # 10MB + "backup_count": int(os.getenv("LOG_BACKUP_COUNT", "5")), } + def get_config(section): """ Obtener configuración de una sección específica """ configs = { - 'admin_panel': ADMIN_PANEL_CONFIG, - 'analytics': ANALYTICS_CONFIG, - 'alerts': ALERT_CONFIG, - 'export': EXPORT_CONFIG, - 'colors': CHART_COLORS, - 'database': DATABASE_CONFIG, - 'cache': CACHE_CONFIG, - 'logging': LOGGING_CONFIG, + "admin_panel": ADMIN_PANEL_CONFIG, + "analytics": ANALYTICS_CONFIG, + "alerts": ALERT_CONFIG, + "export": EXPORT_CONFIG, + "colors": CHART_COLORS, + "database": DATABASE_CONFIG, + "cache": CACHE_CONFIG, + "logging": LOGGING_CONFIG, } - + return configs.get(section, {}) + def is_feature_enabled(feature): """ Verificar si una funcionalidad está habilitada """ feature_configs = { - 'admin_panel': ADMIN_PANEL_CONFIG['enabled'], - 'analytics': True, - 'alerts': True, - 'export': True, - 'caching': CACHE_CONFIG['enabled'], - 'query_logging': DATABASE_CONFIG['enable_query_logging'], + "admin_panel": ADMIN_PANEL_CONFIG["enabled"], + "analytics": True, + "alerts": True, + "export": True, + "caching": CACHE_CONFIG["enabled"], + "query_logging": DATABASE_CONFIG["enable_query_logging"], } - + return feature_configs.get(feature, False) + def get_chart_color(color_name): """ Obtener color para gráficos """ - return CHART_COLORS.get(color_name, '#667eea') + return CHART_COLORS.get(color_name, "#667eea") + def get_alert_threshold(alert_type): """ @@ -117,14 +124,16 @@ def get_alert_threshold(alert_type): """ return ALERT_CONFIG.get(alert_type, 0) + def get_export_limit(): """ Obtener límite de exportación """ - return EXPORT_CONFIG['max_records'] + return EXPORT_CONFIG["max_records"] + def get_refresh_interval(): """ Obtener intervalo de actualización """ - return ADMIN_PANEL_CONFIG['refresh_interval'] + return ADMIN_PANEL_CONFIG["refresh_interval"] diff --git a/auctions/admin_config.py b/auctions/admin_config.py index 88e6d75..1447cfe 100644 --- a/auctions/admin_config.py +++ b/auctions/admin_config.py @@ -5,44 +5,40 @@ # Configuración de colores para gráficos CHART_COLORS = { - 'primary': '#667eea', - 'secondary': '#764ba2', - 'success': '#4facfe', - 'info': '#00f2fe', - 'warning': '#f093fb', - 'danger': '#f5576c', - 'light': '#a8edea', - 'dark': '#fed6e3' + "primary": "#667eea", + "secondary": "#764ba2", + "success": "#4facfe", + "info": "#00f2fe", + "warning": "#f093fb", + "danger": "#f5576c", + "light": "#a8edea", + "dark": "#fed6e3", } # Configuración de métricas METRICS_CONFIG = { - 'refresh_interval': 30000, # 30 segundos - 'chart_height': 300, - 'table_page_size': 20, - 'max_recent_items': 10 + "refresh_interval": 30000, # 30 segundos + "chart_height": 300, + "table_page_size": 20, + "max_recent_items": 10, } # Configuración de exportación EXPORT_CONFIG = { - 'supported_formats': ['csv', 'xlsx'], - 'max_records_per_export': 10000, - 'date_format': '%Y-%m-%d %H:%M:%S' + "supported_formats": ["csv", "xlsx"], + "max_records_per_export": 10000, + "date_format": "%Y-%m-%d %H:%M:%S", } # Configuración de análisis predictivo PREDICTION_CONFIG = { - 'min_data_points': 10, - 'confidence_thresholds': { - 'high': 0.8, - 'medium': 0.6, - 'low': 0.4 - } + "min_data_points": 10, + "confidence_thresholds": {"high": 0.8, "medium": 0.6, "low": 0.4}, } # Configuración de alertas ALERT_CONFIG = { - 'low_activity_threshold': 5, # días sin actividad - 'high_bid_threshold': 1000, # pujas muy altas - 'suspicious_activity_threshold': 50 # pujas por usuario por día + "low_activity_threshold": 5, # días sin actividad + "high_bid_threshold": 1000, # pujas muy altas + "suspicious_activity_threshold": 50, # pujas por usuario por día } diff --git a/auctions/error_views.py b/auctions/error_views.py index ecf3579..30d408f 100644 --- a/auctions/error_views.py +++ b/auctions/error_views.py @@ -27,7 +27,7 @@ def custom_404_view(request, exception=None): debug_info = f""" Request Method: {request.method} Request URL: {request.build_absolute_uri()} -Raised by: {getattr(exception, '__class__', 'Unknown')} +Raised by: {getattr(exception, "__class__", "Unknown")} Using the URLconf defined in {settings.ROOT_URLCONF}, Django tried these URL patterns: @@ -61,8 +61,8 @@ def custom_500_view(request): debug_info = f""" Request Method: {request.method} Request URL: {request.build_absolute_uri()} -Exception Type: {exc_type.__name__ if exc_type else 'Unknown'} -Exception Value: {str(exc_value) if exc_value else 'Unknown'} +Exception Type: {exc_type.__name__ if exc_type else "Unknown"} +Exception Value: {str(exc_value) if exc_value else "Unknown"} Traceback: {traceback.format_exc()} @@ -88,7 +88,7 @@ def custom_403_view(request, exception=None): debug_info = f""" Request Method: {request.method} Request URL: {request.build_absolute_uri()} -Exception: {getattr(exception, '__class__', 'Unknown')} +Exception: {getattr(exception, "__class__", "Unknown")} You don't have permission to access this resource. """ @@ -111,7 +111,7 @@ def custom_400_view(request, exception=None): debug_info = f""" Request Method: {request.method} Request URL: {request.build_absolute_uri()} -Exception: {getattr(exception, '__class__', 'Unknown')} +Exception: {getattr(exception, "__class__", "Unknown")} Bad Request - The request could not be understood by the server. """ diff --git a/auctions/management/commands/generate_reports.py b/auctions/management/commands/generate_reports.py index 332a169..f09022c 100644 --- a/auctions/management/commands/generate_reports.py +++ b/auctions/management/commands/generate_reports.py @@ -10,110 +10,110 @@ class Command(BaseCommand): - help = 'Genera reportes automáticos del sistema de subastas' + help = "Genera reportes automáticos del sistema de subastas" def add_arguments(self, parser): parser.add_argument( - '--days', + "--days", type=int, default=30, - help='Número de días para el análisis (default: 30)' + help="Número de días para el análisis (default: 30)", ) parser.add_argument( - '--output-dir', + "--output-dir", type=str, - default='reports', - help='Directorio de salida para los reportes (default: reports)' + default="reports", + help="Directorio de salida para los reportes (default: reports)", ) parser.add_argument( - '--format', + "--format", type=str, - choices=['json', 'csv'], - default='json', - help='Formato de salida (default: json)' + choices=["json", "csv"], + default="json", + help="Formato de salida (default: json)", ) def handle(self, *args, **options): - days = options['days'] - output_dir = options['output_dir'] - output_format = options['format'] - + days = options["days"] + output_dir = options["output_dir"] + output_format = options["format"] + # Crear directorio de salida si no existe os.makedirs(output_dir, exist_ok=True) - - self.stdout.write(f'Generando reportes para los últimos {days} días...') - + + self.stdout.write(f"Generando reportes para los últimos {days} días...") + # Generar reporte de actividad de usuarios - self.stdout.write('Generando reporte de actividad de usuarios...') + self.stdout.write("Generando reporte de actividad de usuarios...") user_report = ReportGenerator.generate_user_activity_report(days=days) - + # Generar análisis del mercado - self.stdout.write('Generando análisis del mercado...') + self.stdout.write("Generando análisis del mercado...") market_analysis = ReportGenerator.generate_market_analysis(days=days) - + # Generar métricas de rendimiento - self.stdout.write('Generando métricas de rendimiento...') + self.stdout.write("Generando métricas de rendimiento...") performance_metrics = ReportGenerator.generate_performance_metrics(days=days) - + # Obtener alertas - self.stdout.write('Verificando alertas...') + self.stdout.write("Verificando alertas...") alerts = AlertSystem.get_all_alerts() - + # Consolidar reporte report_data = { - 'generated_at': timezone.now().isoformat(), - 'period_days': days, - 'user_activity': user_report, - 'market_analysis': market_analysis, - 'performance_metrics': performance_metrics, - 'alerts': alerts + "generated_at": timezone.now().isoformat(), + "period_days": days, + "user_activity": user_report, + "market_analysis": market_analysis, + "performance_metrics": performance_metrics, + "alerts": alerts, } - + # Guardar reporte - timestamp = timezone.now().strftime('%Y%m%d_%H%M%S') - - if output_format == 'json': - filename = f'report_{timestamp}.json' + timestamp = timezone.now().strftime("%Y%m%d_%H%M%S") + + if output_format == "json": + filename = f"report_{timestamp}.json" filepath = os.path.join(output_dir, filename) - - with open(filepath, 'w', encoding='utf-8') as f: + + with open(filepath, "w", encoding="utf-8") as f: json.dump(report_data, f, indent=2, ensure_ascii=False, default=str) - - elif output_format == 'csv': + + elif output_format == "csv": # Generar archivos CSV separados import pandas as pd - + # Reporte de usuarios - user_filename = f'user_activity_{timestamp}.csv' + user_filename = f"user_activity_{timestamp}.csv" user_filepath = os.path.join(output_dir, user_filename) pd.DataFrame(user_report).to_csv(user_filepath, index=False) - + # Análisis del mercado - market_filename = f'market_analysis_{timestamp}.csv' + market_filename = f"market_analysis_{timestamp}.csv" market_filepath = os.path.join(output_dir, market_filename) market_df = pd.DataFrame([market_analysis]) market_df.to_csv(market_filepath, index=False) - + # Métricas de rendimiento - metrics_filename = f'performance_metrics_{timestamp}.csv' + metrics_filename = f"performance_metrics_{timestamp}.csv" metrics_filepath = os.path.join(output_dir, metrics_filename) pd.DataFrame([performance_metrics]).to_csv(metrics_filepath, index=False) - + self.stdout.write( - self.style.SUCCESS( - f'Reportes generados exitosamente en {output_dir}/' - ) + self.style.SUCCESS(f"Reportes generados exitosamente en {output_dir}/") ) - + # Mostrar resumen - self.stdout.write('\n--- RESUMEN DEL REPORTE ---') - self.stdout.write(f'Período analizado: {days} días') - self.stdout.write(f'Usuarios analizados: {len(user_report)}') - self.stdout.write(f'Subastas totales: {market_analysis["total_listings"]}') - self.stdout.write(f'Tasa de conversión: {performance_metrics["conversion_rate"]}%') - self.stdout.write(f'Alertas activas: {len(alerts)}') - + self.stdout.write("\n--- RESUMEN DEL REPORTE ---") + self.stdout.write(f"Período analizado: {days} días") + self.stdout.write(f"Usuarios analizados: {len(user_report)}") + self.stdout.write(f"Subastas totales: {market_analysis['total_listings']}") + self.stdout.write( + f"Tasa de conversión: {performance_metrics['conversion_rate']}%" + ) + self.stdout.write(f"Alertas activas: {len(alerts)}") + if alerts: - self.stdout.write('\n--- ALERTAS ---') + self.stdout.write("\n--- ALERTAS ---") for alert in alerts: - self.stdout.write(f'- {alert["message"]} ({alert["severity"]})') + self.stdout.write(f"- {alert['message']} ({alert['severity']})") diff --git a/auctions/middleware.py b/auctions/middleware.py index 9224781..12cd9a9 100644 --- a/auctions/middleware.py +++ b/auctions/middleware.py @@ -82,7 +82,6 @@ def __call__(self, request): and hasattr(settings, "USE_CUSTOM_ERROR_HANDLERS") and settings.USE_CUSTOM_ERROR_HANDLERS ): - # Usar nuestro handler personalizado from .error_views import custom_404_view diff --git a/auctions/templatetags/auctions_filters.py b/auctions/templatetags/auctions_filters.py index 775227a..4b19733 100644 --- a/auctions/templatetags/auctions_filters.py +++ b/auctions/templatetags/auctions_filters.py @@ -24,4 +24,4 @@ def sub(value, arg): @register.filter def addclass(field, css): """Add CSS class to form field""" - return field.as_widget(attrs={"class": css}) \ No newline at end of file + return field.as_widget(attrs={"class": css}) From 8706252c82564d937b44aed3d1df64a43a26c8ed Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 15:35:30 +0000 Subject: [PATCH 098/102] Fix: Link variables.css to enable theme switching The dark/light mode theme toggle was not working because the `variables.css` file, which contains the color definitions for both themes, was not being loaded in the main `layout.html` template. This commit adds the necessary `` tag to `layout.html`, ensuring that the CSS variables are available and allowing the theme to change correctly when toggled by the existing JavaScript. --- auctions/templates/auctions/layout.html | 1 + 1 file changed, 1 insertion(+) diff --git a/auctions/templates/auctions/layout.html b/auctions/templates/auctions/layout.html index 202dede..79a8990 100644 --- a/auctions/templates/auctions/layout.html +++ b/auctions/templates/auctions/layout.html @@ -20,6 +20,7 @@ + From 291af49dc5c50bf5485d797dddb8697c38ad7692 Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sun, 14 Sep 2025 16:05:41 -0500 Subject: [PATCH 099/102] =?UTF-8?q?=F0=9F=92=84=20style:=20update=20CSS=20?= =?UTF-8?q?variables=20for=20light=20theme=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit modifies the `variables.css` file to ensure that CSS variables are correctly applied for the light theme. The changes include adding a selector for `[data-theme="light"]` to the root variables, allowing for proper theme switching functionality. --- auctions/static/css/variables.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/auctions/static/css/variables.css b/auctions/static/css/variables.css index a5ccae6..ba53dfa 100644 --- a/auctions/static/css/variables.css +++ b/auctions/static/css/variables.css @@ -8,7 +8,8 @@ /* ======================================== LIGHT THEME VARIABLES ======================================== */ -:root { +:root, +[data-theme="light"] { /* Primary Brand Colors */ --primary-50: #eff6ff; --primary-100: #dbeafe; @@ -233,7 +234,8 @@ /* ======================================== AUCTION-SPECIFIC COLORS ======================================== */ -:root { +:root, +[data-theme="light"] { /* Auction Status Colors */ --status-active: var(--accent-green); --status-pending: var(--accent-gold); From adf539dfcadacf654f564114a4d44476b1a1eb5c Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sun, 14 Sep 2025 16:06:04 -0500 Subject: [PATCH 100/102] =?UTF-8?q?=F0=9F=90=9B=20fix:=20remove=20inline?= =?UTF-8?q?=20JavaScript=20for=20theme=20toggle=20in=20navigation=20compon?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit eliminates the inline JavaScript from the theme toggle button in the navigation component, improving code maintainability and separation of concerns. --- auctions/templates/auctions/components/navigation.html | 1 - 1 file changed, 1 deletion(-) diff --git a/auctions/templates/auctions/components/navigation.html b/auctions/templates/auctions/components/navigation.html index 951386d..fd10165 100644 --- a/auctions/templates/auctions/components/navigation.html +++ b/auctions/templates/auctions/components/navigation.html @@ -8,7 +8,6 @@ From e7941ca91f80f4c1537e1fba67bd95006bd0cb0c Mon Sep 17 00:00:00 2001 From: Juan David Sandoval Salvador Date: Sun, 14 Sep 2025 16:06:21 -0500 Subject: [PATCH 101/102] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20refactor:=20imp?= =?UTF-8?q?rove=20theme=20initialization=20and=20logging=20in=20layout.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit refactors the theme initialization process to occur only after the DOM is fully loaded, enhancing performance. It also adds default behavior for theme application and includes debug logging for theme changes, improving traceability during development. --- auctions/static/js/layout.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/auctions/static/js/layout.js b/auctions/static/js/layout.js index 1a30abb..0fa7c72 100644 --- a/auctions/static/js/layout.js +++ b/auctions/static/js/layout.js @@ -16,11 +16,10 @@ // DOM Elements let themeToggle, themeIcon, backToTopButton, body; - // Initialize immediately for onclick handlers, then when DOM is loaded - initializeElements(); - initializeTheme(); - + // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', function () { + initializeElements(); + initializeTheme(); initializeBackToTop(); initializeAccessibility(); }); @@ -41,11 +40,12 @@ function initializeTheme() { if (!themeToggle || !themeIcon) return; - // Apply saved theme + // Apply saved theme or default to light const savedTheme = localStorage.getItem(CONFIG.themeStorageKey); if (savedTheme === 'dark') { applyDarkTheme(); } else { + // Default to light theme if no preference is saved applyLightTheme(); } @@ -57,7 +57,8 @@ * Toggle between light and dark theme */ function toggleTheme() { - if (body.getAttribute('data-theme') === 'dark') { + const currentTheme = body.getAttribute('data-theme'); + if (currentTheme === 'dark') { applyLightTheme(); } else { applyDarkTheme(); @@ -68,11 +69,14 @@ * Apply light theme */ function applyLightTheme() { - body.removeAttribute('data-theme'); + body.setAttribute('data-theme', 'light'); if (themeIcon) { themeIcon.classList.replace('fa-moon', 'fa-sun'); } localStorage.setItem(CONFIG.themeStorageKey, 'light'); + + // Debug log + console.log('Applied light theme'); // Dispatch custom event document.dispatchEvent( @@ -91,6 +95,9 @@ themeIcon.classList.replace('fa-sun', 'fa-moon'); } localStorage.setItem(CONFIG.themeStorageKey, 'dark'); + + // Debug log + console.log('Applied dark theme'); // Dispatch custom event document.dispatchEvent( From 05a201c8cc930104bd3f2bf2ff23a0fd7ac36184 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 22:00:19 +0000 Subject: [PATCH 102/102] refactor: Enhance color contrast for accessibility This commit adjusts the color variables for both light and dark themes to improve text readability and ensure compliance with WCAG AA accessibility standards. The following changes have been made to `auctions/static/css/variables.css`: - In the light theme, `--text-tertiary` and `--text-muted` colors have been darkened to increase their contrast against light backgrounds. - In the dark theme, `--text-tertiary` and `--text-muted` colors have been lightened to ensure they stand out against dark backgrounds. These changes result in a better user experience, especially for users with visual impairments. --- auctions/static/css/variables.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/auctions/static/css/variables.css b/auctions/static/css/variables.css index ba53dfa..bb3d484 100644 --- a/auctions/static/css/variables.css +++ b/auctions/static/css/variables.css @@ -85,9 +85,9 @@ /* Text Colors */ --text-primary: var(--gray-900); --text-secondary: var(--gray-700); - --text-tertiary: var(--gray-500); + --text-tertiary: var(--gray-600); --text-inverse: var(--white); - --text-muted: var(--gray-400); + --text-muted: var(--gray-500); --text-disabled: var(--gray-300); /* Border Colors */ @@ -212,9 +212,9 @@ /* Text Colors */ --text-primary: var(--gray-100); --text-secondary: var(--gray-200); - --text-tertiary: var(--gray-400); + --text-tertiary: var(--gray-300); --text-inverse: var(--gray-900); - --text-muted: var(--gray-500); + --text-muted: var(--gray-400); --text-disabled: var(--gray-600); /* Border Colors */