├── botc
├── __init__.py
├── asgi.py
├── wsgi.py
├── storage.py
├── urls.py
├── production.py
└── settings.py
├── tests
├── __init__.py
├── settings.py
├── input
│ ├── just_the_drunk.json
│ ├── trouble_brewing.json
│ ├── trouble_brewing_with_meta.json
│ ├── pies_baking.json
│ ├── strings_pulling.json
│ ├── half_of_the_108.json
│ ├── strings_pulling_with_meta.json
│ ├── hybrid1.json
│ └── hybrid2.json
├── test_changes.py
├── test_format_updates.py
├── test_additions.py
└── test_similarity.py
├── scripts
├── __init__.py
├── migrations
│ ├── __init__.py
│ ├── 0034_remove_clocktowercharacter_image_url.py
│ ├── 0004_scripttag_public.py
│ ├── 0003_scriptversion_notes.py
│ ├── 0025_alter_scripttag_name.py
│ ├── 0043_script_num_downloads.py
│ ├── 0023_scripttag_inheritable.py
│ ├── 0002_alter_scriptversion_tags.py
│ ├── 0024_alter_scriptversion_created.py
│ ├── 0041_scripttag_scripttag_order_idx.py
│ ├── 0022_alter_scriptversion_options.py
│ ├── 0037_remove_favourite_script_remove_vote_script.py
│ ├── 0013_worldcup_winner.py
│ ├── 0014_comment_parent.py
│ ├── 0033_homebrewcharacter_script.py
│ ├── 0042_alter_scriptversion_options.py
│ ├── 0007_script_owner.py
│ ├── 0008_alter_script_owner.py
│ ├── 0009_scripttag_style.py
│ ├── 0006_alter_vote_user.py
│ ├── 0040_alter_clocktowercharacter_character_name_and_more.py
│ ├── 0017_alter_scripttag_style.py
│ ├── 0021_auto_20230323_2228.py
│ ├── 0039_alter_scripttag_style.py
│ ├── 0011_favourite.py
│ ├── 0027_remove_special_characters_from_character_ids.py
│ ├── 0028_clocktowercharacter_delete_character_and_more.py
│ ├── 0030_alter_clocktowercharacter_global_reminders_and_more.py
│ ├── 0019_scripttag_order.py
│ ├── 0018_auto_20230316_2218.py
│ ├── 0010_worldcup.py
│ ├── 0026_alter_scripttag_style.py
│ ├── 0020_auto_20230319_2242.py
│ ├── 0044_scriptversion_num_loric_and_more.py
│ ├── 0012_auto_20220523_2251.py
│ ├── 0031_alter_clocktowercharacter_character_id_and_more.py
│ ├── 0005_auto_20211221_0205.py
│ ├── 0032_remove_clocktowercharacter_id_and_more.py
│ ├── 0035_favourite_parent_vote_parent_and_more.py
│ ├── 0036_migrate_edition.py
│ ├── 0016_auto_20220709_1510.py
│ ├── 0015_auto_20220616_2142.py
│ ├── 0001_initial.py
│ ├── 0029_homebrewcharacter_scriptversion_homebrewiness_and_more.py
│ └── 0038_alter_scripttag_options_and_more.py
├── templatetags
│ ├── __init__.py
│ └── botc_script_tags.py
├── templates
│ ├── account
│ │ ├── base.html
│ │ ├── login.html
│ │ ├── logout.html
│ │ ├── provider_panel.html
│ │ └── delete.html
│ ├── socialaccount
│ │ ├── base.html
│ │ ├── authentication_error.html
│ │ ├── signup.html
│ │ └── connections.html
│ ├── robots.txt
│ ├── download_json.html
│ ├── script_table
│ │ ├── favourites.html
│ │ ├── likes.html
│ │ └── actions
│ │ │ ├── default.html
│ │ │ ├── download_json.html
│ │ │ ├── script_tool.html
│ │ │ ├── download_pdf.html
│ │ │ ├── authenticated.html
│ │ │ └── collection.html
│ ├── download_pdf.html
│ ├── widgets
│ │ ├── badge_pill_select_multiple.html
│ │ ├── badge_pill_checkbox_multiple.html
│ │ └── LICENSE
│ ├── vote.html
│ ├── table.html
│ ├── info.html
│ ├── favourite.html
│ ├── update_database.html
│ ├── tags.html
│ ├── collection_list.html
│ ├── worldcup
│ │ ├── fixtures.html
│ │ ├── fixturetable.html
│ │ ├── statstable.html
│ │ ├── fixturerow.html
│ │ └── statistics.html
│ ├── scriptlist.html
│ ├── advanced_search.html
│ ├── chart.html
│ ├── delete_comment.html
│ ├── delete_script.html
│ ├── base.html
│ ├── remove_collection.html
│ ├── add_to_collection.html
│ ├── upload.html
│ ├── collection.html
│ ├── all_roles.html
│ └── navbar.html
├── static
│ ├── btn_discord.png
│ └── btn_google_signin_dark_normal_web.png
├── apps.py
├── context_processors.py
├── constants.py
├── widgets.py
├── admin.py
├── managers.py
├── management
│ └── commands
│ │ ├── delete_orphaned_scripts.py
│ │ ├── update_script_counts.py
│ │ ├── update_homebrewiness.py
│ │ └── fix_latest_flags.py
├── cache.py
├── validators.py
├── api_views.py
├── serializers.py
├── worldcup.py
├── script_json.py
├── urls.py
├── tables.py
└── filters.py
├── dev
├── Dockerfile
└── docker-compose.yml
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ ├── claim-script.md
│ └── bug_report.md
├── dependabot.yml
└── workflows
│ ├── linter.yml
│ ├── pytest.yml
│ ├── main_botc-scripts.yml
│ └── staging_botc-scripts(staging).yml
├── README.md
├── manage.py
├── LICENSE
├── pyproject.toml
├── .gitignore
└── DEVELOPMENT.md
/botc/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/templatetags/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/templates/account/base.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
--------------------------------------------------------------------------------
/scripts/templates/socialaccount/base.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
--------------------------------------------------------------------------------
/scripts/templates/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 | Allow: /$
--------------------------------------------------------------------------------
/dev/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM postgres:latest
2 |
3 | RUN apt-get update && apt-get install -y postgresql-contrib
--------------------------------------------------------------------------------
/scripts/static/btn_discord.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AdmiralGT/botc-scripts/HEAD/scripts/static/btn_discord.png
--------------------------------------------------------------------------------
/scripts/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class ScriptsConfig(AppConfig):
5 | name = "scripts"
6 |
--------------------------------------------------------------------------------
/scripts/templates/download_json.html:
--------------------------------------------------------------------------------
1 | JSON
2 |
--------------------------------------------------------------------------------
/scripts/static/btn_google_signin_dark_normal_web.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AdmiralGT/botc-scripts/HEAD/scripts/static/btn_google_signin_dark_normal_web.png
--------------------------------------------------------------------------------
/scripts/templates/script_table/favourites.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap_icons %}
2 |
3 | {{ record.num_favs }}{% bs_icon 'star-fill' %}
--------------------------------------------------------------------------------
/scripts/templates/script_table/likes.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap_icons %}
2 |
3 | {{ record.score }}{% bs_icon 'hand-thumbs-up-fill' %}
--------------------------------------------------------------------------------
/scripts/templates/download_pdf.html:
--------------------------------------------------------------------------------
1 | {% if record.pdf %}
2 | PDF
3 | {% endif %}
4 |
--------------------------------------------------------------------------------
/scripts/context_processors.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 |
4 | def custom_configuration(_request):
5 | return {"UPLOAD_DISABLED": settings.UPLOAD_DISABLED, "BANNER": settings.BANNER}
6 |
--------------------------------------------------------------------------------
/tests/input/just_the_drunk.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "_meta",
4 | "name": "Just the Drunk",
5 | "author": ""
6 | },
7 | {
8 | "id": "drunk"
9 | }
10 | ]
--------------------------------------------------------------------------------
/dev/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | db.postgres:
3 | build: .
4 | restart: always
5 | environment:
6 | POSTGRES_PASSWORD: postgres
7 | POSTGRES_USER: postgres@db
8 | ports:
9 | - 5432:5432
--------------------------------------------------------------------------------
/scripts/constants.py:
--------------------------------------------------------------------------------
1 | # A list of constants that should be consistent across database/form usage
2 | MAX_SCRIPT_NAME_LENGTH = 100
3 | MAX_AUTHOR_NAME_LENGTH = 100
4 | STANDARD_TEENSYVILLE_CHARACTER_COUNT = 12
5 | MAX_CHARACTER_COUNT = 25
6 |
--------------------------------------------------------------------------------
/scripts/templates/script_table/actions/default.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% include 'script_table/actions/download_json.html' %}
4 | {% include 'script_table/actions/download_pdf.html' %}
5 | {% include 'script_table/actions/script_tool.html' %}
6 |
7 |
--------------------------------------------------------------------------------
/scripts/templates/widgets/badge_pill_select_multiple.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/templates/script_table/actions/download_json.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap_icons %}
2 |
5 |
--------------------------------------------------------------------------------
/scripts/templates/script_table/actions/script_tool.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap_icons botc_script_tags %}
2 |
5 |
--------------------------------------------------------------------------------
/scripts/templates/vote.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap4 %}
2 |
--------------------------------------------------------------------------------
/scripts/templates/script_table/actions/download_pdf.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap_icons %}
2 | {% if record.pdf %}
3 |
6 | {% endif %}
--------------------------------------------------------------------------------
/scripts/widgets.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 |
3 |
4 | class BadgePillSelectMultiple(forms.SelectMultiple):
5 | option_template_name = "widgets/badge_pill_select_multiple.html"
6 |
7 |
8 | class BadgePillCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
9 | option_template_name = "widgets/badge_pill_checkbox_multiple.html"
10 |
--------------------------------------------------------------------------------
/scripts/templates/table.html:
--------------------------------------------------------------------------------
1 | {% load render_table from django_tables2 %}
2 |
3 |
4 |
5 |
6 |
7 | {% render_table table %}
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/scripts/templates/socialaccount/authentication_error.html:
--------------------------------------------------------------------------------
1 | {% extends "socialaccount/base_entrance.html" %}
2 |
3 | {% load i18n %}
4 | {% load bootstrap4 %}
5 | {% load allauth %}
6 |
7 | {% block content %}
8 |
9 | Auth Error: {{ auth_error }}
10 | Auth Error Code: {{ auth_error.code }}
11 | Auth Error Exception: {{ auth_error.exception }}
12 |
13 | {% endblock %}
--------------------------------------------------------------------------------
/scripts/templates/socialaccount/signup.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% load i18n %}
4 | {% load bootstrap4 %}
5 |
6 | {% block content %}
7 | {% trans "Oops!" %}
8 |
9 | You already have an account registered with this e-mail address {{ email }} using another provider.
10 |
11 | Please login using that provider and connect this provider to that account.
12 |
13 | {% endblock %}
--------------------------------------------------------------------------------
/scripts/templates/info.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap_icons %}
2 |
3 | {% bs_icon 'hand-thumbs-up-fill' %}{{ record.score }}
4 | {% bs_icon 'star-fill' %}{{ record.num_favs }}
5 | {% bs_icon 'pen-fill' %}{{ record.num_comments }}
6 | {% bs_icon 'download' %}{{ record.script.num_downloads }}
7 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: 'Feature:'
5 | labels: enhancement
6 | assignees: AdmiralGT
7 |
8 | ---
9 |
10 | **Description**
11 | A clear and concise description of what feature you'd like to see. Please provide as much detail as possible.
12 |
13 | **Additional context**
14 | Add any other context or screenshots about the feature request here.
15 |
--------------------------------------------------------------------------------
/scripts/templates/favourite.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap4 %}
2 | {% load botc_script_tags %}
3 | {% load bootstrap_icons %}
4 |
5 | {% user_favourite record as starstyle %}
6 |
7 |
12 |
--------------------------------------------------------------------------------
/scripts/templates/account/login.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% load bootstrap4 %}
4 | {% load i18n %}
5 | {% load account socialaccount %}
6 |
7 | {% block content %}
8 | {% get_providers as socialaccount_providers %}
9 |
10 | {% trans 'Log in' %}
11 |
12 | {% if socialaccount_providers %}
13 |
14 | {% include "account/provider_panel.html" with process="login" %}
15 |
16 | {% endif %}
17 |
18 | {% endblock %}
--------------------------------------------------------------------------------
/scripts/migrations/0034_remove_clocktowercharacter_image_url.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.3 on 2025-06-13 21:07
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0033_homebrewcharacter_script'),
10 | ]
11 |
12 | operations = [
13 | migrations.RemoveField(
14 | model_name='clocktowercharacter',
15 | name='image_url',
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/scripts/templates/update_database.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% load bootstrap4 %}
4 |
5 | {% block content %}
6 |
7 |
16 |
17 | {% endblock %}
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/claim-script.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Claim Script
3 | about: You created a script in the database and would like to be assigned as the author
4 | title: 'Claim:'
5 | labels: ''
6 | assignees: AdmiralGT
7 |
8 | ---
9 |
10 | **Which script(s) would you like to claim?**
11 | Please add a link to each script you would like to claim.
12 |
13 | **How would you like to be represented as the Author**
14 | What would you like the Author to say e.g. a screen name "AdmiralGT" or a real name "Geoff"
15 |
--------------------------------------------------------------------------------
/botc/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for botc project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault(
15 | "DJANGO_SETTINGS_MODULE", os.environ.get("DJANGO_SETTINGS", "botc.local")
16 | )
17 |
18 | application = get_asgi_application()
19 |
--------------------------------------------------------------------------------
/botc/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for botc 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/3.1/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault(
15 | "DJANGO_SETTINGS_MODULE", os.environ.get("DJANGO_SETTINGS", "botc.local")
16 | )
17 |
18 | application = get_wsgi_application()
19 |
--------------------------------------------------------------------------------
/scripts/migrations/0004_scripttag_public.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.10 on 2021-12-13 00:39
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0003_scriptversion_notes'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='scripttag',
15 | name='public',
16 | field=models.BooleanField(default=False),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/scripts/migrations/0003_scriptversion_notes.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.10 on 2021-12-12 17:45
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0002_alter_scriptversion_tags'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='scriptversion',
15 | name='notes',
16 | field=models.TextField(blank=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/scripts/migrations/0025_alter_scripttag_name.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.2.5 on 2023-12-17 23:43
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("scripts", "0024_alter_scriptversion_created"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="scripttag",
14 | name="name",
15 | field=models.CharField(max_length=100),
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/scripts/migrations/0043_script_num_downloads.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.6 on 2025-09-10 19:47
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0042_alter_scriptversion_options'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='script',
15 | name='num_downloads',
16 | field=models.IntegerField(default=0),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/scripts/migrations/0023_scripttag_inheritable.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.18 on 2023-04-02 21:43
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0022_alter_scriptversion_options'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='scripttag',
15 | name='inheritable',
16 | field=models.BooleanField(default=False),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/scripts/migrations/0002_alter_scriptversion_tags.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2021-07-26 00:01
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0001_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='scriptversion',
15 | name='tags',
16 | field=models.ManyToManyField(blank=True, to='scripts.ScriptTag'),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/scripts/migrations/0024_alter_scriptversion_created.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.18 on 2023-04-21 20:33
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0023_scripttag_inheritable'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='scriptversion',
15 | name='created',
16 | field=models.DateTimeField(auto_now_add=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/scripts/templates/account/logout.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block content %}
4 | {% trans "Sign Out" %}
5 |
6 | {% trans 'Are you sure you want to sign out?' %}
7 |
8 |
15 | {% endblock %}
--------------------------------------------------------------------------------
/scripts/migrations/0041_scripttag_scripttag_order_idx.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.4 on 2025-08-26 20:29
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0040_alter_clocktowercharacter_character_name_and_more'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddIndex(
14 | model_name='scripttag',
15 | index=models.Index(fields=['order'], name='scripttag_order_idx'),
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/scripts/templates/tags.html:
--------------------------------------------------------------------------------
1 | {% if record.num_tags > 0 %}
2 | {% for tag in record.tags.all %}
3 | {% if '?' in request.get_full_path %}
4 | {{ tag }}
5 | {% else %}
6 | {{ tag }}
7 | {% endif %}
8 | {% endfor %}
9 | {% endif %}
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "uv" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/scripts/migrations/0022_alter_scriptversion_options.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.18 on 2023-03-25 00:52
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0021_auto_20230323_2228'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterModelOptions(
14 | name='scriptversion',
15 | options={'permissions': [('download_unsupported_json', 'Can the request the download of a JSON that replaces unsupported characters')]},
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/scripts/migrations/0037_remove_favourite_script_remove_vote_script.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.4 on 2025-07-06 21:02
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0036_migrate_edition'),
10 | ]
11 |
12 | operations = [
13 | migrations.RemoveField(
14 | model_name='favourite',
15 | name='script',
16 | ),
17 | migrations.RemoveField(
18 | model_name='vote',
19 | name='script',
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/scripts/migrations/0013_worldcup_winner.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.13 on 2022-06-01 19:25
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0012_auto_20220523_2251'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='worldcup',
15 | name='winner',
16 | field=models.CharField(choices=[('Unknown', 'Unknown'), ('Home', 'Home'), ('Away', 'Away')], default='Unknown', max_length=10),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/botc/storage.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from storages.backends.azure_storage import AzureStorage
3 |
4 |
5 | class AzureMediaStorage(AzureStorage):
6 | account_name = settings.AZURE_ACCOUNT_NAME
7 | account_key = settings.AZURE_STORAGE_KEY
8 | azure_container = settings.AZURE_MEDIA_CONTAINER
9 | expiration_secs = None
10 | overwrite_files = True
11 |
12 |
13 | class AzureStaticStorage(AzureStorage):
14 | account_name = settings.AZURE_ACCOUNT_NAME
15 | account_key = settings.AZURE_STORAGE_KEY
16 | azure_container = settings.AZURE_STATIC_CONTAINER
17 | expiration_secs = None
18 |
--------------------------------------------------------------------------------
/scripts/templates/collection_list.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% load static %}
4 | {% load bootstrap4 %}
5 |
6 | {% block content %}
7 |
8 | {% if user.is_authenticated %}
9 |
16 | {% endif %}
17 |
18 | {% include "table.html" %}
19 |
20 | {% endblock %}
--------------------------------------------------------------------------------
/.github/workflows/linter.yml:
--------------------------------------------------------------------------------
1 | name: Linter
2 |
3 | on:
4 | pull_request:
5 |
6 | jobs:
7 | test:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 |
12 | - name: Install uv
13 | uses: astral-sh/setup-uv@v5
14 |
15 | - name: "Set up Python"
16 | uses: actions/setup-python@v5
17 | with:
18 | python-version-file: "pyproject.toml"
19 |
20 | - name: Install the project
21 | run: uv sync --all-extras --dev
22 |
23 | - name: Run tests
24 | run: |
25 | uv run ruff check
26 | uv run ruff format --check
--------------------------------------------------------------------------------
/scripts/migrations/0014_comment_parent.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.13 on 2022-06-06 23:22
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 | ('scripts', '0013_worldcup_winner'),
11 | ]
12 |
13 | operations = [
14 | migrations.AddField(
15 | model_name='comment',
16 | name='parent',
17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='scripts.comment'),
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/scripts/migrations/0033_homebrewcharacter_script.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.4 on 2025-02-26 20:27
2 |
3 | import django.db.models.deletion
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('scripts', '0032_remove_clocktowercharacter_id_and_more'),
11 | ]
12 |
13 | operations = [
14 | migrations.AddField(
15 | model_name='homebrewcharacter',
16 | name='script',
17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='scripts.script'),
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/scripts/migrations/0042_alter_scriptversion_options.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.6 on 2025-09-09 19:42
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0041_scripttag_scripttag_order_idx'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterModelOptions(
14 | name='scriptversion',
15 | options={'permissions': [('download_unsupported_json', 'Can the request the download of a JSON that replaces unsupported characters'), ('api_write_permission', 'Can create, update or delete scripts via the API. This is not required for reading scripts.')]},
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/scripts/admin.py:
--------------------------------------------------------------------------------
1 | # Register your models here.
2 | from django.contrib import admin
3 |
4 | from scripts import models
5 |
6 |
7 | class ScriptVersionAdmin(admin.ModelAdmin):
8 | readonly_fields = ["created"]
9 |
10 |
11 | admin.site.register(models.ClocktowerCharacter)
12 | admin.site.register(models.HomebrewCharacter)
13 | admin.site.register(models.Translation)
14 | admin.site.register(models.Comment)
15 | admin.site.register(models.Collection)
16 | admin.site.register(models.Favourite)
17 | admin.site.register(models.Script)
18 | admin.site.register(models.ScriptVersion, ScriptVersionAdmin)
19 | admin.site.register(models.ScriptTag)
20 | admin.site.register(models.Vote)
21 | admin.site.register(models.WorldCup)
22 |
--------------------------------------------------------------------------------
/scripts/migrations/0007_script_owner.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.10 on 2022-01-10 22:10
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 | ('scripts', '0006_alter_vote_user'),
13 | ]
14 |
15 | operations = [
16 | migrations.AddField(
17 | model_name='script',
18 | name='owner',
19 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/scripts/migrations/0008_alter_script_owner.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-02-16 19:54
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 | ('scripts', '0007_script_owner'),
13 | ]
14 |
15 | operations = [
16 | migrations.AlterField(
17 | model_name='script',
18 | name='owner',
19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/scripts/migrations/0009_scripttag_style.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-03-01 00:32
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0008_alter_script_owner'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='scripttag',
15 | name='style',
16 | field=models.CharField(choices=[('badge-primary', 'Blue'), ('badge-secondary', 'Grey'), ('badge-success', 'Green'), ('badge-danger', 'Red'), ('badge-warning', 'Yellow'), ('badge-info', 'Cyan'), ('badge-light', 'White'), ('badge-dark', 'Black')], default='badge-primary', max_length=20),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/.github/workflows/pytest.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 |
13 | - name: Install uv
14 | uses: astral-sh/setup-uv@v5
15 |
16 | - name: "Set up Python"
17 | uses: actions/setup-python@v5
18 | with:
19 | python-version-file: "pyproject.toml"
20 |
21 | - name: Install the project
22 | run: uv sync --all-extras --dev
23 |
24 | - name: Run tests
25 | run: |
26 | uv run ruff check
27 |
28 | - name: Run tests
29 | run: |
30 | uv run pytest tests/ --cov scripts
31 | uv run coverage report
--------------------------------------------------------------------------------
/scripts/migrations/0006_alter_vote_user.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.10 on 2021-12-23 22:18
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 | ('scripts', '0005_auto_20211221_0205'),
13 | ]
14 |
15 | operations = [
16 | migrations.AlterField(
17 | model_name='vote',
18 | name='user',
19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/scripts/migrations/0040_alter_clocktowercharacter_character_name_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.4 on 2025-08-16 20:15
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0039_alter_scripttag_style'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='clocktowercharacter',
15 | name='character_name',
16 | field=models.CharField(max_length=30),
17 | ),
18 | migrations.AlterField(
19 | model_name='homebrewcharacter',
20 | name='character_name',
21 | field=models.CharField(max_length=30),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/scripts/migrations/0017_alter_scripttag_style.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.16 on 2023-01-29 13:19
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0016_auto_20220709_1510'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='scripttag',
15 | name='style',
16 | field=models.CharField(choices=[('badge-primary', 'Blue'), ('badge-secondary', 'Grey'), ('badge-success', 'Green'), ('badge-danger', 'Red'), ('badge-warning', 'Yellow'), ('badge-info', 'Cyan'), ('badge-light', 'White'), ('badge-dark', 'Black'), ('badge-purple', 'Purple')], default='badge-primary', max_length=20),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/scripts/migrations/0021_auto_20230323_2228.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.18 on 2023-03-23 22:28
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0020_auto_20230319_2242'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='character',
15 | name='image_url',
16 | field=models.CharField(blank=True, max_length=100, null=True),
17 | ),
18 | migrations.AlterField(
19 | model_name='scriptversion',
20 | name='edition',
21 | field=models.IntegerField(choices=[(0, 'Base'), (1, 'Kickstarter'), (2, 'clocktower.online'), (3, 'All')], default=0),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [Blood on the Clocktower Script Database](https://www.botcscripts.com/)
2 |
3 | This is a database for hosting Blood on the Clocktower custom scripts. You can visit the live deployment by clicking the link above.
4 |
5 | ## Features
6 |
7 | - Stores JSONs generated by the [official Script Tool](https://bloodontheclocktower.com/custom-scripts).
8 | - Optionally also stores generated PDFs.
9 | - Filter scripts based on required characters or characters to exclude.
10 | - Option to vote for your favourite scripts
11 |
12 | ## Acknowledgements
13 |
14 | This site is not affiliated with The Pandemonium Institute. All roles and characters are the property of Steven Medway and The Pandemonium Institute.
15 |
16 | Blood on the Clocktower is a trademark of Steven Medway and The Pandemonium Institute.
17 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault(
10 | "DJANGO_SETTINGS_MODULE", os.environ.get("DJANGO_SETTINGS", "botc.local")
11 | )
12 | try:
13 | from django.core.management import execute_from_command_line
14 | except ImportError as exc:
15 | raise ImportError(
16 | "Couldn't import Django. Are you sure it's installed and "
17 | "available on your PYTHONPATH environment variable? Did you "
18 | "forget to activate a virtual environment?"
19 | ) from exc
20 | execute_from_command_line(sys.argv)
21 |
22 |
23 | if __name__ == "__main__":
24 | main()
25 |
--------------------------------------------------------------------------------
/scripts/managers.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class ScriptViewManager(models.Manager):
5 | def get_queryset(self):
6 | qs = (
7 | super(ScriptViewManager, self)
8 | .get_queryset()
9 | .annotate(
10 | num_tags=models.Count("tags", distinct=True),
11 | score=models.Count("script__votes", distinct=True),
12 | num_favs=models.Count("script__favourites", distinct=True),
13 | num_comments=models.Count("script__comments", distinct=True),
14 | )
15 | )
16 | return qs
17 |
18 |
19 | class CollectionManager(models.Manager):
20 | def get_queryset(self):
21 | qs = super(CollectionManager, self).get_queryset().annotate(scripts_in_collection=models.Count("scripts"))
22 | return qs
23 |
--------------------------------------------------------------------------------
/scripts/templates/worldcup/fixtures.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% load bootstrap4 %}
4 | {% load botc_script_tags %}
5 |
6 | {% block content %}
7 |
8 | Final
9 | {% include "worldcup/fixturetable.html" with round=round7 %}
10 |
11 | 3rd/4th Place Playoff
12 | {% include "worldcup/fixturetable.html" with round=round6 %}
13 |
14 | Semi-Finals
15 | {% include "worldcup/fixturetable.html" with round=round5 %}
16 |
17 | Quarter-Finals
18 | {% include "worldcup/fixturetable.html" with round=round4 %}
19 |
20 | Round 3
21 | {% include "worldcup/fixturetable.html" with round=round3 %}
22 |
23 | Round 2
24 | {% include "worldcup/fixturetable.html" with round=round2 %}
25 |
26 | Round 1
27 | {% include "worldcup/fixturetable.html" with round=round1 %}
28 |
29 | {% endblock %}
--------------------------------------------------------------------------------
/scripts/templates/widgets/badge_pill_checkbox_multiple.html:
--------------------------------------------------------------------------------
1 | {% if widget.wrap_label %}
2 |
15 | {% endif %}
16 |
--------------------------------------------------------------------------------
/scripts/migrations/0039_alter_scripttag_style.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.4 on 2025-08-01 17:34
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0038_alter_scripttag_options_and_more'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='scripttag',
15 | name='style',
16 | field=models.CharField(choices=[('badge-primary', 'Blue'), ('badge-secondary', 'Grey'), ('badge-success', 'Green'), ('badge-danger', 'Red'), ('badge-warning', 'Yellow'), ('badge-info', 'Cyan'), ('badge-light', 'White'), ('badge-dark', 'Black'), ('badge-purple', 'Purple'), ('badge-nocturne', 'Nocturne'), ('badge-bloodred', 'Bloodred')], default='badge-primary', max_length=20),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/botc/urls.py:
--------------------------------------------------------------------------------
1 | """botc URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/3.1/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 |
17 | from django.contrib import admin
18 | from django.urls import include, path
19 |
20 | urlpatterns = [
21 | path("admin/", admin.site.urls),
22 | path("", include("scripts.urls")),
23 | ]
24 |
--------------------------------------------------------------------------------
/scripts/templates/account/provider_panel.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 | {% load i18n %}
3 | {% load bootstrap4 %}
4 | {% load account socialaccount %}
5 |
6 | {% get_providers as socialaccount_providers %}
7 | {% if socialaccount_providers %}
8 |
22 |
23 | {% endif %}
--------------------------------------------------------------------------------
/scripts/migrations/0011_favourite.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-04-22 23:53
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 | ('scripts', '0010_worldcup'),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name='Favourite',
18 | fields=[
19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20 | ('script', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='scripts.scriptversion')),
21 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favourites', to=settings.AUTH_USER_MODEL)),
22 | ],
23 | ),
24 | ]
25 |
--------------------------------------------------------------------------------
/scripts/templates/scriptlist.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% load static %}
4 | {% load bootstrap4 %}
5 |
6 | {% block content %}
7 |
8 | {% if filter %}
9 |
27 | {% endif %}
28 |
29 | {% include "table.html" %}
30 |
31 | {% endblock %}
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: 'Bug:'
5 | labels: bug
6 | assignees: AdmiralGT
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **When did it happen**
14 | Please try to be as accurate as possible, as this will help in correlating logs/metrics from the server.
15 |
16 | **To Reproduce**
17 | Is this problem reproduceable, if so, please provide the steps required to reproduce the behavior:
18 | 1. Go to '...'
19 | 2. Click on '....'
20 | 3. Scroll down to '....'
21 | 4. See error
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Browser details:**
27 | If this problem is related to the UI (User Interface), please provide as much information as possible about the browser version you are using.
28 |
29 | **Additional context**
30 | Add any other context about the problem here e.g. the JSON/PDF of the script
31 |
--------------------------------------------------------------------------------
/scripts/migrations/0027_remove_special_characters_from_character_ids.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations
2 | from scripts.script_json import strip_special_characters
3 |
4 | def update_character_ids(apps, _schema_editor):
5 | Character = apps.get_model("scripts", "character")
6 | Translation = apps.get_model("scripts", "translation")
7 | for character in Character.objects.all():
8 | character.character_id = strip_special_characters(character.character_id)
9 | character.save()
10 |
11 | for translation in Translation.objects.all():
12 | translation.character_id = strip_special_characters(translation.character_id)
13 | translation.save()
14 |
15 |
16 | class Migration(migrations.Migration):
17 |
18 | dependencies = [
19 | ("scripts", "0026_alter_scripttag_style"),
20 | ]
21 |
22 | operations = [
23 |
24 | migrations.RunPython(
25 | update_character_ids, reverse_code=migrations.RunPython.noop
26 | ),
27 | ]
28 |
--------------------------------------------------------------------------------
/scripts/migrations/0028_clocktowercharacter_delete_character_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.1 on 2024-10-16 00:06
2 | from django.db import migrations, models
3 |
4 |
5 | class Migration(migrations.Migration):
6 |
7 | dependencies = [
8 | ("scripts", "0027_remove_special_characters_from_character_ids"),
9 | ]
10 |
11 | operations = [
12 | migrations.RenameModel(
13 | old_name='Character',
14 | new_name='ClocktowerCharacter',
15 | ),
16 | migrations.DeleteModel(
17 | name="Play",
18 | ),
19 | migrations.AlterField(
20 | model_name="clocktowercharacter",
21 | name="first_night_position",
22 | field=models.FloatField(blank=True, null=True),
23 | ),
24 | migrations.AlterField(
25 | model_name="clocktowercharacter",
26 | name="other_night_position",
27 | field=models.FloatField(blank=True, null=True),
28 | ),
29 | ]
30 |
--------------------------------------------------------------------------------
/scripts/migrations/0030_alter_clocktowercharacter_global_reminders_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.1 on 2024-10-18 00:57
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("scripts", "0029_homebrewcharacter_scriptversion_homebrewiness_and_more"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="clocktowercharacter",
15 | name="global_reminders",
16 | field=models.CharField(blank=True, max_length=60, null=True),
17 | ),
18 | migrations.AlterField(
19 | model_name="homebrewcharacter",
20 | name="global_reminders",
21 | field=models.CharField(blank=True, max_length=60, null=True),
22 | ),
23 | migrations.AlterField(
24 | model_name="translation",
25 | name="global_reminders",
26 | field=models.CharField(blank=True, max_length=60, null=True),
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/scripts/templates/advanced_search.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% load bootstrap4 %}
4 |
5 | {% bootstrap_css %}
6 | {% bootstrap_javascript jquery='full' %}
7 |
8 | {% block content %}
9 |
10 |
24 |
25 | {% endblock %}
--------------------------------------------------------------------------------
/scripts/migrations/0019_scripttag_order.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.18 on 2023-03-19 01:14
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | def set_default_order_field(apps, schema_editor):
7 | ScriptTag = apps.get_model("scripts", "scripttag")
8 | for tag in ScriptTag.objects.all():
9 | tag.order = tag.pk
10 | tag.save()
11 |
12 |
13 | class Migration(migrations.Migration):
14 |
15 | dependencies = [
16 | ("scripts", "0018_auto_20230316_2218"),
17 | ]
18 |
19 | operations = [
20 | migrations.AddField(
21 | model_name="scripttag",
22 | name="order",
23 | field=models.IntegerField(default=0),
24 | preserve_default=False,
25 | ),
26 | migrations.RunPython(
27 | set_default_order_field, reverse_code=migrations.RunPython.noop
28 | ),
29 | migrations.AlterField(
30 | model_name="scripttag",
31 | name="order",
32 | field=models.IntegerField(unique=True),
33 | ),
34 | ]
35 |
--------------------------------------------------------------------------------
/scripts/migrations/0018_auto_20230316_2218.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.18 on 2023-03-16 22:18
2 |
3 | from django.db import migrations, models
4 | from scripts.views import calculate_edition
5 |
6 |
7 | def update_existing_scripts(apps, schema_editor):
8 | ScriptVersion = apps.get_model("scripts", "scriptversion")
9 | for script in ScriptVersion.objects.all():
10 | script.edition = calculate_edition(script.content)
11 | script.save()
12 |
13 |
14 | class Migration(migrations.Migration):
15 |
16 | dependencies = [
17 | ("scripts", "0017_alter_scripttag_style"),
18 | ]
19 |
20 | operations = [
21 | migrations.AddField(
22 | model_name="scriptversion",
23 | name="edition",
24 | field=models.IntegerField(
25 | choices=[(0, "Base"), (1, "+ Kickstarter"), (2, "+ Unreleased")],
26 | default=0,
27 | ),
28 | ),
29 | migrations.RunPython(
30 | update_existing_scripts, reverse_code=migrations.RunPython.noop
31 | ),
32 | ]
33 |
--------------------------------------------------------------------------------
/scripts/templates/script_table/actions/authenticated.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% include 'script_table/actions/download_json.html' %}
4 | {% include 'script_table/actions/download_pdf.html' %}
5 |
6 | {% load bootstrap4 %}
7 | {% load botc_script_tags %}
8 | {% load bootstrap_icons %}
9 |
10 | {% user_favourite record as starstyle %}
11 | {% user_voted_icon record as user_voted_icon %}
12 |
13 |
18 |
19 |
24 |
25 |
--------------------------------------------------------------------------------
/scripts/migrations/0010_worldcup.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-04-02 21: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 | ('scripts', '0009_scripttag_style'),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='WorldCup',
16 | fields=[
17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 | ('round', models.IntegerField()),
19 | ('vod', models.CharField(blank=True, max_length=100)),
20 | ('form', models.CharField(blank=True, max_length=100)),
21 | ('script1', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='scripts.scriptversion')),
22 | ('script2', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='scripts.scriptversion')),
23 | ],
24 | ),
25 | ]
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Geoff Thomas
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 |
--------------------------------------------------------------------------------
/scripts/migrations/0026_alter_scripttag_style.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.2.8 on 2023-12-18 00:16
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("scripts", "0025_alter_scripttag_name"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="scripttag",
14 | name="style",
15 | field=models.CharField(
16 | choices=[
17 | ("badge-primary", "Blue"),
18 | ("badge-secondary", "Grey"),
19 | ("badge-success", "Green"),
20 | ("badge-danger", "Red"),
21 | ("badge-warning", "Yellow"),
22 | ("badge-info", "Cyan"),
23 | ("badge-light", "White"),
24 | ("badge-dark", "Black"),
25 | ("badge-purple", "Purple"),
26 | ("badge-nocturne", "Nocturne"),
27 | ],
28 | default="badge-primary",
29 | max_length=20,
30 | ),
31 | ),
32 | ]
33 |
--------------------------------------------------------------------------------
/tests/input/trouble_brewing.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "washerwoman"
4 | },
5 | {
6 | "id": "librarian"
7 | },
8 | {
9 | "id": "investigator"
10 | },
11 | {
12 | "id": "chef"
13 | },
14 | {
15 | "id": "empath"
16 | },
17 | {
18 | "id": "fortuneteller"
19 | },
20 | {
21 | "id": "undertaker"
22 | },
23 | {
24 | "id": "monk"
25 | },
26 | {
27 | "id": "ravenkeeper"
28 | },
29 | {
30 | "id": "virgin"
31 | },
32 | {
33 | "id": "slayer"
34 | },
35 | {
36 | "id": "soldier"
37 | },
38 | {
39 | "id": "mayor"
40 | },
41 | {
42 | "id": "butler"
43 | },
44 | {
45 | "id": "drunk"
46 | },
47 | {
48 | "id": "recluse"
49 | },
50 | {
51 | "id": "saint"
52 | },
53 | {
54 | "id": "poisoner"
55 | },
56 | {
57 | "id": "spy"
58 | },
59 | {
60 | "id": "scarletwoman"
61 | },
62 | {
63 | "id": "baron"
64 | },
65 | {
66 | "id": "imp"
67 | }
68 | ]
--------------------------------------------------------------------------------
/scripts/templates/chart.html:
--------------------------------------------------------------------------------
1 | $(document).ready(function() {
2 | var ctx = document.getElementById('{{ChartName}}').getContext('2d');
3 | var myChart = new Chart(ctx, {
4 | type: 'bar',
5 | data: {
6 | labels: [{% for number in ChartDictionary %}'{{number}}',{% endfor %}],
7 | datasets: [{
8 | label: '{{ChartLabel}}',
9 | data: [{% for count in ChartDictionary.values %}{{count}},{% endfor %}],
10 | backgroundColor: [{% for number in ChartDictionary %}
11 | '{{ChartColor}}',
12 | {% endfor %}
13 | ],
14 | borderColor: [{% for number in ChartDictionary %}
15 | '{{ChartBorder}}',
16 | {% endfor %}
17 | ],
18 | borderWidth: 1
19 | }]
20 | },
21 | options: {
22 | scales: {
23 | yAxes: [{
24 | ticks: {
25 | beginAtZero: true
26 | }
27 | }]
28 | }
29 | }
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/tests/input/trouble_brewing_with_meta.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "washerwoman"
4 | },
5 | {
6 | "id": "librarian"
7 | },
8 | {
9 | "id": "investigator"
10 | },
11 | {
12 | "id": "chef"
13 | },
14 | {
15 | "id": "empath"
16 | },
17 | {
18 | "id": "fortuneteller"
19 | },
20 | {
21 | "id": "undertaker"
22 | },
23 | {
24 | "id": "monk"
25 | },
26 | {
27 | "id": "ravenkeeper"
28 | },
29 | {
30 | "id": "virgin"
31 | },
32 | {
33 | "id": "slayer"
34 | },
35 | {
36 | "id": "soldier"
37 | },
38 | {
39 | "id": "mayor"
40 | },
41 | {
42 | "id": "butler"
43 | },
44 | {
45 | "id": "drunk"
46 | },
47 | {
48 | "id": "recluse"
49 | },
50 | {
51 | "id": "saint"
52 | },
53 | {
54 | "id": "poisoner"
55 | },
56 | {
57 | "id": "spy"
58 | },
59 | {
60 | "id": "scarletwoman"
61 | },
62 | {
63 | "id": "baron"
64 | },
65 | {
66 | "id": "imp"
67 | }
68 | ]
--------------------------------------------------------------------------------
/scripts/migrations/0020_auto_20230319_2242.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.18 on 2023-03-19 22:42
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("scripts", "0019_scripttag_order"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="character",
15 | name="edition",
16 | field=models.IntegerField(
17 | choices=[
18 | (0, "Base"),
19 | (1, "Kickstarter"),
20 | (2, "clocktower.online"),
21 | (3, "All"),
22 | ]
23 | ),
24 | ),
25 | migrations.AlterField(
26 | model_name="scriptversion",
27 | name="edition",
28 | field=models.IntegerField(
29 | choices=[
30 | (0, "Base"),
31 | (1, "Kickstarter"),
32 | (2, "clocktower.online"),
33 | (3, "All"),
34 | ],
35 | default=3,
36 | ),
37 | ),
38 | ]
39 |
--------------------------------------------------------------------------------
/scripts/templates/delete_comment.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap4 %}
2 |
3 | {% block content %}
4 |
5 |
8 |
9 |
10 |
26 |
27 | {% endblock %}
28 |
29 |
--------------------------------------------------------------------------------
/tests/input/pies_baking.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "noble"
4 | },
5 | {
6 | "id": "chef"
7 | },
8 | {
9 | "id": "washerwoman"
10 | },
11 | {
12 | "id": "librarian"
13 | },
14 | {
15 | "id": "empath"
16 | },
17 | {
18 | "id": "fortuneteller"
19 | },
20 | {
21 | "id": "monk"
22 | },
23 | {
24 | "id": "slayer"
25 | },
26 | {
27 | "id": "soldier"
28 | },
29 | {
30 | "id": "ravenkeeper"
31 | },
32 | {
33 | "id": "virgin"
34 | },
35 | {
36 | "id": "mayor"
37 | },
38 | {
39 | "id": "cannibal"
40 | },
41 | {
42 | "id": "butler"
43 | },
44 | {
45 | "id": "saint"
46 | },
47 | {
48 | "id": "recluse"
49 | },
50 | {
51 | "id": "drunk"
52 | },
53 | {
54 | "id": "poisoner"
55 | },
56 | {
57 | "id": "spy"
58 | },
59 | {
60 | "id": "baron"
61 | },
62 | {
63 | "id": "scarletwoman"
64 | },
65 | {
66 | "id": "marionette"
67 | },
68 | {
69 | "id": "imp"
70 | }
71 | ]
--------------------------------------------------------------------------------
/tests/input/strings_pulling.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "investigator"
4 | },
5 | {
6 | "id": "chef"
7 | },
8 | {
9 | "id": "washerwoman"
10 | },
11 | {
12 | "id": "librarian"
13 | },
14 | {
15 | "id": "empath"
16 | },
17 | {
18 | "id": "fortuneteller"
19 | },
20 | {
21 | "id": "undertaker"
22 | },
23 | {
24 | "id": "monk"
25 | },
26 | {
27 | "id": "slayer"
28 | },
29 | {
30 | "id": "soldier"
31 | },
32 | {
33 | "id": "ravenkeeper"
34 | },
35 | {
36 | "id": "virgin"
37 | },
38 | {
39 | "id": "mayor"
40 | },
41 | {
42 | "id": "butler"
43 | },
44 | {
45 | "id": "saint"
46 | },
47 | {
48 | "id": "recluse"
49 | },
50 | {
51 | "id": "drunk"
52 | },
53 | {
54 | "id": "poisoner"
55 | },
56 | {
57 | "id": "spy"
58 | },
59 | {
60 | "id": "baron"
61 | },
62 | {
63 | "id": "scarletwoman"
64 | },
65 | {
66 | "id": "marionette"
67 | },
68 | {
69 | "id": "imp"
70 | }
71 | ]
--------------------------------------------------------------------------------
/scripts/management/commands/delete_orphaned_scripts.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from scripts.models import Script
3 |
4 |
5 | class Command(BaseCommand):
6 | help = "Find and delete Script objects that have no associated ScriptVersion objects"
7 |
8 | def handle(self, *args, **options):
9 | # Find all scripts with no versions
10 | orphaned_scripts = Script.objects.filter(versions__isnull=True)
11 | total = orphaned_scripts.count()
12 |
13 | if total == 0:
14 | self.stdout.write(self.style.SUCCESS("No orphaned scripts found!"))
15 | return
16 |
17 | self.stdout.write(f"Found {total} script(s) with no versions:\n")
18 |
19 | for script in orphaned_scripts:
20 | info = f" ID: {script.pk} | Name: {script.name}"
21 | if script.owner:
22 | info += f" | Owner: {script.owner.username}"
23 | else:
24 | info += " | Owner: None"
25 |
26 | self.stdout.write(info)
27 |
28 | deleted_count, _ = orphaned_scripts.delete()
29 | self.stdout.write(self.style.SUCCESS(f"Successfully deleted {deleted_count} orphaned script(s)"))
30 |
--------------------------------------------------------------------------------
/scripts/templates/worldcup/fixturetable.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | |
8 | Script 1
9 | |
10 |
11 | |
12 |
13 | Script 2
14 | |
15 |
16 | VoD
17 | |
18 |
19 | Voting Link
20 | |
21 |
22 |
23 |
24 | {% for entry in round %}
25 | {% include "worldcup/fixturerow.html" %}
26 | {% endfor %}
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/tests/input/half_of_the_108.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "washerwoman"
4 | },
5 | {
6 | "id": "librarian"
7 | },
8 | {
9 | "id": "investigator"
10 | },
11 | {
12 | "id": "chef"
13 | },
14 | {
15 | "id": "empath"
16 | },
17 | {
18 | "id": "fortuneteller"
19 | },
20 | {
21 | "id": "undertaker"
22 | },
23 | {
24 | "id": "monk"
25 | },
26 | {
27 | "id": "ravenkeeper"
28 | },
29 | {
30 | "id": "virgin"
31 | },
32 | {
33 | "id": "slayer"
34 | },
35 | {
36 | "id": "soldier"
37 | },
38 | {
39 | "id": "mayor"
40 | },
41 | {
42 | "id": "butler"
43 | },
44 | {
45 | "id": "drunk"
46 | },
47 | {
48 | "id": "recluse"
49 | },
50 | {
51 | "id": "saint"
52 | },
53 | {
54 | "id": "poisoner"
55 | },
56 | {
57 | "id": "spy"
58 | },
59 | {
60 | "id": "baron"
61 | },
62 | {
63 | "id": "scarletwoman"
64 | },
65 | {
66 | "id": "legion"
67 | },
68 | {
69 | "id": "imp"
70 | },
71 | {
72 | "id": "vortox"
73 | }
74 | ]
--------------------------------------------------------------------------------
/scripts/templates/worldcup/statstable.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ left_title }}
4 |
14 |
15 |
16 |
{{ right_title }}
17 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/tests/input/strings_pulling_with_meta.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "_meta",
4 | "name": "Strings Pulling",
5 | "author": ""
6 | },
7 | {
8 | "id": "investigator"
9 | },
10 | {
11 | "id": "chef"
12 | },
13 | {
14 | "id": "washerwoman"
15 | },
16 | {
17 | "id": "librarian"
18 | },
19 | {
20 | "id": "empath"
21 | },
22 | {
23 | "id": "fortuneteller"
24 | },
25 | {
26 | "id": "undertaker"
27 | },
28 | {
29 | "id": "monk"
30 | },
31 | {
32 | "id": "slayer"
33 | },
34 | {
35 | "id": "soldier"
36 | },
37 | {
38 | "id": "ravenkeeper"
39 | },
40 | {
41 | "id": "virgin"
42 | },
43 | {
44 | "id": "mayor"
45 | },
46 | {
47 | "id": "butler"
48 | },
49 | {
50 | "id": "saint"
51 | },
52 | {
53 | "id": "recluse"
54 | },
55 | {
56 | "id": "drunk"
57 | },
58 | {
59 | "id": "poisoner"
60 | },
61 | {
62 | "id": "spy"
63 | },
64 | {
65 | "id": "baron"
66 | },
67 | {
68 | "id": "scarletwoman"
69 | },
70 | {
71 | "id": "marionette"
72 | },
73 | {
74 | "id": "imp"
75 | }
76 | ]
--------------------------------------------------------------------------------
/scripts/migrations/0044_scriptversion_num_loric_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.7 on 2025-10-31 23:53
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0043_script_num_downloads'),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name='scriptversion',
15 | name='num_loric',
16 | field=models.IntegerField(default=0),
17 | preserve_default=False,
18 | ),
19 | migrations.AlterField(
20 | model_name='clocktowercharacter',
21 | name='character_type',
22 | field=models.CharField(choices=[('Townsfolk', 'Townsfolk'), ('Outsider', 'Outsider'), ('Minion', 'Minion'), ('Demon', 'Demon'), ('Traveller', 'Traveller'), ('Fabled', 'Fabled'), ('Loric', 'Loric'), ('Unknown', 'Unknown')], max_length=30),
23 | ),
24 | migrations.AlterField(
25 | model_name='homebrewcharacter',
26 | name='character_type',
27 | field=models.CharField(choices=[('Townsfolk', 'Townsfolk'), ('Outsider', 'Outsider'), ('Minion', 'Minion'), ('Demon', 'Demon'), ('Traveller', 'Traveller'), ('Fabled', 'Fabled'), ('Loric', 'Loric'), ('Unknown', 'Unknown')], max_length=30),
28 | ),
29 | ]
30 |
--------------------------------------------------------------------------------
/scripts/templates/delete_script.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap4 %}
2 | {% load botc_script_tags %}
3 |
4 |
5 |
6 |
7 |
8 |
14 |
15 | Are you sure you want to delete this script?
16 |
17 |
24 |
25 |
26 |
27 |
28 |
31 |
--------------------------------------------------------------------------------
/scripts/management/commands/update_script_counts.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from scripts.models import ScriptVersion, CharacterType
3 | from scripts.views import count_character
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Update character count fields for all script versions"
8 |
9 | def handle(self, *args, **options):
10 | scripts = ScriptVersion.objects.all()
11 | total = scripts.count()
12 |
13 | self.stdout.write(f"Updating {total} script versions...")
14 |
15 | for i, script in enumerate(scripts, 1):
16 | script.num_townsfolk = count_character(script.content, CharacterType.TOWNSFOLK)
17 | script.num_outsiders = count_character(script.content, CharacterType.OUTSIDER)
18 | script.num_minions = count_character(script.content, CharacterType.MINION)
19 | script.num_demons = count_character(script.content, CharacterType.DEMON)
20 | script.num_fabled = count_character(script.content, CharacterType.FABLED)
21 | script.num_loric = count_character(script.content, CharacterType.LORIC)
22 | script.num_travellers = count_character(script.content, CharacterType.TRAVELLER)
23 | script.save()
24 |
25 | if i % 100 == 0:
26 | self.stdout.write(f"Progress: {i}/{total}")
27 |
28 | self.stdout.write(self.style.SUCCESS(f"Successfully updated {total} script versions"))
29 |
--------------------------------------------------------------------------------
/tests/test_changes.py:
--------------------------------------------------------------------------------
1 | import json as js
2 | import os
3 | import pytest
4 | from scripts.script_json import get_json_changes
5 |
6 | current_dir = os.path.dirname(os.path.realpath(__file__))
7 |
8 |
9 | @pytest.mark.parametrize(
10 | "orig, new",
11 | [
12 | ("input/trouble_brewing.json", "input/strings_pulling.json"),
13 | ("input/trouble_brewing.json", "input/strings_pulling_with_meta.json"),
14 | ("input/trouble_brewing_with_meta.json", "input/strings_pulling.json"),
15 | (
16 | "input/trouble_brewing_with_meta.json",
17 | "input/strings_pulling_with_meta.json",
18 | ),
19 | ("input/trouble_brewing.json", "input/half_of_the_108.json"),
20 | ("input/trouble_brewing.json", "input/pies_baking.json"),
21 | ],
22 | )
23 | def test_clocktower_scripts(orig, new):
24 | with open(os.path.join(current_dir, orig), "r") as f:
25 | v1 = js.load(f)
26 | with open(os.path.join(current_dir, new), "r") as f:
27 | v2 = js.load(f)
28 | changes = get_json_changes(v1.copy(), v2.copy())
29 | assert changes == []
30 |
31 |
32 | def test_homebrew():
33 | with open(os.path.join(current_dir, "input/hybrid1.json"), "r") as f:
34 | v1 = js.load(f)
35 | with open(os.path.join(current_dir, "input/hybrid2.json"), "r") as f:
36 | v2 = js.load(f)
37 | changes = get_json_changes(v1.copy(), v2.copy())
38 | assert changes == [{"id": "custom_imp"}]
39 |
--------------------------------------------------------------------------------
/scripts/migrations/0012_auto_20220523_2251.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.13 on 2022-05-23 22:51
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 | ('scripts', '0011_favourite'),
13 | ]
14 |
15 | operations = [
16 | migrations.AlterField(
17 | model_name='favourite',
18 | name='script',
19 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favourites', to='scripts.scriptversion'),
20 | ),
21 | migrations.CreateModel(
22 | name='Collection',
23 | fields=[
24 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
25 | ('name', models.CharField(max_length=100)),
26 | ('description', models.TextField(blank=True, max_length=255, null=True)),
27 | ('notes', models.TextField(blank=True, null=True)),
28 | ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections', to=settings.AUTH_USER_MODEL)),
29 | ('scripts', models.ManyToManyField(blank=True, related_name='collections', to='scripts.ScriptVersion')),
30 | ],
31 | ),
32 | ]
33 |
--------------------------------------------------------------------------------
/scripts/templates/socialaccount/connections.html:
--------------------------------------------------------------------------------
1 | {% extends "socialaccount/base.html" %}
2 |
3 | {% load bootstrap4}
4 | {% load i18n %}
5 |
6 | {% block head_title %}{% trans "Account Connections" %}{% endblock %}
7 |
8 | {% block content %}
9 | {% trans "Account Connections" %}
10 |
11 | {% if form.accounts %}
12 | {% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}
13 |
14 |
39 |
40 | {% endif %}
41 |
42 | {% blocktrans %}Note: You cannot remove the last third part account configured.{% endblocktrans %}
43 |
44 | {% trans 'Add a 3rd Party Account' %}
45 |
46 | {% include "account/provider_panel.html" with process="connect" %}
47 |
48 | {% endblock %}
--------------------------------------------------------------------------------
/scripts/templates/base.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 | {% load bootstrap4 %}
3 | {% load i18n %}
4 |
5 | {% bootstrap_css %}
6 | {% bootstrap_javascript jquery='full' %}
7 |
8 |
9 |
10 |
11 |
25 |
26 | BotC Scripts
27 |
28 |
29 |
30 |
31 |
32 |
33 | {% include 'navbar.html' %}
34 |
35 | {% if BANNER %}
36 |
37 | {{ BANNER }}
38 |
39 | {% endif %}
40 |
41 |
42 |
43 |
44 | {% block content %}
45 | {% endblock %}
46 |
47 |
48 |
49 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/scripts/migrations/0031_alter_clocktowercharacter_character_id_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.2 on 2024-11-01 22:53
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0030_alter_clocktowercharacter_global_reminders_and_more'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='clocktowercharacter',
15 | name='character_id',
16 | field=models.CharField(max_length=50),
17 | ),
18 | migrations.AlterField(
19 | model_name='clocktowercharacter',
20 | name='character_name',
21 | field=models.CharField(max_length=20),
22 | ),
23 | migrations.AlterField(
24 | model_name='homebrewcharacter',
25 | name='character_id',
26 | field=models.CharField(max_length=50),
27 | ),
28 | migrations.AlterField(
29 | model_name='homebrewcharacter',
30 | name='character_name',
31 | field=models.CharField(max_length=20),
32 | ),
33 | migrations.AlterField(
34 | model_name='translation',
35 | name='character_id',
36 | field=models.CharField(max_length=50),
37 | ),
38 | migrations.AlterField(
39 | model_name='translation',
40 | name='character_name',
41 | field=models.CharField(max_length=30),
42 | ),
43 | ]
44 |
--------------------------------------------------------------------------------
/scripts/templates/worldcup/fixturerow.html:
--------------------------------------------------------------------------------
1 | {% load botc_script_tags %}
2 |
3 |
4 | {% if entry.winner == "Home" %}
5 | |
6 | {% else %}
7 | |
8 | {% endif %}
9 | {{ entry.script1.script.name }}
10 | |
11 |
12 | vs
13 | |
14 | {% if entry.winner == "Away" %}
15 |
16 | {% else %}
17 | |
18 | {% endif %}
19 | {{ entry.script2.script.name }}
20 | |
21 |
22 | {% if entry.vod %}
23 | {% for vod in entry.vod|split %}
24 |
25 |
28 |
29 | {% endfor %}
30 | {% endif %}
31 | |
32 |
33 | {% if entry.form %}
34 |
35 |
38 |
39 | {% endif %}
40 | |
41 |
--------------------------------------------------------------------------------
/scripts/cache.py:
--------------------------------------------------------------------------------
1 | from django.core.cache import cache
2 | from scripts import models
3 | from typing import List, Optional
4 | import uuid
5 |
6 | CACHE_TIMEOUT = 60 * 60 * 1 # 1 hour
7 | CLOCKTOWER_CHARACTERS_CACHE_KEY = "clocktower_characters"
8 | HOMEBREW_CHARACTERS_CACHE_KEY = "homebrew_characters"
9 |
10 |
11 | def get_clocktower_characters(force=False) -> dict[str, models.ClocktowerCharacter]:
12 | characters = cache.get(CLOCKTOWER_CHARACTERS_CACHE_KEY)
13 | if force or characters is None:
14 | characters = {character.character_id: character for character in models.ClocktowerCharacter.objects.all()}
15 | cache.set(CLOCKTOWER_CHARACTERS_CACHE_KEY, characters, timeout=CACHE_TIMEOUT) # Cache for 24 hours
16 | return characters
17 |
18 |
19 | def get_homebrew_characters(force=False) -> dict[str, models.HomebrewCharacter]:
20 | characters = cache.get(HOMEBREW_CHARACTERS_CACHE_KEY)
21 | if force or characters is None:
22 | characters = {character.character_id: character for character in models.HomebrewCharacter.objects.all()}
23 | cache.set(HOMEBREW_CHARACTERS_CACHE_KEY, characters, timeout=CACHE_TIMEOUT) # Cache for 24 hours
24 | return characters
25 |
26 |
27 | def store_advanced_search_results(pk_list: List[int]) -> str:
28 | cache_key = f"{uuid.uuid4().hex}"
29 | cache.set(cache_key, {"queryset_pks": pk_list, "num_results": len(pk_list)}, timeout=300)
30 | return cache_key
31 |
32 |
33 | def get_advanced_search_results(cache_key: str) -> Optional[dict]:
34 | return cache.get(cache_key)
35 |
--------------------------------------------------------------------------------
/scripts/templates/remove_collection.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap4 %}
2 | {% load botc_script_tags %}
3 |
4 |
5 |
6 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
21 |
22 | Are you sure you want to remove script {{record.script.name}}, v{{record.version}} from {{collection.name}}?
23 |
24 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/scripts/templates/account/delete.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% load bootstrap4 %}
4 |
5 | {% block content %}
6 |
7 |
8 | Deleting your account will not delete the scripts associated with it.
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
26 |
27 | Are you sure you want to delete your account?
28 |
29 |
36 |
37 |
38 |
39 |
40 | {% endblock %}
41 |
42 |
--------------------------------------------------------------------------------
/tests/input/hybrid1.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "washerwoman"
4 | },
5 | {
6 | "id": "librarian"
7 | },
8 | {
9 | "id": "investigator"
10 | },
11 | {
12 | "id": "chef"
13 | },
14 | {
15 | "id": "empath"
16 | },
17 | {
18 | "id": "fortuneteller"
19 | },
20 | {
21 | "id": "undertaker"
22 | },
23 | {
24 | "id": "monk"
25 | },
26 | {
27 | "id": "ravenkeeper"
28 | },
29 | {
30 | "id": "virgin"
31 | },
32 | {
33 | "id": "slayer"
34 | },
35 | {
36 | "id": "soldier"
37 | },
38 | {
39 | "id": "mayor"
40 | },
41 | {
42 | "id": "butler"
43 | },
44 | {
45 | "id": "drunk"
46 | },
47 | {
48 | "id": "recluse"
49 | },
50 | {
51 | "id": "saint"
52 | },
53 | {
54 | "id": "poisoner"
55 | },
56 | {
57 | "id": "spy"
58 | },
59 | {
60 | "id": "scarletwoman"
61 | },
62 | {
63 | "id": "baron"
64 | },
65 | {
66 | "id": "custom_imp",
67 | "name": "Imp",
68 | "image": [
69 | "https://example.com/assets/imp_g.webp",
70 | "https://example.com/assets/imp_e.webp"
71 | ],
72 | "team": "demon",
73 | "otherNight": 30,
74 | "otherNightReminder": "The Imp chooses a player.",
75 | "reminders": [
76 | "Dead"
77 | ],
78 | "setup": false,
79 | "ability": "Each night*, choose a player: they die. If you kill yourself this way, a Minion becomes the Imp."
80 | }
81 | ]
--------------------------------------------------------------------------------
/tests/input/hybrid2.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "washerwoman"
4 | },
5 | {
6 | "id": "librarian"
7 | },
8 | {
9 | "id": "investigator"
10 | },
11 | {
12 | "id": "chef"
13 | },
14 | {
15 | "id": "empath"
16 | },
17 | {
18 | "id": "fortuneteller"
19 | },
20 | {
21 | "id": "undertaker"
22 | },
23 | {
24 | "id": "monk"
25 | },
26 | {
27 | "id": "ravenkeeper"
28 | },
29 | {
30 | "id": "virgin"
31 | },
32 | {
33 | "id": "slayer"
34 | },
35 | {
36 | "id": "soldier"
37 | },
38 | {
39 | "id": "mayor"
40 | },
41 | {
42 | "id": "butler"
43 | },
44 | {
45 | "id": "drunk"
46 | },
47 | {
48 | "id": "recluse"
49 | },
50 | {
51 | "id": "saint"
52 | },
53 | {
54 | "id": "poisoner"
55 | },
56 | {
57 | "id": "spy"
58 | },
59 | {
60 | "id": "scarletwoman"
61 | },
62 | {
63 | "id": "baron"
64 | },
65 | {
66 | "id": "custom_imp",
67 | "name": "Imp",
68 | "image": [
69 | "https://example.com/assets/imp_g.webp",
70 | "https://example.com/assets/imp_e.webp"
71 | ],
72 | "team": "demon",
73 | "otherNight": 30,
74 | "otherNightReminder": "The Imp chooses a player.",
75 | "reminders": [
76 | "Dead"
77 | ],
78 | "setup": false,
79 | "ability": "Each night*, choose 2 players: they die. If you kill yourself this way, a Minion becomes the Imp."
80 | }
81 | ]
--------------------------------------------------------------------------------
/scripts/templates/widgets/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) Django Software Foundation and individual contributors.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in the
12 | documentation and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of Django nor the names of its contributors may be used
15 | to endorse or promote products derived from this software without
16 | specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/scripts/management/commands/update_homebrewiness.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from scripts.models import ScriptVersion
3 | from scripts.views import create_characters_and_determine_homebrew_status
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Update homebrewiness field for all script versions (Clocktower/Hybrid/Homebrew)"
8 |
9 | def handle(self, *args, **options):
10 | scripts = ScriptVersion.objects.all()
11 | total = scripts.count()
12 | updated_count = 0
13 | self.stdout.write(f"Processing {total} script versions...")
14 |
15 | for i, script_version in enumerate(scripts, 1):
16 | old_homebrewiness = script_version.homebrewiness
17 |
18 | # Calculate the new homebrewiness value
19 | new_homebrewiness = create_characters_and_determine_homebrew_status(
20 | script_version.content, script_version.script
21 | )
22 |
23 | if old_homebrewiness != new_homebrewiness:
24 | updated_count += 1
25 | script_version.homebrewiness = new_homebrewiness
26 | script_version.save(update_fields=["homebrewiness"])
27 |
28 | self.stdout.write(
29 | f" [{i}/{total}] {script_version.script.name} v{script_version.version}: "
30 | f"{old_homebrewiness} -> {new_homebrewiness}"
31 | )
32 |
33 | if i % 100 == 0:
34 | self.stdout.write(f"Progress: {i}/{total} ({updated_count} updates)")
35 |
36 | self.stdout.write(self.style.SUCCESS(f"\nSuccessfully updated {updated_count} script versions"))
37 |
--------------------------------------------------------------------------------
/scripts/migrations/0005_auto_20211221_0205.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.10 on 2021-12-21 02:05
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 | ('scripts', '0004_scripttag_public'),
13 | ]
14 |
15 | operations = [
16 | migrations.AddField(
17 | model_name='vote',
18 | name='user',
19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL),
20 | ),
21 | migrations.AlterField(
22 | model_name='comment',
23 | name='comment',
24 | field=models.TextField(),
25 | ),
26 | migrations.AlterField(
27 | model_name='vote',
28 | name='created',
29 | field=models.DateTimeField(auto_now_add=True),
30 | ),
31 | migrations.CreateModel(
32 | name='Play',
33 | fields=[
34 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
35 | ('playtime', models.DateField(auto_now_add=True)),
36 | ('script', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plays', to='scripts.scriptversion')),
37 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plays', to=settings.AUTH_USER_MODEL)),
38 | ],
39 | ),
40 | ]
41 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "botc-scripts"
3 | version = "0.1.0"
4 | description = ""
5 | authors = [{ name = "Geoff Thomas" }]
6 | requires-python = "<4.0,>=3.10"
7 | license = { text = "MIT" }
8 | dependencies = [
9 | "Django<6.0,>=5.1",
10 | "django-versionfield<2.0.0,>=1.0.2",
11 | "packaging<25.0,>=24.2",
12 | "django-tables2<3.0.0,>=2.4.1",
13 | "django-bootstrap4<25.0,>=24.1",
14 | "django-filter<25.0,>=24.2",
15 | "djangorestframework<4.0.0,>=3.12.2",
16 | "psycopg2-binary<3.0.0,>=2.9.9",
17 | "django-storages[azure]<2.0.0,>=1.11.1",
18 | "django-allauth[socialaccount]<66.0.0,>=65.2.0",
19 | "django-bootstrap-icons<1.0.0,>=0.9.0",
20 | "Babel<3.0.0,>=2.13.1",
21 | "django-markdownify<1.0.0,>=0.9.2",
22 | "requests<3.0.0,>=2.31.0",
23 | "django-cors-headers<5.0.0,>=4.3.0",
24 | "jsonschema<5.0.0,>=4.23.0",
25 | "bleach>=6.2.0",
26 | "drf-spectacular>=0.28.0",
27 | ]
28 | requires-plugins = { poetry-plugin-export = ">=1.8" }
29 | package-mode = false
30 |
31 | [dependency-groups]
32 | dev = [
33 | "pytest>=8.3.2,<9",
34 | "pytest-django>=4.9.0,<5",
35 | "pytest-cov>=6.0.0,<7",
36 | "isort",
37 | "mypy",
38 | "ruff",
39 | ]
40 |
41 | [tool.uv]
42 | package = false
43 |
44 | [tool.pdm.dev-dependencies]
45 | dev = [
46 | "pytest<9.0.0,>=8.3.2",
47 | "pytest-django<5.0.0,>=4.9.0",
48 | "pytest-cov<7.0.0,>=6.0.0",
49 | "isort",
50 | "mypy",
51 | "ruff",
52 | ]
53 |
54 | [tool.pdm.build]
55 | includes = []
56 |
57 | [build-system]
58 | requires = ["pdm-backend"]
59 | build-backend = "pdm.backend"
60 |
61 | [tool.ruff]
62 | exclude = ["scripts/migrations","botc/", "manage.py"]
63 | line-length = 120
64 |
65 | [tool.pytest.ini_options]
66 | DJANGO_SETTINGS_MODULE = "tests.settings"
67 |
--------------------------------------------------------------------------------
/scripts/templates/script_table/actions/collection.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% include 'script_table/actions/download_json.html' %}
4 | {% include 'script_table/actions/download_pdf.html' %}
5 |
6 | {% load bootstrap4 %}
7 | {% load botc_script_tags %}
8 | {% load bootstrap_icons %}
9 |
10 |
11 |
12 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
27 |
28 | Are you sure you want to remove script {{record.script.name}}, v{{record.version}} from {{collection.name}}?
29 |
30 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/scripts/validators.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import ValidationError
2 | from django.conf import settings
3 | from versionfield.forms import VersionField
4 | from scripts import models
5 |
6 |
7 | def check_for_homebrew(item):
8 | """
9 | Homebrew characters are not supported in the custom script database.
10 | """
11 | if item.get("id", "") != "_meta":
12 | if len(item) > 1:
13 | raise ValidationError(
14 | "Only officially supported characters from https://bloodontheclocktower.com/script/ are supported"
15 | )
16 |
17 |
18 | def prevent_fishbucket(json):
19 | """
20 | Stop people trying to upload Fishbucket scripts, we automatically generate them.
21 | """
22 | if len(json) > 50:
23 | raise ValidationError(
24 | 'The script database limits scripts to 50 characters. If you\'re trying to upload a "Fishbucket" script,'
25 | " this is automatically generated at https://botcscripts.com/script/all_roles"
26 | )
27 |
28 |
29 | def validate_json(json):
30 | if settings.DISABLE_VALIDATORS:
31 | return
32 |
33 | prevent_fishbucket(json)
34 | return
35 |
36 |
37 | def validate_homebrew_character(json, script):
38 | for item in json:
39 | if item.get("id", "") == "_meta":
40 | continue
41 |
42 | try:
43 | homebrew_character = models.HomebrewCharacter.objects.get(character_id=item.get("id"))
44 | if homebrew_character.script and homebrew_character.script != script:
45 | raise ValidationError(
46 | f"Character {item.get('id')} is already in the database with a different script ({homebrew_character.script.name}). Please choose another character ID"
47 | )
48 | except models.HomebrewCharacter.DoesNotExist:
49 | pass
50 |
51 |
52 | def valid_version(value):
53 | field = VersionField()
54 | field.check_format(value)
55 |
--------------------------------------------------------------------------------
/scripts/templates/add_to_collection.html:
--------------------------------------------------------------------------------
1 | {% load bootstrap4 %}
2 | {% load botc_script_tags %}
3 |
4 |
5 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/scripts/migrations/0032_remove_clocktowercharacter_id_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.2 on 2024-11-05 00:23
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0031_alter_clocktowercharacter_character_id_and_more'),
10 | ]
11 |
12 | operations = [
13 | migrations.RemoveField(
14 | model_name='clocktowercharacter',
15 | name='id',
16 | ),
17 | migrations.RemoveField(
18 | model_name='homebrewcharacter',
19 | name='id',
20 | ),
21 | migrations.AlterField(
22 | model_name='clocktowercharacter',
23 | name='character_id',
24 | field=models.CharField(max_length=50, primary_key=True, serialize=False),
25 | ),
26 | migrations.AlterField(
27 | model_name='clocktowercharacter',
28 | name='global_reminders',
29 | field=models.TextField(blank=True, null=True),
30 | ),
31 | migrations.AlterField(
32 | model_name='clocktowercharacter',
33 | name='image_url',
34 | field=models.TextField(blank=True, null=True),
35 | ),
36 | migrations.AlterField(
37 | model_name='homebrewcharacter',
38 | name='character_id',
39 | field=models.CharField(max_length=50, primary_key=True, serialize=False),
40 | ),
41 | migrations.AlterField(
42 | model_name='homebrewcharacter',
43 | name='global_reminders',
44 | field=models.TextField(blank=True, null=True),
45 | ),
46 | migrations.AlterField(
47 | model_name='homebrewcharacter',
48 | name='image_url',
49 | field=models.TextField(blank=True, null=True),
50 | ),
51 | migrations.AlterField(
52 | model_name='translation',
53 | name='global_reminders',
54 | field=models.TextField(blank=True, null=True),
55 | ),
56 | ]
57 |
--------------------------------------------------------------------------------
/scripts/migrations/0035_favourite_parent_vote_parent_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.4 on 2025-07-06 13:37
2 |
3 | import django.db.models.deletion
4 | from django.db import migrations, models
5 |
6 | def change_parent(apps, _):
7 | Favourite = apps.get_model("scripts", "favourite")
8 | Vote = apps.get_model("scripts", "vote")
9 | # Update existing favourites and votes to have the parent field set to the script they belong to
10 | for favourite in Favourite.objects.all():
11 | favourite.parent = favourite.script.script
12 | favourite.save()
13 |
14 | for vote in Vote.objects.all():
15 | vote.parent = vote.script.script
16 | vote.save()
17 |
18 | class Migration(migrations.Migration):
19 |
20 | dependencies = [
21 | ('scripts', '0034_remove_clocktowercharacter_image_url'),
22 | ]
23 |
24 | operations = [
25 | migrations.AddField(
26 | model_name='favourite',
27 | name='parent',
28 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='favourites', to='scripts.script'),
29 | ),
30 | migrations.AddField(
31 | model_name='vote',
32 | name='parent',
33 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='scripts.script'),
34 | ),
35 | migrations.AlterField(
36 | model_name='clocktowercharacter',
37 | name='edition',
38 | field=models.IntegerField(choices=[(0, 'Base'), (1, 'Kickstarter'), (2, 'Carousel'), (3, 'All')]),
39 | ),
40 | migrations.AlterField(
41 | model_name='scriptversion',
42 | name='edition',
43 | field=models.IntegerField(choices=[(0, 'Base'), (1, 'Kickstarter'), (2, 'Carousel'), (3, 'All')], default=3),
44 | ),
45 | migrations.RunPython(
46 | change_parent,
47 | reverse_code=migrations.RunPython.noop, # No reverse migration needed
48 | )
49 | ]
50 |
--------------------------------------------------------------------------------
/scripts/migrations/0036_migrate_edition.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 | def change_edition(apps, _):
4 | from scripts import models, views
5 | ClocktowerCharacter = apps.get_model("scripts", "clocktowercharacter")
6 | ScriptVersion = apps.get_model("scripts", "scriptversion")
7 |
8 | # Update existing clocktower characters to have the correct edition choices
9 | for character in ClocktowerCharacter.objects.all():
10 | if character.edition == models.Edition.ALL:
11 | character.edition == models.Edition.CAROUSEL
12 | character.save()
13 |
14 | # Update existing script versions to have the correct edition choices
15 | for version in ScriptVersion.objects.all():
16 | version.edition = views.calculate_edition(version.content)
17 | version.save()
18 |
19 | def remove_duplicate_votes_and_favourites(apps, _):
20 | Vote = apps.get_model("scripts", "vote")
21 | Favourite = apps.get_model("scripts", "favourite")
22 | Script = apps.get_model("scripts", "script")
23 |
24 | for script in Script.objects.all():
25 | VoteUsers = set()
26 | FavouriteUsers = set()
27 | for Vote in script.votes.all():
28 | if Vote.user in VoteUsers:
29 | Vote.delete()
30 | else:
31 | VoteUsers.add(Vote.user)
32 | for Favourite in script.favourites.all():
33 | if Favourite.user in FavouriteUsers:
34 | Favourite.delete()
35 | else:
36 | FavouriteUsers.add(Favourite.user)
37 |
38 |
39 |
40 | class Migration(migrations.Migration):
41 |
42 | dependencies = [
43 | ('scripts', '0035_favourite_parent_vote_parent_and_more'),
44 | ]
45 |
46 | operations = [
47 | migrations.RunPython(
48 | change_edition,
49 | reverse_code=migrations.RunPython.noop, # No reverse migration needed
50 | ),
51 | migrations.RunPython(
52 | remove_duplicate_votes_and_favourites,
53 | reverse_code=migrations.RunPython.noop, # No reverse migration needed
54 | ),
55 | ]
56 |
--------------------------------------------------------------------------------
/scripts/templates/upload.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% load bootstrap4 %}
4 | {% load botc_script_tags %}
5 |
6 | {% bootstrap_css %}
7 | {% bootstrap_javascript jquery='full' %}
8 |
9 | {% block content %}
10 |
11 |
51 |
52 | {% endblock %}
--------------------------------------------------------------------------------
/.github/workflows/main_botc-scripts.yml:
--------------------------------------------------------------------------------
1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
2 | # More GitHub Actions for Azure: https://github.com/Azure/actions
3 | # More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
4 |
5 | name: Build and deploy Python app to Azure Web App - botc-scripts
6 |
7 | on:
8 | push:
9 | branches:
10 | - main
11 | workflow_dispatch:
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - name: Install uv
21 | uses: astral-sh/setup-uv@v5
22 |
23 | - name: Set up Python version
24 | uses: actions/setup-python@v5
25 | with:
26 | python-version-file: "pyproject.toml"
27 |
28 | - name: Produce requirements
29 | run: |
30 | uv export --format requirements-txt --no-dev --output-file requirements.txt
31 |
32 | - name: Create and start virtual environment
33 | run: |
34 | python -m venv venv
35 | source venv/bin/activate
36 |
37 | - name: Install dependencies
38 | run: pip install -r requirements.txt
39 |
40 | # Optional: Add step to run tests here (PyTest, Django test suites, etc.)
41 |
42 | - name: Upload artifact for deployment jobs
43 | uses: actions/upload-artifact@v4
44 | with:
45 | name: python-app
46 | path: |
47 | .
48 | !venv/
49 |
50 | deploy:
51 | runs-on: ubuntu-latest
52 | needs: build
53 | environment:
54 | name: 'Production'
55 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
56 |
57 | steps:
58 | - name: Download artifact from build job
59 | uses: actions/download-artifact@v4
60 | with:
61 | name: python-app
62 | path: .
63 |
64 | - name: 'Deploy to Azure Web App'
65 | uses: azure/webapps-deploy@v2
66 | id: deploy-to-webapp
67 | with:
68 | app-name: 'botc-scripts'
69 | slot-name: 'Production'
70 | publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_4DC4AA964B30435A9B5181F6CEFEB54C }}
71 |
--------------------------------------------------------------------------------
/scripts/templates/worldcup/statistics.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block content %}
4 |
5 |
39 |
40 |
41 | {% include "worldcup/statstable.html" with left_title="Most Townsfolk Additions" right_title="Most Townsfolk Removals" addition=Townsfolkaddition deletion=Townsfolkdeletion table_style="table-striped-good" %}
42 | {% include "worldcup/statstable.html" with left_title="Most Outsider Additions" right_title="Most Outsider Removals" addition=Outsideraddition deletion=Outsiderdeletion table_style="table-striped-good" %}
43 | {% include "worldcup/statstable.html" with left_title="Most Minion Additions" right_title="Most Minion Removals" addition=Minionaddition deletion=Miniondeletion table_style="table-striped-evil" %}
44 | {% include "worldcup/statstable.html" with left_title="Most Demon Additions" right_title="Most Demon Removals" addition=Demonaddition deletion=Demondeletion table_style="table-striped-evil" %}
45 | {% include "worldcup/statstable.html" with left_title="Most Traveller Additions" right_title="Most Traveller Removals" addition=Travelleraddition deletion=Travellerdeletion table_style="table-striped-fabled" %}
46 | {% include "worldcup/statstable.html" with left_title="Most Fabled Additions" right_title="Most Fabled Removals" addition=Fabledaddition deletion=Fableddeletion table_style="table-striped-fabled" %}
47 |
48 |
49 | {% endblock %}
--------------------------------------------------------------------------------
/.github/workflows/staging_botc-scripts(staging).yml:
--------------------------------------------------------------------------------
1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
2 | # More GitHub Actions for Azure: https://github.com/Azure/actions
3 | # More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
4 |
5 | name: Build and deploy Python app to Azure Web App - botc-scripts
6 |
7 | on:
8 | push:
9 | branches:
10 | - staging
11 | workflow_dispatch:
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 |
20 | - name: Set up Python version
21 | uses: actions/setup-python@v4
22 | with:
23 | python-version: '3.8'
24 |
25 | - name: Install poetry
26 | run: |
27 | curl -sSL https://install.python-poetry.org | python3 -
28 |
29 | - name: Produce requirements
30 | run: |
31 | poetry export -f requirements.txt --output requirements.txt
32 |
33 | - name: Create and start virtual environment
34 | run: |
35 | python -m venv venv
36 | source venv/bin/activate
37 |
38 | - name: Install dependencies
39 | run: pip install -r requirements.txt
40 |
41 | # Optional: Add step to run tests here (PyTest, Django test suites, etc.)
42 |
43 | - name: Upload artifact for deployment jobs
44 | uses: actions/upload-artifact@v4
45 | with:
46 | name: python-app
47 | path: |
48 | .
49 | !venv/
50 |
51 | deploy:
52 | runs-on: ubuntu-latest
53 | needs: build
54 | environment:
55 | name: 'staging'
56 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
57 |
58 | steps:
59 | - name: Download artifact from build job
60 | uses: actions/download-artifact@v4
61 | with:
62 | name: python-app
63 | path: .
64 |
65 | - name: 'Deploy to Azure Web App'
66 | uses: azure/webapps-deploy@v2
67 | id: deploy-to-webapp
68 | with:
69 | app-name: 'botc-scripts'
70 | slot-name: 'staging'
71 | publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_DAFF80241C0D491BBA471A24FDF27DDC }}
72 |
--------------------------------------------------------------------------------
/scripts/templates/collection.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% load static %}
4 | {% load bootstrap4 %}
5 | {% load markdownify %}
6 |
7 | {% block content %}
8 |
9 |
10 |
11 |
{{collection.name}}
12 | {% if collection.description %}
13 | {{collection.description}}
14 | {% endif %}
15 |
16 | {% if not collection.notes %}
17 |
18 |
19 | Results: {{ table.rows|length }}
20 |
21 |
22 | {% endif %}
23 |
24 | {% if collection.notes %}
25 |
26 |
27 | {{ collection.notes|markdownify }}
28 |
29 |
30 |
31 | Results: {{ table.rows|length }}
32 |
33 |
34 |
35 | {% endif %}
36 |
37 |
38 |
39 |
40 |
41 |
47 |
48 | Are you sure you want to delete this collection?
49 |
50 |
57 |
58 |
59 |
60 |
61 |
62 | {% include "table.html" %}
63 |
64 | {% if request.user == collection.owner %}
65 |
66 |
69 |
70 | {% endif %}
71 |
72 | {% endblock %}
--------------------------------------------------------------------------------
/tests/test_format_updates.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from scripts.script_json import (
3 | strip_special_characters,
4 | strip_special_characters_from_json,
5 | revert_to_old_format,
6 | )
7 |
8 |
9 | @pytest.mark.parametrize(
10 | "input, expected_output",
11 | [
12 | ("snakecharmer", "snakecharmer"),
13 | ("pit-hag", "pithag"),
14 | ("fortune_teller", "fortuneteller"),
15 | ("Ojo", "ojo"),
16 | ],
17 | )
18 | def test_strip_special_characters(input, expected_output):
19 | output = strip_special_characters(input)
20 | assert output == expected_output
21 |
22 |
23 | def test_revert_to_old_format():
24 | json = [
25 | {
26 | "id": "_meta",
27 | "author": "AdmiralGT",
28 | "name": "Script Name",
29 | },
30 | "snakecharmer",
31 | "fortune_teller",
32 | "pit-hag",
33 | "Ojo",
34 | ]
35 | expected_json = [
36 | {
37 | "id": "_meta",
38 | "author": "AdmiralGT",
39 | "name": "Script Name",
40 | },
41 | {
42 | "id": "snakecharmer",
43 | },
44 | {
45 | "id": "fortune_teller",
46 | },
47 | {
48 | "id": "pit-hag",
49 | },
50 | {
51 | "id": "Ojo",
52 | },
53 | ]
54 | json = revert_to_old_format(json)
55 | assert json == expected_json
56 |
57 |
58 | def test_strip_special_characters_from_json():
59 | json = [
60 | {
61 | "id": "_meta",
62 | "author": "AdmiralGT",
63 | "name": "Script Name",
64 | },
65 | {
66 | "id": "snakecharmer",
67 | },
68 | {
69 | "id": "fortune_teller",
70 | },
71 | {
72 | "id": "pit-hag",
73 | },
74 | {
75 | "id": "Ojo",
76 | },
77 | ]
78 | expected_json = [
79 | {
80 | "id": "_meta",
81 | "author": "AdmiralGT",
82 | "name": "Script Name",
83 | },
84 | {
85 | "id": "snakecharmer",
86 | },
87 | {
88 | "id": "fortuneteller",
89 | },
90 | {
91 | "id": "pithag",
92 | },
93 | {
94 | "id": "ojo",
95 | },
96 | ]
97 |
98 | json = strip_special_characters_from_json(json)
99 | assert json == expected_json
100 |
--------------------------------------------------------------------------------
/scripts/migrations/0016_auto_20220709_1510.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.14 on 2022-07-09 15:10
2 |
3 | from django.db import migrations, models
4 |
5 | from scripts.views import count_character
6 | from scripts.models import CharacterType
7 |
8 |
9 | def update_existing_script_number_fields(apps, schema_editor):
10 | ScriptVersion = apps.get_model("scripts", "scriptversion")
11 | for script in ScriptVersion.objects.all():
12 | script.num_townsfolk = count_character(script.content, CharacterType.TOWNSFOLK)
13 | script.num_outsiders = count_character(script.content, CharacterType.OUTSIDER)
14 | script.num_minions = count_character(script.content, CharacterType.MINION)
15 | script.num_demons = count_character(script.content, CharacterType.DEMON)
16 | script.num_fabled = count_character(script.content, CharacterType.FABLED)
17 | script.num_travellers = count_character(script.content, CharacterType.TRAVELLER)
18 | script.save()
19 |
20 |
21 | class Migration(migrations.Migration):
22 |
23 | dependencies = [
24 | ("scripts", "0015_auto_20220616_2142"),
25 | ]
26 |
27 | operations = [
28 | migrations.AddField(
29 | model_name="scriptversion",
30 | name="num_demons",
31 | field=models.IntegerField(default=0),
32 | preserve_default=False,
33 | ),
34 | migrations.AddField(
35 | model_name="scriptversion",
36 | name="num_fabled",
37 | field=models.IntegerField(default=0),
38 | preserve_default=False,
39 | ),
40 | migrations.AddField(
41 | model_name="scriptversion",
42 | name="num_minions",
43 | field=models.IntegerField(default=0),
44 | preserve_default=False,
45 | ),
46 | migrations.AddField(
47 | model_name="scriptversion",
48 | name="num_outsiders",
49 | field=models.IntegerField(default=0),
50 | preserve_default=False,
51 | ),
52 | migrations.AddField(
53 | model_name="scriptversion",
54 | name="num_townsfolk",
55 | field=models.IntegerField(default=0),
56 | preserve_default=False,
57 | ),
58 | migrations.AddField(
59 | model_name="scriptversion",
60 | name="num_travellers",
61 | field=models.IntegerField(default=0),
62 | preserve_default=False,
63 | ),
64 | migrations.RunPython(
65 | update_existing_script_number_fields, reverse_code=migrations.RunPython.noop
66 | ),
67 | ]
68 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Project specific settings
2 | db.sqlite3
3 | botc/local.py
4 | staticfiles/
5 |
6 | # Byte-compiled / optimized / DLL files
7 | __pycache__/
8 | *.py[cod]
9 | *$py.class
10 |
11 | # C extensions
12 | *.so
13 |
14 | # Distribution / packaging
15 | .Python
16 | build/
17 | develop-eggs/
18 | dist/
19 | downloads/
20 | eggs/
21 | .eggs/
22 | lib/
23 | lib64/
24 | parts/
25 | sdist/
26 | var/
27 | wheels/
28 | pip-wheel-metadata/
29 | share/python-wheels/
30 | *.egg-info/
31 | .installed.cfg
32 | *.egg
33 | MANIFEST
34 |
35 | # PyInstaller
36 | # Usually these files are written by a python script from a template
37 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
38 | *.manifest
39 | *.spec
40 |
41 | # Installer logs
42 | pip-log.txt
43 | pip-delete-this-directory.txt
44 |
45 | # Unit test / coverage reports
46 | htmlcov/
47 | .tox/
48 | .nox/
49 | .coverage
50 | .coverage.*
51 | .cache
52 | nosetests.xml
53 | coverage.xml
54 | *.cover
55 | *.py,cover
56 | .hypothesis/
57 | .pytest_cache/
58 |
59 | # Translations
60 | *.mo
61 | *.pot
62 |
63 | # Django stuff:
64 | *.log
65 | local_settings.py
66 | db.sqlite3
67 | db.sqlite3-journal
68 |
69 | # Flask stuff:
70 | instance/
71 | .webassets-cache
72 |
73 | # Scrapy stuff:
74 | .scrapy
75 |
76 | # Sphinx documentation
77 | docs/_build/
78 |
79 | # PyBuilder
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | .python-version
91 |
92 | # pipenv
93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
96 | # install all needed dependencies.
97 | #Pipfile.lock
98 |
99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
100 | __pypackages__/
101 |
102 | # Celery stuff
103 | celerybeat-schedule
104 | celerybeat.pid
105 |
106 | # SageMath parsed files
107 | *.sage.py
108 |
109 | # Environments
110 | .env
111 | .venv
112 | env/
113 | venv/
114 | ENV/
115 | env.bak/
116 | venv.bak/
117 |
118 | # Spyder project settings
119 | .spyderproject
120 | .spyproject
121 |
122 | # Rope project settings
123 | .ropeproject
124 |
125 | # mkdocs documentation
126 | /site
127 |
128 | # mypy
129 | .mypy_cache/
130 | .dmypy.json
131 | dmypy.json
132 |
133 | # Pyre type checker
134 | .pyre/
135 |
136 | # VSCode
137 | .vscode/
138 |
139 | # ruff
140 | .ruff_cache/
141 |
142 | # poetry
143 | .poetry
144 |
--------------------------------------------------------------------------------
/scripts/management/commands/fix_latest_flags.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from scripts.models import Script
3 |
4 |
5 | class Command(BaseCommand):
6 | help = "Fix the 'latest' flag for script versions - ensures the highest version has latest=True"
7 |
8 | def handle(self, *args, **options):
9 | scripts = Script.objects.all()
10 | total = scripts.count()
11 |
12 | self.stdout.write(f"Processing {total} scripts...")
13 |
14 | updated_count = 0
15 | scripts_with_issues = []
16 |
17 | for i, script in enumerate(scripts, 1):
18 | latest_version = script.latest_version()
19 |
20 | if not latest_version:
21 | # No versions at all for this script
22 | self.stdout.write(
23 | self.style.WARNING(f" [{i}/{total}] Script '{script.name}' (ID: {script.pk}) has no versions")
24 | )
25 | continue
26 |
27 | # Check if the latest version has the latest flag set
28 | if not latest_version.latest:
29 | updated_count += 1
30 | scripts_with_issues.append({"script": script, "version": latest_version})
31 |
32 | self.stdout.write(
33 | f" [{i}/{total}] '{script.name}' v{latest_version.version} "
34 | f"(ID: {latest_version.pk}) - latest flag is False, should be True"
35 | )
36 |
37 | latest_version.latest = True
38 | latest_version.save(update_fields=["latest"])
39 |
40 | # Also check if there are other versions incorrectly marked as latest
41 | other_versions = script.versions.exclude(pk=latest_version.pk).filter(latest=True)
42 | if other_versions.exists():
43 | for other_version in other_versions:
44 | updated_count += 1
45 |
46 | self.stdout.write(
47 | f" [{i}/{total}] '{script.name}' v{other_version.version} "
48 | f"(ID: {other_version.pk}) - latest flag is True, should be False"
49 | )
50 |
51 | other_version.latest = False
52 | other_version.save(update_fields=["latest"])
53 |
54 | if i % 100 == 0:
55 | self.stdout.write(f"Progress: {i}/{total} ({updated_count} updates)")
56 |
57 | self.stdout.write("\n" + "=" * 60)
58 | self.stdout.write(f"Total scripts processed: {total}")
59 | self.stdout.write(f"Script versions updated: {updated_count}")
60 |
61 | self.stdout.write(self.style.SUCCESS(f"\nSuccessfully fixed {updated_count} script version(s)"))
62 |
--------------------------------------------------------------------------------
/scripts/api_views.py:
--------------------------------------------------------------------------------
1 | from rest_framework.views import APIView
2 | from rest_framework.response import Response
3 | from scripts import models
4 | from collections import Counter
5 | from drf_spectacular.utils import extend_schema
6 |
7 |
8 | @extend_schema(
9 | responses={
10 | 200: {
11 | "type": "object",
12 | "additionalProperties": {"type": "integer"},
13 | "description": "Character statistics with total count and individual character counts",
14 | }
15 | },
16 | summary="Get character statistics",
17 | description="Returns statistics for all characters including total count",
18 | )
19 | class StatisticsAPI(APIView):
20 | permission_classes = []
21 |
22 | def get(self, request, format=None):
23 | counter = Counter()
24 | if "all" in request.query_params:
25 | queryset = models.ScriptVersion.objects.all()
26 | else:
27 | queryset = models.ScriptVersion.objects.filter(latest=True)
28 |
29 | for param in request.query_params.lists():
30 | if param[0] == "character":
31 | for character in param[1]:
32 | try:
33 | character = models.ClocktowerCharacter.objects.get(character_id=character)
34 | queryset = queryset.filter(content__contains=[{"id": character.character_id}])
35 | except models.ClocktowerCharacter.DoesNotExist:
36 | continue
37 | elif param[0] == "character_or":
38 | orig_queryset = queryset.all()
39 | queryset = models.ScriptVersion.objects.none()
40 | for character in param[1]:
41 | try:
42 | character = models.ClocktowerCharacter.objects.get(character_id=character)
43 | queryset = queryset | orig_queryset.filter(content__contains=[{"id": character.character_id}])
44 | except models.ClocktowerCharacter.DoesNotExist:
45 | continue
46 | elif param[0] == "exclude":
47 | for character in param[1]:
48 | try:
49 | character = models.ClocktowerCharacter.objects.get(character_id=character)
50 | queryset = queryset.exclude(content__contains=[{"id": character.character_id}])
51 | except models.ClocktowerCharacter.DoesNotExist:
52 | continue
53 |
54 | for character in models.ClocktowerCharacter.objects.all():
55 | counter[character.character_id] = queryset.filter(
56 | content__contains=[{"id": character.character_id}]
57 | ).count()
58 | data = {}
59 | if "total" in request.query_params:
60 | data["total"] = queryset.count()
61 | for character in counter.most_common():
62 | data[character[0]] = character[1]
63 | return Response(data)
64 |
--------------------------------------------------------------------------------
/tests/test_additions.py:
--------------------------------------------------------------------------------
1 | import json as js
2 | import os
3 | import pytest
4 | from scripts.script_json import get_json_additions
5 |
6 | current_dir = os.path.dirname(os.path.realpath(__file__))
7 |
8 |
9 | @pytest.mark.parametrize(
10 | "orig, new",
11 | [
12 | ("input/trouble_brewing.json", "input/strings_pulling.json"),
13 | ("input/trouble_brewing.json", "input/strings_pulling_with_meta.json"),
14 | ("input/trouble_brewing_with_meta.json", "input/strings_pulling.json"),
15 | (
16 | "input/trouble_brewing_with_meta.json",
17 | "input/strings_pulling_with_meta.json",
18 | ),
19 | ],
20 | )
21 | def test_one_addition(orig, new):
22 | with open(os.path.join(current_dir, orig), "r") as f:
23 | v1 = js.load(f)
24 | with open(os.path.join(current_dir, new), "r") as f:
25 | v2 = js.load(f)
26 | addition = get_json_additions(v1.copy(), v2.copy())
27 | assert addition == [{"id": "marionette"}]
28 | reverse = get_json_additions(v2, v1)
29 | assert reverse == []
30 |
31 |
32 | def test_two_additions():
33 | with open(os.path.join(current_dir, "input/trouble_brewing.json"), "r") as f:
34 | v1 = js.load(f)
35 | with open(os.path.join(current_dir, "input/half_of_the_108.json"), "r") as f:
36 | v2 = js.load(f)
37 | addition = get_json_additions(v1.copy(), v2.copy())
38 | assert addition == [{"id": "legion"}, {"id": "vortox"}]
39 | reverse = get_json_additions(v2, v1)
40 | assert reverse == []
41 |
42 |
43 | def test_additions_and_removals():
44 | with open(os.path.join(current_dir, "input/trouble_brewing.json"), "r") as f:
45 | v1 = js.load(f)
46 | with open(os.path.join(current_dir, "input/pies_baking.json"), "r") as f:
47 | v2 = js.load(f)
48 | addition = get_json_additions(v1.copy(), v2.copy())
49 | assert addition == [{"id": "noble"}, {"id": "cannibal"}, {"id": "marionette"}]
50 | reverse = get_json_additions(v2, v1)
51 | assert reverse == [{"id": "investigator"}, {"id": "undertaker"}]
52 |
53 |
54 | def test_homebrew():
55 | with open(os.path.join(current_dir, "input/trouble_brewing.json"), "r") as f:
56 | v1 = js.load(f)
57 | with open(os.path.join(current_dir, "input/hybrid1.json"), "r") as f:
58 | v2 = js.load(f)
59 | addition = get_json_additions(v1.copy(), v2.copy())
60 | assert addition == [
61 | {
62 | "id": "custom_imp",
63 | "name": "Imp",
64 | "image": [
65 | "https://example.com/assets/imp_g.webp",
66 | "https://example.com/assets/imp_e.webp",
67 | ],
68 | "team": "demon",
69 | "otherNight": 30,
70 | "otherNightReminder": "The Imp chooses a player.",
71 | "reminders": ["Dead"],
72 | "setup": False,
73 | "ability": "Each night*, choose a player: they die. If you kill yourself this way, a Minion becomes the Imp.",
74 | }
75 | ]
76 | reverse = get_json_additions(v2, v1)
77 | assert reverse == [{"id": "imp"}]
78 |
--------------------------------------------------------------------------------
/scripts/migrations/0015_auto_20220616_2142.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.13 on 2022-06-16 21:42
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('scripts', '0014_comment_parent'),
10 | ]
11 |
12 | operations = [
13 | migrations.CreateModel(
14 | name='Character',
15 | fields=[
16 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17 | ('character_id', models.CharField(max_length=30)),
18 | ('character_name', models.CharField(max_length=30)),
19 | ('ability', models.TextField()),
20 | ('first_night_reminder', models.TextField(blank=True, null=True)),
21 | ('other_night_reminder', models.TextField(blank=True, null=True)),
22 | ('global_reminders', models.CharField(blank=True, max_length=30, null=True)),
23 | ('reminders', models.TextField(blank=True, null=True)),
24 | ('character_type', models.CharField(choices=[('Townsfolk', 'Townsfolk'), ('Outsider', 'Outsider'), ('Minion', 'Minion'), ('Demon', 'Demon'), ('Traveller', 'Traveller'), ('Fabled', 'Fabled')], max_length=30)),
25 | ('edition', models.IntegerField(choices=[(0, 'Base'), (1, '+ Kickstarter'), (2, '+ Unreleased')])),
26 | ('first_night_position', models.IntegerField(blank=True, null=True)),
27 | ('other_night_position', models.IntegerField(blank=True, null=True)),
28 | ('image_url', models.CharField(max_length=100)),
29 | ('modifies_setup', models.BooleanField(default=False)),
30 | ],
31 | options={
32 | 'permissions': [('update_characters', 'Can update character information')],
33 | },
34 | ),
35 | migrations.CreateModel(
36 | name='Translation',
37 | fields=[
38 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
39 | ('character_id', models.CharField(max_length=30)),
40 | ('character_name', models.CharField(max_length=30)),
41 | ('ability', models.TextField()),
42 | ('first_night_reminder', models.TextField(blank=True, null=True)),
43 | ('other_night_reminder', models.TextField(blank=True, null=True)),
44 | ('global_reminders', models.CharField(blank=True, max_length=30, null=True)),
45 | ('reminders', models.TextField(blank=True, null=True)),
46 | ('language', models.CharField(max_length=10)),
47 | ],
48 | options={
49 | 'permissions': [('update_translation', 'Can update a translation')],
50 | },
51 | ),
52 | migrations.AddIndex(
53 | model_name='translation',
54 | index=models.Index(fields=['language', 'character_id'], name='scripts_tra_languag_477f19_idx'),
55 | ),
56 | migrations.AddConstraint(
57 | model_name='translation',
58 | constraint=models.UniqueConstraint(fields=('language', 'character_id'), name='character_language'),
59 | ),
60 | ]
61 |
--------------------------------------------------------------------------------
/scripts/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2021-07-25 21:17
2 |
3 | import django.db.models.deletion
4 | import versionfield.fields
5 | from django.conf import settings
6 | from django.db import migrations, models
7 |
8 | import scripts.models
9 |
10 |
11 | class Migration(migrations.Migration):
12 |
13 | initial = True
14 |
15 | dependencies = [
16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17 | ]
18 |
19 | operations = [
20 | migrations.CreateModel(
21 | name='Script',
22 | fields=[
23 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24 | ('name', models.CharField(max_length=100)),
25 | ],
26 | ),
27 | migrations.CreateModel(
28 | name='ScriptTag',
29 | fields=[
30 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
31 | ('name', models.CharField(max_length=30)),
32 | ],
33 | ),
34 | migrations.CreateModel(
35 | name='ScriptVersion',
36 | fields=[
37 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
38 | ('latest', models.BooleanField(default=True)),
39 | ('script_type', models.CharField(choices=[('Teensyville', 'Teensyville'), ('Full', 'Full')], default='Full', max_length=20)),
40 | ('author', models.CharField(blank=True, max_length=100, null=True)),
41 | ('version', versionfield.fields.VersionField()),
42 | ('content', models.JSONField()),
43 | ('pdf', models.FileField(blank=True, null=True, upload_to=scripts.models.ScriptVersion.determine_script_location)),
44 | ('created', models.DateTimeField(auto_now=True)),
45 | ('script', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='scripts.script')),
46 | ('tags', models.ManyToManyField(to='scripts.ScriptTag')),
47 | ],
48 | ),
49 | migrations.CreateModel(
50 | name='Vote',
51 | fields=[
52 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
53 | ('created', models.DateTimeField(auto_now=True)),
54 | ('script', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='scripts.scriptversion')),
55 | ],
56 | ),
57 | migrations.CreateModel(
58 | name='Comment',
59 | fields=[
60 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
61 | ('comment', models.CharField(max_length=500)),
62 | ('created', models.DateTimeField(auto_now_add=True)),
63 | ('script', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='scripts.script')),
64 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
65 | ],
66 | ),
67 | ]
68 |
--------------------------------------------------------------------------------
/botc/production.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from .settings import * # noqa
4 |
5 | # Configure the domain name using the environment variable
6 | # that Azure automatically creates for us.
7 | ALLOWED_HOSTS = os.environ.get("DJANGO_HOST", None).split(" ")
8 |
9 | CSRF_TRUSTED_ORIGINS = [
10 | "https://botcscripts.com",
11 | "https://botc-scripts.azurewebsites.net",
12 | "https://www.botcscripts.com",
13 | ]
14 |
15 | SECRET_KEY = os.environ.get("SECRET_KEY")
16 |
17 | # Settings configurable via Environment Variables
18 | DEBUG = os.environ.get("DEBUG", False) == "True"
19 | UPLOAD_DISABLED = os.environ.get("UPLOAD_DISABLED", False) == "True"
20 | DISABLE_VALIDATORS = os.environ.get("DISABLE_VALIDATORS", False) == "True"
21 | BANNER = os.environ.get('BANNER', None)
22 |
23 | # DBHOST is only the server name, not the full URL
24 | hostname = os.environ.get("DBHOST")
25 |
26 | # Configure Postgres database; the full username is username@servername,
27 | # which we construct using the DBHOST value.
28 | DATABASES = {
29 | "default": {
30 | "ENGINE": "django.db.backends.postgresql",
31 | "NAME": os.environ.get("DBNAME"),
32 | "HOST": hostname + ".postgres.database.azure.com",
33 | "USER": os.environ.get("DBUSER"),
34 | "PASSWORD": os.environ.get("DBPASS"),
35 | }
36 | }
37 |
38 |
39 | AZURE_ACCOUNT_NAME = os.environ.get("AZURE_ACCOUNT_NAME", "botcscripts")
40 | AZURE_CUSTOM_DOMAIN = os.environ.get("AZURE_CDN_DOMAIN")
41 | AZURE_STORAGE_KEY = os.environ.get("AZURE_STORAGE_KEY", False)
42 | AZURE_SSL = True
43 |
44 | STORAGES = {
45 | "default": {
46 | "BACKEND": "botc.storage.AzureMediaStorage",
47 | },
48 | "staticfiles": {
49 | "BACKEND": "botc.storage.AzureStaticStorage",
50 | },
51 | }
52 |
53 | AZURE_MEDIA_CONTAINER = os.environ.get("AZURE_MEDIA_CONTAINER", "media")
54 | MEDIA_URL = f"https://{AZURE_CUSTOM_DOMAIN}/{AZURE_MEDIA_CONTAINER}/"
55 |
56 | AZURE_STATIC_CONTAINER = os.environ.get("AZURE_STATIC_CONTAINER", "static")
57 | STATIC_URL = f"https://{AZURE_CUSTOM_DOMAIN}/{AZURE_STATIC_CONTAINER}/"
58 |
59 | STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa
60 | BS_ICONS_CACHE = os.path.join(STATIC_ROOT, "icon_cache")
61 |
62 | ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
63 | SOCIALACCOUNT_PROVIDERS = {
64 | "google": {
65 | "SCOPE": [
66 | "email",
67 | ],
68 | "AUTH_PARAMS": {
69 | "access_type": "online",
70 | "redirect_uri": "https://www.botcscripts.com/google/login/callback/",
71 | },
72 | "APP": {
73 | "client_id": os.getenv("GOOGLE_OAUTH2_CLIENT_ID", None),
74 | "secret": os.getenv("GOOGLE_OAUTH2_SECRET", None),
75 | "key": "",
76 | },
77 | },
78 | "discord": {
79 | "SCOPE": [
80 | "email",
81 | "identify",
82 | ],
83 | "AUTH_PARAMS": {
84 | "redirect_uri": "https://www.botcscripts.com/discord/login/callback/",
85 | },
86 | "APP": {
87 | "client_id": os.getenv("DISCORD_OAUTH2_CLIENT_ID", None),
88 | "secret": os.getenv("DISCORD_OAUTH2_SECRET", None),
89 | "key": "",
90 | },
91 | },
92 | }
93 |
94 | USE_X_FORWARDED_HOST = True
95 | USE_X_FORWARDED_PORT = True
96 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
--------------------------------------------------------------------------------
/scripts/migrations/0029_homebrewcharacter_scriptversion_homebrewiness_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.1 on 2024-10-17 22:00
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("scripts", "0028_clocktowercharacter_delete_character_and_more"),
10 | ]
11 |
12 | operations = [
13 | migrations.CreateModel(
14 | name="HomebrewCharacter",
15 | fields=[
16 | (
17 | "id",
18 | models.BigAutoField(
19 | auto_created=True,
20 | primary_key=True,
21 | serialize=False,
22 | verbose_name="ID",
23 | ),
24 | ),
25 | ("character_id", models.CharField(max_length=30)),
26 | ("character_name", models.CharField(max_length=30)),
27 | ("ability", models.TextField()),
28 | ("first_night_reminder", models.TextField(blank=True, null=True)),
29 | ("other_night_reminder", models.TextField(blank=True, null=True)),
30 | (
31 | "global_reminders",
32 | models.CharField(blank=True, max_length=30, null=True),
33 | ),
34 | ("reminders", models.TextField(blank=True, null=True)),
35 | (
36 | "character_type",
37 | models.CharField(
38 | choices=[
39 | ("Townsfolk", "Townsfolk"),
40 | ("Outsider", "Outsider"),
41 | ("Minion", "Minion"),
42 | ("Demon", "Demon"),
43 | ("Traveller", "Traveller"),
44 | ("Fabled", "Fabled"),
45 | ("Unknown", "Unknown"),
46 | ],
47 | max_length=30,
48 | ),
49 | ),
50 | ("first_night_position", models.FloatField(blank=True, null=True)),
51 | ("other_night_position", models.FloatField(blank=True, null=True)),
52 | ("image_url", models.CharField(blank=True, max_length=100, null=True)),
53 | ("modifies_setup", models.BooleanField(default=False)),
54 | ],
55 | options={
56 | "permissions": [
57 | ("update_characters", "Can update character information")
58 | ],
59 | },
60 | ),
61 | migrations.AddField(
62 | model_name="scriptversion",
63 | name="homebrewiness",
64 | field=models.IntegerField(
65 | choices=[(0, "Clocktower"), (1, "Hybrid"), (2, "Homebrew")], default=0
66 | ),
67 | ),
68 | migrations.AlterField(
69 | model_name="clocktowercharacter",
70 | name="character_type",
71 | field=models.CharField(
72 | choices=[
73 | ("Townsfolk", "Townsfolk"),
74 | ("Outsider", "Outsider"),
75 | ("Minion", "Minion"),
76 | ("Demon", "Demon"),
77 | ("Traveller", "Traveller"),
78 | ("Fabled", "Fabled"),
79 | ("Unknown", "Unknown"),
80 | ],
81 | max_length=30,
82 | ),
83 | ),
84 | ]
85 |
--------------------------------------------------------------------------------
/tests/test_similarity.py:
--------------------------------------------------------------------------------
1 | import json as js
2 | import os
3 | import pytest
4 | from scripts.script_json import get_similarity
5 |
6 | current_dir = os.path.dirname(os.path.realpath(__file__))
7 |
8 |
9 | @pytest.mark.parametrize(
10 | "orig, new",
11 | [
12 | ("input/trouble_brewing.json", "input/trouble_brewing.json"),
13 | ("input/trouble_brewing_with_meta.json", "input/trouble_brewing.json"),
14 | ("input/trouble_brewing.json", "input/trouble_brewing_with_meta.json"),
15 | (
16 | "input/trouble_brewing_with_meta.json",
17 | "input/trouble_brewing_with_meta.json",
18 | ),
19 | ],
20 | )
21 | def test_same(orig, new):
22 | with open(os.path.join(current_dir, orig), "r") as f:
23 | v1 = js.load(f)
24 | with open(os.path.join(current_dir, new), "r") as f:
25 | v2 = js.load(f)
26 | similarity = get_similarity(v1, v2, True)
27 | assert similarity == 100
28 |
29 |
30 | @pytest.mark.parametrize(
31 | "orig, new",
32 | [
33 | ("input/trouble_brewing.json", "input/strings_pulling.json"),
34 | ("input/trouble_brewing_with_meta.json", "input/strings_pulling.json"),
35 | ("input/trouble_brewing.json", "input/strings_pulling_with_meta.json"),
36 | (
37 | "input/trouble_brewing_with_meta.json",
38 | "input/strings_pulling_with_meta.json",
39 | ),
40 | ],
41 | )
42 | def test_minimal_diff(orig, new):
43 | with open(os.path.join(current_dir, orig), "r") as f:
44 | v1 = js.load(f)
45 | with open(os.path.join(current_dir, new), "r") as f:
46 | v2 = js.load(f)
47 | similarity = get_similarity(v1, v2, True)
48 | # TB has 22 characters, Strings Pulling has 23 characters, 22 of which are on TB
49 | # 96 = 22/23
50 | assert similarity == 96
51 | similarity = get_similarity(v1, v2, False)
52 | assert similarity == 100
53 | reverse = get_similarity(v2, v1, True)
54 | assert reverse == 96
55 | reverse = get_similarity(v2, v1, False)
56 | assert reverse == 100
57 |
58 |
59 | def test_two_changes():
60 | with open(os.path.join(current_dir, "input/trouble_brewing.json"), "r") as f:
61 | v1 = js.load(f)
62 | with open(os.path.join(current_dir, "input/half_of_the_108.json"), "r") as f:
63 | v2 = js.load(f)
64 | similarity = get_similarity(v1, v2, True)
65 | assert similarity == 92
66 | similarity = get_similarity(v1, v2, False)
67 | assert similarity == 100
68 | reverse = get_similarity(v2, v1, True)
69 | assert reverse == 92
70 | reverse = get_similarity(v2, v1, False)
71 | assert reverse == 100
72 |
73 |
74 | def test_differences_both_ways():
75 | with open(os.path.join(current_dir, "input/trouble_brewing.json"), "r") as f:
76 | v1 = js.load(f)
77 | with open(os.path.join(current_dir, "input/pies_baking.json"), "r") as f:
78 | v2 = js.load(f)
79 | similarity = get_similarity(v1, v2, True)
80 | # 20 out of 23 characters are shared
81 | assert similarity == 87
82 | # 20 out of 22 characters are shared
83 | similarity = get_similarity(v1, v2, False)
84 | assert similarity == 91
85 | reverse = get_similarity(v2, v1, True)
86 | assert reverse == 87
87 | reverse = get_similarity(v2, v1, False)
88 | assert reverse == 91
89 |
90 |
91 | def test_large_vs_small_script():
92 | with open(os.path.join(current_dir, "input/trouble_brewing.json"), "r") as f:
93 | v1 = js.load(f)
94 | with open(os.path.join(current_dir, "input/just_the_drunk.json"), "r") as f:
95 | v2 = js.load(f)
96 | similarity = get_similarity(v1, v2, True)
97 | assert similarity == 5
98 | similarity = get_similarity(v1, v2, False)
99 | assert similarity == 8
100 | reverse = get_similarity(v2, v1, True)
101 | assert reverse == 5
102 | reverse = get_similarity(v2, v1, False)
103 | assert reverse == 8
104 |
--------------------------------------------------------------------------------
/scripts/templates/all_roles.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block content %}
4 | {% load botc_script_tags %}
5 | {% load bootstrap_icons %}
6 |
7 |
16 |
17 |
18 |
19 |
20 | All Roles/Fishbucket
21 |
22 |
23 |
24 |
25 |
26 |
27 |
34 |
35 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | Base 3:
59 | {% if edition == 0 %}
60 | {% bs_icon "check-circle-fill" color="green" extra_classes="pb-1" size="24px" %}
61 | {% else %}
62 | {% bs_icon "x-circle-fill" color="red" extra_classes="pb-1" size="24px" %}
63 | {% endif %}
64 |
65 |
66 | Kickstarter:
67 | {% if edition <= 1 %}
68 | {% bs_icon "check-circle-fill" color="green" extra_classes="pb-1" size="24px" %}
69 | {% else %}
70 | {% bs_icon "x-circle-fill" color="red" extra_classes="pb-1" size="24px" %}
71 | {% endif %}
72 |
73 |
74 | clocktower.online:
75 | {% if edition <= 2 %}
76 | {% bs_icon "check-circle-fill" color="green" extra_classes="pb-1" size="24px" %}
77 | {% else %}
78 | {% bs_icon "x-circle-fill" color="red" extra_classes="pb-1" size="24px" %}
79 | {% endif %}
80 |
81 |
82 |
83 |
84 | {% for role in content %}
85 | {% if role.id != "_meta" %}
86 | {% character_type_change content forloop.counter0 as newline %}
87 | {% if newline %}
88 |
89 | {% endif %}
90 | {% character_colourisation role.id as character_colour %}
91 |
{% convert_id_to_friendly_text role.id %}
92 | {% endif %}
93 | {% endfor %}
94 |
95 |
96 | {% endblock %}
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # Local Development
2 |
3 | ## Database
4 |
5 | The site uses PostgreSQL as the backend database. The mimimum PostgreSQL version required in v13. The PostgreSQL database must have the `postgresql-contrib` debian installed. It is recommended that you use [docker compose](./dev/docker-compose.yml) to spin up the [attached Dockerfile](./dev/Dockerfile) as your PostgreSQL database.
6 |
7 | In order to test the "Name" and "Author" search fields, you must apply the following migration to your database once it has been deployed.
8 |
9 | ```python
10 | from django.contrib.postgres.operations import TrigramExtension
11 | from django.db import migrations
12 |
13 | class Migration(migrations.Migration):
14 | operations = [
15 | TrigramExtension()
16 | ]
17 | ```
18 |
19 | ## Python environment
20 |
21 | This project uses [`uv`](https://docs.astral.sh/uv/) to manage python dependencies. Follow the [Installing uv](https://docs.astral.sh/uv/getting-started/installation/) guide to install uv.
22 |
23 | You can then install python environment using `uv sync`
24 |
25 | ### Creating the Config
26 |
27 | By default, `manage.py` looks for the file `botc/local.py`. You must create a `botc/local.py` with the following content:
28 |
29 | ```python
30 | from .settings import *
31 |
32 | DATABASES = {
33 | "default": {
34 | "ENGINE": "django.db.backends.postgresql",
35 | "NAME": "postgres",
36 | "HOST": "localhost",
37 | "USER": "postgres@db",
38 | "PASSWORD": "postgres",
39 | }
40 | }
41 |
42 | SECRET_KEY = ""
43 |
44 | STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
45 | BS_ICONS_CACHE = os.path.join(STATIC_ROOT, "icon_cache")
46 | DEBUG = True
47 |
48 | INTERNAL_IPS = [
49 | "127.0.0.1",
50 | "localhost",
51 | ]
52 |
53 | ```
54 |
55 | Be sure to choose your own random string for the `SECRET_KEY`. [You can generate one here](https://randomkeygen.com/).
56 |
57 | If you are not using the docker compose PostgreSQL database then you'll need to configure the `DATABASES` entry above with your own credentials.
58 |
59 | ## Running and Migration
60 |
61 | Per the usual Django development instructions, you need to apply the migrations to the database before running, create the static files and admin account. Run
62 |
63 | 1. `uv run python manage.py migrate`
64 | 1. `uv run python manage.py collectstatic`
65 | 1. `uv run python manage.py createsuperuser`
66 |
67 | You can also populate the database with all the characters (this is useful for testing some function), but you will need to upload your own scripts (some script data may come at a later date)
68 |
69 | `uv run python manage.py loaddata dev/characters`
70 |
71 | The site can be run using:
72 |
73 | `uv run python manage.py runserver`
74 |
75 | The site will be accessible at `http://localhost:8000`. You can access the Django admin panel, logging in with the credentials you used to create the super user at `http://localhost:8000/admin`
76 |
77 | ## Live Debugging
78 |
79 | If you use VSCode for as your IDE, you can use the following `settings.json` to launch the website in debug mode so that you can step through code
80 |
81 | ```json
82 | {
83 | // Use IntelliSense to learn about possible attributes.
84 | // Hover to view descriptions of existing attributes.
85 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
86 | "version": "0.2.0",
87 | "configurations": [
88 | {
89 | "justMyCode": false,
90 | "name": "Python: Django",
91 | "type": "python",
92 | "request": "launch",
93 | "program": "manage.py",
94 | "args": [
95 | "runserver"
96 | ],
97 | "django": true
98 | }
99 | ]
100 | }
101 | ```
102 |
103 | ## Linting
104 |
105 | This project uses [Ruff](https://docs.astral.sh/ruff/#ruff) for linting. The GitHub workflow includes a lint using ruff, but before submitting any code for review, please ensure that ruff passes by running `uv run ruff check`
--------------------------------------------------------------------------------
/scripts/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 | from scripts import models, constants, script_json
3 |
4 |
5 | # Serializers define the API representation.
6 | class ScriptSerializer(serializers.ModelSerializer):
7 | name = serializers.CharField(source="script.name")
8 | score = serializers.IntegerField(source="votes.count", read_only=True)
9 |
10 | class Meta:
11 | model = models.ScriptVersion
12 | fields = ["pk", "name", "version", "author", "content", "score"]
13 |
14 |
15 | class TranslationSerializer(serializers.ModelSerializer):
16 | class Meta:
17 | model = models.Translation
18 | fields = [
19 | "character_name",
20 | "ability",
21 | "first_night_reminder",
22 | "other_night_reminder",
23 | "global_reminders",
24 | "reminders",
25 | ]
26 |
27 |
28 | class ScriptUploadSerializer(serializers.ModelSerializer):
29 | name = serializers.CharField(max_length=constants.MAX_SCRIPT_NAME_LENGTH, required=True)
30 |
31 | class Meta:
32 | model = models.ScriptVersion
33 | fields = ["pk", "name", "content", "script_type", "version", "author", "pdf", "notes"]
34 |
35 | def is_createable(self, raise_exception=False) -> bool:
36 | """
37 | Check if the script can be created.
38 | """
39 | errors = []
40 | if models.ScriptVersion.objects.filter(
41 | script__name=self.validated_data.get("name"), version=self.validated_data.get("version")
42 | ).exists():
43 | errors.append("A script with this name and version already exists.")
44 | if not self.validated_data.get("name"):
45 | errors.append("Script name is required.")
46 | try:
47 | script = models.Script.objects.get(name=self.validated_data.get("name"))
48 | json = script_json.get_json_content(self.validated_data)
49 | if script.latest_version().content == json:
50 | errors.append("The content is identical to the latest version.")
51 | except models.Script.DoesNotExist:
52 | # It's OK if the script doesn't exist, it just means we're creating it.
53 | pass
54 |
55 | if raise_exception and errors:
56 | raise serializers.ValidationError(errors)
57 | return not errors
58 |
59 | def is_valid(self, create=True, raise_exception=False):
60 | """
61 | Override to ensure that the content is a valid JSON.
62 | """
63 | super().is_valid(raise_exception=raise_exception)
64 | errors = []
65 | if create:
66 | if self.initial_data.get("name", None) is None:
67 | errors.append("Script name is required.")
68 | if self.initial_data.get("content", None) is None:
69 | errors.append("Script content is required.")
70 | if self.initial_data.get("script_type", None) is None:
71 | errors.append("Script type is required.")
72 | if self.initial_data.get("content", None):
73 | content = script_json.get_json_content(self.initial_data)
74 | if not isinstance(content, list):
75 | errors.append("Content must be a list of script items.")
76 | if raise_exception and errors:
77 | raise serializers.ValidationError(errors)
78 | return not errors
79 |
80 | def is_expected_script(self, instance, raise_exception=False) -> bool:
81 | """
82 | Check if the name are version are present, that they match the expected script we're trying to update.
83 | """
84 | errors = []
85 | if self.validated_data.get("name") and self.validated_data.get("name") != instance.script.name:
86 | errors.append("You cannot change the name of an existing script.")
87 | if self.validated_data.get("version") and self.validated_data.get("version") != instance.version:
88 | errors.append("You cannot change the version of an existing script.")
89 | if raise_exception and errors:
90 | raise serializers.ValidationError(errors)
91 | return not errors
92 |
--------------------------------------------------------------------------------
/scripts/templates/navbar.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 |
3 |
85 |
--------------------------------------------------------------------------------
/scripts/migrations/0038_alter_scripttag_options_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.4 on 2025-07-15 21:13
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('scripts', '0037_remove_favourite_script_remove_vote_script'),
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterModelOptions(
16 | name='scripttag',
17 | options={'ordering': ['order']},
18 | ),
19 | migrations.AlterUniqueTogether(
20 | name='favourite',
21 | unique_together={('parent', 'user')},
22 | ),
23 | migrations.AlterUniqueTogether(
24 | name='vote',
25 | unique_together={('parent', 'user')},
26 | ),
27 | migrations.AddIndex(
28 | model_name='clocktowercharacter',
29 | index=models.Index(fields=['character_type'], name='cchar_character_type_idx'),
30 | ),
31 | migrations.AddIndex(
32 | model_name='clocktowercharacter',
33 | index=models.Index(fields=['character_id'], name='cchar_character_id_idx'),
34 | ),
35 | migrations.AddIndex(
36 | model_name='clocktowercharacter',
37 | index=models.Index(fields=['edition'], name='cchar_edition_idx'),
38 | ),
39 | migrations.AddIndex(
40 | model_name='favourite',
41 | index=models.Index(fields=['parent'], name='favourite_parent_idx'),
42 | ),
43 | migrations.AddIndex(
44 | model_name='favourite',
45 | index=models.Index(fields=['user'], name='favourite_user_idx'),
46 | ),
47 | migrations.AddIndex(
48 | model_name='homebrewcharacter',
49 | index=models.Index(fields=['character_type'], name='hchar_character_type_idx'),
50 | ),
51 | migrations.AddIndex(
52 | model_name='homebrewcharacter',
53 | index=models.Index(fields=['character_id'], name='hchar_character_id_idx'),
54 | ),
55 | migrations.AddIndex(
56 | model_name='script',
57 | index=models.Index(fields=['name'], name='script_name_idx'),
58 | ),
59 | migrations.AddIndex(
60 | model_name='script',
61 | index=models.Index(fields=['owner'], name='script_owner_idx'),
62 | ),
63 | migrations.AddIndex(
64 | model_name='scripttag',
65 | index=models.Index(fields=['name'], name='scripttag_name_idx'),
66 | ),
67 | migrations.AddIndex(
68 | model_name='scripttag',
69 | index=models.Index(fields=['public'], name='scripttag_public_idx'),
70 | ),
71 | migrations.AddIndex(
72 | model_name='scripttag',
73 | index=models.Index(fields=['inheritable'], name='scripttag_inheritable_idx'),
74 | ),
75 | migrations.AddIndex(
76 | model_name='scriptversion',
77 | index=models.Index(fields=['script'], name='sv_script_idx'),
78 | ),
79 | migrations.AddIndex(
80 | model_name='scriptversion',
81 | index=models.Index(fields=['latest'], name='sv_latest_idx'),
82 | ),
83 | migrations.AddIndex(
84 | model_name='scriptversion',
85 | index=models.Index(fields=['homebrewiness'], name='sv_homebrewiness_idx'),
86 | ),
87 | migrations.AddIndex(
88 | model_name='scriptversion',
89 | index=models.Index(fields=['edition'], name='sv_edition_idx'),
90 | ),
91 | migrations.AddIndex(
92 | model_name='scriptversion',
93 | index=models.Index(fields=['num_demons'], name='sv_num_demons_idx'),
94 | ),
95 | migrations.AddIndex(
96 | model_name='scriptversion',
97 | index=models.Index(fields=['script', 'version'], name='sv_script_and_version_idx'),
98 | ),
99 | migrations.AddIndex(
100 | model_name='scriptversion',
101 | index=models.Index(fields=['latest', 'homebrewiness'], name='sv_latest_and_homebrew_idx'),
102 | ),
103 | migrations.AddIndex(
104 | model_name='vote',
105 | index=models.Index(fields=['parent'], name='vote_parent_idx'),
106 | ),
107 | migrations.AddIndex(
108 | model_name='vote',
109 | index=models.Index(fields=['user'], name='vote_user_idx'),
110 | ),
111 | ]
112 |
--------------------------------------------------------------------------------
/scripts/worldcup.py:
--------------------------------------------------------------------------------
1 | from django.views import generic
2 | from scripts import models, script_json
3 | from typing import Dict, Any
4 | from collections import Counter
5 |
6 |
7 | class WorldCupView(generic.TemplateView):
8 | template_name = "worldcup/fixtures.html"
9 |
10 | def get_world_cup_script(self, script: models.Script):
11 | for version in script.versions.all():
12 | if models.ScriptTag.objects.get(pk=3) in version.tags.all():
13 | return version
14 |
15 | def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
16 | context = super().get_context_data(**kwargs)
17 |
18 | context["round1"] = models.WorldCup.objects.filter(round=1).order_by("pk")
19 | context["round2"] = models.WorldCup.objects.filter(round=2).order_by("pk")
20 | context["round3"] = models.WorldCup.objects.filter(round=3).order_by("pk")
21 | context["round4"] = models.WorldCup.objects.filter(round=4).order_by("pk")
22 | context["round5"] = models.WorldCup.objects.filter(round=5).order_by("pk")
23 | context["round6"] = models.WorldCup.objects.filter(round=6).order_by("pk")
24 | context["round7"] = models.WorldCup.objects.filter(round=7).order_by("pk")
25 |
26 | return context
27 |
28 |
29 | class WorldCupStatisticsView(generic.TemplateView):
30 | template_name = "worldcup/statistics.html"
31 |
32 | def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
33 | context = super().get_context_data(**kwargs)
34 | characters_to_display = 5
35 |
36 | queryset = models.ScriptVersion.objects.filter(tags=3)
37 | queryset = queryset.filter(latest=True)
38 |
39 | if "num" in self.request.GET:
40 | try:
41 | int(self.request.GET.get("num"))
42 | if int(self.request.GET.get("num")):
43 | characters_to_display = int(self.request.GET.get("num"))
44 | if characters_to_display < 1:
45 | characters_to_display = 5
46 | except ValueError:
47 | pass
48 |
49 | context["total"] = queryset.count()
50 |
51 | character_count = {}
52 | character_count["additions"] = {}
53 | character_count["deletions"] = {}
54 | for type in models.CharacterType:
55 | character_count["additions"][type.value] = Counter()
56 | character_count["deletions"][type.value] = Counter()
57 | for character in models.ClocktowerCharacter.objects.all():
58 | if character.character_type != type:
59 | continue
60 | character_count["additions"][character.character_type][character] = 0
61 | character_count["deletions"][character.character_type][character] = 0
62 |
63 | for script_version in queryset:
64 | versions = script_version.script.versions.order_by("version")
65 | previous_version = None
66 | for version in versions:
67 | if previous_version:
68 | additions = script_json.get_json_additions(version.content.copy(), previous_version.content.copy())
69 | for addition in additions:
70 | if addition.get("id", "_meta") == "_meta":
71 | pass
72 | try:
73 | character = models.ClocktowerCharacter.objects.get(character_id=addition.get("id"))
74 | except models.ClocktowerCharacter.DoesNotExist:
75 | continue
76 |
77 | character_count["additions"][character.character_type][character] = (
78 | character_count["additions"][character.character_type][character] + 1
79 | )
80 | deletions = script_json.get_json_additions(previous_version.content.copy(), version.content.copy())
81 | for deletion in deletions:
82 | if deletion.get("id", "_meta") == "_meta":
83 | pass
84 | try:
85 | character = models.ClocktowerCharacter.objects.get(character_id=deletion.get("id"))
86 | except models.ClocktowerCharacter.DoesNotExist:
87 | continue
88 |
89 | character_count["deletions"][character.character_type][character] = (
90 | character_count["deletions"][character.character_type][character] + 1
91 | )
92 | previous_version = version
93 |
94 | for type in models.CharacterType:
95 | context[type.value + "addition"] = character_count["additions"][type.value].most_common(
96 | characters_to_display
97 | )
98 | context[type.value + "deletion"] = character_count["deletions"][type.value].most_common(
99 | characters_to_display
100 | )
101 |
102 | return context
103 |
--------------------------------------------------------------------------------
/scripts/script_json.py:
--------------------------------------------------------------------------------
1 | import gzip
2 | import json as js
3 | import base64 as b64
4 | from scripts import constants
5 | from typing import List
6 | from django.core.files.base import File
7 | from urllib.parse import quote
8 |
9 |
10 | def get_author_from_json(json):
11 | return get_metadata_field_from_json(json, "author")
12 |
13 |
14 | def get_name_from_json(json):
15 | return get_metadata_field_from_json(json, "name")
16 |
17 |
18 | def get_metadata_field_from_json(json, field):
19 | """
20 | Returns a chosen field from the _meta JSON data.
21 | """
22 | for item in json:
23 | if item.get("id", "") == "_meta":
24 | return item.get(field, None)
25 | return None
26 |
27 |
28 | def revert_to_old_format(json):
29 | old_format_json = []
30 |
31 | for item in json:
32 | if isinstance(item, str):
33 | old_format_json.append({"id": item})
34 | else:
35 | old_format_json.append(item)
36 |
37 | return old_format_json
38 |
39 |
40 | class JSONError(Exception):
41 | pass
42 |
43 |
44 | def strip_special_characters(character_id):
45 | return character_id.replace("_", "").replace("-", "").lower()
46 |
47 |
48 | def strip_special_characters_from_json(json):
49 | new_json = []
50 | for item in json:
51 | if not isinstance(item, dict):
52 | raise JSONError(f"Unexpected script element: {item}")
53 |
54 | character = item.get("id", "")
55 | if character == "_meta":
56 | new_json.append(item)
57 | continue
58 | item["id"] = strip_special_characters(character)
59 | new_json.append(item)
60 |
61 | return new_json
62 |
63 |
64 | def get_json_content(data):
65 | json_content = data.get("content", None)
66 | if not json_content:
67 | raise JSONError("Could not read file type")
68 | if isinstance(json_content, File):
69 | try:
70 | json = js.loads(json_content.read().decode("utf-8"))
71 | except js.JSONDecodeError as e:
72 | raise JSONError(f"Invalid JSON content: {e}")
73 | json_content.seek(0)
74 | elif isinstance(json_content, (str, bytes, bytearray)):
75 | try:
76 | json = js.loads(json_content)
77 | except js.JSONDecodeError as e:
78 | raise JSONError(f"Invalid JSON content: {e}")
79 | else:
80 | json = json_content
81 | json = revert_to_old_format(json)
82 | json = strip_special_characters_from_json(json)
83 | return json
84 |
85 |
86 | # Determine the characters that are in the new JSON but not in the old JSON
87 | # This
88 | def get_json_additions(old_json, new_json):
89 | for old_id in old_json:
90 | if old_id["id"] == "_meta":
91 | continue
92 | for new_id in new_json:
93 | if new_id["id"] == "_meta":
94 | continue
95 |
96 | # Check if the IDs are unchanged.
97 | # This is imperfect because this will detect a change from an Official
98 | # to a Homebrew character of the same name, but we only have the JSON to do this check
99 | # and official characters have limited information in the JSON.
100 | if old_id["id"] == new_id["id"]:
101 | new_json.remove(new_id)
102 | continue
103 |
104 | for new_id in new_json:
105 | if new_id["id"] == "_meta":
106 | new_json.remove(new_id)
107 | break
108 |
109 | return new_json
110 |
111 |
112 | # Determine changes to character abilities where the character ID is unchanged
113 | def get_json_changes(old_json, new_json):
114 | changed_json = []
115 | for old_id in old_json:
116 | if old_id["id"] == "_meta":
117 | continue
118 | for new_id in new_json:
119 | if new_id["id"] == "_meta":
120 | continue
121 |
122 | if old_id["id"] == new_id["id"]:
123 | if old_id.get("ability", "UNKNOWN_ABILITY") != new_id.get("ability", "UNKNOWN_ABILITY"):
124 | changed_json.append({"id": new_id["id"]})
125 | continue
126 |
127 | return changed_json
128 |
129 |
130 | def get_similarity(json1: List, json2: List, same_type: bool) -> int:
131 | similarity = 0
132 | json1_metadata_count = 0
133 | json2_metadata_count = 0
134 | for i, id in enumerate(json1):
135 | if id.get("id", "") == "_meta":
136 | json1_metadata_count += 1
137 | continue
138 | for id2 in json2:
139 | if i == 0 and id2.get("id", "") == "_meta":
140 | json2_metadata_count += 1
141 | continue
142 | if id.get("id", "id1") == id2.get("id", "id2"):
143 | similarity += 1
144 | break
145 |
146 | json1_len = len(json1) - json1_metadata_count
147 | json2_len = len(json2) - json2_metadata_count
148 | similarity_max = max(json1_len, json2_len)
149 | similarity_min = max(min(json1_len, json2_len), constants.STANDARD_TEENSYVILLE_CHARACTER_COUNT)
150 |
151 | similarity_comp = similarity_max if same_type else similarity_min
152 | if similarity_comp == 0:
153 | return 0
154 |
155 | return round((similarity / similarity_comp) * 100)
156 |
157 |
158 | def compress_json(json_data):
159 | json_string = js.dumps(json_data)
160 | compressed = gzip.compress(json_string.encode("utf-8"))
161 | base64_encoded = b64.b64encode(compressed).decode("utf-8")
162 | return quote(base64_encoded)
163 |
--------------------------------------------------------------------------------
/scripts/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import include, path, re_path
2 | from rest_framework import routers
3 | from allauth.account.views import login, logout
4 | from allauth.socialaccount import providers
5 | from importlib import import_module
6 | from scripts import api_views, views, viewsets, worldcup
7 | from django.views.generic.base import TemplateView
8 |
9 | # Routers provide an easy way of automatically determining the URL conf.
10 | router = routers.DefaultRouter()
11 | router.register(r"scripts", viewsets.ScriptViewSet)
12 |
13 | translation_detail = viewsets.TranslationViewSet.as_view({"get": "retrieve", "put": "update", "post": "create"})
14 | translate = viewsets.TranslateScriptViewSet.as_view({"get": "retrieve"})
15 |
16 |
17 | urlpatterns = [
18 | path("", views.ScriptsListView.as_view()),
19 | path("api/", include(router.urls)),
20 | path("api/statistics", api_views.StatisticsAPI.as_view()),
21 | path("api/translations///", translation_detail),
22 | path("api/translate//", translate),
23 | path("collections", views.CollectionListView.as_view()),
24 | path(
25 | "collection/",
26 | views.CollectionScriptListView.as_view(),
27 | name="collection",
28 | ),
29 | path(
30 | "collection/add",
31 | views.AddScriptToCollectionView.as_view(),
32 | name="add_to_collection",
33 | ),
34 | path("collection//edit", views.CollectionEditView.as_view()),
35 | path(
36 | "collection//delete",
37 | views.CollectionDeleteView.as_view(),
38 | name="delete_collection",
39 | ),
40 | path(
41 | "collection//remove/",
42 | views.RemoveScriptFromCollectionView.as_view(),
43 | name="remove_from_collection",
44 | ),
45 | path("collection/new", views.CollectionCreateView.as_view()),
46 | path(
47 | "comment//delete",
48 | views.CommentDeleteView.as_view(),
49 | name="delete_comment",
50 | ),
51 | path("comment//edit", views.CommentEditView.as_view(), name="edit_comment"),
52 | path("comment/new", views.CommentCreateView.as_view(), name="create_comment"),
53 | path("health-check", views.HealthCheckView.as_view()),
54 | path(
55 | "robots.txt",
56 | TemplateView.as_view(template_name="robots.txt", content_type="text/plain"),
57 | ),
58 | path("script/all_roles", views.AllRolesScriptView.as_view()),
59 | path(
60 | "script/all_roles/download",
61 | views.download_all_roles_json,
62 | name="download_all_roles_json",
63 | ),
64 | path("script//vote", views.vote_for_script, name="vote"),
65 | path(
66 | "script//favourite",
67 | views.favourite_script,
68 | name="favourite",
69 | ),
70 | path("script/", views.ScriptView.as_view(), name="script"),
71 | path(
72 | "script///similar",
73 | views.get_similar_scripts,
74 | name="similar",
75 | ),
76 | path("script//", views.ScriptView.as_view(), name="script"),
77 | path(
78 | "script///delete",
79 | views.ScriptDeleteView.as_view(),
80 | name="delete_script",
81 | ),
82 | path(
83 | "script///download",
84 | views.download_json,
85 | name="download_json",
86 | ),
87 | path(
88 | "script///download_unsupported",
89 | views.download_unsupported_json,
90 | name="download_unsupported",
91 | ),
92 | path("script///download/", views.download_json),
93 | path(
94 | "script///download_pdf",
95 | views.download_pdf,
96 | name="download_pdf",
97 | ),
98 | path("script/search", views.AdvancedSearchView.as_view(), name="advanced_search"),
99 | path("script/search/results", views.AdvancedSearchResultsView.as_view()),
100 | path("script/upload", views.ScriptUploadView.as_view(), name="upload"),
101 | path("statistics", views.StatisticsView.as_view()),
102 | path("statistics/", views.StatisticsView.as_view()),
103 | path("statistics/tags/", views.StatisticsView.as_view()),
104 | path("update", views.UpdateDatabaseView.as_view()),
105 | path("account/social/", include("allauth.socialaccount.urls")),
106 | path("account/delete/", views.UserDeleteView.as_view(), name="delete_user"),
107 | path(
108 | "account/favourites/",
109 | views.UserScriptsListView.as_view(script_view="favourite"),
110 | ),
111 | path("account/scripts/", views.UserScriptsListView.as_view(script_view="owned")),
112 | path("worldcup", worldcup.WorldCupView.as_view()),
113 | path("worldcup/statistics", worldcup.WorldCupStatisticsView.as_view()),
114 | re_path(r"^login/$", login, name="account_login"),
115 | re_path(r"^logout/$", logout, name="account_logout"),
116 | re_path(r"^signup/$", login, name="account_signup"),
117 | ]
118 |
119 | provider_urlpatterns = []
120 | provider_classes = providers.registry.get_class_list()
121 |
122 | for provider_class in provider_classes:
123 | try:
124 | prov_mod = import_module(provider_class.get_package() + ".urls")
125 | except ImportError:
126 | continue
127 | prov_urlpatterns = getattr(prov_mod, "urlpatterns", None)
128 | if prov_urlpatterns:
129 | provider_urlpatterns += prov_urlpatterns
130 | urlpatterns += provider_urlpatterns
131 |
--------------------------------------------------------------------------------
/scripts/tables.py:
--------------------------------------------------------------------------------
1 | import django_tables2 as tables
2 |
3 | from scripts.models import ScriptVersion, Collection
4 |
5 | table_class = {
6 | "td": {"class": "pl-2 pr-2 p-0 align-middle text-center"},
7 | "th": {"class": "align-middle text-center"},
8 | }
9 |
10 | # Ensure that the buttons only ever take up one line
11 | script_table_actions_class = {
12 | "td": {"class": "pl-2 pr-2 p-0 align-middle text-center", "style": "width:15%"},
13 | "th": {"class": "align-middle text-center", "style": "width:15%"},
14 | }
15 |
16 | script_table_class = {
17 | "td": {"class": "pl-1 p-0 pr-1 align-middle text-center", "style": "width:10%"},
18 | "th": {"class": "pl-1 p-0 pr-1 align-middle text-center", "style": "width:10%"},
19 | }
20 |
21 | excluded_clocktower_version_fields = (
22 | "id",
23 | "content",
24 | "script",
25 | "latest",
26 | "created",
27 | "notes",
28 | "num_townsfolk",
29 | "num_outsiders",
30 | "num_minions",
31 | "num_demons",
32 | "num_travellers",
33 | "num_fabled",
34 | "num_loric",
35 | "edition",
36 | "version",
37 | "pdf",
38 | "homebrewiness",
39 | )
40 |
41 |
42 | class ScriptTable(tables.Table):
43 | name = tables.Column(
44 | empty_values=(),
45 | order_by=("script.name", "-version"),
46 | linkify=(
47 | "script",
48 | {"pk": tables.A("script.pk"), "version": tables.A("version")},
49 | ),
50 | attrs={"td": {"class": "pl-2 pr-2 p-0 align-middle"}},
51 | )
52 |
53 | author = tables.Column(attrs=table_class)
54 |
55 | script_type = tables.Column(attrs=table_class, verbose_name="Type")
56 |
57 | score = tables.TemplateColumn(
58 | template_name="script_table/likes.html",
59 | verbose_name="Likes",
60 | order_by=("-score"),
61 | attrs={
62 | "td": {"class": "pl-2 pr-2 p-0 align-middle text-center"},
63 | "th": {"class": "align-middle text-center"},
64 | },
65 | )
66 |
67 | num_favs = tables.TemplateColumn(
68 | template_name="script_table/favourites.html",
69 | verbose_name="Favs",
70 | order_by=("-num_favs"),
71 | attrs={
72 | "td": {"class": "pl-2 pr-2 p-0 align-middle text-center"},
73 | "th": {"class": "align-middle text-center"},
74 | },
75 | )
76 |
77 | actions = tables.TemplateColumn(
78 | template_name="script_table/actions/default.html",
79 | orderable=False,
80 | verbose_name="",
81 | attrs=script_table_actions_class,
82 | )
83 |
84 | def render_name(self, value, record):
85 | return "{name} ({version})".format(name=record.script.name, version=record.version)
86 |
87 |
88 | class ClocktowerTable(ScriptTable):
89 | tags = tables.TemplateColumn(
90 | orderable=False,
91 | template_name="tags.html",
92 | attrs={
93 | "td": {"class": "pl-2 pr-2 p-0 align-middle text-center"},
94 | "th": {"class": "pl-2 pr-2 p-0 align-middle text-center"},
95 | },
96 | )
97 |
98 | class Meta:
99 | model = ScriptVersion
100 | exclude = excluded_clocktower_version_fields
101 | sequence = (
102 | "name",
103 | "author",
104 | "script_type",
105 | "score",
106 | "num_favs",
107 | "tags",
108 | "actions",
109 | )
110 | orderable = True
111 |
112 |
113 | class UserClocktowerTable(ClocktowerTable):
114 | actions = tables.TemplateColumn(
115 | template_name="script_table/actions/authenticated.html",
116 | orderable=False,
117 | verbose_name="",
118 | attrs=script_table_actions_class,
119 | )
120 |
121 | class Meta:
122 | model = ScriptVersion
123 | exclude = excluded_clocktower_version_fields
124 | sequence = (
125 | "name",
126 | "author",
127 | "script_type",
128 | "score",
129 | "num_favs",
130 | "tags",
131 | "actions",
132 | )
133 | orderable = True
134 |
135 |
136 | class CollectionClocktowerTable(UserClocktowerTable):
137 | actions = tables.TemplateColumn(
138 | template_name="script_table/actions/collection.html",
139 | orderable=False,
140 | verbose_name="",
141 | attrs=script_table_actions_class,
142 | )
143 |
144 | class Meta:
145 | model = ScriptVersion
146 | exclude = excluded_clocktower_version_fields
147 | sequence = (
148 | "name",
149 | "author",
150 | "script_type",
151 | "score",
152 | "num_favs",
153 | "tags",
154 | "actions",
155 | )
156 | orderable = True
157 |
158 |
159 | class CollectionTable(tables.Table):
160 | name = tables.Column(
161 | empty_values=(),
162 | linkify=(
163 | "collection",
164 | {"pk": tables.A("pk")},
165 | ),
166 | attrs={"td": {"class": "pl-1 p-0 pr-2 align-middle"}},
167 | )
168 | description = tables.Column(attrs=table_class)
169 | scripts_in_collection = tables.Column(
170 | attrs=script_table_class,
171 | verbose_name="Scripts",
172 | order_by="-scripts_in_collection",
173 | )
174 |
175 | class Meta:
176 | model = Collection
177 | exclude = ("id", "owner", "scripts", "notes")
178 | sequence = (
179 | "name",
180 | "description",
181 | "scripts_in_collection",
182 | )
183 | orderable = True
184 |
--------------------------------------------------------------------------------
/scripts/templatetags/botc_script_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from scripts import models, cache, script_json
3 | from babel.core import Locale, UnknownLocaleError
4 |
5 | register = template.Library()
6 |
7 |
8 | @register.simple_tag(takes_context=True)
9 | def user_voted(context, script_version):
10 | user = context["user"]
11 | if user.votes.filter(parent=script_version.script).exists():
12 | return "btn-danger"
13 | return "btn-success"
14 |
15 |
16 | @register.simple_tag(takes_context=True)
17 | def user_voted_icon(context, script_version):
18 | user = context["user"]
19 | if user.votes.filter(parent=script_version.script).exists():
20 | return "hand-thumbs-down-fill"
21 | return "hand-thumbs-up-fill"
22 |
23 |
24 | @register.simple_tag(takes_context=True)
25 | def user_favourite(context, script_version):
26 | user = context["user"]
27 | if user.favourites.filter(parent=script_version.script).exists():
28 | return "star-fill"
29 | return "star"
30 |
31 |
32 | @register.simple_tag()
33 | def script_has_tag(tag, initial):
34 | tags = initial.get("tags", None)
35 | if tags and tag in tags:
36 | return True
37 | return False
38 |
39 |
40 | @register.simple_tag()
41 | def script_in_collection(collection, script_version):
42 | if script_version in collection.scripts.all():
43 | return True
44 | return False
45 |
46 |
47 | @register.simple_tag()
48 | def script_not_in_user_collection(user, script_version):
49 | for collection in user.collections.all():
50 | if script_version not in collection.scripts.all():
51 | return True
52 | return False
53 |
54 |
55 | def get_colour_from_character_type(character_type):
56 | match character_type:
57 | case models.CharacterType.TOWNSFOLK:
58 | return "style=color:#0000ff"
59 | case models.CharacterType.OUTSIDER:
60 | return "style=color:#00ccff"
61 | case models.CharacterType.MINION:
62 | return "style=color:#ff8000"
63 | case models.CharacterType.DEMON:
64 | return "style=color:#ff0000"
65 | case models.CharacterType.TRAVELLER:
66 | return "style=color:#cc0099"
67 | case models.CharacterType.FABLED:
68 | return "style=color:#996600"
69 | case models.CharacterType.LORIC:
70 | return "style=color:#64882b"
71 | case _:
72 | return "style=color:#000000"
73 |
74 |
75 | @register.simple_tag()
76 | def character_colourisation(character_id):
77 | clocktower_characters = cache.get_clocktower_characters()
78 | character = clocktower_characters.get(character_id)
79 | if character:
80 | return get_colour_from_character_type(character.character_type)
81 |
82 | homebrew_characters = cache.get_homebrew_characters()
83 | character = homebrew_characters.get(character_id)
84 | if character:
85 | return get_colour_from_character_type(character.character_type)
86 | return "style=color:#000000"
87 |
88 |
89 | @register.simple_tag()
90 | def character_type_change(content, counter):
91 | if counter > 0:
92 | prev_character_id = content[counter - 1].get("id", None)
93 | curr_character_id = content[counter].get("id", None)
94 |
95 | clocktower_characters = cache.get_clocktower_characters()
96 |
97 | prev_character = clocktower_characters.get(prev_character_id)
98 | curr_character = clocktower_characters.get(curr_character_id)
99 |
100 | if not prev_character or not curr_character:
101 | homebrew_characters = cache.get_homebrew_characters()
102 | if not prev_character:
103 | prev_character = homebrew_characters.get(prev_character_id)
104 | if not curr_character:
105 | curr_character = homebrew_characters.get(curr_character_id)
106 |
107 | if not prev_character or not curr_character:
108 | return False
109 |
110 | if prev_character and curr_character:
111 | if prev_character.character_type != curr_character.character_type:
112 | return True
113 | return False
114 |
115 |
116 | @register.simple_tag()
117 | def convert_id_to_friendly_text(character_id):
118 | clocktower_characters = cache.get_clocktower_characters()
119 | text = clocktower_characters.get(character_id)
120 | if text:
121 | return text.character_name
122 |
123 | homebrew_characters = cache.get_homebrew_characters()
124 | text = homebrew_characters.get(character_id)
125 | if text:
126 | return text.character_name
127 |
128 | return character_id
129 |
130 |
131 | @register.filter
132 | def split(string):
133 | return string.split(" ")
134 |
135 |
136 | @register.simple_tag()
137 | def active_tab_status(tab: str, active_tab: str):
138 | if tab in ["notes-tab", "characters-tab"]:
139 | if active_tab:
140 | return ""
141 | return "show active"
142 | if tab == active_tab:
143 | return "show active"
144 | return ""
145 |
146 |
147 | @register.simple_tag()
148 | def active_aria_status(aria: str, active_tab: str):
149 | if aria in ["notes-tab", "characters-tab"]:
150 | if active_tab:
151 | return ""
152 | return "active"
153 | if aria == active_tab:
154 | return "active"
155 | return ""
156 |
157 |
158 | @register.simple_tag()
159 | def get_language_name(locale: str):
160 | try:
161 | return Locale.parse(locale).display_name
162 | except UnknownLocaleError:
163 | if locale == "ja_JA":
164 | return get_language_name("ja_JP")
165 | elif locale == "kw_KW":
166 | return get_language_name("ar_KW")
167 | elif locale == "vi_VI":
168 | return get_language_name("vi_VN")
169 | elif locale == "cl_CL":
170 | return get_language_name("es_CL")
171 |
172 | return locale
173 |
174 |
175 | @register.simple_tag()
176 | def get_character_percentage(count: int, total: int):
177 | percentage = count * 100 / total
178 | return f"{percentage:.2f}%"
179 |
180 |
181 | @register.simple_tag()
182 | def script_tool_url(script_version):
183 | return f"https://script.bloodontheclocktower.com?script={script_json.compress_json(script_version.content)}"
184 |
--------------------------------------------------------------------------------
/botc/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for botc project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.1.7.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.1/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/3.1/ref/settings/
11 | """
12 |
13 | import os
14 | from pathlib import Path
15 |
16 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
17 | BASE_DIR = Path(__file__).resolve().parent.parent
18 |
19 |
20 | # Application definition
21 | INSTALLED_APPS = [
22 | "django.contrib.admin",
23 | "django.contrib.auth",
24 | "django.contrib.contenttypes",
25 | "django.contrib.sessions",
26 | "django.contrib.messages",
27 | "django.contrib.sites",
28 | "django.contrib.staticfiles",
29 | "django.contrib.postgres",
30 | "scripts.apps.ScriptsConfig",
31 | "versionfield",
32 | "django_tables2",
33 | "django_filters",
34 | "bootstrap4",
35 | "django_bootstrap_icons",
36 | "rest_framework",
37 | "storages",
38 | "allauth",
39 | "allauth.account",
40 | "allauth.socialaccount",
41 | "allauth.socialaccount.providers.google",
42 | "allauth.socialaccount.providers.discord",
43 | "markdownify.apps.MarkdownifyConfig",
44 | "corsheaders",
45 | "drf_spectacular",
46 | ]
47 |
48 | MIDDLEWARE = [
49 | "corsheaders.middleware.CorsMiddleware",
50 | "django.middleware.common.CommonMiddleware",
51 | "django.middleware.security.SecurityMiddleware",
52 | "django.contrib.sessions.middleware.SessionMiddleware",
53 | "django.middleware.common.CommonMiddleware",
54 | "django.middleware.csrf.CsrfViewMiddleware",
55 | "django.contrib.auth.middleware.AuthenticationMiddleware",
56 | "django.contrib.messages.middleware.MessageMiddleware",
57 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
58 | "allauth.account.middleware.AccountMiddleware",
59 | ]
60 |
61 | ROOT_URLCONF = "botc.urls"
62 |
63 | TEMPLATES = [
64 | {
65 | "BACKEND": "django.template.backends.django.DjangoTemplates",
66 | "DIRS": [],
67 | "APP_DIRS": True,
68 | "OPTIONS": {
69 | "context_processors": [
70 | "django.template.context_processors.debug",
71 | "django.template.context_processors.request",
72 | "django.contrib.auth.context_processors.auth",
73 | "django.contrib.messages.context_processors.messages",
74 | "django.template.context_processors.request",
75 | "scripts.context_processors.custom_configuration",
76 | ],
77 | },
78 | }
79 | ]
80 |
81 | DJANGO_TABLES2_TEMPLATE = "django_tables2/bootstrap4.html"
82 |
83 | WSGI_APPLICATION = "botc.wsgi.application"
84 |
85 | AUTHENTICATION_BACKENDS = [
86 | "django.contrib.auth.backends.ModelBackend",
87 | "allauth.account.auth_backends.AuthenticationBackend",
88 | ]
89 |
90 | SITE_ID = 1
91 | LOGIN_URL = "/login"
92 | LOGIN_REDIRECT_URL = "/"
93 | LOGOUT_REDIRECT_URL = "/"
94 |
95 | # Password validation
96 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
97 |
98 | AUTH_PASSWORD_VALIDATORS = [
99 | {
100 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
101 | },
102 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
103 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
104 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
105 | ]
106 |
107 |
108 | # Internationalization
109 | # https://docs.djangoproject.com/en/3.1/topics/i18n/
110 |
111 | LANGUAGE_CODE = "en-us"
112 |
113 | TIME_ZONE = "UTC"
114 |
115 | USE_I18N = True
116 |
117 | USE_L10N = False
118 |
119 | USE_TZ = True
120 |
121 | DATETIME_FORMAT = "c"
122 |
123 | # Static files (CSS, JavaScript, Images)
124 | # https://docs.djangoproject.com/en/3.1/howto/static-files/
125 |
126 | REST_FRAMEWORK = {
127 | # Use Django's standard `django.contrib.auth` permissions,
128 | # or allow read-only access for unauthenticated users.
129 | "DEFAULT_PERMISSION_CLASSES": [
130 | "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly"
131 | ],
132 | "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
133 | "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
134 | "PAGE_SIZE": 10,
135 | "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
136 | }
137 |
138 | SPECTACULAR_SETTINGS = {
139 | "TITLE": "BotC Scripts API",
140 | "DESCRIPTION": "API for the www.botcscripts.com",
141 | "VERSION": "0.1.0",
142 | "SERVE_INCLUDE_SCHEMA": False,
143 | }
144 |
145 | # Session Settings
146 | SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
147 |
148 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
149 |
150 | MARKDOWNIFY = {
151 | "default": {
152 | "WHITELIST_TAGS": [
153 | 'a',
154 | 'abbr',
155 | 'acronym',
156 | 'b',
157 | "br",
158 | 'blockquote',
159 | 'em',
160 | "h1",
161 | "h2",
162 | "h3",
163 | 'i',
164 | "img",
165 | 'li',
166 | 'ol',
167 | 'p',
168 | 'strong',
169 | 'ul',
170 | ],
171 | "WHITELIST_ATTRS": [
172 | 'href',
173 | 'src',
174 | 'alt',
175 | ],
176 | "WHITELIST_STYLES": [
177 | 'color',
178 | 'font-weight',
179 | ],
180 | "WHITELIST_PROTOCOLS": [
181 | 'https',
182 | ]
183 | }
184 | }
185 |
186 | # django-allauth configuration
187 | LOGIN_METHODS = "email"
188 | ACCOUNT_EMAIL_REQUIRED = True
189 | ACCOUNT_USERNAME_REQUIRED = False
190 | SOCIALACCOUNT_AUTO_SIGNUP = True
191 | SOCIALACCOUNT_LOGIN_ON_GET = True
192 |
193 | # Allow CORS access to the API for GETs only.
194 | CORS_ALLOW_ALL_ORIGINS = os.getenv("CORS_ALLOW_ALL_ORIGINS", False) == "True"
195 | CORS_URLS_REGEX = r"^.*/api/.*$"
196 | CORS_ALLOW_METHODS = "GET"
197 |
198 | DISABLE_VALIDATORS = os.getenv("DISABLE_VALIDATORS", False) == "True"
199 |
200 | CACHES = {
201 | 'default': {
202 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
203 | 'LOCATION': 'local-cache',
204 | }
205 | }
--------------------------------------------------------------------------------
/scripts/filters.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import django_filters
4 | from django_filters import rest_framework as filters
5 | from django import forms
6 | from django.contrib.postgres.search import TrigramSimilarity
7 |
8 | from scripts import models, widgets, script_json
9 |
10 | edition_choices = (
11 | (models.Edition.BASE, models.Edition.BASE.label),
12 | (models.Edition.KICKSTARTER, models.Edition.KICKSTARTER.label),
13 | (models.Edition.CAROUSEL, models.Edition.CAROUSEL.label),
14 | (models.Edition.ALL, models.Edition.ALL.label),
15 | )
16 |
17 |
18 | def get_characters_by_type(type: models.CharacterType):
19 | return models.ClocktowerCharacter.objects.filter(character_type=type)
20 |
21 |
22 | def get_characters_not_in_edition(edition: models.Edition):
23 | return models.ClocktowerCharacter.objects.filter(edition__gt=edition)
24 |
25 |
26 | def annotate_queryset(queryset, field, value):
27 | return queryset.annotate(similarity=TrigramSimilarity(field, value))
28 |
29 |
30 | def include_characters(queryset, value):
31 | for character in re.split(",|;|:|/", value):
32 | character = script_json.strip_special_characters(character.strip())
33 | if character in ",;:/":
34 | continue
35 | queryset = queryset.filter(content__contains=[{"id": name_to_id(character)}])
36 | return queryset
37 |
38 |
39 | def exclude_characters(queryset, value):
40 | for character in re.split(",|;|:|/", value):
41 | character = script_json.strip_special_characters(character.strip())
42 | if character in ",;:/":
43 | continue
44 | queryset = queryset.exclude(content__contains=[{"id": name_to_id(character)}])
45 | return queryset
46 |
47 |
48 | def name_to_id(name: str):
49 | return name.replace(" ", "_").replace("'", "").lower()
50 |
51 |
52 | class BaseScriptVersionFilter(filters.FilterSet):
53 | all_scripts = django_filters.filters.BooleanFilter(
54 | method="display_all_scripts",
55 | widget=forms.CheckboxInput,
56 | label="Display All Versions",
57 | )
58 | include = django_filters.filters.CharFilter(method="include_characters", label="Includes characters")
59 | exclude = django_filters.filters.CharFilter(method="exclude_characters", label="Excludes characters")
60 | author = django_filters.filters.CharFilter(method="search_authors", label="Author")
61 | search = django_filters.filters.CharFilter(method="search_scripts", label="Search")
62 | mono_demon = django_filters.filters.BooleanFilter(
63 | method="filter_mono_demon_scripts",
64 | widget=forms.CheckboxInput,
65 | label="Mono-Demon Scripts Only",
66 | )
67 | include_hybrid = django_filters.filters.BooleanFilter(
68 | method="filter_hybrid_scripts",
69 | widget=forms.CheckboxInput,
70 | label="Include Hybrid",
71 | )
72 | include_homebrew = django_filters.filters.BooleanFilter(
73 | method="filter_homebrew_scripts",
74 | widget=forms.CheckboxInput,
75 | label="Include Homebrew",
76 | )
77 |
78 | def display_all_scripts(self, queryset, name, value):
79 | if not value:
80 | return queryset.filter(latest=(not value))
81 | return queryset
82 |
83 | def filter_mono_demon_scripts(self, queryset, name, value):
84 | if value:
85 | return queryset.filter(num_demons=1)
86 | return queryset
87 |
88 | def filter_hybrid_scripts(self, queryset, name, value):
89 | if not value:
90 | return queryset.exclude(homebrewiness=models.Homebrewiness.HYBRID)
91 | return queryset
92 |
93 | def filter_homebrew_scripts(self, queryset, name, value):
94 | if not value:
95 | return queryset.exclude(homebrewiness=models.Homebrewiness.HOMEBREW)
96 | return queryset
97 |
98 | def filter_my_scripts(self, queryset, name, value):
99 | if value:
100 | return queryset.filter(script__owner=self.request.user)
101 | return queryset
102 |
103 | def include_characters(self, queryset, name, value):
104 | return include_characters(queryset, value)
105 |
106 | def exclude_characters(self, queryset, name, value):
107 | return exclude_characters(queryset, value)
108 |
109 | def search_scripts(self, queryset, name, value):
110 | queryset = annotate_queryset(queryset, "script__name", value)
111 | try:
112 | if "ordering" in self.request.query_params.keys():
113 | return queryset.filter(similarity__gt=0.3)
114 | except AttributeError:
115 | pass
116 |
117 | return queryset.filter(similarity__gt=0).order_by("-similarity")
118 |
119 | def search_authors(self, queryset, name, value):
120 | queryset = annotate_queryset(queryset, "author", value)
121 | try:
122 | if "ordering" in self.request.query_params.keys():
123 | return queryset.filter(similarity__gt=0.3)
124 | except AttributeError:
125 | pass
126 |
127 | return queryset.filter(similarity__gt=0.3).order_by("-similarity")
128 |
129 |
130 | class ScriptVersionFilter(BaseScriptVersionFilter):
131 | tags = django_filters.filters.ModelMultipleChoiceFilter(
132 | queryset=models.ScriptTag.objects.all().order_by("order"),
133 | widget=widgets.BadgePillSelectMultiple,
134 | )
135 | edition = django_filters.filters.ChoiceFilter(
136 | label="Edition",
137 | method="filter_edition",
138 | choices=edition_choices,
139 | )
140 |
141 | def filter_edition(self, queryset, _, value):
142 | return queryset.filter(edition__lte=value)
143 |
144 | class Meta:
145 | model = models.ScriptVersion
146 | fields = [
147 | "search",
148 | "script_type",
149 | "include",
150 | "exclude",
151 | "edition",
152 | "author",
153 | "tags",
154 | "mono_demon",
155 | "all_scripts",
156 | "include_hybrid",
157 | "include_homebrew",
158 | ]
159 |
160 |
161 | class FavouriteScriptVersionFilter(ScriptVersionFilter):
162 | favourites = django_filters.filters.BooleanFilter(
163 | method="display_favourites", widget=forms.CheckboxInput, label="Favourites"
164 | )
165 | my_scripts = django_filters.filters.BooleanFilter(
166 | method="filter_my_scripts",
167 | widget=forms.CheckboxInput,
168 | label="My Scripts",
169 | )
170 |
171 | def display_favourites(self, queryset, _, value):
172 | if value:
173 | return queryset.filter(script__favourites__user=self.request.user)
174 | return queryset
175 |
176 | class Meta:
177 | model = models.ScriptVersion
178 | fields = [
179 | "search",
180 | "script_type",
181 | "include",
182 | "exclude",
183 | "edition",
184 | "author",
185 | "tags",
186 | "mono_demon",
187 | "favourites",
188 | "my_scripts",
189 | "all_scripts",
190 | "include_hybrid",
191 | "include_homebrew",
192 | ]
193 |
194 |
195 | class CollectionFilter(django_filters.FilterSet, django_filters.filters.QuerySetRequestMixin):
196 | is_owner = django_filters.filters.BooleanFilter(
197 | widget=forms.CheckboxInput, label="My Collections", method="is_owner_function"
198 | )
199 |
200 | class Meta:
201 | model = models.Collection
202 | fields = [
203 | "is_owner",
204 | ]
205 |
206 | def __init__(self, *args, **kwargs):
207 | super(django_filters.FilterSet, self).__init__(*args, **kwargs)
208 | if kwargs.get("data") and kwargs.get("data").get("is_owner") == "on":
209 | self.queryset = models.Collection.objects.filter(owner=self.request.user)
210 |
211 | def is_owner_function(self, queryset, name, value):
212 | """
213 | This function exists so that we can use a non-model field. The queryset was already
214 | altered in the __init__ function.
215 | """
216 | return queryset
217 |
218 |
219 | class StatisticsFilter(django_filters.FilterSet, django_filters.filters.QuerySetRequestMixin):
220 | is_owner = django_filters.filters.BooleanFilter(
221 | widget=forms.CheckboxInput, label="My Scripts only", method="is_owner_function"
222 | )
223 |
224 | class Meta:
225 | model = models.ScriptVersion
226 | fields = [
227 | "is_owner",
228 | ]
229 |
230 | def __init__(self, *args, **kwargs):
231 | super(django_filters.FilterSet, self).__init__(*args, **kwargs)
232 | if kwargs.get("data") and kwargs.get("data").get("is_owner") == "on":
233 | self.queryset = models.ScriptVersion.objects.filter(script__owner=self.request.user)
234 |
235 | def is_owner_function(self, queryset, name, value):
236 | """
237 | This function exists so that we can use a non-model field. The queryset was already
238 | altered in the __init__ function.
239 | """
240 | return queryset
241 |
--------------------------------------------------------------------------------