Move profile and settings into the new unified dashboard, showing user info in one place

This commit is contained in:
badblocks 2025-03-31 22:20:59 -07:00
parent 2d826734a0
commit 7edefe23c3
37 changed files with 726 additions and 500 deletions

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-29 21:23
# Generated by Django 5.1.2 on 2025-03-31 22:48
import django.db.models.deletion
from django.db import migrations, models

View file

@ -334,6 +334,7 @@ class TradeAcceptance(models.Model):
if new_state not in allowed:
raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.")
self._actioning_user = user
self.state = new_state
self.save(update_fields=["state"])

View file

@ -3,6 +3,12 @@ from django.dispatch import receiver
from django.db.models import F
from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
from django.db import transaction
from accounts.models import CustomUser
from datetime import timedelta
from django.utils import timezone
import uuid
import hashlib
from django.conf import settings
ACTIVE_STATES = [
TradeAcceptance.AcceptanceState.ACCEPTED,
@ -80,4 +86,170 @@ def trade_acceptance_post_delete(sender, instance, **kwargs):
trade_offer = instance.trade_offer
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta)
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta)
update_trade_offer_closed_status(trade_offer)
update_trade_offer_closed_status(trade_offer)
@receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_email_notification(sender, instance, created, **kwargs):
# Only proceed if the update was triggered by an acting user.
if not hasattr(instance, "_actioning_user"):
print("No actioning user")
return
# check if were in debug mode
if settings.DEBUG:
print("DEBUG: skipping email notification in debug mode")
return
acting_user = instance._actioning_user
state = instance.state
if state == TradeAcceptance.AcceptanceState.ACCEPTED:
state = "accepted"
elif state == TradeAcceptance.AcceptanceState.SENT:
state = "sent"
elif state == TradeAcceptance.AcceptanceState.RECEIVED:
state = "received"
elif state == TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR:
state = "thanked_by_initiator"
elif state == TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR:
state = "thanked_by_acceptor"
elif state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH:
state = "thanked_by_both"
elif state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR:
state = "rejected_by_initiator"
elif state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR:
state = "rejected_by_acceptor"
else:
return
print("state", state)
print("acting_user", acting_user)
# Determine the non-acting party:
if instance.trade_offer.initiated_by == acting_user:
# The initiator made the change; notify the acceptor.
recipient_user = instance.accepted_by.user
email_template = "trades/email/trade_update_" + state + ".txt"
email_subject = "[PKMN Trade Club] Trade Update"
else:
# The acceptor made the change; notify the initiator.
recipient_user = instance.trade_offer.initiated_by.user
email_template = "trades/email/trade_update_" + state + ".txt"
email_subject = "[PKMN Trade Club] Trade Update"
is_initiator = instance.trade_offer.initiated_by == acting_user
from django.template.loader import render_to_string
email_context = {
"has_card": instance.requested_card,
"want_card": instance.offered_card,
"hash": instance.hash,
"acting_user": acting_user.username,
"recipient_user": recipient_user.username,
"acting_user_friend_code": instance.trade_offer.initiated_by.friend_code if is_initiator else instance.accepted_by.friend_code,
"is_initiator": is_initiator,
"pk": instance.pk,
}
print("email_context", email_context)
email_body = render_to_string(email_template, email_context)
from django.core.mail import send_mail
send_mail(
email_subject,
email_body,
None, # Django will use DEFAULT_FROM_EMAIL from settings
[recipient_user.email],
)
# Clean up the temporary attribute.
del instance._actioning_user
@receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
"""
Update the denormalized reputation score on the user model based on
state transitions for TradeAcceptance.
- THANKED_BY_BOTH: both the initiator and the acceptor receive +1 when transitioning
into this state, and -1 when leaving it.
- REJECTED_BY_INITIATOR: only the acceptor gets -1 when transitioning into it (and +1 when leaving it).
- REJECTED_BY_ACCEPTOR: only the initiator gets -1 when transitioning into it (and +1 when leaving it).
Creation events are ignored because trade acceptances are never created with a terminal state.
"""
if created:
return # No action on creation as terminal states are not expected.
thanks_delta = 0
rejection_delta_initiator = 0 # Delta for the initiator's reputation
rejection_delta_acceptor = 0 # Delta for the acceptor's reputation
old_state = getattr(instance, '_old_state', None)
if old_state is None:
return
# Handle THANKED_BY_BOTH transitions
if old_state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH and instance.state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH:
thanks_delta = 1
elif old_state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH and instance.state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH:
thanks_delta = -1
# Handle REJECTED_BY_INITIATOR transitions (affects the acceptor)
if old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR:
rejection_delta_acceptor = -1
elif old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR:
rejection_delta_acceptor = 1
# Handle REJECTED_BY_ACCEPTOR transitions (affects the initiator)
if old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR:
rejection_delta_initiator = -1
elif old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR:
rejection_delta_initiator = 1
# Apply reputation updates:
# For THANKED_BY_BOTH, update both users.
if thanks_delta:
CustomUser.objects.filter(pk=instance.trade_offer.initiated_by.user.pk).update(
reputation_score=F("reputation_score") + thanks_delta
)
CustomUser.objects.filter(pk=instance.accepted_by.user.pk).update(
reputation_score=F("reputation_score") + thanks_delta
)
# For REJECTED_BY_INITIATOR, update only the acceptor.
if rejection_delta_acceptor:
CustomUser.objects.filter(pk=instance.accepted_by.user.pk).update(
reputation_score=F("reputation_score") + rejection_delta_acceptor
)
# For REJECTED_BY_ACCEPTOR, update only the initiator.
if rejection_delta_initiator:
CustomUser.objects.filter(pk=instance.trade_offer.initiated_by.user.pk).update(
reputation_score=F("reputation_score") + rejection_delta_initiator
)
@receiver(post_delete, sender=TradeAcceptance)
def trade_acceptance_reputation_delete(sender, instance, **kwargs):
"""
When a TradeAcceptance is deleted, adjust the reputation score for the
affected user(s) by reversing any reputation changes previously applied.
- If the deleted instance was in THANKED_BY_BOTH: subtract 1 from both parties.
- If it was in REJECTED_BY_INITIATOR: add 1 to the acceptor.
- If it was in REJECTED_BY_ACCEPTOR: add 1 to the initiator.
"""
if instance.state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH:
CustomUser.objects.filter(pk=instance.trade_offer.initiated_by.user.pk).update(
reputation_score=F("reputation_score") - 1
)
CustomUser.objects.filter(pk=instance.accepted_by.user.pk).update(
reputation_score=F("reputation_score") - 1
)
if instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR:
CustomUser.objects.filter(pk=instance.accepted_by.user.pk).update(
reputation_score=F("reputation_score") + 1
)
if instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR:
CustomUser.objects.filter(pk=instance.trade_offer.initiated_by.user.pk).update(
reputation_score=F("reputation_score") + 1
)

View file

@ -563,7 +563,7 @@ class TradeOfferSecurityTests(TestCase):
# Test without login
urls_to_test = [
reverse("trade_offer_create"),
reverse("trade_offer_my_list"),
reverse("trade_offer_dashboard"),
reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}),
]

View file

@ -4,7 +4,6 @@ from .views import (
TradeOfferCreateView,
TradeOfferCreateConfirmView,
TradeOfferAllListView,
TradeOfferMyListView,
TradeOfferDetailView,
TradeAcceptanceCreateView,
TradeAcceptanceUpdateView,
@ -16,8 +15,7 @@ from .views import (
urlpatterns = [
path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"),
path("create/confirm/", TradeOfferCreateConfirmView.as_view(), name="trade_offer_confirm_create"),
path("all/", TradeOfferAllListView.as_view(), name="trade_offer_list"),
path("my/", TradeOfferMyListView.as_view(), name="trade_offer_my_list"),
path("", TradeOfferAllListView.as_view(), name="trade_offer_list"),
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"),

View file

@ -104,120 +104,11 @@ class TradeOfferAllListView(ListView):
paginated_offers = Paginator(queryset, 10).get_page(page)
return render(
self.request,
"trades/_trade_offer_list_paginated.html",
"trades/_trade_offer_list.html",
{"offers": paginated_offers}
)
return super().render_to_response(context, **response_kwargs)
class TradeOfferMyListView(LoginRequiredMixin, FriendCodeRequiredMixin, ListView):
model = TradeOffer
template_name = "trades/trade_offer_my_list.html"
def get_selected_friend_code(self):
friend_codes = self.request.user.friend_codes.all()
friend_code_param = self.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 = self.request.user.default_friend_code or friend_codes.first()
else:
selected_friend_code = self.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.")
return selected_friend_code
def get_show_closed(self):
return self.request.GET.get("show_closed", "false").lower() == "true"
def get_my_trade_offers_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
queryset = self.get_queryset().filter(initiated_by=selected_friend_code)
if self.get_show_closed():
queryset = queryset.filter(is_closed=True)
else:
queryset = queryset.filter(is_closed=False)
return Paginator(queryset, 10).get_page(page_param)
def get_involved_acceptances(self, selected_friend_code):
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 self.get_show_closed():
return involved_acceptances_qs.filter(state__in=terminal_states)
return involved_acceptances_qs.exclude(state__in=terminal_states)
def get_trade_acceptances_waiting_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
involved_acceptances = self.get_involved_acceptances(selected_friend_code)
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])
)
return Paginator(waiting_acceptances, 10).get_page(page_param)
def get_other_party_trade_acceptances_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
involved_acceptances = self.get_involved_acceptances(selected_friend_code)
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_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk"))
return Paginator(other_acceptances, 10).get_page(page_param)
#@silk_profile(name="Trade Offer My List- Get Context Data")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
request = self.request
show_closed = self.get_show_closed()
context["show_closed"] = show_closed
selected_friend_code = self.get_selected_friend_code()
context["selected_friend_code"] = selected_friend_code
context["friend_codes"] = request.user.friend_codes.all()
# Use request params for initial full page load (could be None)
offers_page = request.GET.get("offers_page")
waiting_page = request.GET.get("waiting_page")
other_page = request.GET.get("other_page")
context["my_trade_offers_paginated"] = self.get_my_trade_offers_paginated(offers_page)
context["trade_acceptances_waiting_paginated"] = self.get_trade_acceptances_waiting_paginated(waiting_page)
context["other_party_trade_acceptances_paginated"] = self.get_other_party_trade_acceptances_paginated(other_page)
return context
#@silk_profile(name="Trade Offer My List- Render to Response")
def render_to_response(self, context, **response_kwargs):
# For AJAX requests, return only the paginated fragment.
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
ajax_section = self.request.GET.get("ajax_section")
page = self.request.GET.get("page")
if ajax_section == "my_trade_offers":
offers = self.get_my_trade_offers_paginated(page)
elif ajax_section == "waiting_acceptances":
offers = self.get_trade_acceptances_waiting_paginated(page)
elif ajax_section == "other_party_acceptances":
offers = self.get_other_party_trade_acceptances_paginated(page)
else:
# Fallback to my trade offers.
offers = self.get_my_trade_offers_paginated(page)
return render(self.request, "trades/_trade_offer_list_paginated.html", {"offers": offers})
return super().render_to_response(context, **response_kwargs)
class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView):
model = TradeOffer
success_url = reverse_lazy("trade_offer_list")
@ -526,6 +417,8 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
def form_valid(self, form):
form.instance.trade_offer = self.trade_offer
# Set the actioning user before saving
form.instance._actioning_user = self.request.user
self.object = form.save()
return HttpResponseRedirect(self.get_success_url())
@ -616,7 +509,6 @@ class TradeOfferPNGView(View):
# If the image is already generated and stored, serve it directly.
if trade_offer.image:
trade_offer.image.open()
print(f"Serving cached image for trade offer {trade_offer.pk}")
return HttpResponse(trade_offer.image.read(), content_type="image/png")
# Acquire PostgreSQL advisory lock to prevent concurrent generation.
@ -629,10 +521,8 @@ class TradeOfferPNGView(View):
trade_offer.refresh_from_db()
if trade_offer.image:
trade_offer.image.open()
print(f"Serving recently-cached image for trade offer {trade_offer.pk}")
return HttpResponse(trade_offer.image.read(), content_type="image/png")
print(f"Generating PNG for trade offer {trade_offer.pk}")
# Generate PNG using Playwright as before.
from trades.templatetags import trade_offer_tags
tag_context = trade_offer_tags.render_trade_offer_png(