├── classic_tetris_project ├── __init__.py ├── web │ ├── __init__.py │ ├── assets │ │ ├── admin.js │ │ ├── stylesheets │ │ │ ├── _profile.scss │ │ │ ├── admin.scss │ │ │ ├── _bootstrap.scss │ │ │ ├── _qualify.scss │ │ │ ├── app.scss │ │ │ ├── _alerts.scss │ │ │ ├── _status_tags.scss │ │ │ ├── _markdown.scss │ │ │ ├── _media.scss │ │ │ ├── _toggle.scss │ │ │ ├── _navbar.scss │ │ │ ├── _forms.scss │ │ │ ├── _colors.scss │ │ │ └── _buttons.scss │ │ ├── js │ │ │ ├── stimulus │ │ │ │ ├── react_controller.js │ │ │ │ ├── site_theme_controller.js │ │ │ │ └── date_picker_controller.js │ │ │ └── react │ │ │ │ └── bracket │ │ │ │ └── tournament_bracket_controller.jsx │ │ └── app.js │ ├── .gitignore │ ├── widgets │ │ ├── __init__.py │ │ └── date_time_picker.py │ ├── templates │ │ ├── event │ │ │ ├── ineligible_reasons │ │ │ │ ├── closed.html │ │ │ │ ├── logged_out.html │ │ │ │ ├── already_qualified.html │ │ │ │ ├── link_twitch.html │ │ │ │ └── link_discord.html │ │ │ ├── all.html │ │ │ ├── qual_rules.html │ │ │ ├── qualify.html │ │ │ ├── qualifier.haml │ │ │ └── index.html │ │ ├── templatetags │ │ │ ├── markdown.html │ │ │ ├── field_list_row.haml │ │ │ ├── react_component.haml │ │ │ ├── field_list_input_row.haml │ │ │ └── module.html │ │ ├── index.html │ │ ├── pages │ │ │ └── page.html │ │ ├── admin │ │ │ ├── base_site.html │ │ │ └── event │ │ │ │ └── duplicate.haml │ │ ├── wiki │ │ │ └── base.html │ │ ├── tournament │ │ │ └── bracket.haml │ │ ├── icons │ │ │ ├── twitch.html │ │ │ ├── hamburger.html │ │ │ ├── twitch_inverted.html │ │ │ └── discord.html │ │ ├── widgets │ │ │ └── date_time_picker.html │ │ ├── oauth │ │ │ └── login.html │ │ ├── live_notifications │ │ │ └── submit.haml │ │ ├── review_qualifiers │ │ │ ├── index.html │ │ │ └── review.haml │ │ ├── user │ │ │ └── show.haml │ │ ├── tournament_match │ │ │ ├── schedule.haml │ │ │ └── report.haml │ │ └── profile │ │ │ └── edit.haml │ ├── views │ │ ├── index.py │ │ ├── policy.py │ │ ├── base.py │ │ ├── autocomplete.py │ │ ├── pages.py │ │ ├── profile.py │ │ ├── simulations.py │ │ ├── user.py │ │ ├── qualifiers.py │ │ └── live_notifications.py │ ├── setup.md │ ├── tests │ │ └── views │ │ │ ├── index.py │ │ │ ├── pages.py │ │ │ ├── user.py │ │ │ ├── oauth.py │ │ │ └── profile.py │ ├── forms │ │ ├── profile.py │ │ ├── live_notification.py │ │ ├── review_qualifiers.py │ │ ├── duplicate_event.py │ │ └── tournament_match.py │ ├── context_processors.py │ └── oauth.py ├── migrations │ ├── __init__.py │ ├── 0013_delete_coin.py │ ├── 0072_remove_tournament_bracket_color.py │ ├── 0008_auto_20190825_0635.py │ ├── 0011_user_same_piece_sets.py │ ├── 0019_discorduser_username.py │ ├── 0033_qualifier_review_data.py │ ├── 0069_alter_qualifier_vod.py │ ├── 0066_match_synced_at.py │ ├── 0077_event_use_custom_font.py │ ├── 0053_auto_20210607_0343.py │ ├── 0065_twitchuser_display_name.py │ ├── 0064_tournament_discord_emote_string.py │ ├── 0035_qualifier_auth_word.py │ ├── 0038_event_pre_qualifying_instructions.py │ ├── 0070_tournament_bracket_color.py │ ├── 0076_tournamentmatch_color.py │ ├── 0017_user_pronouns.py │ ├── 0020_auto_20200420_2028.py │ ├── 0071_alter_tournament_bracket_color.py │ ├── 0073_tournament_bracket_color.py │ ├── 0052_auto_20210607_0311.py │ ├── 0055_auto_20210802_1721.py │ ├── 0029_auto_20201205_1921.py │ ├── 0034_auto_20210125_0712.py │ ├── 0014_auto_20200313_0819.py │ ├── 0063_auto_20220131_0205.py │ ├── 0006_auto_20190812_0423.py │ ├── 0036_auto_20210201_0603.py │ ├── 0026_auto_20200520_0553.py │ ├── 0003_auto_20190628_1759.py │ ├── 0010_auto_20191102_0433.py │ ├── 0001_initial.py │ ├── 0024_auto_20200520_0517.py │ ├── 0057_auto_20220104_0251.py │ ├── 0060_auto_20220123_0527.py │ ├── 0041_auto_20210327_2206.py │ ├── 0050_auto_20210515_0803.py │ ├── 0009_twitchchannel.py │ ├── 0051_auto_20210607_0114.py │ ├── 0079_alter_user_pronouns.py │ ├── 0046_auto_20210405_0650.py │ ├── 0054_auto_20210802_1651.py │ ├── 0059_auto_20220109_0846.py │ ├── 0032_auto_20201226_2255.py │ ├── 0061_auto_20220129_2001.py │ ├── 0048_auto_20210417_2147.py │ ├── 0016_auto_20200318_2227.py │ ├── 0037_auto_20210201_0604.py │ ├── 0062_auto_20220131_0118.py │ ├── 0005_auto_20190810_0847.py │ ├── 0045_auto_20210405_0043.py │ ├── 0025_populate_discord_user_fields.py │ ├── 0067_event_withdrawals_allowed.py │ ├── 0074_auto_20221002_0630.py │ ├── 0030_page.py │ ├── 0047_auto_20210405_0653.py │ ├── 0080_auto_20250714_0114.py │ ├── 0018_websiteuser.py │ ├── 0028_auto_20201130_0208.py │ ├── 0068_auto_20220717_0652.py │ ├── 0023_user_remove_pb_fields.py │ ├── 0056_auto_20211231_0727.py │ ├── 0004_twitchuser_username.py │ ├── 0075_auto_20230429_1913.py │ ├── 0012_coin_side.py │ ├── 0078_auto_20240505_1551.py │ ├── 0022_populate_scorepb.py │ ├── 0015_auto_20200318_0315.py │ ├── 0043_auto_20210327_2316.py │ ├── 0042_auto_20210327_2218.py │ ├── 0002_auto_20190628_1733.py │ ├── 0039_auto_20210228_0824.py │ ├── 0058_auto_20220104_1936.py │ ├── 0044_auto_20210404_2242.py │ ├── 0021_scorepb.py │ ├── 0007_game_match.py │ └── 0049_auto_20210417_2149.py ├── util │ ├── fieldgen │ │ ├── __init__.py │ │ ├── img │ │ │ ├── arrows.png │ │ │ ├── numbers.png │ │ │ ├── template.png │ │ │ └── block_tiles.png │ │ ├── assetpath.py │ │ ├── gravity.py │ │ ├── basecanvas.py │ │ ├── activepiece.py │ │ ├── garbage.py │ │ └── ai.py │ ├── __init__.py │ ├── docs.py │ ├── json_template.py │ ├── memo.py │ ├── constants.py │ ├── cache.py │ └── google_sheets.py ├── commands │ ├── matches │ │ └── __init__.py │ ├── test.py │ ├── __init__.py │ ├── link.py │ ├── testmsg.py │ ├── pb_zip.py │ ├── playstyle.py │ ├── preferred_name.py │ ├── country.py │ └── countdown.py ├── admin.py ├── test_helper │ ├── factories │ │ ├── __init__.py │ │ ├── pages.py │ │ ├── scores.py │ │ ├── users.py │ │ └── events.py │ └── discord.py ├── env.py ├── apps.py ├── models │ ├── restreamers.py │ ├── custom_redirect.py │ ├── coin.py │ ├── __init__.py │ ├── pages.py │ ├── twitch.py │ ├── commands.py │ └── scores.py ├── moderation │ ├── all_caps.py │ ├── rule.py │ └── moderator.py ├── management │ └── commands │ │ └── report_matches.py ├── tests │ └── commands │ │ ├── test.py │ │ └── country.py ├── templates │ └── discord │ │ ├── qualifier_submitted.txt │ │ └── qualifier_reviewed.txt ├── words.py ├── discord_disco.py ├── countries.py ├── facades │ └── user_permissions.py └── logging.py ├── requirements_prod.txt ├── docs └── img │ ├── permissions.png │ └── oauth_permissions.png ├── script ├── start_celery.sh ├── restore_dump.sh └── patch_dump.sql ├── .gitmodules ├── pytest.ini ├── webpack.prod.js ├── classic_tetris_project_django ├── __init__.py ├── wsgi.py ├── celery.py └── urls.py ├── .gitignore ├── webpack.dev.js ├── .editorconfig ├── conftest.py ├── manage.py ├── LICENSE ├── add_tournaments.py ├── package.json ├── webpack.common.js └── export.py /classic_tetris_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /classic_tetris_project/web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements_prod.txt: -------------------------------------------------------------------------------- 1 | psycopg2==2.8.5 2 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/matches/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/admin.js: -------------------------------------------------------------------------------- 1 | import './stylesheets/admin.scss'; 2 | -------------------------------------------------------------------------------- /classic_tetris_project/web/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | 3 | __pycache__/ 4 | 5 | assets/fonts/ -------------------------------------------------------------------------------- /classic_tetris_project/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .constants import * 2 | from .memo import * 3 | -------------------------------------------------------------------------------- /classic_tetris_project/web/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from .date_time_picker import DateTimePicker 2 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/ineligible_reasons/closed.html: -------------------------------------------------------------------------------- 1 | Qualifying for this event is closed. 2 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/ineligible_reasons/logged_out.html: -------------------------------------------------------------------------------- 1 | You must be logged in to qualify. 2 | -------------------------------------------------------------------------------- /docs/img/permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/professor-l/classic-tetris-project/HEAD/docs/img/permissions.png -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/templatetags/markdown.html: -------------------------------------------------------------------------------- 1 |
2 | {{ content }} 3 |
4 | -------------------------------------------------------------------------------- /script/start_celery.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | CELERY_WORKER_RUNNING=1 celery -A classic_tetris_project_django worker -l INFO 4 | -------------------------------------------------------------------------------- /docs/img/oauth_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/professor-l/classic-tetris-project/HEAD/docs/img/oauth_permissions.png -------------------------------------------------------------------------------- /classic_tetris_project/admin.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .web.admin import * 3 | except ModuleNotFoundError: 4 | # private not loaded, ignore 5 | pass 6 | -------------------------------------------------------------------------------- /classic_tetris_project/test_helper/factories/__init__.py: -------------------------------------------------------------------------------- 1 | from .events import * 2 | from .pages import * 3 | from .scores import * 4 | from .users import * 5 | -------------------------------------------------------------------------------- /classic_tetris_project/env.py: -------------------------------------------------------------------------------- 1 | from environ import Env 2 | from django.conf import settings 3 | 4 | assert isinstance(settings.ENV, Env) 5 | env = settings.ENV 6 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/index.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def index(request): 5 | return render(request, "index.html") 6 | -------------------------------------------------------------------------------- /classic_tetris_project/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ClassicTetrisProjectConfig(AppConfig): 5 | name = 'classic_tetris_project' 6 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/policy.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | def cookies(request): 4 | return render(request, "policy/cookies.html") 5 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/img/arrows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/professor-l/classic-tetris-project/HEAD/classic_tetris_project/util/fieldgen/img/arrows.png -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/templatetags/field_list_row.haml: -------------------------------------------------------------------------------- 1 | .field-list__row 2 | .field-list__label 3 | = label 4 | .field-list__value 5 | = value 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "classic_tetris_project/reportmatchmodule"] 2 | path = classic_tetris_project/reportmatchmodule 3 | url = https://github.com/alex-ong/CTMEditor.git 4 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/img/numbers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/professor-l/classic-tetris-project/HEAD/classic_tetris_project/util/fieldgen/img/numbers.png -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/img/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/professor-l/classic-tetris-project/HEAD/classic_tetris_project/util/fieldgen/img/template.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=classic_tetris_project_django.settings 3 | testpaths= 4 | classic_tetris_project/tests 5 | classic_tetris_project/web/tests 6 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/img/block_tiles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/professor-l/classic-tetris-project/HEAD/classic_tetris_project/util/fieldgen/img/block_tiles.png -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'production', 6 | }); 7 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/templatetags/react_component.haml: -------------------------------------------------------------------------------- 1 | %div{data-controller: "react", data-react-component-value: "{{ component }}", data-react-props-value: "{{ props }}"} 2 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_profile.scss: -------------------------------------------------------------------------------- 1 | @use "colors"; 2 | 3 | .profile-header { 4 | &__title { 5 | margin: 0; 6 | } 7 | 8 | &__subtitle { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/ineligible_reasons/already_qualified.html: -------------------------------------------------------------------------------- 1 | You have already qualified for this event. View your qualifier 2 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/ineligible_reasons/link_twitch.html: -------------------------------------------------------------------------------- 1 | You must have a Twitch account linked in order to qualify. Add one from your profile. 2 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/ineligible_reasons/link_discord.html: -------------------------------------------------------------------------------- 1 | You must have a Discord account linked in order to qualify. Add one from your profile. 2 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Classic Tetris Monthly{% endblock %} 4 | 5 | {% block content %} 6 | {% page "index" %} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/pages/page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ page.title }}{% endblock %}j 4 | 5 | {% block content %} 6 | {% markdown page.content %} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /classic_tetris_project/util/docs.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | def parse_docstring(doc: str): 4 | doc = re.sub(r"\n *", r"\n", doc) 5 | doc = re.sub(r"([^\n])\n([^\n])", r"\1 \2", doc) 6 | doc = doc.strip() 7 | return doc 8 | -------------------------------------------------------------------------------- /classic_tetris_project/test_helper/factories/pages.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from classic_tetris_project.models import * 4 | 5 | 6 | class PageFactory(factory.django.DjangoModelFactory): 7 | class Meta: 8 | model = Page 9 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | 3 | {% load render_bundle from webpack_loader %} 4 | 5 | {% block extrahead %} 6 | {% render_bundle "admin" %} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /classic_tetris_project_django/__init__.py: -------------------------------------------------------------------------------- 1 | # This will make sure the app is always imported when 2 | # Django starts so that shared_task will use this app. 3 | from .celery import app as celery_app 4 | 5 | __all__ = ('celery_app',) 6 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/admin.scss: -------------------------------------------------------------------------------- 1 | .markdownx { 2 | .markdownx-editor { 3 | font-family: monospace; 4 | } 5 | .markdownx-preview { 6 | background: #FFFFFF; 7 | color: #000000; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_bootstrap.scss: -------------------------------------------------------------------------------- 1 | // Collapse 2 | .collapse { 3 | &:not(.show) { 4 | display: none; 5 | height: 0; 6 | } 7 | } 8 | 9 | .collapsing { 10 | height: 0; 11 | overflow: hidden; 12 | } 13 | -------------------------------------------------------------------------------- /classic_tetris_project/web/setup.md: -------------------------------------------------------------------------------- 1 | - clone repo 2 | - setup virtualenv 3 | - install postgres 10 and `postgresql-server-dev-10` 4 | - `pip install -r requirements.txt` 5 | - `pip install -r requirements_prod.txt` 6 | - set up .env file 7 | - `npm install` 8 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/wiki/base.html: -------------------------------------------------------------------------------- 1 | {% extends "wiki/base_site.html" %} 2 | 3 | {% block wiki_site_title %} - Wiki{% endblock %} 4 | 5 | {% block wiki_header_branding %} 6 | CTM Wiki 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /classic_tetris_project/web/tests/views/index.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project.test_helper import * 2 | 3 | class Index(Spec): 4 | def test_renders(self): 5 | response = self.client.get("/") 6 | 7 | assert_that(response.status_code, equal_to(200)) 8 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/all.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

All tournaments

5 | {% for event in events %} 6 |

{{ event.name }}

7 | {% endfor %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /classic_tetris_project/web/forms/profile.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from classic_tetris_project.models import User 3 | 4 | class ProfileForm(forms.ModelForm): 5 | class Meta: 6 | model = User 7 | fields = ["preferred_name", "pronouns", "country", "playstyle"] 8 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_qualify.scss: -------------------------------------------------------------------------------- 1 | @use 'colors'; 2 | 3 | .auth-word { 4 | text-align: center; 5 | max-width: 600px; 6 | margin: auto; 7 | margin-bottom: 15px; 8 | 9 | &__word { 10 | font-size: 30px; 11 | font-weight: bold; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /classic_tetris_project/models/restreamers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from .users import User 4 | 5 | 6 | class Restreamer(models.Model): 7 | user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="restreamer") 8 | active = models.BooleanField(default=True) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | *.swp 3 | *.sqlite3 4 | .ackrc 5 | .coverage 6 | 7 | __pycache__/ 8 | static/ 9 | logs/ 10 | cache/ 11 | data/ 12 | node_modules/ 13 | htmlcov/ 14 | .env 15 | *.dump 16 | 17 | /export.py 18 | pbs.csv 19 | webpack-stats.json 20 | 21 | /.vs/ 22 | /.vscode/ 23 | /ctp/ 24 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/tournament/bracket.haml: -------------------------------------------------------------------------------- 1 | - extends "base.html" 2 | 3 | - block title 4 | {{ tournament.name }} Bracket 5 | 6 | - block body 7 | %body{class: "{% if embed %}transparent{% endif %}", data-theme: "dark"} 8 | - react_component "TournamentBracketController" bracket_props 9 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'development', 6 | devtool: 'inline-source-map', 7 | 8 | watch: true, 9 | watchOptions: { 10 | ignored: /node_modules/ 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /classic_tetris_project/util/json_template.py: -------------------------------------------------------------------------------- 1 | import json 2 | def match_template(template, **kwargs): 3 | str_message = template 4 | str_message = str_message.format(**kwargs) 5 | str_message = str_message.replace("{{","{") 6 | str_message = str_message.replace("}}","}") 7 | return json.loads(str_message) -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/templatetags/field_list_input_row.haml: -------------------------------------------------------------------------------- 1 | .field-list__row 2 | .field-list__label.field-list__label--input 3 | %label{for: "{{ field.id_for_label }}"}= label 4 | .field-list__value 5 | - if field 6 | = field 7 | = field.errors 8 | - else 9 | = content 10 | -------------------------------------------------------------------------------- /classic_tetris_project/moderation/all_caps.py: -------------------------------------------------------------------------------- 1 | from .rule import DiscordRule 2 | import time 3 | 4 | class AllCapsRule(DiscordRule): 5 | 6 | def apply(self): 7 | if any(ch.islower() for ch in self.message.content): 8 | time.sleep(0.5) #wait half a second, due to clientside bug. 9 | self.delete_message() 10 | 11 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/templatetags/module.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{ title }} 5 |
6 |
7 |
8 | {{ content }} 9 |
10 |
11 | -------------------------------------------------------------------------------- /classic_tetris_project/test_helper/factories/scores.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from classic_tetris_project.models import * 4 | from .users import * 5 | 6 | 7 | class ScorePBFactory(factory.django.DjangoModelFactory): 8 | class Meta: 9 | model = ScorePB 10 | current = True 11 | score = 100000 12 | user = factory.SubFactory(UserFactory) 13 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/icons/twitch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/icons/hamburger.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /classic_tetris_project/models/custom_redirect.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponseRedirect 2 | from django.contrib.redirects.middleware import RedirectFallbackMiddleware 3 | 4 | class HttpResponseTemporaryRedirect(HttpResponseRedirect): 5 | status_code = 307 6 | 7 | class CustomRedirect(RedirectFallbackMiddleware): 8 | response_redirect_class = HttpResponseTemporaryRedirect 9 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/icons/twitch_inverted.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/assetpath.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class AssetPath(object): 5 | ASSET_FOLDER = "img" 6 | 7 | @staticmethod 8 | def get_file_root(): 9 | return os.path.dirname(os.path.abspath(__file__)) 10 | 11 | @staticmethod 12 | def get_asset_root(): 13 | return os.path.join(AssetPath.get_file_root(), AssetPath.ASSET_FOLDER) 14 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/qual_rules.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Qualifier rules for {{ event.name }}{% endblock %} 4 | 5 | {% block content %} 6 | 7 | {% if event.qualifying_instructions %} 8 | {% markdown event.qualifying_instructions %} 9 | {% else %} 10 |

This event hasn't specified any qualifier rules.

11 | {% endif %} 12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/base.py: -------------------------------------------------------------------------------- 1 | from django.views import View 2 | 3 | from classic_tetris_project.util.memo import lazy 4 | 5 | class BaseView(View): 6 | @lazy 7 | def current_user(self): 8 | if self.request.user.is_authenticated and hasattr(self.request.user, "website_user"): 9 | return self.request.user.website_user.user 10 | else: 11 | return None 12 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0013_delete_coin.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-02-19 03:20 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0012_coin_side'), 10 | ] 11 | 12 | operations = [ 13 | migrations.DeleteModel( 14 | name='Coin', 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/autocomplete.py: -------------------------------------------------------------------------------- 1 | from dal import autocomplete 2 | 3 | from classic_tetris_project.models import TwitchChannel 4 | 5 | 6 | class TwitchChannelAutocomplete(autocomplete.Select2QuerySetView): 7 | def get_queryset(self): 8 | qs = TwitchChannel.objects.all() 9 | if self.q: 10 | qs = qs.filter(twitch_user__username__istartswith=self.q) 11 | 12 | return qs 13 | -------------------------------------------------------------------------------- /classic_tetris_project/models/coin.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | from .users import User 5 | 6 | 7 | class Side(models.Model): 8 | user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="+") 9 | timestamp = models.DateTimeField() 10 | 11 | @staticmethod 12 | def log(user): 13 | Side.objects.create(user=user, timestamp=timezone.now()) 14 | 15 | -------------------------------------------------------------------------------- /classic_tetris_project/moderation/rule.py: -------------------------------------------------------------------------------- 1 | from asgiref.sync import async_to_sync 2 | 3 | class DiscordRule: 4 | def __init__(self, moderator): 5 | self.moderator = moderator 6 | self.message = self.moderator.message 7 | 8 | def delete_message(self): 9 | async_to_sync(self.message.delete)() 10 | 11 | def notify_user(self, message): 12 | self.moderator.user.send_message(message) 13 | -------------------------------------------------------------------------------- /classic_tetris_project/management/commands/report_matches.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from classic_tetris_project.util.match_sheet_reporter import MatchSheetReporter 4 | 5 | class Command(BaseCommand): 6 | def handle(self, *args, **kwargs): 7 | reporter = MatchSheetReporter() 8 | match_count = reporter.sync_all() 9 | self.stdout.write(f"Reported {match_count} matches") 10 | -------------------------------------------------------------------------------- /classic_tetris_project/web/context_processors.py: -------------------------------------------------------------------------------- 1 | def session_processor(request): 2 | if request.path.startswith("/admin"): 3 | return {} 4 | 5 | if request.user.is_authenticated and hasattr(request.user, "website_user"): 6 | current_user = request.user.website_user.user 7 | else: 8 | current_user = None 9 | 10 | return { 11 | "auth_user": request.user, 12 | "current_user": current_user, 13 | } 14 | -------------------------------------------------------------------------------- /classic_tetris_project/tests/commands/test.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project.test_helper import * 2 | 3 | class TestCommand_(CommandSpec): 4 | class discord: 5 | def test_sends_response(self): 6 | self.assert_discord("!test", [ 7 | "Test!" 8 | ]) 9 | 10 | class twitch: 11 | def test_sends_response(self): 12 | self.assert_twitch("!test", [ 13 | "Test!" 14 | ]) 15 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/pages.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.shortcuts import render 3 | 4 | from classic_tetris_project.models import Page 5 | 6 | def page(request, page_slug): 7 | try: 8 | page = Page.objects.filter(public=True).get(slug=page_slug) 9 | return render(request, "pages/page.html", { 10 | "page": page, 11 | }) 12 | except Page.DoesNotExist: 13 | raise Http404() 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,py}] 14 | charset = utf-8 15 | 16 | # 4 space indentation 17 | [*.py] 18 | indent_style = space 19 | indent_size = 4 20 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/test.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from ..util import Platform, DocSection 3 | 4 | @Command.register() 5 | class TestCommand(Command): 6 | """ 7 | Send a test message. 8 | """ 9 | aliases = ("test", "devtest") 10 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 11 | usage = "test" 12 | section = DocSection.OTHER 13 | 14 | def execute(self): 15 | self.send_message("Test!") 16 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/app.scss: -------------------------------------------------------------------------------- 1 | @use 'structure'; 2 | @use 'alerts'; 3 | @use 'bracket'; 4 | @use 'buttons'; 5 | @use 'bootstrap'; 6 | @use 'forms'; 7 | @use 'markdown'; 8 | @use 'navbar'; 9 | @use 'profile'; 10 | @use 'qualify'; 11 | @use 'status_tags'; 12 | @use 'toggle'; 13 | 14 | // External libraries 15 | @import '~simplepicker/dist/simplepicker.css'; 16 | // Override datepicker text 17 | .simpilepicker-date-picker { 18 | color: #000000; 19 | } 20 | -------------------------------------------------------------------------------- /classic_tetris_project/util/memo.py: -------------------------------------------------------------------------------- 1 | # Currently only memoizes nullary instance methods 2 | def memoize(f): 3 | var_name = f"_memo_{f.__name__}" 4 | def helper(self): 5 | if hasattr(self, var_name): 6 | return getattr(self, var_name) 7 | else: 8 | result = f(self) 9 | setattr(self, var_name, result) 10 | return result 11 | return helper 12 | 13 | 14 | def lazy(f): 15 | return property(memoize(f)) 16 | -------------------------------------------------------------------------------- /classic_tetris_project/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .users import User, TwitchUser, DiscordUser, WebsiteUser 2 | from .scores import ScorePB 3 | from .matches import Match, Game 4 | from .twitch import TwitchChannel 5 | from .coin import Side 6 | from .commands import CustomCommand 7 | from .pages import Page 8 | from .events import Event 9 | from .qualifiers import Qualifier 10 | from .tournaments import Tournament, TournamentPlayer, TournamentMatch 11 | from .restreamers import Restreamer 12 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pkgutil 3 | from importlib import import_module 4 | 5 | def import_dir(dirname, package): 6 | for _, package_name, is_pkg in pkgutil.walk_packages([dirname]): 7 | if is_pkg: 8 | import_dir(os.path.join(dirname, package_name), f"{package}.{package_name}") 9 | else: 10 | import_module("." + package_name, package) 11 | 12 | import_dir(os.path.dirname(__file__), "classic_tetris_project.commands") 13 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0072_remove_tournament_bracket_color.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-07-26 19:08 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0071_alter_tournament_bracket_color'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='tournament', 15 | name='bracket_color', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_alerts.scss: -------------------------------------------------------------------------------- 1 | @use "colors"; 2 | 3 | .alert { 4 | font-size: 16px; 5 | padding: 13px 25px; 6 | 7 | &--info { 8 | background-color: colors.$alert-info-bg; 9 | color: colors.$alert-info-text; 10 | } 11 | 12 | &--light-info { 13 | border: 1px colors.$alert-light-info-border solid; 14 | background-color: colors.$alert-light-info-bg; 15 | color: colors.$alert-light-info-text; 16 | } 17 | 18 | a { 19 | color: inherit; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /classic_tetris_project/web/widgets/date_time_picker.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from datetime import datetime, timedelta 3 | 4 | from classic_tetris_project.util import memoize 5 | 6 | class DateTimePicker(forms.widgets.Input): 7 | input_type = "hidden" 8 | template_name = "widgets/date_time_picker.html" 9 | 10 | def format_value(self, value): 11 | if isinstance(value, datetime): 12 | return value.strftime("%Y-%m-%d %H:%M:%S") 13 | else: 14 | return value 15 | -------------------------------------------------------------------------------- /classic_tetris_project/util/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class Platform(Enum): 4 | DISCORD = 0 5 | TWITCH = 1 6 | def display_name(self): 7 | if self == Platform.DISCORD: 8 | return "Discord" 9 | elif self == Platform.TWITCH: 10 | return "Twitch" 11 | else: 12 | raise ValueError("Unhandled platform", self) 13 | 14 | class DocSection(Enum): 15 | USER = 0 16 | ACCOUNT = 1 17 | QUEUE = 2 18 | UTIL = 3 19 | OTHER = 4 20 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0008_auto_20190825_0635.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-25 06:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0007_game_match'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='game', 15 | name='losing_score', 16 | field=models.IntegerField(null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/templates/discord/qualifier_submitted.txt: -------------------------------------------------------------------------------- 1 | {% load humanize %}\ 2 | \ 3 | {% autoescape off %} 4 | {{ qualifier.user.discord_user.user_tag|safe }} submitted a qualifier for [{{ event_name }}]({{ event_url }})! 5 | 6 | {% for label, value in qualifier.type.display_values %}\ 7 | {{ label }}: {{ value|intcomma }} 8 | {% endfor %}\ 9 | {% if qualifier.vod %} 10 | VOD: {{ qualifier.vod }} 11 | {% endif %}\ 12 | {% if qualifier.details %} 13 | {{ qualifier.details }} 14 | {% endif %} 15 | {% endautoescape %} 16 | -------------------------------------------------------------------------------- /classic_tetris_project/words.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import random 3 | from pathlib import Path 4 | 5 | class Words: 6 | WORDS_CSV_PATH = Path(__file__).parent.resolve() / "data" / "words.csv" 7 | FULL_LIST = [] 8 | 9 | @staticmethod 10 | def populate(path=WORDS_CSV_PATH): 11 | with open(path, "r") as f: 12 | Words.FULL_LIST = [row[0] for row in csv.reader(f)] 13 | 14 | @staticmethod 15 | def get_word(): 16 | return random.choice(Words.FULL_LIST).upper() 17 | 18 | Words.populate() 19 | -------------------------------------------------------------------------------- /classic_tetris_project_django/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for classic_tetris_project_django project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'classic_tetris_project_django.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /script/restore_dump.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$1" ]; then 4 | echo "restore_dump.sh [path/to/file.dump]" 5 | exit 1; 6 | fi 7 | 8 | echo "====================" 9 | echo "Creating database" 10 | echo "====================" 11 | createdb tetris 2> /dev/null 12 | 13 | echo "====================" 14 | echo "Restoring dump" 15 | echo "====================" 16 | psql tetris -f $1 17 | 18 | echo "====================" 19 | echo "Applying patches" 20 | echo "====================" 21 | psql tetris -f script/patch_dump.sql 22 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0011_user_same_piece_sets.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-02-04 20:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0010_auto_20191102_0433'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='same_piece_sets', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0019_discorduser_username.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-04-02 07:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0018_websiteuser'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='discorduser', 15 | name='username', 16 | field=models.CharField(max_length=32, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0033_qualifier_review_data.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-01-25 06:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0032_auto_20201226_2255'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='qualifier', 15 | name='review_data', 16 | field=models.JSONField(default=dict), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0069_alter_qualifier_vod.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-07-17 08:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0068_auto_20220717_0652'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='qualifier', 15 | name='vod', 16 | field=models.URLField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0066_match_synced_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-04-10 17:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0065_twitchuser_display_name'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='match', 15 | name='synced_at', 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0077_event_use_custom_font.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2023-11-17 02:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0076_tournamentmatch_color'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='use_custom_font', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0053_auto_20210607_0343.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-06-07 03:43 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0052_auto_20210607_0311'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='tournamentmatch', 15 | options={'permissions': [('restream', 'Can schedule and report restreamed matches')]}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | @pytest.fixture(autouse=True) 4 | def enable_db_access_for_all_tests(db): 5 | pass 6 | 7 | # https://blog.jerrycodes.com/no-http-requests/ 8 | @pytest.fixture(autouse=True) 9 | def no_http_requests(monkeypatch): 10 | def urlopen_mock(self, method, url, *args, **kwargs): 11 | raise RuntimeError( 12 | f"The test was about to {method} {self.scheme}://{self.host}{url}" 13 | ) 14 | 15 | monkeypatch.setattr( 16 | "urllib3.connectionpool.HTTPConnectionPool.urlopen", urlopen_mock 17 | ) 18 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0065_twitchuser_display_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-05 09:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0064_tournament_discord_emote_string'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='twitchuser', 15 | name='display_name', 16 | field=models.CharField(max_length=25, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/templates/discord/qualifier_reviewed.txt: -------------------------------------------------------------------------------- 1 | {% autoescape off %} 2 | Your qualifier for [{{ event_name }}]({{ event_url }}) was **{% if qualifier.approved %}approved{% else %}rejected{% endif %}** by {{ qualifier.reviewed_by.display_name }}.\ 3 | \ 4 | {% if checks %}\ 5 | {% for check, value in checks %} 6 | 7 | {% if value %}:white_check_mark:{% else %}:x:{% endif %} {{ check }}?\ 8 | {% endfor %}\ 9 | {% endif %}\ 10 | \ 11 | {% if qualifier.review_data.notes %} 12 | 13 | {{ qualifier.review_data.notes }} 14 | {% endif %} 15 | {% endautoescape %} 16 | -------------------------------------------------------------------------------- /script/patch_dump.sql: -------------------------------------------------------------------------------- 1 | UPDATE django_site SET domain = 'dev.monthlytetris.info:8000', name = 'dev.monthlytetris.info:8000' WHERE id = 1; 2 | UPDATE classic_tetris_project_twitchchannel SET connected = false; 3 | UPDATE classic_tetris_project_page SET content = regexp_replace(content, 'http(s)?://ctm\.gg', 'http://dev.monthlytetris.info:8000', 'g'); 4 | UPDATE classic_tetris_project_page SET content = regexp_replace(content, 'http(s)?://go\.ctm\.gg', 'http://dev.monthlytetris.info:8000', 'g'); 5 | UPDATE classic_tetris_project_tournament SET google_sheets_id = NULL, google_sheets_range = NULL; 6 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0064_tournament_discord_emote_string.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-02 05:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0063_auto_20220131_0205'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='tournament', 15 | name='discord_emote_string', 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/widgets/date_time_picker.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 9 |
10 |
11 |
12 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0035_qualifier_auth_word.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-01-31 05:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0034_auto_20210125_0712'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='qualifier', 15 | name='auth_word', 16 | field=models.CharField(default='', max_length=6), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0038_event_pre_qualifying_instructions.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-02-01 06:26 2 | 3 | from django.db import migrations 4 | import markdownx.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0037_auto_20210201_0604'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='event', 16 | name='pre_qualifying_instructions', 17 | field=markdownx.models.MarkdownxField(blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0070_tournament_bracket_color.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-07-26 18:24 2 | 3 | import colorfield.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0069_alter_qualifier_vod'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='tournament', 16 | name='bracket_color', 17 | field=colorfield.fields.ColorField(default='#b7b7b7', max_length=18), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/qualify.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Qualify for {{ event.name }}{% endblock %} 4 | 5 | {% block content %} 6 |

Qualify for {{ event.name }}

7 | 8 | {% if event.pre_qualifying_instructions %} 9 | {% markdown event.pre_qualifying_instructions %} 10 | {% endif %} 11 | 12 | Start Qualifier 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0076_tournamentmatch_color.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2023-04-29 22:07 2 | 3 | import colorfield.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0075_auto_20230429_1913'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='tournamentmatch', 16 | name='color', 17 | field=colorfield.fields.ColorField(blank=True, default=None, max_length=18, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/admin/event/duplicate.haml: -------------------------------------------------------------------------------- 1 | - extends "admin/base_site.html" 2 | 3 | - block title 4 | Duplicate Event 5 | 6 | - block content 7 | %h1 Duplicate Event: {{ event.name }} 8 | #content-main 9 | %form{method: "post"} 10 | - csrf_token 11 | = form.non_field_errors 12 | %fieldset.module.aligned 13 | .form-row.field-name 14 | %div 15 | = form.name.errors 16 | %label.required{for: "{{ form.subject.id_for_label }}"} 17 | Name: 18 | = form.name 19 | %input{type: "submit", value: "Submit"} 20 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0017_user_pronouns.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-04-01 02:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0016_auto_20200318_2227'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='pronouns', 16 | field=models.CharField(choices=[('he', 'He/him/his'), ('she', 'She/her/hers'), ('they', 'They/them/theirs')], default='they', max_length=16), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0020_auto_20200420_2028.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-04-20 20:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0019_discorduser_username'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='pronouns', 16 | field=models.CharField(choices=[('he', 'He/him/his'), ('she', 'She/her/hers'), ('they', 'They/them/theirs')], max_length=16, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0071_alter_tournament_bracket_color.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-07-26 18:51 2 | 3 | import colorfield.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0070_tournament_bracket_color'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='tournament', 16 | name='bracket_color', 17 | field=colorfield.fields.ColorField(blank=True, default=None, max_length=18, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0073_tournament_bracket_color.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-07-26 19:08 2 | 3 | import colorfield.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0072_remove_tournament_bracket_color'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='tournament', 16 | name='bracket_color', 17 | field=colorfield.fields.ColorField(blank=True, default=None, max_length=18, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_status_tags.scss: -------------------------------------------------------------------------------- 1 | @use 'colors'; 2 | 3 | .status-tag { 4 | display: inline-block; 5 | padding: 0 0.5em; 6 | line-height: 1.5; 7 | color: colors.$status-tag-text; 8 | 9 | &--green { 10 | background-color: colors.$status-tag-green-bg; 11 | } 12 | 13 | &--yellow { 14 | background-color: colors.$status-tag-yellow-bg; 15 | } 16 | 17 | &--red { 18 | background-color: colors.$status-tag-red-bg; 19 | } 20 | 21 | &--gray { 22 | background-color: colors.$status-tag-gray-bg; 23 | } 24 | 25 | 26 | &--small { 27 | font-size: 12px; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /classic_tetris_project/models/pages.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | from markdownx.models import MarkdownxField 4 | 5 | class Page(models.Model): 6 | title = models.CharField(max_length=64) 7 | slug = models.SlugField(db_index=True) 8 | public = models.BooleanField(default=False) 9 | content = MarkdownxField() 10 | 11 | created_at = models.DateTimeField(auto_now_add=True) 12 | updated_at = models.DateTimeField(auto_now=True) 13 | 14 | def get_absolute_url(self): 15 | return reverse("page", args=[self.slug]) 16 | 17 | def __str__(self): 18 | return self.title 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0052_auto_20210607_0311.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-06-07 03:11 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0051_auto_20210607_0114'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='match', 16 | name='channel', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='classic_tetris_project.twitchchannel'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0055_auto_20210802_1721.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-08-02 17:21 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0054_auto_20210802_1651'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='tournamentmatch', 16 | name='match', 17 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='classic_tetris_project.match'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0029_auto_20201205_1921.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-05 19:21 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0028_auto_20201130_0208'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='customcommand', 16 | name='alias_for', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.customcommand'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_markdown.scss: -------------------------------------------------------------------------------- 1 | @use 'colors'; 2 | 3 | .markdown { 4 | color: var(--text-primary); 5 | 6 | a { 7 | color: var(--link-primary); 8 | } 9 | 10 | table { 11 | border-collapse: collapse; 12 | 13 | th { 14 | border: 1px solid colors.$border-gray; 15 | background-color: colors.$bg-light-gray; 16 | padding: 8px; 17 | 18 | &:not(:first-child) { 19 | border-left: 0; 20 | } 21 | &:not(:last-child) { 22 | border-right: 0; 23 | } 24 | } 25 | 26 | td { 27 | border: 1px solid colors.$border-gray; 28 | padding: 8px; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /classic_tetris_project/web/forms/live_notification.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | POSITIONS =[ 4 | ('topleft', 'Top Left'), 5 | ('topright', 'Top Right'), 6 | ('bottomleft', 'Bottom Left'), 7 | ('bottomright', 'Bottom Right'), 8 | ('centerleft', 'Center Left'), 9 | ('centerright', 'Center Right'), 10 | ('center', 'Center'), 11 | ] 12 | 13 | class LiveNotificationForm(forms.Form): 14 | message = forms.CharField(widget=forms.Textarea, required=False) 15 | duration = forms.IntegerField(label="Duration", min_value=0, max_value=100, required=False) 16 | position = forms.ChoiceField(choices=POSITIONS, widget=forms.RadioSelect, initial="topleft") 17 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0034_auto_20210125_0712.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-01-25 07:12 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0033_qualifier_review_data'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='qualifier', 16 | name='reviewed_by', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qualifiers_reviewed', to='classic_tetris_project.user'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/link.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from ..util import Platform, DocSection 3 | 4 | @Command.register() 5 | class LinkCommand(Command): 6 | """ 7 | Prints instructions for linking accounts from multiple platforms. 8 | """ 9 | aliases = ("link", "linkaccount") 10 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 11 | usage = "link" 12 | section = DocSection.ACCOUNT 13 | 14 | def execute(self): 15 | self.send_message("To link your Twitch or Discord account, head to our website and click 'Login' at the top right: https://go.ctm.gg. Once there, you can view and edit your profile.") 16 | 17 | 18 | # TODO: unlink command lmao 19 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0014_auto_20200313_0819.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-03-13 08:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0013_delete_coin'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='ntsc_pb_19', 16 | field=models.IntegerField(null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='user', 20 | name='ntsc_pb_19_updated_at', 21 | field=models.DateTimeField(null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0063_auto_20220131_0205.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-01-31 02:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0062_auto_20220131_0118'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveConstraint( 14 | model_name='tournamentplayer', 15 | name='unique_tournament_seed', 16 | ), 17 | migrations.AddIndex( 18 | model_name='tournamentplayer', 19 | index=models.Index(fields=['tournament', 'seed'], name='classic_tet_tournam_052ca2_idx'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/testmsg.py: -------------------------------------------------------------------------------- 1 | from .command import Command 2 | from ..util import Platform, DocSection 3 | 4 | # this is so abusable it should've been disabled years ago lmao 5 | # @Command.register() 6 | class TestMessageCommand(Command): 7 | """ 8 | Send a test message to the specified user in a private channel. 9 | """ 10 | aliases = ("testmsg",) 11 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 12 | usage = "testmsg " 13 | section = DocSection.OTHER 14 | 15 | def execute(self, *username): 16 | username = " ".join(username) 17 | 18 | platform_user = self.platform_user_from_username(username) 19 | platform_user.send_message("Test!") 20 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0006_auto_20190812_0423.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-08-12 04:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0005_auto_20190810_0847'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='ntsc_pb_updated_at', 16 | field=models.DateTimeField(null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='user', 20 | name='pal_pb_updated_at', 21 | field=models.DateTimeField(null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0036_auto_20210201_0603.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-02-01 06:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0035_qualifier_auth_word'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='qualifier', 15 | name='submitted', 16 | field=models.BooleanField(default=False), 17 | ), 18 | migrations.AddField( 19 | model_name='qualifier', 20 | name='submitted_at', 21 | field=models.DateTimeField(null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_media.scss: -------------------------------------------------------------------------------- 1 | $tablet-width: 768px; 2 | $desktop-width: 1024px; 3 | 4 | @mixin mobile-only { 5 | @media (max-width: #{$tablet-width - 1}) { 6 | @content; 7 | } 8 | } 9 | 10 | @mixin tablet-only { 11 | @media (min-width: #{$tablet-width}) and (max-width: #{$desktop-width - 1px}) { 12 | @content; 13 | } 14 | } 15 | 16 | @mixin desktop-only { 17 | @media (min-width: #{$desktop-width}) { 18 | @content; 19 | } 20 | } 21 | 22 | @mixin mobile-and-tablet { 23 | @media (max-width: #{$desktop-width - 1px}) { 24 | @content; 25 | } 26 | } 27 | 28 | @mixin tablet-and-desktop { 29 | @media (min-width: #{$tablet-width}) { 30 | @content; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0026_auto_20200520_0553.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-05-20 05:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0025_populate_discord_user_fields'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='discorduser', 15 | name='discriminator', 16 | field=models.CharField(max_length=4), 17 | ), 18 | migrations.AlterField( 19 | model_name='discorduser', 20 | name='username', 21 | field=models.CharField(max_length=32), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0003_auto_20190628_1759.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-28 17:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0002_auto_20190628_1733'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='discorduser', 15 | name='discord_id', 16 | field=models.CharField(max_length=64, unique=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='twitchuser', 20 | name='twitch_id', 21 | field=models.CharField(max_length=64, unique=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0010_auto_20191102_0433.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-11-02 04:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0009_twitchchannel'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='match', 15 | options={'verbose_name_plural': 'matches'}, 16 | ), 17 | migrations.AddField( 18 | model_name='user', 19 | name='playstyle', 20 | field=models.CharField(choices=[('das', 'DAS'), ('hypertap', 'Hypertap'), ('hybrid', 'Hybrid')], max_length=16, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/oauth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | By logging in, you agree to our cookie policy. 6 |

7 | 8 |
9 | 10 | 11 | {% include "icons/discord_inverted.html" %} 12 | 13 | Login with Discord 14 | 15 |
16 | 17 | 18 | {% include "icons/twitch_inverted.html" %} 19 | 20 | Login with Twitch 21 | 22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /classic_tetris_project/util/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | from django.conf import settings 4 | 5 | 6 | class FileCache: 7 | def __init__(self, name): 8 | self.name = name 9 | self.root = os.path.join(settings.BASE_DIR, "cache", name) 10 | os.makedirs(self.root, exist_ok=True) 11 | 12 | def full_path(self, filename): 13 | return os.path.join(self.root, filename) 14 | 15 | def cache_path(self, filename): 16 | return os.path.join("/cache", self.name, filename) 17 | 18 | def has(self, filename): 19 | return os.path.isfile(self.full_path(filename)) 20 | 21 | def put(self, filename, content): 22 | with open(self.full_path(filename), "wb") as f: 23 | f.write(content) 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-28 05:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='User', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('preferred_name', models.CharField(max_length=64, null=True)), 19 | ('ntsc_pb', models.IntegerField(null=True)), 20 | ('pal_pb', models.IntegerField(null=True)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0024_auto_20200520_0517.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-05-20 05:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0023_user_remove_pb_fields'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='discorduser', 15 | name='discriminator', 16 | field=models.CharField(max_length=4, null=True), 17 | ), 18 | migrations.AddConstraint( 19 | model_name='discorduser', 20 | constraint=models.UniqueConstraint(fields=('username', 'discriminator'), name='unique_username_discriminator'), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0057_auto_20220104_0251.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2022-01-04 02:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0056_auto_20211231_0727'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='tournamentmatch', 15 | options={'permissions': [('restream', 'Can schedule and report restreamed matches')], 'verbose_name_plural': 'tournament matches'}, 16 | ), 17 | migrations.AddField( 18 | model_name='match', 19 | name='vod', 20 | field=models.URLField(blank=True, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0060_auto_20220123_0527.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2022-01-23 05:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0059_auto_20220109_0846'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='tournament', 15 | name='google_sheets_id', 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='tournament', 20 | name='google_sheets_range', 21 | field=models.CharField(blank=True, max_length=255, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/gravity.py: -------------------------------------------------------------------------------- 1 | class GravityFrames(object): 2 | LEVELS = [ 3 | 48, 4 | 43, 5 | 38, 6 | 33, 7 | 28, 8 | 23, 9 | 18, 10 | 13, 11 | 8, 12 | 6, 13 | 5, 14 | 5, 15 | 5, 16 | 4, 17 | 4, 18 | 4, 19 | 3, 20 | 3, 21 | 3, 22 | 2, 23 | 2, 24 | 2, 25 | 2, 26 | 2, 27 | 2, 28 | 2, 29 | 2, 30 | 2, 31 | 2, 32 | ] 33 | 34 | @staticmethod 35 | def get_gravityframes(level): 36 | result = 1 37 | level %= 256 38 | if level < 29: 39 | result = GravityFrames.LEVELS[level] 40 | return result 41 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0041_auto_20210327_2206.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-27 22:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0040_auto_20210327_1831'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='tournament', 15 | name='priority', 16 | field=models.IntegerField(default=1), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name='tournament', 21 | name='seed_count', 22 | field=models.IntegerField(default=16), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0050_auto_20210515_0803.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-05-15 08:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0049_auto_20210417_2149'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='tournament', 15 | name='public', 16 | field=models.BooleanField(default=False, help_text='Controls whether the tournament page is available to view'), 17 | ), 18 | migrations.AddField( 19 | model_name='tournamentmatch', 20 | name='restreamed', 21 | field=models.BooleanField(default=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0009_twitchchannel.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-31 05:18 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0008_auto_20190825_0635'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='TwitchChannel', 16 | fields=[ 17 | ('twitch_user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='channel', serialize=False, to='classic_tetris_project.TwitchUser')), 18 | ('connected', models.BooleanField(db_index=True, default=False)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/js/stimulus/react_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import ReactDOM from 'react-dom/client'; 3 | import React from 'react'; 4 | 5 | export default class ReactController extends Controller { 6 | static values = { 7 | component: String, 8 | props: Object 9 | } 10 | 11 | connect() { 12 | const component = window.reactComponents[this.componentValue]; 13 | if (component) { 14 | this.root = ReactDOM.createRoot(this.element); 15 | this.root.render(React.createElement(component, this.propsValue)); 16 | } else { 17 | console.error(`No component named "${this.componentValue}"`); 18 | } 19 | } 20 | 21 | disconnect() { 22 | if (this.root) { 23 | this.root.unmount(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0051_auto_20210607_0114.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-06-07 01:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0050_auto_20210515_0803'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='tournament', 15 | name='restreamed', 16 | field=models.BooleanField(default=False, help_text="Determines whether this tournament's matches can be restreamed by default"), 17 | ), 18 | migrations.AlterField( 19 | model_name='tournamentmatch', 20 | name='restreamed', 21 | field=models.BooleanField(default=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0079_alter_user_pronouns.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2024-07-08 04:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0078_auto_20240505_1551'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='pronouns', 16 | field=models.CharField(blank=True, choices=[('he', 'He/him/his'), ('she', 'She/her/hers'), ('they', 'They/them/theirs'), ('he/they', 'He/they'), ('they/he', 'They/he'), ('she/they', 'She/they'), ('they/she', 'They/she'), ('it', 'It/its'), ('xe', 'Xe/xem/xir'), ('any', 'Any/all'), ('ask', 'Ask')], max_length=16, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classic_tetris_project/test_helper/factories/users.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from classic_tetris_project.models import * 4 | 5 | 6 | class UserFactory(factory.django.DjangoModelFactory): 7 | class Meta: 8 | model = User 9 | 10 | class DiscordUserFactory(factory.django.DjangoModelFactory): 11 | class Meta: 12 | model = DiscordUser 13 | discord_id = factory.Sequence(lambda n: str(n)) 14 | username = factory.Sequence(lambda n: f"User {n}") 15 | discriminator = factory.Sequence(lambda n: f"{n:04}") 16 | 17 | class TwitchUserFactory(factory.django.DjangoModelFactory): 18 | class Meta: 19 | model = TwitchUser 20 | twitch_id = factory.Sequence(lambda n: str(n)) 21 | username = factory.Sequence(lambda n: f"user_{n}") 22 | display_name = factory.LazyAttribute(lambda o: o.username) 23 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/js/stimulus/site_theme_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | 3 | export default class SiteThemeController extends Controller { 4 | static targets = ['checkbox']; 5 | 6 | connect() { 7 | const theme = localStorage.getItem('CTM_THEME') || 'light'; 8 | if (theme === 'dark' && this.hasCheckboxTarget) { 9 | this.checkboxTarget.checked = true; 10 | } 11 | this.setTheme(theme); 12 | } 13 | 14 | setTheme(theme) { 15 | this.element.setAttribute('data-theme', theme); 16 | } 17 | 18 | updateTheme(e) { 19 | try { 20 | const theme = this.checkboxTarget.checked ? "dark" : "light"; 21 | this.setTheme(theme); 22 | localStorage.setItem("CTM_THEME", theme); 23 | } catch (e) { 24 | console.error(e); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0046_auto_20210405_0650.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-04-05 06:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0045_auto_20210405_0043'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='tournamentplayer', 15 | name='name_override', 16 | field=models.CharField(blank=True, max_length=64, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='tournament', 20 | name='placeholders', 21 | field=models.JSONField(blank=True, default=dict, help_text="Reserves a seed that won't get automatically populated by a qualifier"), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0054_auto_20210802_1651.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-08-02 16:51 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0053_auto_20210607_0343'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='match', 16 | name='start_date', 17 | field=models.DateTimeField(blank=True, null=True), 18 | ), 19 | migrations.AddField( 20 | model_name='tournamentmatch', 21 | name='match', 22 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='classic_tetris_project.match'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0059_auto_20220109_0846.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2022-01-09 08:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0058_auto_20220104_1936'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='tournamentmatch', 15 | options={'permissions': [('restream', 'Can report restreamable matches')], 'verbose_name_plural': 'tournament matches'}, 16 | ), 17 | migrations.AddField( 18 | model_name='tournament', 19 | name='active', 20 | field=models.BooleanField(default=True, help_text="Controls whether this tournament's bracket should be updated on match completion"), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0032_auto_20201226_2255.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-26 22:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0031_auto_20201220_0544'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='qualifier', 15 | name='qualifying_type', 16 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 3 Scores')], default=1), 17 | preserve_default=False, 18 | ), 19 | migrations.AlterField( 20 | model_name='event', 21 | name='qualifying_type', 22 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 3 Scores')]), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0061_auto_20220129_2001.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2022-01-29 20:01 2 | 3 | from django.db import migrations 4 | import markdownx.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0060_auto_20220123_0527'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='tournamentmatch', 16 | options={'permissions': [('restream', 'Can schedule and report restreamed matches')], 'verbose_name_plural': 'tournament matches'}, 17 | ), 18 | migrations.AddField( 19 | model_name='tournament', 20 | name='details', 21 | field=markdownx.models.MarkdownxField(blank=True, help_text='Details to show on the tournament page'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/profile.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.shortcuts import redirect 3 | from django.urls import reverse 4 | from django.views.generic.edit import UpdateView 5 | 6 | from classic_tetris_project.models import User 7 | from .base import BaseView 8 | from ..forms.profile import ProfileForm 9 | 10 | class ProfileView(LoginRequiredMixin, BaseView): 11 | def get(self, request): 12 | return redirect(self.current_user) 13 | 14 | class ProfileEditView(LoginRequiredMixin, UpdateView): 15 | form_class = ProfileForm 16 | template_name = "profile/edit.haml" 17 | 18 | def get_object(self): 19 | return self.request.user.website_user.user 20 | 21 | def get_context_data(self, **kwargs): 22 | context = super().get_context_data(**kwargs) 23 | return context 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0048_auto_20210417_2147.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-04-17 21:47 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0047_auto_20210405_0653'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='tournamentmatch', 16 | name='round_number', 17 | field=models.IntegerField(blank=True, null=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='tournamentmatch', 21 | name='tournament', 22 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='classic_tetris_project.tournament'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0016_auto_20200318_2227.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-03-18 22:27 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0015_auto_20200318_0315'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='customcommand', 16 | name='alias_for', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.CustomCommand'), 18 | ), 19 | migrations.AlterField( 20 | model_name='customcommand', 21 | name='output', 22 | field=models.CharField(blank=True, max_length=400, null=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0037_auto_20210201_0604.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-02-01 06:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0036_auto_20210201_0603'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='qualifier', 15 | name='qualifying_data', 16 | field=models.JSONField(null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='qualifier', 20 | name='qualifying_score', 21 | field=models.IntegerField(null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name='qualifier', 25 | name='vod', 26 | field=models.URLField(null=True), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /classic_tetris_project/web/tests/views/pages.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project.test_helper import * 2 | 3 | class Pages(Spec): 4 | def test_invalid_page(self): 5 | response = self.client.get(f"/page/foo/") 6 | 7 | assert_that(response.status_code, equal_to(404)) 8 | 9 | def test_private_page(self): 10 | PageFactory(slug="foo", public=False) 11 | response = self.client.get(f"/page/foo/") 12 | 13 | assert_that(response.status_code, equal_to(404)) 14 | 15 | def test_public_page(self): 16 | PageFactory(slug="foo", public=True, content="This is the page body") 17 | response = self.client.get(f"/page/foo/") 18 | 19 | assert_that(response.status_code, equal_to(200)) 20 | assert_that(response, uses_template("pages/page.html")) 21 | assert_that(str(response.content), contains_string("This is the page body")) 22 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | # Provided commands: https://docs.djangoproject.com/en/3.2/ref/django-admin/ 7 | # Custom commands can be found in classic_tetris_project/management/commands 8 | def main(): 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'classic_tetris_project_django.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/basecanvas.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | 4 | class BaseCanvas(object): 5 | def __init__(self, template_image): 6 | self.img = template_image.copy() 7 | self.frames = [] 8 | 9 | def clone_baseimg(self): 10 | return self.img.copy() 11 | 12 | def add_frame(self, other_image): 13 | self.frames.append(other_image) 14 | 15 | def export_bytearray(self): 16 | byte_array = io.BytesIO() 17 | if len(self.frames) == 0: 18 | self.img.save(byte_array, format="png") 19 | else: 20 | self.img.save( 21 | byte_array, 22 | format="gif", 23 | save_all=True, 24 | append_images=self.frames, 25 | delay=0.060, 26 | loop=0, 27 | ) 28 | byte_array.seek(0) 29 | return byte_array 30 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0062_auto_20220131_0118.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-01-31 01:18 2 | 3 | from django.db import migrations 4 | 5 | def update_tournament_placeholders(apps, schema_editor): 6 | Tournament = apps.get_model("classic_tetris_project", "Tournament") 7 | for tournament in Tournament.objects.all(): 8 | if tournament.placeholders: 9 | for key in tournament.placeholders.keys(): 10 | if isinstance(tournament.placeholders[key], str): 11 | tournament.placeholders[key] = { "name": tournament.placeholders[key] } 12 | tournament.save() 13 | 14 | class Migration(migrations.Migration): 15 | 16 | dependencies = [ 17 | ('classic_tetris_project', '0061_auto_20220129_2001'), 18 | ] 19 | 20 | operations = [ 21 | migrations.RunPython(update_tournament_placeholders), 22 | ] 23 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/activepiece.py: -------------------------------------------------------------------------------- 1 | from .tiles import TileMath 2 | 3 | 4 | class ActivePieceGenerator(object): 5 | TETROMINO_OFFSETS = [[0, 1], [0, 0], [0, -1], [0, -2]] 6 | # TETROMINO_OFFSETS = [[0,0],[-1,-1],[0,-1],[1,-1]] 7 | def __init__(self, tile_gen): 8 | self.tile_gen = tile_gen 9 | 10 | def draw_longbar(self, image, coords, level): 11 | tile = self.tile_gen.get_active_piece_tile(level) 12 | for offset in self.TETROMINO_OFFSETS: 13 | row = coords[1] + offset[1] 14 | if row >= 0: 15 | tile_pos = [coords[0] + offset[0], coords[1] + offset[1]] 16 | tile_pos = [ 17 | tile_pos[0] + TileMath.FIELD_START[0], 18 | tile_pos[1] + TileMath.FIELD_START[1], 19 | ] 20 | image.paste(tile, TileMath.tile_indices_to_pixels(tile_pos)) 21 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/app.js: -------------------------------------------------------------------------------- 1 | import 'jquery'; 2 | import 'jquery-ujs'; 3 | import 'bootstrap'; 4 | import { Application } from '@hotwired/stimulus'; 5 | import { definitionsFromContext } from '@hotwired/stimulus-webpack-helpers'; 6 | 7 | window.Stimulus = Application.start(); 8 | const stimulusContext = require.context('./js/stimulus', true, /\.js$/); 9 | Stimulus.load(definitionsFromContext(stimulusContext)); 10 | 11 | window.reactComponents = {}; 12 | const reactContext = require.context('./js/react', true, /\.jsx?$/); 13 | window.reactContext = reactContext; 14 | reactContext.keys().forEach((key) => { 15 | const module = reactContext(key); 16 | if (module.COMPONENT_NAME) { 17 | // Names are minified 18 | // TODO figure about a better way of registering these 19 | window.reactComponents[module.COMPONENT_NAME] = module.default; 20 | } 21 | }); 22 | 23 | import './stylesheets/app.scss'; 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0005_auto_20190810_0847.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-08-10 08:47 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0004_twitchuser_username'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='discorduser', 16 | name='user', 17 | field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='discord_user', to='classic_tetris_project.User'), 18 | ), 19 | migrations.AlterField( 20 | model_name='twitchuser', 21 | name='user', 22 | field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='twitch_user', to='classic_tetris_project.User'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0045_auto_20210405_0043.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-04-05 00:43 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0044_auto_20210404_2242'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='tournament', 16 | name='placeholders', 17 | field=models.JSONField(default=dict, help_text="Reserves a seed that won't get automatically populated by a qualifier"), 18 | ), 19 | migrations.AlterField( 20 | model_name='tournament', 21 | name='event', 22 | field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, related_name='tournaments', to='classic_tetris_project.event'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0025_populate_discord_user_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-05-20 05:21 2 | 3 | from django.db import migrations 4 | import time 5 | 6 | from classic_tetris_project import discord 7 | 8 | def populate_discord_users(apps, schema_editor): 9 | DiscordUser = apps.get_model('classic_tetris_project', 'DiscordUser') 10 | 11 | for discord_user in DiscordUser.objects.all(): 12 | user_obj = discord.API.user_from_id(discord_user.discord_id) 13 | discord_user.username = user_obj.name 14 | discord_user.discriminator = user_obj.discriminator 15 | discord_user.save() 16 | time.sleep(1) # Avoid running into Discord's rate limit 17 | 18 | class Migration(migrations.Migration): 19 | 20 | dependencies = [ 21 | ('classic_tetris_project', '0024_auto_20200520_0517'), 22 | ] 23 | 24 | operations = [ 25 | migrations.RunPython(populate_discord_users), 26 | ] 27 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0067_event_withdrawals_allowed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-05-01 07:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def backfill_withdrawals_allowed(apps, schema_editor): 7 | Event = apps.get_model("classic_tetris_project", "Event") 8 | Event.objects.update(withdrawals_allowed=models.F("qualifying_open")) 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ('classic_tetris_project', '0066_match_synced_at'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name='event', 20 | name='withdrawals_allowed', 21 | field=models.BooleanField(default=True, help_text='Controls whether users can withdraw their own qualifiers. Automatically disabled when tournaments are seeded.'), 22 | ), 23 | migrations.RunPython(backfill_withdrawals_allowed, migrations.RunPython.noop), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0074_auto_20221002_0630.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-10-02 06:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0073_tournament_bracket_color'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='qualifying_channel_id', 16 | field=models.CharField(blank=True, help_text='Discord channel id that qualifier announcements will be posted to. If blank, announcements will not be posted.', max_length=64), 17 | ), 18 | migrations.AddField( 19 | model_name='event', 20 | name='reporting_channel_id', 21 | field=models.CharField(blank=True, help_text='Discord channel id that match reports will be posted to. If blank, messages will not be posted.', max_length=64), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0030_page.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-06 08:40 2 | 3 | from django.db import migrations, models 4 | import markdownx.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0029_auto_20201205_1921'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Page', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('title', models.CharField(max_length=64)), 19 | ('slug', models.SlugField()), 20 | ('public', models.BooleanField(default=False)), 21 | ('content', markdownx.models.MarkdownxField()), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('updated_at', models.DateTimeField(auto_now=True)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0047_auto_20210405_0653.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-04-05 06:53 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0046_auto_20210405_0650'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='tournamentplayer', 16 | name='qualifier', 17 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='tournament_player', to='classic_tetris_project.qualifier'), 18 | ), 19 | migrations.AlterField( 20 | model_name='tournamentplayer', 21 | name='user', 22 | field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tournament_players', to='classic_tetris_project.user'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0080_auto_20250714_0114.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2025-07-14 01:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0079_alter_user_pronouns'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='event', 15 | name='qualifying_type', 16 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores'), (4, 'Most Maxouts'), (5, 'Lowest Time'), (6, 'Clipped Seven Game Average'), (7, 'CTW Format')]), 17 | ), 18 | migrations.AlterField( 19 | model_name='qualifier', 20 | name='qualifying_type', 21 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores'), (4, 'Most Maxouts'), (5, 'Lowest Time'), (6, 'Clipped Seven Game Average'), (7, 'CTW Format')]), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0018_websiteuser.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-04-02 04:19 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('classic_tetris_project', '0017_user_pronouns'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='WebsiteUser', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('auth_user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='website_user', to=settings.AUTH_USER_MODEL)), 21 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='website_user', to='classic_tetris_project.User')), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /classic_tetris_project/moderation/moderator.py: -------------------------------------------------------------------------------- 1 | from ..env import env 2 | from ..models.users import DiscordUser 3 | from ..util import memoize 4 | 5 | # Explicitly importing all rules 6 | # We want to be very, very sure we WANT a rule before we add it 7 | from .all_caps import AllCapsRule 8 | 9 | class DiscordModerator: 10 | def __init__(self, message): 11 | self.message = message 12 | 13 | def dispatch(self): 14 | rule_class = DISCORD_MODERATION_MAP[str(self.message.channel.id)] 15 | rule = rule_class(self) 16 | rule.apply() 17 | 18 | @property 19 | @memoize 20 | def user(self): 21 | return DiscordUser.get_or_create_from_user_ob(self.message.author) 22 | 23 | @staticmethod 24 | def is_rule(message): 25 | return str(message.channel.id) in DISCORD_MODERATION_MAP.keys() 26 | 27 | 28 | DISCORD_MODERATION_MAP = {} 29 | discord_caps_channel = env("DISCORD_CAPS_CHANNEL", default=None) 30 | if discord_caps_channel: 31 | DISCORD_MODERATION_MAP[discord_caps_channel] = AllCapsRule 32 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0028_auto_20201130_0208.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-30 02:08 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0027_auto_20200621_0117'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveIndex( 15 | model_name='customcommand', 16 | name='classic_tet_twitch__a83c1c_idx', 17 | ), 18 | migrations.AlterField( 19 | model_name='customcommand', 20 | name='alias_for', 21 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.customcommand'), 22 | ), 23 | migrations.AlterField( 24 | model_name='customcommand', 25 | name='output', 26 | field=models.CharField(blank=True, default='', max_length=400), 27 | preserve_default=False, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /classic_tetris_project/test_helper/factories/events.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from django.utils.text import slugify 3 | 4 | from classic_tetris_project.models import * 5 | from classic_tetris_project.test_helper.factories.users import * 6 | 7 | 8 | class EventFactory(factory.django.DjangoModelFactory): 9 | class Meta: 10 | model = Event 11 | name = factory.Sequence(lambda n: f"Event {n}") 12 | slug = factory.LazyAttribute(lambda o: slugify(o.name)) 13 | qualifying_type = 1 14 | 15 | class QualifierFactory(factory.django.DjangoModelFactory): 16 | class Meta: 17 | model = Qualifier 18 | user = factory.SubFactory(UserFactory) 19 | event = factory.SubFactory(EventFactory) 20 | 21 | class Params: 22 | submitted_ = factory.Trait( 23 | qualifying_score=500000, 24 | qualifying_data=[500000], 25 | vod="https://twitch.tv/asdf", 26 | submitted=True, 27 | ) 28 | 29 | approved_ = factory.Trait( 30 | submitted_=True, 31 | approved=True 32 | ) 33 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/live_notifications/submit.haml: -------------------------------------------------------------------------------- 1 | - extends "base.html" 2 | 3 | - block title 4 | Submit Live Notification 5 | 6 | - block content 7 | %form.form{method: "post"} 8 | - csrf_token 9 | 10 | .section 11 | .section__header 12 | %h2.section__title 13 | Live Notifiation Submission 14 | .form.form--narrow 15 | .form__field 16 | %label{for: "{{ live_notification_form.message.id_for_label }}"} 17 | Message contents 18 | = live_notification_form.message 19 | = live_notification_form.message.errors 20 | 21 | .form__field 22 | %label{for: "{{ live_notification_form.duration.id_for_label }}"} 23 | Duration (seconds) 24 | = live_notification_form.duration 25 | = live_notification_form.duration.errors 26 | 27 | .form__field 28 | - for radio in live_notification_form.position 29 | %div= radio 30 | 31 | .form__actions 32 | %input.btn.btn--primary{type: "submit", value: "Submit"} 33 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0068_auto_20220717_0652.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-07-17 06:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0067_event_withdrawals_allowed'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='vod_required', 16 | field=models.BooleanField(default=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='event', 20 | name='qualifying_type', 21 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores'), (4, 'Most Maxouts')]), 22 | ), 23 | migrations.AlterField( 24 | model_name='qualifier', 25 | name='qualifying_type', 26 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores'), (4, 'Most Maxouts')]), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/review_qualifiers/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Review Qualifiers{% endblock %} 4 | 5 | {% block content %} 6 |

Review Qualifiers

7 | {% if qualifiers %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for qualifier in qualifiers %} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% endfor %} 25 |
EventUserScoreSubmitted at
{{ qualifier.event.name }}{{ qualifier.user.display_name }}{{ qualifier.type.format_score }}{{ qualifier.submitted_at }}Review
26 | {% else %} 27 |

No qualifiers to review!

28 | {% endif %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0023_user_remove_pb_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-04-03 04:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0022_populate_scorepb'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='user', 15 | name='ntsc_pb', 16 | ), 17 | migrations.RemoveField( 18 | model_name='user', 19 | name='ntsc_pb_19', 20 | ), 21 | migrations.RemoveField( 22 | model_name='user', 23 | name='ntsc_pb_19_updated_at', 24 | ), 25 | migrations.RemoveField( 26 | model_name='user', 27 | name='ntsc_pb_updated_at', 28 | ), 29 | migrations.RemoveField( 30 | model_name='user', 31 | name='pal_pb', 32 | ), 33 | migrations.RemoveField( 34 | model_name='user', 35 | name='pal_pb_updated_at', 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/qualifier.haml: -------------------------------------------------------------------------------- 1 | - extends "base.html" 2 | 3 | - block title 4 | Qualify for {{ event.name }} 5 | 6 | - block content 7 | %h1 8 | Qualify for {{ event.name }} 9 | 10 | - if event.qualify_instructions 11 | - markdown event.qualifying_instructions 12 | 13 | .auth-word.alert.alert--light-info 14 | Your auth word is: 15 | .auth-word__word 16 | = qualifier.auth_word|upper 17 | When you complete your first game with a score of over 10000, enter this word on the leaderboard. This helps us verify that you're recording your qualifier in real time. 18 | 19 | %form.form{method: "post"} 20 | - csrf_token 21 | = form.non_field_errors 22 | 23 | .field-list 24 | - for field in form.submit_fields 25 | - field_list_input_row field.label_tag 26 | = field 27 | = field.errors 28 | - if field.help_text 29 | .field-list__hint 30 | = field.help_text|safe 31 | 32 | - field_list_row "" 33 | %input.btn.btn--primary{type: "submit", value: "Submit"} 34 | -------------------------------------------------------------------------------- /classic_tetris_project/discord_disco.py: -------------------------------------------------------------------------------- 1 | # Uses disco instead of discordpy. This file is temporary; we'll probably end up switching 2 | # everything over to disco eventually. 3 | 4 | from disco.api.client import APIClient 5 | 6 | from .env import env 7 | 8 | 9 | DISCORD_USER_ID_WHITELIST = env("DISCORD_USER_ID_WHITELIST") 10 | 11 | API = APIClient(env("DISCORD_TOKEN", default="")) 12 | 13 | 14 | def send_direct_message(discord_user_id, *args, **kwargs): 15 | if env("DEBUG") and discord_user_id not in DISCORD_USER_ID_WHITELIST: 16 | print(f"Tried to send message to Discord user {discord_user_id}:") 17 | print(f"{args}, {kwargs}") 18 | return 19 | channel = API.users_me_dms_create(discord_user_id) 20 | API.channels_messages_create(channel, *args, **kwargs) 21 | 22 | def send_channel_message(channel_id, *args, **kwargs): 23 | if env("DEBUG") and not env("DISCORD_CHANNEL_MESSAGES"): 24 | print(f"Tried to send message to Discord channel {channel_id}:") 25 | print(f"{args}, {kwargs}") 26 | return 27 | API.channels_messages_create(channel_id, *args, **kwargs) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2020 Elle Nolan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0056_auto_20211231_0727.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-12-31 07:27 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0055_auto_20210802_1721'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='user', 16 | name='playstyle', 17 | field=models.CharField(blank=True, choices=[('das', 'DAS'), ('hypertap', 'Hypertap'), ('hybrid', 'Hybrid'), ('roll', 'Roll')], max_length=16, null=True), 18 | ), 19 | migrations.CreateModel( 20 | name='Restreamer', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('active', models.BooleanField(default=True)), 24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='restreamer', to='classic_tetris_project.user')), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /classic_tetris_project/countries.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from pathlib import Path 3 | 4 | COUNTRIES_CSV_PATH = Path(__file__).parent.resolve() / "data" / "countries.csv" 5 | 6 | class Country: 7 | ACCEPTED_MAPPINGS = {} 8 | ALL = [] 9 | 10 | def __init__(self, two_letter, full_name): 11 | self.abbreviation = two_letter 12 | self.full_name = full_name 13 | 14 | def get_flag(self): 15 | return f":flag_{self.abbreviation}:" 16 | 17 | @staticmethod 18 | def populate_mappings(path=COUNTRIES_CSV_PATH): 19 | with open(path, "r") as f: 20 | rows = csv.reader(f) 21 | for row in rows: 22 | country = Country(row[0], row[1]) 23 | Country.ALL.append(country) 24 | for column in row: 25 | Country.ACCEPTED_MAPPINGS[column.lower()] = country 26 | Country.ALL.sort(key=lambda country: country.full_name) 27 | 28 | 29 | @staticmethod 30 | def get_country(input_string): 31 | try: 32 | return Country.ACCEPTED_MAPPINGS[input_string.lower()] 33 | except KeyError: 34 | return None 35 | 36 | Country.populate_mappings() 37 | -------------------------------------------------------------------------------- /classic_tetris_project/tests/commands/country.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project.test_helper import * 2 | 3 | class GetCountryCommand_(CommandSpec): 4 | class discord: 5 | def test_with_own_user_and_no_country(self): 6 | self.assert_discord("!country", [ 7 | "User has not set a country." 8 | ]) 9 | 10 | def test_with_own_user_and_country(self): 11 | self.discord_user.user.set_country("us") 12 | self.assert_discord("!country", [ 13 | f"{self.discord_api_user.name} is from United States!" 14 | ]) 15 | 16 | def test_with_other_user_and_no_country(self): 17 | discord_user = DiscordUserFactory(username="Other User") 18 | self.assert_discord("!country Other User", [ 19 | "User has not set a country." 20 | ]) 21 | 22 | def test_with_other_user_and_country(self): 23 | discord_user = DiscordUserFactory(username="Other User") 24 | discord_user.user.set_country("us") 25 | self.assert_discord("!country Other User", [ 26 | "Other User is from United States!" 27 | ]) 28 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0004_twitchuser_username.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-08-07 00:36 2 | 3 | from django.db import migrations, models 4 | 5 | from .. import twitch 6 | 7 | def gen_usernames(apps, schema_editor): 8 | TwitchUser = apps.get_model('classic_tetris_project', 'TwitchUser') 9 | for twitch_user in TwitchUser.objects.all(): 10 | twitch_user.username = twitch.client.get_user(twitch_user.twitch_id).username 11 | twitch_user.save() 12 | 13 | class Migration(migrations.Migration): 14 | 15 | dependencies = [ 16 | ('classic_tetris_project', '0003_auto_20190628_1759'), 17 | ] 18 | 19 | operations = [ 20 | migrations.AddField( 21 | model_name='twitchuser', 22 | name='username', 23 | field=models.CharField(default='', max_length=25), 24 | preserve_default=False, 25 | ), 26 | migrations.RunPython(gen_usernames, reverse_code=migrations.RunPython.noop), 27 | migrations.AlterField( 28 | model_name='twitchuser', 29 | name='username', 30 | field=models.CharField(max_length=25, unique=True), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0075_auto_20230429_1913.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2023-04-29 19:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0074_auto_20221002_0630'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='tournament', 15 | name='bracket_type', 16 | field=models.CharField(choices=[('SINGLE', 'Single Elimination'), ('DOUBLE', 'Double Elimination')], default='SINGLE', max_length=64), 17 | ), 18 | migrations.AlterField( 19 | model_name='event', 20 | name='qualifying_type', 21 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores'), (4, 'Most Maxouts'), (5, 'Lowest Time')]), 22 | ), 23 | migrations.AlterField( 24 | model_name='qualifier', 25 | name='qualifying_type', 26 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores'), (4, 'Most Maxouts'), (5, 'Lowest Time')]), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/garbage.py: -------------------------------------------------------------------------------- 1 | from .tiles import TileMath 2 | from .ai import Aesthetics 3 | 4 | 5 | class GarbageGenerator(object): 6 | TETRIS_HEIGHT = 4 7 | 8 | def __init__(self, tile_gen): 9 | self.tile_gen = tile_gen 10 | 11 | def draw_garbage(self, image, garbage_height, level, target_column): 12 | # draws garbage tiles on field. Leaves 4 rows in target_column at the top 13 | block_choices = self.tile_gen.get_block_tiles(level) 14 | 15 | # tile coordinate starts. 16 | x_start = TileMath.FIELD_START[0] 17 | y_start = TileMath.FIELD_START[1] + (TileMath.FIELD_HEIGHT - garbage_height) 18 | 19 | for y in range(garbage_height): 20 | if y < self.TETRIS_HEIGHT: 21 | tc = target_column 22 | else: 23 | tc = Aesthetics.which_garbage_hole(target_column, y) 24 | for x in range(TileMath.FIELD_WIDTH): 25 | if x == tc: 26 | continue 27 | block = Aesthetics.which_garbage_tile(x, y, block_choices) 28 | coord = [x_start + x, y_start + y] 29 | image.paste(block, TileMath.tile_indices_to_pixels(coord)) 30 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_toggle.scss: -------------------------------------------------------------------------------- 1 | @use "colors"; 2 | 3 | $toggle-width: 75px; 4 | $toggle-height: 30px; 5 | $toggle-offset: 6px; 6 | $toggle-inner: calc($toggle-height - $toggle-offset); 7 | 8 | .toggle { 9 | position: relative; 10 | display: inline-block; 11 | width: $toggle-width; 12 | height: $toggle-height; 13 | 14 | input { 15 | opacity: 0; 16 | height: 0; 17 | width: 0; 18 | 19 | &:checked { 20 | + .slide { 21 | background-color: gray; 22 | 23 | &:before { 24 | transform: translateX(calc($toggle-width - $toggle-offset * 2 - $toggle-inner)); 25 | } 26 | } 27 | } 28 | } 29 | 30 | .slide { 31 | position: absolute; 32 | top: 0; 33 | left: 0; 34 | right: 0; 35 | bottom: 0; 36 | cursor: pointer; 37 | background-color: colors.$bg-light-gray; 38 | transition: 0.3s; 39 | border-radius: $toggle-height; 40 | 41 | &:before { 42 | position: absolute; 43 | content: ""; 44 | height: $toggle-inner; 45 | width: $toggle-inner; 46 | left: $toggle-offset; 47 | bottom: calc($toggle-offset / 2); 48 | background-color: white; 49 | border-radius: 100%; 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0012_coin_side.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-02-11 06:27 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0011_user_same_piece_sets'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Coin', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('heads', models.IntegerField(default=0)), 19 | ('tails', models.IntegerField(default=0)), 20 | ('sides', models.IntegerField(default=0)), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='Side', 25 | fields=[ 26 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('timestamp', models.DateTimeField()), 28 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='classic_tetris_project.User')), 29 | ], 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/event/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load humanize %} 4 | 5 | {% block title %}{{ event.name }}{% endblock %} 6 | 7 | {% block content %} 8 |

{{ event.name }}

9 | 10 | {% if event.event_info %} 11 | {% markdown event.event_info %} 12 | {% endif %} 13 | 14 | {% if user_ineligible_reason %} 15 |
16 | {% include "event/ineligible_reasons/"|add:user_ineligible_reason|add:".html" %} 17 |
18 | {% else %} 19 | Qualify 20 | {% endif %} 21 | 22 |
23 | 24 | {% if qualifier_groups %} 25 |
26 |
27 |
28 | Qualifiers 29 |
30 | 31 | {% if current_user.permissions.review_qualifiers %} 32 |
33 | Review Qualifiers 34 |
35 | {% endif %} 36 |
37 |
38 | {% include "event/qualifier_table.html" with qualifier_groups=qualifier_groups %} 39 |
40 |
41 | {% endif %} 42 | 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /classic_tetris_project_django/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | from celery.signals import task_failure 5 | 6 | # set the default Django settings module for the 'celery' program. 7 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'classic_tetris_project_django.settings') 8 | 9 | app = Celery('classic_tetris_project_django') 10 | 11 | # Using a string here means the worker doesn't have to serialize 12 | # the configuration object to child processes. 13 | # - namespace='CELERY' means all celery-related configuration keys 14 | # should have a `CELERY_` prefix. 15 | app.config_from_object('django.conf:settings', namespace='CELERY') 16 | 17 | # Load task modules from all registered Django app configs. 18 | app.autodiscover_tasks() 19 | 20 | 21 | # https://www.mattlayman.com/blog/2017/django-celery-rollbar/ 22 | if bool(os.environ.get('CELERY_WORKER_RUNNING', False)): 23 | from django.conf import settings 24 | import rollbar 25 | rollbar.init(**settings.ROLLBAR) 26 | 27 | def celery_base_data_hook(request, data): 28 | data['framework'] = 'celery' 29 | 30 | rollbar.BASE_DATA_HOOK = celery_base_data_hook 31 | 32 | @task_failure.connect 33 | def handle_task_failure(**kw): 34 | rollbar.report_exc_info(extra_data=kw) 35 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/user/show.haml: -------------------------------------------------------------------------------- 1 | - extends "base.html" 2 | 3 | - block title 4 | = user.preferred_display_name 5 | 6 | - block content 7 | .profile 8 | .profile-header 9 | %h1.profile-header__title 10 | = user.preferred_display_name 11 | - if user.pronouns 12 | .profile-header__subtitle 13 | = user.get_pronouns_display 14 | 15 | .section 16 | .section__header 17 | %h2.section__title 18 | Profile 19 | - if user == current_user 20 | %a{href: '{% url "profile:edit" %}'} 21 | Edit 22 | .section__body 23 | .field-list 24 | - if user.country 25 | - field_list_row "Country:" 26 | = user.get_country_display 27 | 28 | - if discord_user 29 | - field_list_row "Discord:" 30 | = discord_user.username_with_discriminator 31 | 32 | - if twitch_user 33 | - field_list_row "Twitch:" 34 | %a{href: "{{ twitch_user.twitch_url }}"}= twitch_user.display_name 35 | 36 | - if user.playstyle 37 | - field_list_row "Playstyle:" 38 | = user.get_playstyle_display 39 | 40 | - if pb 41 | - field_list_row "Overall PB:" 42 | = pb 43 | -------------------------------------------------------------------------------- /classic_tetris_project/web/tests/views/user.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project.test_helper import * 2 | 3 | class UserView_(Spec): 4 | def test_with_own_profile(self): 5 | self.sign_in() 6 | response = self.client.get(f"/user/{self.current_user.id}/") 7 | 8 | assert_that(response.status_code, equal_to(200)) 9 | assert_that(response, uses_template("user/show.haml")) 10 | assert_that(response, has_html("a[href='/profile/edit/']", "Edit")) 11 | 12 | def test_with_other_profile_id(self): 13 | user = UserFactory() 14 | response = self.client.get(f"/user/{user.id}/") 15 | 16 | assert_that(response.status_code, equal_to(200)) 17 | 18 | def test_with_other_profile_username(self): 19 | user = UserFactory() 20 | twitch_user = TwitchUserFactory(user=user, username="twitch_username") 21 | response = self.client.get(f"/user/{user.id}/") 22 | 23 | assert_that(response, redirects_to("/user/twitch_username/")) 24 | 25 | response = self.client.get(f"/user/twitch_username/") 26 | 27 | assert_that(response.status_code, equal_to(200)) 28 | 29 | def test_with_nonexistent_profile(self): 30 | response = self.client.get(f"/user/nonexistent_user/") 31 | assert_that(response.status_code, equal_to(404)) 32 | -------------------------------------------------------------------------------- /classic_tetris_project_django/urls.py: -------------------------------------------------------------------------------- 1 | """classic_tetris_project_django URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | from django.conf import settings 19 | from django.conf.urls.static import static 20 | 21 | try: 22 | urlpatterns = [ 23 | path("admin/", admin.site.urls), 24 | path("", include("classic_tetris_project.web.urls")), 25 | ] 26 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 27 | except ModuleNotFoundError as e: 28 | print("Private urls could not be loaded") 29 | print(e) 30 | # web not loaded, ignore all urls 31 | urlpatterns = [] 32 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0078_auto_20240505_1551.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2024-05-05 15:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0077_event_use_custom_font'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='event', 15 | name='qualifying_type', 16 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores'), (4, 'Most Maxouts'), (5, 'Lowest Time'), (6, 'Clipped Seven Game Average')]), 17 | ), 18 | migrations.AlterField( 19 | model_name='qualifier', 20 | name='qualifying_type', 21 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores'), (4, 'Most Maxouts'), (5, 'Lowest Time'), (6, 'Clipped Seven Game Average')]), 22 | ), 23 | migrations.AlterField( 24 | model_name='user', 25 | name='pronouns', 26 | field=models.CharField(blank=True, choices=[('he', 'He/him/his'), ('she', 'She/her/hers'), ('they', 'They/them/theirs'), ('it', 'It/its'), ('xe', 'Xe/xem/xir')], max_length=16, null=True), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /classic_tetris_project/web/tests/views/oauth.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project.test_helper import * 2 | 3 | 4 | class Login(Spec): 5 | url = "/oauth/login/" 6 | 7 | def test_renders(self): 8 | response = self.get() 9 | 10 | assert_that(response.status_code, equal_to(200)) 11 | assert_that(response, uses_template("oauth/login.html")) 12 | assert_that(response, has_html("a[href='/oauth/login/discord/']")) 13 | assert_that(response, has_html("a[href='/oauth/login/twitch/']")) 14 | 15 | def test_renders_with_next_path(self): 16 | response = self.get({ "next": "/profile/" }) 17 | 18 | assert_that(response.status_code, equal_to(200)) 19 | assert_that(response, uses_template("oauth/login.html")) 20 | assert_that(response, has_html("a[href='/oauth/login/discord/?next=%2Fprofile%2F']")) 21 | assert_that(response, has_html("a[href='/oauth/login/twitch/?next=%2Fprofile%2F']")) 22 | 23 | 24 | class Logout(Spec): 25 | url = "/oauth/logout/" 26 | 27 | def test_redirects_when_logged_in(self): 28 | self.sign_in() 29 | response = self.get() 30 | 31 | assert_that(response, redirects_to("/")) 32 | 33 | def test_redirects_when_logged_out(self): 34 | response = self.get() 35 | 36 | assert_that(response, redirects_to("/")) 37 | -------------------------------------------------------------------------------- /classic_tetris_project/models/twitch.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from .users import TwitchUser 4 | from ..util import memoize 5 | from .. import twitch 6 | 7 | 8 | class TwitchChannel(models.Model): 9 | twitch_user = models.OneToOneField(TwitchUser, on_delete=models.CASCADE, related_name="channel", 10 | primary_key=True) 11 | connected = models.BooleanField(default=False, db_index=True) 12 | 13 | @property 14 | def name(self): 15 | return self.twitch_user.display_name 16 | 17 | def summon_bot(self): 18 | self.connected = True 19 | self.save() 20 | # TODO add ability to join/leave channels from a different process 21 | if twitch.client.connection.connected: 22 | twitch.client.join_channel(self.name) 23 | 24 | def eject_bot(self): 25 | self.connected = False 26 | self.save() 27 | twitch.client.leave_channel(self.name) 28 | 29 | @property 30 | @memoize 31 | def client_channel(self): 32 | return twitch.client.get_channel(self.name) 33 | 34 | def send_message(self, message): 35 | self.client_channel.send_message(message) 36 | 37 | def twitch_url(self): 38 | return self.twitch_user.twitch_url 39 | 40 | def __str__(self): 41 | return str(self.twitch_user) 42 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/simulations.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import HttpResponse, HttpResponseBadRequest 3 | from django.views.static import serve 4 | 5 | from classic_tetris_project.util.fieldgen.hz_simulation import HzSimulation 6 | from classic_tetris_project.util.memo import lazy 7 | from .base import BaseView 8 | 9 | 10 | class HzView(BaseView): 11 | def get(self, request): 12 | try: 13 | self.simulation.cache_image() 14 | except (KeyError, ValueError): 15 | return HttpResponseBadRequest("Invalid params") 16 | 17 | if settings.DEBUG: 18 | return serve(request, self.simulation.filename, HzSimulation.IMAGE_CACHE.root) 19 | else: 20 | # Serve up the cached file much more efficiently using nginx: 21 | # https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/ 22 | response = HttpResponse(content_type="image/gif") 23 | response["X-Accel-Redirect"] = HzSimulation.IMAGE_CACHE.cache_path(self.simulation.filename) 24 | return response 25 | 26 | @lazy 27 | def simulation(self): 28 | level = int(self.request.GET["level"]) 29 | height = int(self.request.GET["height"]) 30 | taps = int(self.request.GET["taps"]) 31 | return HzSimulation(level, height, taps) 32 | -------------------------------------------------------------------------------- /classic_tetris_project/models/commands.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.utils import OperationalError 3 | 4 | from .twitch import TwitchChannel 5 | from .users import TwitchUser 6 | 7 | 8 | class CustomCommand(models.Model): 9 | 10 | twitch_channel = models.ForeignKey(TwitchChannel, on_delete=models.CASCADE) 11 | 12 | name = models.CharField(max_length=20) 13 | output = models.CharField(max_length=400, blank=True) 14 | alias_for = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE) 15 | 16 | class Meta: 17 | constraints = [ 18 | models.UniqueConstraint( 19 | # implies index on (twitch_chanel, name) 20 | fields=["twitch_channel", "name"], 21 | name="unique channel plus command" 22 | ) 23 | ] 24 | 25 | @staticmethod 26 | def get_command(channel, command_name): 27 | try: 28 | command = CustomCommand.objects.filter(twitch_channel=channel).get(name=command_name) 29 | except (CustomCommand.DoesNotExist, OperationalError): 30 | return False 31 | 32 | 33 | return command 34 | 35 | def wrap(self, context): 36 | from ..commands.command import CustomTwitchCommand 37 | return CustomTwitchCommand(context, self) 38 | 39 | def __str__(self): 40 | return self.name 41 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/user.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from django.shortcuts import render, redirect 3 | from django.urls import reverse 4 | 5 | from classic_tetris_project.models import User, TwitchUser 6 | from classic_tetris_project.util.memo import lazy 7 | from .base import BaseView 8 | 9 | 10 | class UserView(BaseView): 11 | def get(self, request, id): 12 | user = self.get_user(id) 13 | if hasattr(user, "twitch_user") and id != user.twitch_user.username: 14 | return redirect(reverse("user", args=[user.twitch_user.username])) 15 | 16 | pb = user.get_pb() 17 | if pb is None: 18 | pb = "Not set" 19 | else: 20 | pb = f"{pb:,}" 21 | 22 | return render(request, "user/show.haml", { 23 | "user": user, 24 | "discord_user": (user.discord_user if hasattr(user, "discord_user") else None), 25 | "twitch_user": (user.twitch_user if hasattr(user, "twitch_user") else None), 26 | "pb": pb, 27 | }) 28 | 29 | def get_user(self, id): 30 | twitch_user = TwitchUser.from_username(id) 31 | if twitch_user is not None: 32 | return twitch_user.user 33 | 34 | try: 35 | return User.objects.get(id=int(id)) 36 | except (ValueError, User.DoesNotExist): 37 | raise Http404("User does not exist") 38 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0022_populate_scorepb.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-04-03 04:05 2 | 3 | from django.db import migrations 4 | from django.utils import timezone 5 | 6 | def populate_score_pb(apps, schema_editor): 7 | User = apps.get_model("classic_tetris_project", "User") 8 | ScorePB = apps.get_model("classic_tetris_project", "ScorePB") 9 | now = timezone.now() 10 | for user in User.objects.all(): 11 | if user.ntsc_pb: 12 | ScorePB.objects.create(user=user, score=user.ntsc_pb, console_type="ntsc", 13 | current=True, created_at=user.ntsc_pb_updated_at or now) 14 | if user.pal_pb: 15 | ScorePB.objects.create(user=user, score=user.pal_pb, console_type="pal", 16 | current=True, created_at=user.pal_pb_updated_at or now) 17 | if user.ntsc_pb_19: 18 | ScorePB.objects.create(user=user, score=user.ntsc_pb_19, console_type="ntsc", 19 | starting_level=19, 20 | current=True, created_at=user.ntsc_pb_19_updated_at or now) 21 | 22 | 23 | class Migration(migrations.Migration): 24 | 25 | dependencies = [ 26 | ('classic_tetris_project', '0021_scorepb'), 27 | ] 28 | 29 | operations = [ 30 | migrations.RunPython(populate_score_pb, migrations.RunPython.noop), 31 | ] 32 | -------------------------------------------------------------------------------- /classic_tetris_project/web/forms/review_qualifiers.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.safestring import mark_safe 3 | 4 | from classic_tetris_project.models import Qualifier 5 | 6 | 7 | class ReviewQualifierFormMeta(forms.forms.DeclarativeFieldsMetaclass): 8 | def __new__(mcs, name, bases, attrs): 9 | for check, label in Qualifier.REVIEWER_CHECKS: 10 | field = forms.BooleanField(label=label, label_suffix="?", initial=True, required=False) 11 | attrs[check] = field 12 | return super().__new__(mcs, name, bases, attrs) 13 | 14 | 15 | class ReviewQualifierForm(forms.Form, metaclass=ReviewQualifierFormMeta): 16 | notes = forms.CharField(widget=forms.Textarea, required=False) 17 | approved = forms.TypedChoiceField(coerce=lambda x: x == "True", 18 | choices=((True, "Approve"), (False, "Reject")), 19 | widget=forms.RadioSelect) 20 | 21 | def checks(self): 22 | for check, _ in Qualifier.REVIEWER_CHECKS: 23 | yield self[check] 24 | 25 | def save(self, qualifier, reviewed_by): 26 | qualifier.review( 27 | self.cleaned_data["approved"], 28 | reviewed_by, 29 | checks={ key: self.cleaned_data[key] for key, _ in Qualifier.REVIEWER_CHECKS }, 30 | notes=self.cleaned_data["notes"] 31 | ) 32 | -------------------------------------------------------------------------------- /classic_tetris_project/facades/user_permissions.py: -------------------------------------------------------------------------------- 1 | from ..util import lazy 2 | 3 | class UserPermissions: 4 | REVIEW_QUALIFIERS = "classic_tetris_project.change_qualifier" 5 | RESTREAM = "classic_tetris_project.restream" 6 | REPORT_ALL = "classic_tetris_project.change_tournamentmatch" 7 | CHANGE_TOURNAMENT = "classic_tetris_project.change_tournament" 8 | SEND_LIVE_NOTIFICATIONS = "classic_tetris_project.send_live_notifications" 9 | 10 | def __init__(self, user): 11 | self.user = user 12 | 13 | @lazy 14 | def auth_user(self): 15 | if (self.user and hasattr(self.user, "website_user") 16 | and hasattr(self.user.website_user, "auth_user")): 17 | return self.user.website_user.auth_user 18 | 19 | def review_qualifiers(self): 20 | return self.auth_user is not None and self.auth_user.has_perm(self.REVIEW_QUALIFIERS) 21 | 22 | def restream(self): 23 | return self.auth_user is not None and self.auth_user.has_perm(self.RESTREAM) 24 | 25 | def report_all(self): 26 | return self.auth_user is not None and self.auth_user.has_perm(self.REPORT_ALL) 27 | 28 | def change_tournament(self): 29 | return self.auth_user is not None and self.auth_user.has_perm(self.CHANGE_TOURNAMENT) 30 | 31 | def send_live_notifications(self): 32 | return self.auth_user is not None and self.auth_user.has_perm(self.SEND_LIVE_NOTIFICATIONS) 33 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/tournament_match/schedule.haml: -------------------------------------------------------------------------------- 1 | - extends "base.html" 2 | 3 | - block title 4 | Schedule Match 5 | 6 | - block content 7 | %a{href: "{{ match.get_absolute_url }}"} Back to Match 8 | 9 | .section 10 | .section__header 11 | %h2.section__title 12 | Schedule Match 13 | 14 | .section__body 15 | %form.form{method: "post"} 16 | - csrf_token 17 | = form.non_field_errors 18 | 19 | .field-list 20 | - field_list_row "Tournament:" 21 | %a{href: "{{ match.tournament.get_absolute_url }}"}= match.tournament.name 22 | 23 | - field_list_row "Player 1:" 24 | - if match.player1.user 25 | %a{href: "{{ match.player1.user.get_absolute_url }}"}= match.player1.display_name 26 | - else 27 | = match_display.player1_display_name 28 | 29 | - field_list_row "Player 2:" 30 | - if match.player2.user 31 | %a{href: "{{ match.player2.user.get_absolute_url }}"}= match.player2.display_name 32 | - else 33 | = match_display.player2_display_name 34 | 35 | - field_list_input_row "Twitch channel:" form.channel 36 | - field_list_input_row "Start time:" form.start_date 37 | 38 | - field_list_row "" 39 | %input.btn.btn--primary{type: "submit", value: "Schedule"} 40 | 41 | - block footer 42 | = form.media 43 | -------------------------------------------------------------------------------- /classic_tetris_project/web/forms/duplicate_event.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.db import transaction 3 | 4 | from classic_tetris_project.models import Event 5 | 6 | 7 | class DuplicateEventForm(forms.ModelForm): 8 | class Meta: 9 | model = Event 10 | fields = ["name"] 11 | 12 | @transaction.atomic 13 | def save(self, base_event): 14 | event = Event.objects.create( 15 | name=self.cleaned_data["name"], 16 | qualifying_type=base_event.qualifying_type, 17 | vod_required=base_event.vod_required, 18 | pre_qualifying_instructions=base_event.pre_qualifying_instructions, 19 | qualifying_instructions=base_event.qualifying_instructions, 20 | event_info=base_event.event_info, 21 | qualifying_channel_id=base_event.qualifying_channel_id, 22 | reporting_channel_id=base_event.reporting_channel_id, 23 | ) 24 | for tournament in base_event.tournaments.all(): 25 | event.tournaments.create( 26 | short_name=tournament.short_name, 27 | order=tournament.order, 28 | seed_count=tournament.seed_count, 29 | color=tournament.color, 30 | restreamed=tournament.restreamed, 31 | details=tournament.details, 32 | discord_emote_string=tournament.discord_emote_string 33 | ) 34 | return event 35 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0015_auto_20200318_0315.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-03-18 03:15 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0014_auto_20200313_0819'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='CustomCommand', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=20)), 19 | ('output', models.CharField(max_length=400, null=True)), 20 | ('alias_for', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.CustomCommand')), 21 | ('twitch_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.TwitchChannel')), 22 | ], 23 | ), 24 | migrations.AddIndex( 25 | model_name='customcommand', 26 | index=models.Index(fields=['twitch_channel'], name='classic_tet_twitch__a83c1c_idx'), 27 | ), 28 | migrations.AddConstraint( 29 | model_name='customcommand', 30 | constraint=models.UniqueConstraint(fields=('twitch_channel', 'name'), name='unique channel plus command'), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_navbar.scss: -------------------------------------------------------------------------------- 1 | @use "colors"; 2 | @use "media"; 3 | 4 | .navbar { 5 | display: flex; 6 | flex-flow: row nowrap; 7 | width: 100%; 8 | min-height: 60px; 9 | background-color: var(--bg-nav); 10 | @include media.mobile-only { 11 | flex-flow: row wrap; 12 | } 13 | 14 | &__collapse { 15 | display: flex; 16 | transition: height 0.5s; 17 | 18 | @include media.tablet-and-desktop { 19 | display: flex !important; 20 | } 21 | 22 | @include media.mobile-only { 23 | flex: 0 0 100%; 24 | flex-flow: column; 25 | background-color: var(--bg-nav-drawer); 26 | } 27 | } 28 | 29 | &__item { 30 | flex: 0 0 auto; 31 | padding: 15px 20px; 32 | box-sizing: border-box; 33 | height: 60px; 34 | font-size: 18px; 35 | line-height: 30px; 36 | text-align: right; 37 | color: colors.$navbar-text; 38 | text-decoration: none; 39 | 40 | a { 41 | color: colors.$navbar-text; 42 | } 43 | 44 | &:hover { 45 | color: colors.$navbar-text; 46 | text-decoration: none; 47 | } 48 | 49 | &--title { 50 | font-size: 24px; 51 | } 52 | } 53 | 54 | &__spacer { 55 | flex: 1 0 0px; 56 | } 57 | 58 | &__toggler { 59 | @include media.tablet-and-desktop { 60 | display: none; 61 | } 62 | 63 | .icon { 64 | stroke: colors.$icon-white; 65 | } 66 | } 67 | 68 | .theme-toggle { 69 | position: relative; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /classic_tetris_project/logging.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | import logging.handlers 4 | import os 5 | import sys 6 | 7 | from . import discord, twitch 8 | 9 | internal_logger = logging.getLogger("internal") 10 | 11 | class LoggingManager: 12 | def __init__(self): 13 | self.formatter = logging.Formatter("%(asctime)s [%(name)s] %(levelname)s: %(message)s") 14 | 15 | self.console_handler = logging.StreamHandler() 16 | self.console_handler.setLevel(logging.INFO) 17 | self.console_handler.setFormatter(self.formatter) 18 | 19 | self.file_handler = logging.handlers.TimedRotatingFileHandler( 20 | filename="logs/bot.log", 21 | when="midnight", 22 | interval=1 23 | ) 24 | self.file_handler.setLevel(logging.INFO) 25 | self.file_handler.setFormatter(self.formatter) 26 | self.file_handler.namer = lambda name: datetime.now().strftime("logs/bot-%Y-%m-%d.log") 27 | 28 | def bind(self, logger): 29 | logger.propagate = False 30 | logger.addHandler(self.console_handler) 31 | logger.addHandler(self.file_handler) 32 | logger.setLevel(logging.INFO) 33 | 34 | @staticmethod 35 | def setup(): 36 | try: 37 | os.mkdir("logs") 38 | except FileExistsError: 39 | pass 40 | 41 | manager = LoggingManager() 42 | manager.bind(discord.logger) 43 | manager.bind(twitch.logger) 44 | manager.bind(internal_logger) 45 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/js/stimulus/date_picker_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { DateTime } from 'luxon'; 3 | import SimplePicker from 'simplepicker'; 4 | 5 | export default class DatePickerController extends Controller { 6 | static targets = ['input', 'displayLocal', 'displayUTC']; 7 | 8 | connect() { 9 | this.datetime = DateTime.fromFormat(this.inputTarget.value, 'yyyy-MM-dd HH:mm:ss', { zone: 'UTC' }); 10 | const pickerOptions = this.datetime.invalid ? {} : { selectedDate: this.datetime.toJSDate() }; 11 | this.picker = new SimplePicker(pickerOptions); 12 | this.picker.on('submit', this.onChange.bind(this)); 13 | this.displayTimes(); 14 | } 15 | 16 | open(e) { 17 | e.preventDefault(); 18 | this.picker.open(); 19 | } 20 | 21 | onChange(date, readableDate) { 22 | this.datetime = DateTime.fromJSDate(date); 23 | this.inputTarget.value = this.datetime.toUTC().toISO(); 24 | this.displayTimes(); 25 | } 26 | 27 | displayTimes() { 28 | console.log(this.datetime); 29 | if (this.datetime.invalid) { 30 | this.displayLocalTarget.innerText = ''; 31 | this.displayUTCTarget.innerText = ''; 32 | } else { 33 | const local = this.datetime.toLocal(); 34 | const utc = this.datetime.toUTC(); 35 | this.displayLocalTarget.innerText = `${local.toLocaleString(DateTime.DATETIME_MED)} (${local.zoneName})`; 36 | this.displayUTCTarget.innerText = `${utc.toLocaleString(DateTime.DATETIME_MED)} (UTC)`; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0043_auto_20210327_2316.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-27 23:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0042_auto_20210327_2218'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='tournament', 15 | options={'ordering': ['order']}, 16 | ), 17 | migrations.RemoveField( 18 | model_name='tournament', 19 | name='priority', 20 | ), 21 | migrations.AddField( 22 | model_name='tournament', 23 | name='order', 24 | field=models.PositiveIntegerField(default=0, help_text='Used to order tournaments for seeding'), 25 | ), 26 | migrations.AlterField( 27 | model_name='tournament', 28 | name='name', 29 | field=models.CharField(blank=True, help_text='Full name of the tournament. Leave blank to automatically set to event name + short name.', max_length=64), 30 | ), 31 | migrations.AlterField( 32 | model_name='tournament', 33 | name='seed_count', 34 | field=models.IntegerField(help_text='Number of players to seed into this tournament'), 35 | ), 36 | migrations.AlterField( 37 | model_name='tournament', 38 | name='slug', 39 | field=models.SlugField(blank=True, db_index=False), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/pb_zip.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import zipfile 3 | from .command import Command 4 | from ..models.users import TwitchUser 5 | from ..util import Platform, DocSection 6 | 7 | @Command.register() 8 | class ZipFileCommand(Command): 9 | """ 10 | Sends a zipped list of pbs for each Twitch user. 11 | """ 12 | aliases = ("obsfiles",) 13 | supported_platforms = (Platform.DISCORD,) 14 | usage = "obsfiles [type=ntsc]" 15 | notes = ("Moderator-only",) 16 | section = DocSection.OTHER 17 | 18 | def execute(self, pb_type="ntsc"): 19 | 20 | self.check_moderator() 21 | 22 | buffer = BytesIO() 23 | with zipfile.ZipFile(buffer, "a", zipfile.ZIP_DEFLATED, False) as z: 24 | for twitch_user in TwitchUser.objects.all(): 25 | pb = twitch_user.user.get_pb(pb_type) 26 | 27 | if pb is not None: 28 | name = f"{twitch_user.username}_{pb_type}_pb.txt" 29 | contents = "{n:,}".format(n=pb) 30 | z.writestr(name, contents) 31 | 32 | name = f"{twitch_user.username}_playstyle.txt" 33 | contents = "" 34 | if twitch_user.user.playstyle is not None: 35 | contents = twitch_user.user.playstyle[:3].upper() 36 | contents = contents if contents != "HYP" else "TAP" 37 | z.writestr(name, contents) 38 | 39 | buffer.seek(0) 40 | self.context.send_file(buffer, "pbs.zip") 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /classic_tetris_project/web/forms/tournament_match.py: -------------------------------------------------------------------------------- 1 | from dal import autocomplete 2 | from datetime import datetime 3 | from django import forms 4 | from django.core.exceptions import ValidationError 5 | from django.urls import reverse 6 | from django_select2 import forms as s2forms 7 | 8 | from classic_tetris_project.models import TwitchChannel 9 | from classic_tetris_project.models import Match 10 | from .. import widgets 11 | 12 | 13 | class ScheduleForm(forms.ModelForm): 14 | class Meta: 15 | model = Match 16 | fields = ["channel", "start_date"] 17 | 18 | channel = forms.ModelChoiceField( 19 | queryset=TwitchChannel.objects.all(), 20 | widget=autocomplete.ModelSelect2(url=reverse("autocomplete:twitch_channel")), 21 | ) 22 | start_date = forms.DateTimeField(widget=widgets.DateTimePicker) 23 | 24 | 25 | class ReportForm(forms.ModelForm): 26 | class Meta: 27 | model = Match 28 | fields = ["wins1", "wins2", "channel", "vod", "ended_at"] 29 | 30 | channel = forms.ModelChoiceField( 31 | queryset=TwitchChannel.objects.all(), 32 | widget=autocomplete.ModelSelect2(url=reverse("autocomplete:twitch_channel")), 33 | ) 34 | ended_at = forms.DateTimeField(widget=widgets.DateTimePicker, required=False) 35 | 36 | def clean(self): 37 | if self.cleaned_data["wins1"] == self.cleaned_data["wins2"]: 38 | raise ValidationError("One player must have won more games than the other") 39 | 40 | def save(self, reported_by): 41 | super().save() 42 | self.instance.end(reported_by) 43 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0042_auto_20210327_2218.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-03-27 22:18 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0041_auto_20210327_2206'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='tournament', 16 | name='short_name', 17 | field=models.CharField(default='', help_text='Used in the context of an event, e.g. "Challenger\'s Circuit"', max_length=64), 18 | preserve_default=False, 19 | ), 20 | migrations.AddField( 21 | model_name='tournament', 22 | name='slug', 23 | field=models.SlugField(db_index=False, default=''), 24 | preserve_default=False, 25 | ), 26 | migrations.AlterField( 27 | model_name='tournament', 28 | name='event', 29 | field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.event'), 30 | ), 31 | migrations.AlterField( 32 | model_name='tournament', 33 | name='name', 34 | field=models.CharField(help_text='Full name of the tournament', max_length=64), 35 | ), 36 | migrations.AddConstraint( 37 | model_name='tournament', 38 | constraint=models.UniqueConstraint(fields=('event_id', 'slug'), name='unique_event_slug'), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0002_auto_20190628_1733.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-28 17:33 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='user', 16 | name='country', 17 | field=models.CharField(max_length=3, null=True), 18 | ), 19 | migrations.CreateModel( 20 | name='TwitchUser', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('twitch_id', models.CharField(max_length=64)), 24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.User')), 25 | ], 26 | options={ 27 | 'abstract': False, 28 | }, 29 | ), 30 | migrations.CreateModel( 31 | name='DiscordUser', 32 | fields=[ 33 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('discord_id', models.CharField(max_length=64)), 35 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.User')), 36 | ], 37 | options={ 38 | 'abstract': False, 39 | }, 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/profile/edit.haml: -------------------------------------------------------------------------------- 1 | - extends "base.html" 2 | 3 | - block title 4 | Edit Profile 5 | 6 | - block content 7 | .profile 8 | .section 9 | .section__header 10 | %h2.section__title 11 | Edit Profile 12 | %form.form{method: "post"} 13 | - csrf_token 14 | 15 | .section__body 16 | = form.non_field_errors 17 | .field-list 18 | - field_list_input_row "Preferred name:" form.preferred_name 19 | - field_list_input_row "Pronouns:" form.pronouns 20 | - field_list_input_row "Country:" form.country 21 | - field_list_row "Discord:" 22 | - if user.discord_user 23 | = user.discord_user.username_with_discriminator 24 | - else 25 | %a{href: '{% url "oauth:login" provider="discord" %}?merge_accounts=true&next={% url "profile:edit" %}'} 26 | Link your Discord account 27 | 28 | - field_list_row "Twitch:" 29 | - if user.twitch_user 30 | %a{href: "{{ user.twitch_user.twitch_url }}"}= user.twitch_user.display_name 31 | - else 32 | %a{href: '{% url "oauth:login" provider="twitch"%}?merge_accounts=true&next={% url "profile:edit" %}'} 33 | Link your Twitch account 34 | 35 | - field_list_input_row "Playstyle:" form.playstyle 36 | 37 | .field-list__row 38 | .field-list__label 39 | .field-list__value 40 | %input.btn.btn--primary{type: "submit", value: "Update"} 41 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0039_auto_20210228_0824.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-02-28 08:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('classic_tetris_project', '0038_event_pre_qualifying_instructions'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='qualifier', 15 | name='withdrawn', 16 | field=models.BooleanField(default=False), 17 | ), 18 | migrations.AlterField( 19 | model_name='event', 20 | name='qualifying_type', 21 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores')]), 22 | ), 23 | migrations.AlterField( 24 | model_name='qualifier', 25 | name='qualifying_data', 26 | field=models.JSONField(blank=True, null=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='qualifier', 30 | name='qualifying_score', 31 | field=models.IntegerField(blank=True, null=True), 32 | ), 33 | migrations.AlterField( 34 | model_name='qualifier', 35 | name='qualifying_type', 36 | field=models.IntegerField(choices=[(1, 'Highest Score'), (2, 'Highest 2 Scores'), (3, 'Highest 3 Scores')]), 37 | ), 38 | migrations.AlterField( 39 | model_name='qualifier', 40 | name='submitted_at', 41 | field=models.DateTimeField(blank=True, null=True), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_forms.scss: -------------------------------------------------------------------------------- 1 | @use 'colors'; 2 | 3 | .field-list { 4 | &__row { 5 | display: table-row; 6 | } 7 | 8 | &__label { 9 | display: table-cell; 10 | padding: 5px; 11 | width: 120px; 12 | text-align: right; 13 | vertical-align: top; 14 | 15 | &--input { 16 | padding: 11px 5px; 17 | } 18 | } 19 | 20 | &__value { 21 | display: table-cell; 22 | padding: 5px; 23 | vertical-align: top; 24 | } 25 | 26 | &__hint { 27 | margin-top: 5px; 28 | font-size: 12px; 29 | } 30 | } 31 | 32 | .errorlist { 33 | list-style: none; 34 | margin: 0; 35 | padding: 0; 36 | color: colors.$error-text; 37 | font-size: 14px; 38 | } 39 | 40 | .form { 41 | &--narrow { 42 | width: 400px; 43 | max-width: 100%; 44 | margin: auto; 45 | 46 | input[type="text"], input[type="number"], input[type="url"], select, textarea { 47 | width: 100%; 48 | } 49 | } 50 | 51 | &__field + &__field { 52 | margin-top: 5px; 53 | } 54 | 55 | &__actions { 56 | margin-top: 15px; 57 | } 58 | 59 | // To be refactored once form rendering is streamlined 60 | input[type="text"], input[type="number"], input[type="url"], select, textarea { 61 | padding: 5px; 62 | font-size: 16px; 63 | line-height: 20px; 64 | border: 1px solid colors.$border-gray; 65 | 66 | &:focus { 67 | outline: 2px solid colors.$border-blue; 68 | } 69 | } 70 | 71 | select:after { 72 | color: colors.$border-gray; 73 | } 74 | 75 | textarea { 76 | display: block; 77 | resize: none; 78 | box-sizing: border-box; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0058_auto_20220104_1936.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2022-01-04 19:36 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('classic_tetris_project', '0057_auto_20220104_0251'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='match', 17 | name='reported_by', 18 | field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='classic_tetris_project.user'), 19 | ), 20 | migrations.AlterField( 21 | model_name='match', 22 | name='ended_at', 23 | field=models.DateTimeField(blank=True, null=True), 24 | ), 25 | migrations.AlterField( 26 | model_name='match', 27 | name='wins1', 28 | field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]), 29 | ), 30 | migrations.AlterField( 31 | model_name='match', 32 | name='wins2', 33 | field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]), 34 | ), 35 | migrations.AlterField( 36 | model_name='tournamentmatch', 37 | name='match', 38 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tournament_match', to='classic_tetris_project.match'), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0044_auto_20210404_2242.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-04-04 22:42 2 | 3 | import colorfield.fields 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('classic_tetris_project', '0043_auto_20210327_2316'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='tournament', 17 | name='color', 18 | field=colorfield.fields.ColorField(default='#000000', max_length=18), 19 | ), 20 | migrations.AlterField( 21 | model_name='tournament', 22 | name='short_name', 23 | field=models.CharField(help_text='Used in the context of an event, e.g. "Masters Event"', max_length=64), 24 | ), 25 | migrations.AlterField( 26 | model_name='tournamentplayer', 27 | name='tournament', 28 | field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, related_name='tournament_players', to='classic_tetris_project.tournament'), 29 | ), 30 | migrations.AlterField( 31 | model_name='tournamentplayer', 32 | name='user', 33 | field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.PROTECT, related_name='tournament_players', to='classic_tetris_project.user'), 34 | ), 35 | migrations.AddConstraint( 36 | model_name='tournamentplayer', 37 | constraint=models.UniqueConstraint(fields=('tournament', 'seed'), name='unique_tournament_seed'), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /classic_tetris_project/util/fieldgen/ai.py: -------------------------------------------------------------------------------- 1 | import random 2 | from .tiles import TileMath 3 | 4 | 5 | class Aesthetics(object): 6 | # Artificially intelligent aesthetics class 7 | @staticmethod 8 | def is_odd(number): 9 | # artificial intelligence for determining pseudo-random results 10 | return number % 2 == 1 11 | 12 | @staticmethod 13 | def get_target_column(sequence): 14 | # returns an artificially intelligently derived column to target, 15 | # based on a given sequence 16 | CENTER_COLUMN = TileMath.FIELD_WIDTH // 2 17 | left_right = Aesthetics.get_piece_shift_direction(sequence) 18 | return CENTER_COLUMN + len(sequence) * left_right 19 | 20 | @staticmethod 21 | def get_piece_shift_direction(sequence): 22 | # returns -1 or 1 depending on offset from centre column 23 | left_right = -1 if Aesthetics.is_odd(len(sequence)) else 1 24 | return left_right 25 | 26 | @staticmethod 27 | def which_garbage_hole(excluded_column, row): 28 | # given a target column, decides a column for garbage based on 29 | # artificial intelligence and digital aesthetics 30 | # to do: extend using neural networks. 31 | cells = [i for i in range(TileMath.FIELD_WIDTH)] 32 | cells.remove(excluded_column) 33 | return random.choice(cells) 34 | 35 | @staticmethod 36 | def which_garbage_tile(target_column, target_row, choices): 37 | # given a target board position, decides which tile to use, 38 | # using artificial intelligence and digital aesthetics 39 | # TODO: extend using machine learning. 40 | return random.choice(choices) 41 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/js/react/bracket/tournament_bracket_controller.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import useBracketState from './use_bracket_state'; 5 | import TournamentBracket from './tournament_bracket'; 6 | import BracketControls from './bracket_controls'; 7 | 8 | 9 | export const COMPONENT_NAME = 'TournamentBracketController'; 10 | 11 | const TournamentBracketController = (props) => { 12 | const { bracketUrl, refreshUrl } = props; 13 | const { state, dispatch } = useBracketState(props); 14 | 15 | const [matches, setMatches] = useState(props.matches); 16 | const [timestamp, setTimestamp] = useState(props.ts); 17 | 18 | useEffect(() => { 19 | let id = null; 20 | if (state.autoRefresh) { 21 | id = window.setInterval(() => { 22 | const url = new URL(refreshUrl); 23 | url.searchParams.set('ts', timestamp); 24 | fetch(url.href) 25 | .then(response => response.json()) 26 | .then(data => { 27 | if (data['matches']) { 28 | setMatches(data.matches); 29 | setTimestamp(data.ts); 30 | } 31 | }); 32 | }, 60000); 33 | } 34 | 35 | return () => { 36 | if (id !== null) { 37 | window.clearInterval(id); 38 | } 39 | }; 40 | }, [state.autoRefresh, timestamp]); 41 | 42 | return ( 43 |
44 | 45 | {!state.embed && 46 | 47 | } 48 |
49 | ); 50 | }; 51 | 52 | 53 | export default TournamentBracketController; 54 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0021_scorepb.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-04-07 04:04 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0020_auto_20200420_2028'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='ScorePB', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('score', models.IntegerField(blank=True, null=True)), 19 | ('lines', models.IntegerField(blank=True, null=True)), 20 | ('starting_level', models.IntegerField(blank=True, null=True)), 21 | ('console_type', models.CharField(choices=[('ntsc', 'NTSC'), ('pal', 'PAL')], default='ntsc', max_length=5)), 22 | ('current', models.IntegerField()), 23 | ('created_at', models.DateTimeField(blank=True)), 24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_pbs', related_query_name='score_pb', to='classic_tetris_project.User')), 25 | ], 26 | ), 27 | migrations.AddIndex( 28 | model_name='scorepb', 29 | index=models.Index(condition=models.Q(current=True), fields=['user', 'current'], name='user_current'), 30 | ), 31 | migrations.AddConstraint( 32 | model_name='scorepb', 33 | constraint=models.UniqueConstraint(condition=models.Q(current=True), fields=('user', 'starting_level', 'console_type'), name='unique_when_current'), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /classic_tetris_project/util/google_sheets.py: -------------------------------------------------------------------------------- 1 | from google.oauth2 import service_account 2 | from googleapiclient import errors 3 | from googleapiclient.discovery import build 4 | 5 | from django.conf import settings 6 | 7 | 8 | class GoogleSheetsError(Exception): 9 | pass 10 | 11 | class GoogleSheetsService: 12 | SCOPES = ["https://www.googleapis.com/auth/spreadsheets"] 13 | SERVICE_ACCOUNT_FILE = settings.ENV("GOOGLE_SERVICE_ACCOUNT_FILE") 14 | 15 | def __init__(self): 16 | if not self.__class__.SERVICE_ACCOUNT_FILE: 17 | raise GoogleSheetsError("Service account not configured") 18 | credentials = service_account.Credentials.from_service_account_file( 19 | self.__class__.SERVICE_ACCOUNT_FILE, 20 | scopes=self.__class__.SCOPES) 21 | self.service = build("sheets", "v4", credentials=credentials) 22 | 23 | def update(self, spreadsheet_id, sheet_range, data): 24 | request = self.service.spreadsheets().values().update( 25 | spreadsheetId=spreadsheet_id, 26 | range=sheet_range, 27 | valueInputOption="RAW", 28 | body={ "values": data }, 29 | ) 30 | try: 31 | return request.execute() 32 | except errors.HttpError as e: 33 | raise GoogleSheetsError(e.error_details) 34 | 35 | def append(self, spreadsheet_id, sheet_range, data): 36 | request = self.service.spreadsheets().values().append( 37 | spreadsheetId=spreadsheet_id, 38 | range=sheet_range, 39 | valueInputOption="RAW", 40 | body={ "values": data }, 41 | ) 42 | try: 43 | return request.execute() 44 | except errors.HttpError as e: 45 | raise GoogleSheetsError(e.error_details) 46 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/icons/discord.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/playstyle.py: -------------------------------------------------------------------------------- 1 | from .command import Command, CommandException 2 | from ..util import Platform, DocSection 3 | 4 | @Command.register() 5 | class GetPlaystyleCommand(Command): 6 | """ 7 | Prints the user's playstyle, or yours if no argument is provided. 8 | """ 9 | aliases = ("playstyle", "getplaystyle") 10 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 11 | usage = "playstyle [username] (default username you)" 12 | section = DocSection.USER 13 | 14 | def execute(self, *username): 15 | username = username[0] if len(username) == 1 else self.context.args_string 16 | platform_user = (self.platform_user_from_username(username) if username 17 | else self.context.platform_user) 18 | user = platform_user.user if platform_user else None 19 | 20 | if user and user.playstyle: 21 | self.send_message("{name}'s playstyle is {style}!".format( 22 | name=self.context.display_name(platform_user), 23 | style=user.get_playstyle_display() 24 | )) 25 | else: 26 | self.send_message("User has not set a playstyle.") 27 | 28 | @Command.register() 29 | class SetPlaystyleCommand(Command): 30 | """ 31 | Sets your playstyle to any of the valid options. 32 | """ 33 | aliases = ("setplaystyle", "setstyle") 34 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 35 | usage = "setplaystyle " 36 | section = DocSection.USER 37 | 38 | def execute(self, style): 39 | if self.context.user.set_playstyle(style): 40 | self.send_message(f"Your playstyle is now {self.context.user.get_playstyle_display()}!") 41 | else: 42 | raise CommandException("Invalid playstyle. Valid playstyles: DAS, Hypertap, Hybrid, Roll.") 43 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/tournament_match/report.haml: -------------------------------------------------------------------------------- 1 | - extends "base.html" 2 | 3 | - block title 4 | Report Match 5 | 6 | - block content 7 | %a{href: "{{ match.get_absolute_url }}"} Back to Match 8 | 9 | .section 10 | .section__header 11 | %h2.section__title 12 | Report Match Results 13 | 14 | .section__body 15 | %form.form{method: "post"} 16 | - csrf_token 17 | = form.non_field_errors 18 | 19 | .field-list 20 | - field_list_row "Tournament:" 21 | %a{href: "{{ match.tournament.get_absolute_url }}"}= match.tournament.name 22 | 23 | - field_list_row "Player 1:" 24 | - if match.player1.user 25 | %a{href: "{{ match.player1.user.get_absolute_url }}"}= match.player1.display_name 26 | - else 27 | = match_display.player1_display_name 28 | - field_list_input_row "Games won:" form.wins1 29 | 30 | - field_list_row "Player 2:" 31 | - if match.player2.user 32 | %a{href: "{{ match.player2.user.get_absolute_url }}"}= match.player2.display_name 33 | - else 34 | = match_display.player2_display_name 35 | - field_list_input_row "Games won:" form.wins2 36 | 37 | - field_list_input_row "Twitch channel:" form.channel 38 | - field_list_input_row "VOD:" form.vod 39 | - field_list_input_row "End time:" 40 | = form.ended_at 41 | = form.ended_at.errors 42 | .field-list__hint 43 | Leave blank to set to now 44 | 45 | - field_list_row "" 46 | %input.btn.btn--primary{type: "submit", value: "Submit", data-confirm: "Are you sure this is correct? Once this match is reported, only a moderator will be able to change the results."} 47 | 48 | - block footer 49 | = form.media 50 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0007_game_match.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-08-12 16:10 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0006_auto_20190812_0423'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Match', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('wins1', models.IntegerField(default=0)), 19 | ('wins2', models.IntegerField(default=0)), 20 | ('ended_at', models.DateTimeField(null=True)), 21 | ('channel', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='classic_tetris_project.TwitchUser')), 22 | ('player1', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='classic_tetris_project.User')), 23 | ('player2', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='classic_tetris_project.User')), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='Game', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('losing_score', models.IntegerField()), 31 | ('ended_at', models.DateTimeField(auto_now_add=True)), 32 | ('match', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='classic_tetris_project.Match')), 33 | ('winner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='classic_tetris_project.User')), 34 | ], 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_colors.scss: -------------------------------------------------------------------------------- 1 | $text-black: #000000; 2 | $text-white: #FFFFFF; 3 | 4 | $navbar-text: $text-white; 5 | 6 | $alert-info-bg: #2D9CDB; 7 | $alert-info-text: $text-white; 8 | $alert-light-info-border: #2D9CD8; 9 | $alert-light-info-bg: #BDEFFF; 10 | $alert-light-info-text: #2D9CD8; 11 | 12 | $bg-light-gray: #DDDDDD; 13 | 14 | $border-gray: #BDBDBD; 15 | $border-blue: #2D9CDB; 16 | 17 | $icon-white: #FFFFFF; 18 | 19 | $error-text: #B40000; 20 | 21 | $button-primary-bg: #27AE60; 22 | $button-primary-text: $text-white; 23 | $button-secondary-bg: #2D9CDB; 24 | $button-secondary-text: $text-white; 25 | $button-discord-bg: #7289DA; 26 | $button-discord-text: $text-white; 27 | $button-twitch-bg: #9147FF; 28 | $button-twitch-text: $text-white; 29 | $button-red-bg: #EB5757; 30 | $button-red-text: $text-white; 31 | 32 | $module-bg: #FFFFFF; 33 | $module-header-text: $text-white; 34 | 35 | $qualifier-table-text: $text-white; 36 | 37 | $status-tag-text: $text-white; 38 | $status-tag-green-bg: #00C314; 39 | $status-tag-yellow-bg: #F2C94C; 40 | $status-tag-red-bg: #C30000; 41 | $status-tag-gray-bg: #828282; 42 | 43 | $bracket-default: #b7b7b7; 44 | 45 | 46 | [data-theme='light'], :root { 47 | --bg-primary: #FFFFFF; 48 | --bg-nav: #424242; 49 | --bg-nav-drawer: #333333; 50 | --bg-table-data-even: #F2F2F2; 51 | --bg-table-data-odd: #FFFFFF; 52 | --bg-module-header: #9B51E0; 53 | 54 | --drop-shadow: 0, 0, 0; 55 | 56 | --text-primary: #{$text-black}; 57 | --link-primary: #2e8500; 58 | } 59 | 60 | [data-theme='dark'] { 61 | --bg-primary: #0d1117; 62 | --bg-nav: #0e0e0e; 63 | --bg-nav-drawer: #161616; 64 | --bg-table-data-even: #363636; 65 | --bg-table-data-odd: #444444; 66 | --bg-module-header: #3f007a; 67 | 68 | --drop-shadow: 255, 255, 255; 69 | 70 | --text-primary: #{$alert-info-text}; 71 | --link-primary: #4bce06; 72 | } 73 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/preferred_name.py: -------------------------------------------------------------------------------- 1 | from .command import Command, CommandException 2 | from ..util import Platform, DocSection 3 | 4 | @Command.register() 5 | class GetPreferredNameCommand(Command): 6 | """ 7 | Prints the specified user's preferred name, or yours if no argument is 8 | provided. 9 | """ 10 | aliases = ("name", "getname") 11 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 12 | usage = "name [username] (default username you)" 13 | section = DocSection.USER 14 | 15 | def execute(self, *username): 16 | username = username[0] if len(username) == 1 else self.context.args_string 17 | platform_user = (self.platform_user_from_username(username) if username 18 | else self.context.platform_user) 19 | 20 | if platform_user and platform_user.user.preferred_name: 21 | self.send_message("{user_tag} goes by {preferred_name}".format( 22 | user_tag=platform_user.user_tag, 23 | preferred_name=platform_user.user.preferred_name 24 | )) 25 | else: 26 | self.send_message("User has not set a preferred name.") 27 | 28 | 29 | @Command.register() 30 | class SetPreferredNameCommand(Command): 31 | """ 32 | Sets your preferred name. Can contain letters, numbers, spaces, hyphens, 33 | underscores, and periods. 34 | """ 35 | aliases = ("setname",) 36 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 37 | usage = "setname " 38 | section = DocSection.USER 39 | 40 | def execute(self, *name): 41 | name = " ".join(name) 42 | if self.context.user.set_preferred_name(name): 43 | self.send_message(f"Your preferred name is set to \"{name}\".") 44 | 45 | else: 46 | raise CommandException("Invalid name. Valid characters are " 47 | "letters, numbers, spaces, dashes, underscores, and periods.") 48 | -------------------------------------------------------------------------------- /classic_tetris_project/migrations/0049_auto_20210417_2149.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2021-04-17 21:49 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classic_tetris_project', '0048_auto_20210417_2147'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='tournamentmatch', 16 | name='loser', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='classic_tetris_project.tournamentplayer'), 18 | ), 19 | migrations.AlterField( 20 | model_name='tournamentmatch', 21 | name='player1', 22 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='classic_tetris_project.tournamentplayer'), 23 | ), 24 | migrations.AlterField( 25 | model_name='tournamentmatch', 26 | name='player2', 27 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='classic_tetris_project.tournamentplayer'), 28 | ), 29 | migrations.AlterField( 30 | model_name='tournamentmatch', 31 | name='source1_data', 32 | field=models.IntegerField(blank=True, null=True), 33 | ), 34 | migrations.AlterField( 35 | model_name='tournamentmatch', 36 | name='source2_data', 37 | field=models.IntegerField(blank=True, null=True), 38 | ), 39 | migrations.AlterField( 40 | model_name='tournamentmatch', 41 | name='winner', 42 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='classic_tetris_project.tournamentplayer'), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/qualifiers.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import messages 3 | from django.core.exceptions import PermissionDenied 4 | from django.http import Http404 5 | from django.shortcuts import redirect, render, reverse 6 | from furl import furl 7 | 8 | from classic_tetris_project.models import Qualifier 9 | from classic_tetris_project.util import lazy 10 | from .base import BaseView 11 | 12 | 13 | class WithdrawForm(forms.ModelForm): 14 | class Meta: 15 | model = Qualifier 16 | fields = ["withdrawn"] 17 | widgets = { 18 | "withdrawn": forms.HiddenInput(), 19 | } 20 | 21 | 22 | class QualifierView(BaseView): 23 | def get(self, request, id): 24 | return render(request, "qualifiers/show.html", { 25 | "qualifier": self.qualifier, 26 | "can_withdraw": self.can_withdraw(), 27 | "withdraw_form": WithdrawForm(initial={"withdrawn": True}), 28 | }) 29 | 30 | # Withdraw qualifier 31 | def post(self, request, id): 32 | if not self.can_withdraw(): 33 | raise PermissionDenied 34 | withdraw_form = WithdrawForm(request.POST, instance=self.qualifier) 35 | if withdraw_form.is_valid(): 36 | withdraw_form.save() 37 | if withdraw_form.cleaned_data["withdrawn"]: 38 | messages.info(self.request, "Your qualifier has been withdrawn.") 39 | return redirect(reverse("qualifier", args=[self.qualifier.id])) 40 | 41 | 42 | @lazy 43 | def qualifier(self): 44 | try: 45 | return Qualifier.objects.get(id=self.kwargs["id"]) 46 | except Qualifier.DoesNotExist: 47 | raise Http404() 48 | 49 | def can_withdraw(self): 50 | return (self.qualifier.user == self.current_user and 51 | self.qualifier.event.withdrawals_allowed and 52 | self.qualifier.submitted and 53 | (not self.qualifier.withdrawn)) 54 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/country.py: -------------------------------------------------------------------------------- 1 | from .command import Command, CommandException 2 | from ..countries import Country 3 | from ..util import Platform, DocSection 4 | 5 | @Command.register() 6 | class GetCountryCommand(Command): 7 | """ 8 | Outputs the country of the specified user, or yourself if no argument is 9 | provided. 10 | """ 11 | aliases = ("country", "getcountry") 12 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 13 | usage = "country [username] (default username you)" 14 | section = DocSection.USER 15 | 16 | def execute(self, *username): 17 | username = username[0] if len(username) == 1 else self.context.args_string 18 | platform_user = (self.platform_user_from_username(username) if username 19 | else self.context.platform_user) 20 | user = platform_user.user if platform_user else None 21 | 22 | if user and user.country: 23 | self.send_message("{name} is from {country}!".format( 24 | name=self.context.display_name(platform_user), 25 | country=Country.get_country(user.country).full_name 26 | )) 27 | else: 28 | self.send_message("User has not set a country.") 29 | 30 | @Command.register() 31 | class SetCountryCommand(Command): 32 | """ 33 | Sets your country in the database. You can find a list of the three-letter 34 | codes [here](https://www.iban.com/country-codes) under the "Alpha-3 codes" 35 | column. 36 | """ 37 | aliases = ("setcountry",) 38 | supported_platforms = (Platform.DISCORD, Platform.TWITCH) 39 | usage = "setcountry " 40 | section = DocSection.USER 41 | 42 | def execute(self, country): 43 | if not self.context.user.set_country(country): 44 | raise CommandException("Invalid country.") 45 | else: 46 | country = Country.get_country(self.context.user.country) 47 | self.send_message(f"Your country is now {country.full_name}!") 48 | -------------------------------------------------------------------------------- /classic_tetris_project/test_helper/discord.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from classic_tetris_project.commands.command_context import DiscordCommandContext 4 | from .factories import DiscordUserFactory 5 | 6 | 7 | class MockDiscordGuild: 8 | def __init__(self, members): 9 | self.members = members 10 | 11 | 12 | class MockDiscordChannel: 13 | def __init__(self, name="mock"): 14 | self.sent_messages = [] 15 | self.name = name 16 | 17 | async def send(self, message): 18 | self.sent_messages.append(message) 19 | 20 | def poll(self): 21 | # Convenience method for taking the latest messages sent since the last poll 22 | messages = self.sent_messages 23 | self.sent_messages = [] 24 | return messages 25 | 26 | 27 | class MockDiscordAPIUser: 28 | def __init__(self, id, name, discriminator, display_name=None): 29 | self.id = id 30 | self.name = name 31 | self.discriminator = discriminator 32 | self.display_name = display_name or name 33 | 34 | def send(self, channel, content): 35 | message = MockDiscordMessage(self, content, channel) 36 | context = DiscordCommandContext(message) 37 | context.dispatch() 38 | 39 | def create_discord_user(self): 40 | return DiscordUserFactory(discord_id=self.id, 41 | username=self.name) 42 | 43 | @staticmethod 44 | def create(*args, **kwargs): 45 | return MockDiscordAPIUserFactory(*args, **kwargs) 46 | 47 | class MockDiscordAPIUserFactory(factory.Factory): 48 | class Meta: 49 | model = MockDiscordAPIUser 50 | id = factory.Sequence(lambda n: n) 51 | name = factory.Sequence(lambda n: f"Mock Discord User {n}") 52 | discriminator = factory.Sequence(lambda n: f"{n:04}") 53 | 54 | 55 | class MockDiscordMessage: 56 | def __init__(self, author, content, channel, guild=None): 57 | self.author = author 58 | self.content = content 59 | self.channel = channel 60 | self.guild = guild 61 | -------------------------------------------------------------------------------- /add_tournaments.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project.models import * 2 | 3 | def add_tournaments(event): 4 | for i, tournament_data in enumerate([ 5 | { 6 | "name": "Masters Event", 7 | "color": "#37761D", 8 | "size": 16, 9 | "placeholders": {"16":"CC Winner"}, 10 | }, 11 | { 12 | "name": "Challengers Circuit", 13 | "color": "#E69138", 14 | "size": 16, 15 | "placeholders": {}, 16 | }, 17 | { 18 | "name": "Futures Circuit", 19 | "color": "#741C47", 20 | "size": 32, 21 | "placeholders": {}, 22 | }, 23 | { 24 | "name": "Community T1", 25 | "color": "#6FA8DC", 26 | "size": 32, 27 | "placeholders": {}, 28 | }, 29 | { 30 | "name": "Community T2", 31 | "color": "#3E85C6", 32 | "size": 32, 33 | "placeholders": {}, 34 | }, 35 | { 36 | "name": "Community T3", 37 | "color": "#0C5394", 38 | "size": 32, 39 | "placeholders": {}, 40 | }, 41 | { 42 | "name": "Community T4", 43 | "color": "#F0C233", 44 | "size": 32, 45 | "placeholders": {}, 46 | }, 47 | { 48 | "name": "Community T5", 49 | "color": "#FFE599", 50 | "size": 32, 51 | "placeholders": {}, 52 | }, 53 | ]): 54 | Tournament.objects.create( 55 | event=event, 56 | short_name=tournament_data["name"], 57 | order=i, 58 | seed_count=tournament_data["size"], 59 | placeholders=tournament_data["placeholders"], 60 | color=tournament_data["color"], 61 | ) 62 | -------------------------------------------------------------------------------- /classic_tetris_project/web/views/live_notifications.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.mixins import PermissionRequiredMixin 3 | from django.shortcuts import render 4 | from datetime import datetime 5 | 6 | from classic_tetris_project.facades.user_permissions import UserPermissions 7 | from classic_tetris_project.util import lazy 8 | from ..forms.live_notification import LiveNotificationForm 9 | from .base import BaseView 10 | 11 | import firebase_admin 12 | from firebase_admin import credentials 13 | from firebase_admin import firestore 14 | 15 | cred = credentials.Certificate('data/firebase_credentials.json') 16 | app = firebase_admin.initialize_app(cred) 17 | db = firestore.client() 18 | 19 | class LiveNotificationsView(PermissionRequiredMixin, BaseView): 20 | permission_required = UserPermissions.SEND_LIVE_NOTIFICATIONS 21 | 22 | class LiveView(LiveNotificationsView): 23 | def get(self, request): 24 | return render(request, "live_notifications/live.haml") 25 | 26 | 27 | class SubmitView(LiveNotificationsView): 28 | def get(self, request): 29 | return render(request, "live_notifications/submit.haml", { 30 | "live_notification_form": LiveNotificationForm(), 31 | }) 32 | 33 | def post(self, request): 34 | live_notification_form = LiveNotificationForm(request.POST) 35 | if live_notification_form.is_valid(): 36 | doc_ref = db.collection(u'notifications').document(u'current') 37 | doc_ref.set({ 38 | u'message': live_notification_form.cleaned_data.get("message"), 39 | u'duration': live_notification_form.cleaned_data.get("duration"), 40 | u'position': live_notification_form.cleaned_data.get("position"), 41 | u'created_at': datetime.now() 42 | }) 43 | messages.info(self.request, "Message Submitted") 44 | 45 | return render(request, "live_notifications/submit.haml", { 46 | "live_notification_form": LiveNotificationForm(), 47 | }) 48 | -------------------------------------------------------------------------------- /classic_tetris_project/web/tests/views/profile.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project.test_helper import * 2 | 3 | 4 | class ProfileView_(Spec): 5 | url = "/profile/" 6 | def test_redirects_when_logged_in(self): 7 | self.sign_in() 8 | response = self.get() 9 | 10 | assert_that(response, redirects_to(f"/user/{self.current_user.id}/")) 11 | 12 | def test_redirects_when_logged_out(self): 13 | response = self.get() 14 | 15 | assert_that(response, redirects_to(f"/oauth/login/?next=/profile/")) 16 | 17 | 18 | class ProfileEditView_(Spec): 19 | url = "/profile/edit/" 20 | class GET: 21 | def test_redirects_when_logged_out(self): 22 | response = self.get() 23 | 24 | assert_that(response, redirects_to(f"/oauth/login/?next=/profile/edit/")) 25 | 26 | def test_renders(self): 27 | self.sign_in() 28 | response = self.get() 29 | 30 | assert_that(response.status_code, equal_to(200)) 31 | assert_that(response, uses_template("profile/edit.haml")) 32 | 33 | class POST: 34 | def test_redirects_when_logged_out(self): 35 | response = self.post() 36 | 37 | assert_that(response, redirects_to(f"/oauth/login/?next=/profile/edit/")) 38 | 39 | def test_updates_and_redirects(self): 40 | self.sign_in() 41 | response = self.post({ 42 | "preferred_name": "Preferred Name", 43 | "pronouns": "he", 44 | "country": "us", 45 | "playstyle": "das", 46 | "id": -1, 47 | }) 48 | 49 | assert_that(response, redirects_to(f"/user/{self.current_user.id}/")) 50 | self.current_user.refresh_from_db() 51 | assert_that(self.current_user, has_properties( 52 | preferred_name=equal_to("Preferred Name"), 53 | pronouns=equal_to("he"), 54 | country=equal_to("us"), 55 | playstyle=equal_to("das"), 56 | id=not_(equal_to(-1)) 57 | )) 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "classic_tetris_project_django", 3 | "version": "1.0.0", 4 | "description": "This repository is a new and improved bot for Classic Tetris Monthly that is designed to be accessible to the whole community, both in and out of CTM contexts. It is currently being run on a cloud computing instance rather than a home PC, and as such its uptime is drastically improved from the strikingly mediocre [former bot](https://github.com/professor-l/lsq-bot).", 5 | "main": "index.js", 6 | "directories": { 7 | "doc": "docs", 8 | "test": "tests" 9 | }, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "start": "webpack --config webpack.dev.js", 13 | "build": "webpack --config webpack.prod.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/professor-l/classic-tetris-project.git" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC", 22 | "bugs": { 23 | "url": "https://github.com/professor-l/classic-tetris-project/issues" 24 | }, 25 | "homepage": "https://github.com/professor-l/classic-tetris-project#readme", 26 | "dependencies": { 27 | "@babel/core": "^7.16.5", 28 | "@babel/preset-env": "^7.16.5", 29 | "@babel/preset-react": "^7.16.7", 30 | "@hotwired/stimulus": "^3.0.1", 31 | "@hotwired/stimulus-webpack-helpers": "^1.0.1", 32 | "babel-loader": "^8.2.3", 33 | "bootstrap": "^4.6.0", 34 | "css-loader": "^3.6.0", 35 | "expose-loader": "^1.0.3", 36 | "jquery": "^3.5.1", 37 | "jquery-ujs": "^1.2.2", 38 | "luxon": "^2.2.0", 39 | "mini-css-extract-plugin": "^0.9.0", 40 | "popper.js": "^1.16.1", 41 | "prop-types": "^15.8.1", 42 | "react": "^18.0.0", 43 | "react-dom": "^18.0.0", 44 | "sass": "^1.49.9", 45 | "sass-loader": "^8.0.2", 46 | "simplepicker": "^2.0.4", 47 | "webpack": "^4.43.0", 48 | "webpack-bundle-tracker": "^0.4.3", 49 | "webpack-cli": "^3.3.11", 50 | "webpack-merge": "^4.2.2" 51 | }, 52 | "devDependencies": { 53 | "file-loader": "^6.2.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const BundleTracker = require('webpack-bundle-tracker') 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | 6 | const devMode = process.env.NODE_ENV !== 'production'; 7 | 8 | module.exports = { 9 | context: __dirname, 10 | 11 | entry: { 12 | main: './classic_tetris_project/web/assets/app', 13 | admin: './classic_tetris_project/web/assets/admin', 14 | }, 15 | 16 | output: { 17 | path: path.resolve('./static/bundles/'), 18 | filename: '[name]-[hash].js', 19 | }, 20 | 21 | plugins: [ 22 | new BundleTracker({ 23 | path: __dirname, 24 | filename: './webpack-stats.json', 25 | }), 26 | new MiniCssExtractPlugin({ 27 | filename: '[name]-[hash].css', 28 | }), 29 | new webpack.HotModuleReplacementPlugin(), 30 | 31 | new webpack.ProvidePlugin({ 32 | $: "jquery", 33 | jQuery: "jquery" 34 | }), 35 | ], 36 | 37 | module: { 38 | rules: [ 39 | { test: /\.scss/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader' ], }, 40 | { 41 | test: require.resolve('jquery'), 42 | loader: 'expose-loader', 43 | options: { 44 | exposes: ['$', 'jQuery'], 45 | }, 46 | }, 47 | { 48 | test: /\.m?jsx?$/, 49 | exclude: /node_modules/, 50 | use: { 51 | loader: 'babel-loader', 52 | options: { 53 | presets: [ 54 | ['@babel/preset-env', { targets: "defaults" }], 55 | '@babel/preset-react' 56 | ] 57 | } 58 | } 59 | }, 60 | { 61 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/, 62 | use: [ 63 | { 64 | loader: 'file-loader', 65 | options: { 66 | name: '[name].[ext]', 67 | outputPath: 'fonts/' 68 | } 69 | } 70 | ] 71 | } 72 | ], 73 | }, 74 | 75 | resolve: { 76 | modules: ['node_modules'], 77 | extensions: ['.js', '.jsx'] 78 | }, 79 | } 80 | -------------------------------------------------------------------------------- /classic_tetris_project/web/assets/stylesheets/_buttons.scss: -------------------------------------------------------------------------------- 1 | @use 'colors'; 2 | 3 | .btn { 4 | display: inline-flex; 5 | box-sizing: border-box; 6 | height: 40px; 7 | padding: 10px 20px; 8 | justify-content: center; 9 | font-size: 16px; 10 | line-height: 20px; 11 | border: none; 12 | outline: none; 13 | text-decoration: none; 14 | cursor: pointer; 15 | 16 | // a { 17 | // color: var(--link-primary); 18 | // } 19 | 20 | &:hover { 21 | border: none; 22 | box-shadow: 0px 3px 10px rgba(0, 0, 0, 0.4); 23 | } 24 | 25 | &:active { 26 | border: none; 27 | box-shadow: none; 28 | } 29 | 30 | &--primary { 31 | background-color: colors.$button-primary-bg; 32 | color: colors.$button-primary-text; 33 | &:hover { 34 | background-color: lighten(colors.$button-primary-bg, 5%); 35 | } 36 | } 37 | 38 | &--secondary { 39 | background-color: colors.$button-secondary-bg; 40 | color: colors.$button-secondary-text; 41 | &:hover { 42 | background-color: lighten(colors.$button-secondary-bg, 5%); 43 | } 44 | } 45 | 46 | &--red { 47 | background-color: colors.$button-red-bg; 48 | color: colors.$button-red-text; 49 | &:hover { 50 | background-color: lighten(colors.$button-red-bg, 5%); 51 | } 52 | } 53 | 54 | &--link { 55 | &:hover { 56 | box-shadow: none; 57 | } 58 | } 59 | 60 | &--full { 61 | display: flex; 62 | width: 100%; 63 | } 64 | 65 | &--discord { 66 | background-color: colors.$button-discord-bg; 67 | color: colors.$button-discord-text; 68 | &:hover { 69 | background-color: lighten(colors.$button-discord-bg, 5%); 70 | } 71 | } 72 | 73 | &--twitch { 74 | background-color: colors.$button-twitch-bg; 75 | color: colors.$button-twitch-text; 76 | &:hover { 77 | background-color: lighten(colors.$button-twitch-bg, 5%); 78 | } 79 | } 80 | 81 | &__icon { 82 | width: 30px; 83 | height: 30px; 84 | margin-top: -5px; 85 | margin-right: 10px; 86 | } 87 | } 88 | 89 | 90 | .login-buttons { 91 | max-width: 400px; 92 | margin: auto; 93 | } 94 | -------------------------------------------------------------------------------- /classic_tetris_project/models/scores.py: -------------------------------------------------------------------------------- 1 | from django.db import models, transaction 2 | from django.utils import timezone 3 | 4 | from .users import User 5 | 6 | class ScorePB(models.Model): 7 | class Meta: 8 | indexes = [ 9 | models.Index(fields=["user", "current"], condition=models.Q(current=True), 10 | name="user_current"), 11 | ] 12 | constraints = [ 13 | models.UniqueConstraint(fields=["user", "starting_level", "console_type"], 14 | condition=models.Q(current=True), 15 | name="unique_when_current"), 16 | ] 17 | 18 | TYPE_CHOICES = [ 19 | ("ntsc", "NTSC"), 20 | ("pal", "PAL"), 21 | ] 22 | 23 | user = models.ForeignKey(User, on_delete=models.CASCADE, 24 | related_name="score_pbs", related_query_name="score_pb") 25 | 26 | score = models.IntegerField(null=True, blank=True) 27 | lines = models.IntegerField(null=True, blank=True) 28 | starting_level = models.IntegerField(null=True, blank=True) 29 | console_type = models.CharField(max_length=5, choices=TYPE_CHOICES, default="ntsc", null=False) 30 | current = models.IntegerField(null=False) 31 | created_at = models.DateTimeField(null=False, blank=True) 32 | 33 | 34 | @staticmethod 35 | @transaction.atomic 36 | def log(user, score, starting_level, console_type="ntsc", **params): 37 | ScorePB.objects.filter(user=user, starting_level=starting_level, console_type=console_type, 38 | current=True).update(current=False) 39 | return ScorePB.objects.create(user=user, score=score, starting_level=starting_level, 40 | console_type=console_type, current=True, 41 | **params) 42 | 43 | @staticmethod 44 | def before_save(sender, instance, **kwargs): 45 | if instance.id is None: 46 | instance.created_at = instance.created_at or timezone.now() 47 | 48 | models.signals.pre_save.connect(ScorePB.before_save, sender=ScorePB) 49 | -------------------------------------------------------------------------------- /classic_tetris_project/web/oauth.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from authlib.integrations.django_client import OAuth 3 | from django.core.cache import cache 4 | 5 | from ..env import env 6 | 7 | oauth = OAuth() 8 | oauth.register( 9 | name="discord", 10 | client_id=env("DISCORD_CLIENT_ID"), 11 | client_secret=env("DISCORD_CLIENT_SECRET"), 12 | access_token_url="https://discordapp.com/api/oauth2/token", 13 | authorize_url="https://discordapp.com/api/oauth2/authorize", 14 | api_base_url="https://discordapp.com/api/v6/", 15 | client_kwargs={"scope": "identify"} 16 | ) 17 | oauth.register( 18 | name="twitch", 19 | client_id=env("TWITCH_CLIENT_ID"), 20 | client_secret=env("TWITCH_CLIENT_SECRET"), 21 | access_token_url="https://id.twitch.tv/oauth2/token", 22 | authorize_params={"force_verify": "true"}, 23 | authorize_url="https://id.twitch.tv/oauth2/authorize", 24 | access_token_params={"client_id": env("TWITCH_CLIENT_ID"), 25 | "client_secret": env("TWITCH_CLIENT_SECRET")}, 26 | client_kwargs={"scope": ""} 27 | ) 28 | 29 | discord = oauth.discord 30 | twitch = oauth.twitch 31 | 32 | class State: 33 | CACHE_TIMEOUT = 12 * 60 * 60 # 12 hours 34 | ALLOWED_KEYS = ["next", "merge_accounts"] 35 | 36 | def __init__(self, params={}, state_id=None): 37 | self.state_id = state_id or uuid.uuid4().hex 38 | self.params = { k: v for k, v in params.items() if k in State.ALLOWED_KEYS } 39 | 40 | def __getitem__(self, key): 41 | return self.params.get(key) 42 | 43 | def store(self): 44 | cache.set(f"oauth_states.{self.state_id}", self.params, timeout=State.CACHE_TIMEOUT) 45 | return self.state_id 46 | 47 | def expire(self): 48 | cache.delete(f"oauth_state.{self.state_id}") 49 | 50 | @staticmethod 51 | def from_request(request): 52 | return State(request.GET) 53 | 54 | @staticmethod 55 | def retrieve(state_id): 56 | params = cache.get(f"oauth_states.{state_id}") 57 | if params is None: 58 | return None 59 | else: 60 | return State(params, state_id) 61 | -------------------------------------------------------------------------------- /classic_tetris_project/commands/countdown.py: -------------------------------------------------------------------------------- 1 | import time 2 | from django.core.cache import cache 3 | from random import randint 4 | 5 | from .command import Command, CommandException 6 | from ..util import Platform, DocSection 7 | 8 | MIN_COUNTDOWN = 3 9 | MAX_COUNTDOWN = 10 10 | 11 | @Command.register() 12 | class Countdown(Command): 13 | """ 14 | Counts down from 3 before saying "Tetris!" in the chat. Works for any 15 | number from 3-10. 16 | 17 | **Note:** If the bot is not a moderator in your Twitch channel, it will not 18 | be able to say more than one message per second, and this restriction 19 | (which is built into Twitch) will interfere with countdowns. You can make 20 | the bot a moderator by typing `/mod @ClassicTetrisBot` after you have 21 | `!summon`ed it. 22 | """ 23 | aliases = tuple(str(i) for i in range(MIN_COUNTDOWN, MAX_COUNTDOWN+1)) 24 | supported_platforms = (Platform.TWITCH,) 25 | usage = "n (3 <= n <= 10)" 26 | notes = ("Moderator-only",) 27 | section = DocSection.OTHER 28 | 29 | def __init__(self, context): 30 | super().__init__(context) 31 | self.usage = self.context.command_name 32 | 33 | def execute(self): 34 | self.check_public() 35 | self.check_moderator() 36 | 37 | n = int(self.context.command_name) 38 | self.check_validity(n) 39 | 40 | for i in range(n, 0, -1): 41 | self.send_message(str(i)) 42 | time.sleep(1) 43 | 44 | s = "Tetris!" 45 | i = randint(1, 1000) 46 | if i == 42: 47 | s = "Tortoise!" 48 | elif i == 43: 49 | s = "Texas!" 50 | elif i == 44: 51 | s = "Tetris™!" 52 | elif i == 45: 53 | s = "I'm contacting you regarding your car's extended warranty." 54 | self.send_message(s) 55 | 56 | def check_validity(self, n): 57 | channel = self.context.channel.name 58 | if cache.get(f"countdown_in_progress.{channel}") is not None: 59 | raise CommandException() 60 | else: 61 | cache.set(f"countdown_in_progress.{channel}", True, timeout=(n+1)) 62 | 63 | -------------------------------------------------------------------------------- /export.py: -------------------------------------------------------------------------------- 1 | from classic_tetris_project import discord 2 | from classic_tetris_project.env import env 3 | 4 | @discord.client.event 5 | async def on_ready(): 6 | import csv 7 | from tqdm import tqdm 8 | 9 | from classic_tetris_project import discord 10 | from classic_tetris_project.countries import countries 11 | from classic_tetris_project.models import User 12 | 13 | with open('pbs.csv', 'w') as csvfile: 14 | writer = csv.DictWriter(csvfile, fieldnames=['twitch_id', 'twitch_username', 15 | 'discord_id', 'discord_username', 16 | 'ntsc_pb', 'ntsc_pb_updated_at', 17 | 'pal_pb', 'pal_pb_updated_at', 18 | 'country_code', 'country']) 19 | writer.writeheader() 20 | for user in tqdm(User.objects.all()): 21 | if user.ntsc_pb or user.pal_pb: 22 | d = { 23 | 'ntsc_pb': user.ntsc_pb, 24 | 'ntsc_pb_updated_at': (user.ntsc_pb_updated_at.isoformat() 25 | if user.ntsc_pb_updated_at else None), 26 | 'pal_pb': user.pal_pb, 27 | 'pal_pb_updated_at': (user.pal_pb_updated_at.isoformat() 28 | if user.pal_pb_updated_at else None), 29 | 'country_code': user.country, 30 | 'country': (countries[user.country] if user.country else None), 31 | } 32 | if hasattr(user, 'twitch_user'): 33 | d['twitch_id'] = user.twitch_user.twitch_id 34 | d['twitch_username'] = user.twitch_user.username 35 | if hasattr(user, 'discord_user'): 36 | d['discord_id'] = user.discord_user.discord_id 37 | if user.discord_user.user_obj: 38 | d['discord_username'] = user.discord_user.username 39 | writer.writerow(d) 40 | await discord.client.logout() 41 | 42 | discord.client.run(env("DISCORD_TOKEN")) 43 | -------------------------------------------------------------------------------- /classic_tetris_project/web/templates/review_qualifiers/review.haml: -------------------------------------------------------------------------------- 1 | - extends "base.html" 2 | 3 | - block title 4 | Review Qualifier 5 | 6 | - block content 7 | %form.form{method: "post"} 8 | - csrf_token 9 | 10 | .section 11 | .section__header 12 | %h2.section__title 13 | Review Qualifier 14 | 15 | .section__body 16 | .field-list 17 | - field_list_row "Event:" 18 | %a{href: "{{ qualifier.event.get_absolute_url }}"}= qualifier.event.name 19 | - field_list_row "User:" 20 | %a{href: "{{ qualifier.user.get_absolute_url }}"}= qualifier.user.display_name 21 | 22 | - for field in edit_form.edit_fields 23 | - field_list_input_row field.label_tag 24 | = field 25 | = field.errors 26 | 27 | - field_list_row "Total score:" 28 | = qualifier.type.format_score 29 | - field_list_row "VOD:" 30 | - if qualifier.vod 31 | %a{href: "{{ qualifier.vod }}"}= qualifier.vod 32 | - field_list_row "Auth word:" 33 | = qualifier.auth_word 34 | - field_list_row "Details:" 35 | = qualifier.details 36 | - field_list_row "Started at:" 37 | = qualifier.created_at 38 | - field_list_row "Submitted at:" 39 | = qualifier.submitted_at 40 | %a{href: "{% url 'admin:classic_tetris_project_qualifier_change' qualifier.id %}"} 41 | View/edit in admin 42 | 43 | - module "Reviewer Feedback" 44 | .form.form--narrow 45 | - for check in review_form.checks 46 | .form__field 47 | = check 48 | = check.label_tag 49 | = check.errors 50 | 51 | .form__field 52 | %label{for: "{{ review_form.notes.id_for_label }}"} 53 | Notes for the player: 54 | = review_form.notes 55 | = review_form.notes.errors 56 | 57 | .form__field 58 | - for radio in review_form.approved 59 | %div= radio 60 | = review_form.approved.errors 61 | 62 | .form__actions 63 | %input.btn.btn--primary{type: "submit", value: "Submit"} 64 | --------------------------------------------------------------------------------