from django.views.generic import TemplateView, DeleteView, CreateView, ListView, DetailView, FormView from django.urls import reverse_lazy from django.http import HttpResponseRedirect from django.contrib.auth.mixins import LoginRequiredMixin from django.shortcuts import get_object_or_404 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 from .models import TradeOffer from .forms import TradeOfferUpdateForm, TradeOfferAcceptForm from cards.models import Card class TradeOfferCreateView(LoginRequiredMixin, CreateView): model = TradeOffer template_name = "trades/trade_offer_create.html" success_url = reverse_lazy("trade_offer_list") fields = ["want_cards", "have_cards", "initiated_by"] def form_valid(self, form): # Save the object without committing m2m fields immediately. self.object = form.save(commit=False) self.object.save() try: # This call will trigger the m2m signals and may raise a ValidationError. form.save_m2m() except ValidationError as e: # Attach the error message to the "have_cards" field (or as a non-field error) form.add_error("have_cards", e.messages[0]) return self.form_invalid(form) return HttpResponseRedirect(self.get_success_url()) class TradeOfferListView(LoginRequiredMixin, ListView): model = TradeOffer template_name = "trades/trade_offer_list.html" def get_queryset(self): qs = super().get_queryset().prefetch_related("have_cards", "want_cards").select_related("initiated_by", "accepted_by") request = self.request show_completed = request.GET.get("show_completed", "").lower() in ["true", "1"] my_trades = request.GET.get("my_trades", "").lower() in ["true", "1"] now = timezone.now() seven_days_ago = now - timezone.timedelta(days=7) if show_completed: qs = qs.filter(Q(state=TradeOffer.State.RECEIVED)) else: qs = qs.filter(updated_at__gte=seven_days_ago).exclude(state=TradeOffer.State.RECEIVED) if my_trades: friend_codes = self.request.user.friend_codes.all() qs = qs.filter(Q(initiated_by__in=friend_codes) | Q(accepted_by__in=friend_codes)) return qs.order_by("-updated_at") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["show_completed"] = self.request.GET.get("show_completed", "").lower() in ["true", "1"] context["my_trades"] = self.request.GET.get("my_trades", "").lower() in ["true", "1"] return context class TradeOfferUpdateView(LoginRequiredMixin, FormMixin, DetailView): """ Merged view that displays trade offer details and renders a form used for either: - Accepting an offer (if in INITIATED state and not initiated by the current user), or - Performing an allowed state transition via the update form. """ model = TradeOffer template_name = "trades/trade_offer_update.html" success_url = reverse_lazy("trade_offer_list") def get_user_friend_codes(self): return self.request.user.friend_codes.all() def get_form_class(self): trade_offer = self.get_object() user_friend_codes = self.get_user_friend_codes() if trade_offer.state == trade_offer.State.INITIATED and trade_offer.initiated_by not in user_friend_codes: return TradeOfferAcceptForm return TradeOfferUpdateForm def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["friend_codes"] = self.get_user_friend_codes() if self.get_form_class() == TradeOfferUpdateForm: kwargs["instance"] = self.get_object() return kwargs def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) form_class = self.get_form_class() if "form" not in context: context["form"] = self.get_form(form_class) context["action"] = "accept" if form_class == TradeOfferAcceptForm else "update" trade_offer = self.object user_friend_codes = self.get_user_friend_codes() seven_days_ago = timezone.now() - timezone.timedelta(days=7) can_delete = False if trade_offer.initiated_by in user_friend_codes: if trade_offer.state == trade_offer.State.INITIATED: can_delete = True elif trade_offer.state == trade_offer.State.SENT and trade_offer.updated_at < seven_days_ago: can_delete = True elif trade_offer.accepted_by in user_friend_codes: if trade_offer.state in [trade_offer.State.ACCEPTED, trade_offer.State.RECEIVED]: if trade_offer.updated_at < seven_days_ago: can_delete = True context["can_delete"] = can_delete return context def post(self, request, *args, **kwargs): self.object = self.get_object() user_friend_codes = self.get_user_friend_codes() form_class = self.get_form_class() form = self.get_form(form_class) if form_class == TradeOfferAcceptForm: if not (self.object.state == self.object.State.INITIATED and self.object.initiated_by not in user_friend_codes): raise PermissionDenied("You are not allowed to accept this trade offer.") if form.is_valid(): return self.form_valid(form) else: return self.form_invalid(form) def form_valid(self, form): """ Save the instance and its many-to-many fields in a try/except block to catch ValidationError raised by the m2m_changed signal (or a custom validation method). """ trade_offer = self.get_object() # For example, if you want to perform a pre-save validation of card rarities, # you might call a model method (that you define) like: try: trade_offer.validate_card_rarities() except ValueError as e: form.add_error("have_cards", str(e)) return self.form_invalid(form) # For the m2m part, manually save to catch errors from the signal: self.object = form.save(commit=False) # Process state change or friend code acceptance: if isinstance(form, TradeOfferAcceptForm): chosen_friend_code = form.cleaned_data["friend_code"] trade_offer.accepted_by = chosen_friend_code try: trade_offer.update_state(TradeOffer.State.ACCEPTED) except ValueError as e: # Attach as non-field error (or on a specific field if you prefer) form.add_error(None, str(e)) return self.form_invalid(form) else: new_state = form.cleaned_data["state"] try: trade_offer.update_state(new_state) except ValueError as e: form.add_error("state", str(e)) return self.form_invalid(form) try: # Save instance and its m2m fields; any ValidationError raised here (e.g., # from the m2m_changed signals) will be caught. self.object.save() # Save the TradeOffer instance. form.save_m2m() # This call triggers the m2m_changed signals. except ValidationError as e: # Here we attach the signal error (from card rarities) to the form so that # the user can see it. You can attach it to a specific field or as a non-field error. form.add_error("have_cards", e.messages[0]) return self.form_invalid(form) return HttpResponseRedirect(self.get_success_url()) class TradeOfferDeleteView(LoginRequiredMixin, DeleteView): model = TradeOffer success_url = reverse_lazy("trade_offer_list") template_name = "trades/trade_offer_delete.html" class TradeOfferSearchView(LoginRequiredMixin, ListView): model = TradeOffer template_name = "trades/trade_offer_search.html" context_object_name = "trade_offers" def get_queryset(self): qs = super().get_queryset().filter(state=TradeOffer.State.INITIATED).prefetch_related("have_cards", "want_cards").select_related("initiated_by", "accepted_by") offered_card = self.request.GET.get("offered_card", "").strip() wanted_cards = self.request.GET.getlist("wanted_cards") if not offered_card and not wanted_cards: return qs.none() if offered_card: try: offered_card_id = int(offered_card) except ValueError: qs = qs.none() else: qs = qs.filter(have_cards__id=offered_card_id) if wanted_cards: valid_wanted_cards = [] for card_str in wanted_cards: try: valid_wanted_cards.append(int(card_str)) except ValueError: qs = qs.none() break if valid_wanted_cards: qs = qs.filter(want_cards__id__in=valid_wanted_cards) return qs def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["offered_card"] = self.request.GET.get("offered_card", "") context["wanted_cards"] = self.request.GET.getlist("wanted_cards") context["available_cards"] = Card.objects.order_by("name", "rarity__pk").select_related("rarity", "cardset") return context