Bugfixes for emails and bugfixes for trade acceptance quantities being checked on create, closes #1

This commit is contained in:
badblocks 2025-04-08 00:59:40 -07:00
parent 32da8157a6
commit bd7a65975f
21 changed files with 95 additions and 86 deletions

View file

@ -2,7 +2,6 @@
FROM python:3.12.2-bookworm FROM python:3.12.2-bookworm
# Set environment variables # Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
# Create and set work directory called `app` # Create and set work directory called `app`
@ -22,16 +21,14 @@ COPY . /code/
COPY .env.production /code/.env COPY .env.production /code/.env
ENV HOME=/code ENV HOME=/code
# Install NPM & node.js
RUN apt-get update && apt-get install -y nodejs npm xvfb netcat-openbsd RUN apt-get update && apt-get install -y nodejs npm xvfb netcat-openbsd
# Install playwright (via pip and install script)
RUN playwright install-deps && playwright install RUN playwright install-deps && playwright install
# Expose port 8000 # Expose port 8000
EXPOSE 8000 EXPOSE 8000
#USER 10003:10003
RUN python manage.py collectstatic --noinput RUN python manage.py collectstatic --noinput
#RUN python manage.py loaddata seed/* && python manage.py createcachetable django_cache #RUN python manage.py loaddata seed/* && python manage.py createcachetable django_cache

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-04-07 04:19 # Generated by Django 5.1.2 on 2025-04-08 06:24
import accounts.models import accounts.models
import django.contrib.auth.models import django.contrib.auth.models
@ -33,7 +33,7 @@ class Migration(migrations.Migration):
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('show_friend_code_on_link_previews', models.BooleanField(default=False, help_text='This will primarily affect share link previews on X, Discord, etc.', verbose_name='Show Friend Code on Link Previews')), ('show_friend_code_on_link_previews', models.BooleanField(default=False, help_text='This will primarily affect share link previews on X, Discord, etc.', verbose_name='Show Friend Code on Link Previews')),
('reputation_score', models.PositiveIntegerField(default=0)), ('reputation_score', models.IntegerField(default=0)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
], ],

View file

@ -85,7 +85,7 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
) )
return redirect(self.get_success_url()) return redirect(self.get_success_url())
trade_offer_exists = TradeOffer.all_offers.filter(initiated_by_id=self.object.pk).exists() trade_offer_exists = TradeOffer.objects.filter(initiated_by_id=self.object.pk).exists()
trade_acceptance_exists = TradeAcceptance.objects.filter(accepted_by_id=self.object.pk).exists() trade_acceptance_exists = TradeAcceptance.objects.filter(accepted_by_id=self.object.pk).exists()
if trade_offer_exists or trade_acceptance_exists: if trade_offer_exists or trade_acceptance_exists:

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-04-07 04:19 # Generated by Django 5.1.2 on 2025-04-08 06:24
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

4
deploy.sh Normal file
View file

@ -0,0 +1,4 @@
#!/bin/bash
python manage.py migrate --noinput
python manage.py clear_cache
python manage.py collectstatic --noinput

View file

@ -24,6 +24,3 @@ uv run python manage.py loaddata seed/0*
echo "Creating cache table..." echo "Creating cache table..."
uv run python manage.py createcachetable uv run python manage.py createcachetable
echo "Seeding default friend codes..."
uv run python manage.py seed_default_friend_codes

View file

@ -1,2 +1,3 @@
Thank you for using PᴋMɴ Trade Club.
Happy trading! Happy trading!
PKMN Trade Club

View file

@ -1 +1 @@
[PKMN Trade Club] [PᴋMɴ Trade Club]

View file

@ -5,7 +5,6 @@ Great news! {{ acting_user }} ({{ acting_user_ign }} {{ acting_user_friend_code
Trade Details: Trade Details:
- They have: {{ want_card }} - They have: {{ want_card }}
- They want: {{ has_card }} - They want: {{ has_card }}
(#{{ hash }})
What's next? You can now mark the trade as "Sent" once you've offered the card to them in the app, or reject the trade if needed. What's next? You can now mark the trade as "Sent" once you've offered the card to them in the app, or reject the trade if needed.
@ -13,3 +12,5 @@ Visit your dashboard to manage this trade:
{{ domain }}{% url 'dashboard' %} {{ domain }}{% url 'dashboard' %}
{% include 'email/common/footer.txt' %} {% include 'email/common/footer.txt' %}
Trade ID: #{{ hash }}

View file

@ -5,7 +5,6 @@ Great news! {{ acting_user }} ({{ acting_user_ign }} {{ acting_user_friend_code
Trade Details: Trade Details:
- They sent: {{ want_card }} - They sent: {{ want_card }}
- They received: {{ has_card }} - They received: {{ has_card }}
(#{{ hash }})
What's next? Send a thank you to this user to increase their reputation! What's next? Send a thank you to this user to increase their reputation!
@ -13,3 +12,5 @@ Visit your dashboard to send thanks:
{{ domain }}{% url 'dashboard' %} {{ domain }}{% url 'dashboard' %}
{% include 'email/common/footer.txt' %} {% include 'email/common/footer.txt' %}
Trade ID: #{{ hash }}

View file

@ -1,11 +1,10 @@
{% include 'email/common/header.txt' %} {% include 'email/common/header.txt' %}
We're sorry to inform you that {{ acting_user }} ({{ acting_user_friend_code }}) has canceled their trade acceptance. We're sorry to inform you that {{ acting_user }} ({{ acting_user_ign }} {{ acting_user_friend_code }}) has canceled their trade acceptance.
Trade Details: Trade Details:
- They had: {{ want_card }} - They had: {{ want_card }}
- They wanted: {{ has_card }} - They wanted: {{ has_card }}
(#{{ hash }})
Your trade offer is still active and available for other users to accept. Your trade offer is still active and available for other users to accept.
@ -13,3 +12,5 @@ Visit your dashboard to manage your trade offers:
{{ domain }}{% url 'dashboard' %} {{ domain }}{% url 'dashboard' %}
{% include 'email/common/footer.txt' %} {% include 'email/common/footer.txt' %}
Trade ID: #{{ hash }}

View file

@ -1,11 +1,10 @@
{% include 'email/common/header.txt' %} {% include 'email/common/header.txt' %}
We're sorry to inform you that {{ acting_user }} ({{ acting_user_friend_code }}) has rejected the trade. We're sorry to inform you that {{ acting_user }} ({{ acting_user_ign }} {{ acting_user_friend_code }}) has rejected the trade.
Trade Details: Trade Details:
- You had: {{ has_card }} - You had: {{ has_card }}
- You wanted: {{ want_card }} - You wanted: {{ want_card }}
(#{{ hash }})
Don't worry - there are plenty of other trade opportunities available! You can browse our marketplace for similar trades. Don't worry - there are plenty of other trade opportunities available! You can browse our marketplace for similar trades.
@ -13,3 +12,5 @@ Visit the marketplace:
{{ domain }}{% url 'trade_offer_list' %} {{ domain }}{% url 'trade_offer_list' %}
{% include 'email/common/footer.txt' %} {% include 'email/common/footer.txt' %}
Trade ID: #{{ hash }}

View file

@ -5,7 +5,6 @@
Trade Details: Trade Details:
- You have: {{ want_card }} - You have: {{ want_card }}
- You want: {{ has_card }} - You want: {{ has_card }}
(#{{ hash }})
What's next? Once you respond to the trade in the app, please mark the trade as "Received" in your dashboard. What's next? Once you respond to the trade in the app, please mark the trade as "Received" in your dashboard.
@ -13,3 +12,5 @@ Visit your dashboard to manage this trade:
{{ domain }}{% url 'dashboard' %} {{ domain }}{% url 'dashboard' %}
{% include 'email/common/footer.txt' %} {% include 'email/common/footer.txt' %}
Trade ID: #{{ hash }}

View file

@ -1,11 +1,10 @@
{% include 'email/common/header.txt' %} {% include 'email/common/header.txt' %}
{{ acting_user }} ({{ acting_user_friend_code }}) has sent their thanks for the successful trade! {{ acting_user }} ({{ acting_user_ign }} {{ acting_user_friend_code }}) has sent their thanks for the successful trade!
Trade Details: Trade Details:
- They sent: {{ want_card }} - They sent: {{ want_card }}
- They received: {{ has_card }} - They received: {{ has_card }}
(#{{ hash }})
What's next? Send a thank you to this user to increase their reputation! What's next? Send a thank you to this user to increase their reputation!
@ -13,3 +12,5 @@ Visit your dashboard to send thanks:
{{ domain }}{% url 'dashboard' %} {{ domain }}{% url 'dashboard' %}
{% include 'email/common/footer.txt' %} {% include 'email/common/footer.txt' %}
Trade ID: #{{ hash }}

View file

@ -1,14 +1,13 @@
{% include 'email/common/header.txt' %} {% include 'email/common/header.txt' %}
{{ acting_user }} ({{ acting_user_friend_code }}) has sent their thanks for the successful trade! {{ acting_user }} ({{ acting_user_ign }} {{ acting_user_friend_code }}) has sent their thanks for the successful trade!
Trade Details: Trade Details:
- {% if is_initiator %}They sent: {{ has_card }}{% else %}You sent: {{ want_card }}{% endif %} - {% if is_initiator %}You{% else %}They{% endif %} sent: {{ want_card }}
- {% if is_initiator %}They received: {{ want_card }}{% else %}You received: {{ has_card }}{% endif %} - {% if is_initiator %}You{% else %}They{% endif %} received: {{ has_card }}
(#{{ hash }})
This trade is now completed; no further actions can be made. This trade is now completed; no further actions can be made.
Thank you for using PKMN Trade Club.
{% include 'email/common/footer.txt' %} {% include 'email/common/footer.txt' %}
Trade ID: #{{ hash }}

View file

@ -1,11 +1,10 @@
{% include 'email/common/header.txt' %} {% include 'email/common/header.txt' %}
{{ acting_user }} ({{ acting_user_friend_code }}) has sent their thanks for the successful trade! {{ acting_user }} ({{ acting_user_ign }} {{ acting_user_friend_code }}) has sent their thanks for the successful trade!
Trade Details: Trade Details:
- You sent: {{ want_card }} - You sent: {{ want_card }}
- You received: {{ has_card }} - You received: {{ has_card }}
(#{{ hash }})
What's next? Send a thank you to this user to increase their reputation! What's next? Send a thank you to this user to increase their reputation!
@ -13,3 +12,5 @@ Visit your dashboard to send thanks:
{{ domain }}{% url 'dashboard' %} {{ domain }}{% url 'dashboard' %}
{% include 'email/common/footer.txt' %} {% include 'email/common/footer.txt' %}
Trade ID: #{{ hash }}

View file

@ -62,7 +62,7 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
TradeAcceptance.AcceptanceState.RECEIVED, TradeAcceptance.AcceptanceState.RECEIVED,
] ]
available_requested_ids = [] available_requested_ids = []
for through_obj in trade_offer.trade_offer_have_cards.all(): for through_obj in trade_offer.have_cards_available:
active_count = trade_offer.acceptances.filter( active_count = trade_offer.acceptances.filter(
requested_card=through_obj.card, requested_card=through_obj.card,
state__in=active_states state__in=active_states
@ -73,7 +73,7 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
# Update available offered_card choices from the TradeOffer's "want" side. # Update available offered_card choices from the TradeOffer's "want" side.
available_offered_ids = [] available_offered_ids = []
for through_obj in trade_offer.trade_offer_want_cards.all(): for through_obj in trade_offer.want_cards_available:
active_count = trade_offer.acceptances.filter( active_count = trade_offer.acceptances.filter(
offered_card=through_obj.card, offered_card=through_obj.card,
state__in=active_states state__in=active_states

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-04-07 04:19 # Generated by Django 5.1.2 on 2025-04-08 06:24
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View file

@ -26,28 +26,22 @@ class TradeOfferManager(models.Manager):
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset().select_related( qs = super().get_queryset().select_related(
"initiated_by",
"initiated_by__user", "initiated_by__user",
).prefetch_related( ).prefetch_related(
"trade_offer_have_cards__card", "trade_offer_have_cards__card",
"trade_offer_want_cards__card", "trade_offer_want_cards__card",
"acceptances", "acceptances",
"acceptances__accepted_by",
"acceptances__requested_card", "acceptances__requested_card",
"acceptances__offered_card", "acceptances__offered_card",
"acceptances__accepted_by__user", "acceptances__accepted_by__user",
) )
cutoff = timezone.now() - timedelta(days=28)
qs = qs.filter(created_at__gte=cutoff)
return qs.order_by("-updated_at") return qs.order_by("-updated_at")
class TradeOfferAllManager(models.Manager):
def get_queryset(self):
# Return all trade offers without filtering by the cutoff.
return super().get_queryset()
class TradeOffer(models.Model): class TradeOffer(models.Model):
objects = TradeOfferManager() objects = TradeOfferManager()
all_offers = TradeOfferAllManager()
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
is_closed = models.BooleanField(default=False, db_index=True) is_closed = models.BooleanField(default=False, db_index=True)
@ -106,6 +100,17 @@ class TradeOffer(models.Model):
# Use super().save() here to avoid recursion. # Use super().save() here to avoid recursion.
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"]) super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
# New derived properties for available cards
@property
def have_cards_available(self):
# Returns the list of have_cards (through objects) that still have available quantity.
return [item for item in self.trade_offer_have_cards.all() if item.quantity > item.qty_accepted]
@property
def want_cards_available(self):
# Returns the list of want_cards (through objects) that still have available quantity.
return [item for item in self.trade_offer_want_cards.all() if item.quantity > item.qty_accepted]
class TradeOfferHaveCard(models.Model): class TradeOfferHaveCard(models.Model):
""" """
Through model for TradeOffer.have_cards. Through model for TradeOffer.have_cards.
@ -176,6 +181,16 @@ class TradeAcceptance(models.Model):
REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator' REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator'
REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor' REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor'
# DRY improvement: define active states once as a class-level constant.
ACTIVE_STATES = [
AcceptanceState.ACCEPTED,
AcceptanceState.SENT,
AcceptanceState.RECEIVED,
AcceptanceState.THANKED_BY_INITIATOR,
AcceptanceState.THANKED_BY_ACCEPTOR,
AcceptanceState.THANKED_BY_BOTH,
]
trade_offer = models.ForeignKey( trade_offer = models.ForeignKey(
TradeOffer, TradeOffer,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -187,13 +202,11 @@ class TradeAcceptance(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='trade_acceptances' related_name='trade_acceptances'
) )
# The acceptor selects one card the initiator is offering (from have_cards)
requested_card = models.ForeignKey( requested_card = models.ForeignKey(
"cards.Card", "cards.Card",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='accepted_requested' related_name='accepted_requested'
) )
# And one card from the initiator's wanted cards (from want_cards)
offered_card = models.ForeignKey( offered_card = models.ForeignKey(
"cards.Card", "cards.Card",
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -266,40 +279,25 @@ class TradeAcceptance(models.Model):
return not self.is_completed_or_rejected return not self.is_completed_or_rejected
def clean(self): def clean(self):
# Validate that the requested and offered cards exist in the through tables. from django.core.exceptions import ValidationError
try: try:
have_through_obj = self.trade_offer.trade_offer_have_cards.get(card_id=self.requested_card_id) have_card = self.trade_offer.trade_offer_have_cards.get(card_id=self.requested_card_id)
except TradeOfferHaveCard.DoesNotExist: except TradeOfferHaveCard.DoesNotExist:
raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).") raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).")
try: try:
want_through_obj = self.trade_offer.trade_offer_want_cards.get(card_id=self.offered_card_id) want_card = self.trade_offer.trade_offer_want_cards.get(card_id=self.offered_card_id)
except TradeOfferWantCard.DoesNotExist: except TradeOfferWantCard.DoesNotExist:
raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).") raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).")
if not self.pk and self.trade_offer.is_closed: # Only perform these validations on creation (when self.pk is None).
raise ValidationError("This trade offer is closed. No more acceptances are allowed.") if self.pk is None:
if self.trade_offer.is_closed:
active_states = [ raise ValidationError("This trade offer is closed. No more acceptances are allowed.")
self.AcceptanceState.ACCEPTED, # Use direct comparison with qty_accepted and quantity.
self.AcceptanceState.SENT, if have_card.qty_accepted >= have_card.quantity:
self.AcceptanceState.RECEIVED, raise ValidationError("The requested card has no available quantity.")
self.AcceptanceState.THANKED_BY_INITIATOR, if want_card.qty_accepted >= want_card.quantity:
self.AcceptanceState.THANKED_BY_ACCEPTOR, raise ValidationError("The offered card has no available quantity.")
self.AcceptanceState.THANKED_BY_BOTH,
]
active_acceptances = self.trade_offer.acceptances.filter(state__in=active_states)
if self.pk:
active_acceptances = active_acceptances.exclude(pk=self.pk)
requested_count = active_acceptances.filter(requested_card_id=self.requested_card_id).count()
if requested_count >= have_through_obj.quantity:
raise ValidationError("This requested card has been fully accepted.")
offered_count = active_acceptances.filter(offered_card_id=self.offered_card_id).count()
if offered_count >= want_through_obj.quantity:
raise ValidationError("This offered card has already been fully used.")
def get_step_number(self): def get_step_number(self):
if self.state in [ if self.state in [

View file

@ -58,6 +58,10 @@ def update_trade_offer_closed_status(trade_offer):
@receiver(pre_save, sender=TradeAcceptance) @receiver(pre_save, sender=TradeAcceptance)
def trade_acceptance_pre_save(sender, instance, **kwargs): 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: if instance.pk:
old_instance = TradeAcceptance.objects.get(pk=instance.pk) old_instance = TradeAcceptance.objects.get(pk=instance.pk)
instance._old_state = old_instance.state instance._old_state = old_instance.state
@ -98,9 +102,9 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
return return
# check if were in debug mode # check if were in debug mode
if settings.DEBUG: # if settings.DEBUG:
print("DEBUG: skipping email notification in debug mode") # print("DEBUG: skipping email notification in debug mode")
return # return
acting_user = instance._actioning_user acting_user = instance._actioning_user
state = instance.state state = instance.state
@ -126,14 +130,16 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
# Determine the non-acting party: # Determine the non-acting party:
if instance.trade_offer.initiated_by == acting_user: if instance.trade_offer.initiated_by.user.pk == acting_user.pk:
# The initiator made the change; notify the acceptor. # The initiator made the change; notify the acceptor.
recipient_user = instance.accepted_by.user recipient_user = instance.accepted_by.user
else: elif instance.accepted_by.user.pk == acting_user.pk:
# The acceptor made the change; notify the initiator. # The acceptor made the change; notify the initiator.
recipient_user = instance.trade_offer.initiated_by.user recipient_user = instance.trade_offer.initiated_by.user
else:
return
is_initiator = instance.trade_offer.initiated_by == acting_user is_initiator = instance.trade_offer.initiated_by.user.pk == acting_user.pk
email_context = { email_context = {
"has_card": instance.requested_card, "has_card": instance.requested_card,
@ -145,7 +151,7 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
"recipient_user_ign": instance.accepted_by.in_game_name if is_initiator else instance.trade_offer.initiated_by.in_game_name, "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, "acting_user_friend_code": instance.trade_offer.initiated_by.friend_code if is_initiator else instance.accepted_by.friend_code,
"is_initiator": is_initiator, "is_initiator": is_initiator,
"domain": Site.objects.get_current().domain, "domain": "https://" + Site.objects.get_current().domain,
"pk": instance.pk, "pk": instance.pk,
} }
email_template = "email/trades/trade_update_" + state + ".txt" email_template = "email/trades/trade_update_" + state + ".txt"