Initial working version with minor bugs
This commit is contained in:
parent
f946e4933a
commit
71b3993326
83 changed files with 34485 additions and 173 deletions
|
|
@ -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"]
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
70
accounts/templatetags/gravatar.py
Normal file
70
accounts/templatetags/gravatar.py
Normal 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
9
cards/admin.py
Normal 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
5
cards/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CardsConfig(AppConfig):
|
||||
name = "cards"
|
||||
92
cards/migrations/0001_initial.py
Normal file
92
cards/migrations/0001_initial.py
Normal 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
66
cards/models.py
Normal 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
|
||||
0
cards/templatetags/__init__.py
Normal file
0
cards/templatetags/__init__.py
Normal file
11
cards/templatetags/card_badge.py
Normal file
11
cards/templatetags/card_badge.py
Normal 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
|
||||
}
|
||||
48
cards/templatetags/card_multiselect.py
Normal file
48
cards/templatetags/card_multiselect.py
Normal 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 key—by 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
5
cards/urls.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.urls import path
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
]
|
||||
4
cards/views.py
Normal file
4
cards/views.py
Normal 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
|
||||
|
||||
19
django_project/middleware.py
Normal file
19
django_project/middleware.py
Normal 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
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
0
friend_codes/__init__.py
Normal file
5
friend_codes/admin.py
Normal file
5
friend_codes/admin.py
Normal 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
6
friend_codes/apps.py
Normal 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
22
friend_codes/forms.py
Normal 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
|
||||
27
friend_codes/migrations/0001_initial.py
Normal file
27
friend_codes/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
friend_codes/migrations/0002_alter_friendcode_friend_code.py
Normal file
18
friend_codes/migrations/0002_alter_friendcode_friend_code.py
Normal 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),
|
||||
),
|
||||
]
|
||||
0
friend_codes/migrations/__init__.py
Normal file
0
friend_codes/migrations/__init__.py
Normal file
13
friend_codes/models.py
Normal file
13
friend_codes/models.py
Normal 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
3
friend_codes/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
friend_codes/urls.py
Normal file
8
friend_codes/urls.py
Normal 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
67
friend_codes/views.py
Normal 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
0
home/__init__.py
Normal file
|
|
@ -1,3 +1 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
home/apps.py
Normal file
5
home/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HomeConfig(AppConfig):
|
||||
name = "home"
|
||||
0
home/migrations/__init__.py
Normal file
0
home/migrations/__init__.py
Normal file
|
|
@ -1,3 +1 @@
|
|||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
home/tests.py
Normal file
3
home/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
7
home/urls.py
Normal file
7
home/urls.py
Normal 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
135
home/views.py
Normal 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)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PagesConfig(AppConfig):
|
||||
name = "pages"
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
102
seed/0001_Rarity.json
Normal 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
38
seed/0002_CardSet.json
Normal 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
79
seed/0003_Decks.json
Normal 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
7529
seed/0004_Cards.json
Normal file
File diff suppressed because it is too large
Load diff
38
seed/0005_TestUsers.json
Normal file
38
seed/0005_TestUsers.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
42
seed/0006_TestFriendCodes.json
Normal file
42
seed/0006_TestFriendCodes.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
234
seed/0007_TestTradeOffers.json
Normal file
234
seed/0007_TestTradeOffers.json
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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
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
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
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
12
static/css/flatly.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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>
|
||||
17
templates/friend_codes/add_friend_code.html
Normal file
17
templates/friend_codes/add_friend_code.html
Normal 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 %}
|
||||
13
templates/friend_codes/confirm_delete_friend_code.html
Normal file
13
templates/friend_codes/confirm_delete_friend_code.html
Normal 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 %}
|
||||
25
templates/friend_codes/list_friend_codes.html
Normal file
25
templates/friend_codes/list_friend_codes.html
Normal 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 %}
|
||||
42
templates/home/_search_results.html
Normal file
42
templates/home/_search_results.html
Normal 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
239
templates/home/home.html
Normal 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 %}
|
||||
13
templates/includes/card_badge.html
Normal file
13
templates/includes/card_badge.html
Normal 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 %}
|
||||
29
templates/includes/card_multiselect.html
Normal file
29
templates/includes/card_multiselect.html
Normal 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>
|
||||
62
templates/includes/trade_offer.html
Normal file
62
templates/includes/trade_offer.html
Normal 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>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{% extends '_base.html' %}
|
||||
|
||||
{% block title %}About page{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>About page</h1>
|
||||
{% endblock content %}
|
||||
|
|
@ -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 %}
|
||||
41
templates/trades/trade_offer_create.html
Normal file
41
templates/trades/trade_offer_create.html
Normal 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 non–Select2 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 %}
|
||||
14
templates/trades/trade_offer_delete.html
Normal file
14
templates/trades/trade_offer_delete.html
Normal 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 %}
|
||||
62
templates/trades/trade_offer_list.html
Normal file
62
templates/trades/trade_offer_list.html
Normal 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;">⟶</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 %}
|
||||
100
templates/trades/trade_offer_update.html
Normal file
100
templates/trades/trade_offer_update.html
Normal 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
0
trades/__init__.py
Normal file
4
trades/admin.py
Normal file
4
trades/admin.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from django.contrib import admin
|
||||
from .models import TradeOffer
|
||||
|
||||
admin.site.register(TradeOffer)
|
||||
9
trades/apps.py
Normal file
9
trades/apps.py
Normal 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
70
trades/forms.py
Normal 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
|
||||
31
trades/migrations/0001_initial.py
Normal file
31
trades/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
trades/migrations/__init__.py
Normal file
0
trades/migrations/__init__.py
Normal file
72
trades/models.py
Normal file
72
trades/models.py
Normal 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
25
trades/signals.py
Normal 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)
|
||||
0
trades/templatetags/__init__.py
Normal file
0
trades/templatetags/__init__.py
Normal file
12
trades/templatetags/trade_offer_tags.py
Normal file
12
trades/templatetags/trade_offer_tags.py
Normal 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
3
trades/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
17
trades/urls.py
Normal file
17
trades/urls.py
Normal 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
224
trades/views.py
Normal 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
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue