from django.views.generic import TemplateView, DeleteView, CreateView, ListView, DetailView, UpdateView, FormView from django.urls import reverse_lazy from django.http import HttpResponseRedirect, JsonResponse from django.contrib.auth.mixins import LoginRequiredMixin from django.shortcuts import get_object_or_404, render from django.core.exceptions import PermissionDenied, ValidationError from django.views.generic.edit import FormMixin from django.utils import timezone from django.db.models import Q, Case, When, Value, BooleanField, Prefetch, F from django.utils.decorators import method_decorator from django.views.decorators.http import require_http_methods from django.core.paginator import Paginator from django.contrib import messages from .models import TradeOffer, TradeAcceptance from .forms import (TradeOfferAcceptForm, TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm) from cards.models import Card class TradeOfferCreateView(LoginRequiredMixin, CreateView): model = TradeOffer form_class = TradeOfferCreateForm template_name = "trades/trade_offer_create.html" success_url = reverse_lazy("trade_offer_list") def dispatch(self, request, *args, **kwargs): if not request.user.friend_codes.exists(): raise PermissionDenied("No friend codes available for your account.") return super().dispatch(request, *args, **kwargs) def get_form(self, form_class=None): form = super().get_form(form_class) # Restrict the 'initiated_by' choices to friend codes owned by the logged-in user. form.fields["initiated_by"].queryset = self.request.user.friend_codes.all() return form def get_initial(self): initial = super().get_initial() initial["have_cards"] = self.request.GET.getlist("have_cards") initial["want_cards"] = self.request.GET.getlist("want_cards") if self.request.user.friend_codes.count() == 1: initial["initiated_by"] = self.request.user.friend_codes.first().pk return initial def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) from cards.models import Card # Ensure available_cards is a proper QuerySet context["available_cards"] = Card.objects.all().order_by("name", "rarity__pk") \ .select_related("rarity", "cardset") \ .prefetch_related("decks") friend_codes = self.request.user.friend_codes.all() if "initiated_by" in self.request.GET: try: selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by")) except friend_codes.model.DoesNotExist: selected_friend_code = self.request.user.default_friend_code or friend_codes.first() else: selected_friend_code = self.request.user.default_friend_code or friend_codes.first() context["friend_codes"] = friend_codes context["selected_friend_code"] = selected_friend_code return context def form_valid(self, form): friend_codes = self.request.user.friend_codes.all() if form.cleaned_data.get("initiated_by") not in friend_codes: raise PermissionDenied("You cannot initiate trade offers for friend codes that do not belong to you.") self.object = form.save() return HttpResponseRedirect(self.get_success_url()) class TradeOfferAllListView(ListView): model = TradeOffer template_name = "trades/trade_offer_all_list.html" def get_context_data(self, *, object_list=None, **kwargs): context = super().get_context_data(**kwargs) request = self.request show_closed = request.GET.get("show_closed", "false").lower() == "true" context["show_closed"] = show_closed queryset = ( TradeOffer.objects.select_related('initiated_by') .prefetch_related( 'trade_offer_have_cards__card', 'trade_offer_want_cards__card', Prefetch( 'acceptances', queryset=TradeAcceptance.objects.select_related('accepted_by', 'requested_card', 'offered_card') ) ) .order_by("-updated_at") .annotate( is_active=Case( When( manually_closed=False, total_have_quantity__gt=F('total_have_accepted'), total_want_quantity__gt=F('total_want_accepted'), then=Value(True) ), default=Value(False), output_field=BooleanField() ) ) ) if show_closed: queryset = queryset.filter(is_active=False) else: queryset = queryset.filter(is_active=True) offers_page = request.GET.get("offers_page") offers_paginator = Paginator(queryset, 10) context["all_trade_offers_paginated"] = offers_paginator.get_page(offers_page) return context class TradeOfferMyListView(LoginRequiredMixin, ListView): model = TradeOffer # Fallback model; our context data holds separate filtered querysets. template_name = "trades/trade_offer_my_list.html" def dispatch(self, request, *args, **kwargs): if request.user.is_authenticated and not request.user.friend_codes.exists(): raise PermissionDenied("No friend codes available for your account.") return super().dispatch(request, *args, **kwargs) def get_queryset(self): return ( TradeOffer.objects.select_related('initiated_by') .prefetch_related( 'trade_offer_have_cards__card', 'trade_offer_want_cards__card', Prefetch( 'acceptances', queryset=TradeAcceptance.objects.select_related('accepted_by', 'requested_card', 'offered_card') ) ) .order_by("-updated_at") .annotate( is_active=Case( When( manually_closed=False, total_have_quantity__gt=F('total_have_accepted'), total_want_quantity__gt=F('total_want_accepted'), then=Value(True) ), default=Value(False), output_field=BooleanField() ) ) ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) request = self.request show_closed = request.GET.get("show_closed", "false").lower() == "true" context["show_closed"] = show_closed friend_codes = request.user.friend_codes.all() friend_code_param = request.GET.get("friend_code") if friend_code_param: try: selected_friend_code = friend_codes.get(pk=friend_code_param) except friend_codes.model.DoesNotExist: selected_friend_code = request.user.default_friend_code or friend_codes.first() else: selected_friend_code = request.user.default_friend_code or friend_codes.first() if not selected_friend_code: raise PermissionDenied("You do not have an active friend code associated with your account.") context["selected_friend_code"] = selected_friend_code context["friend_codes"] = friend_codes queryset = self.get_queryset().filter(initiated_by=selected_friend_code) if show_closed: queryset = queryset.filter(is_active=False) else: queryset = queryset.filter(is_active=True) offers_page = request.GET.get("offers_page") offers_paginator = Paginator(queryset, 10) context["my_trade_offers_paginated"] = offers_paginator.get_page(offers_page) # ----- Trade Acceptances involving the user ----- # Update terminal states to include the thanked and rejected states. terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] involved_acceptances_qs = TradeAcceptance.objects.filter( Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code) ).order_by("-updated_at") if show_closed: involved_acceptances = involved_acceptances_qs.filter(state__in=terminal_states) else: involved_acceptances = involved_acceptances_qs.exclude(state__in=terminal_states) # ----- Split Acceptances into "Waiting for Your Response" and "Other" ----- waiting_acceptances = involved_acceptances.filter( Q(trade_offer__initiated_by=selected_friend_code, state__in=[ TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.RECEIVED, ]) | Q(accepted_by=selected_friend_code, state__in=[ TradeAcceptance.AcceptanceState.SENT ]) ) other_party_trade_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk")) waiting_page = request.GET.get("waiting_page") other_page = request.GET.get("other_page") waiting_paginator = Paginator(waiting_acceptances, 10) other_party_paginator = Paginator(other_party_trade_acceptances, 10) context["trade_acceptances_waiting_paginated"] = waiting_paginator.get_page(waiting_page) context["other_party_trade_acceptances_paginated"] = other_party_paginator.get_page(other_page) return context class TradeOfferDeleteView(LoginRequiredMixin, DeleteView): model = TradeOffer success_url = reverse_lazy("trade_offer_list") template_name = "trades/trade_offer_delete.html" def dispatch(self, request, *args, **kwargs): trade_offer = self.get_object() if trade_offer.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True): raise PermissionDenied("You are not authorized to delete or close this trade offer.") return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) trade_offer = self.get_object() terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states) if trade_offer.acceptances.count() == 0: context["action"] = "delete" elif trade_offer.acceptances.count() > 0 and not active_acceptances.exists(): context["action"] = "close" else: context["action"] = None return context def post(self, request, *args, **kwargs): trade_offer = self.get_object() terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states) if active_acceptances.exists(): messages.error(request, "Cannot delete or close this trade offer because there are active acceptances.") context = self.get_context_data(object=trade_offer) return self.render_to_response(context) else: if trade_offer.acceptances.count() > 0: trade_offer.manually_closed = True trade_offer.save(update_fields=["manually_closed"]) messages.success(request, "Trade offer has been marked as closed.") return HttpResponseRedirect(self.get_success_url()) else: messages.success(request, "Trade offer has been deleted.") return super().delete(request, *args, **kwargs) class TradeOfferSearchView(LoginRequiredMixin, ListView): """ Reworked trade offer search view using POST. This view allows users to search active trade offers based on the cards they have and/or want. The POST parameters (offered_cards and wanted_cards) are expected to be in the format 'card_id:quantity'. If both types of selections are provided, the resultant queryset must satisfy both conditions. Offers initiated by any of the user's friend codes are excluded. When the request is AJAX (via X-Requested-With header), only the search results fragment (_search_results.html) is rendered. On GET (initial page load), the search results queryset is empty. """ model = TradeOffer context_object_name = "search_results" template_name = "trades/trade_offer_search.html" paginate_by = 10 http_method_names = ["get", "post"] def parse_selections(self, selection_list): """ Parse a list of selections (each formatted as 'card_id:quantity') into a list of tuples. Defaults the quantity to 1 if missing. """ results = [] for item in selection_list: parts = item.split(":") try: card_id = int(parts[0]) except ValueError: continue # Skip invalid values. qty = 1 if len(parts) > 1: try: qty = int(parts[1]) except ValueError: qty = 1 results.append((card_id, qty)) return results def get_queryset(self): from django.db.models import F # For a GET request (initial load), return an empty queryset. if self.request.method == "GET": return TradeOffer.objects.none() # Parse the POST data for offered and wanted selections. offered_selections = self.parse_selections(self.request.POST.getlist("offered_cards")) wanted_selections = self.parse_selections(self.request.POST.getlist("wanted_cards")) # If no selections are provided, return an empty queryset. if not offered_selections and not wanted_selections: return TradeOffer.objects.none() qs = TradeOffer.objects.filter( manually_closed=False, total_have_accepted__lt=F("total_have_quantity"), total_want_accepted__lt=F("total_want_quantity") ).exclude(initiated_by__in=self.request.user.friend_codes.all()) # Chain filters for offered selections (i.e. the user "has" cards). if offered_selections: for card_id, qty in offered_selections: qs = qs.filter( trade_offer_want_cards__card_id=card_id, trade_offer_want_cards__quantity__gte=qty, ) # Chain filters for wanted selections (i.e. the user "wants" cards). if wanted_selections: for card_id, qty in wanted_selections: qs = qs.filter( trade_offer_have_cards__card_id=card_id, trade_offer_have_cards__quantity__gte=qty, ) return qs.distinct() def post(self, request, *args, **kwargs): # For POST, simply process the search through get(). return self.get(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) from cards.models import Card # Populate available_cards to re-populate the multiselects. context["available_cards"] = Card.objects.all().order_by("name", "rarity__pk") \ .select_related("rarity", "cardset") if self.request.method == "POST": context["offered_cards"] = self.request.POST.getlist("offered_cards") context["wanted_cards"] = self.request.POST.getlist("wanted_cards") else: context["offered_cards"] = [] context["wanted_cards"] = [] return context def render_to_response(self, context, **response_kwargs): """ Render the AJAX fragment if the request is AJAX; otherwise, render the complete page. """ if self.request.headers.get("X-Requested-With") == "XMLHttpRequest": from django.shortcuts import render return render(self.request, "trades/_search_results.html", context) else: return super().render_to_response(context, **response_kwargs) class TradeOfferDetailView(LoginRequiredMixin, DetailView): """ Displays the details of a TradeOffer along with its active acceptances. If the offer is still open and the current user is not its initiator, an acceptance form is provided to create a new acceptance. """ model = TradeOffer template_name = "trades/trade_offer_detail.html" def dispatch(self, request, *args, **kwargs): if not request.user.friend_codes.exists(): raise PermissionDenied("No friend codes available for your account.") return super().dispatch(request, *args, **kwargs) def get_queryset(self): return ( TradeOffer.objects.select_related('initiated_by') .prefetch_related( 'trade_offer_have_cards__card', 'trade_offer_want_cards__card', Prefetch( 'acceptances', queryset=TradeAcceptance.objects.select_related( 'accepted_by', 'requested_card', 'offered_card' ) ) ) .annotate( is_active=Case( When(manually_closed=False, then=Value(True)), default=Value(False), output_field=BooleanField() ) ) ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) trade_offer = self.get_object() # Define terminal (closed) acceptance states based on our new system: terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] # For example, if you want to separate active from terminal acceptances: context["acceptances"] = trade_offer.acceptances.all() # Option 1: Filter active acceptances using the queryset lookup. context["active_acceptances"] = trade_offer.acceptances.exclude(state__in=terminal_states) # Option 2: Or filter using the computed property (if you prefer to work with Python iterables): # context["active_acceptances"] = [acc for acc in trade_offer.acceptances.all() if acc.is_active] user_friend_codes = self.request.user.friend_codes.all() # Add context flag and deletion URL if the current user is the initiator if trade_offer.initiated_by in user_friend_codes: context["is_initiator"] = True context["delete_close_url"] = reverse_lazy("trade_offer_delete", kwargs={"pk": trade_offer.pk}) else: context["is_initiator"] = False # If the current user is not the initiator and the offer is open, allow a new acceptance. if trade_offer.initiated_by not in user_friend_codes and not trade_offer.is_closed: context["acceptance_form"] = TradeAcceptanceCreateForm( trade_offer=trade_offer, friend_codes=user_friend_codes ) return context class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView): """ View to create a new TradeAcceptance. The URL should provide 'offer_pk' so that the proper TradeOffer can be identified. """ model = TradeAcceptance form_class = TradeAcceptanceCreateForm template_name = "trades/trade_acceptance_create.html" def dispatch(self, request, *args, **kwargs): self.trade_offer = self.get_trade_offer() if self.trade_offer.initiated_by_id in request.user.friend_codes.values_list("id", flat=True) or not self.trade_offer.is_active: raise PermissionDenied("You cannot accept this trade offer.") if not request.user.friend_codes.exists(): raise PermissionDenied("No friend codes available for your account.") return super().dispatch(request, *args, **kwargs) def get_trade_offer(self): return ( TradeOffer.objects.select_related('initiated_by') .prefetch_related( 'trade_offer_want_cards__card', 'trade_offer_have_cards__card', Prefetch( 'acceptances', queryset=TradeAcceptance.objects.select_related( 'accepted_by', 'requested_card', 'offered_card' ) ) ) .annotate( is_active=Case( When(manually_closed=False, then=Value(True)), default=Value(False), output_field=BooleanField() ) ) .get(pk=self.kwargs['offer_pk']) ) def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs['trade_offer'] = self.trade_offer kwargs['friend_codes'] = self.request.user.friend_codes.all() return kwargs def form_valid(self, form): form.instance.trade_offer = self.trade_offer self.object = form.save() return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): return reverse_lazy("trade_offer_detail", kwargs={"pk": self.trade_offer.pk}) class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView): """ View to update the state of an existing TradeAcceptance. The allowed state transitions are provided via the form. """ model = TradeAcceptance form_class = TradeAcceptanceTransitionForm template_name = "trades/trade_acceptance_update.html" def dispatch(self, request, *args, **kwargs): self.object = self.get_object() if self.object.accepted_by_id not in request.user.friend_codes.values_list("id", flat=True): raise PermissionDenied("You are not authorized to update this acceptance.") if not request.user.friend_codes.exists(): raise PermissionDenied("No friend codes available for your account.") return super().dispatch(request, *args, **kwargs) def get_form_kwargs(self): kwargs = super().get_form_kwargs() # Pass the current instance to the form so it can set proper allowed transitions. kwargs["instance"] = self.object kwargs["user"] = self.request.user return kwargs def form_valid(self, form): new_state = form.cleaned_data["state"] #match the new state to the TradeAcceptance.AcceptanceState enum if new_state not in TradeAcceptance.AcceptanceState: form.add_error("state", "Invalid state transition.") return self.form_invalid(form) try: # pass the new state and the current user to the update_state method form.instance.update_state(new_state, self.request.user) except ValueError as e: form.add_error("state", str(e)) return self.form_invalid(form) return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): return reverse_lazy("trade_offer_detail", kwargs={"pk": self.object.trade_offer.pk})