Finish packaging and change to src-based packaging layout, replace caddy with haproxy for performance, and update docker-compose and Dockerfiles for new packaging.

This commit is contained in:
badblocks 2025-05-09 18:39:04 -07:00
parent 959b06c425
commit 762361a21b
210 changed files with 235 additions and 168 deletions

View file

View file

@ -0,0 +1 @@
from django.contrib import admin

View file

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

View file

@ -0,0 +1 @@
from django.db import models

View file

@ -0,0 +1,591 @@
from django.test import TestCase, Client, RequestFactory
from django.urls import reverse
from django.contrib.auth import get_user_model
from pkmntrade_club.cards.models import Card, Deck
from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.home.views import HomePageView
import json
from collections import OrderedDict
from unittest.mock import patch, MagicMock
from django.core.exceptions import ObjectDoesNotExist
import importlib
from tests.utils.rarity import RARITY_MAPPING
User = get_user_model()
class HomePageViewTests(TestCase):
"""Test suite for the HomePageView."""
@classmethod
def setUpTestData(cls):
"""Set up data for all test methods."""
# Create a user
cls.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
)
# Create a friend code for the user
cls.friend_code = FriendCode.objects.create(
user=cls.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
)
# Create decks
cls.deck1 = Deck.objects.create(
name='Test Deck 1',
hex_color='#FF0000',
cardset='TEST01'
)
# Create cards with different rarities
cls.common_card = Card.objects.create(
name='Common Test Card',
cardset='TEST01',
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
)
cls.common_card.decks.add(cls.deck1)
cls.rare_card = Card.objects.create(
name='Rare Test Card',
cardset='TEST01',
cardnum=2,
style='normal',
rarity_icon='★★★',
rarity_level=3
)
cls.rare_card.decks.add(cls.deck1)
cls.ultra_rare_card = Card.objects.create(
name='Ultra Rare Test Card',
cardset='TEST01',
cardnum=3,
style='normal',
rarity_icon='★★★★',
rarity_level=4
)
cls.ultra_rare_card.decks.add(cls.deck1)
# Create trade offers with consistent rarities
cls.common_trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon=RARITY_MAPPING[1],
rarity_level=1
)
cls.rare_trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon=RARITY_MAPPING[3],
rarity_level=3
)
# Add have and want cards with the SAME rarity for each trade
TradeOfferHaveCard.objects.create(
trade_offer=cls.common_trade,
card=cls.common_card,
quantity=2
)
TradeOfferHaveCard.objects.create(
trade_offer=cls.rare_trade,
card=cls.rare_card,
quantity=1
)
# Add want cards with the SAME rarity as the have cards for each trade
TradeOfferWantCard.objects.create(
trade_offer=cls.common_trade,
card=cls.common_card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=cls.rare_trade,
card=cls.rare_card, # Changed from ultra_rare_card to match the rarity
quantity=1
)
def setUp(self):
"""Set up before each test method."""
self.client = Client()
self.url = reverse('home')
self.factory = RequestFactory()
def test_home_page_status_code(self):
"""Test that the home page returns a 200 status code."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
def test_home_page_template(self):
"""Test that the home page uses the correct template."""
response = self.client.get(self.url)
self.assertTemplateUsed(response, 'home/home.html')
def test_home_page_context_cards(self):
"""Test that the home page contains all cards in the context."""
response = self.client.get(self.url)
self.assertIn('cards', response.context)
self.assertEqual(response.context['cards'].count(), 3)
def test_home_page_context_recent_offers(self):
"""Test that the home page contains recent offers in the context."""
response = self.client.get(self.url)
self.assertIn('recent_offers', response.context)
self.assertEqual(len(response.context['recent_offers']), 2)
# Recent offers should be ordered by most recent first
self.assertEqual(response.context['recent_offers'][0], self.rare_trade)
def test_home_page_context_most_offered_cards(self):
"""Test that the home page contains most offered cards in the context."""
response = self.client.get(self.url)
self.assertIn('most_offered_cards', response.context)
most_offered = list(response.context['most_offered_cards'])
self.assertEqual(len(most_offered), 2)
# Common card should be most offered (quantity of 2)
self.assertEqual(most_offered[0], self.common_card)
def test_home_page_context_most_wanted_cards(self):
"""Test that the home page contains most wanted cards in the context."""
response = self.client.get(self.url)
self.assertIn('most_wanted_cards', response.context)
most_wanted = list(response.context['most_wanted_cards'])
self.assertEqual(len(most_wanted), 2)
def test_home_page_context_least_offered_cards(self):
"""Test that the home page contains least offered cards in the context."""
response = self.client.get(self.url)
self.assertIn('least_offered_cards', response.context)
def test_home_page_context_featured_offers(self):
"""Test that the home page contains featured offers in the context."""
response = self.client.get(self.url)
self.assertIn('featured_offers', response.context)
featured = response.context['featured_offers']
# Should be an OrderedDict
self.assertIsInstance(featured, OrderedDict)
# Should contain "All" category
self.assertIn("All", featured)
# Should contain both rarity icons
self.assertIn('★★★', featured)
self.assertIn('', featured)
# Higher rarity should come before lower rarity
keys = list(featured.keys())
# First key should be "All"
self.assertEqual(keys[0], "All")
# Higher rarity (★★★) should come before lower rarity (★)
self.assertIn('★★★', keys)
self.assertIn('', keys)
self.assertTrue(keys.index('★★★') < keys.index(''))
def test_closed_offers_not_shown(self):
"""Test that closed offers are not shown on the home page."""
# Close one of the trade offers
self.common_trade.is_closed = True
self.common_trade.save()
response = self.client.get(self.url)
recent_offers = response.context['recent_offers']
# Should only show the rare trade now
self.assertEqual(len(recent_offers), 1)
self.assertEqual(recent_offers[0], self.rare_trade)
def test_home_page_with_no_data(self):
"""Test home page rendering when there's no trade data."""
# Delete all trade offers
TradeOffer.objects.all().delete()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
# Should have empty lists for offers
self.assertEqual(len(response.context['recent_offers']), 0)
def test_home_page_with_authenticated_user(self):
"""Test that the home page works for authenticated users."""
self.client.login(username='testuser', password='testpass123')
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
def test_rarity_sorting_in_featured_offers(self):
"""Test that offers are sorted by rarity level in descending order."""
# Create a new ultra rare trade with consistent rarity
ultra_trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='★★★★',
rarity_level=4
)
# Add have and want cards with the same rarity
TradeOfferHaveCard.objects.create(
trade_offer=ultra_trade,
card=self.ultra_rare_card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=ultra_trade,
card=self.ultra_rare_card,
quantity=1
)
response = self.client.get(self.url)
featured = response.context['featured_offers']
keys = list(featured.keys())
# Order should be: "All", "★★★★" (level 4), "★★★" (level 3), "★" (level 1)
self.assertEqual(keys[0], "All")
self.assertEqual(keys[1], "★★★★")
self.assertEqual(keys[2], "★★★")
self.assertEqual(keys[3], "")
class HomePageViewMockTests(TestCase):
"""Test suite using mocks for HomePageView."""
def setUp(self):
self.factory = RequestFactory()
self.view = HomePageView()
@patch('trades.models.TradeOffer.objects')
@patch('cards.models.Card.objects')
def test_get_context_data_with_mocks(self, mock_card_objects, mock_offer_objects):
"""Test get_context_data using mocks."""
# Set up request
request = self.factory.get(reverse('home'))
self.view.request = request
# Mock the queryset responses
mock_offer_filter = MagicMock()
mock_offer_objects.filter.return_value = mock_offer_filter
mock_offer_filter.order_by.return_value = []
mock_card_filter = MagicMock()
mock_card_objects.filter.return_value = mock_card_filter
mock_card_objects.annotate.return_value = mock_card_filter
mock_card_objects.all.return_value.order_by.return_value = []
mock_card_filter.annotate.return_value = mock_card_filter
mock_card_filter.order_by.return_value = []
mock_offer_filter.values_list.return_value.distinct.return_value = []
# Call the method
context = self.view.get_context_data()
# Verify the expected context keys exist
self.assertIn('cards', context)
self.assertIn('recent_offers', context)
self.assertIn('most_offered_cards', context)
self.assertIn('most_wanted_cards', context)
self.assertIn('least_offered_cards', context)
self.assertIn('featured_offers', context)
@patch('trades.models.TradeOffer.objects')
def test_empty_featured_offers(self, mock_offer_objects):
"""Test handling of empty featured offers."""
# Set up request
request = self.factory.get(reverse('home'))
self.view.request = request
# Configure mock to return empty queryset
mock_offer_filter = MagicMock()
mock_offer_objects.filter.return_value = mock_offer_filter
mock_offer_filter.order_by.return_value = []
mock_offer_filter.values_list.return_value.distinct.return_value = []
# Call the method
context = self.view.get_context_data()
# Verify the featured_offers is an OrderedDict but with just the "All" key
self.assertIsInstance(context['featured_offers'], OrderedDict)
self.assertIn("All", context['featured_offers'])
self.assertEqual(len(context['featured_offers']), 1)
@patch('trades.models.TradeOffer.objects.filter')
def test_exception_handling(self, mock_filter):
"""Test that exceptions are handled gracefully."""
# Set up request
request = self.factory.get(reverse('home'))
self.view.request = request
# Configure mock to raise an exception
mock_filter.side_effect = Exception("Database error")
# Call the method - should not raise an exception
with self.assertLogs(level='ERROR') as cm:
context = self.view.get_context_data()
# Check if error was logged
self.assertIn("Unhandled error in HomePageView.get_context_data", cm.output[0])
# Verify fallback values were set
self.assertEqual(len(context['cards']), 0)
self.assertEqual(len(context['recent_offers']), 0)
self.assertEqual(len(context['most_offered_cards']), 0)
self.assertEqual(len(context['most_wanted_cards']), 0)
self.assertEqual(len(context['least_offered_cards']), 0)
self.assertIsInstance(context['featured_offers'], OrderedDict)
self.assertEqual(len(context['featured_offers']), 1)
self.assertIn("All", context['featured_offers'])
class HomePageEdgeCaseTests(TestCase):
"""Test edge cases for the home page."""
def setUp(self):
self.client = Client()
self.url = reverse('home')
# Create a user
self.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
)
# Create a friend code for the user
self.friend_code = FriendCode.objects.create(
user=self.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
)
def test_home_page_with_no_cards(self):
"""Test home page with no cards in the database."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context['cards']), 0)
def test_home_page_with_many_offers(self):
"""Test home page with many offers to verify pagination or limiting works."""
# Create a card
card = Card.objects.create(
name='Test Card',
cardset='TEST01',
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
)
# Create 20 trade offers
for i in range(20):
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='',
rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=card,
quantity=1
)
response = self.client.get(self.url)
# Check that recent_offers is limited to 6 as per the view
self.assertEqual(len(response.context['recent_offers']), 6)
def test_home_page_with_invalid_parameters(self):
"""Test home page with invalid GET parameters."""
# The view should ignore invalid parameters
response = self.client.get(f"{self.url}?invalid=param&another=invalid")
self.assertEqual(response.status_code, 200)
def test_performance_with_large_dataset(self):
"""Test performance with a larger dataset (basic check)."""
# Create a card
card = Card.objects.create(
name='Performance Test Card',
cardset='PERF01',
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
)
# Create 50 trade offers with different rarities
for i in range(50):
rarity_level = (i % 5) + 1 # 1-5
rarity_icon = '' * rarity_level
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon=rarity_icon,
rarity_level=rarity_level
)
# Add have and want cards with the same rarity
rarity_card = Card.objects.create(
name=f'Performance Test Card {i}',
cardset='PERF01',
cardnum=i+10,
style='normal',
rarity_icon=rarity_icon,
rarity_level=rarity_level
)
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=rarity_card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=rarity_card,
quantity=1
)
# Basic performance test - just checking it completes without timeout
import time
start = time.time()
response = self.client.get(self.url)
end = time.time()
self.assertEqual(response.status_code, 200)
# Should be reasonably fast (adjust threshold as needed)
execution_time = end - start
self.assertLess(execution_time, 2.0) # Should complete in under 2 seconds
class TemplateRenderingTests(TestCase):
"""Tests focused on template rendering."""
@classmethod
def setUpTestData(cls):
# Create a user
cls.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
)
# Create a friend code for the user
cls.friend_code = FriendCode.objects.create(
user=cls.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
)
# Create a card
cls.card = Card.objects.create(
name='Test Card',
cardset='TEST01',
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
)
# Create a trade offer
cls.trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon='',
rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=cls.trade,
card=cls.card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=cls.trade,
card=cls.card,
quantity=1
)
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
def test_template_used(self):
"""Test that the correct template is used."""
response = self.client.get(reverse('home'))
self.assertTemplateUsed(response, 'home/home.html')
def test_context_variables_exist(self):
"""Test that all expected context variables exist."""
response = self.client.get(reverse('home'))
# Check all required context variables
expected_keys = [
'cards',
'recent_offers',
'most_offered_cards',
'most_wanted_cards',
'least_offered_cards',
'featured_offers',
]
for key in expected_keys:
self.assertIn(key, response.context)
def test_view_with_pagination_params(self):
"""Test that view handles pagination parameters correctly, if applicable."""
# Create additional trade offers if pagination is implemented
for i in range(10):
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='',
rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=self.card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=self.card,
quantity=1
)
# Test with page parameter
response = self.client.get(f"{reverse('home')}?page=1")
self.assertEqual(response.status_code, 200)
# Test with invalid page parameter
response = self.client.get(f"{reverse('home')}?page=999")
self.assertEqual(response.status_code, 200) # Should still render with default page
# Test with non-numeric page parameter
response = self.client.get(f"{reverse('home')}?page=abc")
self.assertEqual(response.status_code, 200) # Should handle gracefully
@patch('home.views.HomePageView.get_context_data')
def test_view_renders_with_missing_context(self, mock_get_context):
"""Test that view renders even with incomplete context data."""
# Return incomplete context
mock_get_context.return_value = {'cards': []}
# Should still render without error even with missing context variables
response = self.client.get(reverse('home'))
self.assertEqual(response.status_code, 200)
def test_compatibility_with_multiple_django_versions(self):
"""Ensure compatibility with different Django versions."""
import django
# Simply log the Django version - the test itself verifies the page renders
# with the current version
django_version = django.get_version()
response = self.client.get(reverse('home'))
self.assertEqual(response.status_code, 200)

View file

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

View file

@ -0,0 +1,164 @@
from collections import defaultdict, OrderedDict
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When
from django.db.models.functions import Coalesce
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard
from pkmntrade_club.cards.models import Card
from django.utils.decorators import method_decorator
from django.template.response import TemplateResponse
from django.http import HttpResponseRedirect
import logging
from django.views import View
from django.http import HttpResponse
import contextlib
logger = logging.getLogger(__name__)
class HomePageView(TemplateView):
template_name = "home/home.html"
#@silk_profile(name='Home Page')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
try:
# Get all cards ordered by name, exclude cards with rarity level > 5
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level")
# Reuse base trade offer queryset for market stats
base_offer_qs = TradeOffer.objects.filter(is_closed=False)
# Recent Offers
try:
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
context["recent_offers"] = recent_offers_qs
context["cache_key_recent_offers"] = f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}"
except Exception as e:
logger.error(f"Error fetching recent offers: {str(e)}")
context["recent_offers"] = []
context["cache_key_recent_offers"] = "recent_offers_error"
# Most Offered Cards
try:
most_offered_cards_qs = (
Card.objects.filter(tradeofferhavecard__isnull=False).filter(rarity_level__lte=5)
.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("-offer_count")[:6]
)
context["most_offered_cards"] = most_offered_cards_qs
context["cache_key_most_offered_cards"] = f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}"
except Exception as e:
logger.error(f"Error fetching most offered cards: {str(e)}")
context["most_offered_cards"] = []
context["cache_key_most_offered_cards"] = "most_offered_cards_error"
# Most Wanted Cards
try:
most_wanted_cards_qs = (
Card.objects.filter(tradeofferwantcard__isnull=False).filter(rarity_level__lte=5)
.annotate(offer_count=Sum("tradeofferwantcard__quantity"))
.order_by("-offer_count")[:6]
)
context["most_wanted_cards"] = most_wanted_cards_qs
context["cache_key_most_wanted_cards"] = f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}"
except Exception as e:
logger.error(f"Error fetching most wanted cards: {str(e)}")
context["most_wanted_cards"] = []
# Least Offered Cards
try:
least_offered_cards_qs = (
Card.objects.filter(rarity_level__lte=5).annotate(
offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0)
)
.order_by("offer_count")[:6]
)
context["least_offered_cards"] = least_offered_cards_qs
context["cache_key_least_offered_cards"] = f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}"
except Exception as e:
logger.error(f"Error fetching least offered cards: {str(e)}")
context["least_offered_cards"] = []
context["cache_key_least_offered_cards"] = "least_offered_cards_error"
# Build featured offers with custom ordering
featured = OrderedDict()
# Featured "All" offers remains fixed at the top
try:
featured["All"] = base_offer_qs.order_by("created_at")[:6]
except Exception as e:
logger.error(f"Error fetching 'All' featured offers: {str(e)}")
featured["All"] = []
try:
# Pull out distinct (rarity_level, rarity_icon) tuples
distinct_rarities = base_offer_qs.values_list("rarity_level", "rarity_icon").distinct()
# Prepare a list that holds tuples of (rarity_level, rarity_icon, offers)
rarity_offers = []
for rarity_level, rarity_icon in distinct_rarities:
offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6]
rarity_offers.append((rarity_level, rarity_icon, offers))
# Sort by rarity_level (from greatest to least)
rarity_offers.sort(key=lambda x: x[0], reverse=True)
# Add the sorted offers to the OrderedDict
for rarity_level, rarity_icon, offers in rarity_offers:
featured[rarity_icon] = offers
except Exception as e:
logger.error(f"Error processing rarity-based featured offers: {str(e)}")
context["featured_offers"] = featured
# Generate a cache key based on the pks and updated_at timestamps of all featured offers
all_offer_identifiers = []
for section_name,section_offers in featured.items():
# featured_section is a QuerySet. Fetch (pk, updated_at) tuples.
identifiers = section_offers.values_list('pk', 'updated_at')
# Format each tuple as "pk_timestamp" and add to the list
section_strings = [f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers]
all_offer_identifiers.extend(section_strings)
# Join all identifiers into a single string, sorted for consistency regardless of order
combined_identifiers = "|".join(sorted(all_offer_identifiers))
context["cache_key_featured_offers"] = f"featured_offers_{combined_identifiers}"
except Exception as e:
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
# Provide fallback empty data
context["cards"] = None
context["recent_offers"] = []
context["most_offered_cards"] = []
context["most_wanted_cards"] = []
context["least_offered_cards"] = []
context["featured_offers"] = OrderedDict([("All", [])])
return context
def get(self, request, *args, **kwargs):
"""Override get method to add caching"""
return super().get(request, *args, **kwargs)
class HealthCheckView(View):
def get(self, request, *args, **kwargs):
try:
from django.db import connection
connection.cursor().execute("SELECT 1")
except Exception as e:
return HttpResponse("Database connection failed", status=500)
try:
from pkmntrade_club.trades.models import TradeOffer
with contextlib.redirect_stdout(None):
print(TradeOffer.objects.count())
except Exception as e:
return HttpResponse("DB models not reachable, but db is reachable", status=500)
try:
from django.core.cache import cache
cache.set("test", "test")
with contextlib.redirect_stdout(None):
print(cache.get("test"))
except Exception as e:
return HttpResponse("Cache not reachable", status=500)
return HttpResponse("OK/HEALTHY")