from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver from django.db.models import F from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance, TradeOffer 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.core.mail import send_mail from django.conf import settings from django.template.loader import render_to_string from django.contrib.sites.models import Site from django.core.cache import cache import logging POSITIVE_STATES = [ TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.SENT, TradeAcceptance.AcceptanceState.RECEIVED, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, ] def adjust_qty_for_trade_offer(trade_offer, card, side, delta): """ Increment (or decrement) qty_accepted by delta for the given card on the specified side. """ if side == 'have': TradeOfferHaveCard.objects.filter( trade_offer=trade_offer, card=card ).update(qty_accepted=F('qty_accepted') + delta) elif side == 'want': TradeOfferWantCard.objects.filter( trade_offer=trade_offer, card=card ).update(qty_accepted=F('qty_accepted') + delta) def update_trade_offer_closed_status(trade_offer): """ Check if both sides of the trade offer meet the quantity requirement. Mark the trade_offer as closed if all cards on one side have qty_accepted greater than or equal to quantity; otherwise, mark it as open. """ have_complete = not TradeOfferHaveCard.objects.filter( trade_offer=trade_offer, qty_accepted__lt=F('quantity') ).exists() want_complete = not TradeOfferWantCard.objects.filter( trade_offer=trade_offer, qty_accepted__lt=F('quantity') ).exists() closed = have_complete or want_complete if trade_offer.is_closed != closed: trade_offer.is_closed = closed trade_offer.save(update_fields=["is_closed"]) @receiver(pre_save, sender=TradeAcceptance) def trade_acceptance_pre_save(sender, instance, **kwargs): # Skip signal processing during raw fixture load or when saving a new instance if kwargs.get("raw", False) or instance._state.adding: return if instance.pk: old_instance = TradeAcceptance.objects.get(pk=instance.pk) instance._old_state = old_instance.state @receiver(post_save, sender=TradeAcceptance) def trade_acceptance_post_save(sender, instance, created, **kwargs): delta = 0 if created: if instance.state in POSITIVE_STATES: delta = 1 else: old_state = getattr(instance, '_old_state', None) if old_state is not None: if old_state in POSITIVE_STATES and instance.state not in POSITIVE_STATES: delta = -1 elif old_state not in POSITIVE_STATES and instance.state in POSITIVE_STATES: delta = 1 if delta != 0: 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) @receiver(post_delete, sender=TradeAcceptance) def trade_acceptance_post_delete(sender, instance, **kwargs): if instance.state in POSITIVE_STATES: delta = -1 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) @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"): return # check if were in debug mode # if settings.DEBUG: # print("DEBUG: skipping email notification in debug mode") # return acting_user = instance._actioning_user del 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 # Determine the non-acting party: if instance.trade_offer.initiated_by.user.pk == acting_user.pk: # The initiator made the change; notify the acceptor. recipient_user = instance.accepted_by.user elif instance.accepted_by.user.pk == acting_user.pk: # The acceptor made the change; notify the initiator. recipient_user = instance.trade_offer.initiated_by.user else: return if not recipient_user.enable_email_notifications: return is_initiator = instance.trade_offer.initiated_by.user.pk == acting_user.pk email_context = { "has_card": instance.requested_card, "want_card": instance.offered_card, "hash": instance.hash, "acting_user": acting_user.username, "acting_user_ign": instance.trade_offer.initiated_by.in_game_name if is_initiator else instance.accepted_by.in_game_name, "recipient_user": recipient_user.username, "recipient_user_ign": instance.accepted_by.in_game_name if is_initiator else instance.trade_offer.initiated_by.in_game_name, "acting_user_friend_code": instance.trade_offer.initiated_by.friend_code if is_initiator else instance.accepted_by.friend_code, "is_initiator": is_initiator, "domain": "https://" + Site.objects.get_current().domain, "pk": instance.pk, } email_template = "email/trades/trade_update_" + state + ".txt" email_subject = render_to_string("email/common/subject.txt", email_context) email_subject += render_to_string("email/trades/trade_update_" + state + "_subject.txt", email_context) email_body = render_to_string(email_template, email_context) send_mail( email_subject, email_body, None, [recipient_user.email], ) @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 ) @receiver(post_save, sender=TradeOfferHaveCard) @receiver(post_delete, sender=TradeOfferHaveCard) @receiver(post_save, sender=TradeOfferWantCard) @receiver(post_delete, sender=TradeOfferWantCard) @receiver(post_save, sender=TradeAcceptance) @receiver(post_delete, sender=TradeAcceptance) def bubble_up_trade_offer_updates(sender, instance, **kwargs): """ Bubble up updated_at to the TradeOffer model when related instances change. Also invalidates any cached image by deleting the file. """ trade_offer = getattr(instance, 'trade_offer', None) if trade_offer and trade_offer.image: trade_offer.image.delete(save=True) # deleting the image will trigger a save, which updates the updated_at field elif trade_offer: trade_offer.save(update_fields=['updated_at'])