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
|
# Pull base image
|
||||||
FROM python:3.12.2-slim-bookworm
|
FROM python:3.12.2-bookworm
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV PYTHONDONTWRITEBYTECODE 1
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
|
@ -23,5 +23,7 @@ COPY . /code/
|
||||||
# Expose port 8000
|
# Expose port 8000
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
USER 10003:10003
|
||||||
|
|
||||||
# Use gunicorn on port 8000
|
# Use gunicorn on port 8000
|
||||||
CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "django_project.wsgi"]
|
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.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
|
|
@ -18,92 +18,19 @@ class Migration(migrations.Migration):
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='CustomUser',
|
name='CustomUser',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
'id',
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
(
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
'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')),
|
||||||
models.DateTimeField(blank=True, null=True, verbose_name='last login'),
|
('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')),
|
||||||
'is_superuser',
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||||
models.BooleanField(
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
default=False,
|
('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')),
|
||||||
help_text='Designates that this user has all permissions without explicitly assigning them.',
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
verbose_name='superuser status',
|
('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')),
|
||||||
),
|
|
||||||
(
|
|
||||||
'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={
|
options={
|
||||||
'verbose_name': 'user',
|
'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
|
# Third-party
|
||||||
"allauth",
|
"allauth",
|
||||||
"allauth.account",
|
"allauth.account",
|
||||||
|
'allauth.socialaccount.providers.google',
|
||||||
"crispy_forms",
|
"crispy_forms",
|
||||||
"crispy_bootstrap5",
|
"crispy_bootstrap5",
|
||||||
"debug_toolbar",
|
"debug_toolbar",
|
||||||
|
"el_pagination",
|
||||||
|
"tailwind",
|
||||||
|
#"theme",
|
||||||
|
"django_browser_reload",
|
||||||
# Local
|
# Local
|
||||||
"accounts",
|
"accounts",
|
||||||
"pages",
|
"cards",
|
||||||
|
"home",
|
||||||
|
"trades.apps.TradesConfig",
|
||||||
|
"friend_codes"
|
||||||
]
|
]
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
|
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
|
||||||
|
|
@ -53,6 +61,8 @@ MIDDLEWARE = [
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"allauth.account.middleware.AccountMiddleware", # django-allauth
|
"allauth.account.middleware.AccountMiddleware", # django-allauth
|
||||||
|
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||||
|
"django_project.middleware.AutoLoginMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
|
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
|
||||||
|
|
@ -79,24 +89,24 @@ TEMPLATES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
||||||
DATABASES = {
|
#DATABASES = {
|
||||||
"default": {
|
# "default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
# "ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
# "NAME": BASE_DIR / "db.sqlite3",
|
||||||
}
|
# }
|
||||||
}
|
#}
|
||||||
|
|
||||||
# For Docker/PostgreSQL usage uncomment this and comment the DATABASES config above
|
# For Docker/PostgreSQL usage uncomment this and comment the DATABASES config above
|
||||||
# DATABASES = {
|
DATABASES = {
|
||||||
# "default": {
|
"default": {
|
||||||
# "ENGINE": "django.db.backends.postgresql",
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
# "NAME": "postgres",
|
"NAME": "postgres",
|
||||||
# "USER": "postgres",
|
"USER": "postgres",
|
||||||
# "PASSWORD": "postgres",
|
"PASSWORD": "postgres",
|
||||||
# "HOST": "db", # set in docker-compose.yml
|
"HOST": "db", # set in docker-compose.yml
|
||||||
# "PORT": 5432, # default postgres port
|
"PORT": 5432, # default postgres port
|
||||||
# }
|
}
|
||||||
# }
|
}
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
|
||||||
|
|
@ -173,7 +183,9 @@ DEFAULT_FROM_EMAIL = "root@localhost"
|
||||||
# django-debug-toolbar
|
# django-debug-toolbar
|
||||||
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
|
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#internal-ips
|
# https://docs.djangoproject.com/en/dev/ref/settings/#internal-ips
|
||||||
INTERNAL_IPS = ["127.0.0.1"]
|
import socket
|
||||||
|
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
|
# https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model
|
||||||
AUTH_USER_MODEL = "accounts.CustomUser"
|
AUTH_USER_MODEL = "accounts.CustomUser"
|
||||||
|
|
@ -199,4 +211,70 @@ ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
|
||||||
ACCOUNT_USERNAME_REQUIRED = False
|
ACCOUNT_USERNAME_REQUIRED = False
|
||||||
ACCOUNT_AUTHENTICATION_METHOD = "email"
|
ACCOUNT_AUTHENTICATION_METHOD = "email"
|
||||||
ACCOUNT_EMAIL_REQUIRED = True
|
ACCOUNT_EMAIL_REQUIRED = True
|
||||||
|
#ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||||
|
ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||||
|
ACCOUNT_CHANGE_EMAIL = True
|
||||||
ACCOUNT_UNIQUE_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 = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/", include("allauth.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:
|
if settings.DEBUG:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ services:
|
||||||
build: .
|
build: .
|
||||||
command: python /code/manage.py runserver 0.0.0.0:8000
|
command: python /code/manage.py runserver 0.0.0.0:8000
|
||||||
volumes:
|
volumes:
|
||||||
- .:/code
|
- .:/code:z
|
||||||
ports:
|
ports:
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
depends_on:
|
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
|
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
|
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
|
certifi==2022.12.7
|
||||||
cffi==1.15.1
|
cffi==1.15.1
|
||||||
charset-normalizer==3.0.1
|
charset-normalizer==3.0.1
|
||||||
|
cookiecutter==2.6.0
|
||||||
crispy-bootstrap5==2024.10
|
crispy-bootstrap5==2024.10
|
||||||
cryptography==39.0.1
|
cryptography==39.0.1
|
||||||
defusedxml==0.7.1
|
defusedxml==0.7.1
|
||||||
Django==5.1.2
|
Django==5.1.2
|
||||||
django-allauth==65.0.2
|
django-allauth==65.0.2
|
||||||
|
django-browser-reload==1.17.0
|
||||||
django-crispy-forms==2.3
|
django-crispy-forms==2.3
|
||||||
django-debug-toolbar==4.4.6
|
django-debug-toolbar==4.4.6
|
||||||
|
django-el-pagination==4.1.2
|
||||||
|
django-tailwind-4[reload]==0.1.4
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
idna==3.4
|
idna==3.4
|
||||||
oauthlib==3.2.2
|
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
|
/* Sticky footer styles
|
||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
html {
|
html {
|
||||||
|
|
@ -31,3 +37,148 @@ body {
|
||||||
line-height: 60px; /* Vertically center the text there */
|
line-height: 60px; /* Vertically center the text there */
|
||||||
background-color: #f5f5f5;
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-bs-theme="light">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=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="description" content="A framework for launching new Django projects quickly.">
|
||||||
<meta name="author" content="">
|
<meta name="author" content="">
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="{% static 'images/favicon.ico' %}">
|
<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 -->
|
<!-- Bootstrap CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
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' %}">
|
<link rel="stylesheet" href="{% static 'css/base.css' %}">
|
||||||
|
{% block css %}
|
||||||
{% endblock %}
|
{% 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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
<nav class="navbar navbar-expand-lg bg-light">
|
||||||
<div class="container-fluid">
|
<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"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
|
||||||
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
|
@ -33,13 +72,26 @@
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="{% url 'home' %}">Home</a>
|
<a class="nav-link active" aria-current="page" href="{% url 'home' %}">Home</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link" href="{% url 'about' %}">About</a>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="mr-auto">
|
<div class="mr-auto">
|
||||||
<ul class="navbar-nav">
|
<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">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
||||||
aria-expanded="false">
|
aria-expanded="false">
|
||||||
|
|
@ -50,11 +102,12 @@
|
||||||
<li>
|
<li>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
</li>
|
</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_change_password' %}">Change password</a></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'account_logout' %}">Sign out</a></li>
|
<li><a class="dropdown-item" href="{% url 'account_logout' %}">Sign out</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="mr-auto">
|
<div class="mr-auto">
|
||||||
|
|
@ -79,17 +132,6 @@
|
||||||
<span class="text-muted">Footer...</span>
|
<span class="text-muted">Footer...</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</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