Skip to content

Commit

Permalink
Admin scripts to facilitate running the hunt (#84)
Browse files Browse the repository at this point in the history
* Admin scripts to facilitate running the hunt

* Review markups

* More detailed docstrings

* More lints and a contribution guide

* Add bs4 types to keep mypy happy
  • Loading branch information
Halifilo committed Jul 4, 2023
1 parent e26e180 commit fce36b7
Show file tree
Hide file tree
Showing 9 changed files with 433 additions and 6 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
/media/
/static/
/treasure.sqlite

# e.g. Pycharm config
.idea/
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Setting up a development environment

1. Install Python 3.10 or 3.11
2. Install poetry - see [the poetry docs](https://python-poetry.org/docs/)
3. Run `poetry install --extras azure` to install the project's dependencies

# Running the CI lints locally

See [linting.yml](.github/workflows/linting.yml) for the list of linting commands run by the CI on Github,
such as:

`poetry run ruff .`
`poetry run black --check .`
`poetry run mypy .`
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ docker run \
e-treasure-hunt
```

To use Google maps, you will also need to pass `GM_API_KEY` to this container as
To use Google Maps, you will also need to pass `GM_API_KEY` to this container as
an environment variable.

# Initiating the app
Expand Down Expand Up @@ -105,9 +105,14 @@ and N+1.
You can use the files in `dummy_files.zip`, updating `blurb.txt` at level 0 with
text for the start of the hunt.

It is recommended that, prior to attempting upload, that [level_validation.py](admin_scripts/level_validation.py)
be run over the levels. This will catch numerous formatting problems with the levels before wasting your
time/bandwidth on server upload, and will also catch several conditions that are not technically errors
but are undesirable, such as empty README.md files and too-tight tolerances.

### Level upload through the API

[upload.py](upload.py) contains utilities for uploading levels and hints.
[upload.py](admin_scripts/upload.py) contains utilities for uploading levels and hints.

You'll need to update the `SERVER` and credentials at the top of the file, and
then re-arrange `main()` as appropriate to upload your levels.
Expand Down
71 changes: 71 additions & 0 deletions admin_scripts/calculate_winners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Parse the hunt events CSV from the hunt website to see who won by various metrics
ADV = team advanced to that level
REQ = team requested a hint
Edit the values of the constants at the top of this file for your purposes, e.g.
START_TIME, TEAM_NAMES, etc.
"""
import csv
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path

# Start time
START_TIME = datetime.strptime("2000-01-01 00:00:00+0000", "%Y-%m-%d %H:%M:%S%z")
# 2.0 hours per hint
# N.B. assumes all hints _requested_ take a penalty,
# script will need editing if you want to only account for hints _used_
PENALTY_PER_HINT_IN_HOURS = 2.0
# "Final" level, the advance to which encodes that the team finished
FINAL_LEVEL = "51"
# List of team names as strings
TEAM_NAMES: list["TeamName"] = []
# Path to hunt event csv taken from the website
CSV_FILE_PATH = r"C:\Users\username\Downloads\hunt.huntevent.csv"

TeamName = str


def parse_timestamp(timestamp: str) -> datetime:
return datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=timezone.utc
)


def main(csv_file: str) -> None:
teams = TEAM_NAMES
team_raw_times: dict[TeamName, float] = defaultdict(float)
team_running_totals: dict[TeamName, float] = defaultdict(float)
team_hints_requested: dict[TeamName, int] = defaultdict(int)
team_levels: dict[TeamName, int] = defaultdict(int)

with Path(csv_file).open(encoding="utf-8") as f:
csv_reader = csv.DictReader(f)

for line in csv_reader:
team = line["user"]
assert team in teams
# penalty of x hours per hint
if line["type"] == "REQ":
team_running_totals[team] += PENALTY_PER_HINT_IN_HOURS
team_hints_requested[team] += 1
elif line["type"] == "ADV":
team_levels[team] += 1
# Final level
if line["level"] == FINAL_LEVEL:
timestamp = line["time"].split(".")[0]
finish_time = parse_timestamp(timestamp)
time_taken = (finish_time - START_TIME).total_seconds() / 60 / 60
print(time_taken)
team_running_totals[team] += time_taken
team_raw_times[team] = time_taken

print("Raw times", team_raw_times)
print("Running totals", team_running_totals)
print("Hints requested", team_hints_requested)
print("Team levels completed", team_levels)


if __name__ == "__main__":
main(CSV_FILE_PATH)
152 changes: 152 additions & 0 deletions admin_scripts/level_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Clientside validator for levels
Some of these checks just make sure that the hunt website won't reject the upload
(without having to actually attempt such an upload).
Other checks are for admin-y things like:
- Tolerances that are suspiciously tight
- README.md files (which are supposed to contain a detailed explanation of the
structure of the level for the GM's use)
being smaller than blurb.txt files (which are supposed to be a hunter-consumable
précis of the level answer/concept once they've solved it)
The checking for the names of the images is stricter than the server requires.
The server will consider the images in alphabetical order, so (say) 1-image.jpg,
2-image.jpg, ... is just a valid a scheme as clue.png, hint1.png, etc.
However, this strict checking does serve to remind the admin to make sure that the
level setter has not come up with their own novel image-naming scheme that wouldn't
work once the server considers the images alphabetically.
"""
import argparse
import json
import os
import re
import zipfile
from pathlib import Path
from typing import TextIO

CONTENT_TYPES = {
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
}


def unzip_all() -> None:
for filename in os.listdir(ALL_LEVELS_DIR):
if filename.endswith(".zip"):
folder_path: Path = ALL_LEVELS_DIR / filename[:-4]
if not folder_path.exists():
with zipfile.ZipFile(ALL_LEVELS_DIR / filename) as zip_ref:
zip_ref.extractall(folder_path)


def validate_format() -> None:
count = 0
for filename in os.listdir(ALL_LEVELS_DIR):
dir_path = ALL_LEVELS_DIR / filename
if dir_path.is_dir() and "DUMMY" not in filename:
count += 1
if not (dir_path / "about.json").exists():
print("No json in", filename)
else:
# Check json for values
with (dir_path / "about.json").open() as f:
check_json(f, filename)

if not (dir_path / "readme.md").exists():
print("No readme in", filename)

if not (dir_path / "blurb.txt").exists():
print("No blurb in", filename)

# Check readme is bigger than blurb
if (dir_path / "blurb.txt").exists() and (dir_path / "readme.md").exists():
blurb_size = os.path.getsize(dir_path / "blurb.txt")
readme_size = os.path.getsize(dir_path / "readme.md")
if blurb_size > readme_size:
print("Blurb is bigger than readme for", filename)

images = [
dir_path / file
for file in os.listdir(dir_path)
if Path(file).suffix.lower() in CONTENT_TYPES
]

# Should find exactly the right number - check the file extensions if not.
if len(images) != 5:
print(f"Found {len(images)} images in {dir_path}")
else:
images.sort(key=lambda x: x.name.lower())
if not images[0].name.startswith("clue"):
print("No clue in", filename)

# Check the images aren't too big or bad things will happen to the
# upload. We don't want a repeat of the Wawrinka incident.
for image in images:
image_size = os.path.getsize(image)
if image_size > 3 * 1000 * 1000: # ~3 MB
print(
"Image",
image,
"is too big in",
filename,
"size = ",
f"{image_size:,}",
)

for i in range(1, 5):
if not images[i].name.startswith("hint"):
print("No hint", i, "in", filename)

print("Analyzed", count, "levels")


def check_coord(coord: str, coord_name: str, filename: str) -> None:
lat = float(coord)
if not lat:
print("No", coord_name, "for level", filename)
elif lat == 0.0:
print(" warning: 0", coord_name, "for level", filename)

numbers_and_dp_only = re.sub("[^0-9.]", "", coord)
a, b = numbers_and_dp_only.split(".") if "." in coord else (coord, "")
if len(b) > 5:
print("More than 5 dp for", coord_name, "for level", filename, ":", coord)
if len(a) + len(b) > 7:
print("More than 7 digits for", coord_name, "for level", filename, ":", coord)


def check_json(f: TextIO, filename: str) -> None:
json_data = json.load(f)
if not len(json_data["name"]) > 0:
print("No name for level", filename)

check_coord(json_data["latitude"], "lat", filename)
check_coord(json_data["longitude"], "long", filename)

tol = int(json_data["tolerance"])
if not tol:
print("No tolerance for level", filename)
elif tol < 1:
print("0 tolerance for level", filename)
elif tol < 20:
print("Too-low-resolution tolerance of", tol, "for level", filename)
elif tol <= 50:
print(" warning: Small tolerance of", tol, "for level", filename)


if __name__ == "__main__":
argparser = argparse.ArgumentParser()
argparser.add_argument(
"input_directory",
help="Path to a directory containing the (possibly zipped) "
"levels to be examined",
)
args = argparser.parse_args()
ALL_LEVELS_DIR = Path(args.input_directory)
assert ALL_LEVELS_DIR.exists()
assert ALL_LEVELS_DIR.is_dir()

unzip_all()
validate_format()
Loading

0 comments on commit fce36b7

Please sign in to comment.