Skip to content

Commit

Permalink
feat: implement partial puzzle cog (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
SkellyBG authored Feb 26, 2024
1 parent a49ce75 commit 6947200
Show file tree
Hide file tree
Showing 16 changed files with 320 additions and 36 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,7 @@ cython_debug/
bin/**
lib64/**
pyvenv.cfg
lib64
lib64

# docker volumne
pg_data
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# hunt bot
# PuzzleHunt bot!

A simple discord bot that handles team and puzzle logistic for an online puzzle hunt!

## Local database setup

Expand Down
30 changes: 0 additions & 30 deletions cogs/answers.py

This file was deleted.

80 changes: 80 additions & 0 deletions cogs/puzzle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from datetime import datetime
from zoneinfo import ZoneInfo
import discord
from discord.ext import commands
from discord import app_commands

from src.queries.puzzle import find_puzzle, create_puzzle
from src.queries.submission import create_submission
from src.utils.decorators import in_team_channel
from src.context.puzzle import can_access_puzzle, get_accessible_puzzles


class Puzzle(commands.GroupCog):
def __init__(self, bot):
self.bot = bot

@app_commands.command(name="submit", description="Submit an answer to a puzzle")
@in_team_channel
async def submit_answer(
self, interaction: discord.Interaction, puzzle_id: str, answer: str
):
puzzle = await find_puzzle(puzzle_id)
if not puzzle or not can_access_puzzle(puzzle, interaction.user.id):
return await interaction.response.send_message(
"No puzzle with the corresponding id exist!"
)
# TODO: retrieve team name
team_name = "any"
submission_is_correct = puzzle.puzzle_answer != answer
await create_submission(
puzzle_id,
team_name,
datetime.now(tz=ZoneInfo("Australia/Sydney")),
answer,
submission_is_correct,
)

if not submission_is_correct:
return await interaction.response.send_message(
"The submitted answer is incorrect!"
)

await interaction.response.send_message("The submitted answer is ...CORRECT!")

@app_commands.command(name="list", description="List the available puzzles")
@in_team_channel
async def list_puzzles(self, interaction: discord.Interaction):
puzzles = await get_accessible_puzzles(interaction.user.id)
embed = discord.Embed(title="Current Puzzles", color=discord.Color.greyple())

puzzle_ids, puzzle_links = zip(
*[
(puzzle.puzzle_id, f"[{puzzle.puzzle_name}]({puzzle.puzzle_link})")
for puzzle in puzzles
]
)

embed.add_field(name="ID", value="\n".join(puzzle_ids), inline=True)
embed.add_field(name="Puzzles", value="\n".join(puzzle_links), inline=True)
await interaction.response.send_message(embed=embed)

@app_commands.command(
name="create", description="Create a puzzle (must have admin role)"
)
async def list_puzzles(
self,
interaction: discord.Interaction,
puzzle_name: str,
):
# surely there's a better way to do this
if "Executives" not in [role.name for role in interaction.user.roles]:
return await interaction.response.send_message(
f"You don't have permission to do this!"
)

await interaction.response.send_message(f"Puzzle {puzzle_name} created!")


async def setup(bot: commands.Bot):
await bot.add_cog(Puzzle(bot))
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
7 changes: 7 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ async-timeout==4.0.3
attrs==23.2.0
discord==2.3.2
discord.py==2.3.2
exceptiongroup==1.2.0
frozenlist==1.4.1
idna==3.6
iniconfig==2.0.0
multidict==6.0.5
packaging==23.2
pluggy==1.4.0
psycopg==3.1.18
psycopg-binary==3.1.18
pytest==8.0.2
pytest-asyncio==0.23.5
python-dotenv==1.0.1
tomli==2.0.1
typing_extensions==4.9.0
yarl==1.9.4
16 changes: 16 additions & 0 deletions src/context/puzzle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import List
from src.models.puzzle import Puzzle
from src.queries.puzzle import find_puzzles

NUMBER_OF_FEEDERS = {"UTS": 4, "UNSW": 4, "USYD": 6}


async def can_access_puzzle(puzzle: Puzzle, discord_id: int) -> bool:
# TODO: implementation
return True


async def get_accessible_puzzles(discord_id: int) -> List[Puzzle]:
# TODO: implementation
puzzles = await find_puzzles()
return puzzles
9 changes: 5 additions & 4 deletions src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;

DROP TABLE IF EXISTS public.teams;
DROP TABLE IF EXISTS public.players;
DROP TABLE IF EXISTS public.puzzles;
DROP TABLE IF EXISTS public.submission;
DROP TABLE IF EXISTS public.teams CASCADE;
DROP TABLE IF EXISTS public.players CASCADE;
DROP TABLE IF EXISTS public.puzzles CASCADE;
DROP TABLE IF EXISTS public.submissions CASCADE;

CREATE TABLE public.teams (
team_name text PRIMARY KEY,
Expand All @@ -33,6 +33,7 @@ CREATE TABLE public.puzzles (
puzzle_name text NOT NULL,
puzzle_answer text NOT NULL,
puzzle_author text NOT NULL,
puzzle_link text NOT NULL,
uni text NOT NULL
);

Expand Down
11 changes: 11 additions & 0 deletions src/models/puzzle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from dataclasses import dataclass


@dataclass
class Puzzle:
puzzle_id: str
puzzle_name: str
puzzle_answer: str
puzzle_author: str
puzzle_link: str
uni: str
11 changes: 11 additions & 0 deletions src/models/submission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from dataclasses import dataclass
from datetime import datetime


@dataclass
class Submission:
puzzle_id: str
team_name: str
submission_time: datetime
submission_answer: str
submission_is_correct: bool
50 changes: 50 additions & 0 deletions src/queries/puzzle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import psycopg
from psycopg.rows import class_row

from src.config import config
from src.models.puzzle import Puzzle

DATABASE_URL = config["DATABASE_URL"]


async def find_puzzle(puzzle_id: str):
async with await psycopg.AsyncConnection.connect(DATABASE_URL) as aconn:
async with aconn.cursor(row_factory=class_row(Puzzle)) as acur:
await acur.execute(
"SELECT * FROM public.puzzles WHERE puzzle_id = %s", (puzzle_id,)
)
return await acur.fetchone()


async def find_puzzles():
async with await psycopg.AsyncConnection.connect(DATABASE_URL) as aconn:
async with aconn.cursor(row_factory=class_row(Puzzle)) as acur:
await acur.execute("SELECT * FROM public.puzzles")
return await acur.fetchall()


async def create_puzzle(
puzzle_id: str,
puzzle_name: str,
puzzle_answer: str,
puzzle_author: str,
puzzle_link: str,
uni: str,
):
async with await psycopg.AsyncConnection.connect(DATABASE_URL) as aconn:
async with aconn.cursor() as acur:
await acur.execute(
"""
INSERT INTO public.puzzles
(puzzle_id, puzzle_name, puzzle_answer, puzzle_author, puzzle_link, uni)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
puzzle_id,
puzzle_name,
puzzle_answer,
puzzle_author,
puzzle_link,
uni,
),
)
61 changes: 61 additions & 0 deletions src/queries/submission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import psycopg
from psycopg.rows import class_row
from datetime import datetime

from src.config import config
from src.models.submission import Submission

DATABASE_URL = config["DATABASE_URL"]


async def create_submission(
puzzle_id: str,
team_name: str,
submission_time: datetime,
submission_answer: str,
submission_is_correct: bool,
):
async with await psycopg.AsyncConnection.connect(DATABASE_URL) as aconn:
async with aconn.cursor() as acur:
await acur.execute(
"""
INSERT INTO public.submissions
(puzzle_id, team_name, submission_time, submission_answer, submission_is_correct)
VALUES (%s, %s, %s, %s, %s)
""",
(
puzzle_id,
team_name,
submission_time,
submission_answer,
submission_is_correct,
),
)


async def find_submissions_by_player_id(player_id: str):
async with await psycopg.AsyncConnection.connect(DATABASE_URL) as aconn:
async with aconn.cursor(row_factory=class_row(Submission)) as acur:
await acur.execute(
"""
SELECT s.puzzle_id, s.team_name, s.submission_time, s.submission_answer, s.submission_is_correct
FROM public.submissions AS s
INNER JOIN public.teams AS t ON t.team_name = s.team_name
INNER JOIN public.players AS p ON p.team_name = t.team_name
WHERE p.discord_id = %s
""",
(player_id,),
)

return await acur.fetchall()


async def find_submissions_by_team(team_name: str):
async with await psycopg.AsyncConnection.connect(DATABASE_URL) as aconn:
async with aconn.cursor(row_factory=class_row(Submission)) as acur:
await acur.execute(
"SELECT * FROM public.submissions WHERE team_name = %s",
(team_name,),
)

return await acur.fetchall()
19 changes: 19 additions & 0 deletions src/utils/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import discord

from functools import wraps
from typing import Coroutine


def in_team_channel(func: Coroutine) -> Coroutine:
@wraps(func)
async def wrapper(self, interaction: discord.Interaction):
user = interaction.user
if not isinstance(user, discord.Member):
return await interaction.response.send_message("Something went wrong!")
channel = interaction.channel
# TODO: check if user is in team channel
is_in_team_channel = True

return await func(self, interaction)

return wrapper
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import os
import sys

# Get the absolute path to the project root directory
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# Add the project root to the Python path
sys.path.insert(0, PROJECT_ROOT)
29 changes: 29 additions & 0 deletions tests/puzzle_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import pytest
import pytest_asyncio

from tests.utils import truncate
from src.queries.puzzle import create_puzzle, find_puzzle
from src.models.puzzle import Puzzle


class TestClass:
@pytest_asyncio.fixture(autouse=True)
async def async_setup(self):
await truncate()
yield

@pytest.mark.asyncio
async def test_can_create_puzzle(self):
expected: Puzzle = Puzzle(
"UTS-1", "The Answer of Life", "42", "Skelly", "tiny.cc/rickroll", "UTS"
)
await create_puzzle(
expected.puzzle_id,
expected.puzzle_name,
expected.puzzle_answer,
expected.puzzle_author,
expected.puzzle_link,
expected.uni,
)
result = await find_puzzle("UTS-1")
assert expected == result
Loading

0 comments on commit 6947200

Please sign in to comment.