From 7c0c407f1c16294222beb25ccf2a9393fa5f137d Mon Sep 17 00:00:00 2001 From: Nick Bottone Date: Thu, 28 May 2026 00:49:07 -0400 Subject: [PATCH 1/7] Reset Django migrations baseline --- .gitignore | 1 - discordoauth2/migrations/0001_initial.py | 53 +++++++++++ discordoauth2/migrations/__init__.py | 1 + events/migrations/0001_initial.py | 110 +++++++++++++++++++++++ events/migrations/__init__.py | 1 + highscores/migrations/0001_initial.py | 80 +++++++++++++++++ highscores/migrations/__init__.py | 0 home/migrations/0001_initial.py | 38 ++++++++ home/migrations/__init__.py | 1 + ladder/migrations/__init__.py | 0 ranked/migrations/0001_initial.py | 67 ++++++++++++++ ranked/migrations/__init__.py | 1 + teamleague/migrations/__init__.py | 0 13 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 discordoauth2/migrations/0001_initial.py create mode 100644 discordoauth2/migrations/__init__.py create mode 100644 events/migrations/0001_initial.py create mode 100644 events/migrations/__init__.py create mode 100644 highscores/migrations/0001_initial.py create mode 100644 highscores/migrations/__init__.py create mode 100644 home/migrations/0001_initial.py create mode 100644 home/migrations/__init__.py create mode 100644 ladder/migrations/__init__.py create mode 100644 ranked/migrations/0001_initial.py create mode 100644 ranked/migrations/__init__.py create mode 100644 teamleague/migrations/__init__.py diff --git a/.gitignore b/.gitignore index c3b2e73..646d662 100644 --- a/.gitignore +++ b/.gitignore @@ -132,7 +132,6 @@ dmypy.json # Database *.sqlite3 -migrations/ .vscode/ .DS_Store diff --git a/discordoauth2/migrations/0001_initial.py b/discordoauth2/migrations/0001_initial.py new file mode 100644 index 0000000..11d0b77 --- /dev/null +++ b/discordoauth2/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.25 on 2026-05-28 04:36 + +import discordoauth2.managers +import django.contrib.auth.validators +import django.core.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('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')), + ('display_name', models.CharField(max_length=25, null=True, validators=[django.contrib.auth.validators.ASCIIUsernameValidator(), django.core.validators.MinLengthValidator(4)], verbose_name='display name')), + ('id', models.BigIntegerField(primary_key=True, serialize=False, verbose_name='user id')), + ('username', models.CharField(max_length=100, verbose_name='username')), + ('discriminator', models.CharField(max_length=4, verbose_name='discriminator')), + ('avatar', models.URLField(verbose_name='avatar')), + ('public_flags', models.IntegerField(verbose_name='public flags')), + ('flags', models.IntegerField(verbose_name='flags')), + ('locale', models.CharField(max_length=100, verbose_name='locale')), + ('mfa_enabled', models.BooleanField(verbose_name='multi-factor authentication enabled')), + ('email', models.EmailField(max_length=254, verbose_name='email address')), + ('verified', models.BooleanField(help_text='Whether the user has verified their email address.', verbose_name='email verified')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff')), + ('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')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', discordoauth2.managers.DiscordUserOAuth2Manager()), + ], + ), + ] diff --git a/discordoauth2/migrations/__init__.py b/discordoauth2/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/discordoauth2/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/events/migrations/0001_initial.py b/events/migrations/0001_initial.py new file mode 100644 index 0000000..8953f1a --- /dev/null +++ b/events/migrations/0001_initial.py @@ -0,0 +1,110 @@ +# Generated by Django 3.2.25 on 2026-05-28 04:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ChampionshipPoints', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('player_name', models.CharField(max_length=25)), + ('event_1', models.IntegerField(default=0)), + ('event_2', models.IntegerField(default=0)), + ], + ), + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=25)), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ], + ), + migrations.CreateModel( + name='Ranking', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('player_name', models.CharField(max_length=25)), + ('ranking_points', models.IntegerField()), + ('time_set', models.DateTimeField(blank=True, null=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')), + ], + ), + migrations.CreateModel( + name='Player', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('player_name', models.CharField(max_length=25)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')), + ], + ), + migrations.CreateModel( + name='Match', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('match_number', models.IntegerField(blank=True, null=True)), + ('match_type', models.CharField(choices=[('q', 'Qualifications'), ('qf', 'Quarterfinals'), ('sf', 'Semifinals'), ('f', 'Finals')], default='q', max_length=2)), + ('red1', models.CharField(max_length=25)), + ('red2', models.CharField(max_length=25)), + ('red3', models.CharField(max_length=25)), + ('blue1', models.CharField(max_length=25)), + ('blue2', models.CharField(max_length=25)), + ('blue3', models.CharField(max_length=25)), + ('red1_redcard', models.BooleanField(default=False)), + ('red2_redcard', models.BooleanField(default=False)), + ('red3_redcard', models.BooleanField(default=False)), + ('blue1_redcard', models.BooleanField(default=False)), + ('blue2_redcard', models.BooleanField(default=False)), + ('blue3_redcard', models.BooleanField(default=False)), + ('red1_surrogate', models.BooleanField(default=False)), + ('red2_surrogate', models.BooleanField(default=False)), + ('red3_surrogate', models.BooleanField(default=False)), + ('blue1_surrogate', models.BooleanField(default=False)), + ('blue2_surrogate', models.BooleanField(default=False)), + ('blue3_surrogate', models.BooleanField(default=False)), + ('red1_contribution', models.IntegerField(blank=True, null=True)), + ('red2_contribution', models.IntegerField(blank=True, null=True)), + ('red3_contribution', models.IntegerField(blank=True, null=True)), + ('blue1_contribution', models.IntegerField(blank=True, null=True)), + ('blue2_contribution', models.IntegerField(blank=True, null=True)), + ('blue3_contribution', models.IntegerField(blank=True, null=True)), + ('red_score', models.IntegerField(blank=True, null=True)), + ('red_climb_rp', models.BooleanField(blank=True, null=True)), + ('red_wheel_rp', models.BooleanField(blank=True, null=True)), + ('red_auto_points', models.IntegerField(blank=True, null=True)), + ('red_teleop_points', models.IntegerField(blank=True, null=True)), + ('red_endgame_points', models.IntegerField(blank=True, null=True)), + ('red_power_cells', models.IntegerField(blank=True, null=True)), + ('blue_score', models.IntegerField(blank=True, null=True)), + ('blue_climb_rp', models.BooleanField(blank=True, null=True)), + ('blue_wheel_rp', models.BooleanField(blank=True, null=True)), + ('blue_auto_points', models.IntegerField(blank=True, null=True)), + ('blue_teleop_points', models.IntegerField(blank=True, null=True)), + ('blue_endgame_points', models.IntegerField(blank=True, null=True)), + ('blue_power_cells', models.IntegerField(blank=True, null=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')), + ], + ), + migrations.CreateModel( + name='ElimsAlliance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('alliance_number', models.IntegerField(blank=True, null=True)), + ('player1', models.CharField(max_length=25)), + ('player2', models.CharField(max_length=25)), + ('player3', models.CharField(max_length=25)), + ('advancement', models.CharField(choices=[('qf', 'QF'), ('sf', 'SF'), ('f', 'F'), ('w', 'W')], default='qf', max_length=25)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event')), + ], + ), + ] diff --git a/events/migrations/__init__.py b/events/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/events/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/highscores/migrations/0001_initial.py b/highscores/migrations/0001_initial.py new file mode 100644 index 0000000..8a57cd6 --- /dev/null +++ b/highscores/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 3.2.25 on 2026-05-28 04:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExemptedIP', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip', models.CharField(max_length=45)), + ('reason', models.CharField(blank=True, max_length=200, null=True)), + ], + ), + migrations.CreateModel( + name='Leaderboard', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=25)), + ('robot', models.CharField(max_length=25)), + ('game', models.CharField(max_length=25)), + ('game_slug', models.CharField(max_length=5)), + ('auto_or_teleop', models.CharField(default='TELE', max_length=4)), + ('message', models.CharField(blank=True, max_length=200, null=True)), + ], + ), + migrations.CreateModel( + name='Score', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.IntegerField()), + ('time_set', models.DateTimeField(auto_now_add=True)), + ('source', models.URLField()), + ('approved', models.BooleanField(default=False)), + ('clean_code', models.CharField(max_length=4000)), + ('decrypted_code', models.CharField(blank=True, max_length=2000, null=True)), + ('client_version', models.CharField(blank=True, max_length=20, null=True)), + ('time_of_score', models.CharField(blank=True, max_length=30, null=True)), + ('robot_position', models.CharField(blank=True, max_length=20, null=True)), + ('time_data', models.TextField(blank=True, null=True)), + ('ip', models.CharField(blank=True, max_length=45, null=True)), + ('leaderboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='highscores.leaderboard')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='CleanCodeSubmission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('clean_code', models.CharField(max_length=600)), + ('score', models.IntegerField()), + ('time_set', models.DateTimeField(auto_now_add=True)), + ('ip', models.CharField(blank=True, max_length=45, null=True)), + ('leaderboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='highscores.leaderboard')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddIndex( + model_name='score', + index=models.Index(fields=['leaderboard'], name='highscores__leaderb_ad4364_idx'), + ), + migrations.AddIndex( + model_name='score', + index=models.Index(fields=['player'], name='highscores__player__3776b0_idx'), + ), + migrations.AddIndex( + model_name='score', + index=models.Index(fields=['approved'], name='highscores__approve_054a97_idx'), + ), + ] diff --git a/highscores/migrations/__init__.py b/highscores/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home/migrations/0001_initial.py b/home/migrations/0001_initial.py new file mode 100644 index 0000000..b5f9d0a --- /dev/null +++ b/home/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.25 on 2026-05-28 04:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='HistoricEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('date', models.DateField()), + ('youtube_url', models.URLField(blank=True, null=True)), + ('first_place', models.CharField(max_length=100)), + ('second_place', models.CharField(blank=True, max_length=100, null=True)), + ], + ), + migrations.CreateModel( + name='Staff', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('title', models.CharField(max_length=50)), + ('image_url', models.URLField()), + ('bio', models.TextField()), + ('linkedin_url', models.URLField(blank=True, null=True)), + ('github_url', models.URLField(blank=True, null=True)), + ('email', models.EmailField(max_length=254)), + ], + ), + ] diff --git a/home/migrations/__init__.py b/home/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/home/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/ladder/migrations/__init__.py b/ladder/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ranked/migrations/0001_initial.py b/ranked/migrations/0001_initial.py new file mode 100644 index 0000000..cea68c6 --- /dev/null +++ b/ranked/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 3.2.25 on 2026-05-28 04:36 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GameMode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=25, unique=True)), + ('game', models.CharField(max_length=25)), + ('players_per_alliance', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(3)])), + ('short_code', models.CharField(max_length=10, unique=True)), + ], + ), + migrations.CreateModel( + name='PlayerElo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('elo', models.FloatField(default=1200)), + ('matches_played', models.IntegerField(default=0)), + ('matches_won', models.IntegerField(default=0)), + ('matches_lost', models.IntegerField(default=0)), + ('matches_drawn', models.IntegerField(default=0)), + ('last_match_played_time', models.DateTimeField(blank=True, null=True)), + ('last_match_played_number', models.IntegerField(blank=True, null=True)), + ('total_score', models.IntegerField(default=0)), + ('game_mode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ranked.gamemode')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Match', + fields=[ + ('match_number', models.AutoField(primary_key=True, serialize=False)), + ('time', models.DateTimeField(auto_now_add=True)), + ('red_score', models.IntegerField()), + ('blue_score', models.IntegerField()), + ('red_starting_elo', models.FloatField()), + ('blue_starting_elo', models.FloatField()), + ('blue_alliance', models.ManyToManyField(related_name='blue_alliance', to=settings.AUTH_USER_MODEL)), + ('game_mode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ranked.gamemode')), + ('red_alliance', models.ManyToManyField(related_name='red_alliance', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='EloHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('match_number', models.IntegerField()), + ('elo', models.FloatField()), + ('player_elo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ranked.playerelo')), + ], + ), + ] diff --git a/ranked/migrations/__init__.py b/ranked/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ranked/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/teamleague/migrations/__init__.py b/teamleague/migrations/__init__.py new file mode 100644 index 0000000..e69de29 From b420d4299e4881eef50fba400c05ea4286fbe045 Mon Sep 17 00:00:00 2001 From: Nick Bottone Date: Thu, 28 May 2026 00:49:30 -0400 Subject: [PATCH 2/7] Add premium subscriptions and casual server orchestration --- SRCweb/settings.py | 16 +- SRCweb/urls.py | 2 + home/templates/home/base.html | 9 + subscriptions/__init__.py | 1 + subscriptions/admin.py | 66 ++ subscriptions/api/__init__.py | 1 + subscriptions/api/urls.py | 15 + subscriptions/api/views.py | 164 ++++ subscriptions/apps.py | 6 + subscriptions/management/__init__.py | 1 + subscriptions/management/commands/__init__.py | 1 + .../commands/expire_premium_sessions.py | 19 + subscriptions/migrations/0001_initial.py | 189 +++++ .../migrations/0002_seed_default_plans.py | 47 ++ subscriptions/migrations/__init__.py | 1 + subscriptions/models.py | 252 +++++++ subscriptions/orchestrator.py | 87 +++ subscriptions/services.py | 707 ++++++++++++++++++ .../templates/subscriptions/home.html | 180 +++++ subscriptions/tests.py | 403 ++++++++++ subscriptions/urls.py | 11 + subscriptions/views.py | 135 ++++ 22 files changed, 2312 insertions(+), 1 deletion(-) create mode 100644 subscriptions/__init__.py create mode 100644 subscriptions/admin.py create mode 100644 subscriptions/api/__init__.py create mode 100644 subscriptions/api/urls.py create mode 100644 subscriptions/api/views.py create mode 100644 subscriptions/apps.py create mode 100644 subscriptions/management/__init__.py create mode 100644 subscriptions/management/commands/__init__.py create mode 100644 subscriptions/management/commands/expire_premium_sessions.py create mode 100644 subscriptions/migrations/0001_initial.py create mode 100644 subscriptions/migrations/0002_seed_default_plans.py create mode 100644 subscriptions/migrations/__init__.py create mode 100644 subscriptions/models.py create mode 100644 subscriptions/orchestrator.py create mode 100644 subscriptions/services.py create mode 100644 subscriptions/templates/subscriptions/home.html create mode 100644 subscriptions/tests.py create mode 100644 subscriptions/urls.py create mode 100644 subscriptions/views.py diff --git a/SRCweb/settings.py b/SRCweb/settings.py index 97d7dbe..d08616b 100644 --- a/SRCweb/settings.py +++ b/SRCweb/settings.py @@ -37,6 +37,19 @@ # SECURITY WARNING: keep the API Key used in production secret! API_KEY = os.getenv("API_KEY") or "API_KEY" +# Polar subscriptions +POLAR_API_BASE_URL = os.getenv("POLAR_API_BASE_URL") or "https://api.polar.sh" +POLAR_ACCESS_TOKEN = os.getenv("POLAR_ACCESS_TOKEN") or "" +POLAR_WEBHOOK_SECRET = os.getenv("POLAR_WEBHOOK_SECRET") or "" +POLAR_CHECKOUT_SUCCESS_URL = os.getenv("POLAR_CHECKOUT_SUCCESS_URL") or "" +POLAR_CUSTOMER_PORTAL_URL = os.getenv("POLAR_CUSTOMER_PORTAL_URL") or "" + +# xRC server orchestrator +ORCHESTRATOR_API_BASE_URL = os.getenv("ORCHESTRATOR_API_BASE_URL") or "" +ORCHESTRATOR_API_TOKEN = os.getenv("ORCHESTRATOR_API_TOKEN") or "" +ORCHESTRATOR_REQUEST_TIMEOUT_SECONDS = float(os.getenv("ORCHESTRATOR_REQUEST_TIMEOUT_SECONDS") or 10) +SECONDWEBSITE_SERVICE_TOKEN = os.getenv("SECONDWEBSITE_SERVICE_TOKEN") or "" + # Sends an email to admins when debug = false and a 500 server error occurs ADMINS = [('Webmaster', 'webmaster@secondrobotics.org')] ADMIN_EMAILS = [email for name, email in ADMINS] @@ -73,7 +86,8 @@ # 'teamleague', # 'ladder', 'discordoauth2', - 'ranked' + 'ranked', + 'subscriptions', ] REST_FRAMEWORK = { diff --git a/SRCweb/urls.py b/SRCweb/urls.py index 31ab48c..bca3fb1 100644 --- a/SRCweb/urls.py +++ b/SRCweb/urls.py @@ -30,6 +30,8 @@ path('oauth2/', include('discordoauth2.urls')), path('ranked/', include('ranked.urls')), path('api/ranked/', include('ranked.api.urls', namespace="api-ranked")), + path('subscriptions/', include('subscriptions.urls')), + path('api/subscriptions/', include('subscriptions.api.urls', namespace="api-subscriptions")), ] if settings.DEBUG: diff --git a/home/templates/home/base.html b/home/templates/home/base.html index c19e808..970c734 100644 --- a/home/templates/home/base.html +++ b/home/templates/home/base.html @@ -243,6 +243,15 @@ + + diff --git a/subscriptions/tests.py b/subscriptions/tests.py index 16df89d..9a37db3 100644 --- a/subscriptions/tests.py +++ b/subscriptions/tests.py @@ -1,6 +1,3 @@ -import base64 -import hashlib -import hmac import json from datetime import timedelta from unittest.mock import patch @@ -18,7 +15,7 @@ process_orchestrator_event, start_server_session, stop_server_session, - verify_standard_webhook_signature, + validate_polar_webhook, ) @@ -47,17 +44,17 @@ def create_user(user_id=1001): ) -def create_entitlement(user, tier='supporter', minutes=360): +def create_entitlement(user, tier='supporter', credits=360, provider=SubscriptionEntitlement.PROVIDER_COMP): plan = SubscriptionPlan.objects.get(tier=tier) - if minutes != plan.monthly_server_minutes: - plan.monthly_server_minutes = minutes - plan.save(update_fields=['monthly_server_minutes']) + if credits != plan.monthly_credits: + plan.monthly_credits = credits + plan.save(update_fields=['monthly_credits']) now = timezone.now() return SubscriptionEntitlement.objects.create( user=user, plan=plan, - provider=SubscriptionEntitlement.PROVIDER_COMP, - provider_subscription_id=f'comp-{user.id}-{tier}', + provider=provider, + provider_subscription_id=f'{provider}-{user.id}-{tier}', status=SubscriptionEntitlement.STATUS_ACTIVE, current_period_start=now, current_period_end=now + timedelta(days=30), @@ -73,7 +70,7 @@ def test_start_requires_active_entitlement(self): def test_start_limits_session_to_plan_and_remaining_minutes(self): user = create_user() - create_entitlement(user, tier='supporter', minutes=30) + create_entitlement(user, tier='supporter', credits=30) session = start_server_session(user, requested_minutes=240, game='xRC') @@ -93,8 +90,8 @@ def test_plan_can_allow_multiple_concurrent_sessions(self): user = create_user() plan = SubscriptionPlan.objects.get(tier='supporter') plan.max_concurrent_servers = 2 - plan.monthly_server_minutes = 360 - plan.save(update_fields=['max_concurrent_servers', 'monthly_server_minutes']) + plan.monthly_credits = 360 + plan.save(update_fields=['max_concurrent_servers', 'monthly_credits']) create_entitlement(user) start_server_session(user, requested_minutes=30) @@ -107,13 +104,13 @@ def test_highest_priority_entitlement_is_used(self): supporter = SubscriptionPlan.objects.get(tier='supporter') host = SubscriptionPlan.objects.get(tier='host') supporter.entitlement_priority = 100 - supporter.monthly_server_minutes = 30 - supporter.save(update_fields=['entitlement_priority', 'monthly_server_minutes']) + supporter.monthly_credits = 30 + supporter.save(update_fields=['entitlement_priority', 'monthly_credits']) host.entitlement_priority = 10 - host.monthly_server_minutes = 1500 - host.save(update_fields=['entitlement_priority', 'monthly_server_minutes']) + host.monthly_credits = 1500 + host.save(update_fields=['entitlement_priority', 'monthly_credits']) create_entitlement(user, tier='host') - create_entitlement(user, tier='supporter', minutes=30) + create_entitlement(user, tier='supporter', credits=30) session = start_server_session(user, requested_minutes=240) @@ -131,24 +128,63 @@ def test_stop_records_rounded_usage_once(self): stop_server_session(session) usage = UsageLedgerEntry.objects.get(server_session=session) - self.assertEqual(usage.minutes, 10) + self.assertEqual(usage.credits, 10) self.assertEqual(UsageLedgerEntry.objects.count(), 1) def test_exhausted_entitlement_rejects_start(self): user = create_user() - entitlement = create_entitlement(user, minutes=30) + entitlement = create_entitlement(user, credits=30) UsageLedgerEntry.objects.create( user=user, entitlement=entitlement, billing_period_start=entitlement.current_period_start, billing_period_end=entitlement.current_period_end, - minutes=30, + credits=30, reason=UsageLedgerEntry.REASON_ADMIN_ADJUSTMENT, ) with self.assertRaises(SubscriptionError): start_server_session(user) + @patch('subscriptions.polar_gateway.get_customer_credit_balance') + def test_polar_entitlement_uses_meter_balance_for_launch(self, mock_balance): + mock_balance.return_value = 25 + user = create_user() + create_entitlement(user, provider=SubscriptionEntitlement.PROVIDER_POLAR, credits=360) + + session = start_server_session(user, requested_minutes=60) + + self.assertEqual(session.allocated_minutes, 25) + mock_balance.assert_called_once_with(user) + + @patch('subscriptions.polar_gateway.get_customer_credit_balance') + def test_polar_entitlement_rejects_exhausted_meter_balance(self, mock_balance): + mock_balance.return_value = 0 + user = create_user() + create_entitlement(user, provider=SubscriptionEntitlement.PROVIDER_POLAR, credits=360) + + with self.assertRaises(SubscriptionError): + start_server_session(user, requested_minutes=30) + + @patch('subscriptions.polar_gateway.ingest_usage_event') + @patch('subscriptions.polar_gateway.get_customer_credit_balance') + def test_polar_usage_is_ingested_when_session_stops(self, mock_balance, mock_ingest): + mock_balance.return_value = 60 + mock_ingest.return_value = type('PolarIngestResponse', (), {'inserted': 1, 'duplicates': 0})() + user = create_user() + create_entitlement(user, provider=SubscriptionEntitlement.PROVIDER_POLAR, credits=360) + session = start_server_session(user, requested_minutes=60) + session.started_at = timezone.now() - timedelta(minutes=6) + session.save(update_fields=['started_at']) + + stop_server_session(session) + stop_server_session(session) + + usage = UsageLedgerEntry.objects.get(server_session=session) + self.assertEqual(usage.credits, 10) + self.assertEqual(usage.metadata['polar_inserted'], 1) + mock_ingest.assert_called_once() + def test_expire_stale_sessions_marks_expired_and_records_usage(self): user = create_user() create_entitlement(user) @@ -162,7 +198,7 @@ def test_expire_stale_sessions_marks_expired_and_records_usage(self): self.assertEqual(len(expired), 1) session.refresh_from_db() self.assertEqual(session.status, ServerSession.STATUS_EXPIRED) - self.assertEqual(UsageLedgerEntry.objects.get(server_session=session).minutes, 20) + self.assertEqual(UsageLedgerEntry.objects.get(server_session=session).credits, 20) def test_orchestrator_ready_and_stopped_events_record_usage_once(self): user = create_user() @@ -205,7 +241,7 @@ def test_orchestrator_ready_and_stopped_events_record_usage_once(self): self.assertEqual(session.host, 'play.example.test') self.assertEqual(session.port, 11115) self.assertEqual(session.server_password, 'secret') - self.assertEqual(UsageLedgerEntry.objects.get(server_session=session).minutes, 10) + self.assertEqual(UsageLedgerEntry.objects.get(server_session=session).credits, 10) self.assertEqual(WebhookEvent.objects.filter(provider='orchestrator').count(), 2) def test_orchestrator_launch_failed_does_not_record_usage(self): @@ -349,24 +385,21 @@ def test_orchestrator_callback_accepts_api_key(self): class PolarWebhookTests(TestCase): - @override_settings(POLAR_WEBHOOK_SECRET='whsec_' + base64.b64encode(b'secret').decode('utf-8')) - def test_standard_webhook_signature_verification_accepts_valid_signature(self): + @override_settings(POLAR_WEBHOOK_SECRET='whsec_test') + @patch('subscriptions.polar_gateway.validate_event') + def test_polar_webhook_validation_uses_sdk(self, mock_validate_event): body = b'{"type":"subscription.updated"}' - webhook_id = 'evt_123' - timestamp = str(int(timezone.now().timestamp())) - signed = b'.'.join([webhook_id.encode(), timestamp.encode(), body]) - signature = 'v1,' + base64.b64encode(hmac.new(b'secret', signed, hashlib.sha256).digest()).decode('utf-8') - - verify_standard_webhook_signature( - 'whsec_' + base64.b64encode(b'secret').decode('utf-8'), - body, - webhook_id, - timestamp, - signature, - ) + payload = {'type': 'subscription.updated', 'data': {}} + mock_validate_event.return_value = payload + + result = validate_polar_webhook('whsec_test', body, {'webhook-id': 'evt_123'}) + + self.assertEqual(result, payload) + mock_validate_event.assert_called_once_with(body, {'webhook-id': 'evt_123'}, 'whsec_test') @override_settings(POLAR_WEBHOOK_SECRET='plain-secret') - def test_polar_subscription_webhook_upserts_entitlement_and_is_idempotent(self): + @patch('subscriptions.polar_gateway.validate_event') + def test_polar_subscription_webhook_upserts_entitlement_and_is_idempotent(self, mock_validate_event): user = create_user() plan = SubscriptionPlan.objects.get(tier='supporter') plan.polar_product_id = 'prod_supporter' @@ -382,11 +415,9 @@ def test_polar_subscription_webhook_upserts_entitlement_and_is_idempotent(self): 'product_id': 'prod_supporter', }, } + mock_validate_event.return_value = body raw_body = json.dumps(body).encode('utf-8') webhook_id = 'evt_123' - timestamp = str(int(timezone.now().timestamp())) - signed = b'.'.join([webhook_id.encode(), timestamp.encode(), raw_body]) - signature = 'v1,' + base64.b64encode(hmac.new(b'plain-secret', signed, hashlib.sha256).digest()).decode('utf-8') client = APIClient() response = client.post( @@ -394,8 +425,6 @@ def test_polar_subscription_webhook_upserts_entitlement_and_is_idempotent(self): data=raw_body, content_type='application/json', HTTP_WEBHOOK_ID=webhook_id, - HTTP_WEBHOOK_TIMESTAMP=timestamp, - HTTP_WEBHOOK_SIGNATURE=signature, ) self.assertEqual(response.status_code, 200) @@ -404,8 +433,6 @@ def test_polar_subscription_webhook_upserts_entitlement_and_is_idempotent(self): data=raw_body, content_type='application/json', HTTP_WEBHOOK_ID=webhook_id, - HTTP_WEBHOOK_TIMESTAMP=timestamp, - HTTP_WEBHOOK_SIGNATURE=signature, ) self.assertEqual(response.status_code, 200) diff --git a/subscriptions/views.py b/subscriptions/views.py index bc3ff3c..1fad672 100644 --- a/subscriptions/views.py +++ b/subscriptions/views.py @@ -1,5 +1,3 @@ -import json - from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -18,7 +16,7 @@ launch_casual_server, process_polar_webhook, request_stop_server_session, - verify_standard_webhook_signature, + validate_polar_webhook, ) @@ -111,25 +109,15 @@ def stop_server_session_view(request, session_id): @csrf_exempt @require_POST def polar_webhook(request): - webhook_id = request.META.get('HTTP_WEBHOOK_ID', '') - webhook_timestamp = request.META.get('HTTP_WEBHOOK_TIMESTAMP', '') - webhook_signature = request.META.get('HTTP_WEBHOOK_SIGNATURE', '') - try: - verify_standard_webhook_signature( + payload = validate_polar_webhook( getattr(settings, 'POLAR_WEBHOOK_SECRET', ''), request.body, - webhook_id, - webhook_timestamp, - webhook_signature, + {key[5:].replace('_', '-').lower(): value for key, value in request.META.items() if key.startswith('HTTP_')}, ) except WebhookVerificationError as exc: return HttpResponseBadRequest(str(exc)) - try: - payload = json.loads(request.body.decode('utf-8')) - except json.JSONDecodeError: - return HttpResponseBadRequest('Invalid JSON payload.') - - event = process_polar_webhook(payload, event_id=webhook_id) + event_id = request.META.get('HTTP_WEBHOOK_ID', '') or str(payload.get('id') or '') + event = process_polar_webhook(payload, event_id=event_id) return JsonResponse({'success': True, 'status': event.status}) From 9442fed5efc63b9ca417320fed37a1fcb94e8030 Mon Sep 17 00:00:00 2001 From: Nick Bottone Date: Tue, 7 Jul 2026 01:10:05 -0400 Subject: [PATCH 7/7] chore: hide premium nav link before launch --- home/templates/home/base.html | 8 -------- 1 file changed, 8 deletions(-) diff --git a/home/templates/home/base.html b/home/templates/home/base.html index 970c734..a9a30bc 100644 --- a/home/templates/home/base.html +++ b/home/templates/home/base.html @@ -244,14 +244,6 @@ -