Initial working version with minor bugs

This commit is contained in:
badblocks 2025-02-26 00:06:42 -08:00
parent f946e4933a
commit 71b3993326
83 changed files with 34485 additions and 173 deletions

View file

@ -1,5 +1,5 @@
# Pull base image
FROM python:3.12.2-slim-bookworm
FROM python:3.12.2-bookworm
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
@ -23,5 +23,7 @@ COPY . /code/
# Expose port 8000
EXPOSE 8000
USER 10003:10003
# Use gunicorn on port 8000
CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "django_project.wsgi"]
CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "django_project.wsgi"]

View file

@ -1,4 +1,4 @@
# Generated by Django 5.0.2 on 2024-02-19 12:10
# Generated by Django 5.1.2 on 2025-02-26 08:04
import django.contrib.auth.models
import django.contrib.auth.validators
@ -18,92 +18,19 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='CustomUser',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
(
'last_login',
models.DateTimeField(blank=True, null=True, verbose_name='last login'),
),
(
'is_superuser',
models.BooleanField(
default=False,
help_text='Designates that this user has all permissions without explicitly assigning them.',
verbose_name='superuser status',
),
),
(
'username',
models.CharField(
error_messages={'unique': 'A user with that username already exists.'},
help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name='username',
),
),
(
'first_name',
models.CharField(blank=True, max_length=150, verbose_name='first name'),
),
(
'last_name',
models.CharField(blank=True, max_length=150, verbose_name='last name'),
),
(
'email',
models.EmailField(blank=True, max_length=254, verbose_name='email address'),
),
(
'is_staff',
models.BooleanField(
default=False,
help_text='Designates whether the user can log into this admin site.',
verbose_name='staff status',
),
),
(
'is_active',
models.BooleanField(
default=True,
help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
verbose_name='active',
),
),
(
'date_joined',
models.DateTimeField(
default=django.utils.timezone.now, verbose_name='date joined'
),
),
(
'groups',
models.ManyToManyField(
blank=True,
help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.',
related_name='user_set',
related_query_name='user',
to='auth.group',
verbose_name='groups',
),
),
(
'user_permissions',
models.ManyToManyField(
blank=True,
help_text='Specific permissions for this user.',
related_name='user_set',
related_query_name='user',
to='auth.permission',
verbose_name='user permissions',
),
),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',

View file

@ -0,0 +1,70 @@
import hashlib
from urllib.parse import urlencode
import requests # Added to perform HTTP requests
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter
def gravatar_hash(email):
"""
Returns the hash of the email.
"""
email_encoded = email.strip().lower().encode('utf-8')
email_hash = hashlib.sha256(email_encoded).hexdigest()
return email_hash
@register.filter
def gravatar_url(email, size=20):
"""
Returns the Gravatar URL for a given email. The URL includes parameters
for the default image and the size.
"""
default = "wavatar"
email_hash = gravatar_hash(email)
params = urlencode({'d': default, 's': str(size)})
return f"https://www.gravatar.com/avatar/{email_hash}?{params}"
def gravatar_profile_url(email=None):
"""
Returns the Gravatar Profile URL for a given email.
"""
if email is None:
return f"https://www.gravatar.com/profile"
email_hash = gravatar_hash(email)
return f"https://secure.gravatar.com/{email_hash}"
@register.filter
def gravatar(email, size=20):
"""
Returns an HTML image tag for the Gravatar of a given email,
with the specified width and height.
"""
url = gravatar_url(email, size)
# Return a safe HTML snippet with the image element
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar">'
return mark_safe(html)
@register.filter
def gravatar_profile_data(email):
"""
Retrieves the Gravatar profile JSON for a given email.
It fetches data from https://gravatar.com/<HASH>.json, extracts the first entry,
and returns it as a standardized dictionary for use in templates.
If the email is None or if any error occurs, returns an empty dictionary.
"""
if not email:
return {}
email_hash = gravatar_hash(email)
url = f"https://gravatar.com/{email_hash}.json"
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
# Gravatar's JSON response typically contains an "entry" list; we take the first entry.
if "entry" in data and data["entry"]:
return data["entry"][0]
return {}
except (requests.RequestException, ValueError):
return {}

9
cards/admin.py Normal file
View file

@ -0,0 +1,9 @@
from django.contrib import admin
from .models import CardSet, Deck, Card, Rarity, DeckNameTranslation, CardNameTranslation
admin.site.register(CardSet)
admin.site.register(Deck)
admin.site.register(Card)
admin.site.register(Rarity)
admin.site.register(DeckNameTranslation)
admin.site.register(CardNameTranslation)

5
cards/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CardsConfig(AppConfig):
name = "cards"

View file

@ -0,0 +1,92 @@
# Generated by Django 5.1.2 on 2025-02-26 08:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Card',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('cardnum', models.IntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='CardSet',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='Rarity',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('icons', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='CardNameTranslation',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')),
],
),
migrations.AddField(
model_name='card',
name='cardset',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cards', to='cards.cardset'),
),
migrations.CreateModel(
name='Deck',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('hex_color', models.CharField(max_length=9)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cardset', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decks', to='cards.cardset')),
],
),
migrations.AddField(
model_name='card',
name='decks',
field=models.ManyToManyField(to='cards.deck'),
),
migrations.CreateModel(
name='DeckNameTranslation',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')),
],
),
migrations.AddField(
model_name='card',
name='rarity',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cards', to='cards.rarity'),
),
]

66
cards/models.py Normal file
View file

@ -0,0 +1,66 @@
from django.db import models
class DeckNameTranslation(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
deck = models.ForeignKey("Deck", on_delete=models.PROTECT, related_name='name_translations')
language = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class CardNameTranslation(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
card = models.ForeignKey("Card", on_delete=models.PROTECT, related_name='name_translations')
language = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class CardSet(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Deck(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
hex_color = models.CharField(max_length=9)
cardset = models.ForeignKey("CardSet", on_delete=models.PROTECT, related_name='decks')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Rarity(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
icons = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Card(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
decks = models.ManyToManyField("Deck")
cardset = models.ForeignKey("CardSet", on_delete=models.PROTECT, related_name='cards')
cardnum = models.IntegerField()
rarity = models.ForeignKey(Rarity, on_delete=models.PROTECT, related_name='cards')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name + " " + self.rarity.icons + " " + self.cardset.name

View file

View file

@ -0,0 +1,11 @@
from django import template
register = template.Library()
@register.inclusion_tag("includes/card_badge.html")
def card_badge(card):
return {
'card': card,
'decks': card.decks.all() if card else None,
'dropdown': card is None
}

View file

@ -0,0 +1,48 @@
from django import template
from cards.models import Card
register = template.Library()
@register.inclusion_tag('includes/card_multiselect.html')
def card_multiselect(field_name, label, available_cards, placeholder, selected_values=None, cache_timeout=86400, cache_key="available_cards_options"):
"""
Renders a Select2 field for choosing cards.
Parameters:
- field_name: The name attribute for the select tag.
- label: Label text to show above the selector.
- available_cards: A queryset or list of card objects that will populate the options.
- placeholder: Placeholder text to show in the select.
- selected_values: (Optional) A list of selected card IDs (will be compared as strings).
- cache_timeout: (Optional) Cache timeout (in seconds) for the options block.
- cache_key: (Optional) Cache keyby default both select fields use the same key so that caching is shared.
"""
if selected_values is None:
selected_values = []
# Normalize selected_values to strings.
selected_values = [str(val) for val in selected_values]
# --- Available cards for the search form ---
available_cards = list(
Card.objects.order_by("name", "rarity__pk")
.select_related("rarity", "cardset")
.prefetch_related("decks")
)
for card in available_cards:
if card.decks.count() == 1:
card.style = "background-color: " + card.decks.all()[0].hex_color + "; color: white;"
elif card.decks.count() == 2:
card.style = "background: linear-gradient(to right, " + card.decks.all()[0].hex_color + ", " + card.decks.all()[1].hex_color + "); color: white;"
elif card.decks.count() >= 3:
card.style = "background: linear-gradient(to right, " + card.decks.all()[0].hex_color + ", " + card.decks.all()[1].hex_color + ", " + card.decks.all()[2].hex_color + "); color: white;"
return {
'field_name': field_name,
'field_id': field_name, # using the name as id for simplicity
'label': label,
'available_cards': available_cards,
'placeholder': placeholder,
'selected_values': selected_values,
'cache_timeout': cache_timeout,
'cache_key': cache_key,
}

5
cards/urls.py Normal file
View file

@ -0,0 +1,5 @@
from django.urls import path
urlpatterns = [
]

4
cards/views.py Normal file
View file

@ -0,0 +1,4 @@
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.views.generic import UpdateView, DeleteView, CreateView, ListView, DetailView

View file

@ -0,0 +1,19 @@
from django.conf import settings
from django.contrib.auth import login
from accounts.models import CustomUser
from django.contrib.auth.models import User
class AutoLoginMiddleware:
"""
In development, automatically logs in as a predefined user if the request is anonymous.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Only perform auto-login if in DEBUG mode and user is not authenticated.
if settings.DEBUG and not request.user.is_authenticated:
user = CustomUser.objects.get(email='rob@badblocks.email')
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
response = self.get_response(request)
return response

View file

@ -33,12 +33,20 @@ INSTALLED_APPS = [
# Third-party
"allauth",
"allauth.account",
'allauth.socialaccount.providers.google',
"crispy_forms",
"crispy_bootstrap5",
"debug_toolbar",
"el_pagination",
"tailwind",
#"theme",
"django_browser_reload",
# Local
"accounts",
"pages",
"cards",
"home",
"trades.apps.TradesConfig",
"friend_codes"
]
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
@ -53,6 +61,8 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware", # django-allauth
"django_browser_reload.middleware.BrowserReloadMiddleware",
"django_project.middleware.AutoLoginMiddleware",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
@ -79,24 +89,24 @@ TEMPLATES = [
]
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
#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": {
# "ENGINE": "django.db.backends.postgresql",
# "NAME": "postgres",
# "USER": "postgres",
# "PASSWORD": "postgres",
# "HOST": "db", # set in docker-compose.yml
# "PORT": 5432, # default postgres port
# }
# }
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "postgres",
"USER": "postgres",
"PASSWORD": "postgres",
"HOST": "db", # set in docker-compose.yml
"PORT": 5432, # default postgres port
}
}
# Password validation
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
@ -173,7 +183,9 @@ DEFAULT_FROM_EMAIL = "root@localhost"
# 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]
# https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model
AUTH_USER_MODEL = "accounts.CustomUser"
@ -199,4 +211,70 @@ ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = "email"
ACCOUNT_EMAIL_REQUIRED = True
#ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_CHANGE_EMAIL = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "friend_code"
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
SOCIALACCOUNT_ONLY = False
# SOCIALACCOUNT_PROVIDERS = {
# "google": {
# # For each OAuth based provider, either add a ``SocialApp``
# # (``socialaccount`` app) containing the required client
# # credentials, or list them here:
# "APPS": [
# {
# "client_id": "123",
# "secret": "456",
# "key": ""
# },
# ],
# # These are provider-specific settings that can only be
# # listed here:
# "SCOPE": [
# "profile",
# "email",
# ],
# "AUTH_PARAMS": {
# "access_type": "offline",
# },
# },
# "openid_connect": {
# # Optional PKCE defaults to False, but may be required by your provider
# # Applies to all APPS.
# "OAUTH_PKCE_ENABLED": True,
# "APPS": [
# {
# "provider_id": "nintendo",
# "name": "Nintendo Account",
# "client_id": "your.service.id",
# "secret": "your.service.secret",
# "settings": {
# "server_url": "https://my.server.example.com",
# # Optional token endpoint authentication method.
# # May be one of "client_secret_basic", "client_secret_post"
# # If omitted, a method from the the server's
# # token auth methods list is used
# "token_auth_method": "client_secret_basic",
# },
# },
# ]
# }
# }
if DEBUG:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
}
}
else:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-snowflake",
}
}

View file

@ -5,7 +5,11 @@ from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("", include("pages.urls")),
path("", include("home.urls")),
path("cards/", include("cards.urls")),
path('friend_codes/', include('friend_codes.urls')),
path("trades/", include("trades.urls")),
path("__reload__/", include("django_browser_reload.urls")),
]
if settings.DEBUG:

View file

@ -3,7 +3,7 @@ services:
build: .
command: python /code/manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
- .:/code:z
ports:
- 8000:8000
depends_on:

0
friend_codes/__init__.py Normal file
View file

5
friend_codes/admin.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib import admin
from .models import FriendCode
# Register your models here.
admin.site.register(FriendCode)

6
friend_codes/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class FriendCodesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'friend_codes'

22
friend_codes/forms.py Normal file
View file

@ -0,0 +1,22 @@
from django import forms
from .models import FriendCode
class FriendCodeForm(forms.ModelForm):
class Meta:
model = FriendCode
fields = ["friend_code"]
def clean_friend_code(self):
friend_code = self.cleaned_data.get("friend_code", "").strip()
# Remove any dashes from the input so we can validate the digits only.
friend_code_clean = friend_code.replace("-", "")
# Ensure that the cleaned friend code is exactly 16 digits.
if len(friend_code_clean) != 16 or not friend_code_clean.isdigit():
raise forms.ValidationError("Friend code must be exactly 16 digits long.")
# Format the friend code with dashes: XXXX-XXXX-XXXX-XXXX.
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

View file

@ -0,0 +1,27 @@
# Generated by Django 5.1.2 on 2025-02-20 02:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='FriendCode',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('friend_code', models.CharField(max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2025-02-20 03:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('friend_codes', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='friendcode',
name='friend_code',
field=models.CharField(max_length=19),
),
]

View file

13
friend_codes/models.py Normal file
View file

@ -0,0 +1,13 @@
from django.db import models
from django.conf import settings
from accounts.models import CustomUser
class FriendCode(models.Model):
id = models.AutoField(primary_key=True)
friend_code = models.CharField(max_length=19)
user = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT, related_name='friend_codes')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.friend_code

3
friend_codes/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
friend_codes/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.urls import path
from .views import ListFriendCodesView, AddFriendCodeView, DeleteFriendCodeView
urlpatterns = [
path('', ListFriendCodesView.as_view(), name='list_friend_codes'),
path('add/', AddFriendCodeView.as_view(), name='add_friend_code'),
path('delete/<int:pk>/', DeleteFriendCodeView.as_view(), name='delete_friend_code'),
]

67
friend_codes/views.py Normal file
View file

@ -0,0 +1,67 @@
from django.shortcuts import render, redirect
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import ListView, CreateView, DeleteView
from .models import FriendCode
from .forms import FriendCodeForm
class ListFriendCodesView(LoginRequiredMixin, ListView):
"""
Display the current user's friend codes.
"""
model = FriendCode
template_name = "friend_codes/list_friend_codes.html"
context_object_name = "friend_codes"
def get_queryset(self):
# Only display friend codes that belong to the current user.
return self.request.user.friend_codes.all()
class AddFriendCodeView(LoginRequiredMixin, CreateView):
"""
Add a new friend code for the current user.
"""
model = FriendCode
form_class = FriendCodeForm
template_name = "friend_codes/add_friend_code.html"
success_url = reverse_lazy('list_friend_codes')
def form_valid(self, form):
# Set the friend code's user to the current user before saving.
form.instance.user = self.request.user
messages.success(self.request, "Friend code added successfully.")
return super().form_valid(form)
class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
"""
Remove an existing friend code.
The friend code will not be removed if it is referenced by trade offers via
either the initiated_by or accepted_by relationships.
"""
model = FriendCode
template_name = "friend_codes/confirm_delete_friend_code.html"
context_object_name = "friend_code"
success_url = reverse_lazy('list_friend_codes')
def get_queryset(self):
# Ensure the friend code belongs to the current user.
return FriendCode.objects.filter(user=self.request.user)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
# Check if there are any trade offers associated with this friend code.
if self.object.initiated_by.exists() or self.object.accepted_by.exists():
messages.error(
request,
"Cannot remove this friend code because there are existing trade offers associated with it."
)
return redirect(self.success_url)
else:
self.object.delete()
messages.success(request, "Friend code removed successfully.")
return redirect(self.success_url)

0
home/__init__.py Normal file
View file

View file

@ -1,3 +1 @@
from django.contrib import admin
# Register your models here.

5
home/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
name = "home"

View file

View file

@ -1,3 +1 @@
from django.db import models
# Create your models here.

3
home/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

7
home/urls.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from .views import HomePageView
urlpatterns = [
path("", HomePageView.as_view(), name="home"),
]

135
home/views.py Normal file
View file

@ -0,0 +1,135 @@
from collections import defaultdict
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.db.models import Count, Q
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from trades.models import TradeOffer
from cards.models import Card, CardSet
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.template.response import TemplateResponse
@method_decorator(cache_page(60), name='get') # Cache view for 60 seconds (smallest cache time in the template)
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, fallback to GET
request_data = self.request.POST if self.request.method == "POST" else self.request.GET
# --- Search form logic ---
offered_cards = request_data.getlist("offered_cards")
wanted_cards = request_data.getlist("wanted_cards")
context["offered_cards"] = offered_cards
context["wanted_cards"] = wanted_cards
search_results = None
if offered_cards or wanted_cards:
qs = TradeOffer.objects.filter(
state=TradeOffer.State.INITIATED
).prefetch_related(
"have_cards",
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
"want_cards",
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset"
).select_related(
"initiated_by__user",
"accepted_by__user"
)
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)
# Pagination: 3 results per page
page_number = request_data.get("page", 1)
paginator = Paginator(qs, 3)
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",
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
"want_cards",
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset"
).select_related(
"initiated_by__user",
"accepted_by__user"
)[:5]
# --- Most offered cards ---
context["most_offered_cards"] = Card.objects.annotate(
offer_count=Count("trade_offers_have")
).order_by("-offer_count").select_related("rarity", "cardset").prefetch_related("decks")[:5]
# --- Most wanted cards ---
context["most_wanted_cards"] = Card.objects.annotate(
offer_count=Count("trade_offers_want")
).order_by("-offer_count").select_related("rarity", "cardset").prefetch_related("decks")[:5]
# --- Featured offers grouped by cardset ---
featured = {}
all_offers = list(
TradeOffer.objects.order_by("created_at").prefetch_related(
"have_cards",
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
"want_cards",
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset"
).select_related(
"initiated_by__user",
"accepted_by__user"
)
)
featured["All"] = all_offers[:5]
grouped = defaultdict(list)
for offer in all_offers:
cardsets_in_offer = set()
for card in offer.have_cards.all():
cardsets_in_offer.add(card.cardset.name)
for card in offer.want_cards.all():
cardsets_in_offer.add(card.cardset.name)
for cs_name in cardsets_in_offer:
grouped[cs_name].append(offer)
for cs_name, offers in grouped.items():
featured[cs_name] = 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)

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class PagesConfig(AppConfig):
name = "pages"

View file

@ -1,8 +0,0 @@
from django.urls import path
from .views import HomePageView, AboutPageView
urlpatterns = [
path("", HomePageView.as_view(), name="home"),
path("about/", AboutPageView.as_view(), name="about"),
]

View file

@ -1,9 +0,0 @@
from django.views.generic import TemplateView
class HomePageView(TemplateView):
template_name = "pages/home.html"
class AboutPageView(TemplateView):
template_name = "pages/about.html"

View file

@ -2,13 +2,17 @@ asgiref==3.8.1
certifi==2022.12.7
cffi==1.15.1
charset-normalizer==3.0.1
cookiecutter==2.6.0
crispy-bootstrap5==2024.10
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-debug-toolbar==4.4.6
django-el-pagination==4.1.2
django-tailwind-4[reload]==0.1.4
gunicorn==23.0.0
idna==3.4
oauthlib==3.2.2

102
seed/0001_Rarity.json Normal file
View file

@ -0,0 +1,102 @@
[
{
"model": "cards.rarity",
"pk": 1,
"fields": {
"icons": "🔷",
"name": "Common",
"created_at": "2025-02-16T06:54:40.993Z",
"updated_at": "2025-02-16T06:54:40.993Z"
}
},
{
"model": "cards.rarity",
"pk": 2,
"fields": {
"icons": "🔷🔷",
"name": "Uncommon",
"created_at": "2025-02-16T06:54:44.213Z",
"updated_at": "2025-02-16T06:54:44.213Z"
}
},
{
"model": "cards.rarity",
"pk": 3,
"fields": {
"icons": "🔷🔷🔷",
"name": "Rare",
"created_at": "2025-02-16T06:54:47.297Z",
"updated_at": "2025-02-16T06:54:47.297Z"
}
},
{
"model": "cards.rarity",
"pk": 4,
"fields": {
"icons": "🔷🔷🔷🔷",
"name": "Double Rare",
"created_at": "2025-02-16T06:54:50.363Z",
"updated_at": "2025-02-16T06:54:50.363Z"
}
},
{
"model": "cards.rarity",
"pk": 5,
"fields": {
"icons": "⭐️",
"name": "Full Art Rare",
"created_at": "2025-02-16T06:54:59.888Z",
"updated_at": "2025-02-16T06:54:59.888Z"
}
},
{
"model": "cards.rarity",
"pk": 6,
"fields": {
"icons": "⭐️⭐️",
"name": "Super Rare",
"created_at": "2025-02-16T06:55:02.853Z",
"updated_at": "2025-02-16T06:55:02.853Z"
}
},
{
"model": "cards.rarity",
"pk": 7,
"fields": {
"icons": "🌟🌟",
"name": "Special Art Rare",
"created_at": "2025-02-16T06:55:02.853Z",
"updated_at": "2025-02-16T06:55:02.853Z"
}
},
{
"model": "cards.rarity",
"pk": 8,
"fields": {
"icons": "⭐️⭐️⭐️",
"name": "Immersive Rare",
"created_at": "2025-02-16T06:55:05.728Z",
"updated_at": "2025-02-16T06:55:05.728Z"
}
},
{
"model": "cards.rarity",
"pk": 9,
"fields": {
"icons": "👑",
"name": "Crown Rare",
"created_at": "2025-02-16T06:55:13.907Z",
"updated_at": "2025-02-16T06:55:13.907Z"
}
},
{
"model": "cards.rarity",
"pk": 10,
"fields": {
"icons": "🅿️",
"name": "Promo",
"created_at": "2025-02-16T06:55:13.907Z",
"updated_at": "2025-02-16T06:55:13.907Z"
}
}
]

38
seed/0002_CardSet.json Normal file
View file

@ -0,0 +1,38 @@
[
{
"model": "cards.cardset",
"pk": 1,
"fields": {
"name": "Promo-A",
"created_at": "2025-02-16T07:54:38.986Z",
"updated_at": "2025-02-16T07:54:38.986Z"
}
},
{
"model": "cards.cardset",
"pk": 2,
"fields": {
"name": "A1",
"created_at": "2025-02-16T07:54:04.325Z",
"updated_at": "2025-02-16T07:54:04.325Z"
}
},
{
"model": "cards.cardset",
"pk": 3,
"fields": {
"name": "A1a",
"created_at": "2025-02-16T07:54:08.471Z",
"updated_at": "2025-02-16T07:54:08.471Z"
}
},
{
"model": "cards.cardset",
"pk": 4,
"fields": {
"name": "A2",
"created_at": "2025-02-16T07:54:11.435Z",
"updated_at": "2025-02-16T07:54:11.435Z"
}
}
]

79
seed/0003_Decks.json Normal file
View file

@ -0,0 +1,79 @@
[
{
"model": "cards.deck",
"pk": 1,
"fields": {
"name": "Promo-A",
"cardset": 1,
"hex_color": "#1070EB",
"created_at": "2025-02-16T07:55:34.988Z",
"updated_at": "2025-02-16T07:55:34.988Z"
}
},
{
"model": "cards.deck",
"pk": 2,
"fields": {
"name": "Genetic Apex: Mewtwo",
"cardset": 2,
"hex_color": "#8040E0",
"created_at": "2025-02-16T07:54:57.445Z",
"updated_at": "2025-02-16T07:54:57.445Z"
}
},
{
"model": "cards.deck",
"pk": 3,
"fields": {
"name": "Genetic Apex: Charizard",
"cardset": 2,
"hex_color": "#E00202",
"created_at": "2025-02-16T07:54:52.381Z",
"updated_at": "2025-02-16T07:54:52.381Z"
}
},
{
"model": "cards.deck",
"pk": 4,
"fields": {
"name": "Genetic Apex: Pikachu",
"cardset": 2,
"hex_color": "#EB8600",
"created_at": "2025-02-16T07:55:05.097Z",
"updated_at": "2025-02-16T07:55:05.097Z"
}
},
{
"model": "cards.deck",
"pk": 5,
"fields": {
"name": "Mythical Island",
"cardset": 3,
"hex_color": "#20AA80",
"created_at": "2025-02-16T07:55:11.916Z",
"updated_at": "2025-02-16T07:55:11.916Z"
}
},
{
"model": "cards.deck",
"pk": 6,
"fields": {
"name": "Space-Time Smackdown: Dialga",
"cardset": 4,
"hex_color": "#302FD9",
"created_at": "2025-02-16T07:55:17.582Z",
"updated_at": "2025-02-16T07:55:17.582Z"
}
},
{
"model": "cards.deck",
"pk": 7,
"fields": {
"name": "Space-Time Smackdown: Palkia",
"cardset": 4,
"hex_color": "#CF36E0",
"created_at": "2025-02-16T07:55:27.503Z",
"updated_at": "2025-02-16T07:55:27.503Z"
}
}
]

7529
seed/0004_Cards.json Normal file

File diff suppressed because it is too large Load diff

38
seed/0005_TestUsers.json Normal file
View file

@ -0,0 +1,38 @@
[
{
"model": "accounts.customuser",
"pk": 1,
"fields": {
"password": "pbkdf2_sha256$870000$V4NrlZBJxsPJFIeGf2lrc3$tpkNJJtmZ9mE6i2FXE0tdk9MlL/CUmbcERLFgmp+x8s=",
"last_login": "2025-02-20T08:53:07.044Z",
"is_superuser": true,
"username": "admin",
"first_name": "",
"last_name": "",
"email": "rob@badblocks.email",
"is_staff": true,
"is_active": true,
"date_joined": "2025-02-20T08:31:16.678Z",
"groups": [],
"user_permissions": []
}
},
{
"model": "accounts.customuser",
"pk": 2,
"fields": {
"password": "pbkdf2_sha256$870000$V4NrlZBJxsPJFIeGf2lrc3$tpkNJJtmZ9mE6i2FXE0tdk9MlL/CUmbcERLFgmp+x8s=",
"last_login": "2025-02-20T09:01:59.008Z",
"is_superuser": false,
"username": "test",
"first_name": "",
"last_name": "",
"email": "nathanward2016@gmail.com",
"is_staff": false,
"is_active": true,
"date_joined": "2025-02-20T09:01:58.289Z",
"groups": [],
"user_permissions": []
}
}
]

View file

@ -0,0 +1,42 @@
[
{
"model": "friend_codes.friendcode",
"pk": 1,
"fields": {
"friend_code": "3595-6375-9151-8459",
"user": 1,
"created_at": "2025-02-20T08:57:33.268Z",
"updated_at": "2025-02-20T08:57:33.268Z"
}
},
{
"model": "friend_codes.friendcode",
"pk": 2,
"fields": {
"friend_code": "4863-0754-2764-1890",
"user": 1,
"created_at": "2025-02-20T08:58:11.912Z",
"updated_at": "2025-02-20T08:58:11.912Z"
}
},
{
"model": "friend_codes.friendcode",
"pk": 3,
"fields": {
"friend_code": "1020-0576-9371-6042",
"user": 2,
"created_at": "2025-02-20T08:57:33.268Z",
"updated_at": "2025-02-20T08:57:33.268Z"
}
},
{
"model": "friend_codes.friendcode",
"pk": 4,
"fields": {
"friend_code": "8358-5883-3807-6654",
"user": 2,
"created_at": "2025-02-20T08:58:11.912Z",
"updated_at": "2025-02-20T08:58:11.912Z"
}
}
]

View file

@ -0,0 +1,234 @@
[
{
"model": "trades.tradeoffer",
"pk": 1,
"fields": {
"hash": "c4ca4238",
"initiated_by": 1,
"accepted_by": null,
"created_at": "2025-02-26T06:26:31.024Z",
"updated_at": "2025-02-26T06:26:31.024Z",
"state": "INITIATED",
"want_cards": [
188,
312,
501
],
"have_cards": [
55,
117,
481
]
}
},
{
"model": "trades.tradeoffer",
"pk": 2,
"fields": {
"hash": "c81e728d",
"initiated_by": 2,
"accepted_by": null,
"created_at": "2025-02-26T06:29:06.154Z",
"updated_at": "2025-02-26T06:29:06.154Z",
"state": "INITIATED",
"want_cards": [
17,
417
],
"have_cards": [
91,
524
]
}
},
{
"model": "trades.tradeoffer",
"pk": 3,
"fields": {
"hash": "eccbc87e",
"initiated_by": 3,
"accepted_by": null,
"created_at": "2025-02-26T06:29:55.322Z",
"updated_at": "2025-02-26T06:50:58.181Z",
"state": "INITIATED",
"want_cards": [
275,
370
],
"have_cards": [
575
]
}
},
{
"model": "trades.tradeoffer",
"pk": 4,
"fields": {
"hash": "a87ff679",
"initiated_by": 3,
"accepted_by": null,
"created_at": "2025-02-26T06:30:45.876Z",
"updated_at": "2025-02-26T06:30:45.876Z",
"state": "INITIATED",
"want_cards": [
367,
558
],
"have_cards": [
256,
258,
559
]
}
},
{
"model": "trades.tradeoffer",
"pk": 5,
"fields": {
"hash": "e4da3b7f",
"initiated_by": 4,
"accepted_by": null,
"created_at": "2025-02-26T06:32:37.741Z",
"updated_at": "2025-02-26T06:32:37.741Z",
"state": "INITIATED",
"want_cards": [
136,
165,
224,
321,
375,
417,
489
],
"have_cards": [
15,
75,
106,
116,
200,
383,
424,
447,
485,
512
]
}
},
{
"model": "trades.tradeoffer",
"pk": 6,
"fields": {
"hash": "1679091c",
"initiated_by": 1,
"accepted_by": null,
"created_at": "2025-02-26T06:52:14.287Z",
"updated_at": "2025-02-26T06:52:14.287Z",
"state": "INITIATED",
"want_cards": [
16,
382
],
"have_cards": [
503,
517
]
}
},
{
"model": "trades.tradeoffer",
"pk": 7,
"fields": {
"hash": "8f14e45f",
"initiated_by": 2,
"accepted_by": null,
"created_at": "2025-02-26T06:53:25.694Z",
"updated_at": "2025-02-26T06:53:25.694Z",
"state": "INITIATED",
"want_cards": [
202,
375,
391
],
"have_cards": [
180,
321,
489
]
}
},
{
"model": "trades.tradeoffer",
"pk": 8,
"fields": {
"hash": "c9f0f895",
"initiated_by": 3,
"accepted_by": null,
"created_at": "2025-02-26T06:55:19.117Z",
"updated_at": "2025-02-26T06:55:19.117Z",
"state": "INITIATED",
"want_cards": [
284,
579
],
"have_cards": [
285,
578
]
}
},
{
"model": "trades.tradeoffer",
"pk": 9,
"fields": {
"hash": "45c48cce",
"initiated_by": 4,
"accepted_by": null,
"created_at": "2025-02-26T06:55:39.531Z",
"updated_at": "2025-02-26T06:55:39.531Z",
"state": "INITIATED",
"want_cards": [
507
],
"have_cards": [
115
]
}
},
{
"model": "trades.tradeoffer",
"pk": 10,
"fields": {
"hash": "d3d94468",
"initiated_by": 2,
"accepted_by": null,
"created_at": "2025-02-26T06:55:56.621Z",
"updated_at": "2025-02-26T06:55:56.621Z",
"state": "INITIATED",
"want_cards": [
136,
184
],
"have_cards": [
91
]
}
},
{
"model": "trades.tradeoffer",
"pk": 11,
"fields": {
"hash": "6512bd43",
"initiated_by": 3,
"accepted_by": null,
"created_at": "2025-02-26T07:05:16.870Z",
"updated_at": "2025-02-26T07:05:16.870Z",
"state": "INITIATED",
"want_cards": [
370
],
"have_cards": [
367
]
}
}
]

View file

@ -1,3 +1,9 @@
/* Helper classes
-------------------------------------------------- */
.min-width-fit-content {
min-width: fit-content;
}
/* Sticky footer styles
-------------------------------------------------- */
html {
@ -31,3 +37,148 @@ body {
line-height: 60px; /* Vertically center the text there */
background-color: #f5f5f5;
}
/* Trade Offer
-------------------------------------------------- */
.trade-offer-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.trade-offer-cell {
display: flex;
flex-direction: column;
}
.trade-offer-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.trade-offer-header .avatar {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
}
.trade-offer-cards {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}
.bg-trade-offer {
background: linear-gradient(to right, var(--bs-gray-400) 50%, var(--bs-white) 50%);
}
/* Card Badge
-------------------------------------------------- */
span:has(> .card-badge-grid) {
display: block;
}
.card-badge-grid {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr 1fr;
gap: 0.2rem;
padding: 0.3rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
min-width: 150px;
}
.card-badge-name {
grid-column: 1 / span 2;
grid-row: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-badge-rarity {
grid-column: 1;
grid-row: 2;
text-align: left;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1.2;
}
.card-badge-cardset {
grid-column: 2;
grid-row: 2;
text-align: right;
font-size: 0.75rem;
opacity: 0.9;
}
/* Responsive: On narrow viewports, stack the Has and Wants sections */
@media (max-width: 576px) {
.trade-offer-grid {
grid-template-columns: 1fr;
}
}
/* Fix for the remove item button */
button.select2-selection__choice__remove {
height: 100%;
}
/* Trade Offer Card Body with Vertical Separator
-------------------------------------------------- */
.trade-offer-body {
position: relative; /* Required for the absolute separator */
background-color: var(--bs-white); /* Use a single background color */
}
.trade-offer-body::before {
content: "";
position: absolute;
left: 50%; /* Centered horizontally */
transform: translateX(-50%);
top: 1rem; /* Gap from the top */
bottom: 1rem; /* Gap from the bottom */
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;
}

12142
static/css/darkly.css Normal file

File diff suppressed because it is too large Load diff

12
static/css/darkly.min.css vendored Normal file

File diff suppressed because one or more lines are too long

12115
static/css/flatly.css Normal file

File diff suppressed because it is too large Load diff

12
static/css/flatly.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,33 @@
$(document).ready(function () {
// Initialize Gravatar
Gravatar.init();
// Initialize tooltips
$('[data-bs-toggle="tooltip"]').each(function () {
new bootstrap.Tooltip(this);
});
// Initialize select2 fields
// Updated slider functionality for tab content
$("button[data-bs-toggle='tab']").on("click", function(e) {
e.preventDefault(); // Prevent default Bootstrap behavior
// Get the target pane selector from the button attribute
var targetSelector = $(this).attr("data-bs-target");
var $targetPane = $(targetSelector);
// Update active class on the nav buttons
$(this).closest("ul").find("button").removeClass("active");
$(this).addClass("active");
// Compute the offset of the target pane relative to the grid container
// Using the DOM property offsetLeft ensures any grid gap is taken into account
var offset = $targetPane[0].offsetLeft;
// Slide the grid: translate the container to align the target pane with the viewport
$("#cardsetTabsContent").css("transform", "translateX(-" + offset + "px)");
});
});

View file

@ -1,29 +1,68 @@
{% load static %}
{% load static card_badge %}
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<title>{% block title %}DjangoX{% endblock title %}</title>
<title>{% block title %}Pocket.Trade{% endblock title %}</title>
<meta name="description" content="A framework for launching new Django projects quickly.">
<meta name="author" content="">
<link rel="shortcut icon" type="image/x-icon" href="{% static 'images/favicon.ico' %}">
{% block css %}
<!-- DaisyUI (disabled for now)-->
<!-- <link href="https://cdn.jsdelivr.net/npm/daisyui@5.0.0-beta.8/daisyui.min.css" rel="stylesheet"> -->
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
{# <link rel="stylesheet" href="{% static 'css/darkly.min.css' %}"> #}
{# <link rel="stylesheet" href="{% static 'css/flatly.min.css' %}"> #}
<link rel="stylesheet" href="{% static 'css/base.css' %}">
{% block css %}
{% endblock %}
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Bootstrap JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<!-- Tailwind CSS (disabled for now)-->
<!-- <script src="https://unpkg.com/@tailwindcss/browser@4"></script> -->
<!-- DaisyUI (disabled for now)-->
<!-- <script src="https://cdn.jsdelivr.net/npm/daisyui@5.0.0-beta.8/index.min.js"></!-->
<!-- Select2 -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<!-- Gravatar -->
<script src="https://www.gravatar.com/js/hovercards/hovercards.min.js"></script>
<!-- Project JS -->
<script src="{% static 'js/base.js' %}"></script>
<script>
function formatOption(option) {
if (!option.id) return option.text;
var $option = $(option.element);
var cardName = $option.data('name');
var rarity = $option.data('rarity');
var cardset = $option.data('cardset');
var style = $option.data('style');
var $container = $(
{% card_badge None %}
);
return $container;
}
</script>
{% block javascript %}
{% endblock javascript %}
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<nav class="navbar navbar-expand-lg bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'home' %}">DjangoX</a>
<a class="navbar-brand" href="{% url 'home' %}">Pocket.Trade</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@ -33,13 +72,26 @@
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="{% url 'home' %}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'about' %}">About</a>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="tradeOffersDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Trade Offers
</a>
<ul class="dropdown-menu" aria-labelledby="tradeOffersDropdown">
<li><a class="dropdown-item" href="{% url 'trade_offer_list' %}">All Offers</a></li>
{% if user.is_authenticated %}<li><a class="dropdown-item" href="{% url 'trade_offer_list' %}?my_trades=true">My Trades</a></li>{% endif %}
</ul>
</li>
</ul>
{% if user.is_authenticated %}
<div class="mr-auto">
<ul class="navbar-nav">
<li class="nav-item"><a href="{% url 'trade_offer_create' %}" class="btn btn-primary me-2 mb-2">
Create Trade Offer
</a></li>
</ul>
</div>
<div class="mr-auto">
<div class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
@ -50,11 +102,12 @@
<li>
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item" href="{% url 'list_friend_codes' %}">My Friend Codes</a></li>
<li><a class="dropdown-item" href="{% url 'account_change_password' %}">Change password</a></li>
<li><a class="dropdown-item" href="{% url 'account_logout' %}">Sign out</a></li>
</ul>
</li>
</ul>
</div>
</div>
{% else %}
<div class="mr-auto">
@ -79,17 +132,6 @@
<span class="text-muted">Footer...</span>
</div>
</footer>
{% block javascript %}
<!-- Bootstrap JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<!-- Project JS -->
<script src="{% static 'js/base.js' %}"></script>
{% endblock javascript %}
</body>
</html>

View file

@ -0,0 +1,17 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Add Friend Code{% endblock %}
{% block content %}
<h1>Add Friend Code</h1>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit">Add Friend Code</button>
</form>
<p>
<a href="{% url 'list_friend_codes' %}">Back to Friend Codes</a>
</p>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Log in{% endblock %}
{% block content %}
<h1>Are you sure you want to delete friend code: {{ friend_code.friend_code }}?</h1>
<form method="post">
{% csrf_token %}
<button type="submit">Confirm Delete</button>
<a href="{% url 'list_friend_codes' %}">Cancel</a>
</form>
{% endblock content %}

View file

@ -0,0 +1,25 @@
{% extends '_base.html' %}
{% block title %}My Friend Codes{% endblock %}
{% block content %}
<h1>My Friend Codes</h1>
{% if friend_codes %}
<ul>
{% for code in friend_codes %}
<li>
{{ code.friend_code }}
<!-- Link to the delete confirmation page for this friend code -->
<a href="{% url 'delete_friend_code' code.id %}">Delete</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>You do not have any friend codes added yet.</p>
{% endif %}
<p>
<a href="{% url 'add_friend_code' %}">Add a New Friend Code</a>
</p>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% load trade_offer_tags %}
{% if offered_cards or wanted_cards %}
<hr class="my-5">
<h2 class="mb-4">Results</h2>
{% if search_results and search_results.object_list %}
<ul class="list-group">
{% for offer in search_results %}
<li class="list-group-item border-0">
<a href="{% url 'trade_offer_update' offer.pk %}" class="d-flex align-items-center text-decoration-none">
{% render_trade_offer offer %}
</a>
</li>
{% endfor %}
</ul>
<!-- Pagination Controls -->
<nav aria-label="Search results pagination" class="mt-4">
<ul class="pagination">
{% if search_results.has_previous %}
<li class="page-item">
<a class="page-link ajax-page-link" data-page="{{ search_results.previous_page_number }}" href="#">Previous</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
{% endif %}
{% for num in search_results.paginator.page_range %}
<li class="page-item {% if search_results.number == num %}active{% endif %}">
<a class="page-link ajax-page-link" data-page="{{ num }}" href="#">{{ num }}</a>
</li>
{% endfor %}
{% if search_results.has_next %}
<li class="page-item">
<a class="page-link ajax-page-link" data-page="{{ search_results.next_page_number }}" href="#">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
{% endif %}
</ul>
</nav>
{% else %}
<div class="alert alert-info">No trade offers found.</div>
{% endif %}
{% endif %}

239
templates/home/home.html Normal file
View file

@ -0,0 +1,239 @@
{% extends '_base.html' %}
{% load static %}
{% load trade_offer_tags card_badge %}
{% load cache %}
{% load card_multiselect %}
{% block content %}
<main class="container my-5">
<h1 class="text-center mb-5">Welcome to Pocket.Trade</h1>
<!-- Search Form Section -->
<section id="trade-search" class="mb-5">
<form method="post" action=".">
{% csrf_token %}
<div class="row">
<div class="col-md-6 mb-3">
{% card_multiselect "offered_cards" "Have:" available_cards "Select zero or more cards..." offered_cards %}
</div>
<div class="col-md-6 mb-3">
{% card_multiselect "wanted_cards" "Want:" available_cards "Select zero or more cards..." wanted_cards %}
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Find a Trade Offer</button>
</form>
</section>
<!-- Search Results Section -->
<section id="search-results">
{% include "home/_search_results.html" %}
</section>
<!-- Market Stats Section -->
<section aria-labelledby="stats-heading" class="mb-5">
<h2 id="stats-heading" class="mb-4">Market Stats</h2>
<div class="row gx-5">
<!-- Most Offered Cards (cached for 3600 seconds / 1 hour) -->
<div class="col-md-6 mb-3">
<h5 class="mb-3">Most Offered Cards</h5>
<div class="card h-100 shadow border-0">
<div class="card-body">
{% cache 3600 most_offered_cards %}
{% if most_offered_cards %}
<div class="d-flex flex-column gap-3">
{% for card in most_offered_cards %}
{% if card.offer_count > 0 %}
<a href="?wanted_cards={{ card.id }}"
class="d-flex justify-content-between align-items-center text-decoration-none text-primary">
{% card_badge card %}
<span>{{ card.offer_count }}</span>
</a>
{% endif %}
{% endfor %}
</div>
{% else %}
<p>No cards found</p>
{% endif %}
{% endcache %}
</div>
</div>
</div>
<!-- Most Wanted Cards (cached for 3600 seconds / 1 hour) -->
<div class="col-md-6 mb-3">
<h5 class="mb-3">Most Wanted Cards</h5>
<div class="card h-100 shadow border-0">
<div class="card-body">
{% cache 3600 most_wanted_cards %}
{% if most_wanted_cards %}
<div class="d-flex flex-column gap-3">
{% for card in most_wanted_cards %}
{% if card.offer_count > 0 %}
<a href="?offered_cards={{ card.id }}"
class="d-flex justify-content-between align-items-center text-decoration-none text-primary">
{% card_badge card %}
<span>{{ card.offer_count }}</span>
</a>
{% endif %}
{% endfor %}
</div>
{% else %}
<p>No cards found</p>
{% endif %}
{% endcache %}
</div>
</div>
</div>
</div>
</section>
<!-- Featured Offers and Recent Offers Section -->
<div class="row mb-5">
<!-- Featured Offers Card (cached for 86400 seconds / 1 day) -->
<div class="col-md-6 mb-3">
{% cache 86400 featured_offers %}
<div class="card h-100 border-0">
<div class="card-header border-0 bg-transparent">
<h5 class="card-title mb-0">Featured Offers</h5>
<ul class="nav nav-tabs card-header-tabs mt-3" id="cardsetTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="all-tab" data-bs-toggle="tab" data-bs-target="#all"
type="button" role="tab" aria-controls="all" aria-selected="true">All</button>
</li>
{% for cardset, offers in featured_offers.items %}
{% if cardset != "All" %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="{{ cardset|slugify }}-tab" data-bs-toggle="tab" data-bs-target="#{{ cardset|slugify }}"
type="button" role="tab" aria-controls="{{ cardset|slugify }}" aria-selected="false">
{{ cardset }}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="cardsetTabsContent">
<!-- All Offers Tab Pane -->
<div class="tab-pane fade show active" id="all" role="tabpanel" aria-labelledby="all-tab">
{% if featured_offers.All %}
<div class="d-flex flex-column gap-3">
{% for offer in featured_offers.All %}
<a href="{% url 'trade_offer_update' offer.pk %}" class="d-flex align-items-center text-decoration-none">
{% render_trade_offer offer %}
</a>
{% endfor %}
</div>
{% else %}
<p>No featured offers available.</p>
{% endif %}
</div>
<!-- Other Cardset Tab Panes -->
{% for cardset, offers in featured_offers.items %}
{% if cardset != "All" %}
<div class="tab-pane fade" id="{{ cardset|slugify }}" role="tabpanel" aria-labelledby="{{ cardset|slugify }}-tab">
{% if offers %}
<div class="d-flex flex-column gap-3">
{% for offer in offers %}
<a href="{% url 'trade_offer_update' offer.pk %}" class="d-flex align-items-center text-decoration-none">
{% render_trade_offer offer %}
</a>
{% endfor %}
</div>
{% else %}
<p>No featured offers for {{ cardset }}.</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endcache %}
</div>
<!-- Recent Offers Card (cached for 60 seconds) -->
<div class="col-md-6 mb-3">
{% cache 60 recent_offers %}
<div class="card h-100 border-0">
<div class="card-body">
<h5 class="card-title">Recent Offers</h5>
<div class="d-flex flex-column gap-3">
{% for offer in recent_offers %}
<a href="{% url 'trade_offer_update' offer.pk %}" class="text-decoration-none">
{% render_trade_offer offer %}
</a>
{% empty %}
<div>No offers available</div>
{% endfor %}
</div>
</div>
</div>
{% endcache %}
</div>
</div>
</main>
{% endblock content %}
{% block javascript %}
<!-- <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script> -->
<script>
$(document).ready(function () {
// function formatOption(option) {
// if (!option.id) return option.text;
// var $option = $(option.element);
// var cardName = $option.data('name');
// var rarity = $option.data('rarity');
// var cardset = $option.data('cardset');
// var style = $option.data('style');
// return $('<span>').text(cardName + " " + rarity + " " + cardset).attr('style', style);
// }
// $('.select2-field').select2({
// placeholder: function() {
// return $(this).data('placeholder');
// },
// templateResult: formatOption,
// templateSelection: formatOption,
// width: '100%',
// dropdownAutoWidth: true,
// allowClear: true
// });
// AJAX form submission for trade search
$("#trade-search form").on('submit', function(e) {
e.preventDefault();
$.ajax({
type: $(this).attr("method"),
url: $(this).attr("action"),
data: $(this).serialize(),
headers: { "X-Requested-With": "XMLHttpRequest" },
success: function(data) {
$("#search-results").html(data);
},
error: function() {
alert("There was an error processing your search.");
}
});
});
// AJAX pagination for search results
$(document).on('click', '.ajax-page-link', function(e){
e.preventDefault();
var page = $(this).data('page');
if($("#page").length) {
$("#page").val(page);
} else {
$("<input>").attr({
type: "hidden",
id: "page",
name: "page",
value: page
}).appendTo("#trade-search form");
}
$("#trade-search form").submit();
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% if decks|length == 1 %}
{% if dropdown %}'{% endif %}<span class="badge card-badge-grid m-1" style="{% if dropdown %}'+ style +'{% else %}background-color: {{ decks.0.hex_color }}; color: white;{% endif %}">{% if dropdown %}' + {% endif %}
{% elif decks|length == 2 %}
{% if dropdown %}'{% endif %}<span class="badge card-badge-grid m-1" style="{% if dropdown %}'+ style +'{% else %}background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }}); color: white;{% endif %}">{% if dropdown %}' + {% endif %}
{% elif decks|length >= 3 %}
{% if dropdown %}'{% endif %}<span class="badge card-badge-grid m-1" style="{% if dropdown %}'+ style +'{% else %}background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }}, {{ decks.2.hex_color }}); color: white;{% endif %}">{% if dropdown %}' + {% endif %}
{% else %}
{% if dropdown %}'{% endif %}<span class="badge card-badge-grid m-1" style="{% if dropdown %}'+ style +'{% else %}background-color: #cccccc; color: white;{% endif %}">{% if dropdown %}' + {% endif %}
{% endif %}
{% if dropdown %}'{% endif %}<span class="card-badge-name">{% if dropdown %}'+ cardName +'{% else %}{{ card.name }}{% endif %}</span>{% if dropdown %}' + {% endif %}
{% if dropdown %}'{% endif %}<span class="card-badge-rarity">{% if dropdown %}'+ rarity +'{% else %}{{ card.rarity.icons }}{% endif %}</span>{% if dropdown %}' + {% endif %}
{% if dropdown %}'{% endif %}<span class="card-badge-cardset">{% if dropdown %}'+ cardset +'{% else %}{{ card.cardset.name }}{% endif %}</span>{% if dropdown %}' + {% endif %}
{% if dropdown %}'{% endif %}</span>{% if dropdown %}'{% endif %}

View file

@ -0,0 +1,29 @@
{% load cache card_badge %}
<label for="{{ field_id }}" class="form-label">{{ label }}</label>
<select name="{{ field_name }}" id="{{ field_id }}" class="form-select select2-field" data-placeholder="{{ placeholder }}" multiple="multiple">
{% cache cache_timeout cache_key %}
<option value="" disabled="disabled">{{ placeholder }}</option>
{% for card in available_cards %}
<option value="{{ card.pk }}"
data-name="{{ card.name }}"
data-rarity="{{ card.rarity.icons }}"
data-cardset="{{ card.cardset.name }}"
data-style="{{ card.style }}"
{{ card.name }} {{ card.rarity.icons }} {{ card.cardset.name }}
</option>
{% endfor %}
{% endcache %}
</select>
<script>
$(document).ready(function () {
$('#{{ field_id }}').select2({
placeholder: $('#{{ field_id }}').data('placeholder'),
templateResult: formatOption,
templateSelection: formatOption,
width: '100%',
dropdownAutoWidth: true,
allowClear: true
});
});
</script>

View file

@ -0,0 +1,62 @@
{% load gravatar card_badge %}
<div class="card trade-offer mb-3 mx-auto shadow-lg unified-card" style="border: none;">
<div class="card-body trade-offer-body">
<!-- Header Row: Using Grid, with relative positioning for avatar placement -->
<div class="row no-gutters">
<!-- Has Side -->
<div class="col-6 position-relative" style="padding: 1rem;">
{% if offer.initiated_by and offer.initiated_by.user.email %}
<!-- Positioned to the left -->
<div class="avatar position-absolute" style="left: 1rem; top: 50%; transform: translateY(-50%);">
{{ offer.initiated_by.user.email|gravatar:40 }}
</div>
{% endif %}
<!-- Centered text remains in the normal flow -->
<div class="text-center">
<h6 class="card-subtitle text-muted mb-0">Has</h6>
</div>
</div>
<!-- Wants Side -->
<div class="col-6 position-relative" style="padding: 1rem;">
{% if offer.accepted_by and offer.accepted_by.user.email %}
<!-- Positioned to the right -->
<div class="avatar position-absolute" style="right: 1rem; top: 50%; transform: translateY(-50%);">
{{ offer.accepted_by.user.email|gravatar:40 }}
</div>
{% endif %}
<!-- Centered text remains in the normal flow -->
<div class="text-center">
<h6 class="card-subtitle text-muted mb-0">Wants</h6>
</div>
</div>
</div>
<!-- Body Row: Using Grid, no separators; badge spacing is consistent -->
<div class="row no-gutters">
<div class="col-6" style="padding: 1rem;">
<div class="trade-offer-cards d-flex flex-wrap justify-content-center gap-2">
{% if offer.have_cards.all %}
{% for card in offer.have_cards.all %}
{% card_badge card %}
{% endfor %}
{% endif %}
</div>
</div>
<div class="col-6" style="padding: 1rem;">
<div class="trade-off-offer-cards d-flex flex-wrap justify-content-center gap-2">
{% if offer.want_cards.all %}
{% for card in offer.want_cards.all %}
{% card_badge card %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
<!-- Trade ID Footer with Info Icon -->
<small class="text-muted mt-auto d-block text-end pe-2">
<i class="bi bi-info-circle-fill" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="Trade ID: {{ offer.hash }}" style="cursor: pointer;"></i>
</small>
</div>
</div>

View file

@ -1,7 +0,0 @@
{% extends '_base.html' %}
{% block title %}About page{% endblock %}
{% block content %}
<h1>About page</h1>
{% endblock content %}

View file

@ -1,11 +0,0 @@
{% extends '_base.html' %}
{% load static %}
{% block title %}Home page{% endblock title %}
{% block content %}
<div class="pricing-header px-3 py-3 pt-md-5 pb-md-4 mx-auto text-center">
<img src="{% static 'images/logo.png' %}" class="img-fluid" alt="DjangoX logo"/>
<p class="lead">A Django starter project with batteries.</p>
</div>
{% endblock content %}

View file

@ -0,0 +1,41 @@
{% extends '_base.html' %}
{% load static %}
{% load card_multiselect %}
{% block title %}Create Trade Offer{% endblock title %}
{% block content %}
<h2>Create a Trade Offer</h2>
<form method="post" novalidate>
{% csrf_token %}
{# Render the nonSelect2 field normally (e.g. initiated_by) #}
<div class="mb-3">
<label for="initiated_by" class="form-label">Initiated by</label>
{{ form.initiated_by }}
</div>
<div class="mb-3">
{% card_multiselect "have_cards" "Have:" available_cards "Select one or more cards..." form.have_cards.value %}
</div>
<div class="mb-3">
{% card_multiselect "want_cards" "Want:" available_cards "Select one or more cards..." form.want_cards.value %}
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% if form.errors %}
<div class="alert alert-danger">
<strong>Please correct the errors below:</strong>
<ul>
{% for field in form %}
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,14 @@
{% extends '_base.html' %}
{% load static %}
{% block title %}Delete Trade Offer{% endblock title %}
{% block content %}
<h2>Delete Trade Offer</h2>
<p>Are you sure you want to delete this trade offer?</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Confirm Delete</button>
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock content %}

View file

@ -0,0 +1,62 @@
{% extends '_base.html' %}
{% load static %}
{% load el_pagination_tags %}
{% block title %}Trade Offer List{% endblock title %}
{% block content %}
<div class="d-flex justify-content-end mb-3">
<form method="get" class="d-flex align-items-center">
<div class="form-check me-3">
<input class="form-check-input" type="checkbox" name="show_completed" id="show_completed" value="true" {% if show_completed %}checked{% endif %}>
<label class="form-check-label" for="show_completed">
Only Completed
</label>
</div>
<button type="submit" class="btn btn-primary">Apply</button>
</form>
</div>
<h2>Trade Offers</h2>
<table class="table">
<thead>
<tr>
<th>Offer</th>
<th>State</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
{% paginate 10 object_list as paginated_offers %}
{% for offer in paginated_offers %}
<tr>
<td>
<a href="{% url 'trade_offer_update' offer.id %}" class="d-flex align-items-center text-decoration-none">
<div class="flex-grow-1 text-start">
FT: {% for card in offer.cards_ft.all %}
{{ card.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
<div class="px-2 text-center" style="min-width: 50px;">&#x27F6;</div>
<div class="flex-grow-1 text-end">
LF: {% for card in offer.cards_lf.all %}
{{ card.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
</a>
</td>
<td>{{ offer.get_state_display }}</td>
<td>{{ offer.updated_at }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3">No trade offers available.</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination">
{% show_pages %}
</div>
<a href="{% url 'trade_offer_create' %}" class="btn btn-success">Create New Offer</a>
{% endblock content %}

View file

@ -0,0 +1,100 @@
{% extends '_base.html' %}
{% load static %}
{% block title %}Trade Offer Details & Update{% endblock title %}
{% block content %}
<div class="container my-4">
<h2 class="mb-4">Trade Offer Details</h2>
<!-- Offer Details Card -->
<div class="card mb-4">
<div class="card-header">
Offer Information
</div>
<div class="card-body">
<p>
<strong>Created At:</strong> {{ object.created_at|date:"M d, Y H:i" }}<br>
<strong>Updated At:</strong> {{ object.updated_at|date:"M d, Y H:i" }}<br>
{% comment %}
Only display these fields if the current user is associated with the initiating friend code
or (if available) with the accepted friend code.
{% endcomment %}
{% if object.initiated_by.user == request.user or object.accepted_by and object.accepted_by.user == request.user %}
<strong>Initiated By:</strong> {{ object.initiated_by }}<br>
<strong>Accepted By:</strong>
{% if object.accepted_by %}
{{ object.accepted_by }}
{% else %}
Not yet accepted
{% endif %}<br>
{% endif %}
<strong>Cards You Have:</strong>
{% for card in object.have_cards.all %}
{{ card.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br>
<strong>Cards You Want:</strong>
{% for card in object.want_cards.all %}
{{ card.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br>
<strong>Current State:</strong> {{ object.get_state_display }}
</p>
</div>
</div>
{% if form.fields %}
<!-- Form Card -->
<div class="card mb-4">
<div class="card-header">
{% if action == "accept" %}
Accept Trade Offer
{% else %}
Update Trade Offer
{% endif %}
</div>
<div class="card-body">
<form method="post" novalidate>
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn {% if action == 'accept' %}btn-success{% else %}btn-primary{% endif %}">
{% if action == "accept" %}
Accept Trade Offer
{% else %}
Submit
{% endif %}
</button>
</form>
</div>
</div>
{% else %}
<div class="alert alert-info">
You are not authorized to perform any status changes on this trade offer.
</div>
{% endif %}
{% if form and form.errors %}
<div class="alert alert-danger mt-3">
<strong>Please correct the errors below:</strong>
<ul class="mb-0">
{% for field in form %}
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="mt-3">
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Back to Trade Offers</a>
{% if can_delete %}
<a href="{% url 'trade_offer_delete' object.pk %}" class="btn btn-danger ms-2">Delete Trade Offer</a>
{% endif %}
</div>
</div>
{% endblock content %}

0
trades/__init__.py Normal file
View file

4
trades/admin.py Normal file
View file

@ -0,0 +1,4 @@
from django.contrib import admin
from .models import TradeOffer
admin.site.register(TradeOffer)

9
trades/apps.py Normal file
View file

@ -0,0 +1,9 @@
from django.apps import AppConfig
class TradesConfig(AppConfig):
name = "trades"
def ready(self):
# This import registers the signal handlers defined in trades/signals.py.
import trades.signals

70
trades/forms.py Normal file
View file

@ -0,0 +1,70 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import TradeOffer
from friend_codes.models import FriendCode
from cards.models import Card
class TradeOfferUpdateForm(forms.ModelForm):
class Meta:
model = TradeOffer
# We now only edit the `state` field
fields = ["state"]
def __init__(self, *args, **kwargs):
"""
Expects additional keyword arguments:
- friend_codes: a list of friend code objects for the current user.
This initializer filters the available state choices based on:
- The current state's allowed transition.
- Which party (initiated_by or accepted_by) is acting.
"""
friend_codes = kwargs.pop("friend_codes")
super().__init__(*args, **kwargs)
instance = self.instance
allowed_state = None
# Define permitted transitions based on the current state and user role:
if instance.state == TradeOffer.State.INITIATED:
# Allow the accepted_by party to accept the trade.
if instance.accepted_by in friend_codes:
allowed_state = TradeOffer.State.ACCEPTED
elif instance.state == TradeOffer.State.ACCEPTED:
# Allow the initiated_by party to mark the trade as sent.
if instance.initiated_by in friend_codes:
allowed_state = TradeOffer.State.SENT
elif instance.state == TradeOffer.State.SENT:
# Allow the accepted_by party to mark the trade as received.
if instance.accepted_by in friend_codes:
allowed_state = TradeOffer.State.RECEIVED
if allowed_state:
# Limit the `state` field's choices to only the permitted transition.
label = dict(TradeOffer.State.choices)[allowed_state]
self.fields["state"].choices = [(allowed_state, label)]
else:
# If no valid transition is available for this user, remove the field.
self.fields.pop("state")
def clean_have_cards(self):
have_cards = self.cleaned_data.get("have_cards")
if have_cards:
for card in have_cards.all():
if card.rarity not in ALLOWED_RARITIES:
# Raising a ValidationError here will cause this error message to be shown beneath the 'have_cards' field.
raise ValidationError(
f"The card '{card}' has an invalid rarity: {card.rarity}. Allowed rarities are: {', '.join(ALLOWED_RARITIES)}."
)
return have_cards
class TradeOfferAcceptForm(forms.Form):
friend_code = forms.ModelChoiceField(
queryset=FriendCode.objects.none(),
label="Select a Friend Code to Accept This Trade Offer"
)
def __init__(self, *args, **kwargs):
# Expecting a keyword argument `friend_codes` with the user's friend codes.
friend_codes = kwargs.pop("friend_codes")
super().__init__(*args, **kwargs)
self.fields["friend_code"].queryset = friend_codes

View file

@ -0,0 +1,31 @@
# Generated by Django 5.1.2 on 2025-02-26 08:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('cards', '0001_initial'),
('friend_codes', '0002_alter_friendcode_friend_code'),
]
operations = [
migrations.CreateModel(
name='TradeOffer',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('hash', models.CharField(editable=False, max_length=8)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('state', models.CharField(choices=[('INITIATED', 'Initiated'), ('ACCEPTED', 'Accepted'), ('SENT', 'Sent'), ('RECEIVED', 'Received')], default='INITIATED', max_length=10)),
('accepted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='accepted_by', to='friend_codes.friendcode')),
('have_cards', models.ManyToManyField(related_name='trade_offers_have', to='cards.card')),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_by', to='friend_codes.friendcode')),
('want_cards', models.ManyToManyField(related_name='trade_offers_want', to='cards.card')),
],
),
]

View file

72
trades/models.py Normal file
View file

@ -0,0 +1,72 @@
from django.db import models
import hashlib # <-- import hashlib for computing md5
from cards.models import Card
from friend_codes.models import FriendCode
class TradeOffer(models.Model):
id = models.AutoField(primary_key=True)
hash = models.CharField(max_length=8, editable=False)
initiated_by = models.ForeignKey("friend_codes.FriendCode", on_delete=models.PROTECT, related_name='initiated_by')
accepted_by = models.ForeignKey("friend_codes.FriendCode", on_delete=models.PROTECT, null=True, blank=True, related_name='accepted_by')
want_cards = models.ManyToManyField("cards.Card", related_name='trade_offers_want')
have_cards = models.ManyToManyField("cards.Card", related_name='trade_offers_have')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class State(models.TextChoices):
INITIATED = 'INITIATED', 'Initiated'
ACCEPTED = 'ACCEPTED', 'Accepted'
SENT = 'SENT', 'Sent'
RECEIVED = 'RECEIVED', 'Received'
state = models.CharField(
max_length=10,
choices=State.choices,
default=State.INITIATED,
)
def __str__(self):
return f"Want: {', '.join([x.name for x in self.want_cards.all()])} -> Have: {', '.join([x.name for x in self.have_cards.all()])}"
def update_state(self, new_state):
"""
Explicitly update the trade state to new_state if allowed.
Allowed transitions:
- INITIATED -> ACCEPTED
- ACCEPTED -> SENT
- SENT -> RECEIVED
Raises:
ValueError: If the new_state is not allowed.
"""
allowed_transitions = {
self.State.INITIATED: self.State.ACCEPTED,
self.State.ACCEPTED: self.State.SENT,
self.State.SENT: self.State.RECEIVED
}
# Check that new_state is one of the defined State choices
if new_state not in [choice[0] for choice in self.State.choices]:
raise ValueError(f"'{new_state}' is not a valid state.")
# If the current state is already final, no further transition is allowed.
if self.state not in allowed_transitions:
raise ValueError(f"No transitions allowed from the final state '{self.state}'.")
# Verify that the desired new_state is the valid transition for the current state.
if allowed_transitions[self.state] != new_state:
raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.")
self.state = new_state
# Save all changes so that any in-memory modifications (like accepted_by) are persisted.
self.save() # Changed from self.save(update_fields=["state"])
def save(self, *args, **kwargs):
# Determine if the object is being created (i.e. it doesn't yet have a pk)
is_new = self.pk is None
super().save(*args, **kwargs)
# Once the object has a pk, compute and save the hash if it hasn't been set yet.
if is_new and not self.hash:
self.hash = hashlib.md5(str(self.id).encode('utf-8')).hexdigest()[:8]
super().save(update_fields=["hash"])

25
trades/signals.py Normal file
View file

@ -0,0 +1,25 @@
from django.core.exceptions import ValidationError
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import TradeOffer
from cards.models import Card
def check_trade_offer_rarity(instance):
combined_cards = list(instance.have_cards.all()) + list(instance.want_cards.all())
# Map rarities 6 (Super Rare) and 7 (Special Art Rare) to a single value (here, 6)
rarities = {
card.rarity_id if card.rarity_id not in (6, 7) else 6
for card in combined_cards
}
if len(rarities) > 1:
raise ValidationError("All cards in a trade offer must have the same rarity.")
@receiver(m2m_changed, sender=TradeOffer.have_cards.through)
def validate_have_cards_rarity(sender, instance, action, **kwargs):
if action == "post_add":
check_trade_offer_rarity(instance)
@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)

View file

View file

@ -0,0 +1,12 @@
from django import template
register = template.Library()
@register.inclusion_tag('includes/trade_offer.html')
def render_trade_offer(offer):
"""
Renders a trade offer in the desired format.
"""
return {
'offer': offer
}

3
trades/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

17
trades/urls.py Normal file
View file

@ -0,0 +1,17 @@
from django.urls import path
from .views import (
TradeOfferCreateView,
TradeOfferListView,
TradeOfferUpdateView,
TradeOfferDeleteView,
TradeOfferSearchView,
)
urlpatterns = [
path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"),
path("", TradeOfferListView.as_view(), name="trade_offer_list"),
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
path("<int:pk>/", TradeOfferUpdateView.as_view(), name="trade_offer_update"),
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
]

224
trades/views.py Normal file
View file

@ -0,0 +1,224 @@
from django.views.generic import TemplateView, DeleteView, CreateView, ListView, DetailView, FormView
from django.urls import reverse_lazy
from django.http import HttpResponseRedirect
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404
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 .models import TradeOffer
from .forms import TradeOfferUpdateForm, TradeOfferAcceptForm
from cards.models import Card
class TradeOfferCreateView(LoginRequiredMixin, CreateView):
model = TradeOffer
template_name = "trades/trade_offer_create.html"
success_url = reverse_lazy("trade_offer_list")
fields = ["want_cards", "have_cards", "initiated_by"]
def form_valid(self, form):
# Save the object without committing m2m fields immediately.
self.object = form.save(commit=False)
self.object.save()
try:
# This call will trigger the m2m signals and may raise a ValidationError.
form.save_m2m()
except ValidationError as e:
# Attach the error message to the "have_cards" field (or as a non-field error)
form.add_error("have_cards", e.messages[0])
return self.form_invalid(form)
return HttpResponseRedirect(self.get_success_url())
class TradeOfferListView(LoginRequiredMixin, ListView):
model = TradeOffer
template_name = "trades/trade_offer_list.html"
def get_queryset(self):
qs = super().get_queryset().prefetch_related("have_cards", "want_cards").select_related("initiated_by", "accepted_by")
request = self.request
show_completed = request.GET.get("show_completed", "").lower() in ["true", "1"]
my_trades = request.GET.get("my_trades", "").lower() in ["true", "1"]
now = timezone.now()
seven_days_ago = now - timezone.timedelta(days=7)
if show_completed:
qs = qs.filter(Q(state=TradeOffer.State.RECEIVED))
else:
qs = qs.filter(updated_at__gte=seven_days_ago).exclude(state=TradeOffer.State.RECEIVED)
if my_trades:
friend_codes = self.request.user.friend_codes.all()
qs = qs.filter(Q(initiated_by__in=friend_codes) | Q(accepted_by__in=friend_codes))
return qs.order_by("-updated_at")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["show_completed"] = self.request.GET.get("show_completed", "").lower() in ["true", "1"]
context["my_trades"] = self.request.GET.get("my_trades", "").lower() in ["true", "1"]
return context
class TradeOfferUpdateView(LoginRequiredMixin, FormMixin, DetailView):
"""
Merged view that displays trade offer details and renders a form used for either:
- Accepting an offer (if in INITIATED state and not initiated by the current user), or
- Performing an allowed state transition via the update form.
"""
model = TradeOffer
template_name = "trades/trade_offer_update.html"
success_url = reverse_lazy("trade_offer_list")
def get_user_friend_codes(self):
return self.request.user.friend_codes.all()
def get_form_class(self):
trade_offer = self.get_object()
user_friend_codes = self.get_user_friend_codes()
if trade_offer.state == trade_offer.State.INITIATED and trade_offer.initiated_by not in user_friend_codes:
return TradeOfferAcceptForm
return TradeOfferUpdateForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["friend_codes"] = self.get_user_friend_codes()
if self.get_form_class() == TradeOfferUpdateForm:
kwargs["instance"] = self.get_object()
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
form_class = self.get_form_class()
if "form" not in context:
context["form"] = self.get_form(form_class)
context["action"] = "accept" if form_class == TradeOfferAcceptForm else "update"
trade_offer = self.object
user_friend_codes = self.get_user_friend_codes()
seven_days_ago = timezone.now() - timezone.timedelta(days=7)
can_delete = False
if trade_offer.initiated_by in user_friend_codes:
if trade_offer.state == trade_offer.State.INITIATED:
can_delete = True
elif trade_offer.state == trade_offer.State.SENT and trade_offer.updated_at < seven_days_ago:
can_delete = True
elif trade_offer.accepted_by in user_friend_codes:
if trade_offer.state in [trade_offer.State.ACCEPTED, trade_offer.State.RECEIVED]:
if trade_offer.updated_at < seven_days_ago:
can_delete = True
context["can_delete"] = can_delete
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
user_friend_codes = self.get_user_friend_codes()
form_class = self.get_form_class()
form = self.get_form(form_class)
if form_class == TradeOfferAcceptForm:
if not (self.object.state == self.object.State.INITIATED and
self.object.initiated_by not in user_friend_codes):
raise PermissionDenied("You are not allowed to accept this trade offer.")
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""
Save the instance and its many-to-many fields in a try/except block to catch
ValidationError raised by the m2m_changed signal (or a custom validation method).
"""
trade_offer = self.get_object()
# For example, if you want to perform a pre-save validation of card rarities,
# you might call a model method (that you define) like:
try:
trade_offer.validate_card_rarities()
except ValueError as e:
form.add_error("have_cards", str(e))
return self.form_invalid(form)
# For the m2m part, manually save to catch errors from the signal:
self.object = form.save(commit=False)
# Process state change or friend code acceptance:
if isinstance(form, TradeOfferAcceptForm):
chosen_friend_code = form.cleaned_data["friend_code"]
trade_offer.accepted_by = chosen_friend_code
try:
trade_offer.update_state(TradeOffer.State.ACCEPTED)
except ValueError as e:
# Attach as non-field error (or on a specific field if you prefer)
form.add_error(None, str(e))
return self.form_invalid(form)
else:
new_state = form.cleaned_data["state"]
try:
trade_offer.update_state(new_state)
except ValueError as e:
form.add_error("state", str(e))
return self.form_invalid(form)
try:
# Save instance and its m2m fields; any ValidationError raised here (e.g.,
# from the m2m_changed signals) will be caught.
self.object.save() # Save the TradeOffer instance.
form.save_m2m() # This call triggers the m2m_changed signals.
except ValidationError as e:
# Here we attach the signal error (from card rarities) to the form so that
# the user can see it. You can attach it to a specific field or as a non-field error.
form.add_error("have_cards", e.messages[0])
return self.form_invalid(form)
return HttpResponseRedirect(self.get_success_url())
class TradeOfferDeleteView(LoginRequiredMixin, DeleteView):
model = TradeOffer
success_url = reverse_lazy("trade_offer_list")
template_name = "trades/trade_offer_delete.html"
class TradeOfferSearchView(LoginRequiredMixin, ListView):
model = TradeOffer
template_name = "trades/trade_offer_search.html"
context_object_name = "trade_offers"
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()
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
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")
return context