diff --git a/.bash_history b/.bash_history deleted file mode 100644 index db0cb72..0000000 --- a/.bash_history +++ /dev/null @@ -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 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71a5854 --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7fbeb0a..319356c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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": [] } ] diff --git a/Dockerfile b/Dockerfile index e195bdc..51e738b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/accounts/forms.py b/accounts/forms.py index f8bdf73..2cb1037 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -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 \ No newline at end of file diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index bfc095d..a5df58d 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -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 diff --git a/accounts/urls.py b/accounts/urls.py index 6bb99fd..206a6c0 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -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//", DeleteFriendCodeView.as_view(), name="delete_friend_code"), path("friend-codes/default//", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"), + path("settings/", SettingsView.as_view(), name="settings"), ] \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py index aa1da74..b739952 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -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 @@ -110,4 +110,10 @@ class ChangeDefaultFriendCodeView(LoginRequiredMixin, View): friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user) request.user.set_default_friend_code(friend_code) messages.success(request, "Default friend code updated successfully.") - return redirect("list_friend_codes") \ No newline at end of file + return redirect("list_friend_codes") + +class SettingsView(LoginRequiredMixin, TemplateView): + """ + Display the user's settings. + """ + template_name = "account/settings.html" \ No newline at end of file diff --git a/cards/migrations/0001_initial.py b/cards/migrations/0001_initial.py index aeb030c..2acc31b 100644 --- a/cards/migrations/0001_initial.py +++ b/cards/migrations/0001_initial.py @@ -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 diff --git a/cards/templatetags/card_badge.py b/cards/templatetags/card_badge.py index 954a8ea..1dc125b 100644 --- a/cards/templatetags/card_badge.py +++ b/cards/templatetags/card_badge.py @@ -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) \ No newline at end of file diff --git a/cards/templatetags/card_multiselect.py b/cards/templatetags/card_multiselect.py index a9f4579..958fb0b 100644 --- a/cards/templatetags/card_multiselect.py +++ b/cards/templatetags/card_multiselect.py @@ -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, diff --git a/django_project/settings.py b/django_project/settings.py index 551063f..8478330 100644 --- a/django_project/settings.py +++ b/django_project/settings.py @@ -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,51 +96,43 @@ 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 "PORT": 5432, # default postgres port "OPTIONS": { - "sslmode": "require" - }, + "sslmode": "require" + }, } } # 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 diff --git a/docker-compose.yml b/docker-compose.yml index f8541e5..401a9af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/entrypoint.sh b/entrypoint.sh index 34dba49..44bbf19 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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!" \ No newline at end of file diff --git a/home/views.py b/home/views.py index 1c7c032..4b1983e 100644 --- a/home/views.py +++ b/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) - - # Use POST data if available, else fallback to GET - request_data = self.request.POST if self.request.method == "POST" else self.request.GET + 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, + ] - # --- 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) - - page_number = request_data.get("page", 1) - paginator = Paginator(qs, 6) - try: - search_results = paginator.page(page_number) - except PageNotAnInteger: - search_results = paginator.page(1) - except EmptyPage: - search_results = paginator.page(paginator.num_pages) - - context["search_results"] = search_results - - # --- Recently posted offers (latest 5, newest first) --- - context["recent_offers"] = TradeOffer.objects.order_by("-created_at").prefetch_related( - have_cards_prefetch, - "have_cards__decks", - "have_cards__rarity", - "have_cards__cardset", - want_cards_prefetch, - "want_cards__decks", - "want_cards__rarity", - "want_cards__cardset" - ).select_related( - "initiated_by__user" - )[:5] - - # --- Most offered cards --- - context["most_offered_cards"] = Card.objects.filter( - tradeofferhavecard__isnull=False - ).annotate( - offer_count=Sum("tradeofferhavecard__quantity") - ).order_by("-offer_count").select_related("rarity", "cardset").prefetch_related("decks")[:5] - - # --- Most wanted cards --- - context["most_wanted_cards"] = Card.objects.filter( - tradeofferwantcard__isnull=False - ).annotate( - offer_count=Sum("tradeofferwantcard__quantity") - ).order_by("-offer_count").select_related("rarity", "cardset").prefetch_related("decks")[:5] - - # --- Least offered cards --- - context["least_offered_cards"] = Card.objects.annotate( - offer_count=Sum("tradeofferhavecard__quantity") - ).order_by("offer_count", "?")[:5] - - # --- Featured offers grouped by rarity (using card.rarity.icon for tab names) --- - featured = {} - all_offers = list( - TradeOffer.objects.order_by("created_at").prefetch_related( - have_cards_prefetch, - "have_cards__decks", - "have_cards__rarity", - "have_cards__cardset", - want_cards_prefetch, - "want_cards__decks", - "want_cards__rarity", - "want_cards__cardset" - ).select_related( - "initiated_by__user" + .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() + ) ) ) - featured["All"] = all_offers[:5] + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Add available_cards QuerySet so card_multiselect works properly. + context["available_cards"] = Card.objects.all() \ + .order_by("name", "rarity__pk") \ + .select_related("rarity", "cardset") \ + .prefetch_related("decks") - # Group offers by normalized rarity id from their have_cards + # Reuse base trade offer queryset for market stats + base_offer_qs = self.get_base_trade_offer_queryset().filter(manually_closed=False, is_active=True) + + # Recent Offers + recent_offers_qs = base_offer_qs.order_by("-created_at")[:10] + context["recent_offers"] = list(recent_offers_qs)[:5] + + # Most Offered Cards + context["most_offered_cards"] = ( + Card.objects.filter(tradeofferhavecard__isnull=False) + .annotate(offer_count=Sum("tradeofferhavecard__quantity")) + .order_by("-offer_count") + .select_related("rarity", "cardset") + .prefetch_related("decks")[:5] + ) + + # Most Wanted Cards + context["most_wanted_cards"] = ( + Card.objects.filter(tradeofferwantcard__isnull=False) + .annotate(offer_count=Sum("tradeofferwantcard__quantity")) + .order_by("-offer_count") + .select_related("rarity", "cardset") + .prefetch_related("decks")[:5] + ) + + # Least Offered Cards + context["least_offered_cards"] = ( + Card.objects.annotate(offer_count=Sum("tradeofferhavecard__quantity")) + .order_by("offer_count", "?")[:5] + ) + + # Featured Offers grouped by rarity + all_offers = base_offer_qs.order_by("created_at") + featured = {} + featured["All"] = all_offers[:5] grouped = defaultdict(list) 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) \ No newline at end of file + return context \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..31457a6 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/reset-db_make-migrations_seed-data.sh b/reset-db_make-migrations_seed-data.sh index 56cb74c..4af0594 100755 --- a/reset-db_make-migrations_seed-data.sh +++ b/reset-db_make-migrations_seed-data.sh @@ -23,6 +23,4 @@ echo "Loading seed data..." 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 \ No newline at end of file +docker compose exec web bash -c "python manage.py seed_default_friend_codes" \ No newline at end of file diff --git a/seed/0007_TestTradeOffers.json b/seed/0007_TestTradeOffers.json index c88152b..4b7c1f5 100644 --- a/seed/0007_TestTradeOffers.json +++ b/seed/0007_TestTradeOffers.json @@ -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" diff --git a/seed/0008_TestOfferWantCard.json b/seed/0008_TestOfferWantCard.json index b453f0d..9ee1c92 100644 --- a/seed/0008_TestOfferWantCard.json +++ b/seed/0008_TestOfferWantCard.json @@ -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 } } ] diff --git a/seed/0009_TestOfferHaveCard.json b/seed/0009_TestOfferHaveCard.json index 422df6d..05fa25d 100644 --- a/seed/0009_TestOfferHaveCard.json +++ b/seed/0009_TestOfferHaveCard.json @@ -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 } } ] diff --git a/static/css/base.css b/static/css/base.css index b57274c..5ec6223 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -106,33 +106,4 @@ button.select2-selection__choice__remove { width: 1px; /* The thickness of the separator */ 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; } \ No newline at end of file diff --git a/theme/static/js/tooltip.js b/theme/static/js/tooltip.js new file mode 100644 index 0000000..f4903e6 --- /dev/null +++ b/theme/static/js/tooltip.js @@ -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: + * + */ + +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 = '
' + tooltipContent + '
'; + + // 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); + }); +}); \ No newline at end of file diff --git a/theme/templates/account/login.html b/theme/templates/account/login.html index b6eb2aa..9ed7819 100644 --- a/theme/templates/account/login.html +++ b/theme/templates/account/login.html @@ -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 }}
- {{ form.login|add_class:"input input-bordered w-full" }} + {{ form.login }} {{ form.login.errors }}
- {{ form.password|add_class:"input input-bordered w-full" }} + {{ form.password }} {{ form.password.errors }}
{% if form.remember %} @@ -28,7 +28,7 @@ {% endblock %} \ No newline at end of file diff --git a/theme/templates/account/settings.html b/theme/templates/account/settings.html new file mode 100644 index 0000000..c289461 --- /dev/null +++ b/theme/templates/account/settings.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block head_title %}{% trans "Settings" %}{% endblock %} + +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/theme/templates/account/signup.html b/theme/templates/account/signup.html index c52e941..c7e525a 100644 --- a/theme/templates/account/signup.html +++ b/theme/templates/account/signup.html @@ -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 }}
- {{ form.username|add_class:"input input-bordered w-full" }} + {{ form.username }} {{ form.username.errors }}
- {{ form.email|add_class:"input input-bordered w-full" }} + {{ form.email }} {{ form.email.errors }}
- {{ form.password1|add_class:"input input-bordered w-full" }} + {{ form.password1 }} {{ form.password1.errors }}
- {{ form.password2|add_class:"input input-bordered w-full" }} + {{ form.password2 }} {{ form.password2.errors }}
- {{ form.friend_code|add_class:"input input-bordered w-full" }} + {{ form.friend_code }} {{ form.friend_code.errors }}
diff --git a/theme/templates/base.html b/theme/templates/base.html index 562cf2d..d696d6a 100644 --- a/theme/templates/base.html +++ b/theme/templates/base.html @@ -27,6 +27,10 @@ {% tailwind_css %} + + + + {% block css %}{% endblock %} {% block javascript_head %}{% endblock %} @@ -139,26 +143,23 @@ -
- - - - - - - +
+ + + +
@@ -167,6 +168,8 @@ + + {% block javascript %}{% endblock %} diff --git a/theme/templates/home/_card_list.html b/theme/templates/home/_card_list.html index b2f84ac..5d1a80c 100644 --- a/theme/templates/home/_card_list.html +++ b/theme/templates/home/_card_list.html @@ -4,21 +4,21 @@ - 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 %} -
+
{% for card in cards %} - {% if show_zero|default:False or card.offer_count > 0 %} - {% if mode == "offered" %} - - {% card_badge card card.offer_count %} - + {% if mode == "offered" %} + + {% card_badge card card.offer_count %} + {% endfor %}
{% else %} diff --git a/theme/templates/home/home.html b/theme/templates/home/home.html index 1acd465..c19a51a 100644 --- a/theme/templates/home/home.html +++ b/theme/templates/home/home.html @@ -16,115 +16,88 @@ - - -
- {% include "home/_search_results.html" %} -
- - -
-

Market Stats

-
- -
-
-
-
Most Offered Cards
+ + +
+

Market Stats

+
+ +
+
+
+
Most Offered Cards
+
+
+ {% cache 3600 most_offered_cards %} + {% include "home/_card_list.html" with cards=most_offered_cards mode="wanted" %} + {% endcache %} +
-
- {% cache 3600 most_offered_cards %} - {% include "home/_card_list.html" with cards=most_offered_cards mode="wanted" %} - {% endcache %} +
+ +
+
+
+
Most Wanted Cards
+
+
+ {% cache 3600 most_wanted_cards %} + {% include "home/_card_list.html" with cards=most_wanted_cards mode="offered" %} + {% endcache %} +
+
+
+ +
+
+
+
Least Offered Cards
+
+
+ {% cache 3600 least_offered_cards %} + {% include "home/_card_list.html" with cards=least_offered_cards mode="wanted" %} + {% endcache %} +
- -
-
-
-
Most Wanted Cards
-
-
- {% cache 3600 most_wanted_cards %} - {% include "home/_card_list.html" with cards=most_wanted_cards mode="offered" %} - {% endcache %} -
-
-
- -
-
-
-
Least Offered Cards
-
-
- {% cache 3600 least_offered_cards %} - {% include "home/_card_list.html" with cards=least_offered_cards mode="wanted" show_zero=True %} - {% endcache %} -
-
-
-
-
+
-
- {% cache 86400 featured_offers %} -
-
-
Featured Offers
-
-
- -
{% endblock content %} -{% block css %} - -{% endblock %} - {% block javascript %} +{% endblock %} + +{% block css %} + {% endblock %} \ No newline at end of file diff --git a/theme/templates/home/_search_results.html b/theme/templates/trades/_search_results.html similarity index 75% rename from theme/templates/home/_search_results.html rename to theme/templates/trades/_search_results.html index 5cf762f..5999957 100644 --- a/theme/templates/home/_search_results.html +++ b/theme/templates/trades/_search_results.html @@ -1,8 +1,8 @@ {% load trade_offer_tags %} {% if offered_cards or wanted_cards %} -
+

Results

- {% if search_results and search_results.object_list %} + {% if search_results %} {% include "trades/_trade_offer_list.html" with offers=search_results %} {% else %}
No trade offers found.
diff --git a/theme/templates/trades/_trade_offer_list.html b/theme/templates/trades/_trade_offer_list.html index 453c34a..8bfc930 100644 --- a/theme/templates/trades/_trade_offer_list.html +++ b/theme/templates/trades/_trade_offer_list.html @@ -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 %}
{% for offer in offers %} {% empty %}
No trade offers available.
diff --git a/theme/templates/trades/trade_acceptance_update.html b/theme/templates/trades/trade_acceptance_update.html index cb870a1..6c26fd0 100644 --- a/theme/templates/trades/trade_acceptance_update.html +++ b/theme/templates/trades/trade_acceptance_update.html @@ -1,16 +1,70 @@ {% extends 'base.html' %} +{% load trade_offer_tags %} {% block title %}Update Trade Acceptance{% endblock title %} {% block content %}

Update Trade Acceptance

-
- {% csrf_token %} - {{ form.as_p }} - -
- {% if form.errors %} +
+
    + {% if object.is_thanked %} +
  • Accepted
  • +
  • Card Sent
  • +
  • Card Received
  • +
  • Thanks Sent
  • +
  • Thanks Received
  • +
  • Completed
  • + {% elif object.is_rejected %} +
  • Accepted
  • +
  • + X{{ object.get_state_display }} +
  • + {% else %} +
  • Accepted
  • +
  • Card Sent
  • +
  • Card Received
  • + {% if object.state == 'THANKED_BY_INITIATOR' %} +
  • Thanked by Initiator
  • +
  • Thanked by Acceptor
  • +
  • Completed
  • + {% elif object.state == 'THANKED_BY_ACCEPTOR' %} +
  • Thanked by Acceptor
  • +
  • Thanked by Initiator
  • +
  • Completed
  • + {% elif object.state == 'THANKED_BY_BOTH' %} +
  • Thanked by Initiator
  • +
  • Thanked by Acceptor
  • +
  • Completed
  • + {% else %} +
  • Thanked by Initiator
  • +
  • Thanked by Acceptor
  • +
  • Completed
  • + {% endif %} + {% endif %} +
+
+ +
+ {% render_trade_acceptance object %} +
+ +
+

Select an action:

+ {% if form.fields.state.choices %} + {% for state_value, state_label in form.fields.state.choices %} +
+ {% csrf_token %} + + +
+ {% endfor %} + {% else %} +

No available actions.

+ {% endif %} +
+ + {% if form and form.errors %}
Please correct the errors below:
    @@ -25,8 +79,11 @@
{% endif %} +
{% endblock content %} \ No newline at end of file diff --git a/theme/templates/trades/trade_offer_create.html b/theme/templates/trades/trade_offer_create.html index 050781a..13f2687 100644 --- a/theme/templates/trades/trade_offer_create.html +++ b/theme/templates/trades/trade_offer_create.html @@ -9,10 +9,10 @@
{% 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" %} - +
{% 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'); } diff --git a/theme/templates/trades/trade_offer_delete.html b/theme/templates/trades/trade_offer_delete.html index aecec59..1511e15 100644 --- a/theme/templates/trades/trade_offer_delete.html +++ b/theme/templates/trades/trade_offer_delete.html @@ -14,6 +14,10 @@ {% endif %} +

+ Status: {% if object.is_active %}Open{% else %}Closed{% endif %} +

+ {% if messages %} {% for message in messages %}
{{ message }}
diff --git a/theme/templates/trades/trade_offer_detail.html b/theme/templates/trades/trade_offer_detail.html index 99217b3..bf69c91 100644 --- a/theme/templates/trades/trade_offer_detail.html +++ b/theme/templates/trades/trade_offer_detail.html @@ -1,47 +1,14 @@ {% extends 'base.html' %} +{% load trade_offer_tags %} {% block title %}Trade Offer Detail{% endblock title %} {% block content %}

Trade Offer Details

-
-

- Hash: {{ object.hash }}
- Initiated By: {{ object.initiated_by }}
- Cards You Have (Offer): - {% for through in object.trade_offer_have_cards.all %} - {{ through.card.name }} x{{ through.quantity }}{% if not forloop.last %}, {% endif %} - {% endfor %}
- Cards You Want: - {% for through in object.trade_offer_want_cards.all %} - {{ through.card.name }} x{{ through.quantity }}{% if not forloop.last %}, {% endif %} - {% endfor %}
- Created At: {{ object.created_at|date:"M d, Y H:i" }}
- Updated At: {{ object.updated_at|date:"M d, Y H:i" }}
- Status: {% if object.is_closed %}Closed{% else %}Open{% endif %} -

+
+ {% render_trade_offer object %}
- -

Acceptances

- {% if acceptances %} -
    - {% for acceptance in acceptances %} -
  • -

    - Accepted By: {{ acceptance.accepted_by }}
    - Requested Card: {{ acceptance.requested_card.name }}
    - Offered Card: {{ acceptance.offered_card.name }}
    - State: {{ acceptance.get_state_display }} -

    - Update -
  • - {% endfor %} -
- {% else %} -

No acceptances yet.

- {% endif %} - {% if acceptance_form %}

Accept This Offer

@@ -54,10 +21,9 @@ {% endif %}
- - {% if is_initiator %} - Delete/Close Trade Offer - {% endif %} + {% if is_initiator %} + Delete/Close Trade Offer + {% endif %} Back to Trade Offers
diff --git a/theme/templates/trades/trade_offer_list.html b/theme/templates/trades/trade_offer_list.html index cdca7bb..d3ca9ff 100644 --- a/theme/templates/trades/trade_offer_list.html +++ b/theme/templates/trades/trade_offer_list.html @@ -5,14 +5,14 @@ {% block content %}
- +
{% 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" %} @@ -41,9 +41,9 @@ {% endif %} - +
-

Trade Acceptances Waiting For Your Response

+

Waiting for Your Response

{% if trade_acceptances_waiting_paginated.object_list %} {% include "trades/_trade_offer_list.html" with offers=trade_acceptances_waiting_paginated %}
@@ -64,26 +64,26 @@ {% endif %}
- +
-

Other Trade Acceptances

- {% if other_trade_acceptances_paginated.object_list %} - {% include "trades/_trade_offer_list.html" with offers=other_trade_acceptances_paginated %} +

Waiting for Trade Partner's Response

+ {% if other_party_trade_acceptances_paginated.object_list %} + {% include "trades/_trade_offer_list.html" with offers=other_party_trade_acceptances_paginated %}
- {% if other_trade_acceptances_paginated.has_previous %} - Previous + {% if other_party_trade_acceptances_paginated.has_previous %} + Previous {% else %} {% endif %} - Page {{ other_trade_acceptances_paginated.number }} of {{ other_trade_acceptances_paginated.paginator.num_pages }} - {% if other_trade_acceptances_paginated.has_next %} - Next + Page {{ other_party_trade_acceptances_paginated.number }} of {{ other_party_trade_acceptances_paginated.paginator.num_pages }} + {% if other_party_trade_acceptances_paginated.has_next %} + Next {% else %} {% endif %}
{% else %} -

No other acceptances found.

+

No pending acceptances found.

{% endif %}
diff --git a/theme/templates/trades/trade_offer_search.html b/theme/templates/trades/trade_offer_search.html new file mode 100644 index 0000000..59c9a56 --- /dev/null +++ b/theme/templates/trades/trade_offer_search.html @@ -0,0 +1,86 @@ +{% extends 'base.html' %} +{% load static trade_offer_tags card_badge cache card_multiselect %} + +{% block content %} +

Trade Offer Search

+ + + + + +
+ {% include "trades/_search_results.html" %} +
+{% endblock content %} + +{% block javascript %} + +{% endblock %} \ No newline at end of file diff --git a/theme/templates/trades/trade_offer_update.html b/theme/templates/trades/trade_offer_update.html index ee7a43a..d0e8dd9 100644 --- a/theme/templates/trades/trade_offer_update.html +++ b/theme/templates/trades/trade_offer_update.html @@ -28,7 +28,7 @@ {% for card in object.want_cards.all %} {{ card.name }}{% if not forloop.last %}, {% endif %} {% endfor %}
- Current State: {{ object.get_state_display }} + Status: {% if object.is_active %}Open{% else %}Closed{% endif %}

diff --git a/theme/templates/widgets/button_radio_select.html b/theme/templates/widgets/button_radio_select.html new file mode 100644 index 0000000..932d76f --- /dev/null +++ b/theme/templates/widgets/button_radio_select.html @@ -0,0 +1,13 @@ +{% for group, options, index in widget.optgroups %} +
+ {% for option in options %} + + {% endfor %} +
+{% endfor %} \ No newline at end of file diff --git a/theme/templatetags/card_badge.html b/theme/templatetags/card_badge.html index 2ce76d5..d27dabf 100644 --- a/theme/templatetags/card_badge.html +++ b/theme/templatetags/card_badge.html @@ -1,20 +1,18 @@
- {% if decks|length == 1 %} + {% if num_decks == 1 %}
- {% elif decks|length == 2 %} + {% elif num_decks == 2 %}
- {% elif decks|length >= 3 %} + {% elif num_decks >= 3 %}
{% else %} -
+
{% endif %}
{{ card.name }}
{{ card.rarity.icons }}
{{ card.cardset.name }}
- {% if quantity != 1 %} - {{ quantity }} + {% if is_template %}__QUANTITY__{% else %}{{ quantity }}{% endif %} - {% endif %}
\ No newline at end of file diff --git a/theme/templatetags/card_multiselect.html b/theme/templatetags/card_multiselect.html index 6772272..b6a2d8c 100644 --- a/theme/templatetags/card_multiselect.html +++ b/theme/templatetags/card_multiselect.html @@ -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,59 +26,57 @@ 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 = []; + let globalRarity = null; selects.forEach(select => { - Array.from(select.selectedOptions).forEach(option => { - if (option.value) { - globalSelectedIds.push(option.value); + 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'); + } + } }); - // 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; - }; - + // Update each option element in every select. selects.forEach(select => { - // Update each option element in the select. select.querySelectorAll('option').forEach(function(option) { const cardId = option.value; const optionRarity = option.getAttribute('data-rarity'); - const isSelected = 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. + // Determine if the card is selected using the Choices.js API. + const isSelected = select.choicesInstance ? select.choicesInstance.getValue(true).includes(cardId) : option.selected; + + // 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); - + item.style.display = (passesRarity && passesUnique) ? '' : 'none'; }); } @@ -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; } \ No newline at end of file diff --git a/theme/templatetags/trade_acceptance.html b/theme/templatetags/trade_acceptance.html new file mode 100644 index 0000000..8e11bf7 --- /dev/null +++ b/theme/templatetags/trade_acceptance.html @@ -0,0 +1,51 @@ +{% load gravatar card_badge %} + +
+ +
+
+ +
+
+
+ {{ acceptance.trade_offer.initiated_by.user.email|gravatar:40 }} +
+
+ Has +
+ +
+ Wants +
+
+ {{ acceptance.accepted_by.user.email|gravatar:40 }} +
+
+
+
+
+ + + +
+
+
+ {% card_badge acceptance.requested_card %} +
+
+ {% card_badge acceptance.offered_card %} +
+
+
+
+ + +
+
+ + + +
+
+
\ No newline at end of file diff --git a/theme/templatetags/trade_offer.html b/theme/templatetags/trade_offer.html index 747e626..754e6f2 100644 --- a/theme/templatetags/trade_offer.html +++ b/theme/templatetags/trade_offer.html @@ -1,141 +1,185 @@ {% load gravatar card_badge %} -
- -
- -
+
+ + +
+ +
- -
- - -
-
-
- Has -
-
-
-
- {{ offer.initiated_by.user.email|gravatar:40 }} + + +
+ +
+
+
+
+ Has +
+
+
+
+ {{ offer.initiated_by.user.email|gravatar:40 }} +
-
-
- Wants +
+ Wants +
- -
-
-
- {% if offer.trade_offer_have_cards.all %} - {% with first_have=offer.trade_offer_have_cards.all.0 %} - {% card_badge first_have.card first_have.quantity %} - {% endwith %} - {% endif %} + +
+
+
+
+ {% if have_cards_available %} + {% with first_have=have_cards_available.0 %} + {% card_badge first_have.card first_have.quantity %} + {% endwith %} + {% endif %} +
+
+ {% if want_cards_available %} + {% with first_want=want_cards_available.0 %} + {% card_badge first_want.card first_want.quantity %} + {% endwith %} + {% endif %} +
-
- {% if offer.trade_offer_want_cards.all %} - {% with first_want=offer.trade_offer_want_cards.all.0 %} - {% card_badge first_want.card first_want.quantity %} - {% endwith %} - {% endif %} +
+ + +
+
+
+ {% for th in have_cards_available|slice:"1:" %} + {% card_badge th.card th.quantity %} + {% endfor %} +
+
+ {% for th in want_cards_available|slice:"1:" %} + {% card_badge th.card th.quantity %} + {% endfor %} +
+
+
+ {% if have_cards_available|length > 1 or want_cards_available|length > 1 %} +
+ + + +
+ {% endif %} +
+
+
+
+ + + +
+ +
+ + + +
- - - -
- -
- -
- {% for th in offer.trade_offer_have_cards.all|slice:"1:" %} - {% card_badge th.card th.quantity %} - {% endfor %} -
- -
- {% for th in offer.trade_offer_want_cards.all|slice:"1:" %} - {% card_badge th.card th.quantity %} - {% endfor %} +
+ + + +
+ - - - {% if offer.trade_offer_have_cards.all|length > 1 or offer.trade_offer_want_cards.all|length > 1 %} -
- - - -
- {% endif %} - - -
- -
- - - -
- -
- - - -
-
-
- - -
- - -
-
-
- Has -
-
-
-
- {{ offer.initiated_by.user.email|gravatar:40 }} -
+
- - - -
- - - - -
- {% for acceptance in offer.acceptances.all %} - -
-
- {% card_badge acceptance.requested_card %} -
-
- {% card_badge acceptance.offered_card %} -
-
- -
-
-
- {{ acceptance.accepted_by.user.email|gravatar:32 }} -
-
- {{ acceptance.accepted_by.user.username }} - {{ acceptance.state }} -
- {% endfor %} -
-
- - -
- - - -
- - -
- -
- - +
+ +
- -
+
+
+ +
+ + + +
+
Acceptances ({{ offer.acceptances.all|length }})
- -
- - +
+ +
-
- \ No newline at end of file + diff --git a/trades/apps.py b/trades/apps.py index 1577f36..5ac339c 100644 --- a/trades/apps.py +++ b/trades/apps.py @@ -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 diff --git a/trades/forms.py b/trades/forms.py index e98799a..38ac33e 100644 --- a/trades/forms.py +++ b/trades/forms.py @@ -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 = [] - - # 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") + if instance is None: + raise ValueError("A TradeAcceptance instance must be provided") + self.instance = instance + self.user = user + + 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 diff --git a/trades/migrations/0001_initial.py b/trades/migrations/0001_initial.py index b658fdd..c445ba5 100644 --- a/trades/migrations/0001_initial.py +++ b/trades/migrations/0001_initial.py @@ -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'), + ), ] diff --git a/trades/models.py b/trades/models.py index 2ae8819..7223c76 100644 --- a/trades/models.py +++ b/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] diff --git a/trades/signals.py b/trades/signals.py index c966d15..e3e66d9 100644 --- a/trades/signals.py +++ b/trades/signals.py @@ -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()) @@ -19,4 +20,25 @@ def validate_have_cards_rarity(sender, instance, action, **kwargs): @receiver(m2m_changed, sender=TradeOffer.want_cards.through) def validate_want_cards_rarity(sender, instance, action, **kwargs): if action == "post_add": - check_trade_offer_rarity(instance) \ No newline at end of file + 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() \ No newline at end of file diff --git a/trades/templatetags/trade_offer_tags.py b/trades/templatetags/trade_offer_tags.py index 626939e..89a27d6 100644 --- a/trades/templatetags/trade_offer_tags.py +++ b/trades/templatetags/trade_offer_tags.py @@ -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"), } \ No newline at end of file diff --git a/trades/views.py b/trades/views.py index 0ecea60..5c953d6 100644 --- a/trades/views.py +++ b/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") - - if not offered_card and not wanted_cards: - return qs.none() + 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 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 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 + # 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 no selections are provided, return an empty queryset. + if not offered_selections and not wanted_selections: + return TradeOffer.objects.none() + + 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) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..baf37e4 --- /dev/null +++ b/uv.lock @@ -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 }, +]