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 accounts.models
import django.contrib.auth.models
@ -33,6 +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')),
('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')),
('reputation_score', models.PositiveIntegerField(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')),
('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

@ -17,6 +17,7 @@ class CustomUser(AbstractUser):
verbose_name="Show Friend Code on Link Previews",
help_text="This will primarily affect share link previews on X, Discord, etc."
)
reputation_score = models.PositiveIntegerField(default=0)
def __str__(self):
return self.email

View file

@ -5,8 +5,7 @@ from .views import (
DeleteFriendCodeView,
ChangeDefaultFriendCodeView,
EditFriendCodeView,
SettingsView,
ProfileView,
DashboardView,
)
urlpatterns = [
@ -16,6 +15,5 @@ urlpatterns = [
path("friend-codes/edit/<int:pk>/", EditFriendCodeView.as_view(), name="edit_friend_code"),
path("friend-codes/delete/<int:pk>/", DeleteFriendCodeView.as_view(), name="delete_friend_code"),
path("friend-codes/default/<int:pk>/", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"),
path("settings/", SettingsView.as_view(), name="settings"),
path("profile/", ProfileView.as_view(), name="profile"),
path("dashboard/", DashboardView.as_view(), name="dashboard"),
]

View file

@ -7,6 +7,8 @@ from accounts.models import FriendCode, CustomUser
from accounts.forms import FriendCodeForm, UserSettingsForm
from django.db.models import Case, When, Value, BooleanField
from trades.models import TradeOffer, TradeAcceptance
from django.core.exceptions import PermissionDenied
from trades.mixins import FriendCodeRequiredMixin
class ListFriendCodesView(LoginRequiredMixin, ListView):
"""
@ -127,30 +129,6 @@ class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
messages.success(request, "Default friend code updated successfully.")
return redirect("list_friend_codes")
# Updated SettingsView to update the new user setting.
class SettingsView(LoginRequiredMixin, UpdateView):
"""
Display account navigation links and allow the user to update their friend code
visibility setting.
"""
model = CustomUser
form_class = UserSettingsForm
template_name = "account/settings.html"
success_url = reverse_lazy("settings")
def get_object(self):
return self.request.user
def form_valid(self, form):
messages.success(self.request, "Settings updated successfully.")
return super().form_valid(form)
class ProfileView(LoginRequiredMixin, TemplateView):
"""
Display the user's profile.
"""
template_name = "account/profile.html"
class EditFriendCodeView(LoginRequiredMixin, UpdateView):
"""
Edit the in-game name for a friend code.
@ -171,3 +149,146 @@ class EditFriendCodeView(LoginRequiredMixin, UpdateView):
def form_valid(self, form):
messages.success(self.request, "Friend code updated successfully.")
return super().form_valid(form)
class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, TemplateView):
template_name = "account/dashboard.html"
def post(self, request, *args, **kwargs):
if 'update_settings' in request.POST:
from accounts.forms import UserSettingsForm
form = UserSettingsForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, "Settings updated successfully.")
else:
messages.error(request, "Please correct the errors below.")
return self.get(request, *args, **kwargs)
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_dashboard_offers_paginated(self, page_param):
from django.core.paginator import Paginator
selected_friend_code = self.get_selected_friend_code()
queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=False)
return Paginator(queryset, 10).get_page(page_param)
def get_involved_acceptances(self, selected_friend_code):
from django.db.models import Q
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 = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code)
).order_by("-updated_at")
return involved.exclude(state__in=terminal_states)
def get_trade_acceptances_waiting_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
involved = self.get_involved_acceptances(selected_friend_code)
from django.db.models import Q
waiting = involved.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])
)
from django.core.paginator import Paginator
return Paginator(waiting, 10).get_page(page_param)
def get_other_party_trade_acceptances_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
involved = self.get_involved_acceptances(selected_friend_code)
from django.db.models import Q
waiting = involved.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])
)
others = involved.exclude(pk__in=waiting.values("pk"))
from django.core.paginator import Paginator
return Paginator(others, 10).get_page(page_param)
def get_closed_offers_paginated(self, page_param):
from django.core.paginator import Paginator
selected_friend_code = self.get_selected_friend_code()
queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=True)
return Paginator(queryset, 10).get_page(page_param)
def get_closed_acceptances_paginated(self, page_param):
from django.db.models import Q
from django.core.paginator import Paginator
selected_friend_code = self.get_selected_friend_code()
terminal_success_states = [
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
]
acceptance_qs = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code),
state__in=terminal_success_states
).order_by("-updated_at")
return Paginator(acceptance_qs, 10).get_page(page_param)
def get_rejected_by_me_paginated(self, page_param):
from django.db.models import Q
from django.core.paginator import Paginator
selected_friend_code = self.get_selected_friend_code()
rejection = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) |
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR)
).order_by("-updated_at")
return Paginator(rejection, 10).get_page(page_param)
def get_rejected_by_them_paginated(self, page_param):
from django.db.models import Q
from django.core.paginator import Paginator
selected_friend_code = self.get_selected_friend_code()
rejection = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) |
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR)
).order_by("-updated_at")
return Paginator(rejection, 10).get_page(page_param)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
request = self.request
selected_friend_code = self.get_selected_friend_code()
context["selected_friend_code"] = selected_friend_code
context["friend_codes"] = request.user.friend_codes.all()
offers_page = request.GET.get("offers_page", 1)
waiting_page = request.GET.get("waiting_page", 1)
other_page = request.GET.get("other_page", 1)
closed_offers_page = request.GET.get("closed_offers_page", 1)
closed_acceptances_page = request.GET.get("closed_acceptances_page", 1)
rejected_by_me_page = request.GET.get("rejected_by_me_page", 1)
rejected_by_them_page = request.GET.get("rejected_by_them_page", 1)
context["dashboard_offers_paginated"] = self.get_dashboard_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)
context["closed_offers_paginated"] = self.get_closed_offers_paginated(closed_offers_page)
context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated(closed_acceptances_page)
context["rejected_by_me_paginated"] = self.get_rejected_by_me_paginated(rejected_by_me_page)
context["rejected_by_them_paginated"] = self.get_rejected_by_them_paginated(rejected_by_them_page)
from accounts.forms import UserSettingsForm
context["settings_form"] = UserSettingsForm(instance=request.user)
context["active_tab"] = request.GET.get("tab", "dash")
return context

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

@ -1,11 +0,0 @@
from django import template
register = template.Library()
@register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True)
def render_pagination(context, page_obj):
"""
Renders pagination controls given a page_obj.
The controls use values like page_obj.number, page_obj.has_previous, etc.
"""
return {"page_obj": page_obj}

View file

@ -148,13 +148,13 @@ class CardListView(ReusablePaginationMixin, ListView):
# For non-grouped mode, transform the built-in paginator page
if "page_obj" in context:
page = context["page_obj"]
# Create a unified pagination context dict
# Create a unified pagination context dict with updated keys
custom_page_obj = {
"number": page.number,
"has_previous": page.has_previous(),
"has_next": page.has_next(),
"previous_page": page.previous_page_number() if page.has_previous() else 1,
"next_page": page.next_page_number() if page.has_next() else page.paginator.num_pages,
"previous_page_number": page.previous_page_number() if page.has_previous() else 1,
"next_page_number": page.next_page_number() if page.has_next() else page.paginator.num_pages,
"paginator": {"num_pages": page.paginator.num_pages},
}
context["page_obj"] = custom_page_obj

0
common/__init__.py Normal file
View file

5
common/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
name = 'common'

View file

View file

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True)
def render_pagination(context, page_obj, hide_if_one_page=True):
"""
Renders the pagination controls given a page_obj. Optionally hides the controls if there is only one page.
"""
return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page}

View file

@ -52,6 +52,7 @@ INSTALLED_APPS = [
"tailwind",
"theme",
#"django_browser_reload",
"common",
"accounts",
"cards",
"home",

View file

@ -0,0 +1,179 @@
{% extends "base.html" %}
{% load i18n static crispy_forms_tags gravatar %}
{% block head_title %}{{ _('Dashboard') }}{% endblock %}
{% block content %}
<div class="container mx-auto" x-data="{ activeTab: '{{ active_tab|default:'dash' }}' }">
<!-- Tab Navigation -->
<div class="tabs tabs-border mb-8">
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'dash'}" @click="activeTab = 'dash'">{{ _('Dash') }}</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'dashboard_offers'}" @click="activeTab = 'dashboard_offers'">{{ _('Your Trade Offers') }} ({{ dashboard_offers_paginated.paginator.count }})</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'waiting_on_you'}" @click="activeTab = 'waiting_on_you'">{{ _('Waiting on You') }} ({{ trade_acceptances_waiting_paginated.paginator.count }})</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'waiting_on_them'}" @click="activeTab = 'waiting_on_them'">{{ _('Waiting on Them') }} ({{ other_party_trade_acceptances_paginated.paginator.count }})</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'trade_history'}" @click="activeTab = 'trade_history'">{{ _('Trade History') }}</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'profile'}" @click="activeTab = 'profile'">{{ _('Profile') }}</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'settings'}" @click="activeTab = 'settings'">{{ _('Settings') }}</button>
</div>
<!-- Tab Panels -->
<!-- Dash Tab - Dashboard Summary -->
<div x-show="activeTab === 'dash'">
<div class="card bg-base-100 shadow-xl mb-4">
<div class="card-body">
<h2 class="card-title">{{ _('Account Summary') }}</h2>
<p><strong>{{ _('Username:') }}</strong> {{ request.user.username }}</p>
<p><strong>{{ _('Default Friend Code:') }}</strong> {{ selected_friend_code.friend_code }}</p>
<p><strong>{{ _('Reputation Score:') }}</strong> {{ request.user.reputation_score }}</p>
</div>
</div>
<div class="card bg-base-100 shadow-xl mb-4">
<div class="card-body">
<h2 class="card-title">{{ _('Trade Summary') }}</h2>
<div class="stats shadow">
<div class="stat">
<div class="stat-title">{{ _('Your Trade Offers') }}</div>
<div class="stat-value">{{ dashboard_offers_paginated.paginator.count }}</div>
<div class="stat-desc">{{ _('Active Offers') }}</div>
</div>
<div class="stat">
<div class="stat-title">{{ _('Waiting on You') }}</div>
<div class="stat-value">{{ trade_acceptances_waiting_paginated.paginator.count }}</div>
<div class="stat-desc">{{ _('Pending Requests') }}</div>
</div>
<div class="stat">
<div class="stat-title">{{ _('Waiting on Them') }}</div>
<div class="stat-value">{{ other_party_trade_acceptances_paginated.paginator.count }}</div>
<div class="stat-desc">{{ _('Pending Responses') }}</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{{ _('Quick Actions') }}</h2>
<div class="flex space-x-4">
<a href="{% url 'trade_offer_create' %}" class="btn btn-primary">{{ _('Create New Offer') }}</a>
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">{{ _('View All Offers') }}</a>
</div>
</div>
</div>
</div>
<!-- Your Trade Offers Tab -->
<div x-show="activeTab === 'dashboard_offers'" x-data="tradeOffersPagination('{% url 'dashboard' %}?ajax_section=dashboard_offers')">
{% include 'trades/_trade_offer_list.html' with offers=dashboard_offers_paginated %}
</div>
<!-- Waiting on You Tab -->
<div x-show="activeTab === 'waiting_on_you'" x-data="tradeOffersPagination('{% url 'dashboard' %}?ajax_section=waiting_acceptances')">
{% include 'trades/_trade_offer_list.html' with offers=trade_acceptances_waiting_paginated %}
</div>
<!-- Waiting on Them Tab -->
<div x-show="activeTab === 'waiting_on_them'" x-data="tradeOffersPagination('{% url 'dashboard' %}?ajax_section=other_party_acceptances')">
{% include 'trades/_trade_offer_list.html' with offers=other_party_trade_acceptances_paginated %}
</div>
<!-- Trade History Tab -->
<div x-show="activeTab === 'trade_history'">
<div class="divider">{{ _('Closed Offers') }} ({{ closed_offers_paginated.paginator.count }})</div>
<div class="mb-8">
{% include 'trades/_trade_offer_list.html' with offers=closed_offers_paginated %}
</div>
<div class="divider">{{ _('Closed Acceptances') }} ({{ closed_acceptances_paginated.paginator.count }})</div>
<div class="mb-8">
{% include 'trades/_trade_offer_list.html' with offers=closed_acceptances_paginated %}
</div>
<div class="divider">{{ _('Rejected by Them') }} ({{ rejected_by_them_paginated.paginator.count }})</div>
<div class="mb-8">
{% include 'trades/_trade_offer_list.html' with offers=rejected_by_them_paginated %}
</div>
<div class="divider">{{ _('Rejected by Me') }} ({{ rejected_by_me_paginated.paginator.count }})</div>
<div class="mb-8">
{% include 'trades/_trade_offer_list.html' with offers=rejected_by_me_paginated %}
</div>
</div>
<!-- Profile Tab -->
<div x-show="activeTab === 'profile'">
<div class="card card-border bg-base-100 shadow-lg mx-auto p-6 mb-4">
{% with gravatar_profile=request.user.email|gravatar_profile_data %}
{% if gravatar_profile %}
<div class="hovercard-profile mb-4 text-center">
<img src="{{ gravatar_profile.thumbnailUrl }}" alt="{{ gravatar_profile.displayName|default:request.user.username }}" class="rounded-full mb-2 mx-auto" />
<h3 class="text-xl mb-2">{{ gravatar_profile.displayName|default:request.user.username }}</h3>
<a href="{{ gravatar_profile.profileUrl }}" target="_blank" class="btn btn-primary">{{ _('View Gravatar Profile') }}</a>
</div>
{% else %}
<p class="text-center">{{ _('No Gravatar profile data available.') }}</p>
{% endif %}
{% endwith %}
<div class="divider"></div>
<h2 class="text-lg font-semibold">{{ _('What is Gravatar?') }}</h2>
<p class="mb-4">{{ _('Gravatar (Globally Recognized Avatar) is a free service that links your email address to a profile picture and, optionally, a profile. Many websites use Gravatar to display your avatar automatically.') }}</p>
<h2 class="text-lg font-semibold">{{ _('How does it work?') }}</h2>
<p class="mb-4">{{ _('If you have set up a Gravatar, your profile picture will appear whenever you use your email on supported sites. Updates made on Gravatar will reflect here.') }}</p>
<h2 class="text-lg font-semibold">{{ _('Is it safe? What about privacy?') }}</h2>
<p class="mb-4">{{ _('Gravatar is optional, and your email is hashed to maintain privacy. Your personal data remains secure.') }}</p>
<h2 class="text-lg font-semibold">{{ _('Want to update or add a Gravatar?') }}</h2>
<p class="mb-4">{{ _('Go to Gravatar.com to set up or change your avatar or profile. Your changes will appear here once saved!') }}</p>
</div>
</div>
<!-- Settings Tab -->
<div x-show="activeTab === 'settings'">
<div class="card card-border bg-base-100 shadow-lg mx-auto p-6 mb-4">
<form method="post" action="{% url 'dashboard' %}">
{% csrf_token %}
{{ settings_form|crispy }}
<a href="{% url 'list_friend_codes' %}" class="link link-secondary">{{ _('Edit Friend Codes') }}</a>
<button type="submit" name="update_settings" class="w-full btn btn-success mt-4">{{ _('Save Settings') }}</button>
</form>
</div>
<div class="flex flex-col gap-4">
<a href="{% url 'account_logout' %}" class="btn btn-warning">{{ _('Sign Out') }}</a>
</div>
</div>
</div>
<script>
function tradeOffersPagination(baseUrl) {
return {
baseUrl: baseUrl,
_hasChangePageListener: false,
loadPage(page) {
let url = new URL(this.baseUrl, window.location.origin);
url.searchParams.set("page", page);
fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(response => response.text())
.then(html => {
this.$el.innerHTML = html;
this.init();
});
},
init() {
if (!this._hasChangePageListener) {
this.$el.addEventListener('change-page', event => {
let page = event.detail.page;
this.loadPage(page);
});
this._hasChangePageListener = true;
}
this.$el.querySelectorAll("a.ajax-page-link").forEach(link => {
link.addEventListener("click", (event) => {
event.preventDefault();
let page = link.getAttribute("data-page");
this.loadPage(page);
});
});
}
}
}
</script>
{% endblock %}

View file

@ -1,39 +0,0 @@
{% extends 'base.html' %}
{% load i18n gravatar%}
{% block head_title %}{% trans "Settings" %}{% endblock %}
{% block content %}
<div class="container mx-auto">
<h1 class="text-3xl font-semibold text-center mb-6">{% trans "Profile" %}</h1>
<div class="card card-border bg-base-100 shadow-lg w-4/5 mx-auto">
<div class="card-body">
<div class="hovercard-preview">
<iframe src="https://gravatar.com/{{ user.email|gravatar_hash }}.card" width="100%" height="344px" style="border:0; margin:0; padding:0;" onload="resizeIframe(this)"></iframe>
</div>
<p class="text-center">All profile information is managed through Gravatar.</p>
<div class="text-center mt-4 flex flex-col gap-4 mx-auto">
<a href="https://gravatar.com/profile/" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Edit Profile on Gravatar
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
<a href="{% url 'settings' %}" class="btn btn-secondary">Back to Settings</a>
</div>
<div class="divider"></div>
<h2 class="text-lg font-semibold pt-0">What is Gravatar?</h2>
<p class="mb-4">Gravatar (Globally Recognized Avatar) is a free service that links your email address to a profile picture and, optionally, a profile. Many websites, including this one, use Gravatar to display your avatar and profile automatically.</p>
<h2 class="text-lg font-semibold">How does it work?</h2>
<p class="mb-4">If you've set up a Gravatar, your profile picture will appear here whenever you use your email on supported sites. When someone hovers over or clicks on your avatar, your Gravatar profile will also appear if you have one. If you don't have a Gravatar yet, you'll see a default image instead.</p>
<h2 class="text-lg font-semibold">Is it safe? What about privacy?</h2>
<p class="mb-4">Gravatar is completely optional, opt-in, and prioritizes your security and privacy. Your email is never shared and only a hashed version is sent to Gravatar, protecting your identity while ensuring that your email address is not exposed to bots or scrapers. Your personal data remains secure, and you maintain full control over your public profile.</p>
<h2 class="text-lg font-semibold">Want to update or add a Gravatar?</h2>
<p class="mb-4">Go to Gravatar.com to set up or change your avatar or profile. Your updates will appear here once saved!</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,35 +0,0 @@
{% extends 'base.html' %}
{% load i18n crispy_forms_tags %}
{% block head_title %}{% trans "Settings" %}{% endblock %}
{% block content %}
<div class="container mx-auto space-y-8">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Settings" %}</h1>
<!-- Account Navigation Section -->
<div class="card card-border bg-base-100 shadow-lg w-4/5 mx-auto p-6">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="w-full btn btn-success mt-4">
{% trans "Save Settings" %}
</button>
</form>
<div class="divider my-4"></div>
<div class="flex flex-col gap-4">
<div class="flex flex-row gap-4">
<a href="{% url 'profile' %}" class="btn btn-secondary w-20 grow-1">
{% trans "Profile" %}
</a>
<a href="{% url 'list_friend_codes' %}" class="btn btn-primary w-20 grow-1">
{% trans "Friend Codes" %}
</a>
</div>
<a href="{% url 'account_logout' %}" class="btn btn-warning">
{% trans "Sign Out" %}
</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -2,8 +2,7 @@
{% url 'home' as home_url %}
{% url 'trade_offer_list' as trade_offer_list_url %}
{% url 'cards:card_list' as cards_list_url %}
{% url 'settings' as settings_url %}
{% url 'dashboard' as dashboard_url %}
<!DOCTYPE html>
<html lang="en">
<head>
@ -68,13 +67,7 @@
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li><a href="{% url 'home' %}">Home</a></li>
<li><a href="{% url 'cards:card_list' %}">Cards</a></li>
<li>
<a>Trades</a>
<ul class="p-2">
<li><a href="{% url 'trade_offer_list' %}">All Offers</a></li>
<li><a href="{% url 'trade_offer_my_list' %}">My Trades</a></li>
</ul>
</li>
<li><a href="{% url 'trade_offer_list' %}">Trades</a></li>
</ul>
</div>
<a class="btn btn-ghost text-xl" href="{% url 'home' %}">
@ -89,15 +82,7 @@
<ul class="menu menu-horizontal px-1">
<li><a href="{% url 'home' %}">Home</a></li>
<li><a href="{% url 'cards:card_list' %}">Cards</a></li>
<li>
<details>
<summary>Trades</summary>
<ul class="p-2 w-32 z-10">
<li><a href="{% url 'trade_offer_list' %}">All Offers</a></li>
<li><a href="{% url 'trade_offer_my_list' %}">My Trades</a></li>
</ul>
</details>
</li>
<li><a href="{% url 'trade_offer_list' %}">Trades</a></li>
</ul>
</div>
<div class="navbar-end">
@ -121,8 +106,8 @@
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-32 p-2 shadow">
<li>
<a class="justify-between" href="{% url 'settings' %}">
Settings
<a class="justify-between" href="{% url 'dashboard' %}">
Dashboard
</a>
</li>
<li><a href="{% url 'account_logout' %}">Sign Out</a></li>
@ -164,9 +149,9 @@
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" /></svg>
<span class="dock-label">Trades</span>
</button>
<button @click="window.location.href = '{{ settings_url }}'" class="{% if request.path == settings_url %}dock-active{% endif %}">
<button @click="window.location.href = '{{ dashboard_url }}'" class="{% if request.path == dashboard_url or request.path == settings_url %}dock-active{% endif %}">
{% if user.is_authenticated %}<div tabindex="0" role="button" class="avatar"><div class="w-6 rounded-full">{{ user.email|gravatar:40 }}</div></div>{% else %}<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></circle><path d="m22,13.25v-2.5l-2.318-.966c-.167-.581-.395-1.135-.682-1.654l.954-2.318-1.768-1.768-2.318.954c-.518-.287-1.073-.515-1.654-.682l-.966-2.318h-2.5l-.966,2.318c-.581.167-1.135.395-1.654.682l-2.318-.954-1.768,1.768.954,2.318c-.287.518-.515,1.073-.682,1.654l-2.318.966v2.5l2.318.966c.167.581.395,1.135.682,1.654l-.954,2.318,1.768,1.768,2.318-.954c.518.287,1.073.515,1.654.682l.966,2.318h2.5l.966-2.318c.581-.167,1.135-.395,1.654-.682l2.318.954,1.768-1.768-.954-2.318c.287-.518.515-1.073.682-1.654l2.318-.966Z" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path></g></svg>{% endif %}
<span class="dock-label">Settings</span>
<span class="dock-label">Dashboard</span>
</button>
</div>

View file

@ -7,29 +7,6 @@
</div>
{% endfor %}
</div>
{% if is_paginated %}
<div class="flex justify-between items-center mt-4">
{% if page_obj.has_previous %}
<button type="button" class="btn btn-sm"
@click="$dispatch('change-page-{{ side }}', { page: {{ page_obj.previous_page_number }} })">
Previous
</button>
{% else %}
<span></span>
{% endif %}
{% if paginator.num_pages > 1 %}
<span class="text-sm">Page {{ page_obj.number }} of {{ paginator.num_pages }}</span>
{% endif %}
{% if page_obj.has_next %}
<button type="button" class="btn btn-sm"
@click="$dispatch('change-page-{{ side }}', { page: {{ page_obj.next_page_number }} })">
Next
</button>
{% else %}
<span></span>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="text-gray-500">No trade offers found.</p>
{% endif %}

View file

@ -36,7 +36,7 @@
{% endif %}
<div class="mt-4 flex flex-row justify-between">
<a href="{% url 'settings' %}" class="btn btn-secondary">Back to Settings</a>
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Back to Dashboard</a>
<a href="{% url 'add_friend_code' %}" class="btn btn-primary">Add a New Friend Code</a>
</div>
</div>

View file

@ -1,4 +1,4 @@
{% load trade_offer_tags %}
{% load trade_offer_tags pagination_tags %}
{% comment %}
This snippet renders a grid of trade offer cards (or acceptance cards) along with pagination controls.
For a TradeOffer, we use {% render_trade_offer %}; for a TradeAcceptance, {% render_trade_acceptance %}.
@ -18,3 +18,4 @@
<div>No trade offers available.</div>
{% endfor %}
</div>
{% render_pagination offers %}

View file

@ -1,38 +0,0 @@
{% include "trades/_trade_offer_list.html" %}
{% if offers.has_other_pages %}
<nav aria-label="Trade offers pagination" class="mt-6">
<ul class="flex justify-center space-x-2">
{% if offers.has_previous %}
<li>
<a class="btn btn-outline ajax-page-link" data-page="{{ offers.previous_page_number }}" href="#">
Previous
</a>
</li>
{% else %}
<li>
<span class="btn btn-outline btn-disabled">Previous</span>
</li>
{% endif %}
{% for num in offers.paginator.page_range %}
<li>
<a class="btn btn-outline ajax-page-link {% if offers.number == num %}btn-active{% endif %}" data-page="{{ num }}" href="#">
{{ num }}
</a>
</li>
{% endfor %}
{% if offers.has_next %}
<li>
<a class="btn btn-outline ajax-page-link" data-page="{{ offers.next_page_number }}" href="#">
Next
</a>
</li>
{% else %}
<li>
<span class="btn btn-outline btn-disabled">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}

View file

@ -0,0 +1,16 @@
Hello {{ recipient_user }},
Great news! {{ acting_user }} ({{ acting_user_friend_code }}) has accepted your trade offer.
Trade Details:
- #{{ hash }}
- They are offering: {{ want_card }}
- They want: {{ has_card }}
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.
Visit your dashboard to manage this trade:
{% url 'dashboard' %}
Happy trading!
PKMN Trade Club

View file

@ -0,0 +1,16 @@
Hello {{ recipient_user }},
{{ acting_user }} ({{ acting_user_friend_code }}) has marked your trade as "Received".
Trade Details:
- #{{ hash }}
- Card you sent: {{ has_card }}
- Card they offered: {{ want_card }}
What's next? Send a thank you to this user to increase their reputation!
Visit your dashboard to send thanks:
{% url 'dashboard' %}
Happy trading!
PKMN Trade Club

View file

@ -0,0 +1,16 @@
Hello {{ recipient_user }},
We're sorry to inform you that {{ acting_user }} ({{ acting_user_friend_code }}) has canceled their trade acceptance.
Trade Details:
- #{{ hash }}
- Card you were going to send: {{ has_card }}
- Card they were offering: {{ want_card }}
Your trade offer is still active and available for other users to accept.
Visit your dashboard to manage your trade offers:
{% url 'dashboard' %}
Happy trading!
PKMN Trade Club

View file

@ -0,0 +1,16 @@
Hello {{ recipient_user }},
We're sorry to inform you that {{ acting_user }} ({{ acting_user_friend_code }}) has rejected the trade.
Trade Details:
- #{{ hash }}
- Card you were going to receive: {{ has_card }}
- Card you were offering: {{ want_card }}
Don't worry - there are plenty of other trade opportunities available! You can browse our marketplace for similar trades.
Visit the marketplace:
https://pkmntrade.club{% url 'trade_offer_list' %}
Better luck with your next trade!
PKMN Trade Club

View file

@ -0,0 +1,16 @@
Hello {{ recipient_user }},
{{ acting_user }} ({{ acting_user_friend_code }}) has marked your trade as "Sent".
Trade Details:
- #{{ hash }}
- Card being sent to you: {{ has_card }}
- Card you're offering: {{ want_card }}
What's next? Once you respond to the trade in the app, please mark the trade as "Received" in your dashboard.
Visit your dashboard to manage this trade:
{% url 'dashboard' %}
Happy trading!
PKMN Trade Club

View file

@ -0,0 +1,16 @@
Hello {{ recipient_user }},
{{ acting_user }} ({{ acting_user_friend_code }}) has sent their thanks for the successful trade!
Trade Details:
- #{{ hash }}
- Card you sent: {{ has_card }}
- Card they offered: {{ want_card }}
What's next? Send a thank you to this user to increase their reputation!
Visit your dashboard to send thanks:
{% url 'dashboard' %}
Happy trading!
PKMN Trade Club

View file

@ -0,0 +1,15 @@
Hello {{ recipient_user }},
{{ acting_user }} ({{ acting_user_friend_code }}) has sent their thanks for the successful trade!
Trade Details:
- #{{ hash }}
- Card {% if is_initiator %}you{% else %}they{% endif %} sent: {{ has_card }}
- Card {% if is_initiator %}they{% else %}you{% endif %} offered: {{ want_card }}
This trade is now completed; no further actions can be made.
Thank you for using PKMN Trade Club.
Happy trading!
PKMN Trade Club

View file

@ -0,0 +1,16 @@
Hello {{ recipient_user }},
{{ acting_user }} ({{ acting_user_friend_code }}) has sent their thanks for the successful trade!
Trade Details:
- #{{ hash }}
- Card they sent: {{ has_card }}
- Card you offered: {{ want_card }}
What's next? Send a thank you to this user to increase their reputation!
Visit your dashboard to send thanks:
{% url 'dashboard' %}
Happy trading!
PKMN Trade Club

View file

@ -1,13 +1,13 @@
{% extends 'base.html' %}
{% load static %}
{% load static pagination_tags %}
{% block title %}All Trade Offers{% endblock title %}
{% block title %}Trade Offers{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-4xl mt-6" x-data="{ allExpanded: false }">
<!-- Header-->
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-bold">All Trade Offers</h1>
<h1 class="text-2xl font-bold">Trade Offers</h1>
<div>
<form method="get" class="flex items-center gap-4" x-data>
<label class="cursor-pointer flex items-center gap-2">
@ -27,47 +27,23 @@
<div class="flex justify-end mb-4">
<a href="{% url 'trade_offer_create' %}" class="btn btn-success">Create New Offer</a>
</div>
<div
id="all-trade-offers"
x-data="tradeOffersPagination('{% url 'trade_offer_list' %}?')"
x-init="init()"
>
{% include "trades/_trade_offer_list_paginated.html" with offers=all_trade_offers_paginated %}
<div id="all-trade-offers"
x-data="{
page: {{ all_trade_offers_paginated.number|default:1 }},
loadOffers() {
let url = new URL('{% url 'trade_offer_list' %}', window.location.origin);
let params = new URLSearchParams(window.location.search);
params.set('page', this.page);
url.search = params.toString();
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' }})
.then(response => response.text())
.then(html => { this.$el.innerHTML = html; });
}
}"
x-init="loadOffers()"
x-on:change-page.window="page = $event.detail.page; loadOffers()">
{% include "trades/_trade_offer_list.html" with offers=all_trade_offers_paginated %}
</div>
</section>
</div>
<script>
function tradeOffersPagination(baseUrl) {
return {
baseUrl: baseUrl,
loadPage(page) {
let url = new URL(this.baseUrl, window.location.origin);
url.searchParams.set("page", page);
this.$el.innerHTML = '<div class="flex justify-center items-center w-full mt-10"><span class="loading loading-dots loading-xl"></span></div>';
fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.text())
.then(html => {
this.$el.innerHTML = html;
// Reinitialize the click events after injecting the new fragment.
this.init();
});
},
init() {
// Bind click events for AJAX pagination links within this component.
this.$el.querySelectorAll("a.ajax-page-link").forEach(link => {
link.addEventListener("click", (event) => {
event.preventDefault();
const page = link.getAttribute("data-page");
this.loadPage(page);
});
});
}
}
}
</script>
{% endblock content %}

View file

@ -1,98 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}My Trades{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-4xl mt-6" x-data="{ allExpanded: false }">
<!-- Header -->
<div class="flex justify-between items-start mb-4">
<a href="{% url 'trade_offer_create' %}" class="btn btn-success">Create New Offer</a>
<div>
<form method="get" class="flex flex-wrap justify-end space-x-4 gap-2" x-data>
<label class="cursor-pointer flex items-center space-x-2 h-10">
<span x-text="allExpanded ? 'Collapse All' : 'Expand All'"></span>
<input type="checkbox" name="all_expanded" value="true" class="toggle toggle-primary" @click="allExpanded = !allExpanded; $dispatch('toggle-all', { expanded: allExpanded })">
</label>
<label class="cursor-pointer flex items-center space-x-2 h-10">
<span class="font-medium">Only Closed</span>
<input type="checkbox" name="show_closed" value="true" class="toggle toggle-primary" @change="$el.form.submit()" {% if show_closed %}checked{% endif %}>
</label>
{% include "trades/_friend_code_select.html" with friend_codes=friend_codes selected_friend_code=selected_friend_code field_name="friend_code" label="" %}
<button type="submit" class="btn btn-primary" x-show="false">Apply</button>
</form>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-8">
<!-- Section: Waiting for Your Response -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-4">Waiting for Your Response</h2>
<div
id="waiting-acceptances"
x-data="tradeOffersPagination('{% url 'trade_offer_my_list' %}?ajax_section=waiting_acceptances')"
x-init="init()"
>
{% include "trades/_trade_offer_list_paginated.html" with offers=trade_acceptances_waiting_paginated %}
</div>
</section>
<!-- Section: Waiting for Their Response -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-4">Waiting for Their Response</h2>
<div
id="other-acceptances"
x-data="tradeOffersPagination('{% url 'trade_offer_my_list' %}?ajax_section=other_party_acceptances')"
x-init="init()"
>
{% include "trades/_trade_offer_list_paginated.html" with offers=other_party_trade_acceptances_paginated %}
</div>
</section>
</div>
<!-- Section: My Trade Offers -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-4">My Trade Offers</h2>
<div
id="my-trade-offers"
x-data="tradeOffersPagination('{% url 'trade_offer_my_list' %}?ajax_section=my_trade_offers')"
x-init="init()">
{% include "trades/_trade_offer_list_paginated.html" with offers=my_trade_offers_paginated %}
</div>
</section>
</div>
<script>
function tradeOffersPagination(baseUrl) {
return {
baseUrl: baseUrl,
loadPage(page) {
let url = new URL(this.baseUrl, window.location.origin);
url.searchParams.set("page", page);
fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.text())
.then(html => {
this.$el.innerHTML = html;
// Reinitialize click events after content update.
this.init();
});
},
init() {
// Bind click events on pagination links within the component.
this.$el.querySelectorAll("a.ajax-page-link").forEach(link => {
link.addEventListener("click", (event) => {
event.preventDefault();
let page = link.getAttribute("data-page");
this.loadPage(page);
});
});
}
}
}
</script>
{% endblock content %}

View file

@ -1,38 +1,42 @@
<div class="flex justify-center items-center space-x-2">
<!-- First Button -->
<button class="btn btn-outline btn-md"
{% if not page_obj.has_previous %}disabled{% endif %}
@click="$dispatch('change-page', { page: 1 })">
First
</button>
<!-- Previous Button -->
<button class="btn btn-outline btn-md"
{% if not page_obj.has_previous %}disabled{% endif %}
@click="$dispatch('change-page', { page: {{ page_obj.previous_page }} })">
Prev
</button>
<!-- Goto Page -->
<span class="flex items-center space-x-1 gap-2">
<input type="number" min="1" max="{{ page_obj.paginator.num_pages }}" value="{{ page_obj.number }}"
class="input input-xs text-center" id="gotoPageInput">
{% if not hide_if_one_page or page_obj.paginator.num_pages > 1 %}
<div class="flex flex-col justify-center items-center mt-4">
<div class="flex justify-center items-center space-x-2">
<!-- First Button -->
<button class="btn btn-outline btn-md"
@click="(document.getElementById('gotoPageInput').value >= 1 && document.getElementById('gotoPageInput').value <= {{ page_obj.paginator.num_pages }}) && $dispatch('change-page', { page: parseInt(document.getElementById('gotoPageInput').value) })">
Go
{% if not page_obj.has_previous %}disabled{% endif %}
@click="$dispatch('change-page', { page: 1 })">
First
</button>
</span>
<!-- Next Button -->
<button class="btn btn-outline btn-md"
{% if not page_obj.has_next %}disabled{% endif %}
@click="$dispatch('change-page', { page: {{ page_obj.next_page }} })">
Next
</button>
<!-- Last Button -->
<button class="btn btn-outline btn-md"
{% if page_obj.number == page_obj.paginator.num_pages %}disabled{% endif %}
@click="$dispatch('change-page', { page: {{ page_obj.paginator.num_pages }} })">
Last
</button>
</div>
<div class="flex items-center justify-center mt-2">
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
<!-- Previous Button -->
<button class="btn btn-outline btn-md"
{% if not page_obj.has_previous %}disabled{% endif %}
@click="$dispatch('change-page', { page: {% if page_obj.has_previous %}{{ page_obj.previous_page_number }}{% else %}1{% endif %} })">
Prev
</button>
<!-- Goto Page -->
<span class="flex items-center space-x-1 gap-2">
<input type="number" min="1" max="{{ page_obj.paginator.num_pages }}" value="{{ page_obj.number }}"
class="input input-xs text-center" id="gotoPageInput_{{ page_obj.number }}" title="Enter page number">
<button class="btn btn-outline btn-md"
@click="(document.getElementById('gotoPageInput_{{ page_obj.number }}').value >= 1 && document.getElementById('gotoPageInput_{{ page_obj.number }}').value <= {{ page_obj.paginator.num_pages }}) && $dispatch('change-page', { page: parseInt(document.getElementById('gotoPageInput_{{ page_obj.number }}').value) })">
Go
</button>
</span>
<!-- Next Button -->
<button class="btn btn-outline btn-md"
{% if not page_obj.has_next %}disabled{% endif %}
@click="$dispatch('change-page', { page: {% if page_obj.has_next %}{{ page_obj.next_page_number }}{% else %}{{ page_obj.number }}{% endif %} })">
Next
</button>
<!-- Last Button -->
<button class="btn btn-outline btn-md"
{% if page_obj.number == page_obj.paginator.num_pages %}disabled{% endif %}
@click="$dispatch('change-page', { page: {{ page_obj.paginator.num_pages }} })">
Last
</button>
</div>
<div class="flex items-center justify-center mt-2">
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
</div>
</div>
{% endif %}

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,
@ -81,3 +87,169 @@ def trade_acceptance_post_delete(sender, instance, **kwargs):
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"):
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(