finished conversion to tailwind

This commit is contained in:
badblocks 2025-03-11 23:45:27 -07:00
parent 6e2843c60e
commit d62956d465
50 changed files with 2490 additions and 1273 deletions

View file

@ -1,58 +0,0 @@
pip install django-crispy-forms
pip install crispy-tailwind
exit
npm i -D daisyui@beta
nano requirements.txt
cat requirements.txt
cd ..
exit
cd theme/static_src/
npm run build:clean
npm run build:tailwind
npm run build:tailwind
npm run build:tailwind
npm run build:clean && npm run build:tailwind
npm run build:clean && npm run build:tailwind
npm run build:clean && npm run build:tailwind
ps ax
npm run dev
exit
cd theme/static_src/
npm run dev
exit
pwd
cd /code
ls
cd theme/static_src/
ls
pwd
exit
cd /code/theme/static_src && npm run dev
exit
python manage.py migrate notifications
exit
exit
python manage.py dumpdata --indent 2 trades.TradeOffer
python manage.py dumpdata --indent 2 trades.TradeOffer > seed/0007_TestTradeOffers.json
exit
python manage.py dumpdata --indent 2 trades.TradeOffer > seed/0007_TestTradeOffers.json
python manage.py dumpdata --indent 2 trades.TradeOfferAcceptances > seed/0008_TestTradeOffersAcceptances.json
python manage.py dumpdata --indent 2 trades.TradeOfferAcceptances > seed/0008_TestTradeOffersAcceptance.json
python manage.py dumpdata --indent 2 trades.TradeOfferAcceptance > seed/0008_TestTradeOffersAcceptance.json
python manage.py dumpdata --indent 2 trades.TradeAcceptance > seed/0008_TestTradeAcceptances.json
python manage.py dumpdata --indent 2 trades.TradeOfferWantCard > seed/0008_TestOfferWantCard.json
python manage.py dumpdata --indent 2 trades.TradeOfferHaveCard > seed/0009_TestOfferHaveCard.json
python manage.py dumpdata --indent 2 trades.TradeAcceptance > seed/0008_TestTradeAcceptances.json
exit
python manage.py shell
exit
python manage.py dumpdata trades.TradeOffer --indent 2
python manage.py dumpdata trades.TradeOffer --indent 2 > seed/0007_TestTradeOffers.json
python manage.py dumpdata trades.TradeOfferHasCard --indent 2
python manage.py dumpdata trades.TradeOfferHaveCard --indent 2
python manage.py dumpdata trades.TradeOfferHaveCard --indent 2 > seed/0009_TestOfferHaveCard.json
python manage.py dumpdata trades.TradeOfferWantCard --indent 2 > seed/0009_TestOfferWantCard.json
python manage.py dumpdata trades.TradeOfferWantCard --indent 2 > seed/0008_TestOfferWantCard.json
rm seed/0009_TestOfferWantCard.json
cat seed/0008_TestOfferWantCard.json
exit

15
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Django",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": ["runserver"],
"django": true,
"justMyCode": true,
"preLaunchTask": "Run db"
}
]
}

26
.vscode/tasks.json vendored
View file

@ -5,24 +5,24 @@
"label": "Reset DB, Make Migrations, And Seed Data", "label": "Reset DB, Make Migrations, And Seed Data",
"type": "shell", "type": "shell",
"command": "./reset-db_make-migrations_seed-data.sh", "command": "./reset-db_make-migrations_seed-data.sh",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "Run app", "label": "Run app & db",
"type": "shell", "type": "shell",
"command": "./entrypoint.sh", "command": "./entrypoint.sh",
"presentation": { "problemMatcher": []
"echo": true, },
"reveal": "always", {
"focus": false, "label": "Run tailwind dev server",
"panel": "shared" "type": "shell",
}, "command": "cd theme/static_src && npm run dev",
"problemMatcher": [],
},
{
"label": "Run db",
"type": "shell",
"command": "docker compose up -d",
"problemMatcher": [] "problemMatcher": []
} }
] ]

View file

@ -27,7 +27,10 @@ RUN apt-get update && apt-get install -y nodejs npm
# Expose port 8000 # Expose port 8000
EXPOSE 8000 EXPOSE 8000
USER 10003:10003 #USER 10003:10003
RUN python manage.py collectstatic --noinput
# Use gunicorn on port 8000 # Use gunicorn on port 8000
#CMD ["/bin/bash", "-c", "python manage.py collectstatic --noinput; gunicorn --bind :8000 --workers 2 django_project.wsgi"]
CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "django_project.wsgi"] CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "django_project.wsgi"]

View file

@ -3,17 +3,11 @@ from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import CustomUser, FriendCode from .models import CustomUser, FriendCode
from allauth.account.forms import SignupForm from allauth.account.forms import SignupForm
class CustomUserCreationForm(UserCreationForm):
class Meta(UserCreationForm.Meta):
model = CustomUser
fields = ('email',)
class CustomUserChangeForm(UserChangeForm): class CustomUserChangeForm(UserChangeForm):
class Meta: class Meta:
model = CustomUser model = CustomUser
fields = ('email',) fields = ['email']
class FriendCodeForm(forms.ModelForm): class FriendCodeForm(forms.ModelForm):
class Meta: class Meta:
@ -30,7 +24,12 @@ class FriendCodeForm(forms.ModelForm):
friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}" friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}"
return friend_code_formatted return friend_code_formatted
class CustomSignupForm(SignupForm): class CustomUserCreationForm(SignupForm):
class Meta(UserCreationForm.Meta):
model = CustomUser
fields = ['email', 'username', 'friend_code']
friend_code = forms.CharField( friend_code = forms.CharField(
max_length=19, max_length=19,
required=True, required=True,
@ -41,9 +40,6 @@ class CustomSignupForm(SignupForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Remove the username field completely.
if "username" in self.fields:
del self.fields["username"]
def clean_friend_code(self): def clean_friend_code(self):
friend_code = self.cleaned_data.get("friend_code", "").strip().replace("-", "") friend_code = self.cleaned_data.get("friend_code", "").strip().replace("-", "")
@ -54,10 +50,12 @@ class CustomSignupForm(SignupForm):
def save(self, request): def save(self, request):
# First, complete the normal signup process. # First, complete the normal signup process.
user = super().save(request) user = super(CustomUserCreationForm, self).save(request)
# Create the associated FriendCode record. # Create the associated FriendCode record.
FriendCode.objects.create( friend_code_pk = FriendCode.objects.create(
friend_code=self.cleaned_data["friend_code"], friend_code=self.cleaned_data["friend_code"],
user=user user=user
) )
user.default_friend_code = friend_code_pk
user.save()
return user return user

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-07 01:04 # Generated by Django 5.1.2 on 2025-03-09 05:08
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators

View file

@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from .views import ListFriendCodesView, AddFriendCodeView, DeleteFriendCodeView, ChangeDefaultFriendCodeView from .views import ListFriendCodesView, AddFriendCodeView, DeleteFriendCodeView, ChangeDefaultFriendCodeView, SettingsView
urlpatterns = [ urlpatterns = [
# ... other account URLs ... # ... other account URLs ...
@ -7,4 +7,5 @@ urlpatterns = [
path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"), path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"),
path("friend-codes/delete/<int:pk>/", DeleteFriendCodeView.as_view(), name="delete_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("friend-codes/default/<int:pk>/", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"),
path("settings/", SettingsView.as_view(), name="settings"),
] ]

View file

@ -2,7 +2,7 @@ from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.shortcuts import redirect, get_object_or_404 from django.shortcuts import redirect, get_object_or_404
from django.views.generic import ListView, CreateView, DeleteView, View from django.views.generic import ListView, CreateView, DeleteView, View, TemplateView
from accounts.models import FriendCode from accounts.models import FriendCode
from accounts.forms import FriendCodeForm from accounts.forms import FriendCodeForm
@ -110,4 +110,10 @@ class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user) friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user)
request.user.set_default_friend_code(friend_code) request.user.set_default_friend_code(friend_code)
messages.success(request, "Default friend code updated successfully.") messages.success(request, "Default friend code updated successfully.")
return redirect("list_friend_codes") return redirect("list_friend_codes")
class SettingsView(LoginRequiredMixin, TemplateView):
"""
Display the user's settings.
"""
template_name = "account/settings.html"

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-07 01:04 # Generated by Django 5.1.2 on 2025-03-09 05:08
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View file

@ -5,13 +5,12 @@ from django.utils.safestring import mark_safe
register = template.Library() register = template.Library()
@register.inclusion_tag("templatetags/card_badge.html") @register.inclusion_tag("templatetags/card_badge.html")
def card_badge(card, quantity=1, show_single_count=True): def card_badge(card, quantity=1):
return { return {
'card': card, 'card': card,
'quantity': quantity, 'quantity': quantity,
'decks': card.decks.all() if card else None, 'decks': card.decks.all() if card else None,
'dropdown': card is None, 'num_decks': card.decks.count() if card else None,
'show_single_count': show_single_count,
} }
@register.filter @register.filter
@ -23,6 +22,6 @@ def card_badge_inline(card, quantity=1):
'card': card, 'card': card,
'quantity': quantity, 'quantity': quantity,
'decks': card.decks.all() if card else None, 'decks': card.decks.all() if card else None,
'dropdown': card is None, 'num_decks': card.decks.count() if card else None,
}) })
return mark_safe(html) return mark_safe(html)

View file

@ -6,22 +6,24 @@ register = template.Library()
@register.inclusion_tag('templatetags/card_multiselect.html') @register.inclusion_tag('templatetags/card_multiselect.html')
def card_multiselect(field_name, label, placeholder, card_filter=None, selected_values=None, cache_timeout=86400, cache_key="available_cards_options"): def card_multiselect(field_name, label, placeholder, card_filter=None, selected_values=None, cache_timeout=86400, cache_key="available_cards_options"):
""" """
Renders a multiselect field for choosing cards, storing the card ID only as the option's value and Renders a multiselect field for choosing cards while supporting quantity data.
the quantity in a dedicated data attribute.
Updated to allow `card_filter` to be either a dictionary (of lookup parameters) or a QuerySet.
This is useful when you want to limit available cards based on your new trades models (e.g. showing only
cards that appear in active trade offers).
Parameters: Parameters:
- field_name: The name attribute for the select tag. - field_name: The name attribute for the select tag.
- label: Label text to show above the selector. - label: Label text to show above the selector.
- placeholder: Placeholder text to show in the select. - placeholder: Placeholder text to show in the select.
- card_filter: (Optional) A dictionary of filter parameters to apply on the Card query. - card_filter: (Optional) A dictionary of filter parameters or a QuerySet to obtain the available Card objects.
- selected_values: (Optional) A list of selected card values; if a value includes a quantity - selected_values: (Optional) A list of selected values; if a value includes a quantity it should be in the format "card_id:quantity".
it should be in the format "card_id:quantity".
- cache_timeout: (Optional) Cache timeout (in seconds) for the options block. - cache_timeout: (Optional) Cache timeout (in seconds) for the options block.
- cache_key: (Optional) Cache keyby default both select fields use the same key so that caching is shared. - cache_key: (Optional) Cache key.
""" """
if selected_values is None: if selected_values is None:
selected_values = [] selected_values = []
# Map the selected values into a dictionary: { card_id (str): quantity (str) } # Create a mapping {card_id: quantity}
selected_cards = {} selected_cards = {}
for val in selected_values: for val in selected_values:
parts = str(val).split(':') parts = str(val).split(':')
@ -29,11 +31,16 @@ def card_multiselect(field_name, label, placeholder, card_filter=None, selected_
quantity = parts[1] if len(parts) > 1 else "1" quantity = parts[1] if len(parts) > 1 else "1"
selected_cards[card_id] = quantity selected_cards[card_id] = quantity
# If a card_filter is provided, use it; otherwise retrieve all cards. # Determine how to obtain the available cards.
if card_filter: # If card_filter is not provided, or is None, fall back to all cards.
available_cards_qs = Card.objects.filter(**card_filter) if card_filter is None:
else:
available_cards_qs = Card.objects.all() available_cards_qs = Card.objects.all()
# If card_filter is a dict, treat it as mapping lookup parameters.
elif isinstance(card_filter, dict):
available_cards_qs = Card.objects.filter(**card_filter)
# Otherwise assume it's already a QuerySet.
else:
available_cards_qs = card_filter
available_cards = list( available_cards = list(
available_cards_qs.order_by("name", "rarity__pk") available_cards_qs.order_by("name", "rarity__pk")
@ -41,19 +48,17 @@ def card_multiselect(field_name, label, placeholder, card_filter=None, selected_
.prefetch_related("decks") .prefetch_related("decks")
) )
# Loop through available cards and set styling, plus attach preselected quantity
for card in available_cards: for card in available_cards:
# Apply styling based on deck count. # decks = list(card.decks.all())
deck_count = card.decks.count() # deck_count = len(decks)
if deck_count == 1: # if deck_count == 1:
card.style = f"background-color: {card.decks.all()[0].hex_color}; color: white;" # card.style = f"background-color: {decks[0].hex_color}; color: white;"
elif deck_count == 2: # elif deck_count == 2:
decks = card.decks.all() # card.style = f"background: linear-gradient(to right, {decks[0].hex_color}, {decks[1].hex_color}); color: white;"
card.style = f"background: linear-gradient(to right, {decks[0].hex_color}, {decks[1].hex_color}); color: white;" # elif deck_count >= 3:
elif deck_count >= 3: # card.style = f"background: linear-gradient(to right, {decks[0].hex_color}, {decks[1].hex_color}, {decks[2].hex_color}); color: white;"
decks = card.decks.all()
card.style = f"background: linear-gradient(to right, {decks[0].hex_color}, {decks[1].hex_color}, {decks[2].hex_color}); color: white;"
# Attach selected_quantity only if the card is preselected.
pk_str = str(card.pk) pk_str = str(card.pk)
if pk_str in selected_cards: if pk_str in selected_cards:
card.selected_quantity = selected_cards[pk_str] card.selected_quantity = selected_cards[pk_str]
@ -64,7 +69,7 @@ def card_multiselect(field_name, label, placeholder, card_filter=None, selected_
'label': label, 'label': label,
'available_cards': available_cards, 'available_cards': available_cards,
'placeholder': placeholder, 'placeholder': placeholder,
# For caching/selection checks, pass a list of the preselected card IDs. # Pass just the list of preselected card IDs for caching/selection logic in the template.
'selected_values': list(selected_cards.keys()), 'selected_values': list(selected_cards.keys()),
'cache_timeout': cache_timeout, 'cache_timeout': cache_timeout,
'cache_key': cache_key, 'cache_key': cache_key,

View file

@ -18,8 +18,9 @@ RESEND_API_KEY = "re_BBXJWctP_8gb4iNpfaHuau7Na95mc3feu"
DEBUG = True DEBUG = True
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "pkmntradeclub.fly.dev", "pkmntrade.club"] ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "pkmntrade-club.fly.dev", "pkmntrade.club"]
CSRF_TRUSTED_ORIGINS = ["https://pkmntrade-club.fly.dev", "https://pkmntrade.club"]
# Application definition # Application definition
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -46,7 +47,6 @@ INSTALLED_APPS = [
"cards", "cards",
"home", "home",
"trades.apps.TradesConfig", "trades.apps.TradesConfig",
"widget_tweaks",
] ]
TAILWIND_APP_NAME = 'theme' TAILWIND_APP_NAME = 'theme'
@ -64,7 +64,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware", # django-allauth "allauth.account.middleware.AccountMiddleware", # django-allauth
"django_browser_reload.middleware.BrowserReloadMiddleware", "django_browser_reload.middleware.BrowserReloadMiddleware",
"django_project.middleware.AutoLoginMiddleware", #"django_project.middleware.AutoLoginMiddleware",
] ]
DAISY_SETTINGS = { DAISY_SETTINGS = {
@ -96,51 +96,43 @@ TEMPLATES = [
] ]
# https://docs.djangoproject.com/en/dev/ref/settings/#databases # https://docs.djangoproject.com/en/dev/ref/settings/#databases
#DATABASES = {
# "default": {
# "ENGINE": "django.db.backends.sqlite3",
# "NAME": BASE_DIR / "db.sqlite3",
# }
#}
# For Docker/PostgreSQL usage uncomment this and comment the DATABASES config above
DATABASES = { DATABASES = {
"default": { "local": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"NAME": "postgres", "NAME": "postgres",
"USER": "postgres", "USER": "postgres",
"PASSWORD": "", "PASSWORD": "",
"HOST": "db", # set in docker-compose.yml "HOST": "localhost", # set in docker-compose.yml
"PORT": 5432, # default postgres port "PORT": 5432, # default postgres port
}, },
"neon": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"NAME": "pocket-trade", "NAME": "dev",
"USER": "pocket_trade_owner", "USER": "pocket_trade_owner",
"PASSWORD": "npg_f1lTpOX7Rnvb", "PASSWORD": "npg_f1lTpOX7Rnvb",
"HOST": "ep-cool-cake-a6zvgu85-pooler.us-west-2.aws.neon.tech", # set in docker-compose.yml "HOST": "ep-cool-cake-a6zvgu85-pooler.us-west-2.aws.neon.tech", # set in docker-compose.yml
"PORT": 5432, # default postgres port "PORT": 5432, # default postgres port
"OPTIONS": { "OPTIONS": {
"sslmode": "require" "sslmode": "require"
}, },
} }
} }
# Password validation # Password validation
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ # {
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, # },
{ # {
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, # },
{ # {
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, # },
{ # {
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", # "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, # },
] ]
@ -179,7 +171,7 @@ STORAGES = {
"BACKEND": "django.core.files.storage.FileSystemStorage", "BACKEND": "django.core.files.storage.FileSystemStorage",
}, },
"staticfiles": { "staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
}, },
} }
@ -189,11 +181,11 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# django-crispy-forms # django-crispy-forms
# https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs # https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind'#'bootstrap5' CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind'
CRISPY_TEMPLATE_PACK = "tailwind"#"bootstrap5" CRISPY_TEMPLATE_PACK = "tailwind"
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.resend.com" EMAIL_HOST = "smtp.resend.com"
EMAIL_PORT = 587 EMAIL_PORT = 587
EMAIL_HOST_USER = "resend" EMAIL_HOST_USER = "resend"
@ -206,9 +198,12 @@ DEFAULT_FROM_EMAIL = "noreply@pkmntrade.club"
# django-debug-toolbar # django-debug-toolbar
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
# https://docs.djangoproject.com/en/dev/ref/settings/#internal-ips # https://docs.djangoproject.com/en/dev/ref/settings/#internal-ips
INTERNAL_IPS = [
"127.0.0.1",
]
import socket import socket
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
INTERNAL_IPS = [ip[:-1] + "1" for ip in ips] INTERNAL_IPS.append([ip[:-1] + "1" for ip in ips])
# https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model # https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model
AUTH_USER_MODEL = "accounts.CustomUser" AUTH_USER_MODEL = "accounts.CustomUser"
@ -230,17 +225,18 @@ AUTHENTICATION_BACKENDS = (
) )
# https://django-allauth.readthedocs.io/en/latest/configuration.html # https://django-allauth.readthedocs.io/en/latest/configuration.html
ACCOUNT_SESSION_REMEMBER = True ACCOUNT_SESSION_REMEMBER = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True
ACCOUNT_AUTHENTICATION_METHOD = "email" ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_REQUIRED = True
#ACCOUNT_EMAIL_VERIFICATION = "mandatory" ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION = "none" #ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_CHANGE_EMAIL = True ACCOUNT_CHANGE_EMAIL = True
ACCOUNT_UNIQUE_EMAIL = True ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "in_game_username" ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "in-game-username"
ACCOUNT_USERNAME_REQUIRED = True
ACCOUNT_FORMS = { ACCOUNT_FORMS = {
"signup": "accounts.forms.CustomSignupForm", "signup": "accounts.forms.CustomUserCreationForm",
} }
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True

View file

@ -1,15 +1,17 @@
services: services:
web: # web:
build: . # build: .
command: python /code/manage.py runserver 0.0.0.0:8000 # command: python /code/manage.py runserver 0.0.0.0:8000
volumes: # volumes:
- .:/code:z # - .:/code:z
ports: # ports:
- 8000:8000 # - 8000:8000
depends_on: # depends_on:
- db # - db
db: db:
image: postgres:16 image: postgres:16
ports:
- 5432:5432
volumes: volumes:
- postgres_data:/var/lib/postgresql/data/ - postgres_data:/var/lib/postgresql/data/
environment: environment:

View file

@ -17,7 +17,10 @@ echo "Restarting compose services..."
docker compose down docker compose down
docker compose up -d docker compose up -d
docker compose exec web bash -c "cd /code/theme/static_src && npm run dev" || true #docker compose exec web bash -c "cd /code/theme/static_src && npm run dev" || true
uv run python manage.py runserver &
cd theme/static_src
uv run npm run dev
docker compose down docker compose down
echo "Done!" echo "Done!"

View file

@ -1,32 +1,32 @@
from collections import defaultdict from collections import defaultdict
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db.models import Count, Q, Prefetch, Sum from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from trades.models import TradeOffer from trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard
from cards.models import Card, CardSet, Rarity from cards.models import Card, CardSet, Rarity
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.http import HttpResponseRedirect
@method_decorator(cache_page(60), name='get') # Cache view for 60 seconds @method_decorator(cache_page(60), name='get')
class HomePageView(TemplateView): class HomePageView(TemplateView):
template_name = "home/home.html" template_name = "home/home.html"
def get_context_data(self, **kwargs): def get_base_trade_offer_queryset(self):
context = super().get_context_data(**kwargs) """
Returns a queryset for TradeOffer that includes prefetches and denormalized aggregates.
# Use POST data if available, else fallback to GET """
request_data = self.request.POST if self.request.method == "POST" else self.request.GET active_states = [
TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.SENT,
TradeAcceptance.AcceptanceState.RECEIVED,
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
]
# --- Search form logic ---
offered_cards = request_data.getlist("offered_cards")
wanted_cards = request_data.getlist("wanted_cards")
context["offered_cards"] = offered_cards
context["wanted_cards"] = wanted_cards
# Define prefetch objects ordered by number of associated trade offers ascending,
# and by id secondarily.
have_cards_prefetch = Prefetch( have_cards_prefetch = Prefetch(
'have_cards', 'have_cards',
queryset=Card.objects.annotate( queryset=Card.objects.annotate(
@ -40,11 +40,9 @@ class HomePageView(TemplateView):
).order_by("trade_offer_count", "id") ).order_by("trade_offer_count", "id")
) )
search_results = None qs = (
if offered_cards or wanted_cards: TradeOffer.objects.all()
# Instead of filtering by a 'state' field (which no longer exists), .prefetch_related(
# we fetch all offers. You may later add logic to filter only "open" offers.
qs = TradeOffer.objects.all().prefetch_related(
have_cards_prefetch, have_cards_prefetch,
"have_cards__decks", "have_cards__decks",
"have_cards__rarity", "have_cards__rarity",
@ -52,116 +50,80 @@ class HomePageView(TemplateView):
want_cards_prefetch, want_cards_prefetch,
"want_cards__decks", "want_cards__decks",
"want_cards__rarity", "want_cards__rarity",
"want_cards__cardset" "want_cards__cardset",
).select_related( "acceptances"
"initiated_by__user"
) )
if offered_cards: .select_related("initiated_by__user")
try: .annotate(
offered_card_ids = [int(card) for card in offered_cards] is_active=Case(
except ValueError: When(
qs = qs.none() Q(total_have_accepted__lt=F('total_have_quantity')) &
else: Q(total_want_accepted__lt=F('total_want_quantity')),
qs = qs.filter(want_cards__id__in=offered_card_ids) then=Value(True)
if wanted_cards: ),
try: default=Value(False),
wanted_card_ids = [int(card) for card in wanted_cards] output_field=BooleanField()
except ValueError: )
qs = qs.none()
else:
qs = qs.filter(have_cards__id__in=wanted_card_ids)
page_number = request_data.get("page", 1)
paginator = Paginator(qs, 6)
try:
search_results = paginator.page(page_number)
except PageNotAnInteger:
search_results = paginator.page(1)
except EmptyPage:
search_results = paginator.page(paginator.num_pages)
context["search_results"] = search_results
# --- Recently posted offers (latest 5, newest first) ---
context["recent_offers"] = TradeOffer.objects.order_by("-created_at").prefetch_related(
have_cards_prefetch,
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
want_cards_prefetch,
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset"
).select_related(
"initiated_by__user"
)[:5]
# --- Most offered cards ---
context["most_offered_cards"] = Card.objects.filter(
tradeofferhavecard__isnull=False
).annotate(
offer_count=Sum("tradeofferhavecard__quantity")
).order_by("-offer_count").select_related("rarity", "cardset").prefetch_related("decks")[:5]
# --- Most wanted cards ---
context["most_wanted_cards"] = Card.objects.filter(
tradeofferwantcard__isnull=False
).annotate(
offer_count=Sum("tradeofferwantcard__quantity")
).order_by("-offer_count").select_related("rarity", "cardset").prefetch_related("decks")[:5]
# --- Least offered cards ---
context["least_offered_cards"] = Card.objects.annotate(
offer_count=Sum("tradeofferhavecard__quantity")
).order_by("offer_count", "?")[:5]
# --- Featured offers grouped by rarity (using card.rarity.icon for tab names) ---
featured = {}
all_offers = list(
TradeOffer.objects.order_by("created_at").prefetch_related(
have_cards_prefetch,
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
want_cards_prefetch,
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset"
).select_related(
"initiated_by__user"
) )
) )
featured["All"] = all_offers[:5] return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Add available_cards QuerySet so card_multiselect works properly.
context["available_cards"] = Card.objects.all() \
.order_by("name", "rarity__pk") \
.select_related("rarity", "cardset") \
.prefetch_related("decks")
# Group offers by normalized rarity id from their have_cards # Reuse base trade offer queryset for market stats
base_offer_qs = self.get_base_trade_offer_queryset().filter(manually_closed=False, is_active=True)
# Recent Offers
recent_offers_qs = base_offer_qs.order_by("-created_at")[:10]
context["recent_offers"] = list(recent_offers_qs)[:5]
# Most Offered Cards
context["most_offered_cards"] = (
Card.objects.filter(tradeofferhavecard__isnull=False)
.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("-offer_count")
.select_related("rarity", "cardset")
.prefetch_related("decks")[:5]
)
# Most Wanted Cards
context["most_wanted_cards"] = (
Card.objects.filter(tradeofferwantcard__isnull=False)
.annotate(offer_count=Sum("tradeofferwantcard__quantity"))
.order_by("-offer_count")
.select_related("rarity", "cardset")
.prefetch_related("decks")[:5]
)
# Least Offered Cards
context["least_offered_cards"] = (
Card.objects.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("offer_count", "?")[:5]
)
# Featured Offers grouped by rarity
all_offers = base_offer_qs.order_by("created_at")
featured = {}
featured["All"] = all_offers[:5]
grouped = defaultdict(list) grouped = defaultdict(list)
for offer in all_offers: for offer in all_offers:
normalized_ids = set() normalized_ids = {card.rarity.normalized_id for card in offer.have_cards.all() if card.rarity}
for card in offer.have_cards.all():
if card.rarity:
normalized_ids.add(card.rarity.normalized_id)
for norm in normalized_ids: for norm in normalized_ids:
grouped[norm].append(offer) grouped[norm].append(offer)
# Map each normalized rarity id to a representative icon
norm_ids_available = list(grouped.keys()) norm_ids_available = list(grouped.keys())
rareness_qs = Rarity.objects.filter(pk__in=[6] + [nid for nid in norm_ids_available if nid != 6]) rareness_qs = Rarity.objects.filter(pk__in=[6] + [nid for nid in norm_ids_available if nid != 6])
rarity_map = {rarity.pk: rarity.icons for rarity in rareness_qs} rarity_map = {rarity.pk: rarity.icons for rarity in rareness_qs}
# Order groups by descending normalized rarity id
for norm in sorted(grouped.keys(), reverse=True): for norm in sorted(grouped.keys(), reverse=True):
offers = grouped[norm] offers = grouped[norm]
icon_label = rarity_map.get(norm) icon_label = rarity_map.get(norm)
if icon_label: if icon_label:
featured[icon_label] = offers[:5] featured[icon_label] = offers[:5]
context["featured_offers"] = featured context["featured_offers"] = featured
return context return context
def post(self, request, *args, **kwargs):
# If the request is AJAX, return only the search results fragment
context = self.get_context_data(**kwargs)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
return TemplateResponse(request, "home/_search_results.html", context)
return self.render_to_response(context)

40
pyproject.toml Normal file
View file

@ -0,0 +1,40 @@
[project]
name = "pkmntrade-club"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"asgiref==3.8.1",
"certifi==2022.12.7",
"cffi==1.17.1",
"charset-normalizer==3.0.1",
"cookiecutter==2.6.0",
"crispy-tailwind==1.0.3",
"cryptography==39.0.1",
"defusedxml==0.7.1",
"django==5.1.2",
"django-allauth==65.0.2",
"django-browser-reload==1.17.0",
"django-crispy-forms==2.3",
"django-daisy==1.0.13",
"django-debug-toolbar==4.4.6",
"django-el-pagination==4.1.2",
"django-tailwind-4[reload]==0.1.4",
"django-widget-tweaks==1.5.0",
"gunicorn==23.0.0",
"idna==3.4",
"oauthlib==3.2.2",
"packaging==23.1",
"psycopg==3.2.3",
"psycopg-binary==3.2.3",
"pycparser==2.21",
"pyjwt==2.6.0",
"python3-openid==3.2.0",
"requests==2.28.2",
"requests-oauthlib==1.3.1",
"sqlparse==0.4.3",
"typing-extensions==4.9.0",
"urllib3==1.26.14",
"whitenoise==6.7.0",
]

View file

@ -23,6 +23,4 @@ echo "Loading seed data..."
docker compose exec web bash -c "python manage.py loaddata seed/0*" docker compose exec web bash -c "python manage.py loaddata seed/0*"
echo "Seeding default friend codes..." echo "Seeding default friend codes..."
docker compose exec web bash -c "python manage.py seed_default_friend_codes" docker compose exec web bash -c "python manage.py seed_default_friend_codes"
./entrypoint.sh

View file

@ -4,7 +4,7 @@
"pk": 1, "pk": 1,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "c4ca4238", "hash": "c4ca4238z",
"initiated_by": 3, "initiated_by": 3,
"created_at": "2025-03-07T00:21:33.089Z", "created_at": "2025-03-07T00:21:33.089Z",
"updated_at": "2025-03-07T00:21:33.089Z" "updated_at": "2025-03-07T00:21:33.089Z"
@ -15,7 +15,7 @@
"pk": 2, "pk": 2,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "c81e728d", "hash": "c81e728dz",
"initiated_by": 4, "initiated_by": 4,
"created_at": "2025-03-07T00:24:21.664Z", "created_at": "2025-03-07T00:24:21.664Z",
"updated_at": "2025-03-07T00:24:21.664Z" "updated_at": "2025-03-07T00:24:21.664Z"
@ -26,7 +26,7 @@
"pk": 3, "pk": 3,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "eccbc87e", "hash": "eccbc87ez",
"initiated_by": 3, "initiated_by": 3,
"created_at": "2025-03-07T00:27:36.345Z", "created_at": "2025-03-07T00:27:36.345Z",
"updated_at": "2025-03-07T00:27:36.345Z" "updated_at": "2025-03-07T00:27:36.345Z"
@ -37,7 +37,7 @@
"pk": 4, "pk": 4,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "a87ff679", "hash": "a87ff679z",
"initiated_by": 4, "initiated_by": 4,
"created_at": "2025-03-07T00:28:57.655Z", "created_at": "2025-03-07T00:28:57.655Z",
"updated_at": "2025-03-07T00:28:57.655Z" "updated_at": "2025-03-07T00:28:57.655Z"
@ -48,7 +48,7 @@
"pk": 5, "pk": 5,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "e4da3b7f", "hash": "e4da3b7fz",
"initiated_by": 4, "initiated_by": 4,
"created_at": "2025-03-07T00:30:53.491Z", "created_at": "2025-03-07T00:30:53.491Z",
"updated_at": "2025-03-07T00:30:53.491Z" "updated_at": "2025-03-07T00:30:53.491Z"
@ -59,7 +59,7 @@
"pk": 6, "pk": 6,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "1679091c", "hash": "1679091cz",
"initiated_by": 1, "initiated_by": 1,
"created_at": "2025-03-07T00:21:33.089Z", "created_at": "2025-03-07T00:21:33.089Z",
"updated_at": "2025-03-07T00:21:33.089Z" "updated_at": "2025-03-07T00:21:33.089Z"
@ -70,7 +70,7 @@
"pk": 7, "pk": 7,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "8f14e45f", "hash": "8f14e45fz",
"initiated_by": 2, "initiated_by": 2,
"created_at": "2025-03-07T00:24:21.664Z", "created_at": "2025-03-07T00:24:21.664Z",
"updated_at": "2025-03-07T00:24:21.664Z" "updated_at": "2025-03-07T00:24:21.664Z"
@ -81,7 +81,7 @@
"pk": 8, "pk": 8,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "c9f0f895", "hash": "c9f0f895z",
"initiated_by": 1, "initiated_by": 1,
"created_at": "2025-03-07T00:27:36.345Z", "created_at": "2025-03-07T00:27:36.345Z",
"updated_at": "2025-03-07T00:27:36.345Z" "updated_at": "2025-03-07T00:27:36.345Z"
@ -92,7 +92,7 @@
"pk": 9, "pk": 9,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "45c48cce", "hash": "45c48ccez",
"initiated_by": 2, "initiated_by": 2,
"created_at": "2025-03-07T00:28:57.655Z", "created_at": "2025-03-07T00:28:57.655Z",
"updated_at": "2025-03-07T00:28:57.655Z" "updated_at": "2025-03-07T00:28:57.655Z"
@ -103,7 +103,7 @@
"pk": 10, "pk": 10,
"fields": { "fields": {
"manually_closed": false, "manually_closed": false,
"hash": "d3d94468", "hash": "d3d94468z",
"initiated_by": 1, "initiated_by": 1,
"created_at": "2025-03-07T00:30:53.491Z", "created_at": "2025-03-07T00:30:53.491Z",
"updated_at": "2025-03-07T00:30:53.491Z" "updated_at": "2025-03-07T00:30:53.491Z"

View file

@ -5,7 +5,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 113, "card": 113,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -14,7 +14,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 479, "card": 479,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -23,7 +23,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 206, "card": 206,
"quantity": 8 "quantity": 1
} }
}, },
{ {
@ -32,7 +32,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 414, "card": 414,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -41,7 +41,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 329, "card": 329,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -50,7 +50,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 395, "card": 395,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -59,7 +59,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 42, "card": 42,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -77,7 +77,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 165, "card": 165,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -86,7 +86,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 65, "card": 65,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -95,7 +95,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 309, "card": 309,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -104,7 +104,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 219, "card": 219,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -113,7 +113,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 413, "card": 413,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -122,7 +122,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 173, "card": 173,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -131,7 +131,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 469, "card": 469,
"quantity": 8 "quantity": 1
} }
}, },
{ {
@ -149,7 +149,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 394, "card": 394,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -158,7 +158,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 437, "card": 437,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -167,7 +167,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 384, "card": 384,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -176,7 +176,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 305, "card": 305,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -185,7 +185,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 13, "card": 13,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -194,7 +194,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 177, "card": 177,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -221,7 +221,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 76, "card": 76,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -230,7 +230,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 4, "card": 4,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -239,7 +239,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 471, "card": 471,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -248,7 +248,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 379, "card": 379,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -257,7 +257,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 104, "card": 104,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -275,7 +275,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 529, "card": 529,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -284,7 +284,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 540, "card": 540,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -293,7 +293,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 239, "card": 239,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -302,7 +302,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 248, "card": 248,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -311,7 +311,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 355, "card": 355,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -320,7 +320,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 115, "card": 115,
"quantity": 8 "quantity": 1
} }
}, },
{ {
@ -329,7 +329,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 502, "card": 502,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -338,7 +338,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 517, "card": 517,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -347,7 +347,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 105, "card": 105,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -365,7 +365,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 442, "card": 442,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -374,7 +374,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 287, "card": 287,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -383,7 +383,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 194, "card": 194,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -401,7 +401,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 321, "card": 321,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -410,7 +410,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 34, "card": 34,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -419,7 +419,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 524, "card": 524,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -428,7 +428,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 108, "card": 108,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -437,7 +437,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 30, "card": 30,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -446,7 +446,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 431, "card": 431,
"quantity": 8 "quantity": 1
} }
}, },
{ {
@ -455,7 +455,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 150, "card": 150,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -473,7 +473,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 117, "card": 117,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -482,7 +482,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 40, "card": 40,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -491,7 +491,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 486, "card": 486,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -500,7 +500,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 481, "card": 481,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -509,7 +509,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 425, "card": 425,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -518,7 +518,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 300, "card": 300,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -536,7 +536,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 41, "card": 41,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -545,7 +545,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 84, "card": 84,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -554,7 +554,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 36, "card": 36,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -563,7 +563,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 482, "card": 482,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -572,7 +572,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 401, "card": 401,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -590,7 +590,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 549, "card": 549,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -599,7 +599,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 227, "card": 227,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -608,7 +608,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 530, "card": 530,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -617,7 +617,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 359, "card": 359,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -626,7 +626,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 238, "card": 238,
"quantity": 6 "quantity": 1
} }
} }
] ]

View file

@ -5,7 +5,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 115, "card": 115,
"quantity": 8 "quantity": 1
} }
}, },
{ {
@ -14,7 +14,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 502, "card": 502,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -23,7 +23,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 517, "card": 517,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -32,7 +32,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 105, "card": 105,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -50,7 +50,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 442, "card": 442,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -59,7 +59,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 287, "card": 287,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -68,7 +68,7 @@
"fields": { "fields": {
"trade_offer": 1, "trade_offer": 1,
"card": 194, "card": 194,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -86,7 +86,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 321, "card": 321,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -95,7 +95,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 34, "card": 34,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -104,7 +104,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 524, "card": 524,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -113,7 +113,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 108, "card": 108,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -122,7 +122,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 30, "card": 30,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -131,7 +131,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 431, "card": 431,
"quantity": 8 "quantity": 1
} }
}, },
{ {
@ -140,7 +140,7 @@
"fields": { "fields": {
"trade_offer": 2, "trade_offer": 2,
"card": 150, "card": 150,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -158,7 +158,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 117, "card": 117,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -167,7 +167,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 40, "card": 40,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -176,7 +176,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 486, "card": 486,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -185,7 +185,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 481, "card": 481,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -194,7 +194,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 425, "card": 425,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -203,7 +203,7 @@
"fields": { "fields": {
"trade_offer": 3, "trade_offer": 3,
"card": 300, "card": 300,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -221,7 +221,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 41, "card": 41,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -230,7 +230,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 84, "card": 84,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -239,7 +239,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 36, "card": 36,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -248,7 +248,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 482, "card": 482,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -257,7 +257,7 @@
"fields": { "fields": {
"trade_offer": 4, "trade_offer": 4,
"card": 401, "card": 401,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -275,7 +275,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 549, "card": 549,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -284,7 +284,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 227, "card": 227,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -293,7 +293,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 530, "card": 530,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -302,7 +302,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 359, "card": 359,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -311,7 +311,7 @@
"fields": { "fields": {
"trade_offer": 5, "trade_offer": 5,
"card": 238, "card": 238,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -320,7 +320,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 113, "card": 113,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -329,7 +329,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 479, "card": 479,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -338,7 +338,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 206, "card": 206,
"quantity": 8 "quantity": 1
} }
}, },
{ {
@ -347,7 +347,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 414, "card": 414,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -356,7 +356,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 329, "card": 329,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -365,7 +365,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 395, "card": 395,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -374,7 +374,7 @@
"fields": { "fields": {
"trade_offer": 6, "trade_offer": 6,
"card": 42, "card": 42,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -392,7 +392,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 165, "card": 165,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -401,7 +401,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 65, "card": 65,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -410,7 +410,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 309, "card": 309,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -419,7 +419,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 219, "card": 219,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -428,7 +428,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 413, "card": 413,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -437,7 +437,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 173, "card": 173,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -446,7 +446,7 @@
"fields": { "fields": {
"trade_offer": 7, "trade_offer": 7,
"card": 469, "card": 469,
"quantity": 8 "quantity": 1
} }
}, },
{ {
@ -464,7 +464,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 394, "card": 394,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -473,7 +473,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 437, "card": 437,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -482,7 +482,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 384, "card": 384,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -491,7 +491,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 305, "card": 305,
"quantity": 7 "quantity": 1
} }
}, },
{ {
@ -500,7 +500,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 13, "card": 13,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -509,7 +509,7 @@
"fields": { "fields": {
"trade_offer": 8, "trade_offer": 8,
"card": 177, "card": 177,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -536,7 +536,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 76, "card": 76,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -545,7 +545,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 4, "card": 4,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -554,7 +554,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 471, "card": 471,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -563,7 +563,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 379, "card": 379,
"quantity": 5 "quantity": 1
} }
}, },
{ {
@ -572,7 +572,7 @@
"fields": { "fields": {
"trade_offer": 9, "trade_offer": 9,
"card": 104, "card": 104,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -590,7 +590,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 529, "card": 529,
"quantity": 2 "quantity": 1
} }
}, },
{ {
@ -599,7 +599,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 540, "card": 540,
"quantity": 4 "quantity": 1
} }
}, },
{ {
@ -608,7 +608,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 239, "card": 239,
"quantity": 3 "quantity": 1
} }
}, },
{ {
@ -617,7 +617,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 248, "card": 248,
"quantity": 6 "quantity": 1
} }
}, },
{ {
@ -626,7 +626,7 @@
"fields": { "fields": {
"trade_offer": 10, "trade_offer": 10,
"card": 355, "card": 355,
"quantity": 5 "quantity": 1
} }
} }
] ]

View file

@ -106,33 +106,4 @@ button.select2-selection__choice__remove {
width: 1px; /* The thickness of the separator */ width: 1px; /* The thickness of the separator */
background-color: var(--bs-gray-300); /* Color for the separator */ background-color: var(--bs-gray-300); /* Color for the separator */
z-index: 1; z-index: 1;
}
/* Modified slider wrapper to allow space for left/right shadows */
.tab-slider-wrapper {
overflow: hidden;
/* increase the total width to include extra shadow space */
width: calc(100% + 40px);
/* negative margins pull the container outward */
margin: 0 -20px;
/* add horizontal padding to preserve layout inside */
padding: 0 20px;
}
/* Grid-based slider for tab content */
.tab-content {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
gap: 80px; /* Add gap between columns */
transition: transform 0.5s ease;
position: relative;
will-change: transform; /* Optional: Helps with smoother animations */
}
.tab-pane {
width: 100%;
display: block !important;
position: relative;
opacity: 1;
} }

123
theme/static/js/tooltip.js Normal file
View file

@ -0,0 +1,123 @@
/**
* tooltip.js
*
* This script uses FloatingUI to create modern, styled tooltips for elements with the
* custom attribute "data-tooltip-html". The tooltips are styled using Tailwind CSS classes
* to support both light and dark themes and include a dynamically positioned arrow.
*
* Make sure the FloatingUIDOM global is available.
* For example, include in your base template:
* <script src="https://unpkg.com/@floating-ui/dom"></script>
*/
const { computePosition, offset, flip, shift, arrow } = FloatingUIDOM;
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-tooltip-html]').forEach((el) => {
let tooltipContainer = null;
let arrowElement = null;
let fadeOutTimeout;
const showTooltip = () => {
if (tooltipContainer) return; // Tooltip already visible
// Retrieve the custom HTML content from the data attribute
const tooltipContent = el.getAttribute('data-tooltip-html');
// Create a container for the tooltip (with modern styling)
tooltipContainer = document.createElement('div');
tooltipContainer.classList.add(
'bg-black', 'text-white',
'shadow-lg', 'rounded-lg', 'p-2',
// Transition classes for simple fade in/out
'transition-opacity', 'duration-200', 'opacity-0'
);
tooltipContainer.style.position = 'absolute';
tooltipContainer.style.zIndex = '9999';
// Set the HTML content for the tooltip
tooltipContainer.innerHTML = '<div class="p-2">' + tooltipContent + '</div>';
// Create the arrow element. The arrow is styled as a small rotated square.
arrowElement = document.createElement('div');
arrowElement.classList.add(
'w-3', 'h-3',
'bg-black',
'transform', 'rotate-45'
);
arrowElement.style.position = 'absolute';
// Append the arrow into the tooltip container
tooltipContainer.appendChild(arrowElement);
// Append the tooltip container to the document body
document.body.appendChild(tooltipContainer);
// Use Floating UI to position the tooltip, including the arrow middleware
computePosition(el, tooltipContainer, {
middleware: [
offset(8),
flip(),
shift({ padding: 5 }),
arrow({ element: arrowElement })
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(tooltipContainer.style, {
left: `${x}px`,
top: `${y}px`
});
// Position the arrow using the arrow middleware data
const { x: arrowX, y: arrowY } = middlewareData.arrow || {};
// Reset any previous inline values
arrowElement.style.left = '';
arrowElement.style.top = '';
arrowElement.style.right = '';
arrowElement.style.bottom = '';
// Adjust the arrow's position according to the placement
if (placement.startsWith('top')) {
arrowElement.style.bottom = '-4px';
arrowElement.style.left = arrowX !== undefined ? `${arrowX}px` : '50%';
} else if (placement.startsWith('bottom')) {
arrowElement.style.top = '-4px';
arrowElement.style.left = arrowX !== undefined ? `${arrowX}px` : '50%';
} else if (placement.startsWith('left')) {
arrowElement.style.right = '-4px';
arrowElement.style.top = arrowY !== undefined ? `${arrowY}px` : '50%';
} else if (placement.startsWith('right')) {
arrowElement.style.left = '-4px';
arrowElement.style.top = arrowY !== undefined ? `${arrowY}px` : '50%';
}
});
// Trigger a fade-in by moving from opacity-0 to opacity-100
requestAnimationFrame(() => {
tooltipContainer.classList.remove('opacity-0');
tooltipContainer.classList.add('opacity-100');
});
};
const hideTooltip = () => {
if (tooltipContainer) {
tooltipContainer.classList.remove('opacity-100');
tooltipContainer.classList.add('opacity-0');
// Remove the tooltip from the DOM after the transition duration
fadeOutTimeout = setTimeout(() => {
if (tooltipContainer && tooltipContainer.parentNode) {
tooltipContainer.parentNode.removeChild(tooltipContainer);
}
tooltipContainer = null;
arrowElement = null;
}, 200); // Matches the duration-200 class (200ms)
}
};
// Attach event listeners to show/hide the tooltip
el.addEventListener('mouseenter', showTooltip);
el.addEventListener('mouseleave', hideTooltip);
el.addEventListener('focus', showTooltip);
el.addEventListener('blur', hideTooltip);
});
});

View file

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load crispy_forms_tags i18n widget_tweaks %} {% load crispy_forms_tags i18n %}
{% block head_title %}{% trans "Log In" %}{% endblock %} {% block head_title %}{% trans "Log In" %}{% endblock %}
@ -11,12 +11,12 @@
{{ form.non_field_errors }} {{ form.non_field_errors }}
<div> <div>
<label for="{{ form.login.id_for_label }}" class="block font-medium text-gray-700">{{ form.login.label }}</label> <label for="{{ form.login.id_for_label }}" class="block font-medium text-gray-700">{{ form.login.label }}</label>
{{ form.login|add_class:"input input-bordered w-full" }} {{ form.login }}
{{ form.login.errors }} {{ form.login.errors }}
</div> </div>
<div> <div>
<label for="{{ form.password.id_for_label }}" class="block font-medium text-gray-700">{{ form.password.label }}</label> <label for="{{ form.password.id_for_label }}" class="block font-medium text-gray-700">{{ form.password.label }}</label>
{{ form.password|add_class:"input input-bordered w-full" }} {{ form.password }}
{{ form.password.errors }} {{ form.password.errors }}
</div> </div>
{% if form.remember %} {% if form.remember %}
@ -28,7 +28,7 @@
<button type="submit" class="btn btn-primary w-full">{% trans "Log In" %}</button> <button type="submit" class="btn btn-primary w-full">{% trans "Log In" %}</button>
</form> </form>
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<a href="{% url 'account_reset_password' %}" class="text-primary underline">{% trans "Forgot Password?" %}</a> <a href="{% url 'account_request_login_code' %}" class="text-primary underline">{% trans "Login by Code" %}</a> | <a href="{% url 'account_reset_password' %}" class="text-primary underline">{% trans "Forgot Password?" %}</a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% load i18n %}
{% block head_title %}{% trans "Settings" %}{% endblock %}
{% block content %}
<div class="container mx-auto flex items-center justify-center min-h-screen">
<div class="w-full max-w-sm p-6 bg-base-100 shadow rounded-box">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Settings" %}</h1>
<div class="flex flex-col gap-4">
<a href="{% url 'list_friend_codes' %}" class="btn btn-primary w-full">
{% trans "Friend Codes" %}
</a>
<a href="{% url 'account_logout' %}" class="btn btn-warning w-full">
{% trans "Logout" %}
</a>
<a href="https://www.gravatar.com/profile/" target="_blank" rel="noopener noreferrer" class="btn btn-secondary w-full">
{% trans "Profile" %}
</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n widget_tweaks %} {% load i18n %}
{% block head_title %}{% trans "Sign Up" %}{% endblock %} {% block head_title %}{% trans "Sign Up" %}{% endblock %}
@ -11,27 +11,27 @@
{{ form.non_field_errors }} {{ form.non_field_errors }}
<div> <div>
<label for="{{ form.username.id_for_label }}" class="block font-medium text-gray-700">{{ form.username.label }}</label> <label for="{{ form.username.id_for_label }}" class="block font-medium text-gray-700">{{ form.username.label }}</label>
{{ form.username|add_class:"input input-bordered w-full" }} {{ form.username }}
{{ form.username.errors }} {{ form.username.errors }}
</div> </div>
<div> <div>
<label for="{{ form.email.id_for_label }}" class="block font-medium text-gray-700">{{ form.email.label }}</label> <label for="{{ form.email.id_for_label }}" class="block font-medium text-gray-700">{{ form.email.label }}</label>
{{ form.email|add_class:"input input-bordered w-full" }} {{ form.email }}
{{ form.email.errors }} {{ form.email.errors }}
</div> </div>
<div> <div>
<label for="{{ form.password1.id_for_label }}" class="block font-medium text-gray-700">{{ form.password1.label }}</label> <label for="{{ form.password1.id_for_label }}" class="block font-medium text-gray-700">{{ form.password1.label }}</label>
{{ form.password1|add_class:"input input-bordered w-full" }} {{ form.password1 }}
{{ form.password1.errors }} {{ form.password1.errors }}
</div> </div>
<div> <div>
<label for="{{ form.password2.id_for_label }}" class="block font-medium text-gray-700">{{ form.password2.label }}</label> <label for="{{ form.password2.id_for_label }}" class="block font-medium text-gray-700">{{ form.password2.label }}</label>
{{ form.password2|add_class:"input input-bordered w-full" }} {{ form.password2 }}
{{ form.password2.errors }} {{ form.password2.errors }}
</div> </div>
<div> <div>
<label for="{{ form.friend_code.id_for_label }}" class="block font-medium text-gray-700">{{ form.friend_code.label }}</label> <label for="{{ form.friend_code.id_for_label }}" class="block font-medium text-gray-700">{{ form.friend_code.label }}</label>
{{ form.friend_code|add_class:"input input-bordered w-full" }} {{ form.friend_code }}
{{ form.friend_code.errors }} {{ form.friend_code.errors }}
</div> </div>
<button type="submit" class="btn btn-primary w-full">{% trans "Sign Up" %}</button> <button type="submit" class="btn btn-primary w-full">{% trans "Sign Up" %}</button>

View file

@ -27,6 +27,10 @@
<!-- Tailwind CSS and Base stylesheet --> <!-- Tailwind CSS and Base stylesheet -->
{% tailwind_css %} {% tailwind_css %}
<link rel="stylesheet" href="{% static 'css/base.css' %}"> <link rel="stylesheet" href="{% static 'css/base.css' %}">
<!-- Floating UI -->
<script src="https://cdn.jsdelivr.net/npm/@floating-ui/core@1.6.9"></script>
<script src="https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.6.13"></script>
{% block css %}{% endblock %} {% block css %}{% endblock %}
{% block javascript_head %}{% endblock %} {% block javascript_head %}{% endblock %}
@ -139,26 +143,23 @@
</footer> </footer>
<!-- Dock --> <!-- Dock -->
<div class="dock bg-neutral text-neutral-content sm:hidden"> <div x-data class="dock bg-neutral text-neutral-content">
<button> <button @click="window.location.href = '{% url 'home' %}'" class="{% if request.path == '/' %}dock-active{% endif %}">
<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"><polyline points="1 11 12 2 23 11" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="2"></polyline><path d="m5,13v7c0,1.105.895,2,2,2h10c1.105,0,2-.895,2-2v-7" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path><line x1="12" y1="22" x2="12" y2="18" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></line></g></svg> <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"><polyline points="1 11 12 2 23 11" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="2"></polyline><path d="m5,13v7c0,1.105.895,2,2,2h10c1.105,0,2-.895,2-2v-7" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path><line x1="12" y1="22" x2="12" y2="18" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></line></g></svg>
<span class="dock-label">Home</span> <span class="dock-label">Home</span>
</button> </button>
<button @click="window.location.href = '{% url 'trade_offer_list' %}'" class="{% if '/trades/all/' in request.path %}dock-active{% endif %}">
<button class="dock-active"> <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" class="size-6"><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>
<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"><polyline points="3 14 9 14 9 17 15 17 15 14 21 14" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="2"></polyline><rect x="3" y="3" width="18" height="18" rx="2" ry="2" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></rect></g></svg> <span class="dock-label">All Offers</span>
<span class="dock-label">Trades</span> </button>
</button> <button @click="window.location.href = '{% url 'trade_offer_list' %}?my_trades=true'" class="{% if '/trades/my/' in request.path %}dock-active{% endif %}">
<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" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /></svg>
<button> <span class="dock-label">My Trades</span>
<svg class="size-[1.5em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> </g></svg> </button>
<span class="dock-label">Notifications</span> <button @click="window.location.href = '{% url 'settings' %}'" class="{% if '/settings/' in request.path %}dock-active{% endif %}">
</button> {% 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>
<button> </button>
{% 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>
</button>
</div> </div>
<!-- Alpine Plugins --> <!-- Alpine Plugins -->
@ -167,6 +168,8 @@
<!-- Alpine Core --> <!-- Alpine Core -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script defer src="{% static 'js/tooltip.js' %}"></script>
<script defer src="{% static 'js/base.js' %}"></script> <script defer src="{% static 'js/base.js' %}"></script>
{% block javascript %}{% endblock %} {% block javascript %}{% endblock %}
</body> </body>

View file

@ -4,21 +4,21 @@
- cards: a list of card objects - cards: a list of card objects
- mode: a string that determines the render style. - mode: a string that determines the render style.
It should be "offered" for Most Offered Cards and "wanted" for Most Wanted Cards. It should be "offered" for Most Offered Cards and "wanted" for Most Wanted Cards.
- Optional 'show_zero' flag (default False): if True, also display cards with 0 offers. - Optional:
'show_zero' flag (default False): if True, also display cards with 0 offers.
'layout' variable: if set to "auto", use an auto-fit grid based on available horizontal space.
{% endcomment %} {% endcomment %}
{% if cards %} {% if cards %}
<div class="flex flex-col items-center gap-3"> <div class="mx-4 grid gap-3 grid-cols-[repeat(auto-fit,minmax(150px,1fr))] justify-items-center">
{% for card in cards %} {% for card in cards %}
{% if show_zero|default:False or card.offer_count > 0 %} {% if mode == "offered" %}
{% if mode == "offered" %} <a href="?offered_cards={{ card.id }}"
<a href="?offered_cards={{ card.id }}" {% else %}
{% else %} <a href="?wanted_cards={{ card.id }}"
<a href="?wanted_cards={{ card.id }}"
{% endif %}
class="flex justify-between items-center text-primary no-underline">
{% card_badge card card.offer_count %}
</a>
{% endif %} {% endif %}
class="flex justify-between items-center text-primary no-underline">
{% card_badge card card.offer_count %}
</a>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}

View file

@ -16,115 +16,88 @@
<!-- Search Form Section --> <!-- Search Form Section -->
<section id="trade-search" class="mb-8"> <section id="trade-search" class="mb-8">
<form method="post" action="." class="space-y-4"> <form method="post" action="{% url 'trade_offer_search' %}" class="space-y-4">
{% csrf_token %} {% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
{% card_multiselect "have_cards" "Have:" "Select zero or more cards..." available_cards have_cards %} {% card_multiselect "have_cards" "Have:" "Select some cards..." available_cards have_cards %}
</div> </div>
<div> <div>
{% card_multiselect "want_cards" "Want:" "Select zero or more cards..." available_cards want_cards %} {% card_multiselect "want_cards" "Want:" "Select some cards..." available_cards want_cards %}
</div> </div>
</div> </div>
{% if user.is_authenticated %} <div class="flex flex-col md:flex-row gap-4">
<div class="flex flex-col md:flex-row gap-4"> <button type="submit" class="btn btn-primary flex-1">
<button type="submit" class="btn btn-primary flex-1">Find a Trade Offer</button> Find a Trade Offer
<a href="{% url 'trade_offer_create' %}" id="createTradeOfferBtn" class="btn btn-secondary flex-1 text-center">Create Trade Offer</a> </button>
</div> {% if user.is_authenticated %}
{% else %} <a href="{% url 'trade_offer_create' %}" id="createTradeOfferBtn" class="btn btn-secondary flex-1 text-center">
<div> Create Trade Offer
<button type="submit" class="btn btn-primary w-full">Find a Trade Offer</button> </a>
</div> {% endif %}
{% endif %} </div>
</form> </form>
</section> </section>
<!-- Search Results Section --> <!-- Market Stats Section -->
<section id="search-results" class="mb-8"> <section aria-labelledby="stats-heading" class="mb-8">
{% include "home/_search_results.html" %} <h2 id="stats-heading" class="text-2xl font-semibold mb-4">Market Stats</h2>
</section> <div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<!-- Most Offered Cards -->
<!-- Market Stats Section --> <div>
<section aria-labelledby="stats-heading" class="mb-8"> <div class="card card-border bg-base-100 shadow-lg">
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Market Stats</h2> <div class="card-header text-base-content p-4">
<div class="grid grid-cols-2 md:grid-cols-3 gap-4"> <h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Offered Cards</h5>
<!-- Most Offered Cards --> </div>
<div> <div class="card-body my-4 p-0">
<div class="card bg-base-100 shadow"> {% cache 3600 most_offered_cards %}
<div class="card-header text-base-content p-4"> {% include "home/_card_list.html" with cards=most_offered_cards mode="wanted" %}
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Offered Cards</h5> {% endcache %}
</div>
</div> </div>
<div class="card-body my-4 p-0"> </div>
{% cache 3600 most_offered_cards %} <!-- Most Wanted Cards -->
{% include "home/_card_list.html" with cards=most_offered_cards mode="wanted" %} <div>
{% endcache %} <div class="card card-border bg-base-100 shadow-lg">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Wanted Cards</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 most_wanted_cards %}
{% include "home/_card_list.html" with cards=most_wanted_cards mode="offered" %}
{% endcache %}
</div>
</div>
</div>
<!-- Least Offered Cards (Last Group) -->
<div class="col-span-2 md:col-span-1">
<div class="card card-border bg-base-100 shadow-lg">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Least Offered Cards</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 least_offered_cards %}
{% include "home/_card_list.html" with cards=least_offered_cards mode="wanted" %}
{% endcache %}
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Most Wanted Cards --> </section>
<div>
<div class="card bg-base-100 shadow">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Wanted Cards</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 most_wanted_cards %}
{% include "home/_card_list.html" with cards=most_wanted_cards mode="offered" %}
{% endcache %}
</div>
</div>
</div>
<!-- Least Offered Cards -->
<div>
<div class="card bg-base-100 shadow">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Least Offered Cards</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 least_offered_cards %}
{% include "home/_card_list.html" with cards=least_offered_cards mode="wanted" show_zero=True %}
{% endcache %}
</div>
</div>
</div>
</div>
</section>
<!-- Featured Offers and Recent Offers Section --> <!-- Featured Offers and Recent Offers Section -->
<section class="mb-8"> <section class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Featured Offers --> <!-- Featured Offers -->
<div> <div>
{% cache 86400 featured_offers %} {% cache 86400 featured_offers %}
<div class="card bg-base-100 shadow"> <div class="p-4">
<div class="card-header text-base-content p-4"> <h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5>
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5> </div>
</div> <div class="p-4">
<div class="card-body p-4"> <!-- Tab contents -->
<!-- New pure-CSS tabs for Featured Offers --> <div id="featured-tab-contents">
<div class="featured-offers-tabs"> <div class="tab-content" data-tab="featured-all">
<!-- Radio inputs for all tabs -->
<input type="radio" name="featured_offers_tabs" id="tab-all" class="hidden" checked>
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
<input type="radio" name="featured_offers_tabs" id="tab-{{ forloop.counter }}" class="hidden">
{% endif %}
{% endfor %}
<!-- Tab navigation: all tab labels appear together -->
<div class="tabs tabs-box grid grid-cols-3 gap-2">
<label for="tab-all" class="tab text-xs md:text-base">All</label>
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
<label for="tab-{{ forloop.counter }}" class="tab text-xs md:text-base">{{ rarity }}</label>
{% endif %}
{% endfor %}
</div>
<!-- All tab content panels are placed in one content container -->
<div class="tab-contents">
<!-- Panel for All offers -->
<div class="tab-content" id="content-tab-all">
{% if featured_offers.All %} {% if featured_offers.All %}
<div class="flex flex-col items-center gap-3 w-auto mx-auto"> <div class="flex flex-col items-center gap-3 w-auto mx-auto">
{% for offer in featured_offers.All %} {% for offer in featured_offers.All %}
@ -135,11 +108,9 @@
<p class="text-center">No featured offers available.</p> <p class="text-center">No featured offers available.</p>
{% endif %} {% endif %}
</div> </div>
<!-- Panels for each additional rarity -->
{% for rarity, offers in featured_offers.items %} {% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %} {% if rarity != "All" %}
<div class="tab-content" id="content-tab-{{ forloop.counter }}"> <div class="tab-content" data-tab="featured-{{ forloop.counter }}" style="display: none;">
{% if offers %} {% if offers %}
<div class="flex flex-col items-center gap-3 w-auto mx-auto"> <div class="flex flex-col items-center gap-3 w-auto mx-auto">
{% for offer in offers %} {% for offer in offers %}
@ -153,20 +124,30 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
<!-- DaisyUI Tabs for Featured Offers -->
<div class="card card-border bg-base-100 shadow-lg w-96 md:w-80 lg:w-96 mt-8 mx-auto">
<!-- Tabs navigation using daisyUI tabs-box -->
<div class="tabs tabs-box bg-white dark:bg-base-100 grid grid-cols-3 gap-1.5 justify-items-stretch">
<!-- Radio inputs for controlling tab state -->
<input type="radio" class="tab text-xs font-bold md:text-sm w-full bg-base-100" name="featured_tabs" id="featured-all" checked="checked" aria-label="All">
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
<input type="radio" class="tab text-xs font-bold md:text-sm w-full bg-base-100" name="featured_tabs" id="featured-{{ forloop.counter }}" aria-label="{{ rarity }}">
{% endif %}
{% endfor %}
</div>
</div>
</div> </div>
</div> {% endcache %}
</div> </div>
{% endcache %}
</div>
<!-- Recent Offers --> <!-- Recent Offers -->
<div> <div>
{% cache 60 recent_offers %} {% cache 60 recent_offers %}
<div class="card bg-base-100 shadow"> <div class="text-center text-base-content p-4">
<div class="card-header text-center text-base-content p-4">
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5> <h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5>
</div> </div>
<div class="card-body my-4 p-4"> <div class="p-4">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
{% for offer in recent_offers %} {% for offer in recent_offers %}
{% render_trade_offer offer %} {% render_trade_offer offer %}
@ -175,125 +156,15 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div>
{% endcache %} {% endcache %}
</div> </div>
</div> </div>
</section> </section>
{% endblock content %} {% endblock content %}
{% block css %}
<style>
/* Hide the hidden radio inputs */
.featured-offers-tabs input[type="radio"] {
display: none;
}
/* Styles for the tabs navigation */
.tabs.tabs-box {
margin-bottom: 1rem;
width: 100%;
}
.tabs.tabs-box .tab {
flex: 1; /* Each tab will equally expand */
text-align: center;
cursor: pointer;
padding: 0.5rem 1rem;
border: 1px solid transparent;
transition: border-color 0.3s;
}
.tabs.tabs-box .tab:hover {
border-color: currentColor;
}
/* Active tab styling based on the radio input state */
#tab-all:checked ~ .tabs.tabs-box label[for="tab-all"] {
border-color: #2563eb; /* Example blue highlight */
}
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
#tab-{{ forloop.counter }}:checked ~ .tabs.tabs-box label[for="tab-{{ forloop.counter }}"] {
border-color: #2563eb;
font-weight: bold;
}
{% endif %}
{% endfor %}
/* Hide all content panels by default */
.featured-offers-tabs .tab-contents > .tab-content {
display: none;
transition: opacity 0.3s ease-in-out;
}
/* Display the panel corresponding to the checked radio input */
#tab-all:checked ~ .tab-contents #content-tab-all {
display: block;
}
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
#tab-{{ forloop.counter }}:checked ~ .tab-contents #content-tab-{{ forloop.counter }} {
display: block;
}
{% endif %}
{% endfor %}
</style>
{% endblock %}
{% block javascript %} {% block javascript %}
<script defer> <script defer>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// AJAX trade search form submission with vanilla JavaScript
const tradeSearchForm = document.querySelector('#trade-search form');
if (tradeSearchForm) {
tradeSearchForm.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(tradeSearchForm);
fetch(tradeSearchForm.action, {
method: tradeSearchForm.method,
headers: {
"X-Requested-With": "XMLHttpRequest"
},
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
document.querySelector('#search-results').innerHTML = data;
})
.catch(error => {
alert("There was an error processing your search.");
console.error('Error:', error);
});
});
}
// AJAX pagination click handling
document.addEventListener('click', function(e) {
const target = e.target.closest('.ajax-page-link');
if (target) {
e.preventDefault();
const page = target.getAttribute('data-page');
let pageInput = document.getElementById('page');
if (pageInput) {
pageInput.value = page;
} else {
pageInput = document.createElement('input');
pageInput.type = 'hidden';
pageInput.id = 'page';
pageInput.name = 'page';
pageInput.value = page;
tradeSearchForm.appendChild(pageInput);
}
tradeSearchForm.dispatchEvent(new Event('submit'));
}
});
// Updated: JS to carry over selections (including quantities) to the Create Trade Offer page. // Updated: JS to carry over selections (including quantities) to the Create Trade Offer page.
const createBtn = document.getElementById('createTradeOfferBtn'); const createBtn = document.getElementById('createTradeOfferBtn');
if (createBtn) { if (createBtn) {
@ -326,6 +197,49 @@ document.addEventListener('DOMContentLoaded', function() {
window.location.href = url.href; window.location.href = url.href;
}); });
} }
// Minimal JavaScript for toggling Featured Offers tabs
const featuredTabs = document.querySelectorAll('input[name="featured_tabs"]');
const featuredTabContents = document.querySelectorAll('#featured-tab-contents .tab-content');
function updateFeaturedTabs() {
featuredTabs.forEach(radio => {
if (radio.checked) {
const target = radio.id;
featuredTabContents.forEach(content => {
if (content.getAttribute('data-tab') === target) {
content.style.display = 'block';
} else {
content.style.display = 'none';
}
});
}
});
}
featuredTabs.forEach(radio => {
radio.addEventListener('change', updateFeaturedTabs);
});
// Initialize tabs on page load
updateFeaturedTabs();
}); });
</script> </script>
{% endblock %}
{% block css %}
<style>
.tabs-box .tab {
z-index: 1;
}
.tabs-box .tab:checked,
.tabs-box .tab.active {
z-index: 2;
background-color: var(--color-base-200);
accent-color: var(--color-base-200);
}
.tabs-box .tab:focus-visible {
outline: none;
}
</style>
{% endblock %} {% endblock %}

View file

@ -1,8 +1,8 @@
{% load trade_offer_tags %} {% load trade_offer_tags %}
{% if offered_cards or wanted_cards %} {% if offered_cards or wanted_cards %}
<hr class="my-8 border-t border-gray-200"> <hr class="my-8 border-t border-base-300">
<h2 class="text-2xl font-bold mb-4">Results</h2> <h2 class="text-2xl font-bold mb-4">Results</h2>
{% if search_results and search_results.object_list %} {% if search_results %}
{% include "trades/_trade_offer_list.html" with offers=search_results %} {% include "trades/_trade_offer_list.html" with offers=search_results %}
{% else %} {% else %}
<div class="alert alert-info mt-4">No trade offers found.</div> <div class="alert alert-info mt-4">No trade offers found.</div>

View file

@ -1,18 +1,18 @@
{% load trade_offer_tags %} {% load trade_offer_tags %}
{% comment %} {% comment %}
This snippet renders a grid of trade offer cards along with pagination controls, This snippet renders a grid of trade offer cards (or acceptance cards) along with pagination controls.
using the trade_offer templatetag (i.e. {% render_trade_offer offer %}). For a TradeOffer, we use {% render_trade_offer %}; for a TradeAcceptance, {% render_trade_acceptance %}.
It expects a context variable:
- offers: an iterable or a paginated page of TradeOffer objects.
{% endcomment %} {% endcomment %}
<div class="flex flex-row gap-4 flex-wrap justify-center items-start"> <div class="flex flex-row gap-4 flex-wrap justify-center items-start">
{% for offer in offers %} {% for offer in offers %}
<div class="flex flex-none"> <div class="flex flex-none">
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline"> {% if offer.accepted_by %}
{# Render a trade acceptance using our new tag #}
{% render_trade_acceptance offer %}
{% else %}
{% render_trade_offer offer %} {% render_trade_offer offer %}
</a> {% endif %}
</div> </div>
{% empty %} {% empty %}
<div>No trade offers available.</div> <div>No trade offers available.</div>

View file

@ -1,16 +1,70 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load trade_offer_tags %}
{% block title %}Update Trade Acceptance{% endblock title %} {% block title %}Update Trade Acceptance{% endblock title %}
{% block content %} {% block content %}
<div class="container mx-auto max-w-xl mt-6"> <div class="container mx-auto max-w-xl mt-6">
<h2 class="text-2xl font-bold">Update Trade Acceptance</h2> <h2 class="text-2xl font-bold">Update Trade Acceptance</h2>
<form method="post" novalidate> <div class="text-center py-8">
{% csrf_token %} <ul class="steps">
{{ form.as_p }} {% if object.is_thanked %}
<button type="submit" class="btn btn-primary">Update</button> <li class="step step-primary">Accepted</li>
</form> <li class="step step-primary">Card Sent</li>
{% if form.errors %} <li class="step step-primary">Card Received</li>
<li class="step step-primary">Thanks Sent</li>
<li class="step step-primary">Thanks Received</li>
<li class="step step-primary">Completed</li>
{% elif object.is_rejected %}
<li class="step step-primary">Accepted</li>
<li class="step step-error">
<span class="step-icon">X</span>{{ object.get_state_display }}
</li>
{% else %}
<li class="step step-primary">Accepted</li>
<li class="step {% if object.get_step_number >= 2 %}step-primary{% endif %}">Card Sent</li>
<li class="step {% if object.get_step_number >= 3 %}step-primary{% endif %}">Card Received</li>
{% if object.state == 'THANKED_BY_INITIATOR' %}
<li class="step step-primary">Thanked by Initiator</li>
<li class="step">Thanked by Acceptor</li>
<li class="step">Completed</li>
{% elif object.state == 'THANKED_BY_ACCEPTOR' %}
<li class="step step-primary">Thanked by Acceptor</li>
<li class="step">Thanked by Initiator</li>
<li class="step">Completed</li>
{% elif object.state == 'THANKED_BY_BOTH' %}
<li class="step step-primary">Thanked by Initiator</li>
<li class="step step-primary">Thanked by Acceptor</li>
<li class="step step-primary">Completed</li>
{% else %}
<li class="step">Thanked by Initiator</li>
<li class="step">Thanked by Acceptor</li>
<li class="step">Completed</li>
{% endif %}
{% endif %}
</ul>
</div>
<div class="py-8">
{% render_trade_acceptance object %}
</div>
<div class="mt-6">
<h3 class="text-xl font-semibold mb-4">Select an action:</h3>
{% if form.fields.state.choices %}
{% for state_value, state_label in form.fields.state.choices %}
<form method="post" class="mb-2">
{% csrf_token %}
<input type="hidden" name="state" value="{{ state_value }}">
<button type="submit" class="btn btn-primary w-full">{{ state_label }}</button>
</form>
{% endfor %}
{% else %}
<p>No available actions.</p>
{% endif %}
</div>
{% if form and form.errors %}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
<strong>Please correct the errors below:</strong> <strong>Please correct the errors below:</strong>
<ul> <ul>
@ -25,8 +79,11 @@
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
<div class="mt-6"> <div class="mt-6">
<a href="{% url 'trade_offer_detail' pk=object.trade_offer.pk %}" class="btn btn-secondary">Back to Offer Details</a> <a href="{% url 'trade_offer_detail' pk=object.trade_offer.pk %}" class="btn btn-secondary">
Back to Offer Details
</a>
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}

View file

@ -9,10 +9,10 @@
<form method="post" novalidate class="space-y-4"> <form method="post" novalidate class="space-y-4">
{% csrf_token %} {% csrf_token %}
{# Use the DRY friend code selector fragment #} {# Use our DRY friend code selector #}
{% include "trades/_friend_code_select.html" with friend_codes=friend_codes selected_friend_code=selected_friend_code field_name=form.initiated_by.html_name label="Initiated by" %} {% include "trades/_friend_code_select.html" with friend_codes=friend_codes selected_friend_code=selected_friend_code field_name=form.initiated_by.html_name label="Initiated by" %}
<!-- Grid layout for Card Selectors: "Have" and "Want" --> <!-- Card Selectors: "Have" and "Want" -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
{% card_multiselect "have_cards" "Have:" "Select one or more cards..." available_cards form.initial.have_cards %} {% card_multiselect "have_cards" "Have:" "Select one or more cards..." available_cards form.initial.have_cards %}
@ -67,8 +67,6 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
}, },
}); });
// Style the Choices control as needed
choicesInstance.containerOuter.element.classList.add('bg-secondary', 'select', 'select-bordered', 'w-full'); choicesInstance.containerOuter.element.classList.add('bg-secondary', 'select', 'select-bordered', 'w-full');
choicesInstance.containerInner.element.classList.add('bg-secondary', 'text-white'); choicesInstance.containerInner.element.classList.add('bg-secondary', 'text-white');
} }

View file

@ -14,6 +14,10 @@
{% endif %} {% endif %}
</h2> </h2>
<p>
<strong>Status:</strong> {% if object.is_active %}Open{% else %}Closed{% endif %}
</p>
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
<div class="alert {{ message.tags }}">{{ message }}</div> <div class="alert {{ message.tags }}">{{ message }}</div>

View file

@ -1,47 +1,14 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load trade_offer_tags %}
{% block title %}Trade Offer Detail{% endblock title %} {% block title %}Trade Offer Detail{% endblock title %}
{% block content %} {% block content %}
<div class="container mx-auto max-w-2xl mt-6"> <div class="container mx-auto max-w-2xl mt-6">
<h2 class="text-2xl font-bold">Trade Offer Details</h2> <h2 class="text-2xl font-bold">Trade Offer Details</h2>
<div class="card bg-base-100 shadow-lg p-4"> <div>
<p> {% render_trade_offer object %}
<strong>Hash:</strong> {{ object.hash }}<br>
<strong>Initiated By:</strong> {{ object.initiated_by }}<br>
<strong>Cards You Have (Offer):</strong>
{% for through in object.trade_offer_have_cards.all %}
{{ through.card.name }} x{{ through.quantity }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br>
<strong>Cards You Want:</strong>
{% for through in object.trade_offer_want_cards.all %}
{{ through.card.name }} x{{ through.quantity }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br>
<strong>Created At:</strong> {{ object.created_at|date:"M d, Y H:i" }}<br>
<strong>Updated At:</strong> {{ object.updated_at|date:"M d, Y H:i" }}<br>
<strong>Status:</strong> {% if object.is_closed %}Closed{% else %}Open{% endif %}
</p>
</div> </div>
<h3 class="text-xl font-semibold mt-6">Acceptances</h3>
{% if acceptances %}
<ul class="space-y-2">
{% for acceptance in acceptances %}
<li class="card p-4">
<p>
<strong>Accepted By:</strong> {{ acceptance.accepted_by }}<br>
<strong>Requested Card:</strong> {{ acceptance.requested_card.name }}<br>
<strong>Offered Card:</strong> {{ acceptance.offered_card.name }}<br>
<strong>State:</strong> {{ acceptance.get_state_display }}
</p>
<a href="{% url 'trade_acceptance_update' acceptance.pk %}" class="btn btn-sm">Update</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>No acceptances yet.</p>
{% endif %}
{% if acceptance_form %} {% if acceptance_form %}
<h3 class="text-xl font-semibold mt-6">Accept This Offer</h3> <h3 class="text-xl font-semibold mt-6">Accept This Offer</h3>
<div class="card p-4"> <div class="card p-4">
@ -54,10 +21,9 @@
{% endif %} {% endif %}
<div class="mt-6"> <div class="mt-6">
<!-- Show delete/close button for the initiator --> {% if is_initiator %}
{% if is_initiator %} <a href="{{ delete_close_url }}" class="btn btn-danger">Delete/Close Trade Offer</a>
<a href="{{ delete_close_url }}" class="btn btn-danger">Delete/Close Trade Offer</a> {% endif %}
{% endif %}
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Back to Trade Offers</a> <a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Back to Trade Offers</a>
</div> </div>
</div> </div>

View file

@ -5,14 +5,14 @@
{% block content %} {% block content %}
<div class="container mx-auto max-w-4xl mt-6"> <div class="container mx-auto max-w-4xl mt-6">
<!-- Filter Form: Friend Code Selector + Toggle for Completed view --> <!-- Filter Form: Friend Code Selector + Toggle for Closed Offers -->
<div class="flex justify-end mb-4"> <div class="flex justify-end mb-4">
<form method="get" class="flex items-center space-x-4"> <form method="get" class="flex items-center space-x-4">
{% include "trades/_friend_code_select.html" with friend_codes=friend_codes selected_friend_code=selected_friend_code field_name="friend_code" label="Filter by Friend Code" %} {% include "trades/_friend_code_select.html" with friend_codes=friend_codes selected_friend_code=selected_friend_code field_name="friend_code" label="Filter by Friend Code" %}
<label class="cursor-pointer flex items-center space-x-2"> <label class="cursor-pointer flex items-center space-x-2">
<span class="font-medium">Only Completed</span> <span class="font-medium">Only Closed</span>
<input type="checkbox" name="show_completed" value="true" class="toggle toggle-primary" {% if show_completed %}checked{% endif %}> <input type="checkbox" name="show_closed" value="true" class="toggle toggle-primary" {% if show_closed %}checked{% endif %}>
</label> </label>
<button type="submit" class="btn btn-primary">Apply</button> <button type="submit" class="btn btn-primary">Apply</button>
</form> </form>
@ -41,9 +41,9 @@
{% endif %} {% endif %}
</section> </section>
<!-- Section 2: Trade Acceptances Waiting For Your Response --> <!-- Section 2: Waiting for Your Response -->
<section class="mb-12"> <section class="mb-12">
<h2 class="text-2xl font-bold mb-4">Trade Acceptances Waiting For Your Response</h2> <h2 class="text-2xl font-bold mb-4">Waiting for Your Response</h2>
{% if trade_acceptances_waiting_paginated.object_list %} {% if trade_acceptances_waiting_paginated.object_list %}
{% include "trades/_trade_offer_list.html" with offers=trade_acceptances_waiting_paginated %} {% include "trades/_trade_offer_list.html" with offers=trade_acceptances_waiting_paginated %}
<div class="flex justify-between items-center mt-4"> <div class="flex justify-between items-center mt-4">
@ -64,26 +64,26 @@
{% endif %} {% endif %}
</section> </section>
<!-- Section 3: Other Trade Acceptances --> <!-- Section 3: Waiting for Trade Partner's Response -->
<section> <section>
<h2 class="text-2xl font-bold mb-4">Other Trade Acceptances</h2> <h2 class="text-2xl font-bold mb-4">Waiting for Trade Partner's Response</h2>
{% if other_trade_acceptances_paginated.object_list %} {% if other_party_trade_acceptances_paginated.object_list %}
{% include "trades/_trade_offer_list.html" with offers=other_trade_acceptances_paginated %} {% include "trades/_trade_offer_list.html" with offers=other_party_trade_acceptances_paginated %}
<div class="flex justify-between items-center mt-4"> <div class="flex justify-between items-center mt-4">
{% if other_trade_acceptances_paginated.has_previous %} {% if other_party_trade_acceptances_paginated.has_previous %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'other_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}other_page={{ other_trade_acceptances_paginated.previous_page_number }}" class="btn btn-sm">Previous</a> <a href="?{% for key, value in request.GET.items %}{% if key != 'other_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}other_page={{ other_party_trade_acceptances_paginated.previous_page_number }}" class="btn btn-sm">Previous</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
<span>Page {{ other_trade_acceptances_paginated.number }} of {{ other_trade_acceptances_paginated.paginator.num_pages }}</span> <span>Page {{ other_party_trade_acceptances_paginated.number }} of {{ other_party_trade_acceptances_paginated.paginator.num_pages }}</span>
{% if other_trade_acceptances_paginated.has_next %} {% if other_party_trade_acceptances_paginated.has_next %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'other_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}other_page={{ other_trade_acceptances_paginated.next_page_number }}" class="btn btn-sm">Next</a> <a href="?{% for key, value in request.GET.items %}{% if key != 'other_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}other_page={{ other_party_trade_acceptances_paginated.next_page_number }}" class="btn btn-sm">Next</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
<p>No other acceptances found.</p> <p>No pending acceptances found.</p>
{% endif %} {% endif %}
</section> </section>

View file

@ -0,0 +1,86 @@
{% extends 'base.html' %}
{% load static trade_offer_tags card_badge cache card_multiselect %}
{% block content %}
<h1 class="text-center text-4xl font-bold mb-8 pt-4">Trade Offer Search</h1>
<!-- Search Form Section -->
<section id="trade-search" class="mb-8">
<form method="post" action="{% url 'trade_offer_search' %}" class="space-y-4">
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
{% card_multiselect "offered_cards" "Have:" "Select zero or more cards..." available_cards offered_cards %}
</div>
<div>
{% card_multiselect "wanted_cards" "Want:" "Select zero or more cards..." available_cards wanted_cards %}
</div>
</div>
<div class="flex flex-col md:flex-row gap-4">
<button type="submit" class="btn btn-primary flex-1">Find a Trade Offer</button>
</div>
</form>
</section>
<!-- Search Results Section -->
<section id="search-results" class="mb-8">
{% include "trades/_search_results.html" %}
</section>
{% endblock content %}
{% block javascript %}
<script defer>
document.addEventListener('DOMContentLoaded', function() {
const tradeSearchForm = document.querySelector('#trade-search form');
if (tradeSearchForm) {
tradeSearchForm.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(tradeSearchForm);
document.querySelector("#search-results").innerHTML = "<div class='text-center text-2xl font-bold'>Searching...</div>";
fetch(tradeSearchForm.action, {
method: "POST",
headers: {
"X-Requested-With": "XMLHttpRequest",
"X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]').value
},
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.text();
})
.then(data => {
document.querySelector("#search-results").innerHTML = data;
})
.catch(error => {
alert("There was an error processing your search.");
console.error("Error:", error);
});
});
// AJAX pagination click handling
document.addEventListener('click', function(e) {
const target = e.target.closest('.ajax-page-link');
if (target) {
e.preventDefault();
const page = target.getAttribute('data-page');
let pageInput = document.getElementById('page');
if (pageInput) {
pageInput.value = page;
} else {
pageInput = document.createElement('input');
pageInput.type = 'hidden';
pageInput.id = 'page';
pageInput.name = 'page';
pageInput.value = page;
tradeSearchForm.appendChild(pageInput);
}
tradeSearchForm.dispatchEvent(new Event('submit'));
}
});
}
});
</script>
{% endblock %}

View file

@ -28,7 +28,7 @@
{% for card in object.want_cards.all %} {% for card in object.want_cards.all %}
{{ card.name }}{% if not forloop.last %}, {% endif %} {{ card.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br> {% endfor %}<br>
<strong>Current State:</strong> {{ object.get_state_display }} <strong>Status:</strong> {% if object.is_active %}Open{% else %}Closed{% endif %}
</p> </p>
</div> </div>
</div> </div>

View file

@ -0,0 +1,13 @@
{% for group, options, index in widget.optgroups %}
<div class="btn-group" role="group" aria-label="Trade Acceptance State">
{% for option in options %}
<label class="btn btn-outline-primary {% if option.selected %}btn-active{% endif %}">
<input type="radio" name="{{ widget.name }}" value="{{ option.value }}"
{% for attr, val in option.attrs.items %} {{ attr }}="{{ val }}" {% endfor %}
autocomplete="off" style="display:none;"
{% if option.selected %} checked {% endif %}>
{{ option.label }}
</label>
{% endfor %}
</div>
{% endfor %}

View file

@ -1,20 +1,18 @@
<div class="relative inline-block"> <div class="relative inline-block">
{% if decks|length == 1 %} {% if num_decks == 1 %}
<div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background-color: {{ decks.0.hex_color }};"> <div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background-color: {{ decks.0.hex_color }};">
{% elif decks|length == 2 %} {% elif num_decks == 2 %}
<div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }});"> <div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }});">
{% elif decks|length >= 3 %} {% elif num_decks >= 3 %}
<div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }}, {{ decks.2.hex_color }});"> <div class="grid grid-cols-4 grid-rows-2 my-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }}, {{ decks.2.hex_color }});">
{% else %} {% else %}
<div class="grid grid-cols-4 grid-rows-2 px-2 py-2 h-16 w-36 text-white shadow-md shadow-black/50" style="background-color: #cccccc; color: white;"> <div class="grid grid-cols-4 grid-rows-2 px-2 py-2 h-16 w-36 text-white bg-gray-600 shadow-md shadow-black/50">
{% endif %} {% endif %}
<div class="row-span-1 col-span-4 truncate text-ellipsis self-start font-semibold leading-tight text-sm max-w-7/8">{{ card.name }}</div> <div class="row-span-1 col-span-4 truncate text-ellipsis self-start font-semibold leading-tight text-sm max-w-7/8">{{ card.name }}</div>
<div class="row-start-2 col-span-2 truncate self-end align-bottom text-xs">{{ card.rarity.icons }}</div> <div class="row-start-2 col-span-2 truncate self-end align-bottom text-xs">{{ card.rarity.icons }}</div>
<div class="row-start-2 col-start-3 col-span-2 text-right truncate self-end align-bottom font-semibold leading-none text-sm">{{ card.cardset.name }}</div> <div class="row-start-2 col-start-3 col-span-2 text-right truncate self-end align-bottom font-semibold leading-none text-sm">{{ card.cardset.name }}</div>
</div> </div>
{% if quantity != 1 %}
<span class="card-quantity-badge absolute top-3.5 right-1 bg-gray-600 text-white text-xs font-semibold rounded-full px-2"> <span class="card-quantity-badge absolute top-3.5 right-1 bg-gray-600 text-white text-xs font-semibold rounded-full px-2">
{{ quantity }} {% if is_template %}__QUANTITY__{% else %}{{ quantity }}{% endif %}
</span> </span>
{% endif %}
</div> </div>

View file

@ -10,7 +10,7 @@
value="{{ card.pk }}" value="{{ card.pk }}"
data-quantity="{% if card.pk|stringformat:"s" in selected_values %}{{ card.selected_quantity }}{% else %}1{% endif %}" data-quantity="{% if card.pk|stringformat:"s" in selected_values %}{{ card.selected_quantity }}{% else %}1{% endif %}"
{% if card.pk|stringformat:"s" in selected_values %}selected{% endif %} {% if card.pk|stringformat:"s" in selected_values %}selected{% endif %}
data-html-content='{% if card.selected_quantity %}{{ card|card_badge_inline:"__QUANTITY__" }}{% else %}{{ card|card_badge_inline }}{% endif %}' data-html-content='{{ card|card_badge_inline:"__QUANTITY__" }}'
data-name="{{ card.name }}" data-name="{{ card.name }}"
data-rarity="{{ card.rarity.icons }}" data-rarity="{{ card.rarity.icons }}"
data-cardset="{{ card.cardset.name }}" data-cardset="{{ card.cardset.name }}"
@ -26,59 +26,57 @@ if (!window.updateGlobalCardFilters) {
window.updateGlobalCardFilters = function() { window.updateGlobalCardFilters = function() {
const selects = document.querySelectorAll('.card-multiselect'); const selects = document.querySelectorAll('.card-multiselect');
// Gather all selected card IDs from every multiselect. // Gather all selected card IDs using the Choices.js API.
const globalSelectedIds = []; const globalSelectedIds = [];
let globalRarity = null;
selects.forEach(select => { selects.forEach(select => {
Array.from(select.selectedOptions).forEach(option => { const choicesInstance = select.choicesInstance;
if (option.value) { const selectedValues = choicesInstance ? choicesInstance.getValue(true) : [];
globalSelectedIds.push(option.value);
// Build a list of unique selected card IDs.
selectedValues.forEach(val => {
if (val && globalSelectedIds.indexOf(val) === -1) {
globalSelectedIds.push(val);
} }
}); });
// Set the global rarity based on the first select that has a selection.
if (selectedValues.length > 0 && globalRarity === null) {
const option = select.querySelector(`option[value="${selectedValues[0]}"]`);
if (option) {
globalRarity = option.getAttribute('data-rarity');
}
}
}); });
// Determine the global rarity based on the first found selected option. // Update each option element in every select.
let globalRarity = null;
for (const select of selects) {
if (select.selectedOptions.length > 0) {
globalRarity = select.selectedOptions[0].getAttribute('data-rarity');
break;
}
}
// Helper function to determine if the option for the given card is selected in a specific select.
const isOptionSelected = (select, cardId) => {
const option = select.querySelector(`option[value="${cardId}"]`);
return option ? option.selected : false;
};
selects.forEach(select => { selects.forEach(select => {
// Update each option element in the select.
select.querySelectorAll('option').forEach(function(option) { select.querySelectorAll('option').forEach(function(option) {
const cardId = option.value; const cardId = option.value;
const optionRarity = option.getAttribute('data-rarity'); const optionRarity = option.getAttribute('data-rarity');
const isSelected = option.selected; // Determine if the card is selected using the Choices.js API.
const isSelected = select.choicesInstance ? select.choicesInstance.getValue(true).includes(cardId) : option.selected;
// 1. Global rarity filter: if any card is selected overall, then only allow options that are already
// selected or that match the global rarity. // If no cards are selected globally, globalRarity is null, so all options pass the rarity filter.
const passesRarity = (!globalRarity) || isSelected || (optionRarity === globalRarity); const passesRarity = (!globalRarity) || isSelected || (optionRarity === globalRarity);
// 2. Unique selection filter: if the card is selected anywhere globally (and not on this select), then disable it. // Ensure that if a card is selected in any multiselect, it remains unique.
const passesUnique = isSelected || !globalSelectedIds.includes(cardId); const passesUnique = isSelected || !globalSelectedIds.includes(cardId);
option.disabled = !(passesRarity && passesUnique); option.disabled = !(passesRarity && passesUnique);
}); });
// Update the Choices.js dropdown display as well. // Update the display for the Choices.js dropdown.
if (select.choicesInstance) { if (select.choicesInstance) {
const dropdown = select.choicesInstance.dropdown.element; const dropdown = select.choicesInstance.dropdown.element;
if (dropdown) { if (dropdown) {
dropdown.querySelectorAll('[data-choice]').forEach(function(item) { dropdown.querySelectorAll('[data-choice]').forEach(function(item) {
const cardId = item.getAttribute('data-value'); const cardId = item.getAttribute('data-value');
const itemRarity = item.getAttribute('data-rarity'); const itemRarity = item.getAttribute('data-rarity');
const isSelected = isOptionSelected(select, cardId); const isSelected = select.choicesInstance.getValue(true).includes(cardId);
const passesRarity = (!globalRarity) || isSelected || (itemRarity === globalRarity); const passesRarity = (!globalRarity) || isSelected || (itemRarity === globalRarity);
const passesUnique = isSelected || !globalSelectedIds.includes(cardId); const passesUnique = isSelected || !globalSelectedIds.includes(cardId);
item.style.display = (passesRarity && passesUnique) ? '' : 'none'; item.style.display = (passesRarity && passesUnique) ? '' : 'none';
}); });
} }
@ -91,6 +89,13 @@ document.addEventListener('DOMContentLoaded', function() {
const selectField = document.getElementById('{{ field_id }}'); const selectField = document.getElementById('{{ field_id }}');
const placeholder = selectField.getAttribute('data-placeholder') || ''; const placeholder = selectField.getAttribute('data-placeholder') || '';
// Remove the name attribute from the select field since we don't use the select itself but build our own hidden inputs.
if (selectField.hasAttribute('name')) {
let originalName = selectField.getAttribute('name');
selectField.setAttribute('data-field-name', originalName);
selectField.removeAttribute('name');
}
const choicesInstance = new Choices(selectField, { const choicesInstance = new Choices(selectField, {
removeItemButton: false, removeItemButton: false,
placeholderValue: placeholder, placeholderValue: placeholder,
@ -256,10 +261,15 @@ document.addEventListener('DOMContentLoaded', function() {
const formElement = selectField.closest('form'); const formElement = selectField.closest('form');
if (formElement) { if (formElement) {
formElement.addEventListener('submit', function(e) { formElement.addEventListener('submit', function(e) {
// Remove previously generated hidden inputs to avoid duplications
const existingHiddenInputs = formElement.querySelectorAll('input[data-generated="multiselect"]');
existingHiddenInputs.forEach(input => input.remove());
const multiselects = formElement.querySelectorAll('.card-multiselect'); const multiselects = formElement.querySelectorAll('.card-multiselect');
multiselects.forEach((select) => { multiselects.forEach((select) => {
const fieldName = select.getAttribute('name'); // Use the stored field name from the data-field-name attribute.
select.removeAttribute('name'); const fieldName = select.getAttribute('data-field-name');
Array.from(select.selectedOptions).forEach((option) => { Array.from(select.selectedOptions).forEach((option) => {
const cardId = option.value; const cardId = option.value;
const quantity = option.getAttribute('data-quantity') || '1'; const quantity = option.getAttribute('data-quantity') || '1';
@ -267,6 +277,8 @@ document.addEventListener('DOMContentLoaded', function() {
hiddenInput.type = 'hidden'; hiddenInput.type = 'hidden';
hiddenInput.name = fieldName; hiddenInput.name = fieldName;
hiddenInput.value = cardId + ':' + quantity; hiddenInput.value = cardId + ':' + quantity;
// Mark this element as generated by our multiselect handler.
hiddenInput.setAttribute('data-generated', 'multiselect');
formElement.appendChild(hiddenInput); formElement.appendChild(hiddenInput);
}); });
}); });
@ -300,7 +312,7 @@ document.addEventListener('DOMContentLoaded', function() {
.choices__input--cloned { .choices__input--cloned {
width: 100% !important; width: 100% !important;
} }
div.choices__list span.card-quantity-badge { .choices__list--dropdown span.card-quantity-badge {
display: none; display: none;
} }
</style> </style>

View file

@ -0,0 +1,51 @@
{% load gravatar card_badge %}
<div class="card card-border bg-base-100 shadow-lg w-96 md:w-80 lg:w-96">
<!-- Header -->
<div class="py-4 mx-2 sm:mx-4">
<div class="flex justify-between items-center">
<!-- Left: Initiator's avatar (moved from center) and "Has" -->
<div class="flex items-center">
<div class="avatar mr-2">
<div class="w-10 rounded-full">
{{ acceptance.trade_offer.initiated_by.user.email|gravatar:40 }}
</div>
</div>
<span class="text-sm font-semibold">Has</span>
</div>
<!-- Right: "Wants" with the acceptor's avatar -->
<div class="flex items-center">
<span class="text-sm font-semibold mr-2">Wants</span>
<div class="avatar">
<div class="w-10 rounded-full">
{{ acceptance.accepted_by.user.email|gravatar:40 }}
</div>
</div>
</div>
</div>
</div>
<!-- Main Card Row: Single row with the acceptance's cards -->
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline block">
<div class="px-2 pb-0">
<div class="grid grid-cols-2 items-center border-t border-gray-300">
<div class="flex flex-col items-center">
{% card_badge acceptance.requested_card %}
</div>
<div class="flex flex-col items-center">
{% card_badge acceptance.offered_card %}
</div>
</div>
</div>
</a>
<!-- Footer: Only info button with acceptance hash -->
<div class="flex justify-end px-2 pb-2">
<div class="text-gray-500 text-sm tooltip tooltip-left" data-tip="Acceptance ID: {{ acceptance.hash }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11.25 11.25l.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>
</div>

View file

@ -1,141 +1,185 @@
{% load gravatar card_badge %} {% load gravatar card_badge %}
<div class="card border border-gray-200 shadow-lg w-96 md:w-80 lg:w-96" <!--
x-data="{ flipped: false, badgeExpanded: false, acceptanceExpanded: false }"> The outer div now only establishes Alpine's data context.
<!-- Flip Container with Perspective --> The dynamic height adjustment (x-init & x-effect with x-ref) has been removed.
<div class="flip-container"> -->
<!-- Flip Inner: rotates based on 'flipped' state --> <div x-data="{ flipped: false, badgeExpanded: false, acceptanceExpanded: false }"
<div class="flip-inner grid transform transition-transform duration-700 ease-in-out" class="transition-all duration-700">
<!-- Flip container providing perspective -->
<div class="flip-container" style="perspective: 1000px;">
<!--
The rotating element (.flip-inner) now uses CSS Grid to stack its children in a single cell.
Persistent border, shadow, and rounding are applied here and the card rotates entirely.
-->
<div class="flip-inner grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-96 md:w-80 lg:w-96 transform transition-transform duration-700 ease-in-out"
:class="{'rotate-y-180': flipped}"> :class="{'rotate-y-180': flipped}">
<!-- Front Side: Trade Offer --> <!-- Front Face: Trade Offer -->
<div class="flip-face front col-start-1 row-start-1"> <!-- Using grid placement classes (col-start-1 row-start-1) ensures both faces overlap -->
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline block"> <div class="flip-face front col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between">
<!-- Header: Has/Wants --> <!-- Header -->
<div class="py-4 mx-2 sm:mx-4"> <div class="self-start">
<div class="grid grid-cols-3 items-center"> <div class="py-4 mx-2 sm:mx-4">
<div class="flex justify-center items-center"> <div class="grid grid-cols-3 items-center">
<span class="text-sm font-semibold">Has</span> <div class="flex justify-center items-center">
</div> <span class="text-sm font-semibold">Has</span>
<div class="flex justify-center items-center"> </div>
<div class="avatar"> <div class="flex justify-center items-center">
<div class="w-10 rounded-full"> <div class="avatar">
{{ offer.initiated_by.user.email|gravatar:40 }} <div class="w-10 rounded-full">
{{ offer.initiated_by.user.email|gravatar:40 }}
</div>
</div> </div>
</div> </div>
</div> <div class="flex justify-center items-center">
<div class="flex justify-center items-center"> <span class="text-sm font-semibold">Wants</span>
<span class="text-sm font-semibold">Wants</span> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Main Trade Offer Row: First row of card badges --> <!-- Main Trade Offer Row -->
<div class="px-2 pb-2 min-h-[80px]"> <div class="self-start">
<div class="grid grid-cols-2 gap-2 items-center border-t border-gray-300"> <div class="px-2 pb-0">
<div class="flex flex-col items-center"> <div class="grid grid-cols-2 gap-2 items-center">
{% if offer.trade_offer_have_cards.all %} <div class="flex flex-col items-center">
{% with first_have=offer.trade_offer_have_cards.all.0 %} {% if have_cards_available %}
{% card_badge first_have.card first_have.quantity %} {% with first_have=have_cards_available.0 %}
{% endwith %} {% card_badge first_have.card first_have.quantity %}
{% endif %} {% endwith %}
{% endif %}
</div>
<div class="flex flex-col items-center">
{% if want_cards_available %}
{% with first_want=want_cards_available.0 %}
{% card_badge first_want.card first_want.quantity %}
{% endwith %}
{% endif %}
</div>
</div> </div>
<div class="flex flex-col items-center"> </div>
{% if offer.trade_offer_want_cards.all %}
{% with first_want=offer.trade_offer_want_cards.all.0 %} <!-- Extra Card Badges (Collapsible) -->
{% card_badge first_want.card first_want.quantity %} <div x-show="badgeExpanded" x-collapse.duration.500ms class="px-2">
{% endwith %} <div class="grid grid-cols-2 gap-2">
{% endif %} <div class="flex flex-col items-center">
{% for th in have_cards_available|slice:"1:" %}
{% card_badge th.card th.quantity %}
{% endfor %}
</div>
<div class="flex flex-col items-center">
{% for th in want_cards_available|slice:"1:" %}
{% card_badge th.card th.quantity %}
{% endfor %}
</div>
</div>
</div>
{% if have_cards_available|length > 1 or want_cards_available|length > 1 %}
<div class="flex justify-center my-1">
<svg @click="badgeExpanded = !badgeExpanded"
x-bind:class="{ 'rotate-180': badgeExpanded }"
class="transition-transform duration-500 h-5 w-5 cursor-pointer"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
</div>
{% endif %}
</div>
<div class="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>
<!-- Front-to-back flip button -->
<div class="cursor-pointer text-gray-500"
@click="if(badgeExpanded){ badgeExpanded = false; setTimeout(() => { flipped = true; }, 500); } else { flipped = true; }">
<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> </div>
</div> </div>
</a> </div>
<!-- Extra Card Badges (Collapsible) --> <!-- Back Face: Acceptances View -->
<div x-show="badgeExpanded" x-collapse.duration.500ms class="px-2"> <!-- Placed in the same grid cell as the front face -->
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline block"> <div class="flip-face back col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between" style="transform: rotateY(180deg);">
<div class="grid grid-cols-2 gap-2"> <div class="self-start">
<!-- Extra Has Cards Column --> <a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline">
<div class="flex flex-col items-center"> <div class="py-4 mx-2 sm:mx-4">
{% for th in offer.trade_offer_have_cards.all|slice:"1:" %} <div class="grid grid-cols-3 items-center">
{% card_badge th.card th.quantity %} <div class="flex justify-center items-center">
{% endfor %} <span class="text-sm font-semibold">Has</span>
</div> </div>
<!-- Extra Wants Cards Column --> <div class="flex justify-center items-center">
<div class="flex flex-col items-center"> <div class="avatar">
{% for th in offer.trade_offer_want_cards.all|slice:"1:" %} <div class="w-10 rounded-full">
{% card_badge th.card th.quantity %} {{ offer.initiated_by.user.email|gravatar:40 }}
{% endfor %} </div>
</div>
</div>
<div class="flex justify-center items-center">
<span class="text-sm font-semibold">Wants</span>
</div>
</div> </div>
</div> </div>
</a> </a>
</div> </div>
<div class="self-start">
<!-- Extra Card Badges Expansion Toggle (shown only if extra cards exist) --> <div class="px-2 pb-0">
{% if offer.trade_offer_have_cards.all|length > 1 or offer.trade_offer_want_cards.all|length > 1 %} <div class="overflow-hidden">
<div class="flex justify-center my-1"> {% if offer.acceptances.first %}
<svg @click="badgeExpanded = !badgeExpanded" <div class="space-y-3">
x-bind:class="{ 'rotate-180': badgeExpanded }" {% with acceptance=offer.acceptances.first %}
class="transition-transform duration-500 h-5 w-5 cursor-pointer" <a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> data-tooltip-html='<div class="flex items-center space-x-2">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <div class="avatar">
d="M19 9l-7 7-7-7" /> <div class="w-10 rounded-full">
</svg> {{ acceptance.accepted_by.user.email|gravatar:"40" }}
</div> </div>
{% endif %} </div>
<div class="flex flex-col">
<!-- Bottom Icons on Front Side --> <span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
<div class="flex justify-between px-2 pb-2"> <span class="text-sm">State: {{ acceptance.state }}</span>
<!-- Info Icon at Bottom Left --> <span class="text-sm">Acceptance ID: {{ acceptance.hash }}</span>
<div class="text-gray-500 text-sm tooltip tooltip-right" data-tip="Trade ID: {{ offer.hash }}"> </div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" </div>'>
stroke="currentColor" class="w-5 h-5"> <div class="grid grid-cols-2 gap-4 items-center">
<path stroke-linecap="round" stroke-linejoin="round" <div>
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" /> {% card_badge acceptance.requested_card %}
</svg> </div>
</div> <div>
<!-- Flip Icon at Bottom Right: flips to acceptances view (back side) --> {% card_badge acceptance.offered_card %}
<div class="cursor-pointer" </div>
@click="badgeExpanded = false; $nextTick(() => { flipped = true })"> </div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-5 h-5"> </a>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" {% endwith %}
d="M12 4v1m0 14v1m8-8h1M4 12H3m15.364-6.364l.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707" />
</svg>
</div>
</div>
</div>
<!-- Back Side: Acceptances View -->
<div class="flip-face back col-start-1 row-start-1" style="transform: rotateY(180deg);">
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline block">
<!-- Has/Wants Header -->
<div class="py-4 mx-2 sm:mx-4">
<div class="grid grid-cols-3 items-center">
<div class="flex justify-center items-center">
<span class="text-sm font-semibold">Has</span>
</div>
<div class="flex justify-center items-center">
<div class="avatar">
<div class="w-10 rounded-full">
{{ offer.initiated_by.user.email|gravatar:40 }}
</div>
</div> </div>
</div> {% endif %}
<div class="flex justify-center items-center">
<span class="text-sm font-semibold">Wants</span>
</div>
</div> </div>
</div> <div x-show="acceptanceExpanded" x-collapse.duration.500ms class="space-y-3">
</a> {% for acceptance in offer.acceptances.all|slice:"1:" %}
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
<!-- Acceptances Content Area --> data-tooltip-html='<div class="flex items-center space-x-2">
<div class="px-2 pb-2 min-h-[80px]"> <div class="avatar">
<!-- Collapsed Acceptances: show only the first acceptance (if available) --> <div class="w-10 rounded-full">
<div x-show="!acceptanceExpanded" class="overflow-hidden"> {{ acceptance.accepted_by.user.email|gravatar:"40" }}
{% if offer.acceptances.all|length > 0 %} </div>
<div class="space-y-3"> </div>
{% for acceptance in offer.acceptances.all|slice:"0:1" %} <div class="flex flex-col">
<!-- Acceptance Card Pair --> <span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
<div class="grid grid-cols-2 gap-4 items-center border-t border-gray-300"> <span class="text-sm">State: {{ acceptance.state }}</span>
<span class="text-sm">Acceptance ID: {{ acceptance.hash }}</span>
</div>
</div>'>
<div class="grid grid-cols-2 gap-4 items-center">
<div> <div>
{% card_badge acceptance.requested_card %} {% card_badge acceptance.requested_card %}
</div> </div>
@ -143,111 +187,63 @@
{% card_badge acceptance.offered_card %} {% card_badge acceptance.offered_card %}
</div> </div>
</div> </div>
<!-- Acceptor Info --> </a>
<div class="flex items-center text-xs mt-2"> {% endfor %}
<div class="w-8 h-8 mr-2"> </div>
<div class="w-full h-full rounded-full overflow-hidden">
{{ acceptance.accepted_by.user.email|gravatar:32 }}
</div>
</div>
<span class="leading-tight">{{ acceptance.accepted_by.user.username }}</span>
<span class="ml-auto leading-tight">{{ acceptance.state }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div> </div>
<div class="flex justify-center my-1">
<!-- Expanded Acceptances: displays all acceptances --> <svg @click="acceptanceExpanded = !acceptanceExpanded"
<div x-show="acceptanceExpanded" x-collapse.duration.500ms class="space-y-3"> x-bind:class="{ 'rotate-180': acceptanceExpanded }"
{% for acceptance in offer.acceptances.all %} class="transition-transform duration-500 h-5 w-5 cursor-pointer"
<!-- Acceptance Card Pair --> xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="grid grid-cols-2 gap-4 items-center border-t border-gray-300"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<div> d="M19 9l-7 7-7-7" />
{% card_badge acceptance.requested_card %}
</div>
<div>
{% card_badge acceptance.offered_card %}
</div>
</div>
<!-- Acceptor Info -->
<div class="flex items-center text-xs mt-2">
<div class="w-8 h-8 mr-2">
<div class="w-full h-full rounded-full overflow-hidden">
{{ acceptance.accepted_by.user.email|gravatar:32 }}
</div>
</div>
<span class="leading-tight">{{ acceptance.accepted_by.user.username }}</span>
<span class="ml-auto leading-tight">{{ acceptance.state }}</span>
</div>
{% endfor %}
</div>
</div>
<!-- Bottom Arrow for Acceptances Expansion -->
<div class="flex justify-center my-1">
<svg @click="acceptanceExpanded = !acceptanceExpanded"
x-bind:class="{ 'rotate-180': acceptanceExpanded }"
class="transition-transform duration-500 h-5 w-5 cursor-pointer"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
</div>
<!-- Bottom Icons on Back Side -->
<div class="flex justify-between px-2 pb-2">
<!-- Info Icon at Bottom Left -->
<div class="text-gray-500 text-sm tooltip tooltip-right" data-tip="Trade 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="w-5 h-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> </svg>
</div> </div>
<!-- Acceptances Header --> </div>
<div class="px-1 text-center "> <div class="flex justify-between px-2 pb-2 self-end">
<!-- Back-to-front flip button -->
<div class="text-gray-500 cursor-pointer"
@click="if(acceptanceExpanded){ acceptanceExpanded = false; setTimeout(() => { flipped = false; }, 500); } else { flipped = false; }">
<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="px-1 text-center">
<span class="text-sm font-semibold"> <span class="text-sm font-semibold">
Acceptances ({{ offer.acceptances.all|length }}) Acceptances ({{ offer.acceptances.all|length }})
</span> </span>
</div> </div>
<!-- Flip-Back Icon at Bottom Right: flips back to front side --> <div class="text-gray-500 text-sm tooltip tooltip-left" data-tip="ID: {{ offer.hash }}">
<div class="cursor-pointer" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
@click="acceptanceExpanded = false; $nextTick(() => { flipped = false })"> stroke="currentColor" class="size-5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-5 h-5"> <path stroke-linecap="round" stroke-linejoin="round"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />
d="M12 20l9-8-9-8M3 12h18" />
</svg> </svg>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<style>
/* Minimal custom CSS for the card flip effect */
.flip-container {
perspective: 1000px;
}
<style>
/* Ensure proper 3D transformations on the rotating element */
.flip-inner { .flip-inner {
transform-style: preserve-3d; transform-style: preserve-3d;
} }
/* Hide the back face of each card side */
.flip-face { .flip-face {
backface-visibility: hidden; backface-visibility: hidden;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
} }
/* The front face is unrotated by default */
/* Explicitly set the front face to 0 rotation */
.flip-face.front { .flip-face.front {
transform: rotateY(0); transform: rotateY(0);
} }
/* The .rotate-y-180 class rotates the entire element by 180deg */
/* This class is toggled by AlpineJS to rotate the card container */
.rotate-y-180 { .rotate-y-180 {
transform: rotateY(180deg); transform: rotateY(180deg);
} }
</style> </style>

View file

@ -5,5 +5,6 @@ class TradesConfig(AppConfig):
name = "trades" name = "trades"
def ready(self): def ready(self):
# This import registers the signal handlers defined in trades/signals.py. # This import registers the signal handlers defined in trades/signals.py,
# ensuring that denormalized field updates occur whenever related objects change.
import trades.signals import trades.signals

View file

@ -46,11 +46,11 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
raise ValueError("friend_codes must be provided") raise ValueError("friend_codes must be provided")
self.fields["accepted_by"].queryset = friend_codes self.fields["accepted_by"].queryset = friend_codes
# Update active_states to include only states that mean the acceptance is still "open".
active_states = [ active_states = [
TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.SENT, TradeAcceptance.AcceptanceState.SENT,
TradeAcceptance.AcceptanceState.RECEIVED, TradeAcceptance.AcceptanceState.RECEIVED,
TradeAcceptance.AcceptanceState.COMPLETED,
] ]
# Build available requested_card choices from the TradeOffer's "have" side. # Build available requested_card choices from the TradeOffer's "have" side.
@ -83,67 +83,24 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
self.instance.trade_offer = self.trade_offer self.instance.trade_offer = self.trade_offer
return super().clean() return super().clean()
class TradeAcceptanceUpdateForm(forms.ModelForm): class ButtonRadioSelect(forms.RadioSelect):
""" template_name = "widgets/button_radio_select.html"
Form for updating the state of an existing TradeAcceptance.
Based on the current state and which party is acting (initiator vs. acceptor),
this form limits available state transitions.
"""
class Meta:
model = TradeAcceptance
fields = ["state"]
def __init__(self, *args, friend_codes=None, **kwargs): class TradeAcceptanceTransitionForm(forms.Form):
state = forms.ChoiceField(widget=forms.HiddenInput())
def __init__(self, *args, instance=None, user=None, **kwargs):
"""
Initializes the form with allowed transitions from the provided instance.
:param instance: A TradeAcceptance instance.
"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
instance = self.instance if instance is None:
allowed_choices = [] raise ValueError("A TradeAcceptance instance must be provided")
self.instance = instance
# Allowed transitions for a TradeAcceptance: self.user = user
# - From ACCEPTED:
# • If the initiator is acting, allow SENT and REJECTED_BY_INITIATOR. self.fields["state"].choices = instance.get_allowed_state_transitions(user)
# • If the acceptor is acting, allow REJECTED_BY_ACCEPTOR.
# - From SENT:
# • If the acceptor is acting, allow RECEIVED and REJECTED_BY_ACCEPTOR.
# • If the initiator is acting, allow REJECTED_BY_INITIATOR.
# - From RECEIVED:
# • If the initiator is acting, allow COMPLETED and REJECTED_BY_INITIATOR.
# • If the acceptor is acting, allow REJECTED_BY_ACCEPTOR.
if friend_codes is None:
raise ValueError("friend_codes must be provided")
if instance.state == TradeAcceptance.AcceptanceState.ACCEPTED:
if instance.trade_offer.initiated_by in friend_codes:
allowed_choices = [
(TradeAcceptance.AcceptanceState.SENT, "Sent"),
(TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, "Rejected by Initiator"),
]
elif instance.accepted_by in friend_codes:
allowed_choices = [
(TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, "Rejected by Acceptor"),
]
elif instance.state == TradeAcceptance.AcceptanceState.SENT:
if instance.accepted_by in friend_codes:
allowed_choices = [
(TradeAcceptance.AcceptanceState.RECEIVED, "Received"),
(TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, "Rejected by Acceptor"),
]
elif instance.trade_offer.initiated_by in friend_codes:
allowed_choices = [
(TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, "Rejected by Initiator"),
]
elif instance.state == TradeAcceptance.AcceptanceState.RECEIVED:
if instance.trade_offer.initiated_by in friend_codes:
allowed_choices = [
(TradeAcceptance.AcceptanceState.COMPLETED, "Completed"),
(TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, "Rejected by Initiator"),
]
elif instance.accepted_by in friend_codes:
allowed_choices = [
(TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, "Rejected by Acceptor"),
]
if allowed_choices:
self.fields["state"].choices = allowed_choices
else:
self.fields.pop("state")
class TradeOfferCreateForm(ModelForm): class TradeOfferCreateForm(ModelForm):
# Override the default fields to capture quantity info in the format 'card_id:quantity' # Override the default fields to capture quantity info in the format 'card_id:quantity'
@ -166,10 +123,14 @@ class TradeOfferCreateForm(ModelForm):
data = self.data.getlist("have_cards") data = self.data.getlist("have_cards")
parsed = {} parsed = {}
for item in data: for item in data:
if ':' not in item:
# Ignore any input without a colon.
continue
parts = item.split(':') parts = item.split(':')
card_id = parts[0] card_id = parts[0]
try: try:
quantity = int(parts[1]) if len(parts) > 1 else 1 # Only parse quantity when a colon is present.
quantity = int(parts[1])
except ValueError: except ValueError:
raise forms.ValidationError(f"Invalid quantity provided in {item}") raise forms.ValidationError(f"Invalid quantity provided in {item}")
parsed[card_id] = parsed.get(card_id, 0) + quantity parsed[card_id] = parsed.get(card_id, 0) + quantity
@ -179,10 +140,12 @@ class TradeOfferCreateForm(ModelForm):
data = self.data.getlist("want_cards") data = self.data.getlist("want_cards")
parsed = {} parsed = {}
for item in data: for item in data:
if ':' not in item:
continue
parts = item.split(':') parts = item.split(':')
card_id = parts[0] card_id = parts[0]
try: try:
quantity = int(parts[1]) if len(parts) > 1 else 1 quantity = int(parts[1])
except ValueError: except ValueError:
raise forms.ValidationError(f"Invalid quantity provided in {item}") raise forms.ValidationError(f"Invalid quantity provided in {item}")
parsed[card_id] = parsed.get(card_id, 0) + quantity parsed[card_id] = parsed.get(card_id, 0) + quantity

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2025-03-07 01:04 # Generated by Django 5.1.2 on 2025-03-09 05:08
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -18,10 +18,14 @@ class Migration(migrations.Migration):
name='TradeOffer', name='TradeOffer',
fields=[ fields=[
('id', models.AutoField(primary_key=True, serialize=False)), ('id', models.AutoField(primary_key=True, serialize=False)),
('manually_closed', models.BooleanField(default=False)), ('manually_closed', models.BooleanField(db_index=True, default=False)),
('hash', models.CharField(editable=False, max_length=8)), ('hash', models.CharField(editable=False, max_length=9)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('total_have_quantity', models.PositiveIntegerField(default=0, editable=False)),
('total_want_quantity', models.PositiveIntegerField(default=0, editable=False)),
('total_have_accepted', models.PositiveIntegerField(default=0, editable=False)),
('total_want_accepted', models.PositiveIntegerField(default=0, editable=False)),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')), ('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')),
], ],
), ),
@ -29,7 +33,8 @@ class Migration(migrations.Migration):
name='TradeAcceptance', name='TradeAcceptance',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(choices=[('ACCEPTED', 'Accepted'), ('SENT', 'Sent'), ('RECEIVED', 'Received'), ('COMPLETED', 'Completed'), ('REJECTED_BY_INITIATOR', 'Rejected by Initiator'), ('REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor')], default='ACCEPTED', max_length=25)), ('state', models.CharField(choices=[('ACCEPTED', 'Accepted'), ('SENT', 'Sent'), ('RECEIVED', 'Received'), ('THANKED_BY_INITIATOR', 'Thanked by Initiator'), ('THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'), ('THANKED_BY_BOTH', 'Thanked by Both'), ('REJECTED_BY_INITIATOR', 'Rejected by Initiator'), ('REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor')], db_index=True, default='ACCEPTED', max_length=25)),
('hash', models.CharField(blank=True, editable=False, max_length=9)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('accepted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trade_acceptances', to='accounts.friendcode')), ('accepted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trade_acceptances', to='accounts.friendcode')),
@ -72,4 +77,8 @@ class Migration(migrations.Migration):
name='want_cards', name='want_cards',
field=models.ManyToManyField(related_name='trade_offers_want', through='trades.TradeOfferWantCard', to='cards.card'), field=models.ManyToManyField(related_name='trade_offers_want', through='trades.TradeOfferWantCard', to='cards.card'),
), ),
migrations.AddIndex(
model_name='tradeoffer',
index=models.Index(fields=['manually_closed'], name='trades_trad_manuall_b3b74c_idx'),
),
] ]

View file

@ -7,8 +7,8 @@ from accounts.models import FriendCode
class TradeOffer(models.Model): class TradeOffer(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
manually_closed = models.BooleanField(default=False) manually_closed = models.BooleanField(default=False, db_index=True)
hash = models.CharField(max_length=8, editable=False) hash = models.CharField(max_length=9, editable=False)
initiated_by = models.ForeignKey( initiated_by = models.ForeignKey(
"accounts.FriendCode", "accounts.FriendCode",
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -28,6 +28,12 @@ class TradeOffer(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
# New denormalized fields for aggregated counts
total_have_quantity = models.PositiveIntegerField(default=0, editable=False)
total_want_quantity = models.PositiveIntegerField(default=0, editable=False)
total_have_accepted = models.PositiveIntegerField(default=0, editable=False)
total_want_accepted = models.PositiveIntegerField(default=0, editable=False)
def __str__(self): def __str__(self):
want_names = ", ".join([x.name for x in self.want_cards.all()]) want_names = ", ".join([x.name for x in self.want_cards.all()])
have_names = ", ".join([x.name for x in self.have_cards.all()]) have_names = ", ".join([x.name for x in self.have_cards.all()])
@ -37,44 +43,69 @@ class TradeOffer(models.Model):
is_new = self.pk is None is_new = self.pk is None
super().save(*args, **kwargs) super().save(*args, **kwargs)
if is_new and not self.hash: if is_new and not self.hash:
self.hash = hashlib.md5(str(self.id).encode('utf-8')).hexdigest()[:8] self.hash = hashlib.md5((str(self.id) + "z").encode("utf-8")).hexdigest()[:8] + "z"
super().save(update_fields=["hash"]) super().save(update_fields=["hash"])
def update_aggregates(self):
"""
Recalculate and update aggregated fields from related have/want cards and acceptances.
"""
from django.db.models import Sum
from trades.models import TradeAcceptance
# Calculate total quantities from through models
have_agg = self.trade_offer_have_cards.aggregate(total=Sum("quantity"))
want_agg = self.trade_offer_want_cards.aggregate(total=Sum("quantity"))
self.total_have_quantity = have_agg["total"] or 0
self.total_want_quantity = want_agg["total"] or 0
# Define acceptance states that count as active.
active_states = [
TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.SENT,
TradeAcceptance.AcceptanceState.RECEIVED,
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
]
# Compute accepted counts based on matching card IDs.
have_card_ids = list(self.trade_offer_have_cards.values_list("card_id", flat=True))
want_card_ids = list(self.trade_offer_want_cards.values_list("card_id", flat=True))
self.total_have_accepted = TradeAcceptance.objects.filter(
trade_offer=self,
state__in=active_states,
requested_card_id__in=have_card_ids,
).count()
self.total_want_accepted = TradeAcceptance.objects.filter(
trade_offer=self,
state__in=active_states,
offered_card_id__in=want_card_ids,
).count()
# Save updated aggregate values so they are denormalized in the database.
self.save(update_fields=[
"total_have_quantity",
"total_want_quantity",
"total_have_accepted",
"total_want_accepted",
])
@property @property
def is_closed(self): def is_closed(self):
if self.manually_closed: if self.manually_closed:
return True return True
# Utilize denormalized fields for faster check.
return not (self.total_have_accepted < self.total_have_quantity and
self.total_want_accepted < self.total_want_quantity)
from .models import TradeAcceptance # local import to avoid circular dependencies class Meta:
active_states = [ indexes = [
TradeAcceptance.AcceptanceState.ACCEPTED, models.Index(fields=['manually_closed']),
TradeAcceptance.AcceptanceState.SENT,
TradeAcceptance.AcceptanceState.RECEIVED,
TradeAcceptance.AcceptanceState.COMPLETED
] ]
closed_have = True
for through_obj in self.trade_offer_have_cards.all():
accepted_count = self.acceptances.filter(
requested_card=through_obj.card,
state__in=active_states
).count()
if accepted_count < through_obj.quantity:
closed_have = False
break
closed_want = True
for through_obj in self.trade_offer_want_cards.all():
accepted_count = self.acceptances.filter(
offered_card=through_obj.card,
state__in=active_states
).count()
if accepted_count < through_obj.quantity:
closed_want = False
break
return closed_have or closed_want
class TradeOfferHaveCard(models.Model): class TradeOfferHaveCard(models.Model):
""" """
Through model for TradeOffer.have_cards. Through model for TradeOffer.have_cards.
@ -83,13 +114,14 @@ class TradeOfferHaveCard(models.Model):
trade_offer = models.ForeignKey( trade_offer = models.ForeignKey(
TradeOffer, TradeOffer,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='trade_offer_have_cards' related_name='trade_offer_have_cards',
db_index=True
) )
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True)
quantity = models.PositiveIntegerField(default=1) quantity = models.PositiveIntegerField(default=1)
def __str__(self): def __str__(self):
return f"{self.card.name} x{self.quantity} (Have side for offer {self.trade_offer.hash})" return f"{self.card.name} x{self.quantity}"
class Meta: class Meta:
unique_together = ("trade_offer", "card") unique_together = ("trade_offer", "card")
@ -108,7 +140,7 @@ class TradeOfferWantCard(models.Model):
quantity = models.PositiveIntegerField(default=1) quantity = models.PositiveIntegerField(default=1)
def __str__(self): def __str__(self):
return f"{self.card.name} x{self.quantity} (Want side for offer {self.trade_offer.hash})" return f"{self.card.name} x{self.quantity}"
class Meta: class Meta:
unique_together = ("trade_offer", "card") unique_together = ("trade_offer", "card")
@ -118,14 +150,17 @@ class TradeAcceptance(models.Model):
ACCEPTED = 'ACCEPTED', 'Accepted' ACCEPTED = 'ACCEPTED', 'Accepted'
SENT = 'SENT', 'Sent' SENT = 'SENT', 'Sent'
RECEIVED = 'RECEIVED', 'Received' RECEIVED = 'RECEIVED', 'Received'
COMPLETED = 'COMPLETED', 'Completed' THANKED_BY_INITIATOR = 'THANKED_BY_INITIATOR', 'Thanked by Initiator'
THANKED_BY_ACCEPTOR = 'THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'
THANKED_BY_BOTH = 'THANKED_BY_BOTH', 'Thanked by Both'
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'
trade_offer = models.ForeignKey( trade_offer = models.ForeignKey(
TradeOffer, TradeOffer,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='acceptances' related_name='acceptances',
db_index=True
) )
accepted_by = models.ForeignKey( accepted_by = models.ForeignKey(
"accounts.FriendCode", "accounts.FriendCode",
@ -136,22 +171,115 @@ class TradeAcceptance(models.Model):
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',
db_index=True
) )
# And one card from the initiator's wanted cards (from want_cards) # 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,
related_name='accepted_offered' related_name='accepted_offered',
db_index=True
) )
state = models.CharField( state = models.CharField(
max_length=25, max_length=25,
choices=AcceptanceState.choices, choices=AcceptanceState.choices,
default=AcceptanceState.ACCEPTED default=AcceptanceState.ACCEPTED,
db_index=True
) )
hash = models.CharField(max_length=9, editable=False, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def mark_thanked(self, friend_code):
"""
Mark this acceptance as "thanked" by the given friend_code.
Allowed transitions:
- If the current state is RECEIVED:
* If the initiator thanks, transition to THANKED_BY_INITIATOR.
* If the acceptor thanks, transition to THANKED_BY_ACCEPTOR.
- If already partially thanked:
* If state is THANKED_BY_INITIATOR and the acceptor thanks, transition to THANKED_BY_BOTH.
* If state is THANKED_BY_ACCEPTOR and the initiator thanks, transition to THANKED_BY_BOTH.
Only parties involved in the trade (either the initiator or the acceptor) can mark it as thanked.
"""
if self.state not in [self.AcceptanceState.RECEIVED,
self.AcceptanceState.THANKED_BY_INITIATOR,
self.AcceptanceState.THANKED_BY_ACCEPTOR]:
raise ValidationError("Cannot mark thanked in the current state.")
if friend_code == self.trade_offer.initiated_by:
# Initiator is marking thanks.
if self.state == self.AcceptanceState.RECEIVED:
self.state = self.AcceptanceState.THANKED_BY_INITIATOR
elif self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR:
self.state = self.AcceptanceState.THANKED_BY_BOTH
elif self.state == self.AcceptanceState.THANKED_BY_INITIATOR:
# Already thanked by the initiator.
return
elif friend_code == self.accepted_by:
# Acceptor is marking thanks.
if self.state == self.AcceptanceState.RECEIVED:
self.state = self.AcceptanceState.THANKED_BY_ACCEPTOR
elif self.state == self.AcceptanceState.THANKED_BY_INITIATOR:
self.state = self.AcceptanceState.THANKED_BY_BOTH
elif self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR:
# Already thanked by the acceptor.
return
else:
from django.core.exceptions import PermissionDenied
raise PermissionDenied("You are not a party to this trade acceptance.")
self.save(update_fields=["state"])
@property
def is_completed(self):
"""
Computed boolean property indicating whether the trade acceptance has been
marked as thanked by one or both parties.
"""
return self.state in {
self.AcceptanceState.RECEIVED,
self.AcceptanceState.THANKED_BY_INITIATOR,
self.AcceptanceState.THANKED_BY_ACCEPTOR,
self.AcceptanceState.THANKED_BY_BOTH,
}
@property
def is_thanked(self):
"""
Computed boolean property indicating whether the trade acceptance has been
marked as thanked by one or both parties.
"""
return self.state == self.AcceptanceState.THANKED_BY_BOTH
@property
def is_rejected(self):
"""
Computed boolean property that is True if the trade acceptance has been rejected
by either the initiator or the acceptor.
"""
return self.state in {
self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
}
@property
def is_completed_or_rejected(self):
"""
Computed boolean property that is True if the trade acceptance is either completed
(i.e., thanked) or rejected.
"""
return self.is_completed or self.is_rejected
@property
def is_active(self):
"""
Computed boolean property that is True if the trade acceptance is still active,
meaning it is neither completed (thanked) nor rejected.
"""
return not self.is_completed_or_rejected
def clean(self): def clean(self):
""" """
Validate that: Validate that:
@ -180,7 +308,9 @@ class TradeAcceptance(models.Model):
self.AcceptanceState.ACCEPTED, self.AcceptanceState.ACCEPTED,
self.AcceptanceState.SENT, self.AcceptanceState.SENT,
self.AcceptanceState.RECEIVED, self.AcceptanceState.RECEIVED,
self.AcceptanceState.COMPLETED, self.AcceptanceState.THANKED_BY_INITIATOR,
self.AcceptanceState.THANKED_BY_ACCEPTOR,
self.AcceptanceState.THANKED_BY_BOTH,
] ]
active_acceptances = self.trade_offer.acceptances.filter(state__in=active_states) active_acceptances = self.trade_offer.acceptances.filter(state__in=active_states)
@ -197,59 +327,119 @@ class TradeAcceptance(models.Model):
if offered_count >= want_through_obj.quantity: if offered_count >= want_through_obj.quantity:
raise ValidationError("This offered card has already been fully used.") raise ValidationError("This offered card has already been fully used.")
def update_state(self, new_state): def get_step_number(self):
"""
Return the step number for the current state.
"""
if self.state in [
self.AcceptanceState.THANKED_BY_INITIATOR,
self.AcceptanceState.THANKED_BY_ACCEPTOR,
]:
return 4
elif self.state in [
self.AcceptanceState.THANKED_BY_BOTH,
]:
return 5
elif self.state in [
self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
]:
return 0
else:
# .choices is a list of tuples, so we need to find the index of the tuple that contains the state.
return (next(index for index, choice in enumerate(self.AcceptanceState.choices) if choice[0] == self.state) + 1)
def update_state(self, new_state, user):
""" """
Update the trade acceptance state. Update the trade acceptance state.
Allowed transitions:
- ACCEPTED -> SENT
- SENT -> RECEIVED
- RECEIVED -> COMPLETED
Additionally, from any active state a transition to:
REJECTED_BY_INITIATOR or REJECTED_BY_ACCEPTOR is allowed.
Once in COMPLETED or any rejection state, no further transitions are allowed.
""" """
if new_state not in [choice[0] for choice in self.AcceptanceState.choices]: if new_state not in [choice[0] for choice in self.AcceptanceState.choices]:
raise ValueError(f"'{new_state}' is not a valid state.") raise ValueError(f"'{new_state}' is not a valid state.")
# Terminal states: no further transitions allowed. # Terminal states: no further transitions allowed.
if self.state in [ if self.state in [
self.AcceptanceState.COMPLETED, self.AcceptanceState.THANKED_BY_BOTH,
self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_ACCEPTOR self.AcceptanceState.REJECTED_BY_ACCEPTOR
]: ]:
raise ValueError(f"No transitions allowed from the terminal state '{self.state}'.") raise ValueError(f"No transitions allowed from the terminal state '{self.state}'.")
allowed_transitions = { allowed = [x for x,y in self.get_allowed_state_transitions(user)]
self.AcceptanceState.ACCEPTED: { print(allowed)
self.AcceptanceState.SENT, print(new_state)
self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
self.AcceptanceState.SENT: {
self.AcceptanceState.RECEIVED,
self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
self.AcceptanceState.RECEIVED: {
self.AcceptanceState.COMPLETED,
self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
}
if new_state not in allowed_transitions.get(self.state, {}): if new_state not in allowed:
raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.") raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.")
self.state = new_state self.state = new_state
self.save(update_fields=["state"]) self.save(update_fields=["state"])
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new and not self.hash:
# Append "y" so all trade acceptance hashes differ from trade offers.
self.hash = hashlib.md5((str(self.id) + "y").encode("utf-8")).hexdigest()[:8] + "y"
super().save(update_fields=["hash"])
def __str__(self): def __str__(self):
return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "
f"accepted_by={self.accepted_by}, " f"accepted_by={self.accepted_by}, "
f"requested_card={self.requested_card}, " f"requested_card={self.requested_card}, "
f"offered_card={self.offered_card}, state={self.state})") f"offered_card={self.offered_card}, state={self.state})")
class Meta: def get_allowed_state_transitions(self, user):
# Unique constraints have been removed because validations now allow """
# multiple active acceptances per card based on the available quantity. Returns a list of allowed state transitions as tuples (value, display_label)
pass based on the current state of this trade acceptance.
"""
if self.trade_offer.initiated_by in user.friend_codes.all():
allowed_transitions = {
self.AcceptanceState.ACCEPTED: {
self.AcceptanceState.SENT,
self.AcceptanceState.REJECTED_BY_INITIATOR,
},
self.AcceptanceState.SENT: {
self.AcceptanceState.REJECTED_BY_INITIATOR,
},
self.AcceptanceState.RECEIVED: {
self.AcceptanceState.THANKED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_INITIATOR,
},
self.AcceptanceState.THANKED_BY_INITIATOR: {
self.AcceptanceState.REJECTED_BY_INITIATOR,
},
self.AcceptanceState.THANKED_BY_ACCEPTOR: {
self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.THANKED_BY_BOTH,
},
}
elif self.accepted_by in user.friend_codes.all():
allowed_transitions = {
self.AcceptanceState.ACCEPTED: {
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
self.AcceptanceState.SENT: {
self.AcceptanceState.RECEIVED,
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
self.AcceptanceState.RECEIVED: {
self.AcceptanceState.THANKED_BY_ACCEPTOR,
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
self.AcceptanceState.THANKED_BY_ACCEPTOR: {
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
self.AcceptanceState.THANKED_BY_INITIATOR: {
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
self.AcceptanceState.THANKED_BY_BOTH,
},
}
else:
allowed_transitions = {}
allowed = allowed_transitions.get(self.state, {})
# Return as a list of tuples (state_value, human-readable label)
return [(state, self.AcceptanceState(state).label) for state in allowed]

View file

@ -1,8 +1,9 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models.signals import m2m_changed from django.db.models.signals import m2m_changed, post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from .models import TradeOffer from .models import TradeOffer
from cards.models import Card from cards.models import Card
from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
def check_trade_offer_rarity(instance): def check_trade_offer_rarity(instance):
combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all()) combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all())
@ -19,4 +20,25 @@ def validate_have_cards_rarity(sender, instance, action, **kwargs):
@receiver(m2m_changed, sender=TradeOffer.want_cards.through) @receiver(m2m_changed, sender=TradeOffer.want_cards.through)
def validate_want_cards_rarity(sender, instance, action, **kwargs): def validate_want_cards_rarity(sender, instance, action, **kwargs):
if action == "post_add": if action == "post_add":
check_trade_offer_rarity(instance) check_trade_offer_rarity(instance)
@receiver(post_save, sender=TradeOfferHaveCard)
@receiver(post_delete, sender=TradeOfferHaveCard)
def update_aggregates_from_have_card(sender, instance, **kwargs):
trade_offer = instance.trade_offer
if trade_offer and hasattr(trade_offer, 'update_aggregates'):
trade_offer.update_aggregates()
@receiver(post_save, sender=TradeOfferWantCard)
@receiver(post_delete, sender=TradeOfferWantCard)
def update_aggregates_from_want_card(sender, instance, **kwargs):
trade_offer = instance.trade_offer
if trade_offer and hasattr(trade_offer, 'update_aggregates'):
trade_offer.update_aggregates()
@receiver(post_save, sender=TradeAcceptance)
@receiver(post_delete, sender=TradeAcceptance)
def update_aggregates_from_acceptance(sender, instance, **kwargs):
trade_offer = instance.trade_offer
if trade_offer and hasattr(trade_offer, 'update_aggregates'):
trade_offer.update_aggregates()

View file

@ -8,47 +8,32 @@ def render_trade_offer(context, offer):
Renders a trade offer including detailed trade acceptance information. Renders a trade offer including detailed trade acceptance information.
Groups acceptances for each card on both the have and want sides. Groups acceptances for each card on both the have and want sides.
""" """
request = context.get('request')
current_friend_code = (
getattr(request.user, 'friendcode', None)
if request and request.user.is_authenticated
else None
)
# Get all acceptances with optimized queries. # Use the already prefetched acceptances.
acceptances = offer.acceptances.all().select_related( acceptances = offer.acceptances.all()
'accepted_by', 'requested_card', 'offered_card' have_cards_available = []
) want_cards_available = []
# Build grouping for the have side. for card in offer.trade_offer_have_cards.all():
have_acceptances_data = [] if all(acc.requested_card_id != card.card_id for acc in acceptances):
for have in offer.trade_offer_have_cards.all(): have_cards_available.append(card)
group = {
'card': have.card,
'quantity': have.quantity,
# Filter acceptances where the requested_card matches the have card.
'acceptances': [
acc for acc in acceptances if acc.requested_card_id == have.card.id
],
}
have_acceptances_data.append(group)
# Build grouping for the want side. for card in offer.trade_offer_want_cards.all():
want_acceptances_data = [] if all(acc.offered_card_id != card.card_id for acc in acceptances):
for want in offer.trade_offer_want_cards.all(): want_cards_available.append(card)
group = {
'card': want.card,
'quantity': want.quantity,
# Filter acceptances where the offered_card matches the want card.
'acceptances': [
acc for acc in acceptances if acc.offered_card_id == want.card.id
],
}
want_acceptances_data.append(group)
return { return {
'offer': offer, 'offer': offer,
'have_acceptances_data': have_acceptances_data, 'have_cards_available': have_cards_available,
'want_acceptances_data': want_acceptances_data, 'want_cards_available': want_cards_available,
'current_friend_code': current_friend_code, }
@register.inclusion_tag('templatetags/trade_acceptance.html', takes_context=True)
def render_trade_acceptance(context, acceptance):
"""
Renders a simple trade acceptance view with a single row and simplified header/footer.
"""
return {
"acceptance": acceptance,
"request": context.get("request"),
} }

View file

@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, render
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django.utils import timezone from django.utils import timezone
from django.db.models import Q from django.db.models import Q, Case, When, Value, BooleanField, Prefetch, F
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.core.paginator import Paginator from django.core.paginator import Paginator
@ -14,7 +14,7 @@ from django.contrib import messages
from .models import TradeOffer, TradeAcceptance from .models import TradeOffer, TradeAcceptance
from .forms import (TradeOfferAcceptForm, from .forms import (TradeOfferAcceptForm,
TradeAcceptanceCreateForm, TradeAcceptanceUpdateForm, TradeOfferCreateForm) TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm)
from cards.models import Card from cards.models import Card
class TradeOfferCreateView(LoginRequiredMixin, CreateView): class TradeOfferCreateView(LoginRequiredMixin, CreateView):
@ -23,6 +23,11 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
template_name = "trades/trade_offer_create.html" template_name = "trades/trade_offer_create.html"
success_url = reverse_lazy("trade_offer_list") success_url = reverse_lazy("trade_offer_list")
def dispatch(self, request, *args, **kwargs):
if not request.user.friend_codes.exists():
raise PermissionDenied("No friend codes available for your account.")
return super().dispatch(request, *args, **kwargs)
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)
# Restrict the 'initiated_by' choices to friend codes owned by the logged-in user. # Restrict the 'initiated_by' choices to friend codes owned by the logged-in user.
@ -31,16 +36,19 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
def get_initial(self): def get_initial(self):
initial = super().get_initial() initial = super().get_initial()
# Standardize parameter names: use "have_cards" and "want_cards"
initial["have_cards"] = self.request.GET.getlist("have_cards") initial["have_cards"] = self.request.GET.getlist("have_cards")
initial["want_cards"] = self.request.GET.getlist("want_cards") initial["want_cards"] = self.request.GET.getlist("want_cards")
# If the user has only one friend code, set it as the default.
if self.request.user.friend_codes.count() == 1: if self.request.user.friend_codes.count() == 1:
initial["initiated_by"] = self.request.user.friend_codes.first().pk initial["initiated_by"] = self.request.user.friend_codes.first().pk
return initial return initial
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
from cards.models import Card
# Ensure available_cards is a proper QuerySet
context["available_cards"] = Card.objects.all().order_by("name", "rarity__pk") \
.select_related("rarity", "cardset") \
.prefetch_related("decks")
friend_codes = self.request.user.friend_codes.all() friend_codes = self.request.user.friend_codes.all()
if "initiated_by" in self.request.GET: if "initiated_by" in self.request.GET:
try: try:
@ -54,7 +62,6 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
return context return context
def form_valid(self, form): def form_valid(self, form):
# Double-check that the chosen friend code is owned by the current user.
friend_codes = self.request.user.friend_codes.all() friend_codes = self.request.user.friend_codes.all()
if form.cleaned_data.get("initiated_by") not in friend_codes: if form.cleaned_data.get("initiated_by") not in friend_codes:
raise PermissionDenied("You cannot initiate trade offers for friend codes that do not belong to you.") raise PermissionDenied("You cannot initiate trade offers for friend codes that do not belong to you.")
@ -62,18 +69,46 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
class TradeOfferListView(LoginRequiredMixin, ListView): class TradeOfferListView(LoginRequiredMixin, ListView):
model = TradeOffer # Fallback model; our context data will hold separate querysets. model = TradeOffer # Fallback model; our context data holds separate filtered querysets.
template_name = "trades/trade_offer_list.html" template_name = "trades/trade_offer_list.html"
def dispatch(self, request, *args, **kwargs):
if request.user.is_authenticated and not request.user.friend_codes.exists():
raise PermissionDenied("No friend codes available for your account.")
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return (
TradeOffer.objects.select_related('initiated_by')
.prefetch_related(
'trade_offer_have_cards__card',
'trade_offer_want_cards__card',
Prefetch(
'acceptances',
queryset=TradeAcceptance.objects.select_related('accepted_by', 'requested_card', 'offered_card')
)
)
.order_by("-updated_at")
.annotate(
is_active=Case(
When(
manually_closed=False,
total_have_quantity__gt=F('total_have_accepted'),
total_want_quantity__gt=F('total_want_accepted'),
then=Value(True)
),
default=Value(False),
output_field=BooleanField()
)
)
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
request = self.request request = self.request
show_closed = request.GET.get("show_closed", "false").lower() == "true"
context["show_closed"] = show_closed
# Determine if the user wants to see completed (closed) items.
show_completed = request.GET.get("show_completed", "false").lower() == "true"
context["show_completed"] = show_completed
# Get all friend codes for the current user.
friend_codes = request.user.friend_codes.all() friend_codes = request.user.friend_codes.all()
friend_code_param = request.GET.get("friend_code") friend_code_param = request.GET.get("friend_code")
if friend_code_param: if friend_code_param:
@ -90,28 +125,33 @@ class TradeOfferListView(LoginRequiredMixin, ListView):
context["selected_friend_code"] = selected_friend_code context["selected_friend_code"] = selected_friend_code
context["friend_codes"] = friend_codes context["friend_codes"] = friend_codes
# ----- My Trade Offers ----- queryset = self.get_queryset().filter(initiated_by=selected_friend_code)
if show_completed: if show_closed:
my_trade_offers = TradeOffer.objects.filter(initiated_by=selected_friend_code).order_by("-updated_at") queryset = queryset.filter(is_active=False)
my_trade_offers = [offer for offer in my_trade_offers if offer.is_closed]
else: else:
my_trade_offers = TradeOffer.objects.filter(initiated_by=selected_friend_code).order_by("-updated_at") queryset = queryset.filter(is_active=True)
my_trade_offers = [offer for offer in my_trade_offers if not offer.is_closed]
offers_page = request.GET.get("offers_page")
offers_paginator = Paginator(queryset, 10)
context["my_trade_offers_paginated"] = offers_paginator.get_page(offers_page)
# ----- Trade Acceptances involving the user ----- # ----- Trade Acceptances involving the user -----
# Update terminal states to include the thanked and rejected states.
terminal_states = [ terminal_states = [
TradeAcceptance.AcceptanceState.COMPLETED, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
] ]
involved_acceptances = TradeAcceptance.objects.filter( involved_acceptances_qs = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code) Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code)
).order_by("-updated_at") ).order_by("-updated_at")
if show_completed: if show_closed:
involved_acceptances = involved_acceptances.filter(state__in=terminal_states) involved_acceptances = involved_acceptances_qs.filter(state__in=terminal_states)
else: else:
involved_acceptances = involved_acceptances.exclude(state__in=terminal_states) involved_acceptances = involved_acceptances_qs.exclude(state__in=terminal_states)
# ----- Split Acceptances into "Waiting for Your Response" and "Other" ----- # ----- Split Acceptances into "Waiting for Your Response" and "Other" -----
waiting_acceptances = involved_acceptances.filter( waiting_acceptances = involved_acceptances.filter(
@ -119,22 +159,20 @@ class TradeOfferListView(LoginRequiredMixin, ListView):
TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.RECEIVED, TradeAcceptance.AcceptanceState.RECEIVED,
]) | ]) |
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.SENT) Q(accepted_by=selected_friend_code, state__in=[
TradeAcceptance.AcceptanceState.SENT
])
) )
other_trade_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk")) other_party_trade_acceptances = involved_acceptances.exclude(pk__in=waiting_acceptances.values("pk"))
# ----- Paginate Each Section Separately -----
offers_page = request.GET.get("offers_page")
waiting_page = request.GET.get("waiting_page") waiting_page = request.GET.get("waiting_page")
other_page = request.GET.get("other_page") other_page = request.GET.get("other_page")
offers_paginator = Paginator(my_trade_offers, 10)
waiting_paginator = Paginator(waiting_acceptances, 10) waiting_paginator = Paginator(waiting_acceptances, 10)
other_paginator = Paginator(other_trade_acceptances, 10) other_party_paginator = Paginator(other_party_trade_acceptances, 10)
context["my_trade_offers_paginated"] = offers_paginator.get_page(offers_page)
context["trade_acceptances_waiting_paginated"] = waiting_paginator.get_page(waiting_page) context["trade_acceptances_waiting_paginated"] = waiting_paginator.get_page(waiting_page)
context["other_trade_acceptances_paginated"] = other_paginator.get_page(other_page) context["other_party_trade_acceptances_paginated"] = other_party_paginator.get_page(other_page)
return context return context
class TradeOfferDeleteView(LoginRequiredMixin, DeleteView): class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
@ -144,7 +182,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
trade_offer = self.get_object() trade_offer = self.get_object()
if trade_offer.initiated_by not in request.user.friend_codes.all(): if trade_offer.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True):
raise PermissionDenied("You are not authorized to delete or close this trade offer.") raise PermissionDenied("You are not authorized to delete or close this trade offer.")
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -152,7 +190,9 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
trade_offer = self.get_object() trade_offer = self.get_object()
terminal_states = [ terminal_states = [
TradeAcceptance.AcceptanceState.COMPLETED, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
] ]
@ -168,68 +208,133 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
trade_offer = self.get_object() trade_offer = self.get_object()
terminal_states = [ terminal_states = [
TradeAcceptance.AcceptanceState.COMPLETED, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
] ]
active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states) active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states)
if active_acceptances.exists(): if active_acceptances.exists():
messages.error(request, "Cannot delete or close this trade offer because there are active acceptances.") messages.error(request, "Cannot delete or close this trade offer because there are active acceptances.")
context = self.get_context_data(object=trade_offer) context = self.get_context_data(object=trade_offer)
return self.render_to_response(context) return self.render_to_response(context)
else: else:
if trade_offer.acceptances.count() > 0: if trade_offer.acceptances.count() > 0:
# There are terminal acceptances: mark the offer as closed.
trade_offer.manually_closed = True trade_offer.manually_closed = True
trade_offer.save(update_fields=["manually_closed"]) trade_offer.save(update_fields=["manually_closed"])
messages.success(request, "Trade offer has been marked as closed.") messages.success(request, "Trade offer has been marked as closed.")
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
else: else:
# No acceptances: proceed with deletion.
messages.success(request, "Trade offer has been deleted.") messages.success(request, "Trade offer has been deleted.")
return super().delete(request, *args, **kwargs) return super().delete(request, *args, **kwargs)
class TradeOfferSearchView(LoginRequiredMixin, ListView): class TradeOfferSearchView(LoginRequiredMixin, ListView):
"""
Reworked trade offer search view using POST.
This view allows users to search active trade offers based on the cards they have and/or want.
The POST parameters (offered_cards and wanted_cards) are expected to be in the format 'card_id:quantity'.
If both types of selections are provided, the resultant queryset must satisfy both conditions.
Offers initiated by any of the user's friend codes are excluded.
When the request is AJAX (via X-Requested-With header), only the search results fragment
(_search_results.html) is rendered. On GET (initial page load), the search results queryset
is empty.
"""
model = TradeOffer model = TradeOffer
context_object_name = "search_results"
template_name = "trades/trade_offer_search.html" template_name = "trades/trade_offer_search.html"
context_object_name = "trade_offers" paginate_by = 10
http_method_names = ["get", "post"]
def parse_selections(self, selection_list):
"""
Parse a list of selections (each formatted as 'card_id:quantity') into a list of tuples.
Defaults the quantity to 1 if missing.
"""
results = []
for item in selection_list:
parts = item.split(":")
try:
card_id = int(parts[0])
except ValueError:
continue # Skip invalid values.
qty = 1
if len(parts) > 1:
try:
qty = int(parts[1])
except ValueError:
qty = 1
results.append((card_id, qty))
return results
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset().filter(state=TradeOffer.State.INITIATED).prefetch_related("have_cards", "want_cards").select_related("initiated_by", "accepted_by") from django.db.models import F
offered_card = self.request.GET.get("offered_card", "").strip() # For a GET request (initial load), return an empty queryset.
wanted_cards = self.request.GET.getlist("wanted_cards") if self.request.method == "GET":
return TradeOffer.objects.none()
if not offered_card and not wanted_cards:
return qs.none()
if offered_card: # Parse the POST data for offered and wanted selections.
try: offered_selections = self.parse_selections(self.request.POST.getlist("offered_cards"))
offered_card_id = int(offered_card) wanted_selections = self.parse_selections(self.request.POST.getlist("wanted_cards"))
except ValueError:
qs = qs.none() # If no selections are provided, return an empty queryset.
else: if not offered_selections and not wanted_selections:
qs = qs.filter(have_cards__id=offered_card_id) return TradeOffer.objects.none()
if wanted_cards: qs = TradeOffer.objects.filter(
valid_wanted_cards = [] manually_closed=False,
for card_str in wanted_cards: total_have_accepted__lt=F("total_have_quantity"),
try: total_want_accepted__lt=F("total_want_quantity")
valid_wanted_cards.append(int(card_str)) ).exclude(initiated_by__in=self.request.user.friend_codes.all())
except ValueError:
qs = qs.none() # Chain filters for offered selections (i.e. the user "has" cards).
break if offered_selections:
if valid_wanted_cards: for card_id, qty in offered_selections:
qs = qs.filter(want_cards__id__in=valid_wanted_cards) qs = qs.filter(
return qs trade_offer_want_cards__card_id=card_id,
trade_offer_want_cards__quantity__gte=qty,
)
# Chain filters for wanted selections (i.e. the user "wants" cards).
if wanted_selections:
for card_id, qty in wanted_selections:
qs = qs.filter(
trade_offer_have_cards__card_id=card_id,
trade_offer_have_cards__quantity__gte=qty,
)
return qs.distinct()
def post(self, request, *args, **kwargs):
# For POST, simply process the search through get().
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["offered_card"] = self.request.GET.get("offered_card", "") from cards.models import Card
context["wanted_cards"] = self.request.GET.getlist("wanted_cards") # Populate available_cards to re-populate the multiselects.
context["available_cards"] = Card.objects.order_by("name", "rarity__pk").select_related("rarity", "cardset") context["available_cards"] = Card.objects.all().order_by("name", "rarity__pk") \
.select_related("rarity", "cardset")
if self.request.method == "POST":
context["offered_cards"] = self.request.POST.getlist("offered_cards")
context["wanted_cards"] = self.request.POST.getlist("wanted_cards")
else:
context["offered_cards"] = []
context["wanted_cards"] = []
return context return context
def render_to_response(self, context, **response_kwargs):
"""
Render the AJAX fragment if the request is AJAX; otherwise, render the complete page.
"""
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
from django.shortcuts import render
return render(self.request, "trades/_search_results.html", context)
else:
return super().render_to_response(context, **response_kwargs)
class TradeOfferDetailView(LoginRequiredMixin, DetailView): class TradeOfferDetailView(LoginRequiredMixin, DetailView):
""" """
Displays the details of a TradeOffer along with its active acceptances. Displays the details of a TradeOffer along with its active acceptances.
@ -239,16 +344,55 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView):
model = TradeOffer model = TradeOffer
template_name = "trades/trade_offer_detail.html" template_name = "trades/trade_offer_detail.html"
def dispatch(self, request, *args, **kwargs):
if not request.user.friend_codes.exists():
raise PermissionDenied("No friend codes available for your account.")
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return (
TradeOffer.objects.select_related('initiated_by')
.prefetch_related(
'trade_offer_have_cards__card',
'trade_offer_want_cards__card',
Prefetch(
'acceptances',
queryset=TradeAcceptance.objects.select_related(
'accepted_by', 'requested_card', 'offered_card'
)
)
)
.annotate(
is_active=Case(
When(manually_closed=False, then=Value(True)),
default=Value(False),
output_field=BooleanField()
)
)
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
trade_offer = self.get_object() trade_offer = self.get_object()
active_states = [
TradeAcceptance.AcceptanceState.ACCEPTED, # Define terminal (closed) acceptance states based on our new system:
TradeAcceptance.AcceptanceState.SENT, terminal_states = [
TradeAcceptance.AcceptanceState.RECEIVED, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.COMPLETED, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
] ]
context["acceptances"] = trade_offer.acceptances.filter(state__in=active_states)
# For example, if you want to separate active from terminal acceptances:
context["acceptances"] = trade_offer.acceptances.all()
# Option 1: Filter active acceptances using the queryset lookup.
context["active_acceptances"] = trade_offer.acceptances.exclude(state__in=terminal_states)
# Option 2: Or filter using the computed property (if you prefer to work with Python iterables):
# context["active_acceptances"] = [acc for acc in trade_offer.acceptances.all() if acc.is_active]
user_friend_codes = self.request.user.friend_codes.all() user_friend_codes = self.request.user.friend_codes.all()
# Add context flag and deletion URL if the current user is the initiator # Add context flag and deletion URL if the current user is the initiator
@ -275,16 +419,40 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView):
template_name = "trades/trade_acceptance_create.html" template_name = "trades/trade_acceptance_create.html"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.trade_offer = get_object_or_404(TradeOffer, pk=kwargs.get("offer_pk")) self.trade_offer = self.get_trade_offer()
# Disallow acceptance if the current user is the offer initiator or if the offer is closed. if self.trade_offer.initiated_by_id in request.user.friend_codes.values_list("id", flat=True) or not self.trade_offer.is_active:
if self.trade_offer.initiated_by in request.user.friend_codes.all() or self.trade_offer.is_closed:
raise PermissionDenied("You cannot accept this trade offer.") raise PermissionDenied("You cannot accept this trade offer.")
if not request.user.friend_codes.exists():
raise PermissionDenied("No friend codes available for your account.")
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_trade_offer(self):
return (
TradeOffer.objects.select_related('initiated_by')
.prefetch_related(
'trade_offer_want_cards__card',
'trade_offer_have_cards__card',
Prefetch(
'acceptances',
queryset=TradeAcceptance.objects.select_related(
'accepted_by', 'requested_card', 'offered_card'
)
)
)
.annotate(
is_active=Case(
When(manually_closed=False, then=Value(True)),
default=Value(False),
output_field=BooleanField()
)
)
.get(pk=self.kwargs['offer_pk'])
)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["trade_offer"] = self.trade_offer kwargs['trade_offer'] = self.trade_offer
kwargs["friend_codes"] = self.request.user.friend_codes.all() kwargs['friend_codes'] = self.request.user.friend_codes.all()
return kwargs return kwargs
def form_valid(self, form): def form_valid(self, form):
@ -301,18 +469,33 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView):
The allowed state transitions are provided via the form. The allowed state transitions are provided via the form.
""" """
model = TradeAcceptance model = TradeAcceptance
form_class = TradeAcceptanceUpdateForm form_class = TradeAcceptanceTransitionForm
template_name = "trades/trade_acceptance_update.html" template_name = "trades/trade_acceptance_update.html"
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.accepted_by_id not in request.user.friend_codes.values_list("id", flat=True):
raise PermissionDenied("You are not authorized to update this acceptance.")
if not request.user.friend_codes.exists():
raise PermissionDenied("No friend codes available for your account.")
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["friend_codes"] = self.request.user.friend_codes.all() # Pass the current instance to the form so it can set proper allowed transitions.
kwargs["instance"] = self.object
kwargs["user"] = self.request.user
return kwargs return kwargs
def form_valid(self, form): def form_valid(self, form):
new_state = form.cleaned_data["state"]
#match the new state to the TradeAcceptance.AcceptanceState enum
if new_state not in TradeAcceptance.AcceptanceState:
form.add_error("state", "Invalid state transition.")
return self.form_invalid(form)
try: try:
# Use the model's update_state logic. # pass the new state and the current user to the update_state method
form.instance.update_state(form.cleaned_data["state"]) form.instance.update_state(new_state, self.request.user)
except ValueError as e: except ValueError as e:
form.add_error("state", str(e)) form.add_error("state", str(e))
return self.form_invalid(form) return self.form_invalid(form)

679
uv.lock generated Normal file
View file

@ -0,0 +1,679 @@
version = 1
requires-python = ">=3.13"
[[package]]
name = "arrow"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "types-python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/00/0f6e8fcdb23ea632c866620cc872729ff43ed91d284c866b515c6342b173/arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85", size = 131960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/ed/e97229a566617f2ae958a6b13e7cc0f585470eac730a73e9e82c32a3cdd2/arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80", size = 66419 },
]
[[package]]
name = "asgiref"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 },
]
[[package]]
name = "binaryornot"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chardet" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/fe/7ebfec74d49f97fc55cd38240c7a7d08134002b1e14be8c3897c0dd5e49b/binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061", size = 371054 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4", size = 9006 },
]
[[package]]
name = "certifi"
version = "2022.12.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/f7/2b1b0ec44fdc30a3d31dfebe52226be9ddc40cd6c0f34ffc8923ba423b69/certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", size = 156897 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/4c/3db2b8021bd6f2f0ceb0e088d6b2d49147671f25832fb17970e9b583d742/certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18", size = 155255 },
]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
]
[[package]]
name = "chardet"
version = "5.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 },
]
[[package]]
name = "charset-normalizer"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/d7/1675d9089a1f4677df5eb29c3f8b064aa1e70c1251a0a8a127803158942d/charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f", size = 92842 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/2b/02e9d6a98ddb73fa238d559a9edcc30b247b8dc4ee848b6184c936e99dc0/charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24", size = 45489 },
]
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "cookiecutter"
version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "arrow" },
{ name = "binaryornot" },
{ name = "click" },
{ name = "jinja2" },
{ name = "python-slugify" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/17/9f2cd228eb949a91915acd38d3eecdc9d8893dde353b603f0db7e9f6be55/cookiecutter-2.6.0.tar.gz", hash = "sha256:db21f8169ea4f4fdc2408d48ca44859349de2647fbe494a9d6c3edfc0542c21c", size = 158767 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/d9/0137658a353168ffa9d0fc14b812d3834772040858ddd1cb6eeaf09f7a44/cookiecutter-2.6.0-py3-none-any.whl", hash = "sha256:a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d", size = 39177 },
]
[[package]]
name = "crispy-tailwind"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-crispy-forms" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/ac/a307ae5ce869d7151b90d4b8b042a48eb454a936dacc695f6418486e5bd8/crispy-tailwind-1.0.3.tar.gz", hash = "sha256:2bc9f616d406e4b003f25d46fcb0079f1c2522719d97adb107667271d849459a", size = 19172 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/ca/11f65e24f3c182dfaf90fd3710d2dcca0fbc3026923e47b43f52a4a2349b/crispy_tailwind-1.0.3-py3-none-any.whl", hash = "sha256:31427f66b1c4fd0d6fb040f4197cfb97d104cdbe7641ea2dea940c0057c4db4b", size = 25700 },
]
[[package]]
name = "cryptography"
version = "39.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/f5/a729774d087e50fffd1438b3877a91e9281294f985bda0fd15bf99016c78/cryptography-39.0.1.tar.gz", hash = "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695", size = 603634 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/af/14bcaf14195de7855612dd79d5e04a6d0b88bebc2cb3a6544110065ea8d4/cryptography-39.0.1-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965", size = 5444159 },
{ url = "https://files.pythonhosted.org/packages/cd/e0/f531855bda1e5c4d782518ab9b03b2e26370a5996d5b81aea2130a6582f7/cryptography-39.0.1-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc", size = 2870607 },
{ url = "https://files.pythonhosted.org/packages/98/51/1c0cedac9ac405adc5da60f5c9884c0ff6af8ccb8caa8173b807baa5bd4a/cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41", size = 3680247 },
{ url = "https://files.pythonhosted.org/packages/3f/e9/78f7ca03dff233ca976ed3d40d0376a57f37033be2a90f18dfe090943c97/cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505", size = 4001722 },
{ url = "https://files.pythonhosted.org/packages/bb/03/20b85e10571c919fd4862465c53ae40b6494fa7f82fd74131f401ce504f6/cryptography-39.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6", size = 4183626 },
{ url = "https://files.pythonhosted.org/packages/2f/c7/06087b04cd870f5acfdc10f8ba252f7985b32c82d4ff96cba05e5f034bf3/cryptography-39.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502", size = 4087166 },
{ url = "https://files.pythonhosted.org/packages/14/61/c64c064ffaf1a52c7ee4a29caf3ed88755b016cb0523d841e63eb33a4976/cryptography-39.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f", size = 3996021 },
{ url = "https://files.pythonhosted.org/packages/1b/90/3c06f3f7a74dad0955536088c3b743a74e8c57c265f2c7a4b61cebb369c1/cryptography-39.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106", size = 4198728 },
{ url = "https://files.pythonhosted.org/packages/67/db/8bf23a46eb3d428514ce83a8047bab4304338548bbd891fded615551b032/cryptography-39.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c", size = 4099241 },
{ url = "https://files.pythonhosted.org/packages/ce/cf/678181421aa1506c7669c1ccbe8737203fb628406b2cd7e24b6eb0e12429/cryptography-39.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4", size = 4266997 },
{ url = "https://files.pythonhosted.org/packages/7c/b9/df69ecb429db4888464c133bbfac0a47a590ed88339fde73101715d5a22d/cryptography-39.0.1-cp36-abi3-win32.whl", hash = "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8", size = 2084743 },
{ url = "https://files.pythonhosted.org/packages/b2/67/f55f33730676654d4ec91956293e681083ed858805904f080aadc707065d/cryptography-39.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac", size = 2456498 },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 },
]
[[package]]
name = "django"
version = "5.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/e5/a06e20c963b280af4aa9432bc694fbdeb1c8df9e28c2ffd5fbb71c4b1bec/Django-5.1.2.tar.gz", hash = "sha256:bd7376f90c99f96b643722eee676498706c9fd7dc759f55ebfaf2c08ebcdf4f0", size = 10711674 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/b8/f205f2b8c44c6cdc555c4f56bbe85ceef7f67c0cf1caa8abe078bb7e32bd/Django-5.1.2-py3-none-any.whl", hash = "sha256:f11aa87ad8d5617171e3f77e1d5d16f004b79a2cf5d2e1d2b97a6a1f8e9ba5ed", size = 8276058 },
]
[[package]]
name = "django-allauth"
version = "65.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/98/9013e6859a3fb2716c6e6091583e906e8a48262c4ee9a2a4d2a01f7cfefc/django_allauth-65.0.2.tar.gz", hash = "sha256:6b5b3a7a65b1c28078b6eb0dd234310b58a44e8addfd187dc30437b0b2bc41a5", size = 1278378 }
[[package]]
name = "django-browser-reload"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/bc/3c67f7daca53b826ec51888576fe5e117d9442d2d0acb58f4264d48b9dba/django_browser_reload-1.17.0.tar.gz", hash = "sha256:3667939cde0eee1a6d698dbe3b78cf10b573dabc4e711fb7933f1ba91fb98da4", size = 14312 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/8f/62fc4fbf5c05c2210e6cb616f1c2a3da53871dfecbaa4c44b1f482ca3e8f/django_browser_reload-1.17.0-py3-none-any.whl", hash = "sha256:d372c12c1c5962c02279a53cac7e8a020c48f104592c637a06d0768b28d2d6be", size = 12228 },
]
[[package]]
name = "django-crispy-forms"
version = "2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/f6/5bce7ae3512171c7c0ca3de31689e2a1ced8b030f156fcf13d2870e5468e/django_crispy_forms-2.3.tar.gz", hash = "sha256:2db17ae08527201be1273f0df789e5f92819e23dd28fec69cffba7f3762e1a38", size = 278849 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/3b/5dc3faf8739d1ce7a73cedaff508b4af8f6aa1684120ded6185ca0c92734/django_crispy_forms-2.3-py3-none-any.whl", hash = "sha256:efc4c31e5202bbec6af70d383a35e12fc80ea769d464fb0e7fe21768bb138a20", size = 31411 },
]
[[package]]
name = "django-daisy"
version = "1.0.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/7f/94b673604a4dcea367f43028f620aed7329cafa67f8a94170a704f03baea/django_daisy-1.0.13.tar.gz", hash = "sha256:45299b5899c3cea508237af2467d84d686856fffa43f444118281f22b748f394", size = 8223292 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/1f/dd23e1c865e48834126373238b197df69bcece6d56a49ef7c50d11013a80/django_daisy-1.0.13-py3-none-any.whl", hash = "sha256:629676a888584d42be1f53fa673f8fd649cea245c66e813565c290431ec94199", size = 8046166 },
]
[[package]]
name = "django-debug-toolbar"
version = "4.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "sqlparse" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d4/9c/0a3238eda0a46df20f2e3fe2a30313d34f5042a1a737d08230b77c29a3e9/django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044", size = 272610 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/33/2036a472eedfbe49240dffea965242b3f444de4ea4fbeceb82ccea33a2ce/django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45", size = 229621 },
]
[[package]]
name = "django-el-pagination"
version = "4.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/31/9083e0b7a36f4c8a8767a9446cb20222a96b11e47aa6ace0c63209ded43a/django_el_pagination-4.1.2.tar.gz", hash = "sha256:5d770f02ddbd54f59f3429a2f7d1af426f1d7eb581f9f977d018c8c0cc0364f2", size = 12664823 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/77/1c60bdf7b31bb59cab4c5aac70363c700032ce0990f9a041896cf6ac64ae/django_el_pagination-4.1.2-py3-none-any.whl", hash = "sha256:40e2cce29543286f55a6c959c04055e816c263445cad62befd87624df47c4ef6", size = 32916 },
]
[[package]]
name = "django-tailwind-4"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/25/db0a2cf3180716361da2c27d7051a11b041ed1dfad436d1e85bbca440b39/django_tailwind_4-0.1.4.tar.gz", hash = "sha256:01bc1f64a8867e9258b97218bf79a42e9d2f29073423d3d55c4f4f2666d5ed58", size = 11749 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/9f/57a654404a5cd50042498d4ecd2440ea9ecdac5ffe730981fd816ebdcb8a/django_tailwind_4-0.1.4-py3-none-any.whl", hash = "sha256:e1972c19eef98582f7d92c694af27757fedd7c354c1f4e3b36b4d45e82431bf6", size = 14870 },
]
[package.optional-dependencies]
reload = [
{ name = "django-browser-reload" },
]
[[package]]
name = "django-widget-tweaks"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/fe/26eb92fba83844e71bbec0ced7fc2e843e5990020e3cc676925204031654/django-widget-tweaks-1.5.0.tar.gz", hash = "sha256:1c2180681ebb994e922c754804c7ffebbe1245014777ac47897a81f57cc629c7", size = 14767 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/6a/6cb6deb5c38b785c77c3ba66f53051eada49205979c407323eb666930915/django_widget_tweaks-1.5.0-py3-none-any.whl", hash = "sha256:a41b7b2f05bd44d673d11ebd6c09a96f1d013ee98121cb98c384fe84e33b881e", size = 8960 },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 },
]
[[package]]
name = "idna"
version = "3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/e1/43beb3d38dba6cb420cefa297822eac205a277ab43e5ba5d5c46faf96438/idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", size = 183077 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/34/3030de6f1370931b9dbb4dad48f6ab1015ab1d32447850b9fc94e60097be/idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2", size = 61538 },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "oauthlib"
version = "3.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 },
]
[[package]]
name = "packaging"
version = "23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/6c/7c6658d258d7971c5eb0d9b69fa9265879ec9a9158031206d47800ae2213/packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f", size = 134240 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/c3/57f0601a2d4fe15de7a553c00adbc901425661bf048f2a22dfc500caf121/packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", size = 48905 },
]
[[package]]
name = "pkmntrade-club"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "asgiref" },
{ name = "certifi" },
{ name = "cffi" },
{ name = "charset-normalizer" },
{ name = "cookiecutter" },
{ name = "crispy-tailwind" },
{ name = "cryptography" },
{ name = "defusedxml" },
{ name = "django" },
{ name = "django-allauth" },
{ name = "django-browser-reload" },
{ name = "django-crispy-forms" },
{ name = "django-daisy" },
{ name = "django-debug-toolbar" },
{ name = "django-el-pagination" },
{ name = "django-tailwind-4", extra = ["reload"] },
{ name = "django-widget-tweaks" },
{ name = "gunicorn" },
{ name = "idna" },
{ name = "oauthlib" },
{ name = "packaging" },
{ name = "psycopg" },
{ name = "psycopg-binary" },
{ name = "pycparser" },
{ name = "pyjwt" },
{ name = "python3-openid" },
{ name = "requests" },
{ name = "requests-oauthlib" },
{ name = "sqlparse" },
{ name = "typing-extensions" },
{ name = "urllib3" },
{ name = "whitenoise" },
]
[package.metadata]
requires-dist = [
{ name = "asgiref", specifier = "==3.8.1" },
{ name = "certifi", specifier = "==2022.12.7" },
{ name = "cffi", specifier = "==1.17.1" },
{ name = "charset-normalizer", specifier = "==3.0.1" },
{ name = "cookiecutter", specifier = "==2.6.0" },
{ name = "crispy-tailwind", specifier = "==1.0.3" },
{ name = "cryptography", specifier = "==39.0.1" },
{ name = "defusedxml", specifier = "==0.7.1" },
{ name = "django", specifier = "==5.1.2" },
{ name = "django-allauth", specifier = "==65.0.2" },
{ name = "django-browser-reload", specifier = "==1.17.0" },
{ name = "django-crispy-forms", specifier = "==2.3" },
{ name = "django-daisy", specifier = "==1.0.13" },
{ name = "django-debug-toolbar", specifier = "==4.4.6" },
{ name = "django-el-pagination", specifier = "==4.1.2" },
{ name = "django-tailwind-4", extras = ["reload"], specifier = "==0.1.4" },
{ name = "django-widget-tweaks", specifier = "==1.5.0" },
{ name = "gunicorn", specifier = "==23.0.0" },
{ name = "idna", specifier = "==3.4" },
{ name = "oauthlib", specifier = "==3.2.2" },
{ name = "packaging", specifier = "==23.1" },
{ name = "psycopg", specifier = "==3.2.3" },
{ name = "psycopg-binary", specifier = "==3.2.3" },
{ name = "pycparser", specifier = "==2.21" },
{ name = "pyjwt", specifier = "==2.6.0" },
{ name = "python3-openid", specifier = "==3.2.0" },
{ name = "requests", specifier = "==2.28.2" },
{ name = "requests-oauthlib", specifier = "==1.3.1" },
{ name = "sqlparse", specifier = "==0.4.3" },
{ name = "typing-extensions", specifier = "==4.9.0" },
{ name = "urllib3", specifier = "==1.26.14" },
{ name = "whitenoise", specifier = "==6.7.0" },
]
[[package]]
name = "psycopg"
version = "3.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/ad/7ce016ae63e231575df0498d2395d15f005f05e32d3a2d439038e1bd0851/psycopg-3.2.3.tar.gz", hash = "sha256:a5764f67c27bec8bfac85764d23c534af2c27b893550377e37ce59c12aac47a2", size = 155550 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/21/534b8f5bd9734b7a2fcd3a16b1ee82ef6cad81a4796e95ebf4e0c6a24119/psycopg-3.2.3-py3-none-any.whl", hash = "sha256:644d3973fe26908c73d4be746074f6e5224b03c1101d302d9a53bf565ad64907", size = 197934 },
]
[[package]]
name = "psycopg-binary"
version = "3.2.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/bf/717c5e51c68e2498b60a6e9f1476cc47953013275a54bf8e23fd5082a72d/psycopg_binary-3.2.3-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:41fdec0182efac66b27478ac15ef54c9ebcecf0e26ed467eb7d6f262a913318b", size = 3360874 },
{ url = "https://files.pythonhosted.org/packages/31/d5/6f9ad6fe5ef80ca9172bc3d028ebae8e9a1ee8aebd917c95c747a5efd85f/psycopg_binary-3.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:07d019a786eb020c0f984691aa1b994cb79430061065a694cf6f94056c603d26", size = 3502320 },
{ url = "https://files.pythonhosted.org/packages/fb/7b/c58dd26c27fe7a491141ca765c103e702872ff1c174ebd669d73d7fb0b5d/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c57615791a337378fe5381143259a6c432cdcbb1d3e6428bfb7ce59fff3fb5c", size = 4446950 },
{ url = "https://files.pythonhosted.org/packages/ed/75/acf6a81c788007b7bc0a43b02c22eff7cb19a6ace9e84c32838e86083a3f/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8eb9a4e394926b93ad919cad1b0a918e9b4c846609e8c1cfb6b743683f64da0", size = 4252409 },
{ url = "https://files.pythonhosted.org/packages/83/a5/8a01b923fe42acd185d53f24fb98ead717725ede76a4cd183ff293daf1f1/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5905729668ef1418bd36fbe876322dcb0f90b46811bba96d505af89e6fbdce2f", size = 4488121 },
{ url = "https://files.pythonhosted.org/packages/14/8f/b00e65e204340ab1259ecc8d4cc4c1f72c386be5ca7bfb90ae898a058d68/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd65774ed7d65101b314808b6893e1a75b7664f680c3ef18d2e5c84d570fa393", size = 4190653 },
{ url = "https://files.pythonhosted.org/packages/ce/fc/ba830fc6c9b02b66d1e2fb420736df4d78369760144169a9046f04d72ac6/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:700679c02f9348a0d0a2adcd33a0275717cd0d0aee9d4482b47d935023629505", size = 3118074 },
{ url = "https://files.pythonhosted.org/packages/b8/75/b62d06930a615435e909e05de126aa3d49f6ec2993d1aa6a99e7faab5570/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96334bb64d054e36fed346c50c4190bad9d7c586376204f50bede21a913bf942", size = 3100457 },
{ url = "https://files.pythonhosted.org/packages/57/e5/32dc7518325d0010813853a87b19c784d8b11fdb17f5c0e0c148c5ac77af/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9099e443d4cc24ac6872e6a05f93205ba1a231b1a8917317b07c9ef2b955f1f4", size = 3192788 },
{ url = "https://files.pythonhosted.org/packages/23/a3/d1aa04329253c024a2323051774446770d47b43073874a3de8cca797ed8e/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1985ab05e9abebfbdf3163a16ebb37fbc5d49aff2bf5b3d7375ff0920bbb54cd", size = 3234247 },
{ url = "https://files.pythonhosted.org/packages/03/20/b675af723b9a61d48abd6a3d64cbb9797697d330255d1f8105713d54ed8e/psycopg_binary-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:e90352d7b610b4693fad0feea48549d4315d10f1eba5605421c92bb834e90170", size = 2913413 },
]
[[package]]
name = "pycparser"
version = "2.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/0b/95d387f5f4433cb0f53ff7ad859bd2c6051051cebbb564f139a999ab46de/pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206", size = 170877 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/d5/5f610ebe421e85889f2e55e33b7f9a6795bd982198517d912eb1c76e1a53/pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", size = 118697 },
]
[[package]]
name = "pygments"
version = "2.19.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
]
[[package]]
name = "pyjwt"
version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/65/db64904a7f23e12dbf0565b53de01db04d848a497c6c9b87e102f74c9304/PyJWT-2.6.0.tar.gz", hash = "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd", size = 72984 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/46/505f0dd53c14096f01922bf93a7abb4e40e29a06f858abbaa791e6954324/PyJWT-2.6.0-py3-none-any.whl", hash = "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14", size = 20316 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "text-unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051 },
]
[[package]]
name = "python3-openid"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "defusedxml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/4a/29feb8da6c44f77007dcd29518fea73a3d5653ee02a587ae1f17f1f5ddb5/python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", size = 305600 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/a5/c6ba13860bdf5525f1ab01e01cc667578d6f1efc8a1dba355700fb04c29b/python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b", size = 133681 },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
]
[[package]]
name = "requests"
version = "2.28.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/ee/391076f5937f0a8cdf5e53b701ffc91753e87b07d66bae4a09aa671897bf/requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf", size = 108206 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/f4/274d1dbe96b41cf4e0efb70cbced278ffd61b5c7bb70338b62af94ccb25b/requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa", size = 62822 },
]
[[package]]
name = "requests-oauthlib"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "oauthlib" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/52/531ef197b426646f26b53815a7d2a67cb7a331ef098bb276db26a68ac49f/requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a", size = 52027 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/bb/5deac77a9af870143c684ab46a7934038a53eb4aa975bc0687ed6ca2c610/requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", size = 23892 },
]
[[package]]
name = "rich"
version = "13.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sqlparse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/fa/5b7662b04b69f3a34b8867877e4dbf2a37b7f2a5c0bbb5a9eed64efd1ad1/sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268", size = 70771 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/d3/31dd2c3e48fc2060819f4acb0686248250a0f2326356306b38a42e059144/sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34", size = 42768 },
]
[[package]]
name = "text-unidecode"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 },
]
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20241206"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/60/47d92293d9bc521cd2301e423a358abfac0ad409b3a1606d8fbae1321961/types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb", size = 13802 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/b3/ca41df24db5eb99b00d97f89d7674a90cb6b3134c52fb8121b6d8d30f15c/types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53", size = 14384 },
]
[[package]]
name = "typing-extensions"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/1d/eb26f5e75100d531d7399ae800814b069bc2ed2a7410834d57374d010d96/typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", size = 74918 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/f4/6a90020cd2d93349b442bfcb657d0dc91eee65491600b2cb1d388bc98e6b/typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd", size = 32750 },
]
[[package]]
name = "tzdata"
version = "2025.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 },
]
[[package]]
name = "urllib3"
version = "1.26.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/52/fe421fb7364aa738b3506a2d99e4f3a56e079c0a798e9f4fa5e14c60922f/urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72", size = 300665 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ca/466766e20b767ddb9b951202542310cba37ea5f2d792dae7589f1741af58/urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1", size = 140642 },
]
[[package]]
name = "whitenoise"
version = "6.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/b8/86451d63ef5e1a9c480b52759d9db25ba85c3420ebdaf039057ed152a4c1/whitenoise-6.7.0.tar.gz", hash = "sha256:58c7a6cd811e275a6c91af22e96e87da0b1109e9a53bb7464116ef4c963bf636", size = 24973 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/42/68400d8ad59f67a1f7e12c2f39089ce005f08f73333f3e215f3d5ed6453c/whitenoise-6.7.0-py3-none-any.whl", hash = "sha256:a1ae85e01fdc9815d12fa33f17765bc132ed2c54fa76daf9e39e879dd93566f6", size = 19905 },
]