From ab8b798547e82ca79882ba28b1920077c803425f Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 5 Apr 2019 18:24:32 +0100 Subject: pysite -> pydis_site --- .coveragerc | 14 +- .dockerignore | 8 +- .flake8 | 2 +- azure-pipelines.yml | 4 +- docker-compose.yml | 6 +- docker/app/uwsgi.ini | 6 +- docker/pysite.dockerapp | 2 +- manage.py | 2 +- pydis_site/__init__.py | 0 pydis_site/apps/__init__.py | 0 pydis_site/apps/admin/__init__.py | 0 pydis_site/apps/admin/urls.py | 7 + pydis_site/apps/api/__init__.py | 0 pydis_site/apps/api/admin.py | 26 + pydis_site/apps/api/apps.py | 5 + pydis_site/apps/api/migrations/0001_initial.py | 21 + .../apps/api/migrations/0002_documentationlink.py | 21 + .../api/migrations/0003_offtopicchannelname.py | 20 + pydis_site/apps/api/migrations/0004_role.py | 23 + pydis_site/apps/api/migrations/0005_user.py | 38 + .../apps/api/migrations/0006_add_help_texts.py | 44 + pydis_site/apps/api/migrations/0007_tag.py | 23 + .../api/migrations/0008_tag_embed_validator.py | 23 + pydis_site/apps/api/migrations/0009_snakefact.py | 21 + pydis_site/apps/api/migrations/0010_snakeidiom.py | 21 + .../apps/api/migrations/0011_auto_20181020_1904.py | 18 + .../apps/api/migrations/0012_specialsnake.py | 22 + .../apps/api/migrations/0013_specialsnake_image.py | 21 + .../apps/api/migrations/0014_auto_20181025_1959.py | 23 + .../apps/api/migrations/0015_auto_20181027_1617.py | 19 + .../apps/api/migrations/0016_auto_20181027_1619.py | 18 + .../apps/api/migrations/0017_auto_20181029_1921.py | 19 + .../api/migrations/0018_messagedeletioncontext.py | 24 + pydis_site/apps/api/migrations/0018_user_rename.py | 17 + .../apps/api/migrations/0019_deletedmessage.py | 34 + .../apps/api/migrations/0019_user_in_guild.py | 18 + .../migrations/0020_add_snake_field_validators.py | 24 + pydis_site/apps/api/migrations/0020_infraction.py | 30 + .../migrations/0021_add_special_snake_validator.py | 19 + .../api/migrations/0021_infraction_reason_null.py | 18 + .../api/migrations/0021_merge_20181125_1015.py | 14 + .../api/migrations/0022_infraction_remove_note.py | 18 + .../0023_merge_infractions_snake_validators.py | 14 + .../migrations/0024_add_note_infraction_type.py | 18 + ...25_allow_custom_inserted_at_infraction_field.py | 19 + ...proper_default_for_infraction_insertion_date.py | 19 + .../api/migrations/0027_merge_20190120_0852.py | 14 + .../migrations/0028_allow_message_content_blank.py | 18 + .../migrations/0029_add_infraction_type_watch.py | 18 + pydis_site/apps/api/migrations/0030_reminder.py | 27 + pydis_site/apps/api/migrations/0031_nomination.py | 26 + pydis_site/apps/api/migrations/0032_botsetting.py | 23 + .../api/migrations/0033_create_defcon_settings.py | 30 + .../0034_add_botsetting_name_validator.py | 20 + pydis_site/apps/api/migrations/__init__.py | 0 pydis_site/apps/api/models.py | 452 +++++++++++ pydis_site/apps/api/serializers.py | 174 ++++ pydis_site/apps/api/tests/__init__.py | 0 pydis_site/apps/api/tests/base.py | 69 ++ pydis_site/apps/api/tests/test_deleted_messages.py | 43 + .../apps/api/tests/test_documentation_links.py | 161 ++++ pydis_site/apps/api/tests/test_healthcheck.py | 16 + pydis_site/apps/api/tests/test_infractions.py | 359 +++++++++ pydis_site/apps/api/tests/test_models.py | 113 +++ pydis_site/apps/api/tests/test_nominations.py | 41 + .../apps/api/tests/test_off_topic_channel_names.py | 152 ++++ pydis_site/apps/api/tests/test_rules.py | 35 + pydis_site/apps/api/tests/test_snake_names.py | 67 ++ pydis_site/apps/api/tests/test_users.py | 121 +++ pydis_site/apps/api/tests/test_validators.py | 213 +++++ pydis_site/apps/api/urls.py | 86 ++ pydis_site/apps/api/validators.py | 164 ++++ pydis_site/apps/api/views.py | 161 ++++ pydis_site/apps/api/viewsets.py | 890 +++++++++++++++++++++ pydis_site/apps/home/__init__.py | 0 pydis_site/apps/home/admin.py | 3 + pydis_site/apps/home/apps.py | 5 + pydis_site/apps/home/migrations/__init__.py | 0 pydis_site/apps/home/models.py | 3 + pydis_site/apps/home/tests.py | 9 + pydis_site/apps/home/urls.py | 10 + pydis_site/apps/home/views.py | 3 + pydis_site/apps/wiki/__init__.py | 0 pydis_site/apps/wiki/admin.py | 3 + pydis_site/apps/wiki/apps.py | 5 + pydis_site/apps/wiki/migrations/__init__.py | 0 pydis_site/apps/wiki/models.py | 3 + pydis_site/apps/wiki/tests.py | 3 + pydis_site/apps/wiki/views.py | 3 + pydis_site/hosts.py | 13 + pydis_site/settings.py | 259 ++++++ pydis_site/static/assets/logo-banner.png | Bin 0 -> 34789 bytes pydis_site/static/assets/logo-banner.svg | 55 ++ pydis_site/static/assets/logo-discord.png | Bin 0 -> 63590 bytes pydis_site/static/css/navbar.css | 4 + pydis_site/static/home/css/index.css | 30 + pydis_site/templates/base.html | 22 + pydis_site/templates/home/index.html | 42 + pydis_site/templates/navbar.html | 16 + pydis_site/urls.py | 6 + pydis_site/wsgi.py | 16 + pysite/__init__.py | 0 pysite/apps/__init__.py | 0 pysite/apps/admin/__init__.py | 0 pysite/apps/admin/urls.py | 7 - pysite/apps/api/__init__.py | 0 pysite/apps/api/admin.py | 26 - pysite/apps/api/apps.py | 5 - pysite/apps/api/migrations/0001_initial.py | 21 - .../apps/api/migrations/0002_documentationlink.py | 21 - .../api/migrations/0003_offtopicchannelname.py | 20 - pysite/apps/api/migrations/0004_role.py | 23 - pysite/apps/api/migrations/0005_user.py | 38 - pysite/apps/api/migrations/0006_add_help_texts.py | 44 - pysite/apps/api/migrations/0007_tag.py | 23 - .../api/migrations/0008_tag_embed_validator.py | 23 - pysite/apps/api/migrations/0009_snakefact.py | 21 - pysite/apps/api/migrations/0010_snakeidiom.py | 21 - .../apps/api/migrations/0011_auto_20181020_1904.py | 18 - pysite/apps/api/migrations/0012_specialsnake.py | 22 - .../apps/api/migrations/0013_specialsnake_image.py | 21 - .../apps/api/migrations/0014_auto_20181025_1959.py | 23 - .../apps/api/migrations/0015_auto_20181027_1617.py | 19 - .../apps/api/migrations/0016_auto_20181027_1619.py | 18 - .../apps/api/migrations/0017_auto_20181029_1921.py | 19 - .../api/migrations/0018_messagedeletioncontext.py | 24 - pysite/apps/api/migrations/0018_user_rename.py | 17 - pysite/apps/api/migrations/0019_deletedmessage.py | 34 - pysite/apps/api/migrations/0019_user_in_guild.py | 18 - .../migrations/0020_add_snake_field_validators.py | 24 - pysite/apps/api/migrations/0020_infraction.py | 30 - .../migrations/0021_add_special_snake_validator.py | 19 - .../api/migrations/0021_infraction_reason_null.py | 18 - .../api/migrations/0021_merge_20181125_1015.py | 14 - .../api/migrations/0022_infraction_remove_note.py | 18 - .../0023_merge_infractions_snake_validators.py | 14 - .../migrations/0024_add_note_infraction_type.py | 18 - ...25_allow_custom_inserted_at_infraction_field.py | 19 - ...proper_default_for_infraction_insertion_date.py | 19 - .../api/migrations/0027_merge_20190120_0852.py | 14 - .../migrations/0028_allow_message_content_blank.py | 18 - .../migrations/0029_add_infraction_type_watch.py | 18 - pysite/apps/api/migrations/0030_reminder.py | 27 - pysite/apps/api/migrations/0031_nomination.py | 26 - pysite/apps/api/migrations/0032_botsetting.py | 23 - .../api/migrations/0033_create_defcon_settings.py | 30 - .../0034_add_botsetting_name_validator.py | 20 - pysite/apps/api/migrations/__init__.py | 0 pysite/apps/api/models.py | 452 ----------- pysite/apps/api/serializers.py | 174 ---- pysite/apps/api/tests/__init__.py | 0 pysite/apps/api/tests/base.py | 69 -- pysite/apps/api/tests/test_deleted_messages.py | 43 - pysite/apps/api/tests/test_documentation_links.py | 161 ---- pysite/apps/api/tests/test_healthcheck.py | 16 - pysite/apps/api/tests/test_infractions.py | 359 --------- pysite/apps/api/tests/test_models.py | 113 --- pysite/apps/api/tests/test_nominations.py | 41 - .../apps/api/tests/test_off_topic_channel_names.py | 152 ---- pysite/apps/api/tests/test_rules.py | 35 - pysite/apps/api/tests/test_snake_names.py | 67 -- pysite/apps/api/tests/test_users.py | 121 --- pysite/apps/api/tests/test_validators.py | 213 ----- pysite/apps/api/urls.py | 86 -- pysite/apps/api/validators.py | 164 ---- pysite/apps/api/views.py | 161 ---- pysite/apps/api/viewsets.py | 890 --------------------- pysite/apps/home/__init__.py | 0 pysite/apps/home/admin.py | 3 - pysite/apps/home/apps.py | 5 - pysite/apps/home/migrations/__init__.py | 0 pysite/apps/home/models.py | 3 - pysite/apps/home/tests.py | 9 - pysite/apps/home/urls.py | 10 - pysite/apps/home/views.py | 3 - pysite/apps/wiki/__init__.py | 0 pysite/apps/wiki/admin.py | 3 - pysite/apps/wiki/apps.py | 5 - pysite/apps/wiki/migrations/__init__.py | 0 pysite/apps/wiki/models.py | 3 - pysite/apps/wiki/tests.py | 3 - pysite/apps/wiki/views.py | 3 - pysite/hosts.py | 13 - pysite/settings.py | 259 ------ pysite/static/assets/logo-banner.png | Bin 34789 -> 0 bytes pysite/static/assets/logo-banner.svg | 55 -- pysite/static/assets/logo-discord.png | Bin 63590 -> 0 bytes pysite/static/css/navbar.css | 4 - pysite/static/home/css/index.css | 30 - pysite/templates/base.html | 22 - pysite/templates/home/index.html | 42 - pysite/templates/navbar.html | 16 - pysite/urls.py | 6 - pysite/wsgi.py | 16 - 194 files changed, 4747 insertions(+), 4747 deletions(-) create mode 100644 pydis_site/__init__.py create mode 100644 pydis_site/apps/__init__.py create mode 100644 pydis_site/apps/admin/__init__.py create mode 100644 pydis_site/apps/admin/urls.py create mode 100644 pydis_site/apps/api/__init__.py create mode 100644 pydis_site/apps/api/admin.py create mode 100644 pydis_site/apps/api/apps.py create mode 100644 pydis_site/apps/api/migrations/0001_initial.py create mode 100644 pydis_site/apps/api/migrations/0002_documentationlink.py create mode 100644 pydis_site/apps/api/migrations/0003_offtopicchannelname.py create mode 100644 pydis_site/apps/api/migrations/0004_role.py create mode 100644 pydis_site/apps/api/migrations/0005_user.py create mode 100644 pydis_site/apps/api/migrations/0006_add_help_texts.py create mode 100644 pydis_site/apps/api/migrations/0007_tag.py create mode 100644 pydis_site/apps/api/migrations/0008_tag_embed_validator.py create mode 100644 pydis_site/apps/api/migrations/0009_snakefact.py create mode 100644 pydis_site/apps/api/migrations/0010_snakeidiom.py create mode 100644 pydis_site/apps/api/migrations/0011_auto_20181020_1904.py create mode 100644 pydis_site/apps/api/migrations/0012_specialsnake.py create mode 100644 pydis_site/apps/api/migrations/0013_specialsnake_image.py create mode 100644 pydis_site/apps/api/migrations/0014_auto_20181025_1959.py create mode 100644 pydis_site/apps/api/migrations/0015_auto_20181027_1617.py create mode 100644 pydis_site/apps/api/migrations/0016_auto_20181027_1619.py create mode 100644 pydis_site/apps/api/migrations/0017_auto_20181029_1921.py create mode 100644 pydis_site/apps/api/migrations/0018_messagedeletioncontext.py create mode 100644 pydis_site/apps/api/migrations/0018_user_rename.py create mode 100644 pydis_site/apps/api/migrations/0019_deletedmessage.py create mode 100644 pydis_site/apps/api/migrations/0019_user_in_guild.py create mode 100644 pydis_site/apps/api/migrations/0020_add_snake_field_validators.py create mode 100644 pydis_site/apps/api/migrations/0020_infraction.py create mode 100644 pydis_site/apps/api/migrations/0021_add_special_snake_validator.py create mode 100644 pydis_site/apps/api/migrations/0021_infraction_reason_null.py create mode 100644 pydis_site/apps/api/migrations/0021_merge_20181125_1015.py create mode 100644 pydis_site/apps/api/migrations/0022_infraction_remove_note.py create mode 100644 pydis_site/apps/api/migrations/0023_merge_infractions_snake_validators.py create mode 100644 pydis_site/apps/api/migrations/0024_add_note_infraction_type.py create mode 100644 pydis_site/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py create mode 100644 pydis_site/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py create mode 100644 pydis_site/apps/api/migrations/0027_merge_20190120_0852.py create mode 100644 pydis_site/apps/api/migrations/0028_allow_message_content_blank.py create mode 100644 pydis_site/apps/api/migrations/0029_add_infraction_type_watch.py create mode 100644 pydis_site/apps/api/migrations/0030_reminder.py create mode 100644 pydis_site/apps/api/migrations/0031_nomination.py create mode 100644 pydis_site/apps/api/migrations/0032_botsetting.py create mode 100644 pydis_site/apps/api/migrations/0033_create_defcon_settings.py create mode 100644 pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py create mode 100644 pydis_site/apps/api/migrations/__init__.py create mode 100644 pydis_site/apps/api/models.py create mode 100644 pydis_site/apps/api/serializers.py create mode 100644 pydis_site/apps/api/tests/__init__.py create mode 100644 pydis_site/apps/api/tests/base.py create mode 100644 pydis_site/apps/api/tests/test_deleted_messages.py create mode 100644 pydis_site/apps/api/tests/test_documentation_links.py create mode 100644 pydis_site/apps/api/tests/test_healthcheck.py create mode 100644 pydis_site/apps/api/tests/test_infractions.py create mode 100644 pydis_site/apps/api/tests/test_models.py create mode 100644 pydis_site/apps/api/tests/test_nominations.py create mode 100644 pydis_site/apps/api/tests/test_off_topic_channel_names.py create mode 100644 pydis_site/apps/api/tests/test_rules.py create mode 100644 pydis_site/apps/api/tests/test_snake_names.py create mode 100644 pydis_site/apps/api/tests/test_users.py create mode 100644 pydis_site/apps/api/tests/test_validators.py create mode 100644 pydis_site/apps/api/urls.py create mode 100644 pydis_site/apps/api/validators.py create mode 100644 pydis_site/apps/api/views.py create mode 100644 pydis_site/apps/api/viewsets.py create mode 100644 pydis_site/apps/home/__init__.py create mode 100644 pydis_site/apps/home/admin.py create mode 100644 pydis_site/apps/home/apps.py create mode 100644 pydis_site/apps/home/migrations/__init__.py create mode 100644 pydis_site/apps/home/models.py create mode 100644 pydis_site/apps/home/tests.py create mode 100644 pydis_site/apps/home/urls.py create mode 100644 pydis_site/apps/home/views.py create mode 100644 pydis_site/apps/wiki/__init__.py create mode 100644 pydis_site/apps/wiki/admin.py create mode 100644 pydis_site/apps/wiki/apps.py create mode 100644 pydis_site/apps/wiki/migrations/__init__.py create mode 100644 pydis_site/apps/wiki/models.py create mode 100644 pydis_site/apps/wiki/tests.py create mode 100644 pydis_site/apps/wiki/views.py create mode 100644 pydis_site/hosts.py create mode 100644 pydis_site/settings.py create mode 100644 pydis_site/static/assets/logo-banner.png create mode 100644 pydis_site/static/assets/logo-banner.svg create mode 100644 pydis_site/static/assets/logo-discord.png create mode 100644 pydis_site/static/css/navbar.css create mode 100644 pydis_site/static/home/css/index.css create mode 100644 pydis_site/templates/base.html create mode 100644 pydis_site/templates/home/index.html create mode 100644 pydis_site/templates/navbar.html create mode 100644 pydis_site/urls.py create mode 100644 pydis_site/wsgi.py delete mode 100644 pysite/__init__.py delete mode 100644 pysite/apps/__init__.py delete mode 100644 pysite/apps/admin/__init__.py delete mode 100644 pysite/apps/admin/urls.py delete mode 100644 pysite/apps/api/__init__.py delete mode 100644 pysite/apps/api/admin.py delete mode 100644 pysite/apps/api/apps.py delete mode 100644 pysite/apps/api/migrations/0001_initial.py delete mode 100644 pysite/apps/api/migrations/0002_documentationlink.py delete mode 100644 pysite/apps/api/migrations/0003_offtopicchannelname.py delete mode 100644 pysite/apps/api/migrations/0004_role.py delete mode 100644 pysite/apps/api/migrations/0005_user.py delete mode 100644 pysite/apps/api/migrations/0006_add_help_texts.py delete mode 100644 pysite/apps/api/migrations/0007_tag.py delete mode 100644 pysite/apps/api/migrations/0008_tag_embed_validator.py delete mode 100644 pysite/apps/api/migrations/0009_snakefact.py delete mode 100644 pysite/apps/api/migrations/0010_snakeidiom.py delete mode 100644 pysite/apps/api/migrations/0011_auto_20181020_1904.py delete mode 100644 pysite/apps/api/migrations/0012_specialsnake.py delete mode 100644 pysite/apps/api/migrations/0013_specialsnake_image.py delete mode 100644 pysite/apps/api/migrations/0014_auto_20181025_1959.py delete mode 100644 pysite/apps/api/migrations/0015_auto_20181027_1617.py delete mode 100644 pysite/apps/api/migrations/0016_auto_20181027_1619.py delete mode 100644 pysite/apps/api/migrations/0017_auto_20181029_1921.py delete mode 100644 pysite/apps/api/migrations/0018_messagedeletioncontext.py delete mode 100644 pysite/apps/api/migrations/0018_user_rename.py delete mode 100644 pysite/apps/api/migrations/0019_deletedmessage.py delete mode 100644 pysite/apps/api/migrations/0019_user_in_guild.py delete mode 100644 pysite/apps/api/migrations/0020_add_snake_field_validators.py delete mode 100644 pysite/apps/api/migrations/0020_infraction.py delete mode 100644 pysite/apps/api/migrations/0021_add_special_snake_validator.py delete mode 100644 pysite/apps/api/migrations/0021_infraction_reason_null.py delete mode 100644 pysite/apps/api/migrations/0021_merge_20181125_1015.py delete mode 100644 pysite/apps/api/migrations/0022_infraction_remove_note.py delete mode 100644 pysite/apps/api/migrations/0023_merge_infractions_snake_validators.py delete mode 100644 pysite/apps/api/migrations/0024_add_note_infraction_type.py delete mode 100644 pysite/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py delete mode 100644 pysite/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py delete mode 100644 pysite/apps/api/migrations/0027_merge_20190120_0852.py delete mode 100644 pysite/apps/api/migrations/0028_allow_message_content_blank.py delete mode 100644 pysite/apps/api/migrations/0029_add_infraction_type_watch.py delete mode 100644 pysite/apps/api/migrations/0030_reminder.py delete mode 100644 pysite/apps/api/migrations/0031_nomination.py delete mode 100644 pysite/apps/api/migrations/0032_botsetting.py delete mode 100644 pysite/apps/api/migrations/0033_create_defcon_settings.py delete mode 100644 pysite/apps/api/migrations/0034_add_botsetting_name_validator.py delete mode 100644 pysite/apps/api/migrations/__init__.py delete mode 100644 pysite/apps/api/models.py delete mode 100644 pysite/apps/api/serializers.py delete mode 100644 pysite/apps/api/tests/__init__.py delete mode 100644 pysite/apps/api/tests/base.py delete mode 100644 pysite/apps/api/tests/test_deleted_messages.py delete mode 100644 pysite/apps/api/tests/test_documentation_links.py delete mode 100644 pysite/apps/api/tests/test_healthcheck.py delete mode 100644 pysite/apps/api/tests/test_infractions.py delete mode 100644 pysite/apps/api/tests/test_models.py delete mode 100644 pysite/apps/api/tests/test_nominations.py delete mode 100644 pysite/apps/api/tests/test_off_topic_channel_names.py delete mode 100644 pysite/apps/api/tests/test_rules.py delete mode 100644 pysite/apps/api/tests/test_snake_names.py delete mode 100644 pysite/apps/api/tests/test_users.py delete mode 100644 pysite/apps/api/tests/test_validators.py delete mode 100644 pysite/apps/api/urls.py delete mode 100644 pysite/apps/api/validators.py delete mode 100644 pysite/apps/api/views.py delete mode 100644 pysite/apps/api/viewsets.py delete mode 100644 pysite/apps/home/__init__.py delete mode 100644 pysite/apps/home/admin.py delete mode 100644 pysite/apps/home/apps.py delete mode 100644 pysite/apps/home/migrations/__init__.py delete mode 100644 pysite/apps/home/models.py delete mode 100644 pysite/apps/home/tests.py delete mode 100644 pysite/apps/home/urls.py delete mode 100644 pysite/apps/home/views.py delete mode 100644 pysite/apps/wiki/__init__.py delete mode 100644 pysite/apps/wiki/admin.py delete mode 100644 pysite/apps/wiki/apps.py delete mode 100644 pysite/apps/wiki/migrations/__init__.py delete mode 100644 pysite/apps/wiki/models.py delete mode 100644 pysite/apps/wiki/tests.py delete mode 100644 pysite/apps/wiki/views.py delete mode 100644 pysite/hosts.py delete mode 100644 pysite/settings.py delete mode 100644 pysite/static/assets/logo-banner.png delete mode 100644 pysite/static/assets/logo-banner.svg delete mode 100644 pysite/static/assets/logo-discord.png delete mode 100644 pysite/static/css/navbar.css delete mode 100644 pysite/static/home/css/index.css delete mode 100644 pysite/templates/base.html delete mode 100644 pysite/templates/home/index.html delete mode 100644 pysite/templates/navbar.html delete mode 100644 pysite/urls.py delete mode 100644 pysite/wsgi.py diff --git a/.coveragerc b/.coveragerc index ecdbc740..a2a51727 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,17 +1,17 @@ [run] branch = true source = - pysite - pysite/apps/admin - pysite/apps/api - pysite/apps/home - pysite/apps/wiki + pydis_site + pydis_site/apps/admin + pydis_site/apps/api + pydis_site/apps/home + pydis_site/apps/wiki omit = */admin.py */apps.py */urls.py - pysite/wsgi.py - pysite/settings.py + pydis_site/wsgi.py + pydis_site/settings.py [report] fail_under = 100 diff --git a/.dockerignore b/.dockerignore index ec42c3f4..ca6ba308 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,8 +12,8 @@ __pycache__ admin/tests admin/tests.py -pysite/apps/api/tests -pysite/apps/api/tests.py +pydis_site/apps/api/tests +pydis_site/apps/api/tests.py CHANGELOG.md CONTRIBUTING.md docker @@ -31,5 +31,5 @@ README.md scripts Vagrantfile venv -pysite/apps/wiki/tests -pysite/apps/wiki/tests.py +pydis_site/apps/wiki/tests +pydis_site/apps/wiki/tests.py diff --git a/.flake8 b/.flake8 index ee708c7c..a86910e6 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length=100 ignore=S106 -application_import_names=pysite +application_import_names=pydis_site exclude=__pycache__, venv, .venv, **/migrations import-order-style=pycharm diff --git a/azure-pipelines.yml b/azure-pipelines.yml index e85608a1..1e6d67f0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -93,7 +93,7 @@ jobs: echo "CREATE DATABASE pysite OWNER pysite;" >> pgscript.sql sudo su postgres -c "psql < pgscript.sql" env: - USER_CREATE_COMMAND: CREATE USER pysite WITH PASSWORD 'pysite' CREATEDB + USER_CREATE_COMMAND: CREATE USER pydis_site WITH PASSWORD 'pydis_site' CREATEDB displayName: set up the database - script: python -m pip install pipenv && python -m pipenv install --dev --system @@ -104,7 +104,7 @@ jobs: coverage run --branch manage.py test --testrunner xmlrunner.extra.djangotestrunner.XMLTestRunner --no-input env: CI: azure - DATABASE_URL: postgres://pysite:pysite@localhost/pysite + DATABASE_URL: postgres://pydis_site:pydis_site@localhost/pydis_site displayName: run tests - script: coverage report diff --git a/docker-compose.yml b/docker-compose.yml index 1f101c0b..8519e763 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,9 +20,9 @@ services: postgres: image: postgres:11-alpine environment: - POSTGRES_DB: pysite + POSTGRES_DB: pydis_site POSTGRES_PASSWORD: supersecretpassword - POSTGRES_USER: pysite + POSTGRES_USER: pydis_site web: build: @@ -36,7 +36,7 @@ services: volumes: - .:/app:ro environment: - DATABASE_URL: postgres://pysite:supersecretpassword@postgres/pysite + DATABASE_URL: postgres://pydis_site:supersecretpassword@postgres/pydis_site DEBUG: "true" SECRET_KEY: suitable-for-development-only diff --git a/docker/app/uwsgi.ini b/docker/app/uwsgi.ini index 73014167..3f35258c 100644 --- a/docker/app/uwsgi.ini +++ b/docker/app/uwsgi.ini @@ -5,7 +5,7 @@ socket = :4000 ### File settings # WSGI application -wsgi = pysite.wsgi:application +wsgi = pydis_site.wsgi:application # Directory to move into at startup chdir = /app @@ -18,8 +18,8 @@ cheaper = 1 workers = 4 # Automatically set up meanginful process names auto-procname = true -# Prefix process names with `pysite : ` -procname-prefix-spaced = pysite : +# Prefix process names with `pydis_site : ` +procname-prefix-spaced = pydis_site : ### Worker options # Kill workers if they take more than 30 seconds to respond. diff --git a/docker/pysite.dockerapp b/docker/pysite.dockerapp index dc472b2e..738fcfdd 100644 --- a/docker/pysite.dockerapp +++ b/docker/pysite.dockerapp @@ -1,5 +1,5 @@ version: 0.3.0 -name: pysite +name: pydis_site description: | Our community website, built on Django and PostgreSQL. namespace: python-discord diff --git a/manage.py b/manage.py index 42d82d80..ca7a5142 100755 --- a/manage.py +++ b/manage.py @@ -5,7 +5,7 @@ import sys # Separate definition to ease calling this in other scripts. def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pysite.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydis_site.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/pydis_site/__init__.py b/pydis_site/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/__init__.py b/pydis_site/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/admin/__init__.py b/pydis_site/apps/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/admin/urls.py b/pydis_site/apps/admin/urls.py new file mode 100644 index 00000000..146c6496 --- /dev/null +++ b/pydis_site/apps/admin/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import path + + +urlpatterns = ( + path('', admin.site.urls), +) diff --git a/pydis_site/apps/api/__init__.py b/pydis_site/apps/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py new file mode 100644 index 00000000..3ae7f3c5 --- /dev/null +++ b/pydis_site/apps/api/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin + +from .models import ( + BotSetting, DeletedMessage, + DocumentationLink, Infraction, + MessageDeletionContext, OffTopicChannelName, + Role, SnakeFact, + SnakeIdiom, SnakeName, + SpecialSnake, Tag, + User +) + + +admin.site.register(BotSetting) +admin.site.register(DeletedMessage) +admin.site.register(DocumentationLink) +admin.site.register(Infraction) +admin.site.register(MessageDeletionContext) +admin.site.register(OffTopicChannelName) +admin.site.register(Role) +admin.site.register(SnakeFact) +admin.site.register(SnakeIdiom) +admin.site.register(SnakeName) +admin.site.register(SpecialSnake) +admin.site.register(Tag) +admin.site.register(User) diff --git a/pydis_site/apps/api/apps.py b/pydis_site/apps/api/apps.py new file mode 100644 index 00000000..d87006dd --- /dev/null +++ b/pydis_site/apps/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = 'api' diff --git a/pydis_site/apps/api/migrations/0001_initial.py b/pydis_site/apps/api/migrations/0001_initial.py new file mode 100644 index 00000000..dca6d17f --- /dev/null +++ b/pydis_site/apps/api/migrations/0001_initial.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1 on 2018-08-15 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SnakeName', + fields=[ + ('name', models.CharField(max_length=100, primary_key=True, serialize=False)), + ('scientific', models.CharField(max_length=150)), + ], + ), + ] diff --git a/pydis_site/apps/api/migrations/0002_documentationlink.py b/pydis_site/apps/api/migrations/0002_documentationlink.py new file mode 100644 index 00000000..5dee679a --- /dev/null +++ b/pydis_site/apps/api/migrations/0002_documentationlink.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1 on 2018-08-16 19:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DocumentationLink', + fields=[ + ('package', models.CharField(max_length=50, primary_key=True, serialize=False)), + ('base_url', models.URLField()), + ('inventory_url', models.URLField()), + ], + ), + ] diff --git a/pydis_site/apps/api/migrations/0003_offtopicchannelname.py b/pydis_site/apps/api/migrations/0003_offtopicchannelname.py new file mode 100644 index 00000000..2f19bfd8 --- /dev/null +++ b/pydis_site/apps/api/migrations/0003_offtopicchannelname.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1 on 2018-08-31 22:21 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_documentationlink'), + ] + + operations = [ + migrations.CreateModel( + name='OffTopicChannelName', + fields=[ + ('name', models.CharField(max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^[a-z0-9-]+$')])), + ], + ), + ] diff --git a/pydis_site/apps/api/migrations/0004_role.py b/pydis_site/apps/api/migrations/0004_role.py new file mode 100644 index 00000000..0a6b6c43 --- /dev/null +++ b/pydis_site/apps/api/migrations/0004_role.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2018-09-01 19:54 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_offtopicchannelname'), + ] + + operations = [ + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigIntegerField(help_text="The role's ID, taken from Discord.", primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')])), + ('name', models.CharField(help_text="The role's name, taken from Discord.", max_length=100)), + ('colour', models.IntegerField(help_text='The integer value of the colour of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Colour hex cannot be negative.')])), + ('permissions', models.IntegerField(help_text='The integer value of the permission bitset of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role permissions cannot be negative.'), django.core.validators.MaxValueValidator(limit_value=8589934592, message='Role permission bitset exceeds value of having all permissions')])), + ], + ), + ] diff --git a/pydis_site/apps/api/migrations/0005_user.py b/pydis_site/apps/api/migrations/0005_user.py new file mode 100644 index 00000000..a771119c --- /dev/null +++ b/pydis_site/apps/api/migrations/0005_user.py @@ -0,0 +1,38 @@ +# Generated by Django 2.1 on 2018-09-01 20:02 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_role'), + ] + + operations = [ + migrations.CreateModel( + name='Member', + fields=[ + ('id', models.BigIntegerField(help_text='The ID of this user, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='User IDs cannot be negative.')])), + ('name', models.CharField(help_text='The username, taken from Discord.', max_length=32)), + ('discriminator', models.PositiveSmallIntegerField(help_text='The discriminator of this user, taken from Discord.', validators=[django.core.validators.MaxValueValidator(limit_value=9999, message='Discriminators may not exceed `9999`.')])), + ('avatar_hash', models.CharField(help_text="The user's avatar hash, taken from Discord. Null if the user does not have any custom avatar.", max_length=100, null=True)), + ], + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigIntegerField(help_text='The role ID, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')]), + ), + migrations.AlterField( + model_name='role', + name='name', + field=models.CharField(help_text='The role name, taken from Discord.', max_length=100), + ), + migrations.AddField( + model_name='member', + name='roles', + field=models.ManyToManyField(help_text='Any roles this user has on our server.', to='api.Role'), + ), + ] diff --git a/pydis_site/apps/api/migrations/0006_add_help_texts.py b/pydis_site/apps/api/migrations/0006_add_help_texts.py new file mode 100644 index 00000000..a57d2289 --- /dev/null +++ b/pydis_site/apps/api/migrations/0006_add_help_texts.py @@ -0,0 +1,44 @@ +# Generated by Django 2.1.1 on 2018-09-21 20:26 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_user'), + ] + + operations = [ + migrations.AlterField( + model_name='documentationlink', + name='base_url', + field=models.URLField(help_text='The base URL from which documentation will be available for this project. Used to generate links to various symbols within this package.'), + ), + migrations.AlterField( + model_name='documentationlink', + name='inventory_url', + field=models.URLField(help_text='The URL at which the Sphinx inventory is available for this package.'), + ), + migrations.AlterField( + model_name='documentationlink', + name='package', + field=models.CharField(help_text='The Python package name that this documentation link belongs to.', max_length=50, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='offtopicchannelname', + name='name', + field=models.CharField(help_text='The actual channel name that will be used on our Discord server.', max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^[a-z0-9-]+$')]), + ), + migrations.AlterField( + model_name='snakename', + name='name', + field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='snakename', + name='scientific', + field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150), + ), + ] diff --git a/pydis_site/apps/api/migrations/0007_tag.py b/pydis_site/apps/api/migrations/0007_tag.py new file mode 100644 index 00000000..c22715f9 --- /dev/null +++ b/pydis_site/apps/api/migrations/0007_tag.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.1 on 2018-09-21 22:05 + +import pydis_site.apps.api.models +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_add_help_texts'), + ] + + operations = [ + migrations.CreateModel( + name='Tag', + fields=[ + ('title', models.CharField(help_text='The title of this tag, shown in searches and providing a quick overview over what this embed contains.', max_length=100, primary_key=True, serialize=False)), + ('embed', django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.')), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py new file mode 100644 index 00000000..eecc0bc3 --- /dev/null +++ b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.1 on 2018-09-23 10:07 + +import pydis_site.apps.api.validators +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_tag'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='embed', + field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[ + + + pydis_site.apps.api.validators.validate_tag_embed]), + ), + ] diff --git a/pydis_site/apps/api/migrations/0009_snakefact.py b/pydis_site/apps/api/migrations/0009_snakefact.py new file mode 100644 index 00000000..4fc63bc9 --- /dev/null +++ b/pydis_site/apps/api/migrations/0009_snakefact.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.2 on 2018-10-11 14:25 + +import pydis_site.apps.api.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0008_tag_embed_validator'), + ] + + operations = [ + migrations.CreateModel( + name='SnakeFact', + fields=[ + ('fact', models.CharField(help_text='A fact about snakes.', max_length=200, primary_key=True, serialize=False)), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0010_snakeidiom.py b/pydis_site/apps/api/migrations/0010_snakeidiom.py new file mode 100644 index 00000000..be089cf4 --- /dev/null +++ b/pydis_site/apps/api/migrations/0010_snakeidiom.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.2 on 2018-10-19 16:27 + +import pydis_site.apps.api.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0009_snakefact'), + ] + + operations = [ + migrations.CreateModel( + name='SnakeIdiom', + fields=[ + ('idiom', models.CharField(help_text='A snake idiom', max_length=140, primary_key=True, serialize=False)), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0011_auto_20181020_1904.py b/pydis_site/apps/api/migrations/0011_auto_20181020_1904.py new file mode 100644 index 00000000..bb5a6325 --- /dev/null +++ b/pydis_site/apps/api/migrations/0011_auto_20181020_1904.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.2 on 2018-10-20 19:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0010_snakeidiom'), + ] + + operations = [ + migrations.AlterField( + model_name='snakeidiom', + name='idiom', + field=models.CharField(help_text='A saying about a snake.', max_length=140, primary_key=True, serialize=False), + ), + ] diff --git a/pydis_site/apps/api/migrations/0012_specialsnake.py b/pydis_site/apps/api/migrations/0012_specialsnake.py new file mode 100644 index 00000000..77072526 --- /dev/null +++ b/pydis_site/apps/api/migrations/0012_specialsnake.py @@ -0,0 +1,22 @@ +# Generated by Django 2.1.2 on 2018-10-22 09:53 + +import pydis_site.apps.api.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0011_auto_20181020_1904'), + ] + + operations = [ + migrations.CreateModel( + name='SpecialSnake', + fields=[ + ('name', models.CharField(max_length=140, primary_key=True, serialize=False)), + ('info', models.TextField()), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0013_specialsnake_image.py b/pydis_site/apps/api/migrations/0013_specialsnake_image.py new file mode 100644 index 00000000..a0d0d318 --- /dev/null +++ b/pydis_site/apps/api/migrations/0013_specialsnake_image.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.2 on 2018-10-23 11:51 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0012_specialsnake'), + ] + + operations = [ + migrations.AddField( + model_name='specialsnake', + name='image', + field=models.URLField(default=datetime.datetime(2018, 10, 23, 11, 51, 23, 703868, tzinfo=utc)), + preserve_default=False, + ), + ] diff --git a/pydis_site/apps/api/migrations/0014_auto_20181025_1959.py b/pydis_site/apps/api/migrations/0014_auto_20181025_1959.py new file mode 100644 index 00000000..3599d2cd --- /dev/null +++ b/pydis_site/apps/api/migrations/0014_auto_20181025_1959.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.2 on 2018-10-25 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0013_specialsnake_image'), + ] + + operations = [ + migrations.AlterField( + model_name='specialsnake', + name='info', + field=models.TextField(help_text='Info about a special snake.'), + ), + migrations.AlterField( + model_name='specialsnake', + name='name', + field=models.CharField(help_text='A special snake name.', max_length=140, primary_key=True, serialize=False), + ), + ] diff --git a/pydis_site/apps/api/migrations/0015_auto_20181027_1617.py b/pydis_site/apps/api/migrations/0015_auto_20181027_1617.py new file mode 100644 index 00000000..8973ff6d --- /dev/null +++ b/pydis_site/apps/api/migrations/0015_auto_20181027_1617.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.2 on 2018-10-27 16:17 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0014_auto_20181025_1959'), + ] + + operations = [ + migrations.AlterField( + model_name='specialsnake', + name='image', + field=django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), size=None), + ), + ] diff --git a/pydis_site/apps/api/migrations/0016_auto_20181027_1619.py b/pydis_site/apps/api/migrations/0016_auto_20181027_1619.py new file mode 100644 index 00000000..b8bdfb16 --- /dev/null +++ b/pydis_site/apps/api/migrations/0016_auto_20181027_1619.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.2 on 2018-10-27 16:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0015_auto_20181027_1617'), + ] + + operations = [ + migrations.RenameField( + model_name='specialsnake', + old_name='image', + new_name='images', + ), + ] diff --git a/pydis_site/apps/api/migrations/0017_auto_20181029_1921.py b/pydis_site/apps/api/migrations/0017_auto_20181029_1921.py new file mode 100644 index 00000000..012bda61 --- /dev/null +++ b/pydis_site/apps/api/migrations/0017_auto_20181029_1921.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.2 on 2018-10-29 19:21 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0016_auto_20181027_1619'), + ] + + operations = [ + migrations.AlterField( + model_name='specialsnake', + name='images', + field=django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), help_text='Images displaying this special snake.', size=None), + ), + ] diff --git a/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py b/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py new file mode 100644 index 00000000..dced1288 --- /dev/null +++ b/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.1 on 2018-11-18 20:12 + +import pydis_site.apps.api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0017_auto_20181029_1921'), + ] + + operations = [ + migrations.CreateModel( + name='MessageDeletionContext', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation', models.DateTimeField(help_text='When this deletion took place.')), + ('actor', models.ForeignKey(help_text='The original actor causing this deletion. Could be the author of a manual clean command invocation, the bot when executing automatic actions, or nothing to indicate that the bulk deletion was not issued by us.', null=True, on_delete=django.db.models.deletion.CASCADE, to='api.User')), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0018_user_rename.py b/pydis_site/apps/api/migrations/0018_user_rename.py new file mode 100644 index 00000000..f88eb5bc --- /dev/null +++ b/pydis_site/apps/api/migrations/0018_user_rename.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1.3 on 2018-11-19 20:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0017_auto_20181029_1921'), + ] + + operations = [ + migrations.RenameModel( + old_name='Member', + new_name='User', + ), + ] diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py new file mode 100644 index 00000000..7a039675 --- /dev/null +++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py @@ -0,0 +1,34 @@ +# Generated by Django 2.1.1 on 2018-11-18 20:26 + +import pydis_site.apps.api.models +import pydis_site.apps.api.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0018_messagedeletioncontext'), + ] + + operations = [ + migrations.CreateModel( + name='DeletedMessage', + fields=[ + ('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])), + ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), + ('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)), + ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[ + + + pydis_site.apps.api.validators.validate_tag_embed]), help_text='Embeds attached to this message.', size=None)), + ('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), + ('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')), + ], + options={ + 'abstract': False, + }, + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0019_user_in_guild.py b/pydis_site/apps/api/migrations/0019_user_in_guild.py new file mode 100644 index 00000000..fda008c4 --- /dev/null +++ b/pydis_site/apps/api/migrations/0019_user_in_guild.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-19 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0018_user_rename'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='in_guild', + field=models.BooleanField(default=True, help_text='Whether this user is in our server.'), + ), + ] diff --git a/pydis_site/apps/api/migrations/0020_add_snake_field_validators.py b/pydis_site/apps/api/migrations/0020_add_snake_field_validators.py new file mode 100644 index 00000000..3b625f9b --- /dev/null +++ b/pydis_site/apps/api/migrations/0020_add_snake_field_validators.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.2 on 2018-11-24 17:11 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0019_user_in_guild'), + ] + + operations = [ + migrations.AlterField( + model_name='snakename', + name='name', + field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), + ), + migrations.AlterField( + model_name='snakename', + name='scientific', + field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), + ), + ] diff --git a/pydis_site/apps/api/migrations/0020_infraction.py b/pydis_site/apps/api/migrations/0020_infraction.py new file mode 100644 index 00000000..6bef6b77 --- /dev/null +++ b/pydis_site/apps/api/migrations/0020_infraction.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1.3 on 2018-11-19 22:02 + +import pydis_site.apps.api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0019_user_in_guild'), + ] + + operations = [ + migrations.CreateModel( + name='Infraction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('inserted_at', models.DateTimeField(auto_now_add=True, help_text='The date and time of the creation of this infraction.')), + ('expires_at', models.DateTimeField(help_text="The date and time of the expiration of this infraction. Null if the infraction is permanent or it can't expire.", null=True)), + ('active', models.BooleanField(default=True, help_text='Whether the infraction is still active.')), + ('type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('mute', 'Mute'), ('ban', 'Ban'), ('kick', 'Kick'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9)), + ('reason', models.TextField(help_text='The reason for the infraction.')), + ('hidden', models.BooleanField(default=False, help_text='Whether the infraction is a shadow infraction.')), + ('actor', models.ForeignKey(help_text='The user which applied the infraction.', on_delete=django.db.models.deletion.CASCADE, related_name='infractions_given', to='api.User')), + ('user', models.ForeignKey(help_text='The user to which the infraction was applied.', on_delete=django.db.models.deletion.CASCADE, related_name='infractions_received', to='api.User')), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0021_add_special_snake_validator.py b/pydis_site/apps/api/migrations/0021_add_special_snake_validator.py new file mode 100644 index 00000000..d41b96e5 --- /dev/null +++ b/pydis_site/apps/api/migrations/0021_add_special_snake_validator.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.2 on 2018-11-25 14:59 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0020_add_snake_field_validators'), + ] + + operations = [ + migrations.AlterField( + model_name='specialsnake', + name='name', + field=models.CharField(help_text='A special snake name.', max_length=140, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), + ), + ] diff --git a/pydis_site/apps/api/migrations/0021_infraction_reason_null.py b/pydis_site/apps/api/migrations/0021_infraction_reason_null.py new file mode 100644 index 00000000..6600f230 --- /dev/null +++ b/pydis_site/apps/api/migrations/0021_infraction_reason_null.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-21 00:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0020_infraction'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='reason', + field=models.TextField(help_text='The reason for the infraction.', null=True), + ), + ] diff --git a/pydis_site/apps/api/migrations/0021_merge_20181125_1015.py b/pydis_site/apps/api/migrations/0021_merge_20181125_1015.py new file mode 100644 index 00000000..d8eaa510 --- /dev/null +++ b/pydis_site/apps/api/migrations/0021_merge_20181125_1015.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.1 on 2018-11-25 10:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0020_add_snake_field_validators'), + ('api', '0019_deletedmessage'), + ] + + operations = [ + ] diff --git a/pydis_site/apps/api/migrations/0022_infraction_remove_note.py b/pydis_site/apps/api/migrations/0022_infraction_remove_note.py new file mode 100644 index 00000000..eba84610 --- /dev/null +++ b/pydis_site/apps/api/migrations/0022_infraction_remove_note.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-21 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0021_infraction_reason_null'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='type', + field=models.CharField(choices=[('warning', 'Warning'), ('mute', 'Mute'), ('ban', 'Ban'), ('kick', 'Kick'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9), + ), + ] diff --git a/pydis_site/apps/api/migrations/0023_merge_infractions_snake_validators.py b/pydis_site/apps/api/migrations/0023_merge_infractions_snake_validators.py new file mode 100644 index 00000000..916f78f2 --- /dev/null +++ b/pydis_site/apps/api/migrations/0023_merge_infractions_snake_validators.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.3 on 2018-11-29 19:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0022_infraction_remove_note'), + ('api', '0021_add_special_snake_validator'), + ] + + operations = [ + ] diff --git a/pydis_site/apps/api/migrations/0024_add_note_infraction_type.py b/pydis_site/apps/api/migrations/0024_add_note_infraction_type.py new file mode 100644 index 00000000..4adb53b8 --- /dev/null +++ b/pydis_site/apps/api/migrations/0024_add_note_infraction_type.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.4 on 2019-01-05 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0023_merge_infractions_snake_validators'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='type', + field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9), + ), + ] diff --git a/pydis_site/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py b/pydis_site/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py new file mode 100644 index 00000000..0c02cb91 --- /dev/null +++ b/pydis_site/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.4 on 2019-01-06 16:01 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0024_add_note_infraction_type'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='inserted_at', + field=models.DateTimeField(default=datetime.datetime.utcnow, help_text='The date and time of the creation of this infraction.'), + ), + ] diff --git a/pydis_site/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py b/pydis_site/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py new file mode 100644 index 00000000..56f3b2b8 --- /dev/null +++ b/pydis_site/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.5 on 2019-01-09 19:50 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0025_allow_custom_inserted_at_infraction_field'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='inserted_at', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time of the creation of this infraction.'), + ), + ] diff --git a/pydis_site/apps/api/migrations/0027_merge_20190120_0852.py b/pydis_site/apps/api/migrations/0027_merge_20190120_0852.py new file mode 100644 index 00000000..6fab4fd0 --- /dev/null +++ b/pydis_site/apps/api/migrations/0027_merge_20190120_0852.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.5 on 2019-01-20 08:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0026_use_proper_default_for_infraction_insertion_date'), + ('api', '0021_merge_20181125_1015'), + ] + + operations = [ + ] diff --git a/pydis_site/apps/api/migrations/0028_allow_message_content_blank.py b/pydis_site/apps/api/migrations/0028_allow_message_content_blank.py new file mode 100644 index 00000000..6d57db27 --- /dev/null +++ b/pydis_site/apps/api/migrations/0028_allow_message_content_blank.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.5 on 2019-01-20 09:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0027_merge_20190120_0852'), + ] + + operations = [ + migrations.AlterField( + model_name='deletedmessage', + name='content', + field=models.CharField(blank=True, help_text='The content of this message, taken from Discord.', max_length=2000), + ), + ] diff --git a/pydis_site/apps/api/migrations/0029_add_infraction_type_watch.py b/pydis_site/apps/api/migrations/0029_add_infraction_type_watch.py new file mode 100644 index 00000000..c6f88a11 --- /dev/null +++ b/pydis_site/apps/api/migrations/0029_add_infraction_type_watch.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.5 on 2019-01-20 11:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0028_allow_message_content_blank'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='type', + field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9), + ), + ] diff --git a/pydis_site/apps/api/migrations/0030_reminder.py b/pydis_site/apps/api/migrations/0030_reminder.py new file mode 100644 index 00000000..8c42f6dc --- /dev/null +++ b/pydis_site/apps/api/migrations/0030_reminder.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.5 on 2019-01-22 22:17 + +import pydis_site.apps.api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0029_add_infraction_type_watch'), + ] + + operations = [ + migrations.CreateModel( + name='Reminder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=True, help_text='Whether this reminder is still active. If not, it has been sent out to the user.')), + ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), + ('content', models.CharField(help_text='The content that the user wants to be reminded of.', max_length=1500)), + ('expiration', models.DateTimeField(help_text='When this reminder should be sent.')), + ('author', models.ForeignKey(help_text='The creator of this reminder.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0031_nomination.py b/pydis_site/apps/api/migrations/0031_nomination.py new file mode 100644 index 00000000..75e69701 --- /dev/null +++ b/pydis_site/apps/api/migrations/0031_nomination.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1.5 on 2019-01-27 11:01 + +import pydis_site.apps.api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0030_reminder'), + ] + + operations = [ + migrations.CreateModel( + name='Nomination', + fields=[ + ('active', models.BooleanField(default=True, help_text='Whether this nomination is still relevant.')), + ('reason', models.TextField(help_text='Why this user was nominated.')), + ('user', models.OneToOneField(help_text='The nominated user.', on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='nomination', serialize=False, to='api.User')), + ('inserted_at', models.DateTimeField(auto_now_add=True, help_text='The creation date of this nomination.')), + ('author', models.ForeignKey(help_text='The staff member that nominated this user.', on_delete=django.db.models.deletion.CASCADE, related_name='nomination_set', to='api.User')), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0032_botsetting.py b/pydis_site/apps/api/migrations/0032_botsetting.py new file mode 100644 index 00000000..25186a2b --- /dev/null +++ b/pydis_site/apps/api/migrations/0032_botsetting.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.5 on 2019-02-07 19:03 + +import pydis_site.apps.api.models +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0031_nomination'), + ] + + operations = [ + migrations.CreateModel( + name='BotSetting', + fields=[ + ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), + ('data', django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual settings of this setting.')), + ], + bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/migrations/0033_create_defcon_settings.py b/pydis_site/apps/api/migrations/0033_create_defcon_settings.py new file mode 100644 index 00000000..830f3fb0 --- /dev/null +++ b/pydis_site/apps/api/migrations/0033_create_defcon_settings.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1.5 on 2019-02-18 19:30 + +from django.db import migrations + + +def up(apps, schema_editor): + BotSetting = apps.get_model('api', 'BotSetting') + setting = BotSetting( + name='defcon', + data={ + 'enabled': False, + 'days': 0 + } + ).save() + + +def down(apps, schema_editor): # pragma: no cover - not necessary to test + BotSetting = apps.get_model('api', 'BotSetting') + BotSetting.get(name='defcon').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0032_botsetting'), + ] + + operations = [ + migrations.RunPython(up, down) + ] diff --git a/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py b/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py new file mode 100644 index 00000000..bd370d8e --- /dev/null +++ b/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.5 on 2019-02-18 19:41 + +import pydis_site.apps.api.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0033_create_defcon_settings'), + ] + + operations = [ + migrations.AlterField( + model_name='botsetting', + name='name', + field=models.CharField(max_length=50, primary_key=True, serialize=False, validators=[ + pydis_site.apps.api.validators.validate_bot_setting_name]), + ), + ] diff --git a/pydis_site/apps/api/migrations/__init__.py b/pydis_site/apps/api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/api/models.py b/pydis_site/apps/api/models.py new file mode 100644 index 00000000..86c99f86 --- /dev/null +++ b/pydis_site/apps/api/models.py @@ -0,0 +1,452 @@ +from operator import itemgetter + +from django.contrib.postgres import fields as pgfields +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator +from django.db import models +from django.utils import timezone + +from .validators import validate_bot_setting_name, validate_tag_embed + + +class ModelReprMixin: + """ + Adds a `__repr__` method to the model subclassing this + mixin which will display the model's class name along + with all parameters used to construct the object. + """ + + def __repr__(self): + attributes = ' '.join( + f'{attribute}={value!r}' + for attribute, value in sorted( + self.__dict__.items(), + key=itemgetter(0) + ) + if not attribute.startswith('_') + ) + return f'<{self.__class__.__name__}({attributes})>' + + +class BotSetting(ModelReprMixin, models.Model): + """A configuration entry for the bot.""" + + name = models.CharField( + primary_key=True, + max_length=50, + validators=(validate_bot_setting_name,) + ) + data = pgfields.JSONField( + help_text="The actual settings of this setting." + ) + + +class DocumentationLink(ModelReprMixin, models.Model): + """A documentation link used by the `!docs` command of the bot.""" + + package = models.CharField( + primary_key=True, + max_length=50, + help_text="The Python package name that this documentation link belongs to." + ) + base_url = models.URLField( + help_text=( + "The base URL from which documentation will be available for this project. " + "Used to generate links to various symbols within this package." + ) + ) + inventory_url = models.URLField( + help_text="The URL at which the Sphinx inventory is available for this package." + ) + + def __str__(self): + return f"{self.package} - {self.base_url}" + + +class OffTopicChannelName(ModelReprMixin, models.Model): + name = models.CharField( + primary_key=True, + max_length=96, + validators=(RegexValidator(regex=r'^[a-z0-9-]+$'),), + help_text="The actual channel name that will be used on our Discord server." + ) + + def __str__(self): + return self.name + + +class Role(ModelReprMixin, models.Model): + """A role on our Discord server.""" + + id = models.BigIntegerField( # noqa + primary_key=True, + validators=( + MinValueValidator( + limit_value=0, + message="Role IDs cannot be negative." + ), + ), + help_text="The role ID, taken from Discord." + ) + name = models.CharField( + max_length=100, + help_text="The role name, taken from Discord." + ) + colour = models.IntegerField( + validators=( + MinValueValidator( + limit_value=0, + message="Colour hex cannot be negative." + ), + ), + help_text="The integer value of the colour of this role from Discord." + ) + permissions = models.IntegerField( + validators=( + MinValueValidator( + limit_value=0, + message="Role permissions cannot be negative." + ), + MaxValueValidator( + limit_value=2 << 32, + message="Role permission bitset exceeds value of having all permissions" + ) + ), + help_text="The integer value of the permission bitset of this role from Discord." + ) + + def __str__(self): + return self.name + + +class SnakeFact(ModelReprMixin, models.Model): + """A snake fact used by the bot's snake cog.""" + + fact = models.CharField( + primary_key=True, + max_length=200, + help_text="A fact about snakes." + ) + + def __str__(self): + return self.fact + + +class SnakeIdiom(ModelReprMixin, models.Model): + """A snake idiom used by the snake cog.""" + + idiom = models.CharField( + primary_key=True, + max_length=140, + help_text="A saying about a snake." + ) + + def __str__(self): + return self.idiom + + +class SnakeName(ModelReprMixin, models.Model): + """A snake name used by the bot's snake cog.""" + + name = models.CharField( + primary_key=True, + max_length=100, + help_text="The regular name for this snake, e.g. 'Python'.", + validators=[RegexValidator(regex=r'^([^0-9])+$')] + ) + scientific = models.CharField( + max_length=150, + help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", + validators=[RegexValidator(regex=r'^([^0-9])+$')] + ) + + def __str__(self): + return f"{self.name} ({self.scientific})" + + +class SpecialSnake(ModelReprMixin, models.Model): + """A special snake's name, info and image from our database used by the bot's snake cog.""" + + name = models.CharField( + max_length=140, + primary_key=True, + help_text='A special snake name.', + validators=[RegexValidator(regex=r'^([^0-9])+$')] + ) + info = models.TextField( + help_text='Info about a special snake.' + ) + images = pgfields.ArrayField( + models.URLField(), + help_text='Images displaying this special snake.' + ) + + def __str__(self): + return self.name + + +class Tag(ModelReprMixin, models.Model): + """A tag providing (hopefully) useful information.""" + + title = models.CharField( + max_length=100, + help_text=( + "The title of this tag, shown in searches and providing " + "a quick overview over what this embed contains." + ), + primary_key=True + ) + embed = pgfields.JSONField( + help_text="The actual embed shown by this tag.", + validators=(validate_tag_embed,) + ) + + def __str__(self): + return self.title + + +class User(ModelReprMixin, models.Model): + """A Discord user.""" + + id = models.BigIntegerField( # noqa + primary_key=True, + validators=( + MinValueValidator( + limit_value=0, + message="User IDs cannot be negative." + ), + ), + help_text="The ID of this user, taken from Discord." + ) + name = models.CharField( + max_length=32, + help_text="The username, taken from Discord." + ) + discriminator = models.PositiveSmallIntegerField( + validators=( + MaxValueValidator( + limit_value=9999, + message="Discriminators may not exceed `9999`." + ), + ), + help_text="The discriminator of this user, taken from Discord." + ) + avatar_hash = models.CharField( + max_length=100, + help_text=( + "The user's avatar hash, taken from Discord. " + "Null if the user does not have any custom avatar." + ), + null=True + ) + roles = models.ManyToManyField( + Role, + help_text="Any roles this user has on our server." + ) + in_guild = models.BooleanField( + default=True, + help_text="Whether this user is in our server." + ) + + def __str__(self): + return f"{self.name}#{self.discriminator}" + + +class Message(ModelReprMixin, models.Model): + id = models.BigIntegerField( + primary_key=True, + help_text="The message ID as taken from Discord.", + validators=( + MinValueValidator( + limit_value=0, + message="Message IDs cannot be negative." + ), + ) + ) + author = models.ForeignKey( + User, + on_delete=models.CASCADE, + help_text="The author of this message." + ) + channel_id = models.BigIntegerField( + help_text=( + "The channel ID that this message was " + "sent in, taken from Discord." + ), + validators=( + MinValueValidator( + limit_value=0, + message="Channel IDs cannot be negative." + ), + ) + ) + content = models.CharField( + max_length=2_000, + help_text="The content of this message, taken from Discord.", + blank=True + ) + embeds = pgfields.ArrayField( + pgfields.JSONField( + validators=(validate_tag_embed,) + ), + help_text="Embeds attached to this message." + ) + + class Meta: + abstract = True + + +class MessageDeletionContext(ModelReprMixin, models.Model): + actor = models.ForeignKey( + User, + on_delete=models.CASCADE, + help_text=( + "The original actor causing this deletion. Could be the author " + "of a manual clean command invocation, the bot when executing " + "automatic actions, or nothing to indicate that the bulk " + "deletion was not issued by us." + ), + null=True + ) + creation = models.DateTimeField( + # Consider whether we want to add a validator here that ensures + # the deletion context does not take place in the future. + help_text="When this deletion took place." + ) + + +class DeletedMessage(Message): + deletion_context = models.ForeignKey( + MessageDeletionContext, + help_text="The deletion context this message is part of.", + on_delete=models.CASCADE + ) + + +class Infraction(ModelReprMixin, models.Model): + """An infraction for a Discord user.""" + + TYPE_CHOICES = ( + ("note", "Note"), + ("warning", "Warning"), + ("watch", "Watch"), + ("mute", "Mute"), + ("kick", "Kick"), + ("ban", "Ban"), + ("superstar", "Superstar") + ) + inserted_at = models.DateTimeField( + default=timezone.now, + help_text="The date and time of the creation of this infraction." + ) + expires_at = models.DateTimeField( + null=True, + help_text=( + "The date and time of the expiration of this infraction. " + "Null if the infraction is permanent or it can't expire." + ) + ) + active = models.BooleanField( + default=True, + help_text="Whether the infraction is still active." + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='infractions_received', + help_text="The user to which the infraction was applied." + ) + actor = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='infractions_given', + help_text="The user which applied the infraction." + ) + type = models.CharField( + max_length=9, + choices=TYPE_CHOICES, + help_text="The type of the infraction." + ) + reason = models.TextField( + null=True, + help_text="The reason for the infraction." + ) + hidden = models.BooleanField( + default=False, + help_text="Whether the infraction is a shadow infraction." + ) + + def __str__(self): + s = f"#{self.id}: {self.type} on {self.user_id}" + if self.expires_at: + s += f" until {self.expires_at}" + if self.hidden: + s += " (hidden)" + return s + + +class Reminder(ModelReprMixin, models.Model): + """A reminder created by a user.""" + + active = models.BooleanField( + default=True, + help_text=( + "Whether this reminder is still active. " + "If not, it has been sent out to the user." + ) + ) + author = models.ForeignKey( + User, + on_delete=models.CASCADE, + help_text="The creator of this reminder." + ) + channel_id = models.BigIntegerField( + help_text=( + "The channel ID that this message was " + "sent in, taken from Discord." + ), + validators=( + MinValueValidator( + limit_value=0, + message="Channel IDs cannot be negative." + ), + ) + ) + content = models.CharField( + max_length=1500, + help_text="The content that the user wants to be reminded of." + ) + expiration = models.DateTimeField( + help_text="When this reminder should be sent." + ) + + def __str__(self): + return f"{self.content} on {self.expiration} by {self.author}" + + +class Nomination(ModelReprMixin, models.Model): + """A helper nomination created by staff.""" + + active = models.BooleanField( + default=True, + help_text="Whether this nomination is still relevant." + ) + author = models.ForeignKey( + User, + on_delete=models.CASCADE, + help_text="The staff member that nominated this user.", + related_name='nomination_set' + ) + reason = models.TextField( + help_text="Why this user was nominated." + ) + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + help_text="The nominated user.", + primary_key=True, + related_name='nomination' + ) + inserted_at = models.DateTimeField( + auto_now_add=True, + help_text="The creation date of this nomination." + ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py new file mode 100644 index 00000000..9a92313a --- /dev/null +++ b/pydis_site/apps/api/serializers.py @@ -0,0 +1,174 @@ +from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError +from rest_framework.validators import UniqueValidator +from rest_framework_bulk import BulkSerializerMixin + +from .models import ( + BotSetting, DeletedMessage, + DocumentationLink, Infraction, + MessageDeletionContext, Nomination, + OffTopicChannelName, Reminder, + Role, SnakeFact, + SnakeIdiom, SnakeName, + SpecialSnake, Tag, + User +) + + +class BotSettingSerializer(ModelSerializer): + class Meta: + model = BotSetting + fields = ('name', 'data') + + +class DeletedMessageSerializer(ModelSerializer): + author = PrimaryKeyRelatedField( + queryset=User.objects.all() + ) + deletion_context = PrimaryKeyRelatedField( + queryset=MessageDeletionContext.objects.all(), + # This will be overriden in the `create` function + # of the deletion context serializer. + required=False + ) + + class Meta: + model = DeletedMessage + fields = ( + 'id', 'author', + 'channel_id', 'content', + 'embeds', 'deletion_context' + ) + + +class MessageDeletionContextSerializer(ModelSerializer): + deletedmessage_set = DeletedMessageSerializer(many=True) + + class Meta: + model = MessageDeletionContext + fields = ('actor', 'creation', 'id', 'deletedmessage_set') + depth = 1 + + def create(self, validated_data): + messages = validated_data.pop('deletedmessage_set') + deletion_context = MessageDeletionContext.objects.create(**validated_data) + for message in messages: + DeletedMessage.objects.create( + deletion_context=deletion_context, + **message + ) + + return deletion_context + + +class DocumentationLinkSerializer(ModelSerializer): + class Meta: + model = DocumentationLink + fields = ('package', 'base_url', 'inventory_url') + + +class InfractionSerializer(ModelSerializer): + class Meta: + model = Infraction + fields = ( + 'id', 'inserted_at', 'expires_at', 'active', 'user', 'actor', 'type', 'reason', 'hidden' + ) + + def validate(self, attrs): + infr_type = attrs.get('type') + + expires_at = attrs.get('expires_at') + if expires_at and infr_type in ('kick', 'warning'): + raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']}) + + hidden = attrs.get('hidden') + if hidden and infr_type in ('superstar',): + raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) + + return attrs + + +class ExpandedInfractionSerializer(InfractionSerializer): + def to_representation(self, instance): + ret = super().to_representation(instance) + + user = User.objects.get(id=ret['user']) + user_data = UserSerializer(user).data + ret['user'] = user_data + + actor = User.objects.get(id=ret['actor']) + actor_data = UserSerializer(actor).data + ret['actor'] = actor_data + + return ret + + +class OffTopicChannelNameSerializer(ModelSerializer): + class Meta: + model = OffTopicChannelName + fields = ('name',) + + def to_representation(self, obj): + return obj.name + + +class SnakeFactSerializer(ModelSerializer): + class Meta: + model = SnakeFact + fields = ('fact',) + + +class SnakeIdiomSerializer(ModelSerializer): + class Meta: + model = SnakeIdiom + fields = ('idiom',) + + +class SnakeNameSerializer(ModelSerializer): + class Meta: + model = SnakeName + fields = ('name', 'scientific') + + +class SpecialSnakeSerializer(ModelSerializer): + class Meta: + model = SpecialSnake + fields = ('name', 'images', 'info') + + +class ReminderSerializer(ModelSerializer): + author = PrimaryKeyRelatedField(queryset=User.objects.all()) + + class Meta: + model = Reminder + fields = ('active', 'author', 'channel_id', 'content', 'expiration', 'id') + + +class RoleSerializer(ModelSerializer): + class Meta: + model = Role + fields = ('id', 'name', 'colour', 'permissions') + + +class TagSerializer(ModelSerializer): + class Meta: + model = Tag + fields = ('title', 'embed') + + +class UserSerializer(BulkSerializerMixin, ModelSerializer): + roles = PrimaryKeyRelatedField(many=True, queryset=Role.objects.all(), required=False) + + class Meta: + model = User + fields = ('id', 'avatar_hash', 'name', 'discriminator', 'roles', 'in_guild') + depth = 1 + + +class NominationSerializer(ModelSerializer): + author = PrimaryKeyRelatedField(queryset=User.objects.all()) + user = PrimaryKeyRelatedField(queryset=User.objects.all()) + + class Meta: + model = Nomination + fields = ('active', 'author', 'reason', 'user', 'inserted_at') + depth = 1 diff --git a/pydis_site/apps/api/tests/__init__.py b/pydis_site/apps/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/api/tests/base.py b/pydis_site/apps/api/tests/base.py new file mode 100644 index 00000000..0290fa69 --- /dev/null +++ b/pydis_site/apps/api/tests/base.py @@ -0,0 +1,69 @@ +from django.contrib.auth.models import User +from rest_framework.test import APITestCase + + +test_user, _created = User.objects.get_or_create( + username='test', + email='test@example.com', + password='testpass', # noqa: S106 + is_superuser=True, + is_staff=True +) + + +class APISubdomainTestCase(APITestCase): + """ + Configures the test client to use the proper subdomain + for requests and forces authentication for the test user. + + The test user is considered staff and superuser. + If you want to test for a custom user (for example, to test model permissions), + create the user, assign the relevant permissions, and use + `self.client.force_authenticate(user=created_user)` to force authentication + through the created user. + + Using this performs the following niceties for you which ease writing tests: + - setting the `HTTP_HOST` request header to `api.pythondiscord.local:8000`, and + - forcing authentication for the test user. + If you don't want to force authentication (for example, to test a route's response + for an unauthenticated user), un-force authentication by using the following: + + >>> from pydis_site.apps.api import APISubdomainTestCase + >>> class UnauthedUserTestCase(APISubdomainTestCase): + ... def setUp(self): + ... super().setUp() + ... self.client.force_authentication(user=None) + ... def test_can_read_objects_at_my_endpoint(self): + ... resp = self.client.get('/my-publicly-readable-endpoint') + ... self.assertEqual(resp.status_code, 200) + ... def test_cannot_delete_objects_at_my_endpoint(self): + ... resp = self.client.delete('/my-publicly-readable-endpoint/42') + ... self.assertEqual(resp.status_code, 401) + + Make sure to include the `super().setUp(self)` call, otherwise, you may get + status code 404 for some URLs due to the missing `HTTP_HOST` header. + + ## Example + Using this in a test case is rather straightforward: + + >>> from pydis_site.apps.api import APISubdomainTestCase + >>> class MyAPITestCase(APISubdomainTestCase): + ... def test_that_it_works(self): + ... response = self.client.get('/my-endpoint') + ... self.assertEqual(response.status_code, 200) + + To reverse URLs of the API host, you need to use `django_hosts`: + + >>> from django_hosts.resolvers import reverse + >>> from pydis_site.apps.api import APISubdomainTestCase + >>> class MyReversedTestCase(APISubdomainTestCase): + ... def test_my_endpoint(self): + ... url = reverse('user-detail', host='api') + ... response = self.client.get(url) + ... self.assertEqual(response.status_code, 200) + """ + + def setUp(self): + super().setUp() + self.client.defaults['HTTP_HOST'] = 'api.pythondiscord.local:8000' + self.client.force_authenticate(test_user) diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py new file mode 100644 index 00000000..cd5acab0 --- /dev/null +++ b/pydis_site/apps/api/tests/test_deleted_messages.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import User + + +class DeletedMessagesTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.author = User.objects.create( + id=55, + name='Robbie Rotten', + discriminator=55, + avatar_hash=None + ) + + cls.data = { + 'actor': None, + 'creation': datetime.utcnow().isoformat(), + 'deletedmessage_set': [ + { + 'author': cls.author.id, + 'id': 55, + 'channel_id': 5555, + 'content': "Terror Billy is a meanie", + 'embeds': [] + }, + { + 'author': cls.author.id, + 'id': 56, + 'channel_id': 5555, + 'content': "If you purge this, you're evil", + 'embeds': [] + } + ] + } + + def test_accepts_valid_data(self): + url = reverse('bot:messagedeletioncontext-list', host='api') + response = self.client.post(url, data=self.data) + self.assertEqual(response.status_code, 201) diff --git a/pydis_site/apps/api/tests/test_documentation_links.py b/pydis_site/apps/api/tests/test_documentation_links.py new file mode 100644 index 00000000..f6c78391 --- /dev/null +++ b/pydis_site/apps/api/tests/test_documentation_links.py @@ -0,0 +1,161 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import DocumentationLink + + +class UnauthedDocumentationLinkAPITests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_detail_lookup_returns_401(self): + url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_list_returns_401(self): + url = reverse('bot:documentationlink-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_create_returns_401(self): + url = reverse('bot:documentationlink-list', host='api') + response = self.client.post(url, data={'hi': 'there'}) + + self.assertEqual(response.status_code, 401) + + def test_delete_returns_401(self): + url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 401) + + +class EmptyDatabaseDocumentationLinkAPITests(APISubdomainTestCase): + def test_detail_lookup_returns_404(self): + url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 404) + + def test_list_all_returns_empty_list(self): + url = reverse('bot:documentationlink-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_delete_returns_404(self): + url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 404) + + +class DetailLookupDocumentationLinkAPITests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.doc_link = DocumentationLink.objects.create( + package='testpackage', + base_url='https://example.com', + inventory_url='https://example.com' + ) + + cls.doc_json = { + 'package': cls.doc_link.package, + 'base_url': cls.doc_link.base_url, + 'inventory_url': cls.doc_link.inventory_url + } + + def test_detail_lookup_unknown_package_returns_404(self): + url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 404) + + def test_detail_lookup_created_package_returns_package(self): + url = reverse('bot:documentationlink-detail', args=(self.doc_link.package,), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), self.doc_json) + + def test_list_all_packages_shows_created_package(self): + url = reverse('bot:documentationlink-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [self.doc_json]) + + def test_create_invalid_body_returns_400(self): + url = reverse('bot:documentationlink-list', host='api') + response = self.client.post(url, data={'i': 'am', 'totally': 'valid'}) + + self.assertEqual(response.status_code, 400) + + def test_create_invalid_url_returns_400(self): + body = { + 'package': 'example', + 'base_url': 'https://example.com', + 'inventory_url': 'totally an url' + } + + url = reverse('bot:documentationlink-list', host='api') + response = self.client.post(url, data=body) + + self.assertEqual(response.status_code, 400) + + +class DocumentationLinkCreationTests(APISubdomainTestCase): + def setUp(self): + super().setUp() + + self.body = { + 'package': 'example', + 'base_url': 'https://example.com', + 'inventory_url': 'https://docs.example.com' + } + + url = reverse('bot:documentationlink-list', host='api') + response = self.client.post(url, data=self.body) + + self.assertEqual(response.status_code, 201) + + def test_package_in_full_list(self): + url = reverse('bot:documentationlink-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [self.body]) + + def test_detail_lookup_works_with_package(self): + url = reverse('bot:documentationlink-detail', args=(self.body['package'],), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), self.body) + + +class DocumentationLinkDeletionTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.doc_link = DocumentationLink.objects.create( + package='example', + base_url='https://example.com', + inventory_url='https://docs.example.com' + ) + + def test_unknown_package_returns_404(self): + url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 404) + + def test_delete_known_package_returns_204(self): + url = reverse('bot:documentationlink-detail', args=(self.doc_link.package,), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 204) diff --git a/pydis_site/apps/api/tests/test_healthcheck.py b/pydis_site/apps/api/tests/test_healthcheck.py new file mode 100644 index 00000000..b0fd71bf --- /dev/null +++ b/pydis_site/apps/api/tests/test_healthcheck.py @@ -0,0 +1,16 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase + + +class UnauthedHealthcheckAPITests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_can_access_healthcheck_view(self): + url = reverse('healthcheck', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'status': 'ok'}) diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py new file mode 100644 index 00000000..7c370c17 --- /dev/null +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -0,0 +1,359 @@ +from datetime import datetime as dt, timedelta, timezone +from urllib.parse import quote + +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import Infraction, User + + +class UnauthenticatedTests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_detail_lookup_returns_401(self): + url = reverse('bot:infraction-detail', args=(5,), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_list_returns_401(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_create_returns_401(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.post(url, data={'reason': 'Have a nice day.'}) + + self.assertEqual(response.status_code, 401) + + def test_partial_update_returns_401(self): + url = reverse('bot:infraction-detail', args=(5,), host='api') + response = self.client.patch(url, data={'reason': 'Have a nice day.'}) + + self.assertEqual(response.status_code, 401) + + +class InfractionTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.user = User.objects.create( + id=5, + name='james', + discriminator=1, + avatar_hash=None + ) + cls.ban_hidden = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='ban', + reason='He terk my jerb!', + hidden=True, + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) + ) + cls.ban_inactive = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='ban', + reason='James is an ass, and we won\'t be working with him again.', + active=False + ) + + def test_list_all(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(len(infractions), 2) + self.assertEqual(infractions[0]['id'], self.ban_hidden.id) + self.assertEqual(infractions[1]['id'], self.ban_inactive.id) + + def test_filter_search(self): + url = reverse('bot:infraction-list', host='api') + pattern = quote(r'^James(\s\w+){3},') + response = self.client.get(f'{url}?search={pattern}') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(len(infractions), 1) + self.assertEqual(infractions[0]['id'], self.ban_inactive.id) + + def test_filter_field(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?type=ban&hidden=true') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(len(infractions), 1) + self.assertEqual(infractions[0]['id'], self.ban_hidden.id) + + def test_returns_empty_for_no_match(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?type=ban&search=poop') + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + + def test_ignores_bad_filters(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?type=ban&hidden=maybe&foo=bar') + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 2) + + def test_retrieve_single_from_id(self): + url = reverse('bot:infraction-detail', args=(self.ban_inactive.id,), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], self.ban_inactive.id) + + def test_retrieve_returns_404_for_absent_id(self): + url = reverse('bot:infraction-detail', args=(1337,), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 404) + + def test_partial_update(self): + url = reverse('bot:infraction-detail', args=(self.ban_hidden.id,), host='api') + data = { + 'expires_at': '4143-02-15T21:04:31+00:00', + 'active': False, + 'reason': 'durka derr' + } + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 200) + infraction = Infraction.objects.get(id=self.ban_hidden.id) + + # These fields were updated. + self.assertEqual(infraction.expires_at.isoformat(), data['expires_at']) + self.assertEqual(infraction.active, data['active']) + self.assertEqual(infraction.reason, data['reason']) + + # These fields are still the same. + self.assertEqual(infraction.id, self.ban_hidden.id) + self.assertEqual(infraction.inserted_at, self.ban_hidden.inserted_at) + self.assertEqual(infraction.user.id, self.ban_hidden.user.id) + self.assertEqual(infraction.actor.id, self.ban_hidden.actor.id) + self.assertEqual(infraction.type, self.ban_hidden.type) + self.assertEqual(infraction.hidden, self.ban_hidden.hidden) + + def test_partial_update_returns_400_for_frozen_field(self): + url = reverse('bot:infraction-detail', args=(self.ban_hidden.id,), host='api') + data = {'user': 6} + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'user': ['This field cannot be updated.'] + }) + + +class CreationTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.user = User.objects.create( + id=5, + name='james', + discriminator=1, + avatar_hash=None + ) + + def test_accepts_valid_data(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'ban', + 'reason': 'He terk my jerb!', + 'hidden': True, + 'expires_at': '5018-11-20T15:52:00+00:00' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 201) + + infraction = Infraction.objects.get(id=response.json()['id']) + self.assertAlmostEqual( + infraction.inserted_at, + dt.now(timezone.utc), + delta=timedelta(seconds=2) + ) + self.assertEqual(infraction.expires_at.isoformat(), data['expires_at']) + self.assertEqual(infraction.user.id, data['user']) + self.assertEqual(infraction.actor.id, data['actor']) + self.assertEqual(infraction.type, data['type']) + self.assertEqual(infraction.reason, data['reason']) + self.assertEqual(infraction.hidden, data['hidden']) + self.assertEqual(infraction.active, True) + + def test_returns_400_for_missing_user(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'actor': self.user.id, + 'type': 'kick' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'user': ['This field is required.'] + }) + + def test_returns_400_for_bad_user(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': 1337, + 'actor': self.user.id, + 'type': 'kick' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'user': ['Invalid pk "1337" - object does not exist.'] + }) + + def test_returns_400_for_bad_type(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'hug' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'type': ['"hug" is not a valid choice.'] + }) + + def test_returns_400_for_bad_expired_at_format(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'ban', + 'expires_at': '20/11/5018 15:52:00' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'expires_at': [ + 'Datetime has wrong format. Use one of these formats instead: ' + 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].' + ] + }) + + def test_returns_400_for_expiring_non_expirable_type(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'kick', + 'expires_at': '5018-11-20T15:52:00+00:00' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'expires_at': [f'{data["type"]} infractions cannot expire.'] + }) + + def test_returns_400_for_hidden_non_hideable_type(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'superstar', + 'hidden': True + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'hidden': [f'{data["type"]} infractions cannot be hidden.'] + }) + + +class ExpandedTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.user = User.objects.create( + id=5, + name='james', + discriminator=1, + avatar_hash=None + ) + cls.kick = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='kick' + ) + cls.warning = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='warning' + ) + + def check_expanded_fields(self, infraction): + for key in ('user', 'actor'): + obj = infraction[key] + for field in ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild'): + self.assertTrue(field in obj, msg=f'field "{field}" missing from {key}') + + def test_list_expanded(self): + url = reverse('bot:infraction-list-expanded', host='api') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + response_data = response.json() + self.assertEqual(len(response_data), 2) + + for infraction in response_data: + self.check_expanded_fields(infraction) + + def test_create_expanded(self): + url = reverse('bot:infraction-list-expanded', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'warning' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 201) + + self.assertEqual(len(Infraction.objects.all()), 3) + self.check_expanded_fields(response.json()) + + def test_retrieve_expanded(self): + url = reverse('bot:infraction-detail-expanded', args=(self.warning.id,), host='api') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + infraction = response.json() + self.assertEqual(infraction['id'], self.warning.id) + self.check_expanded_fields(infraction) + + def test_partial_update_expanded(self): + url = reverse('bot:infraction-detail-expanded', args=(self.kick.id,), host='api') + data = {'active': False} + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 200) + + infraction = Infraction.objects.get(id=self.kick.id) + self.assertEqual(infraction.active, data['active']) + self.check_expanded_fields(response.json()) diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py new file mode 100644 index 00000000..43d1eb41 --- /dev/null +++ b/pydis_site/apps/api/tests/test_models.py @@ -0,0 +1,113 @@ +from datetime import datetime as dt, timezone + +from django.test import SimpleTestCase + +from ..models import ( + BotSetting, DeletedMessage, + DocumentationLink, Infraction, + Message, MessageDeletionContext, + ModelReprMixin, OffTopicChannelName, + Reminder, Role, + SnakeFact, SnakeIdiom, + SnakeName, SpecialSnake, + Tag, User +) + + +class SimpleClass(ModelReprMixin): + def __init__(self, is_what): + self.the_cake = is_what + + +class ReprMixinTests(SimpleTestCase): + def setUp(self): + self.klass = SimpleClass('is a lie') + + def test_shows_attributes(self): + expected = "" + self.assertEqual(repr(self.klass), expected) + + +class StringDunderMethodTests(SimpleTestCase): + def setUp(self): + self.objects = ( + DeletedMessage( + id=45, + author=User( + id=444, name='bill', + discriminator=5, avatar_hash=None + ), + channel_id=666, + content="wooey", + deletion_context=MessageDeletionContext( + actor=User( + id=5555, name='shawn', + discriminator=555, avatar_hash=None + ), + creation=dt.utcnow() + ), + embeds=[] + ), + DocumentationLink( + 'test', 'http://example.com', 'http://example.com' + ), + OffTopicChannelName(name='bob-the-builders-playground'), + SnakeFact(fact='snakes are cute'), + SnakeIdiom(idiom='snake snacks'), + SnakeName(name='python', scientific='3'), + SpecialSnake( + name='Pythagoras Pythonista', + info='The only python snake that is born a triangle' + ), + Role( + id=5, name='test role', + colour=0x5, permissions=0 + ), + Message( + id=45, + author=User( + id=444, name='bill', + discriminator=5, avatar_hash=None + ), + channel_id=666, + content="wooey", + embeds=[] + ), + MessageDeletionContext( + actor=User( + id=5555, name='shawn', + discriminator=555, avatar_hash=None + ), + creation=dt.utcnow() + ), + Tag( + title='bob', + embed={'content': "the builder"} + ), + User( + id=5, name='bob', + discriminator=1, avatar_hash=None + ), + Infraction( + user_id=5, actor_id=5, + type='kick', reason='He terk my jerb!' + ), + Infraction( + user_id=5, actor_id=5, hidden=True, + type='kick', reason='He terk my jerb!', + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) + ), + Reminder( + author=User( + id=452, name='billy', + discriminator=5, avatar_hash=None + ), + channel_id=555, + content="oh no", + expiration=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) + ) + ) + + def test_returns_string(self): + for instance in self.objects: + self.assertIsInstance(str(instance), str) diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py new file mode 100644 index 00000000..1f03d1b0 --- /dev/null +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -0,0 +1,41 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import Nomination, User + + +class NominationTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.author = User.objects.create( + id=5152, + name='Ro Bert', + discriminator=256, + avatar_hash=None + ) + cls.user = cls.author + + cls.nomination = Nomination.objects.create( + author=cls.author, + reason="he's good", + user=cls.author + ) + + def test_returns_400_on_attempt_to_update_frozen_field(self): + url = reverse('bot:nomination-detail', args=(self.user.id,), host='api') + response = self.client.put( + url, + data={'inserted_at': 'something bad'} + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'inserted_at': ['This field cannot be updated.'] + }) + + def test_returns_200_on_successful_update(self): + url = reverse('bot:nomination-detail', args=(self.user.id,), host='api') + response = self.client.patch( + url, + data={'reason': 'there are many like it, but this test is mine'} + ) + self.assertEqual(response.status_code, 200) diff --git a/pydis_site/apps/api/tests/test_off_topic_channel_names.py b/pydis_site/apps/api/tests/test_off_topic_channel_names.py new file mode 100644 index 00000000..60af1f62 --- /dev/null +++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py @@ -0,0 +1,152 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import OffTopicChannelName + + +class UnauthenticatedTests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_cannot_read_off_topic_channel_name_list(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_cannot_read_off_topic_channel_name_list_with_random_item_param(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(f'{url}?random_items=no') + + self.assertEqual(response.status_code, 401) + + +class EmptyDatabaseTests(APISubdomainTestCase): + def test_returns_empty_object(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_returns_empty_list_with_get_all_param(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(f'{url}?random_items=5') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_returns_400_for_bad_random_items_param(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(f'{url}?random_items=totally-a-valid-integer') + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'random_items': ["Must be a valid integer."] + }) + + def test_returns_400_for_negative_random_items_param(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(f'{url}?random_items=-5') + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'random_items': ["Must be a positive integer."] + }) + + +class ListTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand') + cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') + + def test_returns_name_in_list(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + [ + self.test_name.name, + self.test_name_2.name + ] + ) + + def test_returns_single_item_with_random_items_param_set_to_1(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(f'{url}?random_items=1') + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + + +class CreationTests(APISubdomainTestCase): + def setUp(self): + super().setUp() + + url = reverse('bot:offtopicchannelname-list', host='api') + self.name = "lemonade-shop" + response = self.client.post(f'{url}?name={self.name}') + self.assertEqual(response.status_code, 201) + + def test_name_in_full_list(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [self.name]) + + def test_returns_400_for_missing_name_param(self): + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.post(url) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'name': ["This query parameter is required."] + }) + + def test_returns_400_for_bad_name_param(self): + url = reverse('bot:offtopicchannelname-list', host='api') + invalid_names = ( + 'space between words', + 'UPPERCASE', + '$$$$$$$$' + ) + + for name in invalid_names: + response = self.client.post(f'{url}?name={name}') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'name': ["Enter a valid value."] + }) + + +class DeletionTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand') + cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') + + def test_deleting_unknown_name_returns_404(self): + url = reverse('bot:offtopicchannelname-detail', args=('unknown-name',), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 404) + + def test_deleting_known_name_returns_204(self): + url = reverse('bot:offtopicchannelname-detail', args=(self.test_name.name,), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 204) + + def test_name_gets_deleted(self): + url = reverse('bot:offtopicchannelname-detail', args=(self.test_name_2.name,), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 204) + + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(url) + self.assertNotIn(self.test_name_2.name, response.json()) diff --git a/pydis_site/apps/api/tests/test_rules.py b/pydis_site/apps/api/tests/test_rules.py new file mode 100644 index 00000000..c94f89cc --- /dev/null +++ b/pydis_site/apps/api/tests/test_rules.py @@ -0,0 +1,35 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..views import RulesView + + +class RuleAPITests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_can_access_rules_view(self): + url = reverse('rules', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json(), list) + + def test_link_format_query_param_produces_different_results(self): + url = reverse('rules', host='api') + markdown_links_response = self.client.get(url + '?link_format=md') + html_links_response = self.client.get(url + '?link_format=html') + self.assertNotEqual( + markdown_links_response.json(), + html_links_response.json() + ) + + def test_format_link_raises_value_error_for_invalid_target(self): + with self.assertRaises(ValueError): + RulesView._format_link("a", "b", "c") + + def test_get_returns_400_for_wrong_link_format(self): + url = reverse('rules', host='api') + response = self.client.get(url + '?link_format=unknown') + self.assertEqual(response.status_code, 400) diff --git a/pydis_site/apps/api/tests/test_snake_names.py b/pydis_site/apps/api/tests/test_snake_names.py new file mode 100644 index 00000000..41dfae63 --- /dev/null +++ b/pydis_site/apps/api/tests/test_snake_names.py @@ -0,0 +1,67 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import SnakeName + + +class StatusTests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_cannot_read_snake_name_list(self): + url = reverse('bot:snakename-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_cannot_read_snake_names_with_get_all_param(self): + url = reverse('bot:snakename-list', host='api') + response = self.client.get(f'{url}?get_all=True') + + self.assertEqual(response.status_code, 401) + + +class EmptyDatabaseSnakeNameTests(APISubdomainTestCase): + def test_endpoint_returns_empty_object(self): + url = reverse('bot:snakename-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {}) + + def test_endpoint_returns_empty_list_with_get_all_param(self): + url = reverse('bot:snakename-list', host='api') + response = self.client.get(f'{url}?get_all=True') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + +class SnakeNameListTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.snake_python = SnakeName.objects.create(name='Python', scientific='Totally.') + + def test_endpoint_returns_all_snakes_with_get_all_param(self): + url = reverse('bot:snakename-list', host='api') + response = self.client.get(f'{url}?get_all=True') + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + [ + { + 'name': self.snake_python.name, + 'scientific': self.snake_python.scientific + } + ] + ) + + def test_endpoint_returns_single_snake_without_get_all_param(self): + url = reverse('bot:snakename-list', host='api') + response = self.client.get(url) + self.assertEqual(response.json(), { + 'name': self.snake_python.name, + 'scientific': self.snake_python.scientific + }) diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py new file mode 100644 index 00000000..90bc3d30 --- /dev/null +++ b/pydis_site/apps/api/tests/test_users.py @@ -0,0 +1,121 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import Role, User + + +class UnauthedUserAPITests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_detail_lookup_returns_401(self): + url = reverse('bot:user-detail', args=('whatever',), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_list_returns_401(self): + url = reverse('bot:user-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_create_returns_401(self): + url = reverse('bot:user-list', host='api') + response = self.client.post(url, data={'hi': 'there'}) + + self.assertEqual(response.status_code, 401) + + def test_delete_returns_401(self): + url = reverse('bot:user-detail', args=('whatever',), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 401) + + +class CreationTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.role = Role.objects.create( + id=5, + name="Test role pls ignore", + colour=2, + permissions=0b01010010101 + ) + + def test_accepts_valid_data(self): + url = reverse('bot:user-list', host='api') + data = { + 'id': 42, + 'avatar_hash': "validavatarhashiswear", + 'name': "Test", + 'discriminator': 42, + 'roles': [ + self.role.id + ], + 'in_guild': True + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json(), data) + + user = User.objects.get(id=42) + self.assertEqual(user.avatar_hash, data['avatar_hash']) + self.assertEqual(user.name, data['name']) + self.assertEqual(user.discriminator, data['discriminator']) + self.assertEqual(user.in_guild, data['in_guild']) + + def test_supports_multi_creation(self): + url = reverse('bot:user-list', host='api') + data = [ + { + 'id': 5, + 'avatar_hash': "hahayes", + 'name': "test man", + 'discriminator': 42, + 'roles': [ + self.role.id + ], + 'in_guild': True + }, + { + 'id': 8, + 'avatar_hash': "maybenot", + 'name': "another test man", + 'discriminator': 555, + 'roles': [], + 'in_guild': False + } + ] + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json(), data) + + def test_returns_400_for_unknown_role_id(self): + url = reverse('bot:user-list', host='api') + data = { + 'id': 5, + 'avatar_hash': "hahayes", + 'name': "test man", + 'discriminator': 42, + 'roles': [ + 190810291 + ] + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + + def test_returns_400_for_bad_data(self): + url = reverse('bot:user-list', host='api') + data = { + 'id': True, + 'avatar_hash': 1902831, + 'discriminator': "totally!" + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py new file mode 100644 index 00000000..d2c0a136 --- /dev/null +++ b/pydis_site/apps/api/tests/test_validators.py @@ -0,0 +1,213 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from ..validators import ( + validate_bot_setting_name, + validate_tag_embed +) + + +REQUIRED_KEYS = ( + 'content', 'fields', 'image', 'title', 'video' +) + + +class BotSettingValidatorTests(TestCase): + def test_accepts_valid_names(self): + validate_bot_setting_name('defcon') + + def test_rejects_bad_names(self): + with self.assertRaises(ValidationError): + validate_bot_setting_name('bad name') + +class TagEmbedValidatorTests(TestCase): + def test_rejects_non_mapping(self): + with self.assertRaises(ValidationError): + validate_tag_embed('non-empty non-mapping') + + def test_rejects_missing_required_keys(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'unknown': "key" + }) + + def test_rejects_one_correct_one_incorrect(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'provider': "??", + 'title': "" + }) + + def test_rejects_empty_required_key(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': '' + }) + + def test_rejects_list_as_embed(self): + with self.assertRaises(ValidationError): + validate_tag_embed([]) + + def test_rejects_required_keys_and_unknown_keys(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "the duck walked up to the lemonade stand", + 'and': "he said to the man running the stand" + }) + + def test_rejects_too_long_title(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': 'a' * 257 + }) + + def test_rejects_too_many_fields(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': [{} for _ in range(26)] + }) + + def test_rejects_too_long_description(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'description': 'd' * 2049 + }) + + def test_allows_valid_embed(self): + validate_tag_embed({ + 'title': "My embed", + 'description': "look at my embed, my embed is amazing" + }) + + def test_allows_unvalidated_fields(self): + validate_tag_embed({ + 'title': "My embed", + 'provider': "what am I??" + }) + + def test_rejects_fields_as_list_of_non_mappings(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': ['abc'] + }) + + def test_rejects_fields_with_unknown_fields(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': [ + { + 'what': "is this field" + } + ] + }) + + def test_rejects_fields_with_too_long_name(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': [ + { + 'name': "a" * 257 + } + ] + }) + + def test_rejects_one_correct_one_incorrect_field(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': [ + { + 'name': "Totally valid", + 'value': "LOOK AT ME" + }, + { + 'oh': "what is this key?" + } + ] + }) + + def test_allows_valid_fields(self): + validate_tag_embed({ + 'fields': [ + { + 'name': "valid", + 'value': "field" + } + ] + }) + + def test_rejects_footer_as_non_mapping(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'footer': [] + }) + + def test_rejects_footer_with_unknown_fields(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'footer': { + 'duck': "quack" + } + }) + + def test_rejects_footer_with_empty_text(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'footer': { + 'text': "" + } + }) + + def test_allows_footer_with_proper_values(self): + validate_tag_embed({ + 'title': "whatever", + 'footer': { + 'text': "django good" + } + }) + + def test_rejects_author_as_non_mapping(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'author': [] + }) + + def test_rejects_author_with_unknown_field(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'author': { + 'field': "that is unknown" + } + }) + + def test_rejects_author_with_empty_name(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'author': { + 'name': "" + } + }) + + def test_rejects_author_with_one_correct_one_incorrect(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'author': { + # Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour + 'url': "bobswebsite.com", + 'name': "" + } + }) + + def test_allows_author_with_proper_values(self): + validate_tag_embed({ + 'title': "whatever", + 'author': { + 'name': "Bob" + } + }) diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py new file mode 100644 index 00000000..6c89a52e --- /dev/null +++ b/pydis_site/apps/api/urls.py @@ -0,0 +1,86 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import HealthcheckView, RulesView +from .viewsets import ( + BotSettingViewSet, DeletedMessageViewSet, + DocumentationLinkViewSet, InfractionViewSet, + NominationViewSet, OffTopicChannelNameViewSet, + ReminderViewSet, RoleViewSet, + SnakeFactViewSet, SnakeIdiomViewSet, + SnakeNameViewSet, SpecialSnakeViewSet, + TagViewSet, UserViewSet +) + + +# http://www.django-rest-framework.org/api-guide/routers/#defaultrouter +bot_router = DefaultRouter(trailing_slash=False) +bot_router.register( + 'bot-settings', + BotSettingViewSet +) +bot_router.register( + 'deleted-messages', + DeletedMessageViewSet +) +bot_router.register( + 'documentation-links', + DocumentationLinkViewSet +) +bot_router.register( + 'infractions', + InfractionViewSet +) +bot_router.register( + 'nominations', + NominationViewSet +) +bot_router.register( + 'off-topic-channel-names', + OffTopicChannelNameViewSet, + base_name='offtopicchannelname' +) +bot_router.register( + 'reminders', + ReminderViewSet +) +bot_router.register( + 'roles', + RoleViewSet +) +bot_router.register( + 'snake-facts', + SnakeFactViewSet +) +bot_router.register( + 'snake-idioms', + SnakeIdiomViewSet +) +bot_router.register( + 'snake-names', + SnakeNameViewSet, + base_name='snakename' +) +bot_router.register( + 'special-snakes', + SpecialSnakeViewSet +) +bot_router.register( + 'tags', + TagViewSet +) +bot_router.register( + 'users', + UserViewSet +) + +app_name = 'api' +urlpatterns = ( + # Build URLs using something like... + # + # from django_hosts.resolvers import reverse + # snake_name_endpoint = reverse('bot:snakename-list', host='api') # `bot/` endpoints + path('bot/', include((bot_router.urls, 'api'), namespace='bot')), + path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), + path('rules', RulesView.as_view(), name='rules') +) diff --git a/pydis_site/apps/api/validators.py b/pydis_site/apps/api/validators.py new file mode 100644 index 00000000..ac2fb739 --- /dev/null +++ b/pydis_site/apps/api/validators.py @@ -0,0 +1,164 @@ +from collections.abc import Mapping + +from django.core.exceptions import ValidationError +from django.core.validators import MaxLengthValidator, MinLengthValidator + + +def validate_tag_embed_fields(fields): + field_validators = { + 'name': (MaxLengthValidator(limit_value=256),), + 'value': (MaxLengthValidator(limit_value=1024),) + } + + for field in fields: + if not isinstance(field, Mapping): + raise ValidationError("Embed fields must be a mapping.") + + for field_name, value in field.items(): + if field_name not in field_validators: + raise ValidationError(f"Unknown embed field field: {field_name!r}.") + + for validator in field_validators[field_name]: + validator(value) + + +def validate_tag_embed_footer(footer): + field_validators = { + 'text': ( + MinLengthValidator( + limit_value=1, + message="Footer text must not be empty." + ), + MaxLengthValidator(limit_value=2048) + ), + 'icon_url': (), + 'proxy_icon_url': () + } + + if not isinstance(footer, Mapping): + raise ValidationError("Embed footer must be a mapping.") + + for field_name, value in footer.items(): + if field_name not in field_validators: + raise ValidationError(f"Unknown embed footer field: {field_name!r}.") + + for validator in field_validators[field_name]: + validator(value) + + +def validate_tag_embed_author(author): + field_validators = { + 'name': ( + MinLengthValidator( + limit_value=1, + message="Embed author name must not be empty." + ), + MaxLengthValidator(limit_value=256) + ), + 'url': (), + 'icon_url': (), + 'proxy_icon_url': () + } + + if not isinstance(author, Mapping): + raise ValidationError("Embed author must be a mapping.") + + for field_name, value in author.items(): + if field_name not in field_validators: + raise ValidationError(f"Unknown embed author field: {field_name!r}.") + + for validator in field_validators[field_name]: + validator(value) + + +def validate_tag_embed(embed): + """ + Validate a JSON document containing an embed as possible to send + on Discord. This attempts to rebuild the validation used by Discord + as well as possible by checking for various embed limits so we can + ensure that any embed we store here will also be accepted as a + valid embed by the Discord API. + + Using this directly is possible, although not intended - you usually + stick this onto the `validators` keyword argument of model fields. + + Example: + + >>> from django.contrib.postgres import fields as pgfields + >>> from django.db import models + >>> from pydis_site.apps.api import validate_tag_embed + >>> class MyMessage(models.Model): + ... embed = pgfields.JSONField( + ... validators=( + ... validate_tag_embed, + ... ) + ... ) + ... # ... + ... + + Args: + embed (Dict[str, Union[str, List[dict], dict]]): + A dictionary describing the contents of this embed. + See the official documentation for a full reference + of accepted keys by this dictionary: + https://discordapp.com/developers/docs/resources/channel#embed-object + + Raises: + ValidationError: + In case the given embed is deemed invalid, a `ValidationError` + is raised which in turn will allow Django to display errors + as appropriate. + """ + + all_keys = { + 'title', 'type', 'description', 'url', 'timestamp', + 'color', 'footer', 'image', 'thumbnail', 'video', + 'provider', 'author', 'fields' + } + one_required_of = {'description', 'fields', 'image', 'title', 'video'} + field_validators = { + 'title': ( + MinLengthValidator( + limit_value=1, + message="Embed title must not be empty." + ), + MaxLengthValidator(limit_value=256) + ), + 'description': (MaxLengthValidator(limit_value=2048),), + 'fields': ( + MaxLengthValidator(limit_value=25), + validate_tag_embed_fields + ), + 'footer': (validate_tag_embed_footer,), + 'author': (validate_tag_embed_author,) + } + + if not embed: + raise ValidationError("Tag embed must not be empty.") + + elif not isinstance(embed, Mapping): + raise ValidationError("Tag embed must be a mapping.") + + elif not any(field in embed for field in one_required_of): + raise ValidationError(f"Tag embed must contain one of the fields {one_required_of}.") + + for required_key in one_required_of: + if required_key in embed and not embed[required_key]: + raise ValidationError(f"Key {required_key!r} must not be empty.") + + for field_name, value in embed.items(): + if field_name not in all_keys: + raise ValidationError(f"Unknown field name: {field_name!r}") + + if field_name in field_validators: + for validator in field_validators[field_name]: + validator(value) + + +def validate_bot_setting_name(name): + KNOWN_SETTINGS = ( + 'defcon', + ) + + if name not in KNOWN_SETTINGS: + raise ValidationError(f"`{name}` is not a known setting name.") diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py new file mode 100644 index 00000000..3160e8f7 --- /dev/null +++ b/pydis_site/apps/api/views.py @@ -0,0 +1,161 @@ +from rest_framework.exceptions import ParseError +from rest_framework.response import Response +from rest_framework.views import APIView + + +class HealthcheckView(APIView): + """ + Provides a simple view to check that the website is alive and well. + + ## Routes + ### GET /healthcheck + Returns a simple JSON document showcasing whether the system is working: + + >>> { + ... 'status': 'ok' + ... } + + Seems to be. + + ## Authentication + Does not require any authentication nor permissions. + """ + + authentication_classes = () + permission_classes = () + + def get(self, request, format=None): # noqa + return Response({'status': 'ok'}) + + +class RulesView(APIView): + """ + Return a list of the server's rules. + + ## Routes + ### GET /rules + Returns a JSON array containing the server's rules: + + >>> [ + ... "Eat candy.", + ... "Wake up at 4 AM.", + ... "Take your medicine." + ... ] + + Since some of the the rules require links, this view + gives you the option to return rules in either Markdown + or HTML format by specifying the `link_format` query parameter + as either `md` or `html`. Specifying a different value than + `md` or `html` will return 400. + + ## Authentication + Does not require any authentication nor permissions. + """ + + authentication_classes = () + permission_classes = () + + @staticmethod + def _format_link(description, link, target): + """ + Build the markup necessary to render `link` with `description` + as its description in the given `target` language. + + Arguments: + description (str): + A textual description of the string. Represents the content + between the `` tags in HTML, or the content between the + array brackets in Markdown. + + link (str): + The resulting link that a user should be redirected to + upon clicking the generated element. + + target (str): + One of `{'md', 'html'}`, denoting the target format that the + link should be rendered in. + + Returns: + str: + The link, rendered appropriately for the given `target` format + using `description` as its textual description. + + Raises: + ValueError: + If `target` is not `'md'` or `'html'`. + """ + + if target == 'html': + return f'{description}' + elif target == 'md': + return f'[{description}]({link})' + else: + raise ValueError( + f"Can only template links to `html` or `md`, got `{target}`" + ) + + # `format` here is the result format, we have a link format here instead. + def get(self, request, format=None): # noqa + link_format = request.query_params.get('link_format', 'md') + if link_format not in ('html', 'md'): + raise ParseError( + f"`format` must be `html` or `md`, got `{format}`." + ) + + discord_community_guidelines_link = self._format_link( + 'Discord Community Guidelines', + 'https://discordapp.com/guidelines', + link_format + ) + channels_page_link = self._format_link( + 'channels page', + 'https://pythondiscord.com/about/channels', + link_format + ) + google_translate_link = self._format_link( + 'Google Translate', + 'https://translate.google.com/', + link_format + ) + + return Response([ + "Be polite, and do not spam.", + f"Follow the {discord_community_guidelines_link}.", + ( + "Don't intentionally make other people uncomfortable - if " + "someone asks you to stop discussing something, you should stop." + ), + ( + "Be patient both with users asking " + "questions, and the users answering them." + ), + ( + "We will not help you with anything that might break a law or the " + "terms of service of any other community, pydis_site, service, or " + "otherwise - No piracy, brute-forcing, captcha circumvention, " + "sneaker bots, or anything else of that nature." + ), + ( + "Listen to and respect the staff members - we're " + "here to help, but we're all human beings." + ), + ( + "All discussion should be kept within the relevant " + "channels for the subject - See the " + f"{channels_page_link} for more information." + ), + ( + "This is an English-speaking server, so please speak English " + f"to the best of your ability - {google_translate_link} " + "should be fine if you're not sure." + ), + ( + "Keep all discussions safe for work - No gore, nudity, sexual " + "soliciting, references to suicide, or anything else of that nature" + ), + ( + "We do not allow advertisements for communities (including " + "other Discord servers) or commercial projects - Contact " + "us directly if you want to discuss a partnership!" + ) + ]) diff --git a/pydis_site/apps/api/viewsets.py b/pydis_site/apps/api/viewsets.py new file mode 100644 index 00000000..0471f79d --- /dev/null +++ b/pydis_site/apps/api/viewsets.py @@ -0,0 +1,890 @@ +from django.shortcuts import get_object_or_404 +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.decorators import action +from rest_framework.exceptions import ParseError, ValidationError +from rest_framework.filters import SearchFilter +from rest_framework.mixins import ( + CreateModelMixin, DestroyModelMixin, + ListModelMixin, RetrieveModelMixin, + UpdateModelMixin +) +from rest_framework.response import Response +from rest_framework.status import HTTP_201_CREATED +from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet +from rest_framework_bulk import BulkCreateModelMixin + +from .models import ( + BotSetting, DocumentationLink, + Infraction, MessageDeletionContext, + Nomination, OffTopicChannelName, + Reminder, Role, + SnakeFact, SnakeIdiom, + SnakeName, SpecialSnake, + Tag, User +) +from .serializers import ( + BotSettingSerializer, DocumentationLinkSerializer, + ExpandedInfractionSerializer, InfractionSerializer, + MessageDeletionContextSerializer, NominationSerializer, + OffTopicChannelNameSerializer, ReminderSerializer, + RoleSerializer, SnakeFactSerializer, + SnakeIdiomSerializer, SnakeNameSerializer, + SpecialSnakeSerializer, TagSerializer, + UserSerializer +) + + +class BotSettingViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet): + """ + View providing update operations on bot setting routes. + """ + + serializer_class = BotSettingSerializer + queryset = BotSetting.objects.all() + + +class DeletedMessageViewSet(CreateModelMixin, GenericViewSet): + """ + View providing support for posting bulk deletion logs generated by the bot. + + ## Routes + ### POST /bot/deleted-messages + Post messages from bulk deletion logs. + + #### Body schema + >>> { + ... # The member ID of the original actor, if applicable. + ... # If a member ID is given, it must be present on the pydis_site. + ... 'actor': Optional[int] + ... 'creation': datetime, + ... 'messages': [ + ... { + ... 'id': int, + ... 'author': int, + ... 'channel_id': int, + ... 'content': str, + ... 'embeds': [ + ... # Discord embed objects + ... ] + ... } + ... ] + ... } + + #### Status codes + - 204: returned on success + """ + + queryset = MessageDeletionContext.objects.all() + serializer_class = MessageDeletionContextSerializer + + +class DocumentationLinkViewSet( + CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet +): + """ + View providing management of documentation links used in the bot's `Doc` cog. + + ## Routes + ### GET /bot/documentation-links + Retrieve all currently stored entries from the database. + + #### Response format + >>> [ + ... { + ... 'package': 'flask', + ... 'base_url': 'https://flask.pocoo.org/docs/dev', + ... 'inventory_url': 'https://flask.pocoo.org/docs/objects.inv' + ... }, + ... # ... + ... ] + + #### Status codes + - 200: returned on success + + ### GET /bot/documentation-links/ + Look up the documentation object for the given `package`. + + #### Response format + >>> { + ... 'package': 'flask', + ... 'base_url': 'https://flask.pocoo.org/docs/dev', + ... 'inventory_url': 'https://flask.pocoo.org/docs/objects.inv' + ... } + + #### Status codes + - 200: returned on success + - 404: if no entry for the given `package` exists + + ### POST /bot/documentation-links + Create a new documentation link object. + + #### Body schema + >>> { + ... 'package': str, + ... 'base_url': URL, + ... 'inventory_url': URL + ... } + + #### Status codes + - 201: returned on success + - 400: if the request body has invalid fields, see the response for details + + ### DELETE /bot/documentation-links/ + Delete the entry for the given `package`. + + #### Status codes + - 204: returned on success + - 404: if the given `package` could not be found + """ + + queryset = DocumentationLink.objects.all() + serializer_class = DocumentationLinkSerializer + lookup_field = 'package' + + +class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet): + """ + View providing CRUD operations on infractions for Discord users. + + ## Routes + ### GET /bot/infractions + Retrieve all infractions. + May be filtered by the query parameters. + + #### Query parameters + - **active** `bool`: whether the infraction is still active + - **actor** `int`: snowflake of the user which applied the infraction + - **hidden** `bool`: whether the infraction is a shadow infraction + - **search** `str`: regular expression applied to the infraction's reason + - **type** `str`: the type of the infraction + - **user** `int`: snowflake of the user to which the infraction was applied + + Invalid query parameters are ignored. + + #### Response format + >>> [ + ... { + ... 'id': 5, + ... 'inserted_at': '2018-11-22T07:24:06.132307Z', + ... 'expires_at': '5018-11-20T15:52:00Z', + ... 'active': False, + ... 'user': 172395097705414656, + ... 'actor': 125435062127820800, + ... 'type': 'ban', + ... 'reason': 'He terk my jerb!', + ... 'hidden': True + ... } + ... ] + + #### Status codes + - 200: returned on success + + ### GET /bot/infractions/ + Retrieve a single infraction by ID. + + #### Response format + See `GET /bot/infractions`. + + #### Status codes + - 200: returned on success + - 404: if an infraction with the given `id` could not be found + + ### POST /bot/infractions + Create a new infraction and return the created infraction. + Only `actor`, `type`, and `user` are required. + The `actor` and `user` must be users known by the pydis_site. + + #### Request body + >>> { + ... 'active': False, + ... 'actor': 125435062127820800, + ... 'expires_at': '5018-11-20T15:52:00+00:00', + ... 'hidden': True, + ... 'type': 'ban', + ... 'reason': 'He terk my jerb!', + ... 'user': 172395097705414656 + ... } + + #### Response format + See `GET /bot/infractions`. + + #### Status codes + - 201: returned on success + - 400: if a given user is unknown or a field in the request body is invalid + + ### PATCH /bot/infractions/ + Update the infraction with the given `id` and return the updated infraction. + Only `active`, `reason`, and `expires_at` may be updated. + + #### Request body + >>> { + ... 'active': True, + ... 'expires_at': '4143-02-15T21:04:31+00:00', + ... 'reason': 'durka derr' + ... } + + #### Response format + See `GET /bot/infractions`. + + #### Status codes + - 200: returned on success + - 400: if a field in the request body is invalid or disallowed + - 404: if an infraction with the given `id` could not be found + + ### Expanded routes + All routes support expansion of `user` and `actor` in responses. To use an expanded route, + append `/expanded` to the end of the route e.g. `GET /bot/infractions/expanded`. + + #### Response format + See `GET /bot/users/` for the expanded formats of `user` and `actor`. Responses + are otherwise identical to their non-expanded counterparts. + """ + + serializer_class = InfractionSerializer + queryset = Infraction.objects.all() + filter_backends = (DjangoFilterBackend, SearchFilter) + filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type') + search_fields = ('$reason',) + frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') + + def partial_update(self, request, *args, **kwargs): + for field in request.data: + if field in self.frozen_fields: + raise ValidationError({field: ['This field cannot be updated.']}) + + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + @action(url_path='expanded', detail=False) + def list_expanded(self, *args, **kwargs): + self.serializer_class = ExpandedInfractionSerializer + return self.list(*args, **kwargs) + + @list_expanded.mapping.post + def create_expanded(self, *args, **kwargs): + self.serializer_class = ExpandedInfractionSerializer + return self.create(*args, **kwargs) + + @action(url_path='expanded', url_name='detail-expanded', detail=True) + def retrieve_expanded(self, *args, **kwargs): + self.serializer_class = ExpandedInfractionSerializer + return self.retrieve(*args, **kwargs) + + @retrieve_expanded.mapping.patch + def partial_update_expanded(self, *args, **kwargs): + self.serializer_class = ExpandedInfractionSerializer + return self.partial_update(*args, **kwargs) + + +class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet): + """ + View of off-topic channel names used by the bot + to rotate our off-topic names on a daily basis. + + ## Routes + ### GET /bot/off-topic-channel-names + Return all known off-topic channel names from the database. + If the `random_items` query parameter is given, for example using... + $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?random_items=5 + ... then the API will return `5` random items from the database. + + #### Response format + Return a list of off-topic-channel names: + >>> [ + ... "lemons-lemonade-stand", + ... "bbq-with-bisk" + ... ] + + #### Status codes + - 200: returned on success + - 400: returned when `random_items` is not a positive integer + + ### POST /bot/off-topic-channel-names + Create a new off-topic-channel name in the database. + The name must be given as a query parameter, for example: + $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?name=lemons-lemonade-shop + + #### Status codes + - 201: returned on success + - 400: if the request body has invalid fields, see the response for details + + ### DELETE /bot/off-topic-channel-names/ + Delete the off-topic-channel name with the given `name`. + + #### Status codes + - 204: returned on success + - 404: returned when the given `name` was not found + + ## Authentication + Requires a API token. + """ + + lookup_field = 'name' + serializer_class = OffTopicChannelNameSerializer + + def get_object(self): + queryset = self.get_queryset() + name = self.kwargs[self.lookup_field] + return get_object_or_404(queryset, name=name) + + def get_queryset(self): + return OffTopicChannelName.objects.all() + + def create(self, request): + if 'name' in request.query_params: + create_data = {'name': request.query_params['name']} + serializer = OffTopicChannelNameSerializer(data=create_data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(create_data, status=HTTP_201_CREATED) + + else: + raise ParseError(detail={ + 'name': ["This query parameter is required."] + }) + + def list(self, request): # noqa + if 'random_items' in request.query_params: + param = request.query_params['random_items'] + try: + random_count = int(param) + except ValueError: + raise ParseError(detail={'random_items': ["Must be a valid integer."]}) + + if random_count <= 0: + raise ParseError(detail={ + 'random_items': ["Must be a positive integer."] + }) + + queryset = self.get_queryset().order_by('?')[:random_count] + serialized = self.serializer_class(queryset, many=True) + return Response(serialized.data) + + queryset = self.get_queryset() + serialized = self.serializer_class(queryset, many=True) + return Response(serialized.data) + + +class ReminderViewSet(CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet): + """ + View providing CRUD access to reminders. + + ## Routes + ### GET /bot/reminders + Returns all reminders in the database. + + #### Response format + >>> [ + ... { + ... 'active': True, + ... 'author': 1020103901030, + ... 'content': "Make dinner", + ... 'expiration': '5018-11-20T15:52:00Z' + ... 'id': 11 + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + + ### POST /bot/reminders + Create a new reminder. + + #### Request body + >>> { + ... 'author': int, + ... 'content': str, + ... 'expiration': str # ISO-formatted datetime + ... } + + #### Status codes + - 201: returned on success + - 400: if the body format is invalid + - 404: if no user with the given ID could be found + + ### DELETE /bot/reminders/ + Delete the reminder with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a reminder with the given `id` does not exist + + ## Authentication + Requires an API token. + """ + + serializer_class = ReminderSerializer + queryset = Reminder.objects.prefetch_related('author') + filter_backends = (DjangoFilterBackend, SearchFilter) + filter_fields = ('active', 'author__id') + + +class RoleViewSet(ModelViewSet): + """ + View providing CRUD access to the roles on our server, used + by the bot to keep a mirror of our server's roles on the pydis_site. + + ## Routes + ### GET /bot/roles + Returns all roles in the database. + + #### Response format + >>> [ + ... { + ... 'id': 267628507062992896, + ... 'name': "Admins", + ... 'colour': 1337, + ... 'permissions': 8 + ... } + ... ] + + #### Status codes + - 200: returned on success + + ### GET /bot/roles/ + Gets a single role by ID. + + #### Response format + >>> { + ... 'id': 267628507062992896, + ... 'name': "Admins", + ... 'colour': 1337, + ... 'permissions': 8 + ... } + + #### Status codes + - 200: returned on success + - 404: if a role with the given `snowflake` could not be found + + ### POST /bot/roles + Adds a single new role. + + #### Request body + >>> { + ... 'id': int, + ... 'name': str, + ... 'colour': int, + ... 'permissions': int, + ... } + + #### Status codes + - 201: returned on success + - 400: if the body format is invalid + + ### PUT /bot/roles/ + Update the role with the given `snowflake`. + All fields in the request body are required. + + #### Request body + >>> { + ... 'id': int, + ... 'name': str, + ... 'colour': int, + ... 'permissions': int + ... } + + #### Status codes + - 200: returned on success + - 400: if the request body was invalid + + ### PATCH /bot/roles/ + Update the role with the given `snowflake`. + All fields in the request body are required. + + >>> { + ... 'id': int, + ... 'name': str, + ... 'colour': int, + ... 'permissions': int + ... } + + ### DELETE /bot/roles/ + Deletes the role with the given `snowflake`. + + #### Status codes + - 204: returned on success + - 404: if a role with the given `snowflake` does not exist + """ + + queryset = Role.objects.all() + serializer_class = RoleSerializer + + +class SnakeFactViewSet(ListModelMixin, GenericViewSet): + """ + View providing snake facts created by the Pydis community in the first code jam. + + ## Routes + ### GET /bot/snake-facts + Returns snake facts from the database. + + #### Response format + >>> [ + ... {'fact': 'Snakes are dangerous'}, + ... {'fact': 'Except for Python, we all love it'} + ... ] + + #### Status codes + - 200: returned on success + + ## Authentication + Requires an API token. + """ + + serializer_class = SnakeFactSerializer + queryset = SnakeFact.objects.all() + + +class SnakeIdiomViewSet(ListModelMixin, GenericViewSet): + """ + View providing snake idioms for the snake cog. + + ## Routes + ### GET /bot/snake-idioms + Returns snake idioms from the database. + + #### Response format + >>> [ + ... {'idiom': 'Sneky snek'}, + ... {'idiom': 'Snooky Snake'} + ... ] + + #### Status codes + - 200: returned on success + + ## Authentication + Requires an API token + """ + + serializer_class = SnakeIdiomSerializer + queryset = SnakeIdiom.objects.all() + + +class SnakeNameViewSet(ViewSet): + """ + View providing snake names for the bot's snake cog from our first code jam's winners. + + ## Routes + ### GET /bot/snake-names + By default, return a single random snake name along with its name and scientific name. + If the `get_all` query parameter is given, for example using... + $ curl api.pythondiscord.local:8000/bot/snake-names?get_all=yes + ... then the API will return all snake names and scientific names in the database. + + #### Response format + Without `get_all` query parameter: + >>> { + ... 'name': "Python", + ... 'scientific': "Langus greatus" + ... } + + If the database is empty for whatever reason, this will return an empty dictionary. + + With `get_all` query parameter: + >>> [ + ... {'name': "Python 3", 'scientific': "Langus greatus"}, + ... {'name': "Python 2", 'scientific': "Langus decentus"} + ... ] + + #### Status codes + - 200: returned on success + + ## Authentication + Requires a API token. + """ + + serializer_class = SnakeNameSerializer + + def get_queryset(self): + return SnakeName.objects.all() + + def list(self, request): # noqa + if request.query_params.get('get_all'): + queryset = self.get_queryset() + serialized = self.serializer_class(queryset, many=True) + return Response(serialized.data) + + single_snake = SnakeName.objects.order_by('?').first() + if single_snake is not None: + body = { + 'name': single_snake.name, + 'scientific': single_snake.scientific + } + + return Response(body) + + return Response({}) + + +class SpecialSnakeViewSet(ListModelMixin, GenericViewSet): + """ + View providing special snake names for our bot's snake cog. + + ## Routes + ### GET /bot/special-snakes + Returns a list of special snake names. + + #### Response Format + >>> [ + ... { + ... 'name': 'Snakky sneakatus', + ... 'info': 'Scary snek', + ... 'image': 'https://discordapp.com/assets/53ef346458017da2062aca5c7955946b.svg' + ... } + ... ] + + #### Status codes + - 200: returned on success + + ## Authentication + Requires an API token. + """ + + serializer_class = SpecialSnakeSerializer + queryset = SpecialSnake.objects.all() + + +class TagViewSet(ModelViewSet): + """ + View providing CRUD operations on tags shown by our bot. + + ## Routes + ### GET /bot/tags + Returns all tags in the database. + + #### Response format + >>> [ + ... { + ... 'title': "resources", + ... 'embed': { + ... 'content': "Did you really think I'd put something useful here?" + ... } + ... } + ... ] + + #### Status codes + - 200: returned on success + + ### GET /bot/tags/ + Gets a single tag by its title. + + #### Response format + >>> { + ... 'title': "My awesome tag", + ... 'embed': { + ... 'content': "totally not filler words" + ... } + ... } + + #### Status codes + - 200: returned on success + - 404: if a tag with the given `title` could not be found + + ### POST /bot/tags + Adds a single tag to the database. + + #### Request body + >>> { + ... 'title': str, + ... 'embed': dict + ... } + + The embed structure is the same as the embed structure that the Discord API + expects. You can view the documentation for it here: + https://discordapp.com/developers/docs/resources/channel#embed-object + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PUT /bot/tags/ + Update the tag with the given `title`. + + #### Request body + >>> { + ... 'title': str, + ... 'embed': dict + ... } + + The embed structure is the same as the embed structure that the Discord API + expects. You can view the documentation for it here: + https://discordapp.com/developers/docs/resources/channel#embed-object + + #### Status codes + - 200: returned on success + - 400: if the request body was invalid, see response body for details + - 404: if the tag with the given `title` could not be found + + ### PATCH /bot/tags/ + Update the tag with the given `title`. + + #### Request body + >>> { + ... 'title': str, + ... 'embed': dict + ... } + + The embed structure is the same as the embed structure that the Discord API + expects. You can view the documentation for it here: + https://discordapp.com/developers/docs/resources/channel#embed-object + + #### Status codes + - 200: returned on success + - 400: if the request body was invalid, see response body for details + - 404: if the tag with the given `title` could not be found + + ### DELETE /bot/tags/ + Deletes the tag with the given `title`. + + #### Status codes + - 204: returned on success + - 404: if a tag with the given `title` does not exist + """ + + serializer_class = TagSerializer + queryset = Tag.objects.all() + + +class UserViewSet(BulkCreateModelMixin, ModelViewSet): + """ + View providing CRUD operations on Discord users through the bot. + + ## Routes + ### GET /bot/users + Returns all users currently known. + + #### Response format + >>> [ + ... { + ... 'id': 409107086526644234, + ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb", + ... 'name': "Python", + ... 'discriminator': 4329, + ... 'roles': [ + ... 352427296948486144, + ... 270988689419665409, + ... 277546923144249364, + ... 458226699344019457 + ... ], + ... 'in_guild': True + ... } + ... ] + + #### Status codes + - 200: returned on success + + ### GET /bot/users/ + Gets a single user by ID. + + #### Response format + >>> { + ... 'id': 409107086526644234, + ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb", + ... 'name': "Python", + ... 'discriminator': 4329, + ... 'roles': [ + ... 352427296948486144, + ... 270988689419665409, + ... 277546923144249364, + ... 458226699344019457 + ... ], + ... 'in_guild': True + ... } + + #### Status codes + - 200: returned on success + - 404: if a user with the given `snowflake` could not be found + + ### POST /bot/users + Adds a single or multiple new users. + The roles attached to the user(s) must be roles known by the pydis_site. + + #### Request body + >>> { + ... 'id': int, + ... 'avatar': str, + ... 'name': str, + ... 'discriminator': int, + ... 'roles': List[int], + ... 'in_guild': bool + ... } + + Alternatively, request users can be POSTed as a list of above objects, + in which case multiple users will be created at once. + + #### Status codes + - 201: returned on success + - 400: if one of the given roles does not exist, or one of the given fields is invalid + + ### PUT /bot/users/ + Update the user with the given `snowflake`. + All fields in the request body are required. + + #### Request body + >>> { + ... 'id': int, + ... 'avatar': str, + ... 'name': str, + ... 'discriminator': int, + ... 'roles': List[int], + ... 'in_guild': bool + ... } + + #### Status codes + - 200: returned on success + - 400: if the request body was invalid, see response body for details + - 404: if the user with the given `snowflake` could not be found + + ### PATCH /bot/users/ + Update the user with the given `snowflake`. + All fields in the request body are optional. + + #### Request body + >>> { + ... 'id': int, + ... 'avatar': str, + ... 'name': str, + ... 'discriminator': int, + ... 'roles': List[int], + ... 'in_guild': bool + ... } + + #### Status codes + - 200: returned on success + - 400: if the request body was invalid, see response body for details + - 404: if the user with the given `snowflake` could not be found + + ### DELETE /bot/users/ + Deletes the user with the given `snowflake`. + + #### Status codes + - 204: returned on success + - 404: if a user with the given `snowflake` does not exist + """ + + serializer_class = UserSerializer + queryset = User.objects.prefetch_related('roles') + + +class NominationViewSet(ModelViewSet): + # TODO: doc me + serializer_class = NominationSerializer + queryset = Nomination.objects.prefetch_related('author', 'user') + frozen_fields = ('author', 'inserted_at', 'user') + + def update(self, request, *args, **kwargs): + for field in request.data: + if field in self.frozen_fields: + raise ValidationError({field: ['This field cannot be updated.']}) + + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) diff --git a/pydis_site/apps/home/__init__.py b/pydis_site/apps/home/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/home/admin.py b/pydis_site/apps/home/admin.py new file mode 100644 index 00000000..4185d360 --- /dev/null +++ b/pydis_site/apps/home/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/pydis_site/apps/home/apps.py b/pydis_site/apps/home/apps.py new file mode 100644 index 00000000..90dc7137 --- /dev/null +++ b/pydis_site/apps/home/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class HomeConfig(AppConfig): + name = 'home' diff --git a/pydis_site/apps/home/migrations/__init__.py b/pydis_site/apps/home/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/home/models.py b/pydis_site/apps/home/models.py new file mode 100644 index 00000000..0b4331b3 --- /dev/null +++ b/pydis_site/apps/home/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/pydis_site/apps/home/tests.py b/pydis_site/apps/home/tests.py new file mode 100644 index 00000000..54fac6e8 --- /dev/null +++ b/pydis_site/apps/home/tests.py @@ -0,0 +1,9 @@ +from django.test import TestCase +from django_hosts.resolvers import reverse + + +class TestIndexReturns200(TestCase): + def test_index_returns_200(self): + url = reverse('index') + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py new file mode 100644 index 00000000..a01e019e --- /dev/null +++ b/pydis_site/apps/home/urls.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from django.urls import path +from django.views.generic import TemplateView + + +app_name = 'home' +urlpatterns = [ + path('', TemplateView.as_view(template_name='home/index.html'), name='index'), + path('admin/', admin.site.urls) +] diff --git a/pydis_site/apps/home/views.py b/pydis_site/apps/home/views.py new file mode 100644 index 00000000..fd0e0449 --- /dev/null +++ b/pydis_site/apps/home/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. diff --git a/pydis_site/apps/wiki/__init__.py b/pydis_site/apps/wiki/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/wiki/admin.py b/pydis_site/apps/wiki/admin.py new file mode 100644 index 00000000..4185d360 --- /dev/null +++ b/pydis_site/apps/wiki/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/pydis_site/apps/wiki/apps.py b/pydis_site/apps/wiki/apps.py new file mode 100644 index 00000000..fce4708e --- /dev/null +++ b/pydis_site/apps/wiki/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WikiConfig(AppConfig): + name = 'wiki' diff --git a/pydis_site/apps/wiki/migrations/__init__.py b/pydis_site/apps/wiki/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/wiki/models.py b/pydis_site/apps/wiki/models.py new file mode 100644 index 00000000..0b4331b3 --- /dev/null +++ b/pydis_site/apps/wiki/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/pydis_site/apps/wiki/tests.py b/pydis_site/apps/wiki/tests.py new file mode 100644 index 00000000..a79ca8be --- /dev/null +++ b/pydis_site/apps/wiki/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/pydis_site/apps/wiki/views.py b/pydis_site/apps/wiki/views.py new file mode 100644 index 00000000..fd0e0449 --- /dev/null +++ b/pydis_site/apps/wiki/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. diff --git a/pydis_site/hosts.py b/pydis_site/hosts.py new file mode 100644 index 00000000..86375173 --- /dev/null +++ b/pydis_site/hosts.py @@ -0,0 +1,13 @@ +from django.conf import settings +from django_hosts import host, patterns + +host_patterns = patterns( + '', + # > | Subdomain | URL Module | Host entry name | + host(r'admin', 'pydis_site.apps.admin.urls', name="admin"), + host(r'api', 'pydis_site.apps.api.urls', name='api'), + # host(r"staff", "pydis_site.apps.staff", name="staff"), + # host(r"wiki", "pydis_site.apps.wiki", name="wiki"), + # host(r"ws", "pydis_site.apps. ws", name="ws"), + host(r'.*', 'pydis_site.apps.home.urls', name=settings.DEFAULT_HOST) +) diff --git a/pydis_site/settings.py b/pydis_site/settings.py new file mode 100644 index 00000000..e8355918 --- /dev/null +++ b/pydis_site/settings.py @@ -0,0 +1,259 @@ +""" +Django settings for pydis_site project. + +Generated by 'django-admin startproject' using Django 2.1. + +For more information on this file, see +https://docs.djangoproject.com/en/2.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.1/ref/settings/ +""" + +import os +import sys + +import environ + + +env = environ.Env( + DEBUG=(bool, False) +) + + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +DEBUG = env('DEBUG') + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +if DEBUG: + ALLOWED_HOSTS = [ + 'pythondiscord.local', + 'admin.pythondiscord.local', + 'api.pythondiscord.local', + 'staff.pythondiscord.local', + 'wiki.pythondiscord.local' + ] + SECRET_KEY = "+_x00w3e94##2-qm-v(5&-x_@*l3t9zlir1etu+7$@4%!it2##" + +elif 'CI' in os.environ: + ALLOWED_HOSTS = ['*'] + SECRET_KEY = "{©ø¬½.Þ7&Ñ`Q^Kº*~¢j + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pydis_site/static/assets/logo-discord.png b/pydis_site/static/assets/logo-discord.png new file mode 100644 index 00000000..2bf74ffd Binary files /dev/null and b/pydis_site/static/assets/logo-discord.png differ diff --git a/pydis_site/static/css/navbar.css b/pydis_site/static/css/navbar.css new file mode 100644 index 00000000..db4b85e7 --- /dev/null +++ b/pydis_site/static/css/navbar.css @@ -0,0 +1,4 @@ +.navbar-icon { + max-height: 3em; + margin: 0.5em 0 0.5em 2.5em; +} diff --git a/pydis_site/static/home/css/index.css b/pydis_site/static/home/css/index.css new file mode 100644 index 00000000..76653320 --- /dev/null +++ b/pydis_site/static/home/css/index.css @@ -0,0 +1,30 @@ +html { + background-color: #7289DA; +} + +.overview > h1 { + margin-top: 0.5em; + margin-bottom: -0.25em; +} + +.overview > p.is-size-7 { + margin-bottom: 2em; +} + +.overview > p.is-size-4 { + margin-bottom: 1em; +} + +.overview > p.is-size-6 { + margin-bottom: 1em; +} + +.overview > img { + border: 1px solid #6378BF; + margin-bottom: 1em; +} + +.overview > .divider { + letter-spacing: -3px; + margin-bottom: 1em; +} diff --git a/pydis_site/templates/base.html b/pydis_site/templates/base.html new file mode 100644 index 00000000..1dcdfdc4 --- /dev/null +++ b/pydis_site/templates/base.html @@ -0,0 +1,22 @@ +{# Base template, with a few basic style definitions. #} +{% load django_simple_bulma %} +{% load static %} + + + + + + Python Discord | {% block page_title %}Website{% endblock %} + + + {% bulma %} + {% font_awesome %} + {% block head %}{% endblock %} + + + {% block body %} + {% endblock %} + + + + diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html new file mode 100644 index 00000000..cc99763b --- /dev/null +++ b/pydis_site/templates/home/index.html @@ -0,0 +1,42 @@ +{% extends 'navbar.html' %} +{% load static %} + +{% block page_title %}Home{% endblock %} +{% block head %} + {{ block.super }} + +{% endblock %} +{% block body %} + {{ block.super }} +
+

Python Discord

+

+ The official Discord server of + r/Python +

+ +

+ We're a large, friendly community focused around the Python programming language, open to those + who wish to learn the language or improve their skills, as well as those looking to help others. +

+ +

+ We organise regular community events and have a dedicated staff of talented Python + developers available to assist around the clock. Whether you're looking to learn the + language or working on a complex project, we've got someone who can help you if you get stuck. +

+ + + +

+ ------------------------------------------------------------------------------------------------------------           O          ------------------------------------------------------------------------------------------------------------- +

+ +

+ Please note: this site is under construction. What you see now may be vastly different + from the final project state. Feel free to chat to us on Discord if you're curious! +

+
+{% endblock %} + + diff --git a/pydis_site/templates/navbar.html b/pydis_site/templates/navbar.html new file mode 100644 index 00000000..0efa51c2 --- /dev/null +++ b/pydis_site/templates/navbar.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% load static %} + +{% block head %} + +{% endblock %} +{% block body %} + + {{ block.super }} +{% endblock %} + + diff --git a/pydis_site/urls.py b/pydis_site/urls.py new file mode 100644 index 00000000..c68375da --- /dev/null +++ b/pydis_site/urls.py @@ -0,0 +1,6 @@ +from django.urls import include, path + + +urlpatterns = ( + path('', include('pydis_site.apps.home.urls', namespace='home')), +) diff --git a/pydis_site/wsgi.py b/pydis_site/wsgi.py new file mode 100644 index 00000000..853e56f1 --- /dev/null +++ b/pydis_site/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for pydis_site project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydis_site.settings') + +application = get_wsgi_application() diff --git a/pysite/__init__.py b/pysite/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/__init__.py b/pysite/apps/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/admin/__init__.py b/pysite/apps/admin/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/admin/urls.py b/pysite/apps/admin/urls.py deleted file mode 100644 index 146c6496..00000000 --- a/pysite/apps/admin/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.contrib import admin -from django.urls import path - - -urlpatterns = ( - path('', admin.site.urls), -) diff --git a/pysite/apps/api/__init__.py b/pysite/apps/api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/api/admin.py b/pysite/apps/api/admin.py deleted file mode 100644 index 3ae7f3c5..00000000 --- a/pysite/apps/api/admin.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.contrib import admin - -from .models import ( - BotSetting, DeletedMessage, - DocumentationLink, Infraction, - MessageDeletionContext, OffTopicChannelName, - Role, SnakeFact, - SnakeIdiom, SnakeName, - SpecialSnake, Tag, - User -) - - -admin.site.register(BotSetting) -admin.site.register(DeletedMessage) -admin.site.register(DocumentationLink) -admin.site.register(Infraction) -admin.site.register(MessageDeletionContext) -admin.site.register(OffTopicChannelName) -admin.site.register(Role) -admin.site.register(SnakeFact) -admin.site.register(SnakeIdiom) -admin.site.register(SnakeName) -admin.site.register(SpecialSnake) -admin.site.register(Tag) -admin.site.register(User) diff --git a/pysite/apps/api/apps.py b/pysite/apps/api/apps.py deleted file mode 100644 index d87006dd..00000000 --- a/pysite/apps/api/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class ApiConfig(AppConfig): - name = 'api' diff --git a/pysite/apps/api/migrations/0001_initial.py b/pysite/apps/api/migrations/0001_initial.py deleted file mode 100644 index dca6d17f..00000000 --- a/pysite/apps/api/migrations/0001_initial.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.1 on 2018-08-15 17:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='SnakeName', - fields=[ - ('name', models.CharField(max_length=100, primary_key=True, serialize=False)), - ('scientific', models.CharField(max_length=150)), - ], - ), - ] diff --git a/pysite/apps/api/migrations/0002_documentationlink.py b/pysite/apps/api/migrations/0002_documentationlink.py deleted file mode 100644 index 5dee679a..00000000 --- a/pysite/apps/api/migrations/0002_documentationlink.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.1 on 2018-08-16 19:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='DocumentationLink', - fields=[ - ('package', models.CharField(max_length=50, primary_key=True, serialize=False)), - ('base_url', models.URLField()), - ('inventory_url', models.URLField()), - ], - ), - ] diff --git a/pysite/apps/api/migrations/0003_offtopicchannelname.py b/pysite/apps/api/migrations/0003_offtopicchannelname.py deleted file mode 100644 index 2f19bfd8..00000000 --- a/pysite/apps/api/migrations/0003_offtopicchannelname.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.1 on 2018-08-31 22:21 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0002_documentationlink'), - ] - - operations = [ - migrations.CreateModel( - name='OffTopicChannelName', - fields=[ - ('name', models.CharField(max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^[a-z0-9-]+$')])), - ], - ), - ] diff --git a/pysite/apps/api/migrations/0004_role.py b/pysite/apps/api/migrations/0004_role.py deleted file mode 100644 index 0a6b6c43..00000000 --- a/pysite/apps/api/migrations/0004_role.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.1 on 2018-09-01 19:54 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0003_offtopicchannelname'), - ] - - operations = [ - migrations.CreateModel( - name='Role', - fields=[ - ('id', models.BigIntegerField(help_text="The role's ID, taken from Discord.", primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')])), - ('name', models.CharField(help_text="The role's name, taken from Discord.", max_length=100)), - ('colour', models.IntegerField(help_text='The integer value of the colour of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Colour hex cannot be negative.')])), - ('permissions', models.IntegerField(help_text='The integer value of the permission bitset of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role permissions cannot be negative.'), django.core.validators.MaxValueValidator(limit_value=8589934592, message='Role permission bitset exceeds value of having all permissions')])), - ], - ), - ] diff --git a/pysite/apps/api/migrations/0005_user.py b/pysite/apps/api/migrations/0005_user.py deleted file mode 100644 index a771119c..00000000 --- a/pysite/apps/api/migrations/0005_user.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 2.1 on 2018-09-01 20:02 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0004_role'), - ] - - operations = [ - migrations.CreateModel( - name='Member', - fields=[ - ('id', models.BigIntegerField(help_text='The ID of this user, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='User IDs cannot be negative.')])), - ('name', models.CharField(help_text='The username, taken from Discord.', max_length=32)), - ('discriminator', models.PositiveSmallIntegerField(help_text='The discriminator of this user, taken from Discord.', validators=[django.core.validators.MaxValueValidator(limit_value=9999, message='Discriminators may not exceed `9999`.')])), - ('avatar_hash', models.CharField(help_text="The user's avatar hash, taken from Discord. Null if the user does not have any custom avatar.", max_length=100, null=True)), - ], - ), - migrations.AlterField( - model_name='role', - name='id', - field=models.BigIntegerField(help_text='The role ID, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')]), - ), - migrations.AlterField( - model_name='role', - name='name', - field=models.CharField(help_text='The role name, taken from Discord.', max_length=100), - ), - migrations.AddField( - model_name='member', - name='roles', - field=models.ManyToManyField(help_text='Any roles this user has on our server.', to='api.Role'), - ), - ] diff --git a/pysite/apps/api/migrations/0006_add_help_texts.py b/pysite/apps/api/migrations/0006_add_help_texts.py deleted file mode 100644 index a57d2289..00000000 --- a/pysite/apps/api/migrations/0006_add_help_texts.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 2.1.1 on 2018-09-21 20:26 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0005_user'), - ] - - operations = [ - migrations.AlterField( - model_name='documentationlink', - name='base_url', - field=models.URLField(help_text='The base URL from which documentation will be available for this project. Used to generate links to various symbols within this package.'), - ), - migrations.AlterField( - model_name='documentationlink', - name='inventory_url', - field=models.URLField(help_text='The URL at which the Sphinx inventory is available for this package.'), - ), - migrations.AlterField( - model_name='documentationlink', - name='package', - field=models.CharField(help_text='The Python package name that this documentation link belongs to.', max_length=50, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='offtopicchannelname', - name='name', - field=models.CharField(help_text='The actual channel name that will be used on our Discord server.', max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^[a-z0-9-]+$')]), - ), - migrations.AlterField( - model_name='snakename', - name='name', - field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='snakename', - name='scientific', - field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150), - ), - ] diff --git a/pysite/apps/api/migrations/0007_tag.py b/pysite/apps/api/migrations/0007_tag.py deleted file mode 100644 index d5546ccc..00000000 --- a/pysite/apps/api/migrations/0007_tag.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.1.1 on 2018-09-21 22:05 - -import pysite.apps.api.models -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0006_add_help_texts'), - ] - - operations = [ - migrations.CreateModel( - name='Tag', - fields=[ - ('title', models.CharField(help_text='The title of this tag, shown in searches and providing a quick overview over what this embed contains.', max_length=100, primary_key=True, serialize=False)), - ('embed', django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.')), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0008_tag_embed_validator.py b/pysite/apps/api/migrations/0008_tag_embed_validator.py deleted file mode 100644 index fd2385d1..00000000 --- a/pysite/apps/api/migrations/0008_tag_embed_validator.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.1.1 on 2018-09-23 10:07 - -import pysite.apps.api.validators -import django.contrib.postgres.fields.jsonb -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0007_tag'), - ] - - operations = [ - migrations.AlterField( - model_name='tag', - name='embed', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[ - - - pysite.apps.api.validators.validate_tag_embed]), - ), - ] diff --git a/pysite/apps/api/migrations/0009_snakefact.py b/pysite/apps/api/migrations/0009_snakefact.py deleted file mode 100644 index 2a136f83..00000000 --- a/pysite/apps/api/migrations/0009_snakefact.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-11 14:25 - -import pysite.apps.api.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0008_tag_embed_validator'), - ] - - operations = [ - migrations.CreateModel( - name='SnakeFact', - fields=[ - ('fact', models.CharField(help_text='A fact about snakes.', max_length=200, primary_key=True, serialize=False)), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0010_snakeidiom.py b/pysite/apps/api/migrations/0010_snakeidiom.py deleted file mode 100644 index 3eb99198..00000000 --- a/pysite/apps/api/migrations/0010_snakeidiom.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-19 16:27 - -import pysite.apps.api.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0009_snakefact'), - ] - - operations = [ - migrations.CreateModel( - name='SnakeIdiom', - fields=[ - ('idiom', models.CharField(help_text='A snake idiom', max_length=140, primary_key=True, serialize=False)), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0011_auto_20181020_1904.py b/pysite/apps/api/migrations/0011_auto_20181020_1904.py deleted file mode 100644 index bb5a6325..00000000 --- a/pysite/apps/api/migrations/0011_auto_20181020_1904.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-20 19:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0010_snakeidiom'), - ] - - operations = [ - migrations.AlterField( - model_name='snakeidiom', - name='idiom', - field=models.CharField(help_text='A saying about a snake.', max_length=140, primary_key=True, serialize=False), - ), - ] diff --git a/pysite/apps/api/migrations/0012_specialsnake.py b/pysite/apps/api/migrations/0012_specialsnake.py deleted file mode 100644 index ecf1b9d9..00000000 --- a/pysite/apps/api/migrations/0012_specialsnake.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-22 09:53 - -import pysite.apps.api.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0011_auto_20181020_1904'), - ] - - operations = [ - migrations.CreateModel( - name='SpecialSnake', - fields=[ - ('name', models.CharField(max_length=140, primary_key=True, serialize=False)), - ('info', models.TextField()), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0013_specialsnake_image.py b/pysite/apps/api/migrations/0013_specialsnake_image.py deleted file mode 100644 index a0d0d318..00000000 --- a/pysite/apps/api/migrations/0013_specialsnake_image.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-23 11:51 - -import datetime -from django.db import migrations, models -from django.utils.timezone import utc - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0012_specialsnake'), - ] - - operations = [ - migrations.AddField( - model_name='specialsnake', - name='image', - field=models.URLField(default=datetime.datetime(2018, 10, 23, 11, 51, 23, 703868, tzinfo=utc)), - preserve_default=False, - ), - ] diff --git a/pysite/apps/api/migrations/0014_auto_20181025_1959.py b/pysite/apps/api/migrations/0014_auto_20181025_1959.py deleted file mode 100644 index 3599d2cd..00000000 --- a/pysite/apps/api/migrations/0014_auto_20181025_1959.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-25 19:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0013_specialsnake_image'), - ] - - operations = [ - migrations.AlterField( - model_name='specialsnake', - name='info', - field=models.TextField(help_text='Info about a special snake.'), - ), - migrations.AlterField( - model_name='specialsnake', - name='name', - field=models.CharField(help_text='A special snake name.', max_length=140, primary_key=True, serialize=False), - ), - ] diff --git a/pysite/apps/api/migrations/0015_auto_20181027_1617.py b/pysite/apps/api/migrations/0015_auto_20181027_1617.py deleted file mode 100644 index 8973ff6d..00000000 --- a/pysite/apps/api/migrations/0015_auto_20181027_1617.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-27 16:17 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0014_auto_20181025_1959'), - ] - - operations = [ - migrations.AlterField( - model_name='specialsnake', - name='image', - field=django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), size=None), - ), - ] diff --git a/pysite/apps/api/migrations/0016_auto_20181027_1619.py b/pysite/apps/api/migrations/0016_auto_20181027_1619.py deleted file mode 100644 index b8bdfb16..00000000 --- a/pysite/apps/api/migrations/0016_auto_20181027_1619.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-27 16:19 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0015_auto_20181027_1617'), - ] - - operations = [ - migrations.RenameField( - model_name='specialsnake', - old_name='image', - new_name='images', - ), - ] diff --git a/pysite/apps/api/migrations/0017_auto_20181029_1921.py b/pysite/apps/api/migrations/0017_auto_20181029_1921.py deleted file mode 100644 index 012bda61..00000000 --- a/pysite/apps/api/migrations/0017_auto_20181029_1921.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.1.2 on 2018-10-29 19:21 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0016_auto_20181027_1619'), - ] - - operations = [ - migrations.AlterField( - model_name='specialsnake', - name='images', - field=django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), help_text='Images displaying this special snake.', size=None), - ), - ] diff --git a/pysite/apps/api/migrations/0018_messagedeletioncontext.py b/pysite/apps/api/migrations/0018_messagedeletioncontext.py deleted file mode 100644 index 10428ceb..00000000 --- a/pysite/apps/api/migrations/0018_messagedeletioncontext.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.1.1 on 2018-11-18 20:12 - -import pysite.apps.api.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0017_auto_20181029_1921'), - ] - - operations = [ - migrations.CreateModel( - name='MessageDeletionContext', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation', models.DateTimeField(help_text='When this deletion took place.')), - ('actor', models.ForeignKey(help_text='The original actor causing this deletion. Could be the author of a manual clean command invocation, the bot when executing automatic actions, or nothing to indicate that the bulk deletion was not issued by us.', null=True, on_delete=django.db.models.deletion.CASCADE, to='api.User')), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0018_user_rename.py b/pysite/apps/api/migrations/0018_user_rename.py deleted file mode 100644 index f88eb5bc..00000000 --- a/pysite/apps/api/migrations/0018_user_rename.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.1.3 on 2018-11-19 20:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0017_auto_20181029_1921'), - ] - - operations = [ - migrations.RenameModel( - old_name='Member', - new_name='User', - ), - ] diff --git a/pysite/apps/api/migrations/0019_deletedmessage.py b/pysite/apps/api/migrations/0019_deletedmessage.py deleted file mode 100644 index cb4c59f2..00000000 --- a/pysite/apps/api/migrations/0019_deletedmessage.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 2.1.1 on 2018-11-18 20:26 - -import pysite.apps.api.models -import pysite.apps.api.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0018_messagedeletioncontext'), - ] - - operations = [ - migrations.CreateModel( - name='DeletedMessage', - fields=[ - ('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])), - ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), - ('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)), - ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[ - - - pysite.apps.api.validators.validate_tag_embed]), help_text='Embeds attached to this message.', size=None)), - ('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), - ('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')), - ], - options={ - 'abstract': False, - }, - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0019_user_in_guild.py b/pysite/apps/api/migrations/0019_user_in_guild.py deleted file mode 100644 index fda008c4..00000000 --- a/pysite/apps/api/migrations/0019_user_in_guild.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.3 on 2018-11-19 20:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0018_user_rename'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='in_guild', - field=models.BooleanField(default=True, help_text='Whether this user is in our server.'), - ), - ] diff --git a/pysite/apps/api/migrations/0020_add_snake_field_validators.py b/pysite/apps/api/migrations/0020_add_snake_field_validators.py deleted file mode 100644 index 3b625f9b..00000000 --- a/pysite/apps/api/migrations/0020_add_snake_field_validators.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.1.2 on 2018-11-24 17:11 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0019_user_in_guild'), - ] - - operations = [ - migrations.AlterField( - model_name='snakename', - name='name', - field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), - ), - migrations.AlterField( - model_name='snakename', - name='scientific', - field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), - ), - ] diff --git a/pysite/apps/api/migrations/0020_infraction.py b/pysite/apps/api/migrations/0020_infraction.py deleted file mode 100644 index 7f7d5a41..00000000 --- a/pysite/apps/api/migrations/0020_infraction.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 2.1.3 on 2018-11-19 22:02 - -import pysite.apps.api.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0019_user_in_guild'), - ] - - operations = [ - migrations.CreateModel( - name='Infraction', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('inserted_at', models.DateTimeField(auto_now_add=True, help_text='The date and time of the creation of this infraction.')), - ('expires_at', models.DateTimeField(help_text="The date and time of the expiration of this infraction. Null if the infraction is permanent or it can't expire.", null=True)), - ('active', models.BooleanField(default=True, help_text='Whether the infraction is still active.')), - ('type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('mute', 'Mute'), ('ban', 'Ban'), ('kick', 'Kick'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9)), - ('reason', models.TextField(help_text='The reason for the infraction.')), - ('hidden', models.BooleanField(default=False, help_text='Whether the infraction is a shadow infraction.')), - ('actor', models.ForeignKey(help_text='The user which applied the infraction.', on_delete=django.db.models.deletion.CASCADE, related_name='infractions_given', to='api.User')), - ('user', models.ForeignKey(help_text='The user to which the infraction was applied.', on_delete=django.db.models.deletion.CASCADE, related_name='infractions_received', to='api.User')), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0021_add_special_snake_validator.py b/pysite/apps/api/migrations/0021_add_special_snake_validator.py deleted file mode 100644 index d41b96e5..00000000 --- a/pysite/apps/api/migrations/0021_add_special_snake_validator.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.1.2 on 2018-11-25 14:59 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0020_add_snake_field_validators'), - ] - - operations = [ - migrations.AlterField( - model_name='specialsnake', - name='name', - field=models.CharField(help_text='A special snake name.', max_length=140, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), - ), - ] diff --git a/pysite/apps/api/migrations/0021_infraction_reason_null.py b/pysite/apps/api/migrations/0021_infraction_reason_null.py deleted file mode 100644 index 6600f230..00000000 --- a/pysite/apps/api/migrations/0021_infraction_reason_null.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.3 on 2018-11-21 00:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0020_infraction'), - ] - - operations = [ - migrations.AlterField( - model_name='infraction', - name='reason', - field=models.TextField(help_text='The reason for the infraction.', null=True), - ), - ] diff --git a/pysite/apps/api/migrations/0021_merge_20181125_1015.py b/pysite/apps/api/migrations/0021_merge_20181125_1015.py deleted file mode 100644 index d8eaa510..00000000 --- a/pysite/apps/api/migrations/0021_merge_20181125_1015.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 2.1.1 on 2018-11-25 10:15 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0020_add_snake_field_validators'), - ('api', '0019_deletedmessage'), - ] - - operations = [ - ] diff --git a/pysite/apps/api/migrations/0022_infraction_remove_note.py b/pysite/apps/api/migrations/0022_infraction_remove_note.py deleted file mode 100644 index eba84610..00000000 --- a/pysite/apps/api/migrations/0022_infraction_remove_note.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.3 on 2018-11-21 21:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0021_infraction_reason_null'), - ] - - operations = [ - migrations.AlterField( - model_name='infraction', - name='type', - field=models.CharField(choices=[('warning', 'Warning'), ('mute', 'Mute'), ('ban', 'Ban'), ('kick', 'Kick'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9), - ), - ] diff --git a/pysite/apps/api/migrations/0023_merge_infractions_snake_validators.py b/pysite/apps/api/migrations/0023_merge_infractions_snake_validators.py deleted file mode 100644 index 916f78f2..00000000 --- a/pysite/apps/api/migrations/0023_merge_infractions_snake_validators.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 2.1.3 on 2018-11-29 19:37 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0022_infraction_remove_note'), - ('api', '0021_add_special_snake_validator'), - ] - - operations = [ - ] diff --git a/pysite/apps/api/migrations/0024_add_note_infraction_type.py b/pysite/apps/api/migrations/0024_add_note_infraction_type.py deleted file mode 100644 index 4adb53b8..00000000 --- a/pysite/apps/api/migrations/0024_add_note_infraction_type.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.4 on 2019-01-05 14:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0023_merge_infractions_snake_validators'), - ] - - operations = [ - migrations.AlterField( - model_name='infraction', - name='type', - field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9), - ), - ] diff --git a/pysite/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py b/pysite/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py deleted file mode 100644 index 0c02cb91..00000000 --- a/pysite/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.1.4 on 2019-01-06 16:01 - -import datetime -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0024_add_note_infraction_type'), - ] - - operations = [ - migrations.AlterField( - model_name='infraction', - name='inserted_at', - field=models.DateTimeField(default=datetime.datetime.utcnow, help_text='The date and time of the creation of this infraction.'), - ), - ] diff --git a/pysite/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py b/pysite/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py deleted file mode 100644 index 56f3b2b8..00000000 --- a/pysite/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.1.5 on 2019-01-09 19:50 - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0025_allow_custom_inserted_at_infraction_field'), - ] - - operations = [ - migrations.AlterField( - model_name='infraction', - name='inserted_at', - field=models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time of the creation of this infraction.'), - ), - ] diff --git a/pysite/apps/api/migrations/0027_merge_20190120_0852.py b/pysite/apps/api/migrations/0027_merge_20190120_0852.py deleted file mode 100644 index 6fab4fd0..00000000 --- a/pysite/apps/api/migrations/0027_merge_20190120_0852.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 2.1.5 on 2019-01-20 08:52 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0026_use_proper_default_for_infraction_insertion_date'), - ('api', '0021_merge_20181125_1015'), - ] - - operations = [ - ] diff --git a/pysite/apps/api/migrations/0028_allow_message_content_blank.py b/pysite/apps/api/migrations/0028_allow_message_content_blank.py deleted file mode 100644 index 6d57db27..00000000 --- a/pysite/apps/api/migrations/0028_allow_message_content_blank.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.5 on 2019-01-20 09:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0027_merge_20190120_0852'), - ] - - operations = [ - migrations.AlterField( - model_name='deletedmessage', - name='content', - field=models.CharField(blank=True, help_text='The content of this message, taken from Discord.', max_length=2000), - ), - ] diff --git a/pysite/apps/api/migrations/0029_add_infraction_type_watch.py b/pysite/apps/api/migrations/0029_add_infraction_type_watch.py deleted file mode 100644 index c6f88a11..00000000 --- a/pysite/apps/api/migrations/0029_add_infraction_type_watch.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.5 on 2019-01-20 11:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0028_allow_message_content_blank'), - ] - - operations = [ - migrations.AlterField( - model_name='infraction', - name='type', - field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9), - ), - ] diff --git a/pysite/apps/api/migrations/0030_reminder.py b/pysite/apps/api/migrations/0030_reminder.py deleted file mode 100644 index 8448ac8c..00000000 --- a/pysite/apps/api/migrations/0030_reminder.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 2.1.5 on 2019-01-22 22:17 - -import pysite.apps.api.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0029_add_infraction_type_watch'), - ] - - operations = [ - migrations.CreateModel( - name='Reminder', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('active', models.BooleanField(default=True, help_text='Whether this reminder is still active. If not, it has been sent out to the user.')), - ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), - ('content', models.CharField(help_text='The content that the user wants to be reminded of.', max_length=1500)), - ('expiration', models.DateTimeField(help_text='When this reminder should be sent.')), - ('author', models.ForeignKey(help_text='The creator of this reminder.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0031_nomination.py b/pysite/apps/api/migrations/0031_nomination.py deleted file mode 100644 index f15da5c3..00000000 --- a/pysite/apps/api/migrations/0031_nomination.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 2.1.5 on 2019-01-27 11:01 - -import pysite.apps.api.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0030_reminder'), - ] - - operations = [ - migrations.CreateModel( - name='Nomination', - fields=[ - ('active', models.BooleanField(default=True, help_text='Whether this nomination is still relevant.')), - ('reason', models.TextField(help_text='Why this user was nominated.')), - ('user', models.OneToOneField(help_text='The nominated user.', on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='nomination', serialize=False, to='api.User')), - ('inserted_at', models.DateTimeField(auto_now_add=True, help_text='The creation date of this nomination.')), - ('author', models.ForeignKey(help_text='The staff member that nominated this user.', on_delete=django.db.models.deletion.CASCADE, related_name='nomination_set', to='api.User')), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0032_botsetting.py b/pysite/apps/api/migrations/0032_botsetting.py deleted file mode 100644 index b7916dff..00000000 --- a/pysite/apps/api/migrations/0032_botsetting.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.1.5 on 2019-02-07 19:03 - -import pysite.apps.api.models -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0031_nomination'), - ] - - operations = [ - migrations.CreateModel( - name='BotSetting', - fields=[ - ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), - ('data', django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual settings of this setting.')), - ], - bases=(pysite.apps.api.models.ModelReprMixin, models.Model), - ), - ] diff --git a/pysite/apps/api/migrations/0033_create_defcon_settings.py b/pysite/apps/api/migrations/0033_create_defcon_settings.py deleted file mode 100644 index 830f3fb0..00000000 --- a/pysite/apps/api/migrations/0033_create_defcon_settings.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 2.1.5 on 2019-02-18 19:30 - -from django.db import migrations - - -def up(apps, schema_editor): - BotSetting = apps.get_model('api', 'BotSetting') - setting = BotSetting( - name='defcon', - data={ - 'enabled': False, - 'days': 0 - } - ).save() - - -def down(apps, schema_editor): # pragma: no cover - not necessary to test - BotSetting = apps.get_model('api', 'BotSetting') - BotSetting.get(name='defcon').delete() - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0032_botsetting'), - ] - - operations = [ - migrations.RunPython(up, down) - ] diff --git a/pysite/apps/api/migrations/0034_add_botsetting_name_validator.py b/pysite/apps/api/migrations/0034_add_botsetting_name_validator.py deleted file mode 100644 index 79aee41e..00000000 --- a/pysite/apps/api/migrations/0034_add_botsetting_name_validator.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.1.5 on 2019-02-18 19:41 - -import pysite.apps.api.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0033_create_defcon_settings'), - ] - - operations = [ - migrations.AlterField( - model_name='botsetting', - name='name', - field=models.CharField(max_length=50, primary_key=True, serialize=False, validators=[ - pysite.apps.api.validators.validate_bot_setting_name]), - ), - ] diff --git a/pysite/apps/api/migrations/__init__.py b/pysite/apps/api/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/api/models.py b/pysite/apps/api/models.py deleted file mode 100644 index 86c99f86..00000000 --- a/pysite/apps/api/models.py +++ /dev/null @@ -1,452 +0,0 @@ -from operator import itemgetter - -from django.contrib.postgres import fields as pgfields -from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator -from django.db import models -from django.utils import timezone - -from .validators import validate_bot_setting_name, validate_tag_embed - - -class ModelReprMixin: - """ - Adds a `__repr__` method to the model subclassing this - mixin which will display the model's class name along - with all parameters used to construct the object. - """ - - def __repr__(self): - attributes = ' '.join( - f'{attribute}={value!r}' - for attribute, value in sorted( - self.__dict__.items(), - key=itemgetter(0) - ) - if not attribute.startswith('_') - ) - return f'<{self.__class__.__name__}({attributes})>' - - -class BotSetting(ModelReprMixin, models.Model): - """A configuration entry for the bot.""" - - name = models.CharField( - primary_key=True, - max_length=50, - validators=(validate_bot_setting_name,) - ) - data = pgfields.JSONField( - help_text="The actual settings of this setting." - ) - - -class DocumentationLink(ModelReprMixin, models.Model): - """A documentation link used by the `!docs` command of the bot.""" - - package = models.CharField( - primary_key=True, - max_length=50, - help_text="The Python package name that this documentation link belongs to." - ) - base_url = models.URLField( - help_text=( - "The base URL from which documentation will be available for this project. " - "Used to generate links to various symbols within this package." - ) - ) - inventory_url = models.URLField( - help_text="The URL at which the Sphinx inventory is available for this package." - ) - - def __str__(self): - return f"{self.package} - {self.base_url}" - - -class OffTopicChannelName(ModelReprMixin, models.Model): - name = models.CharField( - primary_key=True, - max_length=96, - validators=(RegexValidator(regex=r'^[a-z0-9-]+$'),), - help_text="The actual channel name that will be used on our Discord server." - ) - - def __str__(self): - return self.name - - -class Role(ModelReprMixin, models.Model): - """A role on our Discord server.""" - - id = models.BigIntegerField( # noqa - primary_key=True, - validators=( - MinValueValidator( - limit_value=0, - message="Role IDs cannot be negative." - ), - ), - help_text="The role ID, taken from Discord." - ) - name = models.CharField( - max_length=100, - help_text="The role name, taken from Discord." - ) - colour = models.IntegerField( - validators=( - MinValueValidator( - limit_value=0, - message="Colour hex cannot be negative." - ), - ), - help_text="The integer value of the colour of this role from Discord." - ) - permissions = models.IntegerField( - validators=( - MinValueValidator( - limit_value=0, - message="Role permissions cannot be negative." - ), - MaxValueValidator( - limit_value=2 << 32, - message="Role permission bitset exceeds value of having all permissions" - ) - ), - help_text="The integer value of the permission bitset of this role from Discord." - ) - - def __str__(self): - return self.name - - -class SnakeFact(ModelReprMixin, models.Model): - """A snake fact used by the bot's snake cog.""" - - fact = models.CharField( - primary_key=True, - max_length=200, - help_text="A fact about snakes." - ) - - def __str__(self): - return self.fact - - -class SnakeIdiom(ModelReprMixin, models.Model): - """A snake idiom used by the snake cog.""" - - idiom = models.CharField( - primary_key=True, - max_length=140, - help_text="A saying about a snake." - ) - - def __str__(self): - return self.idiom - - -class SnakeName(ModelReprMixin, models.Model): - """A snake name used by the bot's snake cog.""" - - name = models.CharField( - primary_key=True, - max_length=100, - help_text="The regular name for this snake, e.g. 'Python'.", - validators=[RegexValidator(regex=r'^([^0-9])+$')] - ) - scientific = models.CharField( - max_length=150, - help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", - validators=[RegexValidator(regex=r'^([^0-9])+$')] - ) - - def __str__(self): - return f"{self.name} ({self.scientific})" - - -class SpecialSnake(ModelReprMixin, models.Model): - """A special snake's name, info and image from our database used by the bot's snake cog.""" - - name = models.CharField( - max_length=140, - primary_key=True, - help_text='A special snake name.', - validators=[RegexValidator(regex=r'^([^0-9])+$')] - ) - info = models.TextField( - help_text='Info about a special snake.' - ) - images = pgfields.ArrayField( - models.URLField(), - help_text='Images displaying this special snake.' - ) - - def __str__(self): - return self.name - - -class Tag(ModelReprMixin, models.Model): - """A tag providing (hopefully) useful information.""" - - title = models.CharField( - max_length=100, - help_text=( - "The title of this tag, shown in searches and providing " - "a quick overview over what this embed contains." - ), - primary_key=True - ) - embed = pgfields.JSONField( - help_text="The actual embed shown by this tag.", - validators=(validate_tag_embed,) - ) - - def __str__(self): - return self.title - - -class User(ModelReprMixin, models.Model): - """A Discord user.""" - - id = models.BigIntegerField( # noqa - primary_key=True, - validators=( - MinValueValidator( - limit_value=0, - message="User IDs cannot be negative." - ), - ), - help_text="The ID of this user, taken from Discord." - ) - name = models.CharField( - max_length=32, - help_text="The username, taken from Discord." - ) - discriminator = models.PositiveSmallIntegerField( - validators=( - MaxValueValidator( - limit_value=9999, - message="Discriminators may not exceed `9999`." - ), - ), - help_text="The discriminator of this user, taken from Discord." - ) - avatar_hash = models.CharField( - max_length=100, - help_text=( - "The user's avatar hash, taken from Discord. " - "Null if the user does not have any custom avatar." - ), - null=True - ) - roles = models.ManyToManyField( - Role, - help_text="Any roles this user has on our server." - ) - in_guild = models.BooleanField( - default=True, - help_text="Whether this user is in our server." - ) - - def __str__(self): - return f"{self.name}#{self.discriminator}" - - -class Message(ModelReprMixin, models.Model): - id = models.BigIntegerField( - primary_key=True, - help_text="The message ID as taken from Discord.", - validators=( - MinValueValidator( - limit_value=0, - message="Message IDs cannot be negative." - ), - ) - ) - author = models.ForeignKey( - User, - on_delete=models.CASCADE, - help_text="The author of this message." - ) - channel_id = models.BigIntegerField( - help_text=( - "The channel ID that this message was " - "sent in, taken from Discord." - ), - validators=( - MinValueValidator( - limit_value=0, - message="Channel IDs cannot be negative." - ), - ) - ) - content = models.CharField( - max_length=2_000, - help_text="The content of this message, taken from Discord.", - blank=True - ) - embeds = pgfields.ArrayField( - pgfields.JSONField( - validators=(validate_tag_embed,) - ), - help_text="Embeds attached to this message." - ) - - class Meta: - abstract = True - - -class MessageDeletionContext(ModelReprMixin, models.Model): - actor = models.ForeignKey( - User, - on_delete=models.CASCADE, - help_text=( - "The original actor causing this deletion. Could be the author " - "of a manual clean command invocation, the bot when executing " - "automatic actions, or nothing to indicate that the bulk " - "deletion was not issued by us." - ), - null=True - ) - creation = models.DateTimeField( - # Consider whether we want to add a validator here that ensures - # the deletion context does not take place in the future. - help_text="When this deletion took place." - ) - - -class DeletedMessage(Message): - deletion_context = models.ForeignKey( - MessageDeletionContext, - help_text="The deletion context this message is part of.", - on_delete=models.CASCADE - ) - - -class Infraction(ModelReprMixin, models.Model): - """An infraction for a Discord user.""" - - TYPE_CHOICES = ( - ("note", "Note"), - ("warning", "Warning"), - ("watch", "Watch"), - ("mute", "Mute"), - ("kick", "Kick"), - ("ban", "Ban"), - ("superstar", "Superstar") - ) - inserted_at = models.DateTimeField( - default=timezone.now, - help_text="The date and time of the creation of this infraction." - ) - expires_at = models.DateTimeField( - null=True, - help_text=( - "The date and time of the expiration of this infraction. " - "Null if the infraction is permanent or it can't expire." - ) - ) - active = models.BooleanField( - default=True, - help_text="Whether the infraction is still active." - ) - user = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='infractions_received', - help_text="The user to which the infraction was applied." - ) - actor = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='infractions_given', - help_text="The user which applied the infraction." - ) - type = models.CharField( - max_length=9, - choices=TYPE_CHOICES, - help_text="The type of the infraction." - ) - reason = models.TextField( - null=True, - help_text="The reason for the infraction." - ) - hidden = models.BooleanField( - default=False, - help_text="Whether the infraction is a shadow infraction." - ) - - def __str__(self): - s = f"#{self.id}: {self.type} on {self.user_id}" - if self.expires_at: - s += f" until {self.expires_at}" - if self.hidden: - s += " (hidden)" - return s - - -class Reminder(ModelReprMixin, models.Model): - """A reminder created by a user.""" - - active = models.BooleanField( - default=True, - help_text=( - "Whether this reminder is still active. " - "If not, it has been sent out to the user." - ) - ) - author = models.ForeignKey( - User, - on_delete=models.CASCADE, - help_text="The creator of this reminder." - ) - channel_id = models.BigIntegerField( - help_text=( - "The channel ID that this message was " - "sent in, taken from Discord." - ), - validators=( - MinValueValidator( - limit_value=0, - message="Channel IDs cannot be negative." - ), - ) - ) - content = models.CharField( - max_length=1500, - help_text="The content that the user wants to be reminded of." - ) - expiration = models.DateTimeField( - help_text="When this reminder should be sent." - ) - - def __str__(self): - return f"{self.content} on {self.expiration} by {self.author}" - - -class Nomination(ModelReprMixin, models.Model): - """A helper nomination created by staff.""" - - active = models.BooleanField( - default=True, - help_text="Whether this nomination is still relevant." - ) - author = models.ForeignKey( - User, - on_delete=models.CASCADE, - help_text="The staff member that nominated this user.", - related_name='nomination_set' - ) - reason = models.TextField( - help_text="Why this user was nominated." - ) - user = models.OneToOneField( - User, - on_delete=models.CASCADE, - help_text="The nominated user.", - primary_key=True, - related_name='nomination' - ) - inserted_at = models.DateTimeField( - auto_now_add=True, - help_text="The creation date of this nomination." - ) diff --git a/pysite/apps/api/serializers.py b/pysite/apps/api/serializers.py deleted file mode 100644 index 9a92313a..00000000 --- a/pysite/apps/api/serializers.py +++ /dev/null @@ -1,174 +0,0 @@ -from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError -from rest_framework.validators import UniqueValidator -from rest_framework_bulk import BulkSerializerMixin - -from .models import ( - BotSetting, DeletedMessage, - DocumentationLink, Infraction, - MessageDeletionContext, Nomination, - OffTopicChannelName, Reminder, - Role, SnakeFact, - SnakeIdiom, SnakeName, - SpecialSnake, Tag, - User -) - - -class BotSettingSerializer(ModelSerializer): - class Meta: - model = BotSetting - fields = ('name', 'data') - - -class DeletedMessageSerializer(ModelSerializer): - author = PrimaryKeyRelatedField( - queryset=User.objects.all() - ) - deletion_context = PrimaryKeyRelatedField( - queryset=MessageDeletionContext.objects.all(), - # This will be overriden in the `create` function - # of the deletion context serializer. - required=False - ) - - class Meta: - model = DeletedMessage - fields = ( - 'id', 'author', - 'channel_id', 'content', - 'embeds', 'deletion_context' - ) - - -class MessageDeletionContextSerializer(ModelSerializer): - deletedmessage_set = DeletedMessageSerializer(many=True) - - class Meta: - model = MessageDeletionContext - fields = ('actor', 'creation', 'id', 'deletedmessage_set') - depth = 1 - - def create(self, validated_data): - messages = validated_data.pop('deletedmessage_set') - deletion_context = MessageDeletionContext.objects.create(**validated_data) - for message in messages: - DeletedMessage.objects.create( - deletion_context=deletion_context, - **message - ) - - return deletion_context - - -class DocumentationLinkSerializer(ModelSerializer): - class Meta: - model = DocumentationLink - fields = ('package', 'base_url', 'inventory_url') - - -class InfractionSerializer(ModelSerializer): - class Meta: - model = Infraction - fields = ( - 'id', 'inserted_at', 'expires_at', 'active', 'user', 'actor', 'type', 'reason', 'hidden' - ) - - def validate(self, attrs): - infr_type = attrs.get('type') - - expires_at = attrs.get('expires_at') - if expires_at and infr_type in ('kick', 'warning'): - raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']}) - - hidden = attrs.get('hidden') - if hidden and infr_type in ('superstar',): - raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) - - return attrs - - -class ExpandedInfractionSerializer(InfractionSerializer): - def to_representation(self, instance): - ret = super().to_representation(instance) - - user = User.objects.get(id=ret['user']) - user_data = UserSerializer(user).data - ret['user'] = user_data - - actor = User.objects.get(id=ret['actor']) - actor_data = UserSerializer(actor).data - ret['actor'] = actor_data - - return ret - - -class OffTopicChannelNameSerializer(ModelSerializer): - class Meta: - model = OffTopicChannelName - fields = ('name',) - - def to_representation(self, obj): - return obj.name - - -class SnakeFactSerializer(ModelSerializer): - class Meta: - model = SnakeFact - fields = ('fact',) - - -class SnakeIdiomSerializer(ModelSerializer): - class Meta: - model = SnakeIdiom - fields = ('idiom',) - - -class SnakeNameSerializer(ModelSerializer): - class Meta: - model = SnakeName - fields = ('name', 'scientific') - - -class SpecialSnakeSerializer(ModelSerializer): - class Meta: - model = SpecialSnake - fields = ('name', 'images', 'info') - - -class ReminderSerializer(ModelSerializer): - author = PrimaryKeyRelatedField(queryset=User.objects.all()) - - class Meta: - model = Reminder - fields = ('active', 'author', 'channel_id', 'content', 'expiration', 'id') - - -class RoleSerializer(ModelSerializer): - class Meta: - model = Role - fields = ('id', 'name', 'colour', 'permissions') - - -class TagSerializer(ModelSerializer): - class Meta: - model = Tag - fields = ('title', 'embed') - - -class UserSerializer(BulkSerializerMixin, ModelSerializer): - roles = PrimaryKeyRelatedField(many=True, queryset=Role.objects.all(), required=False) - - class Meta: - model = User - fields = ('id', 'avatar_hash', 'name', 'discriminator', 'roles', 'in_guild') - depth = 1 - - -class NominationSerializer(ModelSerializer): - author = PrimaryKeyRelatedField(queryset=User.objects.all()) - user = PrimaryKeyRelatedField(queryset=User.objects.all()) - - class Meta: - model = Nomination - fields = ('active', 'author', 'reason', 'user', 'inserted_at') - depth = 1 diff --git a/pysite/apps/api/tests/__init__.py b/pysite/apps/api/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/api/tests/base.py b/pysite/apps/api/tests/base.py deleted file mode 100644 index 61829d6f..00000000 --- a/pysite/apps/api/tests/base.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.contrib.auth.models import User -from rest_framework.test import APITestCase - - -test_user, _created = User.objects.get_or_create( - username='test', - email='test@example.com', - password='testpass', # noqa: S106 - is_superuser=True, - is_staff=True -) - - -class APISubdomainTestCase(APITestCase): - """ - Configures the test client to use the proper subdomain - for requests and forces authentication for the test user. - - The test user is considered staff and superuser. - If you want to test for a custom user (for example, to test model permissions), - create the user, assign the relevant permissions, and use - `self.client.force_authenticate(user=created_user)` to force authentication - through the created user. - - Using this performs the following niceties for you which ease writing tests: - - setting the `HTTP_HOST` request header to `api.pythondiscord.local:8000`, and - - forcing authentication for the test user. - If you don't want to force authentication (for example, to test a route's response - for an unauthenticated user), un-force authentication by using the following: - - >>> from pysite.apps.api import APISubdomainTestCase - >>> class UnauthedUserTestCase(APISubdomainTestCase): - ... def setUp(self): - ... super().setUp() - ... self.client.force_authentication(user=None) - ... def test_can_read_objects_at_my_endpoint(self): - ... resp = self.client.get('/my-publicly-readable-endpoint') - ... self.assertEqual(resp.status_code, 200) - ... def test_cannot_delete_objects_at_my_endpoint(self): - ... resp = self.client.delete('/my-publicly-readable-endpoint/42') - ... self.assertEqual(resp.status_code, 401) - - Make sure to include the `super().setUp(self)` call, otherwise, you may get - status code 404 for some URLs due to the missing `HTTP_HOST` header. - - ## Example - Using this in a test case is rather straightforward: - - >>> from pysite.apps.api import APISubdomainTestCase - >>> class MyAPITestCase(APISubdomainTestCase): - ... def test_that_it_works(self): - ... response = self.client.get('/my-endpoint') - ... self.assertEqual(response.status_code, 200) - - To reverse URLs of the API host, you need to use `django_hosts`: - - >>> from django_hosts.resolvers import reverse - >>> from pysite.apps.api import APISubdomainTestCase - >>> class MyReversedTestCase(APISubdomainTestCase): - ... def test_my_endpoint(self): - ... url = reverse('user-detail', host='api') - ... response = self.client.get(url) - ... self.assertEqual(response.status_code, 200) - """ - - def setUp(self): - super().setUp() - self.client.defaults['HTTP_HOST'] = 'api.pythondiscord.local:8000' - self.client.force_authenticate(test_user) diff --git a/pysite/apps/api/tests/test_deleted_messages.py b/pysite/apps/api/tests/test_deleted_messages.py deleted file mode 100644 index cd5acab0..00000000 --- a/pysite/apps/api/tests/test_deleted_messages.py +++ /dev/null @@ -1,43 +0,0 @@ -from datetime import datetime - -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase -from ..models import User - - -class DeletedMessagesTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.author = User.objects.create( - id=55, - name='Robbie Rotten', - discriminator=55, - avatar_hash=None - ) - - cls.data = { - 'actor': None, - 'creation': datetime.utcnow().isoformat(), - 'deletedmessage_set': [ - { - 'author': cls.author.id, - 'id': 55, - 'channel_id': 5555, - 'content': "Terror Billy is a meanie", - 'embeds': [] - }, - { - 'author': cls.author.id, - 'id': 56, - 'channel_id': 5555, - 'content': "If you purge this, you're evil", - 'embeds': [] - } - ] - } - - def test_accepts_valid_data(self): - url = reverse('bot:messagedeletioncontext-list', host='api') - response = self.client.post(url, data=self.data) - self.assertEqual(response.status_code, 201) diff --git a/pysite/apps/api/tests/test_documentation_links.py b/pysite/apps/api/tests/test_documentation_links.py deleted file mode 100644 index f6c78391..00000000 --- a/pysite/apps/api/tests/test_documentation_links.py +++ /dev/null @@ -1,161 +0,0 @@ -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase -from ..models import DocumentationLink - - -class UnauthedDocumentationLinkAPITests(APISubdomainTestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_detail_lookup_returns_401(self): - url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - def test_list_returns_401(self): - url = reverse('bot:documentationlink-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - def test_create_returns_401(self): - url = reverse('bot:documentationlink-list', host='api') - response = self.client.post(url, data={'hi': 'there'}) - - self.assertEqual(response.status_code, 401) - - def test_delete_returns_401(self): - url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') - response = self.client.delete(url) - - self.assertEqual(response.status_code, 401) - - -class EmptyDatabaseDocumentationLinkAPITests(APISubdomainTestCase): - def test_detail_lookup_returns_404(self): - url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 404) - - def test_list_all_returns_empty_list(self): - url = reverse('bot:documentationlink-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) - - def test_delete_returns_404(self): - url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') - response = self.client.delete(url) - - self.assertEqual(response.status_code, 404) - - -class DetailLookupDocumentationLinkAPITests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.doc_link = DocumentationLink.objects.create( - package='testpackage', - base_url='https://example.com', - inventory_url='https://example.com' - ) - - cls.doc_json = { - 'package': cls.doc_link.package, - 'base_url': cls.doc_link.base_url, - 'inventory_url': cls.doc_link.inventory_url - } - - def test_detail_lookup_unknown_package_returns_404(self): - url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 404) - - def test_detail_lookup_created_package_returns_package(self): - url = reverse('bot:documentationlink-detail', args=(self.doc_link.package,), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), self.doc_json) - - def test_list_all_packages_shows_created_package(self): - url = reverse('bot:documentationlink-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), [self.doc_json]) - - def test_create_invalid_body_returns_400(self): - url = reverse('bot:documentationlink-list', host='api') - response = self.client.post(url, data={'i': 'am', 'totally': 'valid'}) - - self.assertEqual(response.status_code, 400) - - def test_create_invalid_url_returns_400(self): - body = { - 'package': 'example', - 'base_url': 'https://example.com', - 'inventory_url': 'totally an url' - } - - url = reverse('bot:documentationlink-list', host='api') - response = self.client.post(url, data=body) - - self.assertEqual(response.status_code, 400) - - -class DocumentationLinkCreationTests(APISubdomainTestCase): - def setUp(self): - super().setUp() - - self.body = { - 'package': 'example', - 'base_url': 'https://example.com', - 'inventory_url': 'https://docs.example.com' - } - - url = reverse('bot:documentationlink-list', host='api') - response = self.client.post(url, data=self.body) - - self.assertEqual(response.status_code, 201) - - def test_package_in_full_list(self): - url = reverse('bot:documentationlink-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), [self.body]) - - def test_detail_lookup_works_with_package(self): - url = reverse('bot:documentationlink-detail', args=(self.body['package'],), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), self.body) - - -class DocumentationLinkDeletionTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.doc_link = DocumentationLink.objects.create( - package='example', - base_url='https://example.com', - inventory_url='https://docs.example.com' - ) - - def test_unknown_package_returns_404(self): - url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') - response = self.client.delete(url) - - self.assertEqual(response.status_code, 404) - - def test_delete_known_package_returns_204(self): - url = reverse('bot:documentationlink-detail', args=(self.doc_link.package,), host='api') - response = self.client.delete(url) - - self.assertEqual(response.status_code, 204) diff --git a/pysite/apps/api/tests/test_healthcheck.py b/pysite/apps/api/tests/test_healthcheck.py deleted file mode 100644 index b0fd71bf..00000000 --- a/pysite/apps/api/tests/test_healthcheck.py +++ /dev/null @@ -1,16 +0,0 @@ -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase - - -class UnauthedHealthcheckAPITests(APISubdomainTestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_can_access_healthcheck_view(self): - url = reverse('healthcheck', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {'status': 'ok'}) diff --git a/pysite/apps/api/tests/test_infractions.py b/pysite/apps/api/tests/test_infractions.py deleted file mode 100644 index 7c370c17..00000000 --- a/pysite/apps/api/tests/test_infractions.py +++ /dev/null @@ -1,359 +0,0 @@ -from datetime import datetime as dt, timedelta, timezone -from urllib.parse import quote - -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase -from ..models import Infraction, User - - -class UnauthenticatedTests(APISubdomainTestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_detail_lookup_returns_401(self): - url = reverse('bot:infraction-detail', args=(5,), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - def test_list_returns_401(self): - url = reverse('bot:infraction-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - def test_create_returns_401(self): - url = reverse('bot:infraction-list', host='api') - response = self.client.post(url, data={'reason': 'Have a nice day.'}) - - self.assertEqual(response.status_code, 401) - - def test_partial_update_returns_401(self): - url = reverse('bot:infraction-detail', args=(5,), host='api') - response = self.client.patch(url, data={'reason': 'Have a nice day.'}) - - self.assertEqual(response.status_code, 401) - - -class InfractionTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.user = User.objects.create( - id=5, - name='james', - discriminator=1, - avatar_hash=None - ) - cls.ban_hidden = Infraction.objects.create( - user_id=cls.user.id, - actor_id=cls.user.id, - type='ban', - reason='He terk my jerb!', - hidden=True, - expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) - ) - cls.ban_inactive = Infraction.objects.create( - user_id=cls.user.id, - actor_id=cls.user.id, - type='ban', - reason='James is an ass, and we won\'t be working with him again.', - active=False - ) - - def test_list_all(self): - url = reverse('bot:infraction-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - infractions = response.json() - - self.assertEqual(len(infractions), 2) - self.assertEqual(infractions[0]['id'], self.ban_hidden.id) - self.assertEqual(infractions[1]['id'], self.ban_inactive.id) - - def test_filter_search(self): - url = reverse('bot:infraction-list', host='api') - pattern = quote(r'^James(\s\w+){3},') - response = self.client.get(f'{url}?search={pattern}') - - self.assertEqual(response.status_code, 200) - infractions = response.json() - - self.assertEqual(len(infractions), 1) - self.assertEqual(infractions[0]['id'], self.ban_inactive.id) - - def test_filter_field(self): - url = reverse('bot:infraction-list', host='api') - response = self.client.get(f'{url}?type=ban&hidden=true') - - self.assertEqual(response.status_code, 200) - infractions = response.json() - - self.assertEqual(len(infractions), 1) - self.assertEqual(infractions[0]['id'], self.ban_hidden.id) - - def test_returns_empty_for_no_match(self): - url = reverse('bot:infraction-list', host='api') - response = self.client.get(f'{url}?type=ban&search=poop') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.json()), 0) - - def test_ignores_bad_filters(self): - url = reverse('bot:infraction-list', host='api') - response = self.client.get(f'{url}?type=ban&hidden=maybe&foo=bar') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.json()), 2) - - def test_retrieve_single_from_id(self): - url = reverse('bot:infraction-detail', args=(self.ban_inactive.id,), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()['id'], self.ban_inactive.id) - - def test_retrieve_returns_404_for_absent_id(self): - url = reverse('bot:infraction-detail', args=(1337,), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 404) - - def test_partial_update(self): - url = reverse('bot:infraction-detail', args=(self.ban_hidden.id,), host='api') - data = { - 'expires_at': '4143-02-15T21:04:31+00:00', - 'active': False, - 'reason': 'durka derr' - } - - response = self.client.patch(url, data=data) - self.assertEqual(response.status_code, 200) - infraction = Infraction.objects.get(id=self.ban_hidden.id) - - # These fields were updated. - self.assertEqual(infraction.expires_at.isoformat(), data['expires_at']) - self.assertEqual(infraction.active, data['active']) - self.assertEqual(infraction.reason, data['reason']) - - # These fields are still the same. - self.assertEqual(infraction.id, self.ban_hidden.id) - self.assertEqual(infraction.inserted_at, self.ban_hidden.inserted_at) - self.assertEqual(infraction.user.id, self.ban_hidden.user.id) - self.assertEqual(infraction.actor.id, self.ban_hidden.actor.id) - self.assertEqual(infraction.type, self.ban_hidden.type) - self.assertEqual(infraction.hidden, self.ban_hidden.hidden) - - def test_partial_update_returns_400_for_frozen_field(self): - url = reverse('bot:infraction-detail', args=(self.ban_hidden.id,), host='api') - data = {'user': 6} - - response = self.client.patch(url, data=data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'user': ['This field cannot be updated.'] - }) - - -class CreationTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.user = User.objects.create( - id=5, - name='james', - discriminator=1, - avatar_hash=None - ) - - def test_accepts_valid_data(self): - url = reverse('bot:infraction-list', host='api') - data = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': 'ban', - 'reason': 'He terk my jerb!', - 'hidden': True, - 'expires_at': '5018-11-20T15:52:00+00:00' - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 201) - - infraction = Infraction.objects.get(id=response.json()['id']) - self.assertAlmostEqual( - infraction.inserted_at, - dt.now(timezone.utc), - delta=timedelta(seconds=2) - ) - self.assertEqual(infraction.expires_at.isoformat(), data['expires_at']) - self.assertEqual(infraction.user.id, data['user']) - self.assertEqual(infraction.actor.id, data['actor']) - self.assertEqual(infraction.type, data['type']) - self.assertEqual(infraction.reason, data['reason']) - self.assertEqual(infraction.hidden, data['hidden']) - self.assertEqual(infraction.active, True) - - def test_returns_400_for_missing_user(self): - url = reverse('bot:infraction-list', host='api') - data = { - 'actor': self.user.id, - 'type': 'kick' - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'user': ['This field is required.'] - }) - - def test_returns_400_for_bad_user(self): - url = reverse('bot:infraction-list', host='api') - data = { - 'user': 1337, - 'actor': self.user.id, - 'type': 'kick' - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'user': ['Invalid pk "1337" - object does not exist.'] - }) - - def test_returns_400_for_bad_type(self): - url = reverse('bot:infraction-list', host='api') - data = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': 'hug' - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'type': ['"hug" is not a valid choice.'] - }) - - def test_returns_400_for_bad_expired_at_format(self): - url = reverse('bot:infraction-list', host='api') - data = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': 'ban', - 'expires_at': '20/11/5018 15:52:00' - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'expires_at': [ - 'Datetime has wrong format. Use one of these formats instead: ' - 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].' - ] - }) - - def test_returns_400_for_expiring_non_expirable_type(self): - url = reverse('bot:infraction-list', host='api') - data = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': 'kick', - 'expires_at': '5018-11-20T15:52:00+00:00' - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'expires_at': [f'{data["type"]} infractions cannot expire.'] - }) - - def test_returns_400_for_hidden_non_hideable_type(self): - url = reverse('bot:infraction-list', host='api') - data = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': 'superstar', - 'hidden': True - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'hidden': [f'{data["type"]} infractions cannot be hidden.'] - }) - - -class ExpandedTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.user = User.objects.create( - id=5, - name='james', - discriminator=1, - avatar_hash=None - ) - cls.kick = Infraction.objects.create( - user_id=cls.user.id, - actor_id=cls.user.id, - type='kick' - ) - cls.warning = Infraction.objects.create( - user_id=cls.user.id, - actor_id=cls.user.id, - type='warning' - ) - - def check_expanded_fields(self, infraction): - for key in ('user', 'actor'): - obj = infraction[key] - for field in ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild'): - self.assertTrue(field in obj, msg=f'field "{field}" missing from {key}') - - def test_list_expanded(self): - url = reverse('bot:infraction-list-expanded', host='api') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - response_data = response.json() - self.assertEqual(len(response_data), 2) - - for infraction in response_data: - self.check_expanded_fields(infraction) - - def test_create_expanded(self): - url = reverse('bot:infraction-list-expanded', host='api') - data = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': 'warning' - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 201) - - self.assertEqual(len(Infraction.objects.all()), 3) - self.check_expanded_fields(response.json()) - - def test_retrieve_expanded(self): - url = reverse('bot:infraction-detail-expanded', args=(self.warning.id,), host='api') - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - infraction = response.json() - self.assertEqual(infraction['id'], self.warning.id) - self.check_expanded_fields(infraction) - - def test_partial_update_expanded(self): - url = reverse('bot:infraction-detail-expanded', args=(self.kick.id,), host='api') - data = {'active': False} - - response = self.client.patch(url, data=data) - self.assertEqual(response.status_code, 200) - - infraction = Infraction.objects.get(id=self.kick.id) - self.assertEqual(infraction.active, data['active']) - self.check_expanded_fields(response.json()) diff --git a/pysite/apps/api/tests/test_models.py b/pysite/apps/api/tests/test_models.py deleted file mode 100644 index 43d1eb41..00000000 --- a/pysite/apps/api/tests/test_models.py +++ /dev/null @@ -1,113 +0,0 @@ -from datetime import datetime as dt, timezone - -from django.test import SimpleTestCase - -from ..models import ( - BotSetting, DeletedMessage, - DocumentationLink, Infraction, - Message, MessageDeletionContext, - ModelReprMixin, OffTopicChannelName, - Reminder, Role, - SnakeFact, SnakeIdiom, - SnakeName, SpecialSnake, - Tag, User -) - - -class SimpleClass(ModelReprMixin): - def __init__(self, is_what): - self.the_cake = is_what - - -class ReprMixinTests(SimpleTestCase): - def setUp(self): - self.klass = SimpleClass('is a lie') - - def test_shows_attributes(self): - expected = "" - self.assertEqual(repr(self.klass), expected) - - -class StringDunderMethodTests(SimpleTestCase): - def setUp(self): - self.objects = ( - DeletedMessage( - id=45, - author=User( - id=444, name='bill', - discriminator=5, avatar_hash=None - ), - channel_id=666, - content="wooey", - deletion_context=MessageDeletionContext( - actor=User( - id=5555, name='shawn', - discriminator=555, avatar_hash=None - ), - creation=dt.utcnow() - ), - embeds=[] - ), - DocumentationLink( - 'test', 'http://example.com', 'http://example.com' - ), - OffTopicChannelName(name='bob-the-builders-playground'), - SnakeFact(fact='snakes are cute'), - SnakeIdiom(idiom='snake snacks'), - SnakeName(name='python', scientific='3'), - SpecialSnake( - name='Pythagoras Pythonista', - info='The only python snake that is born a triangle' - ), - Role( - id=5, name='test role', - colour=0x5, permissions=0 - ), - Message( - id=45, - author=User( - id=444, name='bill', - discriminator=5, avatar_hash=None - ), - channel_id=666, - content="wooey", - embeds=[] - ), - MessageDeletionContext( - actor=User( - id=5555, name='shawn', - discriminator=555, avatar_hash=None - ), - creation=dt.utcnow() - ), - Tag( - title='bob', - embed={'content': "the builder"} - ), - User( - id=5, name='bob', - discriminator=1, avatar_hash=None - ), - Infraction( - user_id=5, actor_id=5, - type='kick', reason='He terk my jerb!' - ), - Infraction( - user_id=5, actor_id=5, hidden=True, - type='kick', reason='He terk my jerb!', - expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) - ), - Reminder( - author=User( - id=452, name='billy', - discriminator=5, avatar_hash=None - ), - channel_id=555, - content="oh no", - expiration=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) - ) - ) - - def test_returns_string(self): - for instance in self.objects: - self.assertIsInstance(str(instance), str) diff --git a/pysite/apps/api/tests/test_nominations.py b/pysite/apps/api/tests/test_nominations.py deleted file mode 100644 index 1f03d1b0..00000000 --- a/pysite/apps/api/tests/test_nominations.py +++ /dev/null @@ -1,41 +0,0 @@ -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase -from ..models import Nomination, User - - -class NominationTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.author = User.objects.create( - id=5152, - name='Ro Bert', - discriminator=256, - avatar_hash=None - ) - cls.user = cls.author - - cls.nomination = Nomination.objects.create( - author=cls.author, - reason="he's good", - user=cls.author - ) - - def test_returns_400_on_attempt_to_update_frozen_field(self): - url = reverse('bot:nomination-detail', args=(self.user.id,), host='api') - response = self.client.put( - url, - data={'inserted_at': 'something bad'} - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'inserted_at': ['This field cannot be updated.'] - }) - - def test_returns_200_on_successful_update(self): - url = reverse('bot:nomination-detail', args=(self.user.id,), host='api') - response = self.client.patch( - url, - data={'reason': 'there are many like it, but this test is mine'} - ) - self.assertEqual(response.status_code, 200) diff --git a/pysite/apps/api/tests/test_off_topic_channel_names.py b/pysite/apps/api/tests/test_off_topic_channel_names.py deleted file mode 100644 index 60af1f62..00000000 --- a/pysite/apps/api/tests/test_off_topic_channel_names.py +++ /dev/null @@ -1,152 +0,0 @@ -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase -from ..models import OffTopicChannelName - - -class UnauthenticatedTests(APISubdomainTestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_cannot_read_off_topic_channel_name_list(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - def test_cannot_read_off_topic_channel_name_list_with_random_item_param(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(f'{url}?random_items=no') - - self.assertEqual(response.status_code, 401) - - -class EmptyDatabaseTests(APISubdomainTestCase): - def test_returns_empty_object(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) - - def test_returns_empty_list_with_get_all_param(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(f'{url}?random_items=5') - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) - - def test_returns_400_for_bad_random_items_param(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(f'{url}?random_items=totally-a-valid-integer') - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'random_items': ["Must be a valid integer."] - }) - - def test_returns_400_for_negative_random_items_param(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(f'{url}?random_items=-5') - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'random_items': ["Must be a positive integer."] - }) - - -class ListTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand') - cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') - - def test_returns_name_in_list(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.json(), - [ - self.test_name.name, - self.test_name_2.name - ] - ) - - def test_returns_single_item_with_random_items_param_set_to_1(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(f'{url}?random_items=1') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.json()), 1) - - -class CreationTests(APISubdomainTestCase): - def setUp(self): - super().setUp() - - url = reverse('bot:offtopicchannelname-list', host='api') - self.name = "lemonade-shop" - response = self.client.post(f'{url}?name={self.name}') - self.assertEqual(response.status_code, 201) - - def test_name_in_full_list(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), [self.name]) - - def test_returns_400_for_missing_name_param(self): - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.post(url) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'name': ["This query parameter is required."] - }) - - def test_returns_400_for_bad_name_param(self): - url = reverse('bot:offtopicchannelname-list', host='api') - invalid_names = ( - 'space between words', - 'UPPERCASE', - '$$$$$$$$' - ) - - for name in invalid_names: - response = self.client.post(f'{url}?name={name}') - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'name': ["Enter a valid value."] - }) - - -class DeletionTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand') - cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') - - def test_deleting_unknown_name_returns_404(self): - url = reverse('bot:offtopicchannelname-detail', args=('unknown-name',), host='api') - response = self.client.delete(url) - - self.assertEqual(response.status_code, 404) - - def test_deleting_known_name_returns_204(self): - url = reverse('bot:offtopicchannelname-detail', args=(self.test_name.name,), host='api') - response = self.client.delete(url) - - self.assertEqual(response.status_code, 204) - - def test_name_gets_deleted(self): - url = reverse('bot:offtopicchannelname-detail', args=(self.test_name_2.name,), host='api') - response = self.client.delete(url) - - self.assertEqual(response.status_code, 204) - - url = reverse('bot:offtopicchannelname-list', host='api') - response = self.client.get(url) - self.assertNotIn(self.test_name_2.name, response.json()) diff --git a/pysite/apps/api/tests/test_rules.py b/pysite/apps/api/tests/test_rules.py deleted file mode 100644 index c94f89cc..00000000 --- a/pysite/apps/api/tests/test_rules.py +++ /dev/null @@ -1,35 +0,0 @@ -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase -from ..views import RulesView - - -class RuleAPITests(APISubdomainTestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_can_access_rules_view(self): - url = reverse('rules', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response.json(), list) - - def test_link_format_query_param_produces_different_results(self): - url = reverse('rules', host='api') - markdown_links_response = self.client.get(url + '?link_format=md') - html_links_response = self.client.get(url + '?link_format=html') - self.assertNotEqual( - markdown_links_response.json(), - html_links_response.json() - ) - - def test_format_link_raises_value_error_for_invalid_target(self): - with self.assertRaises(ValueError): - RulesView._format_link("a", "b", "c") - - def test_get_returns_400_for_wrong_link_format(self): - url = reverse('rules', host='api') - response = self.client.get(url + '?link_format=unknown') - self.assertEqual(response.status_code, 400) diff --git a/pysite/apps/api/tests/test_snake_names.py b/pysite/apps/api/tests/test_snake_names.py deleted file mode 100644 index 41dfae63..00000000 --- a/pysite/apps/api/tests/test_snake_names.py +++ /dev/null @@ -1,67 +0,0 @@ -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase -from ..models import SnakeName - - -class StatusTests(APISubdomainTestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_cannot_read_snake_name_list(self): - url = reverse('bot:snakename-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - def test_cannot_read_snake_names_with_get_all_param(self): - url = reverse('bot:snakename-list', host='api') - response = self.client.get(f'{url}?get_all=True') - - self.assertEqual(response.status_code, 401) - - -class EmptyDatabaseSnakeNameTests(APISubdomainTestCase): - def test_endpoint_returns_empty_object(self): - url = reverse('bot:snakename-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {}) - - def test_endpoint_returns_empty_list_with_get_all_param(self): - url = reverse('bot:snakename-list', host='api') - response = self.client.get(f'{url}?get_all=True') - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) - - -class SnakeNameListTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.snake_python = SnakeName.objects.create(name='Python', scientific='Totally.') - - def test_endpoint_returns_all_snakes_with_get_all_param(self): - url = reverse('bot:snakename-list', host='api') - response = self.client.get(f'{url}?get_all=True') - - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.json(), - [ - { - 'name': self.snake_python.name, - 'scientific': self.snake_python.scientific - } - ] - ) - - def test_endpoint_returns_single_snake_without_get_all_param(self): - url = reverse('bot:snakename-list', host='api') - response = self.client.get(url) - self.assertEqual(response.json(), { - 'name': self.snake_python.name, - 'scientific': self.snake_python.scientific - }) diff --git a/pysite/apps/api/tests/test_users.py b/pysite/apps/api/tests/test_users.py deleted file mode 100644 index 90bc3d30..00000000 --- a/pysite/apps/api/tests/test_users.py +++ /dev/null @@ -1,121 +0,0 @@ -from django_hosts.resolvers import reverse - -from .base import APISubdomainTestCase -from ..models import Role, User - - -class UnauthedUserAPITests(APISubdomainTestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_detail_lookup_returns_401(self): - url = reverse('bot:user-detail', args=('whatever',), host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - def test_list_returns_401(self): - url = reverse('bot:user-list', host='api') - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - def test_create_returns_401(self): - url = reverse('bot:user-list', host='api') - response = self.client.post(url, data={'hi': 'there'}) - - self.assertEqual(response.status_code, 401) - - def test_delete_returns_401(self): - url = reverse('bot:user-detail', args=('whatever',), host='api') - response = self.client.delete(url) - - self.assertEqual(response.status_code, 401) - - -class CreationTests(APISubdomainTestCase): - @classmethod - def setUpTestData(cls): # noqa - cls.role = Role.objects.create( - id=5, - name="Test role pls ignore", - colour=2, - permissions=0b01010010101 - ) - - def test_accepts_valid_data(self): - url = reverse('bot:user-list', host='api') - data = { - 'id': 42, - 'avatar_hash': "validavatarhashiswear", - 'name': "Test", - 'discriminator': 42, - 'roles': [ - self.role.id - ], - 'in_guild': True - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json(), data) - - user = User.objects.get(id=42) - self.assertEqual(user.avatar_hash, data['avatar_hash']) - self.assertEqual(user.name, data['name']) - self.assertEqual(user.discriminator, data['discriminator']) - self.assertEqual(user.in_guild, data['in_guild']) - - def test_supports_multi_creation(self): - url = reverse('bot:user-list', host='api') - data = [ - { - 'id': 5, - 'avatar_hash': "hahayes", - 'name': "test man", - 'discriminator': 42, - 'roles': [ - self.role.id - ], - 'in_guild': True - }, - { - 'id': 8, - 'avatar_hash': "maybenot", - 'name': "another test man", - 'discriminator': 555, - 'roles': [], - 'in_guild': False - } - ] - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json(), data) - - def test_returns_400_for_unknown_role_id(self): - url = reverse('bot:user-list', host='api') - data = { - 'id': 5, - 'avatar_hash': "hahayes", - 'name': "test man", - 'discriminator': 42, - 'roles': [ - 190810291 - ] - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) - - def test_returns_400_for_bad_data(self): - url = reverse('bot:user-list', host='api') - data = { - 'id': True, - 'avatar_hash': 1902831, - 'discriminator': "totally!" - } - - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) diff --git a/pysite/apps/api/tests/test_validators.py b/pysite/apps/api/tests/test_validators.py deleted file mode 100644 index d2c0a136..00000000 --- a/pysite/apps/api/tests/test_validators.py +++ /dev/null @@ -1,213 +0,0 @@ -from django.core.exceptions import ValidationError -from django.test import TestCase - -from ..validators import ( - validate_bot_setting_name, - validate_tag_embed -) - - -REQUIRED_KEYS = ( - 'content', 'fields', 'image', 'title', 'video' -) - - -class BotSettingValidatorTests(TestCase): - def test_accepts_valid_names(self): - validate_bot_setting_name('defcon') - - def test_rejects_bad_names(self): - with self.assertRaises(ValidationError): - validate_bot_setting_name('bad name') - -class TagEmbedValidatorTests(TestCase): - def test_rejects_non_mapping(self): - with self.assertRaises(ValidationError): - validate_tag_embed('non-empty non-mapping') - - def test_rejects_missing_required_keys(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'unknown': "key" - }) - - def test_rejects_one_correct_one_incorrect(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'provider': "??", - 'title': "" - }) - - def test_rejects_empty_required_key(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': '' - }) - - def test_rejects_list_as_embed(self): - with self.assertRaises(ValidationError): - validate_tag_embed([]) - - def test_rejects_required_keys_and_unknown_keys(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': "the duck walked up to the lemonade stand", - 'and': "he said to the man running the stand" - }) - - def test_rejects_too_long_title(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': 'a' * 257 - }) - - def test_rejects_too_many_fields(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'fields': [{} for _ in range(26)] - }) - - def test_rejects_too_long_description(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'description': 'd' * 2049 - }) - - def test_allows_valid_embed(self): - validate_tag_embed({ - 'title': "My embed", - 'description': "look at my embed, my embed is amazing" - }) - - def test_allows_unvalidated_fields(self): - validate_tag_embed({ - 'title': "My embed", - 'provider': "what am I??" - }) - - def test_rejects_fields_as_list_of_non_mappings(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'fields': ['abc'] - }) - - def test_rejects_fields_with_unknown_fields(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'fields': [ - { - 'what': "is this field" - } - ] - }) - - def test_rejects_fields_with_too_long_name(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'fields': [ - { - 'name': "a" * 257 - } - ] - }) - - def test_rejects_one_correct_one_incorrect_field(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'fields': [ - { - 'name': "Totally valid", - 'value': "LOOK AT ME" - }, - { - 'oh': "what is this key?" - } - ] - }) - - def test_allows_valid_fields(self): - validate_tag_embed({ - 'fields': [ - { - 'name': "valid", - 'value': "field" - } - ] - }) - - def test_rejects_footer_as_non_mapping(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': "whatever", - 'footer': [] - }) - - def test_rejects_footer_with_unknown_fields(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': "whatever", - 'footer': { - 'duck': "quack" - } - }) - - def test_rejects_footer_with_empty_text(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': "whatever", - 'footer': { - 'text': "" - } - }) - - def test_allows_footer_with_proper_values(self): - validate_tag_embed({ - 'title': "whatever", - 'footer': { - 'text': "django good" - } - }) - - def test_rejects_author_as_non_mapping(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': "whatever", - 'author': [] - }) - - def test_rejects_author_with_unknown_field(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': "whatever", - 'author': { - 'field': "that is unknown" - } - }) - - def test_rejects_author_with_empty_name(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': "whatever", - 'author': { - 'name': "" - } - }) - - def test_rejects_author_with_one_correct_one_incorrect(self): - with self.assertRaises(ValidationError): - validate_tag_embed({ - 'title': "whatever", - 'author': { - # Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour - 'url': "bobswebsite.com", - 'name': "" - } - }) - - def test_allows_author_with_proper_values(self): - validate_tag_embed({ - 'title': "whatever", - 'author': { - 'name': "Bob" - } - }) diff --git a/pysite/apps/api/urls.py b/pysite/apps/api/urls.py deleted file mode 100644 index 6c89a52e..00000000 --- a/pysite/apps/api/urls.py +++ /dev/null @@ -1,86 +0,0 @@ -from django.urls import include, path -from rest_framework.routers import DefaultRouter - -from .views import HealthcheckView, RulesView -from .viewsets import ( - BotSettingViewSet, DeletedMessageViewSet, - DocumentationLinkViewSet, InfractionViewSet, - NominationViewSet, OffTopicChannelNameViewSet, - ReminderViewSet, RoleViewSet, - SnakeFactViewSet, SnakeIdiomViewSet, - SnakeNameViewSet, SpecialSnakeViewSet, - TagViewSet, UserViewSet -) - - -# http://www.django-rest-framework.org/api-guide/routers/#defaultrouter -bot_router = DefaultRouter(trailing_slash=False) -bot_router.register( - 'bot-settings', - BotSettingViewSet -) -bot_router.register( - 'deleted-messages', - DeletedMessageViewSet -) -bot_router.register( - 'documentation-links', - DocumentationLinkViewSet -) -bot_router.register( - 'infractions', - InfractionViewSet -) -bot_router.register( - 'nominations', - NominationViewSet -) -bot_router.register( - 'off-topic-channel-names', - OffTopicChannelNameViewSet, - base_name='offtopicchannelname' -) -bot_router.register( - 'reminders', - ReminderViewSet -) -bot_router.register( - 'roles', - RoleViewSet -) -bot_router.register( - 'snake-facts', - SnakeFactViewSet -) -bot_router.register( - 'snake-idioms', - SnakeIdiomViewSet -) -bot_router.register( - 'snake-names', - SnakeNameViewSet, - base_name='snakename' -) -bot_router.register( - 'special-snakes', - SpecialSnakeViewSet -) -bot_router.register( - 'tags', - TagViewSet -) -bot_router.register( - 'users', - UserViewSet -) - -app_name = 'api' -urlpatterns = ( - # Build URLs using something like... - # - # from django_hosts.resolvers import reverse - # snake_name_endpoint = reverse('bot:snakename-list', host='api') # `bot/` endpoints - path('bot/', include((bot_router.urls, 'api'), namespace='bot')), - path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), - path('rules', RulesView.as_view(), name='rules') -) diff --git a/pysite/apps/api/validators.py b/pysite/apps/api/validators.py deleted file mode 100644 index 5159cdb3..00000000 --- a/pysite/apps/api/validators.py +++ /dev/null @@ -1,164 +0,0 @@ -from collections.abc import Mapping - -from django.core.exceptions import ValidationError -from django.core.validators import MaxLengthValidator, MinLengthValidator - - -def validate_tag_embed_fields(fields): - field_validators = { - 'name': (MaxLengthValidator(limit_value=256),), - 'value': (MaxLengthValidator(limit_value=1024),) - } - - for field in fields: - if not isinstance(field, Mapping): - raise ValidationError("Embed fields must be a mapping.") - - for field_name, value in field.items(): - if field_name not in field_validators: - raise ValidationError(f"Unknown embed field field: {field_name!r}.") - - for validator in field_validators[field_name]: - validator(value) - - -def validate_tag_embed_footer(footer): - field_validators = { - 'text': ( - MinLengthValidator( - limit_value=1, - message="Footer text must not be empty." - ), - MaxLengthValidator(limit_value=2048) - ), - 'icon_url': (), - 'proxy_icon_url': () - } - - if not isinstance(footer, Mapping): - raise ValidationError("Embed footer must be a mapping.") - - for field_name, value in footer.items(): - if field_name not in field_validators: - raise ValidationError(f"Unknown embed footer field: {field_name!r}.") - - for validator in field_validators[field_name]: - validator(value) - - -def validate_tag_embed_author(author): - field_validators = { - 'name': ( - MinLengthValidator( - limit_value=1, - message="Embed author name must not be empty." - ), - MaxLengthValidator(limit_value=256) - ), - 'url': (), - 'icon_url': (), - 'proxy_icon_url': () - } - - if not isinstance(author, Mapping): - raise ValidationError("Embed author must be a mapping.") - - for field_name, value in author.items(): - if field_name not in field_validators: - raise ValidationError(f"Unknown embed author field: {field_name!r}.") - - for validator in field_validators[field_name]: - validator(value) - - -def validate_tag_embed(embed): - """ - Validate a JSON document containing an embed as possible to send - on Discord. This attempts to rebuild the validation used by Discord - as well as possible by checking for various embed limits so we can - ensure that any embed we store here will also be accepted as a - valid embed by the Discord API. - - Using this directly is possible, although not intended - you usually - stick this onto the `validators` keyword argument of model fields. - - Example: - - >>> from django.contrib.postgres import fields as pgfields - >>> from django.db import models - >>> from pysite.apps.api import validate_tag_embed - >>> class MyMessage(models.Model): - ... embed = pgfields.JSONField( - ... validators=( - ... validate_tag_embed, - ... ) - ... ) - ... # ... - ... - - Args: - embed (Dict[str, Union[str, List[dict], dict]]): - A dictionary describing the contents of this embed. - See the official documentation for a full reference - of accepted keys by this dictionary: - https://discordapp.com/developers/docs/resources/channel#embed-object - - Raises: - ValidationError: - In case the given embed is deemed invalid, a `ValidationError` - is raised which in turn will allow Django to display errors - as appropriate. - """ - - all_keys = { - 'title', 'type', 'description', 'url', 'timestamp', - 'color', 'footer', 'image', 'thumbnail', 'video', - 'provider', 'author', 'fields' - } - one_required_of = {'description', 'fields', 'image', 'title', 'video'} - field_validators = { - 'title': ( - MinLengthValidator( - limit_value=1, - message="Embed title must not be empty." - ), - MaxLengthValidator(limit_value=256) - ), - 'description': (MaxLengthValidator(limit_value=2048),), - 'fields': ( - MaxLengthValidator(limit_value=25), - validate_tag_embed_fields - ), - 'footer': (validate_tag_embed_footer,), - 'author': (validate_tag_embed_author,) - } - - if not embed: - raise ValidationError("Tag embed must not be empty.") - - elif not isinstance(embed, Mapping): - raise ValidationError("Tag embed must be a mapping.") - - elif not any(field in embed for field in one_required_of): - raise ValidationError(f"Tag embed must contain one of the fields {one_required_of}.") - - for required_key in one_required_of: - if required_key in embed and not embed[required_key]: - raise ValidationError(f"Key {required_key!r} must not be empty.") - - for field_name, value in embed.items(): - if field_name not in all_keys: - raise ValidationError(f"Unknown field name: {field_name!r}") - - if field_name in field_validators: - for validator in field_validators[field_name]: - validator(value) - - -def validate_bot_setting_name(name): - KNOWN_SETTINGS = ( - 'defcon', - ) - - if name not in KNOWN_SETTINGS: - raise ValidationError(f"`{name}` is not a known setting name.") diff --git a/pysite/apps/api/views.py b/pysite/apps/api/views.py deleted file mode 100644 index f88e1039..00000000 --- a/pysite/apps/api/views.py +++ /dev/null @@ -1,161 +0,0 @@ -from rest_framework.exceptions import ParseError -from rest_framework.response import Response -from rest_framework.views import APIView - - -class HealthcheckView(APIView): - """ - Provides a simple view to check that the website is alive and well. - - ## Routes - ### GET /healthcheck - Returns a simple JSON document showcasing whether the system is working: - - >>> { - ... 'status': 'ok' - ... } - - Seems to be. - - ## Authentication - Does not require any authentication nor permissions. - """ - - authentication_classes = () - permission_classes = () - - def get(self, request, format=None): # noqa - return Response({'status': 'ok'}) - - -class RulesView(APIView): - """ - Return a list of the server's rules. - - ## Routes - ### GET /rules - Returns a JSON array containing the server's rules: - - >>> [ - ... "Eat candy.", - ... "Wake up at 4 AM.", - ... "Take your medicine." - ... ] - - Since some of the the rules require links, this view - gives you the option to return rules in either Markdown - or HTML format by specifying the `link_format` query parameter - as either `md` or `html`. Specifying a different value than - `md` or `html` will return 400. - - ## Authentication - Does not require any authentication nor permissions. - """ - - authentication_classes = () - permission_classes = () - - @staticmethod - def _format_link(description, link, target): - """ - Build the markup necessary to render `link` with `description` - as its description in the given `target` language. - - Arguments: - description (str): - A textual description of the string. Represents the content - between the `` tags in HTML, or the content between the - array brackets in Markdown. - - link (str): - The resulting link that a user should be redirected to - upon clicking the generated element. - - target (str): - One of `{'md', 'html'}`, denoting the target format that the - link should be rendered in. - - Returns: - str: - The link, rendered appropriately for the given `target` format - using `description` as its textual description. - - Raises: - ValueError: - If `target` is not `'md'` or `'html'`. - """ - - if target == 'html': - return f'{description}' - elif target == 'md': - return f'[{description}]({link})' - else: - raise ValueError( - f"Can only template links to `html` or `md`, got `{target}`" - ) - - # `format` here is the result format, we have a link format here instead. - def get(self, request, format=None): # noqa - link_format = request.query_params.get('link_format', 'md') - if link_format not in ('html', 'md'): - raise ParseError( - f"`format` must be `html` or `md`, got `{format}`." - ) - - discord_community_guidelines_link = self._format_link( - 'Discord Community Guidelines', - 'https://discordapp.com/guidelines', - link_format - ) - channels_page_link = self._format_link( - 'channels page', - 'https://pythondiscord.com/about/channels', - link_format - ) - google_translate_link = self._format_link( - 'Google Translate', - 'https://translate.google.com/', - link_format - ) - - return Response([ - "Be polite, and do not spam.", - f"Follow the {discord_community_guidelines_link}.", - ( - "Don't intentionally make other people uncomfortable - if " - "someone asks you to stop discussing something, you should stop." - ), - ( - "Be patient both with users asking " - "questions, and the users answering them." - ), - ( - "We will not help you with anything that might break a law or the " - "terms of service of any other community, pysite, service, or " - "otherwise - No piracy, brute-forcing, captcha circumvention, " - "sneaker bots, or anything else of that nature." - ), - ( - "Listen to and respect the staff members - we're " - "here to help, but we're all human beings." - ), - ( - "All discussion should be kept within the relevant " - "channels for the subject - See the " - f"{channels_page_link} for more information." - ), - ( - "This is an English-speaking server, so please speak English " - f"to the best of your ability - {google_translate_link} " - "should be fine if you're not sure." - ), - ( - "Keep all discussions safe for work - No gore, nudity, sexual " - "soliciting, references to suicide, or anything else of that nature" - ), - ( - "We do not allow advertisements for communities (including " - "other Discord servers) or commercial projects - Contact " - "us directly if you want to discuss a partnership!" - ) - ]) diff --git a/pysite/apps/api/viewsets.py b/pysite/apps/api/viewsets.py deleted file mode 100644 index 0bd6149f..00000000 --- a/pysite/apps/api/viewsets.py +++ /dev/null @@ -1,890 +0,0 @@ -from django.shortcuts import get_object_or_404 -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.decorators import action -from rest_framework.exceptions import ParseError, ValidationError -from rest_framework.filters import SearchFilter -from rest_framework.mixins import ( - CreateModelMixin, DestroyModelMixin, - ListModelMixin, RetrieveModelMixin, - UpdateModelMixin -) -from rest_framework.response import Response -from rest_framework.status import HTTP_201_CREATED -from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet -from rest_framework_bulk import BulkCreateModelMixin - -from .models import ( - BotSetting, DocumentationLink, - Infraction, MessageDeletionContext, - Nomination, OffTopicChannelName, - Reminder, Role, - SnakeFact, SnakeIdiom, - SnakeName, SpecialSnake, - Tag, User -) -from .serializers import ( - BotSettingSerializer, DocumentationLinkSerializer, - ExpandedInfractionSerializer, InfractionSerializer, - MessageDeletionContextSerializer, NominationSerializer, - OffTopicChannelNameSerializer, ReminderSerializer, - RoleSerializer, SnakeFactSerializer, - SnakeIdiomSerializer, SnakeNameSerializer, - SpecialSnakeSerializer, TagSerializer, - UserSerializer -) - - -class BotSettingViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet): - """ - View providing update operations on bot setting routes. - """ - - serializer_class = BotSettingSerializer - queryset = BotSetting.objects.all() - - -class DeletedMessageViewSet(CreateModelMixin, GenericViewSet): - """ - View providing support for posting bulk deletion logs generated by the bot. - - ## Routes - ### POST /bot/deleted-messages - Post messages from bulk deletion logs. - - #### Body schema - >>> { - ... # The member ID of the original actor, if applicable. - ... # If a member ID is given, it must be present on the pysite. - ... 'actor': Optional[int] - ... 'creation': datetime, - ... 'messages': [ - ... { - ... 'id': int, - ... 'author': int, - ... 'channel_id': int, - ... 'content': str, - ... 'embeds': [ - ... # Discord embed objects - ... ] - ... } - ... ] - ... } - - #### Status codes - - 204: returned on success - """ - - queryset = MessageDeletionContext.objects.all() - serializer_class = MessageDeletionContextSerializer - - -class DocumentationLinkViewSet( - CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet -): - """ - View providing management of documentation links used in the bot's `Doc` cog. - - ## Routes - ### GET /bot/documentation-links - Retrieve all currently stored entries from the database. - - #### Response format - >>> [ - ... { - ... 'package': 'flask', - ... 'base_url': 'https://flask.pocoo.org/docs/dev', - ... 'inventory_url': 'https://flask.pocoo.org/docs/objects.inv' - ... }, - ... # ... - ... ] - - #### Status codes - - 200: returned on success - - ### GET /bot/documentation-links/ - Look up the documentation object for the given `package`. - - #### Response format - >>> { - ... 'package': 'flask', - ... 'base_url': 'https://flask.pocoo.org/docs/dev', - ... 'inventory_url': 'https://flask.pocoo.org/docs/objects.inv' - ... } - - #### Status codes - - 200: returned on success - - 404: if no entry for the given `package` exists - - ### POST /bot/documentation-links - Create a new documentation link object. - - #### Body schema - >>> { - ... 'package': str, - ... 'base_url': URL, - ... 'inventory_url': URL - ... } - - #### Status codes - - 201: returned on success - - 400: if the request body has invalid fields, see the response for details - - ### DELETE /bot/documentation-links/ - Delete the entry for the given `package`. - - #### Status codes - - 204: returned on success - - 404: if the given `package` could not be found - """ - - queryset = DocumentationLink.objects.all() - serializer_class = DocumentationLinkSerializer - lookup_field = 'package' - - -class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet): - """ - View providing CRUD operations on infractions for Discord users. - - ## Routes - ### GET /bot/infractions - Retrieve all infractions. - May be filtered by the query parameters. - - #### Query parameters - - **active** `bool`: whether the infraction is still active - - **actor** `int`: snowflake of the user which applied the infraction - - **hidden** `bool`: whether the infraction is a shadow infraction - - **search** `str`: regular expression applied to the infraction's reason - - **type** `str`: the type of the infraction - - **user** `int`: snowflake of the user to which the infraction was applied - - Invalid query parameters are ignored. - - #### Response format - >>> [ - ... { - ... 'id': 5, - ... 'inserted_at': '2018-11-22T07:24:06.132307Z', - ... 'expires_at': '5018-11-20T15:52:00Z', - ... 'active': False, - ... 'user': 172395097705414656, - ... 'actor': 125435062127820800, - ... 'type': 'ban', - ... 'reason': 'He terk my jerb!', - ... 'hidden': True - ... } - ... ] - - #### Status codes - - 200: returned on success - - ### GET /bot/infractions/ - Retrieve a single infraction by ID. - - #### Response format - See `GET /bot/infractions`. - - #### Status codes - - 200: returned on success - - 404: if an infraction with the given `id` could not be found - - ### POST /bot/infractions - Create a new infraction and return the created infraction. - Only `actor`, `type`, and `user` are required. - The `actor` and `user` must be users known by the pysite. - - #### Request body - >>> { - ... 'active': False, - ... 'actor': 125435062127820800, - ... 'expires_at': '5018-11-20T15:52:00+00:00', - ... 'hidden': True, - ... 'type': 'ban', - ... 'reason': 'He terk my jerb!', - ... 'user': 172395097705414656 - ... } - - #### Response format - See `GET /bot/infractions`. - - #### Status codes - - 201: returned on success - - 400: if a given user is unknown or a field in the request body is invalid - - ### PATCH /bot/infractions/ - Update the infraction with the given `id` and return the updated infraction. - Only `active`, `reason`, and `expires_at` may be updated. - - #### Request body - >>> { - ... 'active': True, - ... 'expires_at': '4143-02-15T21:04:31+00:00', - ... 'reason': 'durka derr' - ... } - - #### Response format - See `GET /bot/infractions`. - - #### Status codes - - 200: returned on success - - 400: if a field in the request body is invalid or disallowed - - 404: if an infraction with the given `id` could not be found - - ### Expanded routes - All routes support expansion of `user` and `actor` in responses. To use an expanded route, - append `/expanded` to the end of the route e.g. `GET /bot/infractions/expanded`. - - #### Response format - See `GET /bot/users/` for the expanded formats of `user` and `actor`. Responses - are otherwise identical to their non-expanded counterparts. - """ - - serializer_class = InfractionSerializer - queryset = Infraction.objects.all() - filter_backends = (DjangoFilterBackend, SearchFilter) - filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type') - search_fields = ('$reason',) - frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') - - def partial_update(self, request, *args, **kwargs): - for field in request.data: - if field in self.frozen_fields: - raise ValidationError({field: ['This field cannot be updated.']}) - - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() - - return Response(serializer.data) - - @action(url_path='expanded', detail=False) - def list_expanded(self, *args, **kwargs): - self.serializer_class = ExpandedInfractionSerializer - return self.list(*args, **kwargs) - - @list_expanded.mapping.post - def create_expanded(self, *args, **kwargs): - self.serializer_class = ExpandedInfractionSerializer - return self.create(*args, **kwargs) - - @action(url_path='expanded', url_name='detail-expanded', detail=True) - def retrieve_expanded(self, *args, **kwargs): - self.serializer_class = ExpandedInfractionSerializer - return self.retrieve(*args, **kwargs) - - @retrieve_expanded.mapping.patch - def partial_update_expanded(self, *args, **kwargs): - self.serializer_class = ExpandedInfractionSerializer - return self.partial_update(*args, **kwargs) - - -class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet): - """ - View of off-topic channel names used by the bot - to rotate our off-topic names on a daily basis. - - ## Routes - ### GET /bot/off-topic-channel-names - Return all known off-topic channel names from the database. - If the `random_items` query parameter is given, for example using... - $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?random_items=5 - ... then the API will return `5` random items from the database. - - #### Response format - Return a list of off-topic-channel names: - >>> [ - ... "lemons-lemonade-stand", - ... "bbq-with-bisk" - ... ] - - #### Status codes - - 200: returned on success - - 400: returned when `random_items` is not a positive integer - - ### POST /bot/off-topic-channel-names - Create a new off-topic-channel name in the database. - The name must be given as a query parameter, for example: - $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?name=lemons-lemonade-shop - - #### Status codes - - 201: returned on success - - 400: if the request body has invalid fields, see the response for details - - ### DELETE /bot/off-topic-channel-names/ - Delete the off-topic-channel name with the given `name`. - - #### Status codes - - 204: returned on success - - 404: returned when the given `name` was not found - - ## Authentication - Requires a API token. - """ - - lookup_field = 'name' - serializer_class = OffTopicChannelNameSerializer - - def get_object(self): - queryset = self.get_queryset() - name = self.kwargs[self.lookup_field] - return get_object_or_404(queryset, name=name) - - def get_queryset(self): - return OffTopicChannelName.objects.all() - - def create(self, request): - if 'name' in request.query_params: - create_data = {'name': request.query_params['name']} - serializer = OffTopicChannelNameSerializer(data=create_data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(create_data, status=HTTP_201_CREATED) - - else: - raise ParseError(detail={ - 'name': ["This query parameter is required."] - }) - - def list(self, request): # noqa - if 'random_items' in request.query_params: - param = request.query_params['random_items'] - try: - random_count = int(param) - except ValueError: - raise ParseError(detail={'random_items': ["Must be a valid integer."]}) - - if random_count <= 0: - raise ParseError(detail={ - 'random_items': ["Must be a positive integer."] - }) - - queryset = self.get_queryset().order_by('?')[:random_count] - serialized = self.serializer_class(queryset, many=True) - return Response(serialized.data) - - queryset = self.get_queryset() - serialized = self.serializer_class(queryset, many=True) - return Response(serialized.data) - - -class ReminderViewSet(CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet): - """ - View providing CRUD access to reminders. - - ## Routes - ### GET /bot/reminders - Returns all reminders in the database. - - #### Response format - >>> [ - ... { - ... 'active': True, - ... 'author': 1020103901030, - ... 'content': "Make dinner", - ... 'expiration': '5018-11-20T15:52:00Z' - ... 'id': 11 - ... }, - ... ... - ... ] - - #### Status codes - - 200: returned on success - - ### POST /bot/reminders - Create a new reminder. - - #### Request body - >>> { - ... 'author': int, - ... 'content': str, - ... 'expiration': str # ISO-formatted datetime - ... } - - #### Status codes - - 201: returned on success - - 400: if the body format is invalid - - 404: if no user with the given ID could be found - - ### DELETE /bot/reminders/ - Delete the reminder with the given `id`. - - #### Status codes - - 204: returned on success - - 404: if a reminder with the given `id` does not exist - - ## Authentication - Requires an API token. - """ - - serializer_class = ReminderSerializer - queryset = Reminder.objects.prefetch_related('author') - filter_backends = (DjangoFilterBackend, SearchFilter) - filter_fields = ('active', 'author__id') - - -class RoleViewSet(ModelViewSet): - """ - View providing CRUD access to the roles on our server, used - by the bot to keep a mirror of our server's roles on the pysite. - - ## Routes - ### GET /bot/roles - Returns all roles in the database. - - #### Response format - >>> [ - ... { - ... 'id': 267628507062992896, - ... 'name': "Admins", - ... 'colour': 1337, - ... 'permissions': 8 - ... } - ... ] - - #### Status codes - - 200: returned on success - - ### GET /bot/roles/ - Gets a single role by ID. - - #### Response format - >>> { - ... 'id': 267628507062992896, - ... 'name': "Admins", - ... 'colour': 1337, - ... 'permissions': 8 - ... } - - #### Status codes - - 200: returned on success - - 404: if a role with the given `snowflake` could not be found - - ### POST /bot/roles - Adds a single new role. - - #### Request body - >>> { - ... 'id': int, - ... 'name': str, - ... 'colour': int, - ... 'permissions': int, - ... } - - #### Status codes - - 201: returned on success - - 400: if the body format is invalid - - ### PUT /bot/roles/ - Update the role with the given `snowflake`. - All fields in the request body are required. - - #### Request body - >>> { - ... 'id': int, - ... 'name': str, - ... 'colour': int, - ... 'permissions': int - ... } - - #### Status codes - - 200: returned on success - - 400: if the request body was invalid - - ### PATCH /bot/roles/ - Update the role with the given `snowflake`. - All fields in the request body are required. - - >>> { - ... 'id': int, - ... 'name': str, - ... 'colour': int, - ... 'permissions': int - ... } - - ### DELETE /bot/roles/ - Deletes the role with the given `snowflake`. - - #### Status codes - - 204: returned on success - - 404: if a role with the given `snowflake` does not exist - """ - - queryset = Role.objects.all() - serializer_class = RoleSerializer - - -class SnakeFactViewSet(ListModelMixin, GenericViewSet): - """ - View providing snake facts created by the Pydis community in the first code jam. - - ## Routes - ### GET /bot/snake-facts - Returns snake facts from the database. - - #### Response format - >>> [ - ... {'fact': 'Snakes are dangerous'}, - ... {'fact': 'Except for Python, we all love it'} - ... ] - - #### Status codes - - 200: returned on success - - ## Authentication - Requires an API token. - """ - - serializer_class = SnakeFactSerializer - queryset = SnakeFact.objects.all() - - -class SnakeIdiomViewSet(ListModelMixin, GenericViewSet): - """ - View providing snake idioms for the snake cog. - - ## Routes - ### GET /bot/snake-idioms - Returns snake idioms from the database. - - #### Response format - >>> [ - ... {'idiom': 'Sneky snek'}, - ... {'idiom': 'Snooky Snake'} - ... ] - - #### Status codes - - 200: returned on success - - ## Authentication - Requires an API token - """ - - serializer_class = SnakeIdiomSerializer - queryset = SnakeIdiom.objects.all() - - -class SnakeNameViewSet(ViewSet): - """ - View providing snake names for the bot's snake cog from our first code jam's winners. - - ## Routes - ### GET /bot/snake-names - By default, return a single random snake name along with its name and scientific name. - If the `get_all` query parameter is given, for example using... - $ curl api.pythondiscord.local:8000/bot/snake-names?get_all=yes - ... then the API will return all snake names and scientific names in the database. - - #### Response format - Without `get_all` query parameter: - >>> { - ... 'name': "Python", - ... 'scientific': "Langus greatus" - ... } - - If the database is empty for whatever reason, this will return an empty dictionary. - - With `get_all` query parameter: - >>> [ - ... {'name': "Python 3", 'scientific': "Langus greatus"}, - ... {'name': "Python 2", 'scientific': "Langus decentus"} - ... ] - - #### Status codes - - 200: returned on success - - ## Authentication - Requires a API token. - """ - - serializer_class = SnakeNameSerializer - - def get_queryset(self): - return SnakeName.objects.all() - - def list(self, request): # noqa - if request.query_params.get('get_all'): - queryset = self.get_queryset() - serialized = self.serializer_class(queryset, many=True) - return Response(serialized.data) - - single_snake = SnakeName.objects.order_by('?').first() - if single_snake is not None: - body = { - 'name': single_snake.name, - 'scientific': single_snake.scientific - } - - return Response(body) - - return Response({}) - - -class SpecialSnakeViewSet(ListModelMixin, GenericViewSet): - """ - View providing special snake names for our bot's snake cog. - - ## Routes - ### GET /bot/special-snakes - Returns a list of special snake names. - - #### Response Format - >>> [ - ... { - ... 'name': 'Snakky sneakatus', - ... 'info': 'Scary snek', - ... 'image': 'https://discordapp.com/assets/53ef346458017da2062aca5c7955946b.svg' - ... } - ... ] - - #### Status codes - - 200: returned on success - - ## Authentication - Requires an API token. - """ - - serializer_class = SpecialSnakeSerializer - queryset = SpecialSnake.objects.all() - - -class TagViewSet(ModelViewSet): - """ - View providing CRUD operations on tags shown by our bot. - - ## Routes - ### GET /bot/tags - Returns all tags in the database. - - #### Response format - >>> [ - ... { - ... 'title': "resources", - ... 'embed': { - ... 'content': "Did you really think I'd put something useful here?" - ... } - ... } - ... ] - - #### Status codes - - 200: returned on success - - ### GET /bot/tags/ - Gets a single tag by its title. - - #### Response format - >>> { - ... 'title': "My awesome tag", - ... 'embed': { - ... 'content': "totally not filler words" - ... } - ... } - - #### Status codes - - 200: returned on success - - 404: if a tag with the given `title` could not be found - - ### POST /bot/tags - Adds a single tag to the database. - - #### Request body - >>> { - ... 'title': str, - ... 'embed': dict - ... } - - The embed structure is the same as the embed structure that the Discord API - expects. You can view the documentation for it here: - https://discordapp.com/developers/docs/resources/channel#embed-object - - #### Status codes - - 201: returned on success - - 400: if one of the given fields is invalid - - ### PUT /bot/tags/ - Update the tag with the given `title`. - - #### Request body - >>> { - ... 'title': str, - ... 'embed': dict - ... } - - The embed structure is the same as the embed structure that the Discord API - expects. You can view the documentation for it here: - https://discordapp.com/developers/docs/resources/channel#embed-object - - #### Status codes - - 200: returned on success - - 400: if the request body was invalid, see response body for details - - 404: if the tag with the given `title` could not be found - - ### PATCH /bot/tags/ - Update the tag with the given `title`. - - #### Request body - >>> { - ... 'title': str, - ... 'embed': dict - ... } - - The embed structure is the same as the embed structure that the Discord API - expects. You can view the documentation for it here: - https://discordapp.com/developers/docs/resources/channel#embed-object - - #### Status codes - - 200: returned on success - - 400: if the request body was invalid, see response body for details - - 404: if the tag with the given `title` could not be found - - ### DELETE /bot/tags/ - Deletes the tag with the given `title`. - - #### Status codes - - 204: returned on success - - 404: if a tag with the given `title` does not exist - """ - - serializer_class = TagSerializer - queryset = Tag.objects.all() - - -class UserViewSet(BulkCreateModelMixin, ModelViewSet): - """ - View providing CRUD operations on Discord users through the bot. - - ## Routes - ### GET /bot/users - Returns all users currently known. - - #### Response format - >>> [ - ... { - ... 'id': 409107086526644234, - ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb", - ... 'name': "Python", - ... 'discriminator': 4329, - ... 'roles': [ - ... 352427296948486144, - ... 270988689419665409, - ... 277546923144249364, - ... 458226699344019457 - ... ], - ... 'in_guild': True - ... } - ... ] - - #### Status codes - - 200: returned on success - - ### GET /bot/users/ - Gets a single user by ID. - - #### Response format - >>> { - ... 'id': 409107086526644234, - ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb", - ... 'name': "Python", - ... 'discriminator': 4329, - ... 'roles': [ - ... 352427296948486144, - ... 270988689419665409, - ... 277546923144249364, - ... 458226699344019457 - ... ], - ... 'in_guild': True - ... } - - #### Status codes - - 200: returned on success - - 404: if a user with the given `snowflake` could not be found - - ### POST /bot/users - Adds a single or multiple new users. - The roles attached to the user(s) must be roles known by the pysite. - - #### Request body - >>> { - ... 'id': int, - ... 'avatar': str, - ... 'name': str, - ... 'discriminator': int, - ... 'roles': List[int], - ... 'in_guild': bool - ... } - - Alternatively, request users can be POSTed as a list of above objects, - in which case multiple users will be created at once. - - #### Status codes - - 201: returned on success - - 400: if one of the given roles does not exist, or one of the given fields is invalid - - ### PUT /bot/users/ - Update the user with the given `snowflake`. - All fields in the request body are required. - - #### Request body - >>> { - ... 'id': int, - ... 'avatar': str, - ... 'name': str, - ... 'discriminator': int, - ... 'roles': List[int], - ... 'in_guild': bool - ... } - - #### Status codes - - 200: returned on success - - 400: if the request body was invalid, see response body for details - - 404: if the user with the given `snowflake` could not be found - - ### PATCH /bot/users/ - Update the user with the given `snowflake`. - All fields in the request body are optional. - - #### Request body - >>> { - ... 'id': int, - ... 'avatar': str, - ... 'name': str, - ... 'discriminator': int, - ... 'roles': List[int], - ... 'in_guild': bool - ... } - - #### Status codes - - 200: returned on success - - 400: if the request body was invalid, see response body for details - - 404: if the user with the given `snowflake` could not be found - - ### DELETE /bot/users/ - Deletes the user with the given `snowflake`. - - #### Status codes - - 204: returned on success - - 404: if a user with the given `snowflake` does not exist - """ - - serializer_class = UserSerializer - queryset = User.objects.prefetch_related('roles') - - -class NominationViewSet(ModelViewSet): - # TODO: doc me - serializer_class = NominationSerializer - queryset = Nomination.objects.prefetch_related('author', 'user') - frozen_fields = ('author', 'inserted_at', 'user') - - def update(self, request, *args, **kwargs): - for field in request.data: - if field in self.frozen_fields: - raise ValidationError({field: ['This field cannot be updated.']}) - - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() - - return Response(serializer.data) diff --git a/pysite/apps/home/__init__.py b/pysite/apps/home/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/home/admin.py b/pysite/apps/home/admin.py deleted file mode 100644 index 4185d360..00000000 --- a/pysite/apps/home/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.contrib import admin - -# Register your models here. diff --git a/pysite/apps/home/apps.py b/pysite/apps/home/apps.py deleted file mode 100644 index 90dc7137..00000000 --- a/pysite/apps/home/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class HomeConfig(AppConfig): - name = 'home' diff --git a/pysite/apps/home/migrations/__init__.py b/pysite/apps/home/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/home/models.py b/pysite/apps/home/models.py deleted file mode 100644 index 0b4331b3..00000000 --- a/pysite/apps/home/models.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.db import models - -# Create your models here. diff --git a/pysite/apps/home/tests.py b/pysite/apps/home/tests.py deleted file mode 100644 index 54fac6e8..00000000 --- a/pysite/apps/home/tests.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.test import TestCase -from django_hosts.resolvers import reverse - - -class TestIndexReturns200(TestCase): - def test_index_returns_200(self): - url = reverse('index') - resp = self.client.get(url) - self.assertEqual(resp.status_code, 200) diff --git a/pysite/apps/home/urls.py b/pysite/apps/home/urls.py deleted file mode 100644 index a01e019e..00000000 --- a/pysite/apps/home/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.contrib import admin -from django.urls import path -from django.views.generic import TemplateView - - -app_name = 'home' -urlpatterns = [ - path('', TemplateView.as_view(template_name='home/index.html'), name='index'), - path('admin/', admin.site.urls) -] diff --git a/pysite/apps/home/views.py b/pysite/apps/home/views.py deleted file mode 100644 index fd0e0449..00000000 --- a/pysite/apps/home/views.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.shortcuts import render - -# Create your views here. diff --git a/pysite/apps/wiki/__init__.py b/pysite/apps/wiki/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/wiki/admin.py b/pysite/apps/wiki/admin.py deleted file mode 100644 index 4185d360..00000000 --- a/pysite/apps/wiki/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.contrib import admin - -# Register your models here. diff --git a/pysite/apps/wiki/apps.py b/pysite/apps/wiki/apps.py deleted file mode 100644 index fce4708e..00000000 --- a/pysite/apps/wiki/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class WikiConfig(AppConfig): - name = 'wiki' diff --git a/pysite/apps/wiki/migrations/__init__.py b/pysite/apps/wiki/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pysite/apps/wiki/models.py b/pysite/apps/wiki/models.py deleted file mode 100644 index 0b4331b3..00000000 --- a/pysite/apps/wiki/models.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.db import models - -# Create your models here. diff --git a/pysite/apps/wiki/tests.py b/pysite/apps/wiki/tests.py deleted file mode 100644 index a79ca8be..00000000 --- a/pysite/apps/wiki/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.test import TestCase - -# Create your tests here. diff --git a/pysite/apps/wiki/views.py b/pysite/apps/wiki/views.py deleted file mode 100644 index fd0e0449..00000000 --- a/pysite/apps/wiki/views.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.shortcuts import render - -# Create your views here. diff --git a/pysite/hosts.py b/pysite/hosts.py deleted file mode 100644 index bb8f867d..00000000 --- a/pysite/hosts.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.conf import settings -from django_hosts import host, patterns - -host_patterns = patterns( - '', - # > | Subdomain | URL Module | Host entry name | - host(r'admin', 'pysite.apps.admin.urls', name="admin"), - host(r'api', 'pysite.apps.api.urls', name='api'), - # host(r"staff", "pysite.apps.staff", name="staff"), - # host(r"wiki", "pysite.apps.wiki", name="wiki"), - # host(r"ws", "pysite.apps. ws", name="ws"), - host(r'.*', 'pysite.apps.home.urls', name=settings.DEFAULT_HOST) -) diff --git a/pysite/settings.py b/pysite/settings.py deleted file mode 100644 index 4bcc27b4..00000000 --- a/pysite/settings.py +++ /dev/null @@ -1,259 +0,0 @@ -""" -Django settings for pysite project. - -Generated by 'django-admin startproject' using Django 2.1. - -For more information on this file, see -https://docs.djangoproject.com/en/2.1/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.1/ref/settings/ -""" - -import os -import sys - -import environ - - -env = environ.Env( - DEBUG=(bool, False) -) - - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -DEBUG = env('DEBUG') - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -if DEBUG: - ALLOWED_HOSTS = [ - 'pythondiscord.local', - 'admin.pythondiscord.local', - 'api.pythondiscord.local', - 'staff.pythondiscord.local', - 'wiki.pythondiscord.local' - ] - SECRET_KEY = "+_x00w3e94##2-qm-v(5&-x_@*l3t9zlir1etu+7$@4%!it2##" - -elif 'CI' in os.environ: - ALLOWED_HOSTS = ['*'] - SECRET_KEY = "{©ø¬½.Þ7&Ñ`Q^Kº*~¢j - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pysite/static/assets/logo-discord.png b/pysite/static/assets/logo-discord.png deleted file mode 100644 index 2bf74ffd..00000000 Binary files a/pysite/static/assets/logo-discord.png and /dev/null differ diff --git a/pysite/static/css/navbar.css b/pysite/static/css/navbar.css deleted file mode 100644 index db4b85e7..00000000 --- a/pysite/static/css/navbar.css +++ /dev/null @@ -1,4 +0,0 @@ -.navbar-icon { - max-height: 3em; - margin: 0.5em 0 0.5em 2.5em; -} diff --git a/pysite/static/home/css/index.css b/pysite/static/home/css/index.css deleted file mode 100644 index 76653320..00000000 --- a/pysite/static/home/css/index.css +++ /dev/null @@ -1,30 +0,0 @@ -html { - background-color: #7289DA; -} - -.overview > h1 { - margin-top: 0.5em; - margin-bottom: -0.25em; -} - -.overview > p.is-size-7 { - margin-bottom: 2em; -} - -.overview > p.is-size-4 { - margin-bottom: 1em; -} - -.overview > p.is-size-6 { - margin-bottom: 1em; -} - -.overview > img { - border: 1px solid #6378BF; - margin-bottom: 1em; -} - -.overview > .divider { - letter-spacing: -3px; - margin-bottom: 1em; -} diff --git a/pysite/templates/base.html b/pysite/templates/base.html deleted file mode 100644 index 1dcdfdc4..00000000 --- a/pysite/templates/base.html +++ /dev/null @@ -1,22 +0,0 @@ -{# Base template, with a few basic style definitions. #} -{% load django_simple_bulma %} -{% load static %} - - - - - - Python Discord | {% block page_title %}Website{% endblock %} - - - {% bulma %} - {% font_awesome %} - {% block head %}{% endblock %} - - - {% block body %} - {% endblock %} - - - - diff --git a/pysite/templates/home/index.html b/pysite/templates/home/index.html deleted file mode 100644 index cc99763b..00000000 --- a/pysite/templates/home/index.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends 'navbar.html' %} -{% load static %} - -{% block page_title %}Home{% endblock %} -{% block head %} - {{ block.super }} - -{% endblock %} -{% block body %} - {{ block.super }} -
-

Python Discord

-

- The official Discord server of - r/Python -

- -

- We're a large, friendly community focused around the Python programming language, open to those - who wish to learn the language or improve their skills, as well as those looking to help others. -

- -

- We organise regular community events and have a dedicated staff of talented Python - developers available to assist around the clock. Whether you're looking to learn the - language or working on a complex project, we've got someone who can help you if you get stuck. -

- - - -

- ------------------------------------------------------------------------------------------------------------           O          ------------------------------------------------------------------------------------------------------------- -

- -

- Please note: this site is under construction. What you see now may be vastly different - from the final project state. Feel free to chat to us on Discord if you're curious! -

-
-{% endblock %} - - diff --git a/pysite/templates/navbar.html b/pysite/templates/navbar.html deleted file mode 100644 index 0efa51c2..00000000 --- a/pysite/templates/navbar.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block head %} - -{% endblock %} -{% block body %} - - {{ block.super }} -{% endblock %} - - diff --git a/pysite/urls.py b/pysite/urls.py deleted file mode 100644 index 25ce2106..00000000 --- a/pysite/urls.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.urls import include, path - - -urlpatterns = ( - path('', include('pysite.apps.home.urls', namespace='home')), -) diff --git a/pysite/wsgi.py b/pysite/wsgi.py deleted file mode 100644 index 0a7ca6bd..00000000 --- a/pysite/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for pysite project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pysite.settings') - -application = get_wsgi_application() -- cgit v1.2.3