finished conversion to tailwind
This commit is contained in:
parent
6e2843c60e
commit
d62956d465
50 changed files with 2490 additions and 1273 deletions
|
|
@ -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
15
.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
24
.vscode/tasks.json
vendored
24
.vscode/tasks.json
vendored
|
|
@ -5,24 +5,24 @@
|
|||
"label": "Reset DB, Make Migrations, And Seed Data",
|
||||
"type": "shell",
|
||||
"command": "./reset-db_make-migrations_seed-data.sh",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Run app",
|
||||
"label": "Run app & db",
|
||||
"type": "shell",
|
||||
"command": "./entrypoint.sh",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Run tailwind dev server",
|
||||
"type": "shell",
|
||||
"command": "cd theme/static_src && npm run dev",
|
||||
"problemMatcher": [],
|
||||
},
|
||||
{
|
||||
"label": "Run db",
|
||||
"type": "shell",
|
||||
"command": "docker compose up -d",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ RUN apt-get update && apt-get install -y nodejs npm
|
|||
# Expose port 8000
|
||||
EXPOSE 8000
|
||||
|
||||
USER 10003:10003
|
||||
#USER 10003:10003
|
||||
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
# 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"]
|
||||
|
|
|
|||
|
|
@ -3,17 +3,11 @@ from django.contrib.auth.forms import UserCreationForm, UserChangeForm
|
|||
from .models import CustomUser, FriendCode
|
||||
from allauth.account.forms import SignupForm
|
||||
|
||||
class CustomUserCreationForm(UserCreationForm):
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
model = CustomUser
|
||||
fields = ('email',)
|
||||
|
||||
class CustomUserChangeForm(UserChangeForm):
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ('email',)
|
||||
fields = ['email']
|
||||
|
||||
class FriendCodeForm(forms.ModelForm):
|
||||
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]}"
|
||||
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(
|
||||
max_length=19,
|
||||
required=True,
|
||||
|
|
@ -41,9 +40,6 @@ class CustomSignupForm(SignupForm):
|
|||
|
||||
def __init__(self, *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):
|
||||
friend_code = self.cleaned_data.get("friend_code", "").strip().replace("-", "")
|
||||
|
|
@ -54,10 +50,12 @@ class CustomSignupForm(SignupForm):
|
|||
|
||||
def save(self, request):
|
||||
# First, complete the normal signup process.
|
||||
user = super().save(request)
|
||||
user = super(CustomUserCreationForm, self).save(request)
|
||||
# Create the associated FriendCode record.
|
||||
FriendCode.objects.create(
|
||||
friend_code_pk = FriendCode.objects.create(
|
||||
friend_code=self.cleaned_data["friend_code"],
|
||||
user=user
|
||||
)
|
||||
user.default_friend_code = friend_code_pk
|
||||
user.save()
|
||||
return user
|
||||
|
|
@ -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.validators
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from django.urls import path
|
||||
from .views import ListFriendCodesView, AddFriendCodeView, DeleteFriendCodeView, ChangeDefaultFriendCodeView
|
||||
from .views import ListFriendCodesView, AddFriendCodeView, DeleteFriendCodeView, ChangeDefaultFriendCodeView, SettingsView
|
||||
|
||||
urlpatterns = [
|
||||
# ... other account URLs ...
|
||||
|
|
@ -7,4 +7,5 @@ urlpatterns = [
|
|||
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/default/<int:pk>/", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"),
|
||||
path("settings/", SettingsView.as_view(), name="settings"),
|
||||
]
|
||||
|
|
@ -2,7 +2,7 @@ from django.contrib import messages
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse_lazy
|
||||
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.forms import FriendCodeForm
|
||||
|
||||
|
|
@ -111,3 +111,9 @@ class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
|
|||
request.user.set_default_friend_code(friend_code)
|
||||
messages.success(request, "Default friend code updated successfully.")
|
||||
return redirect("list_friend_codes")
|
||||
|
||||
class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
"""
|
||||
Display the user's settings.
|
||||
"""
|
||||
template_name = "account/settings.html"
|
||||
|
|
@ -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
|
||||
from django.db import migrations, models
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ from django.utils.safestring import mark_safe
|
|||
register = template.Library()
|
||||
|
||||
@register.inclusion_tag("templatetags/card_badge.html")
|
||||
def card_badge(card, quantity=1, show_single_count=True):
|
||||
def card_badge(card, quantity=1):
|
||||
return {
|
||||
'card': card,
|
||||
'quantity': quantity,
|
||||
'decks': card.decks.all() if card else None,
|
||||
'dropdown': card is None,
|
||||
'show_single_count': show_single_count,
|
||||
'num_decks': card.decks.count() if card else None,
|
||||
}
|
||||
|
||||
@register.filter
|
||||
|
|
@ -23,6 +22,6 @@ def card_badge_inline(card, quantity=1):
|
|||
'card': card,
|
||||
'quantity': quantity,
|
||||
'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)
|
||||
|
|
@ -6,22 +6,24 @@ register = template.Library()
|
|||
@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"):
|
||||
"""
|
||||
Renders a multiselect field for choosing cards, storing the card ID only as the option's value and
|
||||
the quantity in a dedicated data attribute.
|
||||
Renders a multiselect field for choosing cards while supporting quantity data.
|
||||
|
||||
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:
|
||||
- field_name: The name attribute for the select tag.
|
||||
- label: Label text to show above the selector.
|
||||
- placeholder: Placeholder text to show in the select.
|
||||
- card_filter: (Optional) A dictionary of filter parameters to apply on the Card query.
|
||||
- selected_values: (Optional) A list of selected card values; if a value includes a quantity
|
||||
it should be in the format "card_id:quantity".
|
||||
- card_filter: (Optional) A dictionary of filter parameters or a QuerySet to obtain the available Card objects.
|
||||
- selected_values: (Optional) A list of selected values; if a value includes a quantity it should be in the format "card_id:quantity".
|
||||
- cache_timeout: (Optional) Cache timeout (in seconds) for the options block.
|
||||
- cache_key: (Optional) Cache key—by default both select fields use the same key so that caching is shared.
|
||||
- cache_key: (Optional) Cache key.
|
||||
"""
|
||||
if selected_values is None:
|
||||
selected_values = []
|
||||
# Map the selected values into a dictionary: { card_id (str): quantity (str) }
|
||||
# Create a mapping {card_id: quantity}
|
||||
selected_cards = {}
|
||||
for val in selected_values:
|
||||
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"
|
||||
selected_cards[card_id] = quantity
|
||||
|
||||
# If a card_filter is provided, use it; otherwise retrieve all cards.
|
||||
if card_filter:
|
||||
available_cards_qs = Card.objects.filter(**card_filter)
|
||||
else:
|
||||
# Determine how to obtain the available cards.
|
||||
# If card_filter is not provided, or is None, fall back to all cards.
|
||||
if card_filter is None:
|
||||
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_qs.order_by("name", "rarity__pk")
|
||||
|
|
@ -41,19 +48,17 @@ def card_multiselect(field_name, label, placeholder, card_filter=None, selected_
|
|||
.prefetch_related("decks")
|
||||
)
|
||||
|
||||
# Loop through available cards and set styling, plus attach pre‑selected quantity
|
||||
for card in available_cards:
|
||||
# Apply styling based on deck count.
|
||||
deck_count = card.decks.count()
|
||||
if deck_count == 1:
|
||||
card.style = f"background-color: {card.decks.all()[0].hex_color}; color: white;"
|
||||
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;"
|
||||
elif deck_count >= 3:
|
||||
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;"
|
||||
# decks = list(card.decks.all())
|
||||
# deck_count = len(decks)
|
||||
# if deck_count == 1:
|
||||
# card.style = f"background-color: {decks[0].hex_color}; color: white;"
|
||||
# elif deck_count == 2:
|
||||
# card.style = f"background: linear-gradient(to right, {decks[0].hex_color}, {decks[1].hex_color}); color: white;"
|
||||
# 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;"
|
||||
|
||||
# Attach selected_quantity only if the card is pre‑selected.
|
||||
pk_str = str(card.pk)
|
||||
if pk_str in selected_cards:
|
||||
card.selected_quantity = selected_cards[pk_str]
|
||||
|
|
@ -64,7 +69,7 @@ def card_multiselect(field_name, label, placeholder, card_filter=None, selected_
|
|||
'label': label,
|
||||
'available_cards': available_cards,
|
||||
'placeholder': placeholder,
|
||||
# For caching/selection checks, pass a list of the pre‑selected card IDs.
|
||||
# Pass just the list of pre‑selected card IDs for caching/selection logic in the template.
|
||||
'selected_values': list(selected_cards.keys()),
|
||||
'cache_timeout': cache_timeout,
|
||||
'cache_key': cache_key,
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ RESEND_API_KEY = "re_BBXJWctP_8gb4iNpfaHuau7Na95mc3feu"
|
|||
DEBUG = True
|
||||
|
||||
# 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
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
INSTALLED_APPS = [
|
||||
|
|
@ -46,7 +47,6 @@ INSTALLED_APPS = [
|
|||
"cards",
|
||||
"home",
|
||||
"trades.apps.TradesConfig",
|
||||
"widget_tweaks",
|
||||
]
|
||||
|
||||
TAILWIND_APP_NAME = 'theme'
|
||||
|
|
@ -64,7 +64,7 @@ MIDDLEWARE = [
|
|||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"allauth.account.middleware.AccountMiddleware", # django-allauth
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
"django_project.middleware.AutoLoginMiddleware",
|
||||
#"django_project.middleware.AutoLoginMiddleware",
|
||||
]
|
||||
|
||||
DAISY_SETTINGS = {
|
||||
|
|
@ -96,26 +96,18 @@ TEMPLATES = [
|
|||
]
|
||||
|
||||
# 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 = {
|
||||
"default": {
|
||||
"local": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "postgres",
|
||||
"USER": "postgres",
|
||||
"PASSWORD": "",
|
||||
"HOST": "db", # set in docker-compose.yml
|
||||
"HOST": "localhost", # set in docker-compose.yml
|
||||
"PORT": 5432, # default postgres port
|
||||
},
|
||||
"neon": {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "pocket-trade",
|
||||
"NAME": "dev",
|
||||
"USER": "pocket_trade_owner",
|
||||
"PASSWORD": "npg_f1lTpOX7Rnvb",
|
||||
"HOST": "ep-cool-cake-a6zvgu85-pooler.us-west-2.aws.neon.tech", # set in docker-compose.yml
|
||||
|
|
@ -129,18 +121,18 @@ DATABASES = {
|
|||
# Password validation
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
# {
|
||||
# "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
# },
|
||||
# {
|
||||
# "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
# },
|
||||
# {
|
||||
# "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
# },
|
||||
# {
|
||||
# "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
# },
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -179,7 +171,7 @@ STORAGES = {
|
|||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -189,11 +181,11 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|||
|
||||
# django-crispy-forms
|
||||
# https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind'#'bootstrap5'
|
||||
CRISPY_TEMPLATE_PACK = "tailwind"#"bootstrap5"
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind'
|
||||
CRISPY_TEMPLATE_PACK = "tailwind"
|
||||
|
||||
# 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_PORT = 587
|
||||
EMAIL_HOST_USER = "resend"
|
||||
|
|
@ -206,9 +198,12 @@ DEFAULT_FROM_EMAIL = "noreply@pkmntrade.club"
|
|||
# django-debug-toolbar
|
||||
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#internal-ips
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
]
|
||||
import socket
|
||||
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
|
||||
AUTH_USER_MODEL = "accounts.CustomUser"
|
||||
|
|
@ -230,17 +225,18 @@ AUTHENTICATION_BACKENDS = (
|
|||
)
|
||||
# https://django-allauth.readthedocs.io/en/latest/configuration.html
|
||||
ACCOUNT_SESSION_REMEMBER = True
|
||||
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
|
||||
ACCOUNT_AUTHENTICATION_METHOD = "email"
|
||||
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True
|
||||
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
#ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
#ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||
ACCOUNT_CHANGE_EMAIL = True
|
||||
ACCOUNT_UNIQUE_EMAIL = True
|
||||
ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "in_game_username"
|
||||
ACCOUNT_USERNAME_REQUIRED = False
|
||||
ACCOUNT_LOGIN_BY_CODE_ENABLED = True
|
||||
ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "in-game-username"
|
||||
ACCOUNT_USERNAME_REQUIRED = True
|
||||
ACCOUNT_FORMS = {
|
||||
"signup": "accounts.forms.CustomSignupForm",
|
||||
"signup": "accounts.forms.CustomUserCreationForm",
|
||||
}
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
services:
|
||||
web:
|
||||
build: .
|
||||
command: python /code/manage.py runserver 0.0.0.0:8000
|
||||
volumes:
|
||||
- .:/code:z
|
||||
ports:
|
||||
- 8000:8000
|
||||
depends_on:
|
||||
- db
|
||||
# web:
|
||||
# build: .
|
||||
# command: python /code/manage.py runserver 0.0.0.0:8000
|
||||
# volumes:
|
||||
# - .:/code:z
|
||||
# ports:
|
||||
# - 8000:8000
|
||||
# depends_on:
|
||||
# - db
|
||||
db:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data/
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ echo "Restarting compose services..."
|
|||
docker compose down
|
||||
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
|
||||
echo "Done!"
|
||||
180
home/views.py
180
home/views.py
|
|
@ -1,32 +1,32 @@
|
|||
from collections import defaultdict
|
||||
from django.views.generic import TemplateView
|
||||
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 trades.models import TradeOffer
|
||||
from trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard
|
||||
from cards.models import Card, CardSet, Rarity
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
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):
|
||||
template_name = "home/home.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
def get_base_trade_offer_queryset(self):
|
||||
"""
|
||||
Returns a queryset for TradeOffer that includes prefetches and denormalized aggregates.
|
||||
"""
|
||||
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,
|
||||
]
|
||||
|
||||
# Use POST data if available, else fallback to GET
|
||||
request_data = self.request.POST if self.request.method == "POST" else self.request.GET
|
||||
|
||||
# --- 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',
|
||||
queryset=Card.objects.annotate(
|
||||
|
|
@ -40,11 +40,9 @@ class HomePageView(TemplateView):
|
|||
).order_by("trade_offer_count", "id")
|
||||
)
|
||||
|
||||
search_results = None
|
||||
if offered_cards or wanted_cards:
|
||||
# Instead of filtering by a 'state' field (which no longer exists),
|
||||
# we fetch all offers. You may later add logic to filter only "open" offers.
|
||||
qs = TradeOffer.objects.all().prefetch_related(
|
||||
qs = (
|
||||
TradeOffer.objects.all()
|
||||
.prefetch_related(
|
||||
have_cards_prefetch,
|
||||
"have_cards__decks",
|
||||
"have_cards__rarity",
|
||||
|
|
@ -52,116 +50,80 @@ class HomePageView(TemplateView):
|
|||
want_cards_prefetch,
|
||||
"want_cards__decks",
|
||||
"want_cards__rarity",
|
||||
"want_cards__cardset"
|
||||
).select_related(
|
||||
"initiated_by__user"
|
||||
"want_cards__cardset",
|
||||
"acceptances"
|
||||
)
|
||||
if offered_cards:
|
||||
try:
|
||||
offered_card_ids = [int(card) for card in offered_cards]
|
||||
except ValueError:
|
||||
qs = qs.none()
|
||||
else:
|
||||
qs = qs.filter(want_cards__id__in=offered_card_ids)
|
||||
if wanted_cards:
|
||||
try:
|
||||
wanted_card_ids = [int(card) for card in wanted_cards]
|
||||
except ValueError:
|
||||
qs = qs.none()
|
||||
else:
|
||||
qs = qs.filter(have_cards__id__in=wanted_card_ids)
|
||||
.select_related("initiated_by__user")
|
||||
.annotate(
|
||||
is_active=Case(
|
||||
When(
|
||||
Q(total_have_accepted__lt=F('total_have_quantity')) &
|
||||
Q(total_want_accepted__lt=F('total_want_quantity')),
|
||||
then=Value(True)
|
||||
),
|
||||
default=Value(False),
|
||||
output_field=BooleanField()
|
||||
)
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
context["search_results"] = search_results
|
||||
# Reuse base trade offer queryset for market stats
|
||||
base_offer_qs = self.get_base_trade_offer_queryset().filter(manually_closed=False, is_active=True)
|
||||
|
||||
# --- 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]
|
||||
# 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 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]
|
||||
# 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]
|
||||
# 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 Offers grouped by rarity
|
||||
all_offers = base_offer_qs.order_by("created_at")
|
||||
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]
|
||||
|
||||
# Group offers by normalized rarity id from their have_cards
|
||||
grouped = defaultdict(list)
|
||||
for offer in all_offers:
|
||||
normalized_ids = set()
|
||||
for card in offer.have_cards.all():
|
||||
if card.rarity:
|
||||
normalized_ids.add(card.rarity.normalized_id)
|
||||
normalized_ids = {card.rarity.normalized_id for card in offer.have_cards.all() if card.rarity}
|
||||
for norm in normalized_ids:
|
||||
grouped[norm].append(offer)
|
||||
|
||||
# Map each normalized rarity id to a representative icon
|
||||
norm_ids_available = list(grouped.keys())
|
||||
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}
|
||||
|
||||
# Order groups by descending normalized rarity id
|
||||
for norm in sorted(grouped.keys(), reverse=True):
|
||||
offers = grouped[norm]
|
||||
icon_label = rarity_map.get(norm)
|
||||
if icon_label:
|
||||
featured[icon_label] = offers[:5]
|
||||
|
||||
context["featured_offers"] = featured
|
||||
|
||||
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
40
pyproject.toml
Normal 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",
|
||||
]
|
||||
|
|
@ -24,5 +24,3 @@ docker compose exec web bash -c "python manage.py loaddata seed/0*"
|
|||
|
||||
echo "Seeding default friend codes..."
|
||||
docker compose exec web bash -c "python manage.py seed_default_friend_codes"
|
||||
|
||||
./entrypoint.sh
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"pk": 1,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "c4ca4238",
|
||||
"hash": "c4ca4238z",
|
||||
"initiated_by": 3,
|
||||
"created_at": "2025-03-07T00:21:33.089Z",
|
||||
"updated_at": "2025-03-07T00:21:33.089Z"
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
"pk": 2,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "c81e728d",
|
||||
"hash": "c81e728dz",
|
||||
"initiated_by": 4,
|
||||
"created_at": "2025-03-07T00:24:21.664Z",
|
||||
"updated_at": "2025-03-07T00:24:21.664Z"
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"pk": 3,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "eccbc87e",
|
||||
"hash": "eccbc87ez",
|
||||
"initiated_by": 3,
|
||||
"created_at": "2025-03-07T00:27:36.345Z",
|
||||
"updated_at": "2025-03-07T00:27:36.345Z"
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
"pk": 4,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "a87ff679",
|
||||
"hash": "a87ff679z",
|
||||
"initiated_by": 4,
|
||||
"created_at": "2025-03-07T00:28:57.655Z",
|
||||
"updated_at": "2025-03-07T00:28:57.655Z"
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"pk": 5,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "e4da3b7f",
|
||||
"hash": "e4da3b7fz",
|
||||
"initiated_by": 4,
|
||||
"created_at": "2025-03-07T00:30:53.491Z",
|
||||
"updated_at": "2025-03-07T00:30:53.491Z"
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
"pk": 6,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "1679091c",
|
||||
"hash": "1679091cz",
|
||||
"initiated_by": 1,
|
||||
"created_at": "2025-03-07T00:21:33.089Z",
|
||||
"updated_at": "2025-03-07T00:21:33.089Z"
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
"pk": 7,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "8f14e45f",
|
||||
"hash": "8f14e45fz",
|
||||
"initiated_by": 2,
|
||||
"created_at": "2025-03-07T00:24:21.664Z",
|
||||
"updated_at": "2025-03-07T00:24:21.664Z"
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
"pk": 8,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "c9f0f895",
|
||||
"hash": "c9f0f895z",
|
||||
"initiated_by": 1,
|
||||
"created_at": "2025-03-07T00:27:36.345Z",
|
||||
"updated_at": "2025-03-07T00:27:36.345Z"
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"pk": 9,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "45c48cce",
|
||||
"hash": "45c48ccez",
|
||||
"initiated_by": 2,
|
||||
"created_at": "2025-03-07T00:28:57.655Z",
|
||||
"updated_at": "2025-03-07T00:28:57.655Z"
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
"pk": 10,
|
||||
"fields": {
|
||||
"manually_closed": false,
|
||||
"hash": "d3d94468",
|
||||
"hash": "d3d94468z",
|
||||
"initiated_by": 1,
|
||||
"created_at": "2025-03-07T00:30:53.491Z",
|
||||
"updated_at": "2025-03-07T00:30:53.491Z"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 113,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 479,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 206,
|
||||
"quantity": 8
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 414,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 329,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 395,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 42,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 165,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 65,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -95,7 +95,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 309,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 219,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 413,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 173,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 469,
|
||||
"quantity": 8
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -149,7 +149,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 394,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -158,7 +158,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 437,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -167,7 +167,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 384,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 305,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 13,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -194,7 +194,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 177,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 76,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -230,7 +230,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 4,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -239,7 +239,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 471,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -248,7 +248,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 379,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -257,7 +257,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 104,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -275,7 +275,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 529,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -284,7 +284,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 540,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 239,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -302,7 +302,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 248,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -311,7 +311,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 355,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -320,7 +320,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 115,
|
||||
"quantity": 8
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -329,7 +329,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 502,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -338,7 +338,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 517,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -347,7 +347,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 105,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -365,7 +365,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 442,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -374,7 +374,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 287,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -383,7 +383,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 194,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -401,7 +401,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 321,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -410,7 +410,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 34,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -419,7 +419,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 524,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -428,7 +428,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 108,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -437,7 +437,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 30,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -446,7 +446,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 431,
|
||||
"quantity": 8
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -455,7 +455,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 150,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -473,7 +473,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 117,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -482,7 +482,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 40,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -491,7 +491,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 486,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -500,7 +500,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 481,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -509,7 +509,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 425,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -518,7 +518,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 300,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -536,7 +536,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 41,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -545,7 +545,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 84,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -554,7 +554,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 36,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -563,7 +563,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 482,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -572,7 +572,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 401,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -590,7 +590,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 549,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -599,7 +599,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 227,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -608,7 +608,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 530,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -617,7 +617,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 359,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -626,7 +626,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 238,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 115,
|
||||
"quantity": 8
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 502,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 517,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 105,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 442,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 287,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 1,
|
||||
"card": 194,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 321,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -95,7 +95,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 34,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 524,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 108,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 30,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 431,
|
||||
"quantity": 8
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -140,7 +140,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 2,
|
||||
"card": 150,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -158,7 +158,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 117,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -167,7 +167,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 40,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 486,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 481,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -194,7 +194,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 425,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -203,7 +203,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 3,
|
||||
"card": 300,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 41,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -230,7 +230,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 84,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -239,7 +239,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 36,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -248,7 +248,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 482,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -257,7 +257,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 4,
|
||||
"card": 401,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -275,7 +275,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 549,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -284,7 +284,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 227,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -293,7 +293,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 530,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -302,7 +302,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 359,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -311,7 +311,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 5,
|
||||
"card": 238,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -320,7 +320,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 113,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -329,7 +329,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 479,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -338,7 +338,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 206,
|
||||
"quantity": 8
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -347,7 +347,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 414,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -356,7 +356,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 329,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -365,7 +365,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 395,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -374,7 +374,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 6,
|
||||
"card": 42,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -392,7 +392,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 165,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -401,7 +401,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 65,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -410,7 +410,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 309,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -419,7 +419,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 219,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -428,7 +428,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 413,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -437,7 +437,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 173,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -446,7 +446,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 7,
|
||||
"card": 469,
|
||||
"quantity": 8
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -464,7 +464,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 394,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -473,7 +473,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 437,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -482,7 +482,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 384,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -491,7 +491,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 305,
|
||||
"quantity": 7
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -500,7 +500,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 13,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -509,7 +509,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 8,
|
||||
"card": 177,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -536,7 +536,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 76,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -545,7 +545,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 4,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -554,7 +554,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 471,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -563,7 +563,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 379,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -572,7 +572,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 9,
|
||||
"card": 104,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -590,7 +590,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 529,
|
||||
"quantity": 2
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -599,7 +599,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 540,
|
||||
"quantity": 4
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -608,7 +608,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 239,
|
||||
"quantity": 3
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -617,7 +617,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 248,
|
||||
"quantity": 6
|
||||
"quantity": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -626,7 +626,7 @@
|
|||
"fields": {
|
||||
"trade_offer": 10,
|
||||
"card": 355,
|
||||
"quantity": 5
|
||||
"quantity": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -107,32 +107,3 @@ button.select2-selection__choice__remove {
|
|||
background-color: var(--bs-gray-300); /* Color for the separator */
|
||||
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
123
theme/static/js/tooltip.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags i18n widget_tweaks %}
|
||||
{% load crispy_forms_tags i18n %}
|
||||
|
||||
{% block head_title %}{% trans "Log In" %}{% endblock %}
|
||||
|
||||
|
|
@ -11,12 +11,12 @@
|
|||
{{ form.non_field_errors }}
|
||||
<div>
|
||||
<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 }}
|
||||
</div>
|
||||
<div>
|
||||
<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 }}
|
||||
</div>
|
||||
{% if form.remember %}
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
<button type="submit" class="btn btn-primary w-full">{% trans "Log In" %}</button>
|
||||
</form>
|
||||
<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>
|
||||
{% endblock %}
|
||||
23
theme/templates/account/settings.html
Normal file
23
theme/templates/account/settings.html
Normal 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 %}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load i18n widget_tweaks %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}{% trans "Sign Up" %}{% endblock %}
|
||||
|
||||
|
|
@ -11,27 +11,27 @@
|
|||
{{ form.non_field_errors }}
|
||||
<div>
|
||||
<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 }}
|
||||
</div>
|
||||
<div>
|
||||
<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 }}
|
||||
</div>
|
||||
<div>
|
||||
<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 }}
|
||||
</div>
|
||||
<div>
|
||||
<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 }}
|
||||
</div>
|
||||
<div>
|
||||
<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 }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full">{% trans "Sign Up" %}</button>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
<!-- Tailwind CSS and Base stylesheet -->
|
||||
{% tailwind_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 javascript_head %}{% endblock %}
|
||||
|
|
@ -139,23 +143,20 @@
|
|||
</footer>
|
||||
|
||||
<!-- Dock -->
|
||||
<div class="dock bg-neutral text-neutral-content sm:hidden">
|
||||
<button>
|
||||
<div x-data class="dock bg-neutral text-neutral-content">
|
||||
<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>
|
||||
<span class="dock-label">Home</span>
|
||||
</button>
|
||||
|
||||
<button class="dock-active">
|
||||
<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">Trades</span>
|
||||
<button @click="window.location.href = '{% url 'trade_offer_list' %}'" class="{% if '/trades/all/' 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.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" /></svg>
|
||||
<span class="dock-label">All Offers</span>
|
||||
</button>
|
||||
|
||||
<button>
|
||||
<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>
|
||||
<span class="dock-label">Notifications</span>
|
||||
<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>
|
||||
<span class="dock-label">My Trades</span>
|
||||
</button>
|
||||
|
||||
<button>
|
||||
<button @click="window.location.href = '{% url 'settings' %}'" class="{% if '/settings/' in request.path %}dock-active{% endif %}">
|
||||
{% if user.is_authenticated %}<div tabindex="0" role="button" class="avatar"><div class="w-6 rounded-full">{{ user.email|gravatar:40 }}</div></div>{% else %}<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></circle><path d="m22,13.25v-2.5l-2.318-.966c-.167-.581-.395-1.135-.682-1.654l.954-2.318-1.768-1.768-2.318.954c-.518-.287-1.073-.515-1.654-.682l-.966-2.318h-2.5l-.966,2.318c-.581.167-1.135.395-1.654.682l-2.318-.954-1.768,1.768.954,2.318c-.287.518-.515,1.073-.682,1.654l-2.318.966v2.5l2.318.966c.167.581.395,1.135.682,1.654l-.954,2.318,1.768,1.768,2.318-.954c.518.287,1.073.515,1.654.682l.966,2.318h2.5l.966-2.318c.581-.167,1.135-.395,1.654-.682l2.318.954,1.768-1.768-.954-2.318c.287-.518.515-1.073.682-1.654l2.318-.966Z" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path></g></svg>{% endif %}
|
||||
<span class="dock-label">Settings</span>
|
||||
</button>
|
||||
|
|
@ -167,6 +168,8 @@
|
|||
<!-- Alpine Core -->
|
||||
<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>
|
||||
{% block javascript %}{% endblock %}
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@
|
|||
- cards: a list of card objects
|
||||
- mode: a string that determines the render style.
|
||||
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 %}
|
||||
{% 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 %}
|
||||
{% if show_zero|default:False or card.offer_count > 0 %}
|
||||
{% if mode == "offered" %}
|
||||
<a href="?offered_cards={{ card.id }}"
|
||||
{% else %}
|
||||
|
|
@ -18,7 +19,6 @@
|
|||
class="flex justify-between items-center text-primary no-underline">
|
||||
{% card_badge card card.offer_count %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
|||
|
|
@ -16,41 +16,36 @@
|
|||
|
||||
<!-- Search Form Section -->
|
||||
<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 %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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>
|
||||
{% 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>
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<button type="submit" class="btn btn-primary flex-1">Find a Trade Offer</button>
|
||||
<a href="{% url 'trade_offer_create' %}" id="createTradeOfferBtn" class="btn btn-secondary flex-1 text-center">Create Trade Offer</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary w-full">Find a Trade Offer</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary flex-1">
|
||||
Find a Trade Offer
|
||||
</button>
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'trade_offer_create' %}" id="createTradeOfferBtn" class="btn btn-secondary flex-1 text-center">
|
||||
Create Trade Offer
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Search Results Section -->
|
||||
<section id="search-results" class="mb-8">
|
||||
{% include "home/_search_results.html" %}
|
||||
</section>
|
||||
|
||||
<!-- Market Stats Section -->
|
||||
<section aria-labelledby="stats-heading" class="mb-8">
|
||||
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Market Stats</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<!-- Most Offered Cards -->
|
||||
<div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<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 Offered Cards</h5>
|
||||
</div>
|
||||
|
|
@ -63,7 +58,7 @@
|
|||
</div>
|
||||
<!-- Most Wanted Cards -->
|
||||
<div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<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>
|
||||
|
|
@ -74,15 +69,15 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Least Offered Cards -->
|
||||
<div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<!-- 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" show_zero=True %}
|
||||
{% include "home/_card_list.html" with cards=least_offered_cards mode="wanted" %}
|
||||
{% endcache %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -96,35 +91,13 @@
|
|||
<!-- Featured Offers -->
|
||||
<div>
|
||||
{% cache 86400 featured_offers %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-header text-base-content p-4">
|
||||
<div class="p-4">
|
||||
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<!-- New pure-CSS tabs for Featured Offers -->
|
||||
<div class="featured-offers-tabs">
|
||||
<!-- 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">
|
||||
<div class="p-4">
|
||||
<!-- Tab contents -->
|
||||
<div id="featured-tab-contents">
|
||||
<div class="tab-content" data-tab="featured-all">
|
||||
{% if featured_offers.All %}
|
||||
<div class="flex flex-col items-center gap-3 w-auto mx-auto">
|
||||
{% for offer in featured_offers.All %}
|
||||
|
|
@ -135,11 +108,9 @@
|
|||
<p class="text-center">No featured offers available.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Panels for each additional rarity -->
|
||||
{% for rarity, offers in featured_offers.items %}
|
||||
{% 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 %}
|
||||
<div class="flex flex-col items-center gap-3 w-auto mx-auto">
|
||||
{% for offer in offers %}
|
||||
|
|
@ -153,6 +124,17 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
|
@ -162,11 +144,10 @@
|
|||
<!-- Recent Offers -->
|
||||
<div>
|
||||
{% cache 60 recent_offers %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-header text-center text-base-content p-4">
|
||||
<div class="text-center text-base-content p-4">
|
||||
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5>
|
||||
</div>
|
||||
<div class="card-body my-4 p-4">
|
||||
<div class="p-4">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
{% for offer in recent_offers %}
|
||||
{% render_trade_offer offer %}
|
||||
|
|
@ -175,125 +156,15 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endcache %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% 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 %}
|
||||
<script defer>
|
||||
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.
|
||||
const createBtn = document.getElementById('createTradeOfferBtn');
|
||||
if (createBtn) {
|
||||
|
|
@ -326,6 +197,49 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
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>
|
||||
{% 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 %}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
{% load trade_offer_tags %}
|
||||
{% 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>
|
||||
{% if search_results and search_results.object_list %}
|
||||
{% if search_results %}
|
||||
{% include "trades/_trade_offer_list.html" with offers=search_results %}
|
||||
{% else %}
|
||||
<div class="alert alert-info mt-4">No trade offers found.</div>
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
{% load trade_offer_tags %}
|
||||
{% comment %}
|
||||
This snippet renders a grid of trade offer cards along with pagination controls,
|
||||
using the trade_offer templatetag (i.e. {% render_trade_offer offer %}).
|
||||
|
||||
It expects a context variable:
|
||||
- offers: an iterable or a paginated page of TradeOffer objects.
|
||||
This snippet renders a grid of trade offer cards (or acceptance cards) along with pagination controls.
|
||||
For a TradeOffer, we use {% render_trade_offer %}; for a TradeAcceptance, {% render_trade_acceptance %}.
|
||||
{% endcomment %}
|
||||
|
||||
<div class="flex flex-row gap-4 flex-wrap justify-center items-start">
|
||||
{% for offer in offers %}
|
||||
<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 %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div>No trade offers available.</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,70 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load trade_offer_tags %}
|
||||
|
||||
{% block title %}Update Trade Acceptance{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto max-w-xl mt-6">
|
||||
<h2 class="text-2xl font-bold">Update Trade Acceptance</h2>
|
||||
<form method="post" novalidate>
|
||||
<div class="text-center py-8">
|
||||
<ul class="steps">
|
||||
{% if object.is_thanked %}
|
||||
<li class="step step-primary">Accepted</li>
|
||||
<li class="step step-primary">Card Sent</li>
|
||||
<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 %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
<input type="hidden" name="state" value="{{ state_value }}">
|
||||
<button type="submit" class="btn btn-primary w-full">{{ state_label }}</button>
|
||||
</form>
|
||||
{% if form.errors %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No available actions.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if form and form.errors %}
|
||||
<div class="alert alert-error mt-4">
|
||||
<strong>Please correct the errors below:</strong>
|
||||
<ul>
|
||||
|
|
@ -25,8 +79,11 @@
|
|||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
{% endblock content %}
|
||||
|
|
@ -9,10 +9,10 @@
|
|||
<form method="post" novalidate class="space-y-4">
|
||||
{% 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" %}
|
||||
|
||||
<!-- 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="form-control">
|
||||
{% 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.containerInner.element.classList.add('bg-secondary', 'text-white');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<strong>Status:</strong> {% if object.is_active %}Open{% else %}Closed{% endif %}
|
||||
</p>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert {{ message.tags }}">{{ message }}</div>
|
||||
|
|
|
|||
|
|
@ -1,47 +1,14 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load trade_offer_tags %}
|
||||
|
||||
{% block title %}Trade Offer Detail{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto max-w-2xl mt-6">
|
||||
<h2 class="text-2xl font-bold">Trade Offer Details</h2>
|
||||
<div class="card bg-base-100 shadow-lg p-4">
|
||||
<p>
|
||||
<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>
|
||||
{% render_trade_offer object %}
|
||||
</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 %}
|
||||
<h3 class="text-xl font-semibold mt-6">Accept This Offer</h3>
|
||||
<div class="card p-4">
|
||||
|
|
@ -54,7 +21,6 @@
|
|||
{% endif %}
|
||||
|
||||
<div class="mt-6">
|
||||
<!-- Show delete/close button for the initiator -->
|
||||
{% if is_initiator %}
|
||||
<a href="{{ delete_close_url }}" class="btn btn-danger">Delete/Close Trade Offer</a>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@
|
|||
|
||||
{% block content %}
|
||||
<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">
|
||||
<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" %}
|
||||
|
||||
<label class="cursor-pointer flex items-center space-x-2">
|
||||
<span class="font-medium">Only Completed</span>
|
||||
<input type="checkbox" name="show_completed" value="true" class="toggle toggle-primary" {% if show_completed %}checked{% endif %}>
|
||||
<span class="font-medium">Only Closed</span>
|
||||
<input type="checkbox" name="show_closed" value="true" class="toggle toggle-primary" {% if show_closed %}checked{% endif %}>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Apply</button>
|
||||
</form>
|
||||
|
|
@ -41,9 +41,9 @@
|
|||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Section 2: Trade Acceptances Waiting For Your Response -->
|
||||
<!-- Section 2: Waiting for Your Response -->
|
||||
<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 %}
|
||||
{% include "trades/_trade_offer_list.html" with offers=trade_acceptances_waiting_paginated %}
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
|
|
@ -64,26 +64,26 @@
|
|||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Section 3: Other Trade Acceptances -->
|
||||
<!-- Section 3: Waiting for Trade Partner's Response -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold mb-4">Other Trade Acceptances</h2>
|
||||
{% if other_trade_acceptances_paginated.object_list %}
|
||||
{% include "trades/_trade_offer_list.html" with offers=other_trade_acceptances_paginated %}
|
||||
<h2 class="text-2xl font-bold mb-4">Waiting for Trade Partner's Response</h2>
|
||||
{% if other_party_trade_acceptances_paginated.object_list %}
|
||||
{% include "trades/_trade_offer_list.html" with offers=other_party_trade_acceptances_paginated %}
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
{% if other_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>
|
||||
{% 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_party_trade_acceptances_paginated.previous_page_number }}" class="btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
<span>Page {{ other_trade_acceptances_paginated.number }} of {{ other_trade_acceptances_paginated.paginator.num_pages }}</span>
|
||||
{% if other_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>
|
||||
<span>Page {{ other_party_trade_acceptances_paginated.number }} of {{ other_party_trade_acceptances_paginated.paginator.num_pages }}</span>
|
||||
{% 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_party_trade_acceptances_paginated.next_page_number }}" class="btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No other acceptances found.</p>
|
||||
<p>No pending acceptances found.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
86
theme/templates/trades/trade_offer_search.html
Normal file
86
theme/templates/trades/trade_offer_search.html
Normal 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 %}
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
{% for card in object.want_cards.all %}
|
||||
{{ card.name }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}<br>
|
||||
<strong>Current State:</strong> {{ object.get_state_display }}
|
||||
<strong>Status:</strong> {% if object.is_active %}Open{% else %}Closed{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
13
theme/templates/widgets/button_radio_select.html
Normal file
13
theme/templates/widgets/button_radio_select.html
Normal 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 %}
|
||||
|
|
@ -1,20 +1,18 @@
|
|||
<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 }};">
|
||||
{% 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 }});">
|
||||
{% 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 }});">
|
||||
{% 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 %}
|
||||
<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-start-3 col-span-2 text-right truncate self-end align-bottom font-semibold leading-none text-sm">{{ card.cardset.name }}</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">
|
||||
{{ quantity }}
|
||||
{% if is_template %}__QUANTITY__{% else %}{{ quantity }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
value="{{ card.pk }}"
|
||||
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 %}
|
||||
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-rarity="{{ card.rarity.icons }}"
|
||||
data-cardset="{{ card.cardset.name }}"
|
||||
|
|
@ -26,55 +26,53 @@ if (!window.updateGlobalCardFilters) {
|
|||
window.updateGlobalCardFilters = function() {
|
||||
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 = [];
|
||||
selects.forEach(select => {
|
||||
Array.from(select.selectedOptions).forEach(option => {
|
||||
if (option.value) {
|
||||
globalSelectedIds.push(option.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Determine the global rarity based on the first found selected option.
|
||||
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 => {
|
||||
// Update each option element in the select.
|
||||
const choicesInstance = select.choicesInstance;
|
||||
const selectedValues = choicesInstance ? choicesInstance.getValue(true) : [];
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update each option element in every select.
|
||||
selects.forEach(select => {
|
||||
select.querySelectorAll('option').forEach(function(option) {
|
||||
const cardId = option.value;
|
||||
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);
|
||||
// 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);
|
||||
|
||||
option.disabled = !(passesRarity && passesUnique);
|
||||
});
|
||||
|
||||
// Update the Choices.js dropdown display as well.
|
||||
// Update the display for the Choices.js dropdown.
|
||||
if (select.choicesInstance) {
|
||||
const dropdown = select.choicesInstance.dropdown.element;
|
||||
if (dropdown) {
|
||||
dropdown.querySelectorAll('[data-choice]').forEach(function(item) {
|
||||
const cardId = item.getAttribute('data-value');
|
||||
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 passesUnique = isSelected || !globalSelectedIds.includes(cardId);
|
||||
|
|
@ -91,6 +89,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const selectField = document.getElementById('{{ field_id }}');
|
||||
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, {
|
||||
removeItemButton: false,
|
||||
placeholderValue: placeholder,
|
||||
|
|
@ -256,10 +261,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const formElement = selectField.closest('form');
|
||||
if (formElement) {
|
||||
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');
|
||||
multiselects.forEach((select) => {
|
||||
const fieldName = select.getAttribute('name');
|
||||
select.removeAttribute('name');
|
||||
// Use the stored field name from the data-field-name attribute.
|
||||
const fieldName = select.getAttribute('data-field-name');
|
||||
|
||||
Array.from(select.selectedOptions).forEach((option) => {
|
||||
const cardId = option.value;
|
||||
const quantity = option.getAttribute('data-quantity') || '1';
|
||||
|
|
@ -267,6 +277,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = fieldName;
|
||||
hiddenInput.value = cardId + ':' + quantity;
|
||||
// Mark this element as generated by our multiselect handler.
|
||||
hiddenInput.setAttribute('data-generated', 'multiselect');
|
||||
formElement.appendChild(hiddenInput);
|
||||
});
|
||||
});
|
||||
|
|
@ -300,7 +312,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
.choices__input--cloned {
|
||||
width: 100% !important;
|
||||
}
|
||||
div.choices__list span.card-quantity-badge {
|
||||
.choices__list--dropdown span.card-quantity-badge {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
51
theme/templatetags/trade_acceptance.html
Normal file
51
theme/templatetags/trade_acceptance.html
Normal 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>
|
||||
|
|
@ -1,17 +1,26 @@
|
|||
{% 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 }">
|
||||
<!-- Flip Container with Perspective -->
|
||||
<div class="flip-container">
|
||||
<!-- Flip Inner: rotates based on 'flipped' state -->
|
||||
<div class="flip-inner grid transform transition-transform duration-700 ease-in-out"
|
||||
<!--
|
||||
The outer div now only establishes Alpine's data context.
|
||||
The dynamic height adjustment (x-init & x-effect with x-ref) has been removed.
|
||||
-->
|
||||
<div x-data="{ flipped: false, badgeExpanded: false, acceptanceExpanded: false }"
|
||||
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}">
|
||||
|
||||
<!-- Front Side: Trade Offer -->
|
||||
<div class="flip-face front col-start-1 row-start-1">
|
||||
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline block">
|
||||
<!-- Header: Has/Wants -->
|
||||
<!-- Front Face: Trade Offer -->
|
||||
<!-- Using grid placement classes (col-start-1 row-start-1) ensures both faces overlap -->
|
||||
<div class="flip-face front col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between">
|
||||
<!-- Header -->
|
||||
<div class="self-start">
|
||||
<div class="py-4 mx-2 sm:mx-4">
|
||||
<div class="grid grid-cols-3 items-center">
|
||||
<div class="flex justify-center items-center">
|
||||
|
|
@ -29,49 +38,44 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main Trade Offer Row: First row of card badges -->
|
||||
<div class="px-2 pb-2 min-h-[80px]">
|
||||
<div class="grid grid-cols-2 gap-2 items-center border-t border-gray-300">
|
||||
</div>
|
||||
<!-- Main Trade Offer Row -->
|
||||
<div class="self-start">
|
||||
<div class="px-2 pb-0">
|
||||
<div class="grid grid-cols-2 gap-2 items-center">
|
||||
<div class="flex flex-col items-center">
|
||||
{% if offer.trade_offer_have_cards.all %}
|
||||
{% with first_have=offer.trade_offer_have_cards.all.0 %}
|
||||
{% if have_cards_available %}
|
||||
{% with first_have=have_cards_available.0 %}
|
||||
{% card_badge first_have.card first_have.quantity %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
{% if offer.trade_offer_want_cards.all %}
|
||||
{% with first_want=offer.trade_offer_want_cards.all.0 %}
|
||||
{% if want_cards_available %}
|
||||
{% with first_want=want_cards_available.0 %}
|
||||
{% card_badge first_want.card first_want.quantity %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Extra Card Badges (Collapsible) -->
|
||||
<div x-show="badgeExpanded" x-collapse.duration.500ms class="px-2">
|
||||
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline block">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<!-- Extra Has Cards Column -->
|
||||
<div class="flex flex-col items-center">
|
||||
{% for th in offer.trade_offer_have_cards.all|slice:"1:" %}
|
||||
{% for th in have_cards_available|slice:"1:" %}
|
||||
{% card_badge th.card th.quantity %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- Extra Wants Cards Column -->
|
||||
<div class="flex flex-col items-center">
|
||||
{% for th in offer.trade_offer_want_cards.all|slice:"1:" %}
|
||||
{% for th in want_cards_available|slice:"1:" %}
|
||||
{% card_badge th.card th.quantity %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Extra Card Badges Expansion Toggle (shown only if extra cards exist) -->
|
||||
{% if offer.trade_offer_have_cards.all|length > 1 or offer.trade_offer_want_cards.all|length > 1 %}
|
||||
{% 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 }"
|
||||
|
|
@ -82,32 +86,33 @@
|
|||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bottom Icons on Front Side -->
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<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 }}">
|
||||
<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="w-5 h-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>
|
||||
<!-- Flip Icon at Bottom Right: flips to acceptances view (back side) -->
|
||||
<div class="cursor-pointer"
|
||||
@click="badgeExpanded = false; $nextTick(() => { flipped = true })">
|
||||
<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" stroke-width="2"
|
||||
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" />
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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 -->
|
||||
<!-- Back Face: Acceptances View -->
|
||||
<!-- Placed in the same grid cell as the front face -->
|
||||
<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="self-start">
|
||||
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline">
|
||||
<div class="py-4 mx-2 sm:mx-4">
|
||||
<div class="grid grid-cols-3 items-center">
|
||||
<div class="flex justify-center items-center">
|
||||
|
|
@ -126,16 +131,27 @@
|
|||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Acceptances Content Area -->
|
||||
<div class="px-2 pb-2 min-h-[80px]">
|
||||
<!-- Collapsed Acceptances: show only the first acceptance (if available) -->
|
||||
<div x-show="!acceptanceExpanded" class="overflow-hidden">
|
||||
{% if offer.acceptances.all|length > 0 %}
|
||||
</div>
|
||||
<div class="self-start">
|
||||
<div class="px-2 pb-0">
|
||||
<div class="overflow-hidden">
|
||||
{% if offer.acceptances.first %}
|
||||
<div class="space-y-3">
|
||||
{% for acceptance in offer.acceptances.all|slice:"0:1" %}
|
||||
<!-- Acceptance Card Pair -->
|
||||
<div class="grid grid-cols-2 gap-4 items-center border-t border-gray-300">
|
||||
{% with acceptance=offer.acceptances.first %}
|
||||
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
|
||||
data-tooltip-html='<div class="flex items-center space-x-2">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
{{ acceptance.accepted_by.user.email|gravatar:"40" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
|
||||
<span class="text-sm">State: {{ acceptance.state }}</span>
|
||||
<span class="text-sm">Acceptance ID: {{ acceptance.hash }}</span>
|
||||
</div>
|
||||
</div>'>
|
||||
<div class="grid grid-cols-2 gap-4 items-center">
|
||||
<div>
|
||||
{% card_badge acceptance.requested_card %}
|
||||
</div>
|
||||
|
|
@ -143,26 +159,27 @@
|
|||
{% 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 %}
|
||||
</a>
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Expanded Acceptances: displays all acceptances -->
|
||||
<div x-show="acceptanceExpanded" x-collapse.duration.500ms class="space-y-3">
|
||||
{% for acceptance in offer.acceptances.all %}
|
||||
<!-- Acceptance Card Pair -->
|
||||
<div class="grid grid-cols-2 gap-4 items-center border-t border-gray-300">
|
||||
{% for acceptance in offer.acceptances.all|slice:"1:" %}
|
||||
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
|
||||
data-tooltip-html='<div class="flex items-center space-x-2">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
{{ acceptance.accepted_by.user.email|gravatar:"40" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
|
||||
<span class="text-sm">State: {{ acceptance.state }}</span>
|
||||
<span class="text-sm">Acceptance ID: {{ acceptance.hash }}</span>
|
||||
</div>
|
||||
</div>'>
|
||||
<div class="grid grid-cols-2 gap-4 items-center">
|
||||
<div>
|
||||
{% card_badge acceptance.requested_card %}
|
||||
</div>
|
||||
|
|
@ -170,21 +187,10 @@
|
|||
{% 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>
|
||||
</a>
|
||||
{% 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 }"
|
||||
|
|
@ -194,59 +200,49 @@
|
|||
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" />
|
||||
</div>
|
||||
<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>
|
||||
<!-- Acceptances Header -->
|
||||
<div class="px-1 text-center ">
|
||||
<div class="px-1 text-center">
|
||||
<span class="text-sm font-semibold">
|
||||
Acceptances ({{ offer.acceptances.all|length }})
|
||||
</span>
|
||||
</div>
|
||||
<!-- Flip-Back Icon at Bottom Right: flips back to front side -->
|
||||
<div class="cursor-pointer"
|
||||
@click="acceptanceExpanded = false; $nextTick(() => { flipped = false })">
|
||||
<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" stroke-width="2"
|
||||
d="M12 20l9-8-9-8M3 12h18" />
|
||||
<div class="text-gray-500 text-sm tooltip tooltip-left" data-tip="ID: {{ offer.hash }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Hide the back face of each card side */
|
||||
.flip-face {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Explicitly set the front face to 0 rotation */
|
||||
/* The front face is unrotated by default */
|
||||
.flip-face.front {
|
||||
transform: rotateY(0);
|
||||
}
|
||||
|
||||
/* This class is toggled by AlpineJS to rotate the card container */
|
||||
/* The .rotate-y-180 class rotates the entire element by 180deg */
|
||||
.rotate-y-180 {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@ class TradesConfig(AppConfig):
|
|||
name = "trades"
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
|
|||
raise ValueError("friend_codes must be provided")
|
||||
self.fields["accepted_by"].queryset = friend_codes
|
||||
|
||||
# Update active_states to include only states that mean the acceptance is still "open".
|
||||
active_states = [
|
||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.SENT,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
]
|
||||
|
||||
# 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
|
||||
return super().clean()
|
||||
|
||||
class TradeAcceptanceUpdateForm(forms.ModelForm):
|
||||
"""
|
||||
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"]
|
||||
class ButtonRadioSelect(forms.RadioSelect):
|
||||
template_name = "widgets/button_radio_select.html"
|
||||
|
||||
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)
|
||||
instance = self.instance
|
||||
allowed_choices = []
|
||||
if instance is None:
|
||||
raise ValueError("A TradeAcceptance instance must be provided")
|
||||
self.instance = instance
|
||||
self.user = user
|
||||
|
||||
# Allowed transitions for a TradeAcceptance:
|
||||
# - From ACCEPTED:
|
||||
# • If the initiator is acting, allow SENT and REJECTED_BY_INITIATOR.
|
||||
# • 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")
|
||||
self.fields["state"].choices = instance.get_allowed_state_transitions(user)
|
||||
|
||||
class TradeOfferCreateForm(ModelForm):
|
||||
# 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")
|
||||
parsed = {}
|
||||
for item in data:
|
||||
if ':' not in item:
|
||||
# Ignore any input without a colon.
|
||||
continue
|
||||
parts = item.split(':')
|
||||
card_id = parts[0]
|
||||
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:
|
||||
raise forms.ValidationError(f"Invalid quantity provided in {item}")
|
||||
parsed[card_id] = parsed.get(card_id, 0) + quantity
|
||||
|
|
@ -179,10 +140,12 @@ class TradeOfferCreateForm(ModelForm):
|
|||
data = self.data.getlist("want_cards")
|
||||
parsed = {}
|
||||
for item in data:
|
||||
if ':' not in item:
|
||||
continue
|
||||
parts = item.split(':')
|
||||
card_id = parts[0]
|
||||
try:
|
||||
quantity = int(parts[1]) if len(parts) > 1 else 1
|
||||
quantity = int(parts[1])
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f"Invalid quantity provided in {item}")
|
||||
parsed[card_id] = parsed.get(card_id, 0) + quantity
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from django.db import migrations, models
|
||||
|
|
@ -18,10 +18,14 @@ class Migration(migrations.Migration):
|
|||
name='TradeOffer',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('manually_closed', models.BooleanField(default=False)),
|
||||
('hash', models.CharField(editable=False, max_length=8)),
|
||||
('manually_closed', models.BooleanField(db_index=True, default=False)),
|
||||
('hash', models.CharField(editable=False, max_length=9)),
|
||||
('created_at', models.DateTimeField(auto_now_add=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')),
|
||||
],
|
||||
),
|
||||
|
|
@ -29,7 +33,8 @@ class Migration(migrations.Migration):
|
|||
name='TradeAcceptance',
|
||||
fields=[
|
||||
('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)),
|
||||
('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')),
|
||||
|
|
@ -72,4 +77,8 @@ class Migration(migrations.Migration):
|
|||
name='want_cards',
|
||||
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'),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
334
trades/models.py
334
trades/models.py
|
|
@ -7,8 +7,8 @@ from accounts.models import FriendCode
|
|||
|
||||
class TradeOffer(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
manually_closed = models.BooleanField(default=False)
|
||||
hash = models.CharField(max_length=8, editable=False)
|
||||
manually_closed = models.BooleanField(default=False, db_index=True)
|
||||
hash = models.CharField(max_length=9, editable=False)
|
||||
initiated_by = models.ForeignKey(
|
||||
"accounts.FriendCode",
|
||||
on_delete=models.PROTECT,
|
||||
|
|
@ -28,6 +28,12 @@ class TradeOffer(models.Model):
|
|||
created_at = models.DateTimeField(auto_now_add=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):
|
||||
want_names = ", ".join([x.name for x in self.want_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
|
||||
super().save(*args, **kwargs)
|
||||
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"])
|
||||
|
||||
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
|
||||
def is_closed(self):
|
||||
if self.manually_closed:
|
||||
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
|
||||
active_states = [
|
||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.SENT,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
TradeAcceptance.AcceptanceState.COMPLETED
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['manually_closed']),
|
||||
]
|
||||
|
||||
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):
|
||||
"""
|
||||
Through model for TradeOffer.have_cards.
|
||||
|
|
@ -83,13 +114,14 @@ class TradeOfferHaveCard(models.Model):
|
|||
trade_offer = models.ForeignKey(
|
||||
TradeOffer,
|
||||
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)
|
||||
|
||||
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:
|
||||
unique_together = ("trade_offer", "card")
|
||||
|
|
@ -108,7 +140,7 @@ class TradeOfferWantCard(models.Model):
|
|||
quantity = models.PositiveIntegerField(default=1)
|
||||
|
||||
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:
|
||||
unique_together = ("trade_offer", "card")
|
||||
|
|
@ -118,14 +150,17 @@ class TradeAcceptance(models.Model):
|
|||
ACCEPTED = 'ACCEPTED', 'Accepted'
|
||||
SENT = 'SENT', 'Sent'
|
||||
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_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor'
|
||||
|
||||
trade_offer = models.ForeignKey(
|
||||
TradeOffer,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='acceptances'
|
||||
related_name='acceptances',
|
||||
db_index=True
|
||||
)
|
||||
accepted_by = models.ForeignKey(
|
||||
"accounts.FriendCode",
|
||||
|
|
@ -136,22 +171,115 @@ class TradeAcceptance(models.Model):
|
|||
requested_card = models.ForeignKey(
|
||||
"cards.Card",
|
||||
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)
|
||||
offered_card = models.ForeignKey(
|
||||
"cards.Card",
|
||||
on_delete=models.PROTECT,
|
||||
related_name='accepted_offered'
|
||||
related_name='accepted_offered',
|
||||
db_index=True
|
||||
)
|
||||
state = models.CharField(
|
||||
max_length=25,
|
||||
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)
|
||||
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):
|
||||
"""
|
||||
Validate that:
|
||||
|
|
@ -180,7 +308,9 @@ class TradeAcceptance(models.Model):
|
|||
self.AcceptanceState.ACCEPTED,
|
||||
self.AcceptanceState.SENT,
|
||||
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)
|
||||
|
|
@ -197,59 +327,119 @@ class TradeAcceptance(models.Model):
|
|||
if offered_count >= want_through_obj.quantity:
|
||||
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.
|
||||
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]:
|
||||
raise ValueError(f"'{new_state}' is not a valid state.")
|
||||
|
||||
# Terminal states: no further transitions allowed.
|
||||
if self.state in [
|
||||
self.AcceptanceState.COMPLETED,
|
||||
self.AcceptanceState.THANKED_BY_BOTH,
|
||||
self.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
self.AcceptanceState.REJECTED_BY_ACCEPTOR
|
||||
]:
|
||||
raise ValueError(f"No transitions allowed from the terminal state '{self.state}'.")
|
||||
|
||||
allowed_transitions = {
|
||||
self.AcceptanceState.ACCEPTED: {
|
||||
self.AcceptanceState.SENT,
|
||||
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,
|
||||
},
|
||||
}
|
||||
allowed = [x for x,y in self.get_allowed_state_transitions(user)]
|
||||
print(allowed)
|
||||
print(new_state)
|
||||
|
||||
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.")
|
||||
|
||||
self.state = new_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):
|
||||
return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "
|
||||
f"accepted_by={self.accepted_by}, "
|
||||
f"requested_card={self.requested_card}, "
|
||||
f"offered_card={self.offered_card}, state={self.state})")
|
||||
|
||||
class Meta:
|
||||
# Unique constraints have been removed because validations now allow
|
||||
# multiple active acceptances per card based on the available quantity.
|
||||
pass
|
||||
def get_allowed_state_transitions(self, user):
|
||||
"""
|
||||
Returns a list of allowed state transitions as tuples (value, display_label)
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
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 .models import TradeOffer
|
||||
from cards.models import Card
|
||||
from trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
|
||||
|
||||
def check_trade_offer_rarity(instance):
|
||||
combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all())
|
||||
|
|
@ -20,3 +21,24 @@ def validate_have_cards_rarity(sender, instance, action, **kwargs):
|
|||
def validate_want_cards_rarity(sender, instance, action, **kwargs):
|
||||
if action == "post_add":
|
||||
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()
|
||||
|
|
@ -8,47 +8,32 @@ def render_trade_offer(context, offer):
|
|||
Renders a trade offer including detailed trade acceptance information.
|
||||
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.
|
||||
acceptances = offer.acceptances.all().select_related(
|
||||
'accepted_by', 'requested_card', 'offered_card'
|
||||
)
|
||||
# Use the already prefetched acceptances.
|
||||
acceptances = offer.acceptances.all()
|
||||
have_cards_available = []
|
||||
want_cards_available = []
|
||||
|
||||
# Build grouping for the have side.
|
||||
have_acceptances_data = []
|
||||
for have in offer.trade_offer_have_cards.all():
|
||||
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)
|
||||
for card in offer.trade_offer_have_cards.all():
|
||||
if all(acc.requested_card_id != card.card_id for acc in acceptances):
|
||||
have_cards_available.append(card)
|
||||
|
||||
# Build grouping for the want side.
|
||||
want_acceptances_data = []
|
||||
for want in offer.trade_offer_want_cards.all():
|
||||
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)
|
||||
for card in offer.trade_offer_want_cards.all():
|
||||
if all(acc.offered_card_id != card.card_id for acc in acceptances):
|
||||
want_cards_available.append(card)
|
||||
|
||||
return {
|
||||
'offer': offer,
|
||||
'have_acceptances_data': have_acceptances_data,
|
||||
'want_acceptances_data': want_acceptances_data,
|
||||
'current_friend_code': current_friend_code,
|
||||
'have_cards_available': have_cards_available,
|
||||
'want_cards_available': want_cards_available,
|
||||
}
|
||||
|
||||
@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"),
|
||||
}
|
||||
341
trades/views.py
341
trades/views.py
|
|
@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, render
|
|||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.views.generic.edit import FormMixin
|
||||
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.views.decorators.http import require_http_methods
|
||||
from django.core.paginator import Paginator
|
||||
|
|
@ -14,7 +14,7 @@ from django.contrib import messages
|
|||
|
||||
from .models import TradeOffer, TradeAcceptance
|
||||
from .forms import (TradeOfferAcceptForm,
|
||||
TradeAcceptanceCreateForm, TradeAcceptanceUpdateForm, TradeOfferCreateForm)
|
||||
TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm)
|
||||
from cards.models import Card
|
||||
|
||||
class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
||||
|
|
@ -23,6 +23,11 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
|||
template_name = "trades/trade_offer_create.html"
|
||||
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):
|
||||
form = super().get_form(form_class)
|
||||
# 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):
|
||||
initial = super().get_initial()
|
||||
# Standardize parameter names: use "have_cards" and "want_cards"
|
||||
initial["have_cards"] = self.request.GET.getlist("have_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:
|
||||
initial["initiated_by"] = self.request.user.friend_codes.first().pk
|
||||
return initial
|
||||
|
||||
def get_context_data(self, **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()
|
||||
if "initiated_by" in self.request.GET:
|
||||
try:
|
||||
|
|
@ -54,7 +62,6 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
|||
return context
|
||||
|
||||
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()
|
||||
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.")
|
||||
|
|
@ -62,18 +69,46 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
|||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
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"
|
||||
|
||||
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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
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_code_param = request.GET.get("friend_code")
|
||||
if friend_code_param:
|
||||
|
|
@ -90,28 +125,33 @@ class TradeOfferListView(LoginRequiredMixin, ListView):
|
|||
context["selected_friend_code"] = selected_friend_code
|
||||
context["friend_codes"] = friend_codes
|
||||
|
||||
# ----- My Trade Offers -----
|
||||
if show_completed:
|
||||
my_trade_offers = TradeOffer.objects.filter(initiated_by=selected_friend_code).order_by("-updated_at")
|
||||
my_trade_offers = [offer for offer in my_trade_offers if offer.is_closed]
|
||||
queryset = self.get_queryset().filter(initiated_by=selected_friend_code)
|
||||
if show_closed:
|
||||
queryset = queryset.filter(is_active=False)
|
||||
else:
|
||||
my_trade_offers = TradeOffer.objects.filter(initiated_by=selected_friend_code).order_by("-updated_at")
|
||||
my_trade_offers = [offer for offer in my_trade_offers if not offer.is_closed]
|
||||
queryset = queryset.filter(is_active=True)
|
||||
|
||||
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 -----
|
||||
# Update terminal states to include the thanked and rejected 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_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)
|
||||
).order_by("-updated_at")
|
||||
|
||||
if show_completed:
|
||||
involved_acceptances = involved_acceptances.filter(state__in=terminal_states)
|
||||
if show_closed:
|
||||
involved_acceptances = involved_acceptances_qs.filter(state__in=terminal_states)
|
||||
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" -----
|
||||
waiting_acceptances = involved_acceptances.filter(
|
||||
|
|
@ -119,22 +159,20 @@ class TradeOfferListView(LoginRequiredMixin, ListView):
|
|||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
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")
|
||||
other_page = request.GET.get("other_page")
|
||||
|
||||
offers_paginator = Paginator(my_trade_offers, 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["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
|
||||
|
||||
class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
||||
|
|
@ -144,7 +182,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
|||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
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.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
|
@ -152,7 +190,9 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
|||
context = super().get_context_data(**kwargs)
|
||||
trade_offer = self.get_object()
|
||||
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_ACCEPTOR,
|
||||
]
|
||||
|
|
@ -168,68 +208,133 @@ class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
|
|||
def post(self, request, *args, **kwargs):
|
||||
trade_offer = self.get_object()
|
||||
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_ACCEPTOR,
|
||||
]
|
||||
active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states)
|
||||
|
||||
if active_acceptances.exists():
|
||||
messages.error(request, "Cannot delete or close this trade offer because there are active acceptances.")
|
||||
context = self.get_context_data(object=trade_offer)
|
||||
return self.render_to_response(context)
|
||||
else:
|
||||
if trade_offer.acceptances.count() > 0:
|
||||
# There are terminal acceptances: mark the offer as closed.
|
||||
trade_offer.manually_closed = True
|
||||
trade_offer.save(update_fields=["manually_closed"])
|
||||
messages.success(request, "Trade offer has been marked as closed.")
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
else:
|
||||
# No acceptances: proceed with deletion.
|
||||
messages.success(request, "Trade offer has been deleted.")
|
||||
return super().delete(request, *args, **kwargs)
|
||||
|
||||
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
|
||||
context_object_name = "search_results"
|
||||
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):
|
||||
qs = super().get_queryset().filter(state=TradeOffer.State.INITIATED).prefetch_related("have_cards", "want_cards").select_related("initiated_by", "accepted_by")
|
||||
offered_card = self.request.GET.get("offered_card", "").strip()
|
||||
wanted_cards = self.request.GET.getlist("wanted_cards")
|
||||
from django.db.models import F
|
||||
# For a GET request (initial load), return an empty queryset.
|
||||
if self.request.method == "GET":
|
||||
return TradeOffer.objects.none()
|
||||
|
||||
if not offered_card and not wanted_cards:
|
||||
return qs.none()
|
||||
# Parse the POST data for offered and wanted selections.
|
||||
offered_selections = self.parse_selections(self.request.POST.getlist("offered_cards"))
|
||||
wanted_selections = self.parse_selections(self.request.POST.getlist("wanted_cards"))
|
||||
|
||||
if offered_card:
|
||||
try:
|
||||
offered_card_id = int(offered_card)
|
||||
except ValueError:
|
||||
qs = qs.none()
|
||||
else:
|
||||
qs = qs.filter(have_cards__id=offered_card_id)
|
||||
# If no selections are provided, return an empty queryset.
|
||||
if not offered_selections and not wanted_selections:
|
||||
return TradeOffer.objects.none()
|
||||
|
||||
if wanted_cards:
|
||||
valid_wanted_cards = []
|
||||
for card_str in wanted_cards:
|
||||
try:
|
||||
valid_wanted_cards.append(int(card_str))
|
||||
except ValueError:
|
||||
qs = qs.none()
|
||||
break
|
||||
if valid_wanted_cards:
|
||||
qs = qs.filter(want_cards__id__in=valid_wanted_cards)
|
||||
return qs
|
||||
qs = TradeOffer.objects.filter(
|
||||
manually_closed=False,
|
||||
total_have_accepted__lt=F("total_have_quantity"),
|
||||
total_want_accepted__lt=F("total_want_quantity")
|
||||
).exclude(initiated_by__in=self.request.user.friend_codes.all())
|
||||
|
||||
# Chain filters for offered selections (i.e. the user "has" cards).
|
||||
if offered_selections:
|
||||
for card_id, qty in offered_selections:
|
||||
qs = qs.filter(
|
||||
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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["offered_card"] = self.request.GET.get("offered_card", "")
|
||||
context["wanted_cards"] = self.request.GET.getlist("wanted_cards")
|
||||
context["available_cards"] = Card.objects.order_by("name", "rarity__pk").select_related("rarity", "cardset")
|
||||
from cards.models import Card
|
||||
# Populate available_cards to re-populate the multiselects.
|
||||
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
|
||||
|
||||
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):
|
||||
"""
|
||||
Displays the details of a TradeOffer along with its active acceptances.
|
||||
|
|
@ -239,16 +344,55 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView):
|
|||
model = TradeOffer
|
||||
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):
|
||||
context = super().get_context_data(**kwargs)
|
||||
trade_offer = self.get_object()
|
||||
active_states = [
|
||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||
TradeAcceptance.AcceptanceState.SENT,
|
||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||
TradeAcceptance.AcceptanceState.COMPLETED,
|
||||
|
||||
# Define terminal (closed) acceptance states based on our new system:
|
||||
terminal_states = [
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||
]
|
||||
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()
|
||||
|
||||
# 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"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.trade_offer = get_object_or_404(TradeOffer, pk=kwargs.get("offer_pk"))
|
||||
# Disallow acceptance if the current user is the offer initiator or if the offer is closed.
|
||||
if self.trade_offer.initiated_by in request.user.friend_codes.all() or self.trade_offer.is_closed:
|
||||
self.trade_offer = self.get_trade_offer()
|
||||
if self.trade_offer.initiated_by_id in request.user.friend_codes.values_list("id", flat=True) or not self.trade_offer.is_active:
|
||||
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)
|
||||
|
||||
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):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["trade_offer"] = self.trade_offer
|
||||
kwargs["friend_codes"] = self.request.user.friend_codes.all()
|
||||
kwargs['trade_offer'] = self.trade_offer
|
||||
kwargs['friend_codes'] = self.request.user.friend_codes.all()
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
|
|
@ -301,18 +469,33 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView):
|
|||
The allowed state transitions are provided via the form.
|
||||
"""
|
||||
model = TradeAcceptance
|
||||
form_class = TradeAcceptanceUpdateForm
|
||||
form_class = TradeAcceptanceTransitionForm
|
||||
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):
|
||||
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
|
||||
|
||||
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:
|
||||
# Use the model's update_state logic.
|
||||
form.instance.update_state(form.cleaned_data["state"])
|
||||
# pass the new state and the current user to the update_state method
|
||||
form.instance.update_state(new_state, self.request.user)
|
||||
except ValueError as e:
|
||||
form.add_error("state", str(e))
|
||||
return self.form_invalid(form)
|
||||
|
|
|
|||
679
uv.lock
generated
Normal file
679
uv.lock
generated
Normal 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 },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue