This is a Django app for running puzzlehunts, written by several members of ✈✈✈ Galactic Trendsetters ✈✈✈ for running the Galactic Puzzle Hunt.
This repository is not formally maintained; expect it to be based on an export of the most recently run GPH, with any updates at other times of year being best-effort. In particular, while we've stripped out most of the hunt-specific logic, some fundamental pieces are still in place: GPH 2019 unlocked puzzles sequentially using DEEP (a combination of time and solves), while GPH 2020 had puzzles divided by round, so those conditions are reflected in the model schema and unlocking code. If you want to do something different, say an Australian-style hunt, it should still be possible, but you'll have to do some legwork.
We will try to respond to emails or pull requests when we can, but this isn't guaranteed, especially during the months when we aren't actively working on the current GPH. As several other online hunts have used this codebase, you may be able to direct questions to one of them, although at this time there does not seem to be any listing of such teams.
- Set up your environment.
- Make sure you have Python 3 and the corresponding
pip
. (This may be namedpip3
depending on your environment.) - We recommend that you install virtualenv:
pip install virtualenv
. This allows you to install this project's dependencies into a "virtual environment" contained in this directory.- You can also proceed without virtualenv (skip these steps), which will install the dependencies globally. This is not recommended if you develop in other Python projects on the same machine.
- Create a virtualenv:
virtualenv venv
- If you have both Python 2 and Python 3 on your system, use the
-p
argument tovirtualenv
to point to the correct Python runtime, for example:virtualenv venv -p python3
- If this doesn't work, you may have to add to your path; try
python -m virtualenv venv
instead if you don't want to do that.
- If you have both Python 2 and Python 3 on your system, use the
- Activate the virtualenv with
source venv/bin/activate
- Alternatively use
venv/bin/activate.csh
orvenv/bin/activate.fish
if you're using csh or fish. - Use
venv\Scripts\activate
on Windows. - You should run this command each time before you start working on this app.
- Alternatively use
- Later, when you're done working on the project and want to leave the virtualenv, run
deactivate
.
- Make sure you have Python 3 and the corresponding
- Install the required packages:
pip install -r requirements.txt
- Are you getting
fatal error: Python.h: No such file or directory
? Try installingpython-dev
libraries first (e.g.sudo apt-get install python3-dev
).
- Are you getting
- Start the development server with
./manage.py runserver
- If you get a warning (red text) about making migrations, stop the server, run
./manage.py migrate
, then start it again. - If all went well, the dev server should start, printing its local URL to stdout.
- If you get a warning (red text) about making migrations, stop the server, run
- We rely on Redis, specifically for WebSocket support and rate limiting. Unfortunately, our deploy configuration doesn't do a good job of ensuring a compatible Redis environment. This could use some attention from someone who understands Ansible.
- Our database writes are not atomic; if a request handler loads a model instance, does some other stuff, then calls
.save()
, that will save all the fields of the object and possibly overwrite some other handler that ran in the meantime. Our schema so happens to be set up so that (apart from Hints) we don't often have to update existing objects at all, let alone within fractions of a second of each other in a non-idempotent way. But we could address this with transactions, shortening the time between read and write, and/or limiting the fields written. - In production we use gunicorn. It does not appear that gunicorn has a rolling restart mechanism. That is, even though it uses many worker processes, all of those workers die and restart at the same time when redeploying the server, which leads to many noticeable seconds of downtime. It would be nice to fix this.
-
...even?
- The site is built on Django, which has a lot of features and is pretty self-contained. Usually, you will start a local server with
./manage.py runserver
and make changes within thepuzzles/
subdirectory.runserver
will watch for code changes and automatically restart if needed.
- The site is built on Django, which has a lot of features and is pretty self-contained. Usually, you will start a local server with
-
...set up the database?
-
The site is set up to use a
db.sqlite3
file in the root of this repository as its database. If this doesn't exist, Django will create a new empty database when you run./manage.py migrate
. It's perfectly fine to start with this, but you won't have any puzzles populated and you almost certainly want to create a superuser.If you just want to try out the website quickly with some sample data, you can run
./manage.py loaddata sample.yaml
(after./manage.py migrate
) to load a sample hunt, an admin account (username and passwordadmin
), and a test account with a team (username and passwordtest
). You can view the templates used to render the puzzles in the puzzles/templates/puzzle_bodies and puzzles/templates/solution_bodies folders, which you can also base your puzzles/solutions off of.
-
-
...be a superuser?
- Superusers are a Django concept that allows users access to the
/admin
control panel on the server. We have additionally set it to control access to certain site pages/actions, such as replying to hint requests from teams, or viewing solutions before the deadline../manage.py createsuperuser
will make a new superuser from the command line, but this user won't be associated with any team on the site (so it won't be able to e.g. solve puzzles). To fix this, you can either get a prepopulateddb.sqlite3
from a friend, hit theCreate team
button in the top bar on the main site to attach a new team to your user, or go into/admin
and swap out the user on an existing team for your new one.
- Superusers are a Django concept that allows users access to the
-
...edit the database?
- The
/admin
control panel lets you query and modify all of the objects in the database. It should be pretty straightforward to use. It does use the same login as the main site, so you won't be able to log in as a superuser for/admin
and a non-superuser for the main site in the same browser window.
- The
-
...be a testsolver?
- We have a notion of prerelease testsolver that is separate from that of superuser. Prerelease testsolvers can see all the puzzles even before the hunt starts. To make a prerelease testsolver, you can find a team in
/admin
and set the relevant checkbox there. Or, to make yourself a prerelease testsolver as a superuser, use theToggle testsolver
button in the top bar.
- We have a notion of prerelease testsolver that is separate from that of superuser. Prerelease testsolvers can see all the puzzles even before the hunt starts. To make a prerelease testsolver, you can find a team in
-
...set up a "real" testsolve?
- Go to
/admin
and set a team's start offset. The greater this offset, the earlier that team will be able to start and progress in the hunt. This can be used to run a full-hunt testsolve to test the unlock structure.
- Go to
-
...see some other team's view of the hunt?
- As a superuser, go to
/teams
and click on anyImpersonate
button. Be careful with this, as you don't want to accidentally perform any actions on behalf of the team.
- As a superuser, go to
-
...add a "keep going" message? give a team more guesses? delete a team? etc.
- All these things should be done through
/admin
.
- All these things should be done through
-
...give myself hints for testing? reset my hints? show me a puzzle's answer? etc.
- All these things can be done through the shortcuts menu in the top bar as a superuser (but can also be done through
/admin
).
- All these things can be done through the shortcuts menu in the top bar as a superuser (but can also be done through
-
...postprod a puzzle?
-
You'll need both a prerelease testsolver team, and a database Puzzle object (either create one or obtain a
db.sqlite3
with the puzzles set up) for your puzzle. Thebody_template
field on the Puzzle defines which template file will be used (this doesn't have to match theslug
field, though it may be nice if it does). Put the body of the puzzle in a file underpuzzles/templates/puzzle_bodies
. Put required static resources underpuzzles/static/puzzle_resources/$PUZZLE
. Put solutions and their resources underpuzzles/templates/solution_bodies
. See the sample files there as guides.Puzzles and solutions (but not other templates) support Markdown (though the library may or may not have some bugs). You'll override either
puzzle-body-md
orpuzzle-body-html
depending on whether you'd like to write Markdown or HTML. The same applies to solution bodies, author notes, and appendices.
-
-
...edit an email template?
- All templates used to render email bodies have two versions, HTML and plain text, with the same filename. If you change one, be sure to change the other to match.
-
...create a new model?
- Add a class to
models.py
on the pattern of the ones already there. To make it show up in/admin
, add it toadmin.py
as well. Finally, if you add or change any database model or field, you'll need to run./manage.py makemigrations
to create a migration file, then check that in.
- Add a class to
-
...use a model?
- The code should have plenty of examples for creating and reading database objects, and Django's online documentation is quite comprehensive. As a general tip, Django's unobtrusive syntax for database objects means it's very easy to trigger a lookup and not notice. It's mostly important to avoid doing
O(n)
(or worse) separate database lookups for one query; otherwise, don't worry about it too much. However, if you'd like to find opportunities for optimization, you can set up Django to print database queries to the console by changing thedjango.db.backends
log setting.
- The code should have plenty of examples for creating and reading database objects, and Django's online documentation is quite comprehensive. As a general tip, Django's unobtrusive syntax for database objects means it's very easy to trigger a lookup and not notice. It's mostly important to avoid doing
-
...create a new view?
- Add a function to
views.py
that returns a response object (usually by rendering a template, but you can also create one and write to it directly). Check if you want to gate it behind any of the decorators used in the file. You will need to add your view tourls.py
as well to make it accessible. The name you put inurls.py
should be used with functions like{% url %}
(in templates) orreverse
andredirect
(in Python) to generate the URL for your page whenever you need to output it.
- Add a function to
-
...create a view called by a puzzle?
- If your view is for a specific puzzle, you should put it in
puzzlehandlers/
. That directory also contains helpers for rate limiting so teams can't brute-force your puzzle. Then in your puzzle template, you can include Javascript or forms that call your new view however you wish.
- If your view is for a specific puzzle, you should put it in
-
...add CSS?
- If the element you're styling is in
base.html
or appears in multiple separate pages, put it inbase.css
. Otherwise, just put it inline in your template.
- If the element you're styling is in
-
...add template context?
- Context parameters are how to pass information from Python into templates. Similar to the above, if you want to use the same data in more than one page, consider putting it in
context.py
, which defines context shared between all page templates. Otherwise, put it in a dict passed torender
in yourview.py
function.
- Context parameters are how to pass information from Python into templates. Similar to the above, if you want to use the same data in more than one page, consider putting it in
-
...add template functions?
- To create a custom tag or filter that's callable from templates (for example, we have one that takes a timestamp and formats it), you have to put it in
templatetags/
. (This is enforced by Django for some reason.) Then, in the template file you're changing, include{% load puzzle_tags %}
at the top.
- To create a custom tag or filter that's callable from templates (for example, we have one that takes a timestamp and formats it), you have to put it in
-
...set up the unlock structure?
- The unlock threshold for each puzzle is defined in its database entry. Most other parameters and logic are in
hunt_config.py
. You will probably just have to edit these case by case, but note that e.g. it is not necessary to make code changes in order to update puzzle unlock thresholds.
- The unlock threshold for each puzzle is defined in its database entry. Most other parameters and logic are in
-
...enable the story or wrapup page?
- In addition to making the necessary template changes, in order to make these pages visible, you have to set the
*_PAGE_VISIBLE
flags inhunt_config.py
to true.
- In addition to making the necessary template changes, in order to make these pages visible, you have to set the
-
...do analysis of what teams do during the hunt?
- Use the shortcuts menu to download a hint log, guess log, and puzzle log. The first two are generated from the database; the latter from whatever calls
messaging.log_puzzle_info
. For example, if you have a puzzle that's a game, you can set up an endpoint to log whenever a team wins. You can also set up whatever additional logs you wish (and if you want, expose them using a new view over the bridge). Then you can write your own scripts or spreadsheets to analyze them.
- Use the shortcuts menu to download a hint log, guess log, and puzzle log. The first two are generated from the database; the latter from whatever calls
-
...time zones?
- For reasons that I'm sure made sense at the time (heh), Django stores timestamps as UTC in the database and converts them to the currently set time zone (i.e. Eastern) when rendering templates. This means that you don't need to worry if you include a timestamp in a template file, but if you're trying to render it in Python (including in
templatetags/
), you may have to adjust its time zone explicitly to prevent it from showing as UTC.
- For reasons that I'm sure made sense at the time (heh), Django stores timestamps as UTC in the database and converts them to the currently set time zone (i.e. Eastern) when rendering templates. This means that you don't need to worry if you include a timestamp in a template file, but if you're trying to render it in Python (including in
-
...issue errata?
- Errata are stored in the database. You can go to
/errata
as a superuser to create one. Errata can be shown on the puzzle page, the top-level updates page, or both; you can also create a general announcement that's not associated with a puzzle. If you save an erratum as unpublished, you can see how it looks before revealing it to solvers. The updates page won't be available to solvers until there's something they can see there.
- Errata are stored in the database. You can go to
-
...answer hints?
- You can find the hint interface at
/hints
, through links in Discord hint messages, or via the red hint icon that appears for superusers browsing the site when there are unanswered hints. The interface lets you claim a hint, write a response, and send it off to the team. If a hint is marked as obsolete, that means the team solved the puzzle while it was open; if refunded, then the responder decided not to charge them a hint token. If a hint is a followup, that means it's part of a conversation thread with the team and doesn't cost a token either.
- You can find the hint interface at
-
... use websockets?
- We now have somewhat experimental websocket support! Take a look at the consumer classes in
messaging.py
; there are prototypes for two-way communication with a single browser tab, or for broadcasting to all members of a team or all logged-in admins. If you want something different, say for a "Teamwork Time" puzzle where team members interact with each other, it shouldn't be hard to add. Then add your consumer torouting.py
and useopenSocket
in JS to connect to it.
- We now have somewhat experimental websocket support! Take a look at the consumer classes in
-
... provide the site in my language?
- Generate the translations placeholders for your language
lang_COUNTRY
(e.g. en_US):django-admin makemessages -e html,txt,py,svg -l lang_COUNTRY
django-admin makemessages -d djangojs -l lang_COUNTRY
- add your translations in msgstr in the django.po and djangojs.po files under locale/
lang_COUNTRY
- Compile the translations:
django-admin compilemessages
- create a gph/formats/
lang
(e.g. en) folder and copy an existing one (e.g. en to be translated, see https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-FORMAT_MODULE_PATH). This contains the date/time formats used in django templates (see https://docs.djangoproject.com/en/4.0/ref/templates/builtins/#std:templatefilter-date) - set LANGUAGE_CODE in base/settings.py as
lang-country
(e.g. en-us) - note that the compiled .mo translated files are not in the repo, make sure to make them part of the deploy to your site
- see https://docs.djangoproject.com/en/4.0/topics/i18n/ for more info
- note that django-admin makemessages doesn't handle escaped characters correctly in python strings, make sure to use the actual unicode character or its html sequence instead of its escaped code value (e.g.
’
instead of\u2019
) - contact enigmatix if you need help with localization of your site
- Generate the translations placeholders for your language
The GPH server is built on Django. We use Ansible to manage deploys to our cloud VMs and nginx as the web server in production, but you're free to use whatever web server setup makes sense for you.
db.sqlite3
: This is the database used by Django. An empty one is automatically created if you start the server without it, but for testing many features, you may wish to get one with teams, puzzles, etc. populated.manage.py
: This is Django's way of administering the server from the command line. It includes help features that will tell you the things it can do. Common commands arecreatesuperuser
,shell
/dbshell
,migrate
/makemigrations
, andrunserver
. There are also custom commands, defined inpuzzles/management/commands
.README.md
: You're reading me.requirements.txt
: A file thatpip
can read to install the Python packages needed by the server. If you want to add one, put it in the file. Locally, you'll need to runpip install -r requirements.txt
to pick it up (inside the virtualenv if you're using one). The production server will pick it up when it next gets deployed.gph/
: A catch-all for various configuration.wsgi.py
: Boilerplate for hooking Django up to a web server in production.settings/
: Here are a few sets of Django settings depending on environment. Most of the options are built-in to Django, so you can consult the docs. You can also put new things here if they should be global or differ by environment. They'll be accessible anywhere in the Django project.urls.py
: Routing configuration for the server. If you add a new page inviews.py
, you'll need to add it here as well.
logs/
: Holds logs written by the server while it runs.static/
: If you runcollectstatic
, which you probably should in production, Django gathers files frompuzzles/static
and puts them here. If you're seeing weird static file caching behavior or files you thought you'd deleted still showing up, try clearing this out.venv/
: Contains the virtualenv if you're using one, including all the Python packages you installed for this project.
This directory contains all of the business logic for the site.
admin.py
: Sets up custom logic for the interface on/admin
for managing the database objects defined inmodels.py
. If you add a new model, add it here too.context.py
: This file defines an object that gets attached to the request, encompassing data that can be calculated when responding to the request as well as accessed inside rendered templates.forms.py
: Configuration for various user-visible forms found throughout the site, including validation functions.hunt_config.py
: Intended to encapsulate all the numbers and details for one year's hunt progression, including the date and time for the start and end of hunt.messaging.py
: Functions for sending email and Discord messages.models.py
: Defines database objects.Puzzle
: A puzzle.Team
: A team corresponds to a Django user, since it has a single login, but a team can list multiple names and emails. TeamMember objects are essentially just for display and email purposes.PuzzleUnlock
: Represents a team having access to a puzzle. Since this needs to be recalculated all the time anyway as teams progress, it's not that useful as a caching mechanism. It mostly allows analysis and statistics of when exactly unlocks happened.AnswerSubmission
: A guess by a team on a puzzle, either right or wrong.Hint
: A hint request initiated by a team. Has special listeners to send email and Discord messages when one is received or answered.
shortcuts.py
: Defines a number of one-click actions available to superusers for use while developing the site.views.py
: Defines the handlers serving each page on the site. Makes heavy use of decorators for access control.management/
: Defines custom commands formanage.py
; see below. Generally, this includes any sort of administrative action you might want to automate with access to the database.migrations/
: If you ever changemodels.py
by deleting, removing, or modifying a database type or its fields, run./manage.py makemigrations
to autogenerate a migration file that makes necessary changes to the database. This runs during deployment, or run./manage.py migrate
locally.puzzlehandlers/
: If you write a puzzle that requires server code, put it in a new file here (and refer to it inviews.py
and/orurls.py
). You can wrap it in a rate limiter and export it from__init__.py
.static/
: Any files to be served directly to the user's browser. Note: do NOT put anything used by a puzzle solution in here, as they should be locked until the hunt ends.templates/
: Generally, these get rendered fromviews.py
. Contains not only HTML files but also plain-text email bodies (side-by-side with HTML versions) and inline SVGs.puzzle_bodies/
: All templates for individual puzzles. Put any static resources instatic/puzzle_resources/$PUZZLE/
.solution_bodies/
: All templates for individual solutions. Put any static resources intemplates/solution_bodies/$PUZZLE/
.
templatetags/
: If you want to define a function callable from within a template, put it inpuzzle_tags.py
. This is for stuff like formatting timestamps.
If you are new to web development and deployment, you can check out DEPLOY.md for some work in progress suggestions on places to deploy this site and instructions on how to deploy them. Otherwise, here is a short list of things you should fix
(The most accurate way is probably just to grep for the string FIXME
.
Required:
- Set the SECRET_KEY in gph/settings/base.py to a secure random key. (TODO: what's actually the best way to do this? Should we use an environment variable?) Also probably set up the email credentials and titles.
- Change all the settings in
puzzles/hunt_config.py
: hunt times, title, organizers, email, etc. - Set the domain in
gph/settings/prod.py
andgph/settings/staging.py
if you're using that.
Optional:
- Configure the paths where logs are stored in
settings/base.py
. - Put the text you want in the home page and other static pages via the templates. (See CONTENT.md)
puzzles/messaging.py
contains some configurable settings for Discord webhooks.
Your main tool will be the Django admin panel, at /admin
on either a local or production server. Logging in with an admin account will let you edit any database object. Convenience commands are available in the shortcuts menu on the main site.
manage.py
is a command-line management tool. We've added some custom commands in puzzles/management/
. If you're running the site in a production environment, you'll need SSH access to the relevant server.
If something goes very wrong, you can try SSHing to the server and editing files or using Git commands directly. We recommend taking regular backups of the database that you can restore from if need be. We also recommend controlling which commits make it to the live site during the hunt, by creating a separate production
Git branch that lags behind master
, and verifying all changes on a staging deploy.
In addition to the hunt start and end time, there's also a somewhat non-obvious "hunt close time" in hunt_config.py
. Here's how it works:
- When the hunt ends, the leaderboard freezes, hint requests are disabled, and solutions are published, but account signups and progressing through the hunt are still allowed. The idea is to give people extra time to finish the hunt at their own pace if they want, but without any of the maintenance costs of actually staffing the hunt (responding to hint requests, avoiding spoilers for competition fairness).
- When the hunt closes, account registration and log ins are actually disabled.
You can, of course, set the hunt close time to be equal to the hunt end time to skip the in-between stage.