finished conversion to tailwind

This commit is contained in:
badblocks 2025-03-11 23:45:27 -07:00
parent 6e2843c60e
commit d62956d465
50 changed files with 2490 additions and 1273 deletions

View file

@ -6,7 +6,7 @@ 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
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
@ -14,7 +14,7 @@ from django.contrib import messages
from .models import TradeOffer, TradeAcceptance
from .forms import (TradeOfferAcceptForm,
TradeAcceptanceCreateForm, TradeAcceptanceUpdateForm, TradeOfferCreateForm)
TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm)
from cards.models import Card
class TradeOfferCreateView(LoginRequiredMixin, CreateView):
@ -23,6 +23,11 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
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.
@ -31,16 +36,19 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
def get_initial(self):
initial = super().get_initial()
# Standardize parameter names: use "have_cards" and "want_cards"
initial["have_cards"] = self.request.GET.getlist("have_cards")
initial["want_cards"] = self.request.GET.getlist("want_cards")
# If the user has only one friend code, set it as the default.
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:
@ -54,7 +62,6 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
return context
def form_valid(self, form):
# Double-check that the chosen friend code is owned by the current user.
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.")
@ -62,18 +69,46 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
return HttpResponseRedirect(self.get_success_url())
class TradeOfferListView(LoginRequiredMixin, ListView):
model = TradeOffer # Fallback model; our context data will hold separate querysets.
model = TradeOffer # Fallback model; our context data holds separate filtered querysets.
template_name = "trades/trade_offer_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
# Determine if the user wants to see completed (closed) items.
show_completed = request.GET.get("show_completed", "false").lower() == "true"
context["show_completed"] = show_completed
# Get all friend codes for the current user.
friend_codes = request.user.friend_codes.all()
friend_code_param = request.GET.get("friend_code")
if friend_code_param:
@ -90,28 +125,33 @@ class TradeOfferListView(LoginRequiredMixin, ListView):
context["selected_friend_code"] = selected_friend_code
context["friend_codes"] = friend_codes
# ----- My Trade Offers -----
if show_completed:
my_trade_offers = TradeOffer.objects.filter(initiated_by=selected_friend_code).order_by("-updated_at")
my_trade_offers = [offer for offer in my_trade_offers if offer.is_closed]
queryset = self.get_queryset().filter(initiated_by=selected_friend_code)
if show_closed:
queryset = queryset.filter(is_active=False)
else:
my_trade_offers = TradeOffer.objects.filter(initiated_by=selected_friend_code).order_by("-updated_at")
my_trade_offers = [offer for offer in my_trade_offers if not offer.is_closed]
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.COMPLETED,
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 = TradeAcceptance.objects.filter(
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_completed:
involved_acceptances = involved_acceptances.filter(state__in=terminal_states)
if show_closed:
involved_acceptances = involved_acceptances_qs.filter(state__in=terminal_states)
else:
involved_acceptances = involved_acceptances.exclude(state__in=terminal_states)
involved_acceptances = involved_acceptances_qs.exclude(state__in=terminal_states)
# ----- Split Acceptances into "Waiting for Your Response" and "Other" -----
waiting_acceptances = involved_acceptances.filter(
@ -119,22 +159,20 @@ class TradeOfferListView(LoginRequiredMixin, ListView):
TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.RECEIVED,
]) |
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.SENT)
Q(accepted_by=selected_friend_code, state__in=[
TradeAcceptance.AcceptanceState.SENT
])
)
other_trade_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk"))
other_party_trade_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk"))
# ----- Paginate Each Section Separately -----
offers_page = request.GET.get("offers_page")
waiting_page = request.GET.get("waiting_page")
other_page = request.GET.get("other_page")
offers_paginator = Paginator(my_trade_offers, 10)
waiting_paginator = Paginator(waiting_acceptances, 10)
other_paginator = Paginator(other_trade_acceptances, 10)
other_party_paginator = Paginator(other_party_trade_acceptances, 10)
context["my_trade_offers_paginated"] = offers_paginator.get_page(offers_page)
context["trade_acceptances_waiting_paginated"] = waiting_paginator.get_page(waiting_page)
context["other_trade_acceptances_paginated"] = other_paginator.get_page(other_page)
context["other_party_trade_acceptances_paginated"] = other_party_paginator.get_page(other_page)
return context
class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
@ -144,7 +182,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
def dispatch(self, request, *args, **kwargs):
trade_offer = self.get_object()
if trade_offer.initiated_by not in request.user.friend_codes.all():
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)
@ -152,7 +190,9 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
context = super().get_context_data(**kwargs)
trade_offer = self.get_object()
terminal_states = [
TradeAcceptance.AcceptanceState.COMPLETED,
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,
]
@ -168,68 +208,133 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
def post(self, request, *args, **kwargs):
trade_offer = self.get_object()
terminal_states = [
TradeAcceptance.AcceptanceState.COMPLETED,
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:
# There are terminal acceptances: mark the offer as closed.
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:
# No acceptances: proceed with deletion.
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"
context_object_name = "trade_offers"
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):
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()
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()
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
# 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)
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")
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.
@ -239,16 +344,55 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView):
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()
active_states = [
TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.SENT,
TradeAcceptance.AcceptanceState.RECEIVED,
TradeAcceptance.AcceptanceState.COMPLETED,
# 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,
]
context["acceptances"] = trade_offer.acceptances.filter(state__in=active_states)
# 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
@ -275,16 +419,40 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView):
template_name = "trades/trade_acceptance_create.html"
def dispatch(self, request, *args, **kwargs):
self.trade_offer = get_object_or_404(TradeOffer, pk=kwargs.get("offer_pk"))
# Disallow acceptance if the current user is the offer initiator or if the offer is closed.
if self.trade_offer.initiated_by in request.user.friend_codes.all() or self.trade_offer.is_closed:
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()
kwargs['trade_offer'] = self.trade_offer
kwargs['friend_codes'] = self.request.user.friend_codes.all()
return kwargs
def form_valid(self, form):
@ -301,18 +469,33 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView):
The allowed state transitions are provided via the form.
"""
model = TradeAcceptance
form_class = TradeAcceptanceUpdateForm
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()
kwargs["friend_codes"] = self.request.user.friend_codes.all()
# 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:
# Use the model's update_state logic.
form.instance.update_state(form.cleaned_data["state"])
# 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)