add email setting, closes #5. other misc small fixes

This commit is contained in:
badblocks 2025-04-13 21:14:20 -07:00
parent 135bd95a6a
commit 86b061c971
11 changed files with 85 additions and 99 deletions

View file

@ -87,4 +87,4 @@ class CustomUserCreationForm(SignupForm):
class UserSettingsForm(forms.ModelForm):
class Meta:
model = CustomUser
fields = ['show_friend_code_on_link_previews']
fields = ['show_friend_code_on_link_previews', 'enable_email_notifications']

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-04-13 05:10
# Generated by Django 5.1.2 on 2025-04-14 04:07
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')),
('enable_email_notifications', models.BooleanField(default=True, help_text='Receive new trade notifications via email.', verbose_name='Enable Email Notifications')),
('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')),
('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,11 @@ class CustomUser(AbstractUser):
verbose_name="Show Friend Code on Link Previews",
help_text="This will primarily affect share link previews on X, Discord, etc."
)
enable_email_notifications = models.BooleanField(
default=True,
verbose_name="Enable Email Notifications",
help_text="Receive new trade notifications via email."
)
reputation_score = models.IntegerField(default=0)
def __str__(self):

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-04-13 05:10
# Generated by Django 5.1.2 on 2025-04-14 04:07
import django.db.models.deletion
from django.db import migrations, models

View file

@ -46,6 +46,10 @@ module.exports = {
'alert-success',
'alert-warning',
'alert-error',
'btn-info',
'btn-success',
'btn-warning',
'btn-error',
'bg-info',
'bg-success',
'bg-warning',

View file

@ -1,4 +1,4 @@
<a href="{{ url }}">
<a href="{{ url }}" @click.stop>
<div class="relative block">
{% if not expanded %}
<div class="flex flex-row items-center h-[32px] p-1.5 w-40 text-white shadow-lg" style="{{ style }}">

View file

@ -24,7 +24,6 @@
<script defer>
if (!window.updateGlobalCardFilters) {
window.updateGlobalCardFilters = function() {
console.log("updateGlobalCardFilters called.");
const selects = document.querySelectorAll('.card-multiselect');
// Rebuild global selections and rarity filtering.
@ -46,8 +45,6 @@ if (!window.updateGlobalCardFilters) {
}
});
console.log("Global selected card IDs:", globalSelectedIds, "Current Global rarity:", globalRarity);
selects.forEach(select => {
if (select.choicesInstance && select.choicesInstance.dropdown.element) {
// Reset all options to enabled.
@ -82,7 +79,6 @@ if (!window.updateGlobalCardFilters) {
}
document.addEventListener('DOMContentLoaded', function() {
console.log("DOM fully loaded. Initializing card multiselect for field '{{ field_id }}'");
const selectField = document.getElementById('{{ field_id }}');
const placeholder = selectField.getAttribute('data-placeholder') || '';
@ -156,7 +152,6 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
console.log("Initialized Choices.js instance for field '{{ field_id }}':", choicesInstance);
// Associate the Choices instance with the select field.
selectField.choicesInstance = choicesInstance;
@ -166,7 +161,6 @@ document.addEventListener('DOMContentLoaded', function() {
window.cardMultiselectInstances.push(selectField);
selectField.addEventListener('change', function() {
console.log("Select field changed. Current selected values:", selectField.choicesInstance.getValue(true));
if (window.updateGlobalCardFilters) {
window.updateGlobalCardFilters();
}
@ -177,7 +171,6 @@ document.addEventListener('DOMContentLoaded', function() {
choicesContainer.addEventListener('click', function(e) {
if (e.target.classList.contains('increment')) {
console.log("Increment button clicked.");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
@ -185,14 +178,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (container) {
let quantityBadge = container.querySelector('.card-quantity-badge');
let quantity = getOptionQuantity(container);
console.log("Increment action on card", container.getAttribute('data-card-id'), "original quantity:", quantity, "new quantity:", quantity + 1);
quantity = quantity + 1;
quantityBadge.innerText = quantity;
updateOptionQuantity(container, quantity);
}
}
if (e.target.classList.contains('decrement')) {
console.log("Decrement button clicked.");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
@ -202,20 +193,15 @@ document.addEventListener('DOMContentLoaded', function() {
let quantity = getOptionQuantity(container);
const cardId = container.getAttribute('data-card-id');
if (quantity === 1) {
console.log("Decrement action: quantity is 1 for card", cardId, "initiating removal.");
const option = selectField.querySelector('option[value="' + cardId + '"]');
if (option) {
console.log("Removing card from Choices.js instance. Value removed:", option.value);
choicesInstance.removeActiveItemsByValue(option.value);
option.selected = false;
} else {
console.log("No active item found for card", cardId);
}
if (window.updateGlobalCardFilters) {
window.updateGlobalCardFilters();
}
} else {
console.log("Decrement action on card", cardId, "reducing quantity from", quantity, "to", quantity - 1);
quantity = quantity - 1;
quantityBadge.innerText = quantity;
updateOptionQuantity(container, quantity);
@ -232,18 +218,16 @@ document.addEventListener('DOMContentLoaded', function() {
function updateOptionQuantity(item, quantity) {
const cardId = item.getAttribute('data-card-id');
console.log("Updating option quantity for card", cardId, "to", quantity);
const option = item.closest('.choices__inner').querySelector('option[value="' + cardId + '"]');
if (option) {
option.setAttribute('data-quantity', quantity);
console.log("Updated data-quantity for card", cardId, "to", quantity);
}
}
function getOptionQuantity(item) {
const cardId = item.getAttribute('data-card-id');
const option = item.closest('.choices__inner').querySelector('option[value="' + cardId + '"]');
return option ? parseInt(option.getAttribute('data-quantity')) : 1;
return option ? parseInt(option.getAttribute('data-quantity')) : "";
}
if (choicesInstance.getValue(true).length > 0 && window.updateGlobalCardFilters) {

View file

@ -7,7 +7,7 @@
<div class="flex justify-start items-center">
<!-- Left: Initiator's avatar and "Has" -->
<div class="flex items-center">
<div class="avatar me-2">
<div class="avatar me-2 tooltip tooltip-top cursor-default" @click.stop.prevent data-tip="{{ acceptance.trade_offer.initiated_by.user.username }} | {{ acceptance.trade_offer.initiated_by.user.reputation_score }} rep">
<div class="w-10 rounded-full">
{{ acceptance.trade_offer.initiated_by.user.email|gravatar:40 }}
</div>
@ -44,7 +44,7 @@
Waiting on {{ acceptance.accepted_by.user.username }} to {{ acceptance.next_action_label }}...
{% endif %}
</div>
<div class="avatar ms-2">
<div class="avatar ms-2 tooltip tooltip-bottom cursor-default" @click.stop.prevent data-tip="{{ acceptance.accepted_by.user.username}} | {{ acceptance.accepted_by.user.reputation_score }} rep">
<div class="w-10 rounded-full">
{{ acceptance.accepted_by.user.email|gravatar:40 }}
</div>

View file

@ -3,25 +3,27 @@
{% cache 60 trade_offer offer_pk %}
<div x-data="{ flipped: {{flipped|lower}}, offerExpanded: {{flipped|yesno:'false,true'}}, acceptanceExpanded: {{flipped|lower}} }" x-ref="tradeOffer" class="transition-all duration-500 trade-offer-card">
<div class="flip-container">
<div{% if not on_detail_page %} @click="window.location.href = '{% url 'trade_offer_detail' pk=offer_pk %}'"{% endif %} class="flip-inner grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-90 transform transition-transform duration-500 ease-in-out{% if not on_detail_page %} cursor-pointer{% endif %}{%if flipped %} rotate-y-180{% endif %}"
<div class="flip-inner grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-90 transform transition-transform duration-500 ease-in-out{%if flipped %} rotate-y-180{% endif %}"
:class="{'rotate-y-180': flipped}">
<div class="flip-face front rotate-y-0 col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between">
<div class="flip-face-header self-start">
<a{% if not on_detail_page %} href="{% url 'trade_offer_detail' pk=offer_pk %}"{% endif %}>
<div class="relative mt-6 mb-4 mx-2 sm:mx-4">
<div class="grid grid-cols-2 items-center">
<span class="text-sm font-semibold text-center">Has</span>
<span class="text-sm font-semibold text-center">Wants</span>
</div>
<div class="absolute inset-x-0 top-1/2 transform -translate-y-1/2 flex justify-center">
<div class="avatar tooltip tooltip-top" data-tip="{{ initiated_by_username }} | {{ initiated_reputation }} rep">
<div class="avatar tooltip tooltip-top cursor-default" @click.stop.prevent data-tip="{{ initiated_by_username }} | {{ initiated_reputation }} rep">
<div class="w-10 rounded-full">
{{ initiated_by_email|gravatar:40 }}
</div>
</div>
</div>
</div>
</a>
</div>
<div class="flip-face-body self-start">
<div class="flip-face-body self-start{% if not on_detail_page %} cursor-pointer{% endif %}"{% if not on_detail_page %} @click.stop.prevent="window.location.href = '{% url 'trade_offer_detail' pk=offer_pk %}'"{% endif %}>
<div x-show="offerExpanded" x-collapse.duration.500ms class="px-2 badges">
<div class="flex flex-row justify-around">
{% if num_cards_available > 0 %}
@ -42,92 +44,81 @@
</div>
</div>
<div class="flip-face-footer self-end">
<div class="flex justify-between px-2 pb-2">
<div class="text-gray-500 text-sm tooltip tooltip-right" data-tip="ID: {{ offer_hash }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
</div>
<div class="text-xs">{{ rarity_icon }}</div>
<div class="cursor-pointer text-gray-500"
@click.stop="$refs.tradeOffer.scrollIntoView({ behavior: 'auto', block: 'start' });
offerExpanded = false;
setTimeout(() => {
flipped = true;
setTimeout(() => { acceptanceExpanded = true; }, 500);
}, 500);">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8.689c0-.864.933-1.406 1.683-.977l7.108 4.061a1.125 1.125 0 0 1 0 1.954l-7.108 4.061A1.125 1.125 0 0 1 3 16.811V8.69ZM12.75 8.689c0-.864.933-1.406 1.683-.977l7.108 4.061a1.125 1.125 0 0 1 0 1.954l-7.108 4.061a1.125 1.125 0 0 1-1.683-.977V8.69Z" />
<a{% if not on_detail_page %} href="{% url 'trade_offer_detail' pk=offer_pk %}"{% endif %}>
<div class="flex justify-between px-2 pb-2">
<div class="text-gray-500 text-sm tooltip tooltip-right" data-tip="ID: {{ offer_hash }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
</div>
<div class="text-xs text-transparent shadow-gray-700 dark:shadow-white" style="text-shadow: 0 0 0 var(--tw-shadow-color);">{{ rarity_icon }}</div>
<div class="cursor-pointer text-gray-500"
@click.stop.prevent="$refs.tradeOffer.scrollIntoView({ behavior: 'auto', block: 'start' });
offerExpanded = false;
setTimeout(() => {
flipped = true;
setTimeout(() => { acceptanceExpanded = true; }, 500);
}, 500);">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8.689c0-.864.933-1.406 1.683-.977l7.108 4.061a1.125 1.125 0 0 1 0 1.954l-7.108 4.061A1.125 1.125 0 0 1 3 16.811V8.69ZM12.75 8.689c0-.864.933-1.406 1.683-.977l7.108 4.061a1.125 1.125 0 0 1 0 1.954l-7.108 4.061a1.125 1.125 0 0 1-1.683-.977V8.69Z" />
</svg>
</div>
</div>
</div>
</a>
</div>
</div>
<div class="flip-face back col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between rotate-y-180">
<div class="flip-face-header self-start">
<div class="relative mt-6 mb-4 mx-2 sm:mx-4">
<div class="grid grid-cols-2 items-center">
<span class="text-sm font-semibold text-center">Has</span>
<span class="text-sm font-semibold text-center">Wants</span>
</div>
<div class="absolute inset-x-0 top-1/2 transform -translate-y-1/2 flex justify-center">
<div class="avatar tooltip tooltip-top" data-tip="{{ initiated_by_username }} | {{ initiated_reputation }} rep">
<div class="w-10 rounded-full">
{{ initiated_by_email|gravatar:40 }}
<a{% if not on_detail_page %} href="{% url 'trade_offer_detail' pk=offer_pk %}"{% endif %}>
<div class="relative mt-6 mb-4 mx-2 sm:mx-4">
<div class="grid grid-cols-2 items-center">
<span class="text-sm font-semibold text-center">Has</span>
<span class="text-sm font-semibold text-center">Wants</span>
</div>
<div class="absolute inset-x-0 top-1/2 transform -translate-y-1/2 flex justify-center">
<div class="avatar tooltip tooltip-top cursor-default" @click.stop.prevent data-tip="{{ initiated_by_username }} | {{ initiated_reputation }} rep">
<div class="w-10 rounded-full">
{{ initiated_by_email|gravatar:40 }}
</div>
</div>
</div>
</div>
</div>
</a>
</div>
<div class="flip-face-body self-start">
<div class="px-2">
<div x-show="acceptanceExpanded" x-collapse.duration.500ms class="space-y-3">
{% for acceptance in acceptances %}
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
data-tooltip-html='<div class="flex items-center space-x-2">
<div class="avatar">
<div class="w-10 rounded-full">
{{ acceptance.accepted_by.user.email|gravatar:"40" }}
</div>
</div>
<div class="flex flex-col">
<span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
<span class="text-sm">State: {{ acceptance.state }}</span>
<span class="text-sm">ID: {{ acceptance.hash }}</span>
</div>
</div>'>
<div class="grid grid-cols-2 gap-4 items-center">
<div>
{% card_badge acceptance.requested_card %}
</div>
<div>
{% card_badge acceptance.offered_card %}
</div>
<div class="px-2 space-y-3 text-xs">
<div x-show="acceptanceExpanded" x-collapse.duration.500ms class="flex flex-row flex-wrap justify-start items-baseline w-90 lg:w-181 mx-auto">
{% for trade in acceptances %}
<div class="p-2 grid grid-cols-2 justify-items-center items-baseline gap-1">
<div class="">{% card_badge trade.offered_card %}</div>
<div class="">{% card_badge trade.requested_card %}</div>
<div class="col-span-2 text-center">{{ trade.accepted_by.user.username }} &bull; <a class="link link-hover" href="{% url 'trade_acceptance_update' pk=trade.pk %}">#{{ trade.hash }}</a> &bull; {{ trade.get_state_display }}</div>
</div>
</a>
{% empty %}
<div class="text-center text-sm mb-2">No trades yet!</div>
{% endfor %}
</div>
{% empty %}
<div class="text-center justify-center w-full text-center">No trades yet.</div>
{% endfor %}
</div>
</div>
</div>
<div class="flip-face-footer self-end">
<div class="flex justify-between px-2 pb-2">
<div class="text-gray-500 cursor-pointer"
@click.stop="$refs.tradeOffer.scrollIntoView({ behavior: 'auto', block: 'start' }); acceptanceExpanded = false; setTimeout(() => { flipped = false; setTimeout(() => { offerExpanded = true; }, 500); }, 500);">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 16.811c0 .864-.933 1.406-1.683.977l-7.108-4.061a1.125 1.125 0 0 1 0-1.954l7.108-4.061A1.125 1.125 0 0 1 21 8.689v8.122ZM11.25 16.811c0 .864-.933 1.406-1.683.977l-7.108-4.061a1.125 1.125 0 0 1 0-1.954l7.108-4.061a1.125 1.125 0 0 1 1.683.977v8.122Z" />
</svg>
</div>
<div class="text-xs">{{ rarity_icon }}</div>
<div class="px-1 text-center text-gray-500">
<span class="text-sm font-semibold">
({{ acceptances|length }})
</span>
<a{% if not on_detail_page %} href="{% url 'trade_offer_detail' pk=offer_pk %}"{% endif %}></a>
<div class="flex justify-between px-2 pb-2">
<div class="text-gray-500 cursor-pointer"
@click.stop.prevent="$refs.tradeOffer.scrollIntoView({ behavior: 'auto', block: 'start' }); acceptanceExpanded = false; setTimeout(() => { flipped = false; setTimeout(() => { offerExpanded = true; }, 500); }, 500);">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 16.811c0 .864-.933 1.406-1.683.977l-7.108-4.061a1.125 1.125 0 0 1 0-1.954l7.108-4.061A1.125 1.125 0 0 1 21 8.689v8.122ZM11.25 16.811c0 .864-.933 1.406-1.683.977l-7.108-4.061a1.125 1.125 0 0 1 0-1.954l7.108-4.061a1.125 1.125 0 0 1 1.683.977v8.122Z" />
</svg>
</div>
</div>
<div class="text-xs text-transparent shadow-gray-700 dark:shadow-white" style="text-shadow: 0 0 0 var(--tw-shadow-color);">{{ rarity_icon }}</div>
<div class="px-1 text-center text-gray-500">
<span class="text-sm font-semibold">
({{ acceptances|length }})
</span>
</div>
</div>
</a>
</div>
</div>
</div>

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-04-13 05:10
# Generated by Django 5.1.2 on 2025-04-14 04:07
import django.db.models.deletion
from django.db import migrations, models

View file

@ -139,6 +139,9 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
else:
return
if not recipient_user.enable_email_notifications:
return
is_initiator = instance.trade_offer.initiated_by.user.pk == acting_user.pk
email_context = {
@ -159,8 +162,6 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
email_subject += render_to_string("email/trades/trade_update_" + state + "_subject.txt", email_context)
email_body = render_to_string(email_template, email_context)
print("initiated by: ", instance.trade_offer.initiated_by, ", accepted by: ", instance.accepted_by, ", acting user: ", acting_user, ", recipient user: ", recipient_user, ", state: ", state)
send_mail(
email_subject,
email_body,