diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f7ffaf --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Django environment +# Use "development" locally. Omit or set a production value in production. +PYTHON_ENV=development + +# Django/API secrets +SECRET_KEY=replace-me +API_KEY=replace-me + +# Used in highscores clean-code decoding +NEW_AES_KEY=0123456789abcdef + +# Discord OAuth +DISCORD_CLIENT_SECRET=replace-me + +# Email +SMTP_SERVER=smtp.example.com +SMTP_PASSWORD=replace-me + +# Optional Discord webhook for highscores notifications +HIGHSCORES_WEBHOOK_URL= + +# Polar subscriptions +POLAR_API_BASE_URL=https://api.polar.sh +POLAR_ACCESS_TOKEN= +POLAR_WEBHOOK_SECRET= +POLAR_CHECKOUT_SUCCESS_URL= +POLAR_CUSTOMER_PORTAL_URL= + +# xRC server orchestrator +ORCHESTRATOR_API_BASE_URL= +ORCHESTRATOR_API_TOKEN= +ORCHESTRATOR_REQUEST_TIMEOUT_SECONDS=10 diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 07fdd39..81ca6c4 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -1,42 +1,81 @@ -name: 🚀 Deploy Django +name: Deploy Django on: push: branches: [main] jobs: - build-deploy: - name: 🎉 Build and Deploy + validate: + name: Validate runs-on: ubuntu-latest steps: - - name: 🚚 Get latest code - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v4 - - name: 🔨 Set up Python 3.12 - uses: actions/setup-python@v2 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: "3.12" + cache: pip - - name: 🛠 Install Dependencies + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - - name: ▶ Run Tests - run: | - python manage.py test + - name: Check for missing migrations + run: python manage.py makemigrations --check --dry-run env: - SECRET_KEY: ${{ secrets.BASE_KEY }} - SMTP_SERVER: ${{ secrets.SMTP_SERVER }} - SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} - NEW_AES_KEY: ${{ secrets.NEW_AES_KEY }} - DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} + SECRET_KEY: ci-secret-key + NEW_AES_KEY: 0123456789abcdef + DISCORD_CLIENT_SECRET: ci-discord-secret + + - name: Apply migrations on a fresh database + run: python manage.py migrate --noinput + env: + SECRET_KEY: ci-secret-key + NEW_AES_KEY: 0123456789abcdef + DISCORD_CLIENT_SECRET: ci-discord-secret + + - name: Run Django checks + run: python manage.py check + env: + SECRET_KEY: ci-secret-key + NEW_AES_KEY: 0123456789abcdef + DISCORD_CLIENT_SECRET: ci-discord-secret + + - name: Run tests + run: pytest + env: + SECRET_KEY: ci-secret-key + NEW_AES_KEY: 0123456789abcdef + DISCORD_CLIENT_SECRET: ci-discord-secret + + build-deploy: + name: Build and Deploy + needs: validate + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip - - name: 📦 Collect Static Files + - name: Install dependencies run: | - python manage.py collectstatic --noinput + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Collect static files + run: python manage.py collectstatic --noinput env: SECRET_KEY: ${{ secrets.BASE_KEY }} SMTP_SERVER: ${{ secrets.SMTP_SERVER }} @@ -44,12 +83,12 @@ jobs: NEW_AES_KEY: ${{ secrets.NEW_AES_KEY }} DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} - - name: 🔃 Restart Server + - name: Restart server run: | mkdir tmp touch tmp/restart.txt - - name: ☁ SFTP Deploy + - name: SFTP deploy uses: easingthemes/ssh-deploy@v2 env: SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b5b072..65f92c3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,23 +9,43 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: 🔨 Set up Python 3.12 - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: "3.12" + cache: pip - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt + + - name: Check for missing migrations + run: python manage.py makemigrations --check --dry-run + env: + SECRET_KEY: ci-secret-key + NEW_AES_KEY: 0123456789abcdef + DISCORD_CLIENT_SECRET: ci-discord-secret + + - name: Apply migrations on a fresh database + run: python manage.py migrate --noinput + env: + SECRET_KEY: ci-secret-key + NEW_AES_KEY: 0123456789abcdef + DISCORD_CLIENT_SECRET: ci-discord-secret + + - name: Run Django checks + run: python manage.py check + env: + SECRET_KEY: ci-secret-key + NEW_AES_KEY: 0123456789abcdef + DISCORD_CLIENT_SECRET: ci-discord-secret - - name: ▶ Run Tests - run: | - pytest + - name: Run tests + run: pytest env: - SECRET_KEY: ${{ secrets.BASE_KEY }} - SMTP_SERVER: ${{ secrets.SMTP_SERVER }} - SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} - NEW_AES_KEY: ${{ secrets.NEW_AES_KEY }} - DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} + SECRET_KEY: ci-secret-key + NEW_AES_KEY: 0123456789abcdef + DISCORD_CLIENT_SECRET: ci-discord-secret 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/SRCweb/settings.py b/SRCweb/settings.py index 97d7dbe..cb659a4 100644 --- a/SRCweb/settings.py +++ b/SRCweb/settings.py @@ -37,6 +37,18 @@ # 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) + # 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 +85,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/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/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 @@ + +