diff --git a/.gitignore b/.gitignore index 11d110d..95baeea 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ cython_debug/ #.idea/ db*.json +.bot_config.toml .vscode/ .ruff_cache/ diff --git a/Makefile b/Makefile index 2ac8b5e..2892bee 100644 --- a/Makefile +++ b/Makefile @@ -8,4 +8,4 @@ cov: uv run pytest --cov --cov-report=term-missing launch: - . ./.env && uv run eadk_discord + export EADK_DISCORD_CONFIG_PATH=.bot_config.toml && uv run eadk_discord diff --git a/eadk_discord/__main__.py b/eadk_discord/__main__.py index 485ec26..6f8833e 100644 --- a/eadk_discord/__main__.py +++ b/eadk_discord/__main__.py @@ -1,34 +1,33 @@ # pragma: coverage exclude file import logging import os +import sys +import tomllib from pathlib import Path -import discord -from discord.abc import Snowflake - from eadk_discord import bot_setup -if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) - bot_token_option: str | None = os.getenv("DISCORD_BOT_TOKEN") - if bot_token_option is None: - raise ValueError("DISCORD_BOT_TOKEN is not set in environment variables") - else: - bot_token: str = bot_token_option +def get_config_path(env_var_name: str) -> str: + match len(sys.argv): + case 1: + env_var_option: str | None = os.getenv(env_var_name) + if env_var_option is None: + raise ValueError(f"environment variable '{env_var_name}' is not set") + else: + return env_var_option + case 2: + return sys.argv[1] + case num_args: + raise ValueError(f"expected 0 or 1 argument, got {num_args-1}") - database_path_option: str | None = os.getenv("DATABASE_PATH") - if database_path_option is None: - raise ValueError("DATABASE_PATH is not set in environment variables") - else: - database_path: Path = Path(database_path_option) - guild_ids_option: str | None = os.getenv("GUILD_IDS") - if guild_ids_option is None: - raise ValueError("GUILD_IDS is not set in environment variables") - else: - guilds: list[Snowflake] = [discord.Object(id=int(guild_id)) for guild_id in guild_ids_option.split(",")] +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + config_path = Path(get_config_path("EADK_DISCORD_CONFIG_PATH")) - bot = bot_setup.setup_bot(database_path, guilds) + with open(config_path, "rb") as config_file: + config = bot_setup.BotConfig.model_validate(tomllib.load(config_file)) - bot.run(bot_token) + config.run_bot() diff --git a/eadk_discord/bot.py b/eadk_discord/bot.py index a3f3490..a9e10f9 100644 --- a/eadk_discord/bot.py +++ b/eadk_discord/bot.py @@ -20,14 +20,23 @@ class CommandInfo(BaseModel): now: datetime = Field() format_user: Callable[[int], str] = Field() author_id: int = Field() + author_role_ids: set[int] = Field() @beartype @staticmethod def from_interaction(interaction: discord.Interaction) -> "CommandInfo": + match interaction.user: + case discord.Member() as member: + role_ids = set(role.id for role in member.roles) + case discord.User(): + role_ids = set() + case _: + raise ValueError("Invalid interaction user type") return CommandInfo( now=datetime.now(TIME_ZONE), format_user=lambda user: fmt.user(interaction, user), author_id=interaction.user.id, + author_role_ids=role_ids, ) @@ -53,10 +62,20 @@ async def send(self, interaction: discord.Interaction) -> None: # pragma: no co class EADKBot: _database: Database + _regular_role_ids: set[int] + _admin_role_ids: set[int] @beartype - def __init__(self, database: Database) -> None: + def __init__(self, database: Database, regular_role_ids: set[int], admin_role_ids: set[int]) -> None: self._database = database + self._regular_role_ids = regular_role_ids + self._admin_role_ids = admin_role_ids + + def _is_author_regular(self, info: CommandInfo) -> bool: + return bool(info.author_role_ids.intersection(self._regular_role_ids.union(self._admin_role_ids))) + + def _is_author_admin(self, info: CommandInfo) -> bool: + return bool(info.author_role_ids.intersection(self._admin_role_ids)) @property def database(self) -> Database: @@ -123,12 +142,15 @@ def book( if end_date is not None: days = self._database.state.day_range(booking_date, end_date) for day in days: - if day.desk(desk_index).owner is not info.author_id: + if day.desk(desk_index).owner is not info.author_id and not self._is_author_admin(info): return Response( message="Range bookings are only allowed for desks you own for the entire range.", ephemeral=True, ) + if user_id != info.author_id and not self._is_author_regular(info): + return Response(message="You do not have permission to book desks for other users.", ephemeral=True) + self._database.handle_event( Event( author=info.author_id, @@ -173,7 +195,7 @@ def unbook( return Response(message="A desk must be specified for range unbookings.", ephemeral=True) desk_index = desk_num - 1 for booking_day in booking_days: - if booking_day.desk(desk_index).owner != info.author_id: + if booking_day.desk(desk_index).owner != info.author_id and not self._is_author_admin(info): return Response( message="Range unbookings are only allowed for desks you own for the entire range.", ephemeral=True, @@ -213,6 +235,8 @@ def unbook( return Response(message=(f"Desk {desk_num} has been unbooked from {date_str} to {fmt.date(end_date)}.")) else: [booking_day] = booking_days + if booking_day.desk(desk_index).booker != info.author_id and not self._is_author_regular(info): + return Response(message="You do not have permission to unbook desks for other users.", ephemeral=True) desk_booker = booking_day.desk(desk_index).booker if desk_booker is not None: self._database.handle_event( diff --git a/eadk_discord/bot_setup.py b/eadk_discord/bot_setup.py index 1646bcd..c2b3b7d 100644 --- a/eadk_discord/bot_setup.py +++ b/eadk_discord/bot_setup.py @@ -1,24 +1,21 @@ # pragma: coverage exclude file import logging +from collections.abc import Sequence from datetime import date, datetime from pathlib import Path import discord -from beartype import beartype from discord import Intents, Interaction, Member, app_commands from discord.abc import Snowflake from discord.app_commands import AppCommandError, Choice, Range from discord.ext import commands from discord.ext.commands import Bot, Context +from pydantic import BaseModel from eadk_discord.bot import CommandInfo, EADKBot, Response from eadk_discord.database import Database from eadk_discord.database.event import Event, SetNumDesks -TEST_SERVER_ROLE_ID = 1287776907563106436 -EADK_DESK_ADMIN_ID = 1288070128533114880 -EADK_DESK_REGULAR_ID = 1288068945324146718 - INTERNAL_ERROR_MESSAGE = "INTERNAL ERROR HAS OCCURRED BEEP BOOP" @@ -31,139 +28,153 @@ async def date_autocomplete(interaction: Interaction, current: str) -> list[Choi return [Choice(name=option, value=option) for option in options if option.startswith(current.lower())] -async def channel_check(interaction: Interaction[discord.Client]) -> bool: - test_server_channel_id = 1285163169391841323 - eadk_office_channel_id = 1283770439381946461 - return interaction.channel_id == test_server_channel_id or interaction.channel_id == eadk_office_channel_id - - -@beartype -def setup_bot(database_path: Path, guilds: list[Snowflake]) -> Bot: - if database_path.exists(): - database = Database.load(database_path) - else: - database = Database.initialize(date.today()) - database.handle_event( - Event(author=None, time=datetime.now(), event=SetNumDesks(date=date.today(), num_desks=6)) - ) - database.save(database_path) - - intents: Intents = discord.Intents.default() - intents.message_content = True - intents.members = True - - eadk_bot = EADKBot(database) - bot = Bot(command_prefix="!", intents=intents) - - @bot.tree.command(name="info", description="Get current booking status.", guilds=guilds) - @app_commands.autocomplete(date_arg=date_autocomplete) - @app_commands.rename(date_arg="date") - @app_commands.check(channel_check) - async def info( - interaction: Interaction, - date_arg: str | None, - ) -> None: - await eadk_bot.info( - CommandInfo.from_interaction(interaction), - date_arg, - ).send(interaction) - - @bot.tree.command(name="book", description="Book a desk.", guilds=guilds) - @app_commands.autocomplete(booking_date_arg=date_autocomplete) - @app_commands.rename(booking_date_arg="date", desk_num_arg="desk_id", end_date_arg="end_date") - @app_commands.check(channel_check) - @app_commands.checks.has_any_role(TEST_SERVER_ROLE_ID, EADK_DESK_ADMIN_ID, EADK_DESK_REGULAR_ID) - async def book( - interaction: Interaction, - booking_date_arg: str | None, - user: Member | None, - desk_num_arg: Range[int, 1] | None, - end_date_arg: str | None, - ) -> None: - await eadk_bot.book( - CommandInfo.from_interaction(interaction), - booking_date_arg, - user.id if user else None, - desk_num_arg, - end_date_arg, - ).send(interaction) - database.save(database_path) - - @bot.tree.command(name="unbook", description="Unbook a desk.", guilds=guilds) - @app_commands.autocomplete(booking_date_arg=date_autocomplete) - @app_commands.rename(booking_date_arg="date", desk_num_arg="desk_id", end_date_arg="end_date") - @app_commands.check(channel_check) - @app_commands.checks.has_any_role(TEST_SERVER_ROLE_ID, EADK_DESK_ADMIN_ID, EADK_DESK_REGULAR_ID) - async def unbook( - interaction: Interaction, - booking_date_arg: str | None, - user: Member | None, - desk_num_arg: Range[int, 1] | None, - end_date_arg: str | None, - ) -> None: - await eadk_bot.unbook( - CommandInfo.from_interaction(interaction), - booking_date_arg, - user.id if user else None, - desk_num_arg, - end_date_arg, - ).send(interaction) - database.save(database_path) - - @bot.tree.command( - name="makeowned", description="Make a user the owner of the desk from a specific date onwards", guilds=guilds - ) - @app_commands.autocomplete(start_date_str=date_autocomplete) - @app_commands.rename(start_date_str="start_date", desk="desk_id") - @app_commands.check(channel_check) - @app_commands.checks.has_any_role(TEST_SERVER_ROLE_ID, EADK_DESK_ADMIN_ID) - async def makeowned( - interaction: Interaction, - start_date_str: str, - user: Member | None, - desk: Range[int, 1], - ) -> None: - await eadk_bot.makeowned( - CommandInfo.from_interaction(interaction), start_date_str, user.id if user else None, desk - ).send(interaction) - database.save(database_path) - - @bot.tree.command( - name="makeflex", description="Make a desk a flex desk from a specific date onwards", guilds=guilds - ) - @app_commands.autocomplete(start_date_str=date_autocomplete) - @app_commands.rename(start_date_str="start_date", desk="desk_id") - @app_commands.check(channel_check) - @app_commands.checks.has_any_role(TEST_SERVER_ROLE_ID, EADK_DESK_ADMIN_ID) - async def makeflex(interaction: Interaction, start_date_str: str, desk: Range[int, 1]) -> None: - await eadk_bot.makeflex(CommandInfo.from_interaction(interaction), start_date_str, desk).send(interaction) +class BotConfig(BaseModel): + bot_token: str + database_path: Path + guild_ids: Sequence[int | str] + channel_ids: Sequence[int | str] + regular_role_ids: Sequence[int] + admin_role_ids: Sequence[int] + + def guilds(self) -> Sequence[Snowflake]: + return [discord.Object(id=int(guild_id)) for guild_id in self.guild_ids] + + def setup_bot(self) -> Bot: + database_path = self.database_path + guilds = self.guilds() + if database_path.exists(): + database = Database.load(database_path) + else: + database = Database.initialize(date.today()) + database.handle_event( + Event(author=None, time=datetime.now(), event=SetNumDesks(date=date.today(), num_desks=6)) + ) database.save(database_path) - @bot.command() - @commands.is_owner() - async def sync(ctx: Context) -> None: - """Sync commands""" - for guild in guilds: - synced_commands = await ctx.bot.tree.sync(guild=guild) - logging.info(f"Synced {synced_commands} commands to guild {guild}") - - @bot.command() - @commands.is_owner() - async def syncglobal(ctx: Context) -> None: - """Sync commands""" - synced_commands = await ctx.bot.tree.sync() - logging.info(f"Synced {synced_commands} commands globally") - - @bot.event - async def on_ready() -> None: - logging.info(f"We have logged in as {bot.user}") - - @bot.tree.error - async def on_error(interaction: Interaction, error: AppCommandError) -> None: - try: - await eadk_bot.handle_error(CommandInfo.from_interaction(interaction), error).send(interaction) - except Exception: - await Response(message=INTERNAL_ERROR_MESSAGE, ephemeral=True).send(interaction) - raise - - return bot + intents: Intents = discord.Intents.default() + intents.message_content = True + intents.members = True + + eadk_bot = EADKBot(database, set(self.regular_role_ids), set(self.admin_role_ids)) + bot = Bot(command_prefix="!", intents=intents) + + async def channel_check(interaction: Interaction[discord.Client]) -> bool: + return interaction.channel_id in self.channel_ids + + @bot.tree.command(name="info", description="Get current booking status.", guilds=guilds) + @app_commands.autocomplete(date_arg=date_autocomplete) + @app_commands.rename(date_arg="date") + @app_commands.check(channel_check) + async def info( + interaction: Interaction, + date_arg: str | None, + ) -> None: + await eadk_bot.info( + CommandInfo.from_interaction(interaction), + date_arg, + ).send(interaction) + + @bot.tree.command(name="book", description="Book a desk.", guilds=guilds) + @app_commands.autocomplete(booking_date_arg=date_autocomplete) + @app_commands.rename(booking_date_arg="date", desk_num_arg="desk_id", end_date_arg="end_date") + @app_commands.check(channel_check) + async def book( + interaction: Interaction, + booking_date_arg: str | None, + user: Member | None, + desk_num_arg: Range[int, 1] | None, + end_date_arg: str | None, + ) -> None: + await eadk_bot.book( + CommandInfo.from_interaction(interaction), + booking_date_arg, + user.id if user else None, + desk_num_arg, + end_date_arg, + ).send(interaction) + database.save(database_path) + + @bot.tree.command(name="unbook", description="Unbook a desk.", guilds=guilds) + @app_commands.autocomplete(booking_date_arg=date_autocomplete) + @app_commands.rename(booking_date_arg="date", desk_num_arg="desk_id", end_date_arg="end_date") + @app_commands.check(channel_check) + async def unbook( + interaction: Interaction, + booking_date_arg: str | None, + user: Member | None, + desk_num_arg: Range[int, 1] | None, + end_date_arg: str | None, + ) -> None: + await eadk_bot.unbook( + CommandInfo.from_interaction(interaction), + booking_date_arg, + user.id if user else None, + desk_num_arg, + end_date_arg, + ).send(interaction) + database.save(database_path) + + @bot.tree.command( + name="makeowned", + description="Make a user the owner of the desk from a specific date onwards", + guilds=guilds, + ) + @app_commands.autocomplete(start_date_str=date_autocomplete) + @app_commands.rename(start_date_str="start_date", desk="desk_id") + @app_commands.check(channel_check) + @app_commands.checks.has_any_role(*self.admin_role_ids) + async def makeowned( + interaction: Interaction, + start_date_str: str, + user: Member | None, + desk: Range[int, 1], + ) -> None: + await eadk_bot.makeowned( + CommandInfo.from_interaction(interaction), start_date_str, user.id if user else None, desk + ).send(interaction) + database.save(database_path) + + @bot.tree.command( + name="makeflex", description="Make a desk a flex desk from a specific date onwards", guilds=guilds + ) + @app_commands.autocomplete(start_date_str=date_autocomplete) + @app_commands.rename(start_date_str="start_date", desk="desk_id") + @app_commands.check(channel_check) + @app_commands.checks.has_any_role(*self.admin_role_ids) + async def makeflex(interaction: Interaction, start_date_str: str, desk: Range[int, 1]) -> None: + await eadk_bot.makeflex(CommandInfo.from_interaction(interaction), start_date_str, desk).send(interaction) + database.save(database_path) + + @bot.command() + @commands.is_owner() + async def sync(ctx: Context) -> None: + """Sync commands""" + for guild in guilds: + synced_commands = await ctx.bot.tree.sync(guild=guild) + logging.info(f"Synced {synced_commands} commands to guild {guild}") + + @bot.command() + @commands.is_owner() + async def syncglobal(ctx: Context) -> None: + """Sync commands""" + synced_commands = await ctx.bot.tree.sync() + logging.info(f"Synced {synced_commands} commands globally") + + @bot.event + async def on_ready() -> None: + logging.info(f"We have logged in as {bot.user}") + + @bot.tree.error + async def on_error(interaction: Interaction, error: AppCommandError) -> None: + try: + await eadk_bot.handle_error(CommandInfo.from_interaction(interaction), error).send(interaction) + except Exception: + await Response(message=INTERNAL_ERROR_MESSAGE, ephemeral=True).send(interaction) + raise + + return bot + + def run_bot(self) -> Bot: + bot = self.setup_bot() + bot.run(self.bot_token) + return bot diff --git a/pyproject.toml b/pyproject.toml index 2d3f79c..5db9431 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ authors = [{name = "albertsgarde", email = "albertsgarde@gmail.com"}] dependencies = [ "beartype>=0.19.0", "discord-py>=2.4.0", + "pydantic-settings>=2.5.2", "pydantic>=2.9.2" ] description = "A Discord bot the EADK discord server" diff --git a/tests/conftest.py b/tests/conftest.py index 3e9c6ee..d8417ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,20 +1,38 @@ +from collections.abc import Callable, Sequence from datetime import date, datetime import pytest -from eadk_discord.bot import EADKBot +from eadk_discord.bot import CommandInfo, EADKBot from eadk_discord.database.database import Database from eadk_discord.database.event import Event, SetNumDesks NOW: datetime = datetime.fromisoformat("2024-09-13") # Friday TODAY: date = NOW.date() +REGULAR_ROLE_ID: int = 1 +ADMIN_ROLE_ID: int = 2 + @pytest.fixture def bot() -> EADKBot: database = Database.initialize(TODAY) database.handle_event(Event(author=None, time=NOW, event=SetNumDesks(date=TODAY, num_desks=6))) - bot = EADKBot(database) + bot = EADKBot(database, set([REGULAR_ROLE_ID]), set([ADMIN_ROLE_ID])) return bot + + +def command_info( + now: datetime = NOW, + format_user: Callable[[int], str] = lambda user: str(user), + author_id: int = 1, + author_role_ids: Sequence[int] = [], +) -> CommandInfo: + return CommandInfo( + now=now, + format_user=format_user, + author_id=author_id, + author_role_ids=set(author_role_ids), + ) diff --git a/tests/test_book.py b/tests/test_book.py index 4bcfcdf..85abc44 100644 --- a/tests/test_book.py +++ b/tests/test_book.py @@ -2,9 +2,9 @@ from itertools import chain import pytest -from conftest import NOW, TODAY +from conftest import ADMIN_ROLE_ID, NOW, REGULAR_ROLE_ID, TODAY, command_info -from eadk_discord.bot import CommandInfo, EADKBot +from eadk_discord.bot import EADKBot from eadk_discord.bot_setup import INTERNAL_ERROR_MESSAGE from eadk_discord.database.event_errors import DeskAlreadyBookedError, NonExistentDeskError from eadk_discord.database.state import DateTooEarlyError @@ -14,7 +14,7 @@ def test_book(bot: EADKBot) -> None: database = bot.database response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=None, user_id=None, desk_num=None, @@ -30,7 +30,7 @@ def test_book_with_desk(bot: EADKBot) -> None: database = bot.database response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=None, user_id=None, desk_num=5, @@ -49,7 +49,7 @@ def test_book2(bot: EADKBot) -> None: database.state.day(TODAY)[0].desk(0).booker = 0 response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=None, user_id=None, desk_num=None, @@ -66,7 +66,7 @@ def test_book_with_user(bot: EADKBot) -> None: database = bot.database response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str=None, user_id=7, desk_num=None, @@ -82,7 +82,7 @@ def test_book_with_user_desk(bot: EADKBot) -> None: database = bot.database response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str=None, user_id=4, desk_num=5, @@ -101,7 +101,7 @@ def test_book_with_date(bot: EADKBot) -> None: tomorrow = TODAY + timedelta(1) response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="tomorrow", user_id=None, desk_num=None, @@ -121,7 +121,7 @@ def test_book_with_date_desk(bot: EADKBot) -> None: tomorrow = TODAY + timedelta(1) response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="tomorrow", user_id=None, desk_num=3, @@ -141,7 +141,7 @@ def test_book_with_date_user(bot: EADKBot) -> None: tomorrow = TODAY + timedelta(1) response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str="tomorrow", user_id=8, desk_num=None, @@ -157,7 +157,7 @@ def test_book_with_date_user(bot: EADKBot) -> None: def test_book_range_no_desk(bot: EADKBot) -> None: response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="today", user_id=None, desk_num=None, @@ -172,7 +172,7 @@ def test_book_range_with_desk(bot: EADKBot) -> None: tomorrow = TODAY + timedelta(1) response = bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[ADMIN_ROLE_ID]), start_date_str="today", user_id=None, desk_num=3, @@ -181,7 +181,7 @@ def test_book_range_with_desk(bot: EADKBot) -> None: assert not response.ephemeral response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="today", user_id=None, desk_num=3, @@ -191,7 +191,7 @@ def test_book_range_with_desk(bot: EADKBot) -> None: assert not response.ephemeral response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="today", user_id=None, desk_num=3, @@ -208,14 +208,26 @@ def test_book_range_with_desk(bot: EADKBot) -> None: def test_book_range_unowned(bot: EADKBot) -> None: + database = bot.database + response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str="today", user_id=None, desk_num=3, end_date_str="tomorrow", ) assert response.ephemeral + response = bot.book( + command_info(author_role_ids=[ADMIN_ROLE_ID]), + date_str="today", + user_id=None, + desk_num=3, + end_date_str="tomorrow", + ) + assert not response.ephemeral + assert database.state.day(TODAY)[0].desk(2).booker == 1 + assert database.state.day(TODAY + timedelta(1))[0].desk(2).booker == 1 def test_book_weekday_same_week(bot: EADKBot) -> None: @@ -224,7 +236,7 @@ def test_book_weekday_same_week(bot: EADKBot) -> None: sunday = TODAY + timedelta(2) response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="sunday", user_id=None, desk_num=None, @@ -245,7 +257,7 @@ def test_book_weekday_next_week(bot: EADKBot) -> None: tuesday = TODAY + timedelta(4) response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="tuesday", user_id=None, desk_num=None, @@ -266,7 +278,7 @@ def test_book_date(bot: EADKBot) -> None: date = TODAY + timedelta(23) response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=date.isoformat(), user_id=None, desk_num=None, @@ -282,7 +294,7 @@ def test_book_with_date_user_desk(bot: EADKBot) -> None: database = bot.database response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str="today", user_id=3, desk_num=2, @@ -295,7 +307,7 @@ def test_book_with_date_user_desk(bot: EADKBot) -> None: def test_book_in_past(bot: EADKBot) -> None: response = bot.book( - CommandInfo(now=NOW + timedelta(2), format_user=lambda user: str(user), author_id=1), + command_info(now=NOW + timedelta(2)), date_str=TODAY.isoformat(), user_id=None, desk_num=None, @@ -312,7 +324,7 @@ def test_book_fully_booked(bot: EADKBot) -> None: database.state.day(TODAY)[0].desk(i).booker = i response = bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=7), + command_info(author_id=7), date_str=None, user_id=None, desk_num=None, @@ -325,7 +337,7 @@ def test_book_fully_booked(bot: EADKBot) -> None: def test_book_too_early(bot: EADKBot) -> None: with pytest.raises(DateTooEarlyError): bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=(TODAY - timedelta(1)).isoformat(), user_id=0, desk_num=1, @@ -336,7 +348,7 @@ def test_book_too_early(bot: EADKBot) -> None: def test_book_non_existent_desk(bot: EADKBot) -> None: with pytest.raises(NonExistentDeskError): bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str="today", user_id=0, desk_num=7, @@ -344,7 +356,7 @@ def test_book_non_existent_desk(bot: EADKBot) -> None: ) with pytest.raises(NonExistentDeskError): bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str="today", user_id=0, desk_num=0, @@ -359,9 +371,33 @@ def test_book_already_booked(bot: EADKBot) -> None: with pytest.raises(DeskAlreadyBookedError): bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="today", user_id=None, desk_num=1, end_date_str=None, ) + + +def test_book_for_other(bot: EADKBot) -> None: + database = bot.database + + response = bot.book( + command_info(author_role_ids=[]), + date_str=None, + user_id=7, + desk_num=None, + end_date_str=None, + ) + assert response.ephemeral is True + assert database.state.day(TODAY)[0].desk(0).booker is None + + response = bot.book( + command_info(author_role_ids=[ADMIN_ROLE_ID]), + date_str=None, + user_id=7, + desk_num=None, + end_date_str=None, + ) + assert response.ephemeral is False + assert database.state.day(TODAY)[0].desk(0).booker == 7 diff --git a/tests/test_makeflex.py b/tests/test_makeflex.py index 66188d0..ef503b8 100644 --- a/tests/test_makeflex.py +++ b/tests/test_makeflex.py @@ -1,9 +1,9 @@ from datetime import timedelta import pytest -from conftest import NOW, TODAY +from conftest import NOW, TODAY, command_info -from eadk_discord.bot import CommandInfo, EADKBot +from eadk_discord.bot import EADKBot from eadk_discord.database.event import Event, SetNumDesks from eadk_discord.database.event_errors import DeskNotOwnedError, NonExistentDeskError @@ -16,7 +16,7 @@ def test_makeflex(bot: EADKBot) -> None: distant_date2 = TODAY + timedelta(days=43) bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=tomorrow.isoformat(), user_id=None, desk_num=3, @@ -28,7 +28,7 @@ def test_makeflex(bot: EADKBot) -> None: assert database.state.day(distant_date2)[0].desk(2).owner == 1 response = bot.makeflex( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=distant_date.isoformat(), desk_num=3, ) @@ -51,7 +51,7 @@ def test_makeflex_booked(bot: EADKBot) -> None: database.state.day(distant_date2)[0].desk(2).booker = 4 bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=tomorrow.isoformat(), user_id=None, desk_num=3, @@ -63,7 +63,7 @@ def test_makeflex_booked(bot: EADKBot) -> None: assert database.state.day(distant_date2)[0].desk(2).owner == 1 response = bot.makeflex( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=distant_date.isoformat(), desk_num=3, ) @@ -86,21 +86,21 @@ def test_makeflex_varying_desk_num(bot: EADKBot) -> None: database.handle_event(Event(author=None, time=NOW, event=SetNumDesks(date=date2, num_desks=6))) bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=TODAY.isoformat(), user_id=None, desk_num=5, ) bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=date2.isoformat(), user_id=None, desk_num=5, ) bot.makeflex( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=(TODAY + timedelta(1)).isoformat(), desk_num=5, ) @@ -114,13 +114,13 @@ def test_makeflex_varying_desk_num(bot: EADKBot) -> None: def test_makeflex_non_existent_desk(bot: EADKBot) -> None: with pytest.raises(NonExistentDeskError): bot.makeflex( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=TODAY.isoformat(), desk_num=7, ) with pytest.raises(NonExistentDeskError): bot.makeflex( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=TODAY.isoformat(), desk_num=0, ) @@ -129,7 +129,7 @@ def test_makeflex_non_existent_desk(bot: EADKBot) -> None: def test_makeflex_flex_desk(bot: EADKBot) -> None: with pytest.raises(DeskNotOwnedError): bot.makeflex( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=TODAY.isoformat(), desk_num=1, ) diff --git a/tests/test_makeowned.py b/tests/test_makeowned.py index 57709b2..c8faee8 100644 --- a/tests/test_makeowned.py +++ b/tests/test_makeowned.py @@ -2,9 +2,9 @@ from itertools import chain import pytest -from conftest import NOW, TODAY +from conftest import NOW, TODAY, command_info -from eadk_discord.bot import CommandInfo, EADKBot +from eadk_discord.bot import EADKBot from eadk_discord.database.event import Event, SetNumDesks from eadk_discord.database.event_errors import DeskAlreadyOwnedError, NonExistentDeskError @@ -16,7 +16,7 @@ def test_makeowned(bot: EADKBot) -> None: distant_date = TODAY + timedelta(days=23) response = bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=tomorrow.isoformat(), user_id=None, desk_num=3, @@ -45,7 +45,7 @@ def test_makeowned_with_user(bot: EADKBot) -> None: distant_date = TODAY + timedelta(days=23) response = bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=tomorrow.isoformat(), user_id=4, desk_num=3, @@ -76,7 +76,7 @@ def test_makeowned_booked(bot: EADKBot) -> None: database.state.day(distant_date)[0].desk(2).booker = 4 response = bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=tomorrow.isoformat(), user_id=None, desk_num=3, @@ -108,7 +108,7 @@ def test_makeowned_varying_desk_num(bot: EADKBot) -> None: database.handle_event(Event(author=None, time=NOW, event=SetNumDesks(date=date2, num_desks=6))) bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=TODAY.isoformat(), user_id=None, desk_num=5, @@ -125,7 +125,7 @@ def test_makeowned_non_existent_desk(bot: EADKBot) -> None: with pytest.raises(NonExistentDeskError): bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=tomorrow.isoformat(), user_id=None, desk_num=7, @@ -133,7 +133,7 @@ def test_makeowned_non_existent_desk(bot: EADKBot) -> None: with pytest.raises(NonExistentDeskError): bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=tomorrow.isoformat(), user_id=None, desk_num=0, @@ -157,7 +157,7 @@ def test_makeowned_owned_desk(bot: EADKBot) -> None: with pytest.raises(DeskAlreadyOwnedError): bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=tomorrow.isoformat(), user_id=None, desk_num=3, diff --git a/tests/test_misc.py b/tests/test_misc.py index 31c061e..1700ff7 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,9 +1,9 @@ from datetime import timedelta import pytest -from conftest import NOW, TODAY +from conftest import NOW, TODAY, command_info -from eadk_discord.bot import CommandInfo, EADKBot +from eadk_discord.bot import EADKBot from eadk_discord.database.event import Event, SetNumDesks from eadk_discord.database.event_errors import DateTooEarlyError, NonExistentDeskError, RemoveDeskError from eadk_discord.dates import DateParseError @@ -12,7 +12,7 @@ def test_date_invalid(bot: EADKBot) -> None: with pytest.raises(DateParseError): bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="invalid", user_id=None, desk_num=None, @@ -21,13 +21,11 @@ def test_date_invalid(bot: EADKBot) -> None: def test_info(bot: EADKBot) -> None: - response = bot.info(CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), date_str=None) + response = bot.info(command_info(), date_str=None) assert response.ephemeral is True with pytest.raises(DateTooEarlyError): - response = bot.info( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), date_str="2021-01-01" - ) + response = bot.info(command_info(), date_str="2021-01-01") def test_change_desk_num(bot: EADKBot) -> None: @@ -62,7 +60,7 @@ def test_change_desk_num_owned_or_used(bot: EADKBot) -> None: date2 = TODAY + timedelta(days=42) bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=date1.isoformat(), user_id=None, desk_num=6, @@ -72,7 +70,7 @@ def test_change_desk_num_owned_or_used(bot: EADKBot) -> None: database.handle_event(Event(author=None, time=NOW, event=SetNumDesks(date=date1, num_desks=7))) bot.book( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=date2.isoformat(), user_id=None, desk_num=7, diff --git a/tests/test_unbook.py b/tests/test_unbook.py index b232dda..54ed396 100644 --- a/tests/test_unbook.py +++ b/tests/test_unbook.py @@ -1,9 +1,9 @@ from datetime import timedelta import pytest -from conftest import NOW, TODAY +from conftest import ADMIN_ROLE_ID, NOW, REGULAR_ROLE_ID, TODAY, command_info -from eadk_discord.bot import CommandInfo, EADKBot +from eadk_discord.bot import EADKBot from eadk_discord.bot_setup import INTERNAL_ERROR_MESSAGE from eadk_discord.database.event_errors import NonExistentDeskError from eadk_discord.database.state import DateTooEarlyError @@ -15,7 +15,7 @@ def test_unbook(bot: EADKBot) -> None: database.state.day(TODAY)[0].desk(3).booker = 1 response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=None, user_id=None, desk_num=None, @@ -32,7 +32,7 @@ def test_unbook_with_desk(bot: EADKBot) -> None: state.day(TODAY)[0].desk(4).booker = 1 response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=None, user_id=None, desk_num=5, @@ -43,7 +43,7 @@ def test_unbook_with_desk(bot: EADKBot) -> None: assert state.day(TODAY)[0].desk(i).booker is None response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=None, user_id=None, desk_num=3, @@ -63,7 +63,7 @@ def test_unbook_with_user(bot: EADKBot) -> None: day.desk(1).booker = 4 response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str=None, user_id=5, desk_num=None, @@ -84,7 +84,7 @@ def test_unbook_with_user_desk(bot: EADKBot) -> None: day.desk(1).booker = 4 response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str=None, user_id=5, desk_num=4, @@ -95,7 +95,7 @@ def test_unbook_with_user_desk(bot: EADKBot) -> None: assert day.desk(3).booker is None response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=None, user_id=5, desk_num=2, @@ -118,7 +118,7 @@ def test_unbook_with_date(bot: EADKBot) -> None: day.desk(4).booker = 1 response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=date.isoformat(), user_id=None, desk_num=None, @@ -136,7 +136,7 @@ def test_unbook_range_no_desk(bot: EADKBot) -> None: date = TODAY + timedelta(3) response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=TODAY.isoformat(), user_id=None, desk_num=None, @@ -151,7 +151,7 @@ def test_unbook_range(bot: EADKBot) -> None: date = TODAY + timedelta(3) response = bot.makeowned( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), start_date_str=TODAY.isoformat(), user_id=None, desk_num=1, @@ -160,7 +160,7 @@ def test_unbook_range(bot: EADKBot) -> None: assert not response.ephemeral response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=TODAY.isoformat(), user_id=None, desk_num=1, @@ -177,21 +177,39 @@ def test_unbook_range(bot: EADKBot) -> None: def test_unbook_range_unowned(bot: EADKBot) -> None: + database = bot.database + date = TODAY + timedelta(3) + database.state.day(TODAY)[0].desk(0).booker = 1 + database.state.day(TODAY + timedelta(1))[0].desk(0).booker = 1 + database.state.day(TODAY + timedelta(2))[0].desk(0).booker = 1 + database.state.day(date)[0].desk(0).booker = 1 + response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(author_role_ids=[REGULAR_ROLE_ID]), date_str=TODAY.isoformat(), user_id=None, desk_num=1, end_date_str=date.isoformat(), ) assert response.ephemeral + assert database.state.day(TODAY)[0].desk(0).booker == 1 + + response = bot.unbook( + command_info(author_role_ids=[ADMIN_ROLE_ID]), + date_str=TODAY.isoformat(), + user_id=None, + desk_num=1, + end_date_str=date.isoformat(), + ) + assert not response.ephemeral + assert database.state.day(TODAY)[0].desk(0).booker is None def test_unbook_in_past(bot: EADKBot) -> None: response = bot.unbook( - CommandInfo(now=NOW + timedelta(2), format_user=lambda user: str(user), author_id=1), + command_info(now=NOW + timedelta(2), author_id=1), date_str=TODAY.isoformat(), user_id=None, desk_num=None, @@ -204,7 +222,7 @@ def test_unbook_in_past(bot: EADKBot) -> None: def test_unbook_too_early(bot: EADKBot) -> None: with pytest.raises(DateTooEarlyError): bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str=(TODAY - timedelta(1)).isoformat(), user_id=0, desk_num=1, @@ -215,7 +233,7 @@ def test_unbook_too_early(bot: EADKBot) -> None: def test_unbook_non_existent_desk(bot: EADKBot) -> None: with pytest.raises(NonExistentDeskError): bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="today", user_id=0, desk_num=7, @@ -223,7 +241,7 @@ def test_unbook_non_existent_desk(bot: EADKBot) -> None: ) with pytest.raises(NonExistentDeskError): bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="today", user_id=0, desk_num=0, @@ -233,7 +251,7 @@ def test_unbook_non_existent_desk(bot: EADKBot) -> None: def test_unbook_unbooked_desk(bot: EADKBot) -> None: response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="today", user_id=0, desk_num=None, @@ -243,7 +261,7 @@ def test_unbook_unbooked_desk(bot: EADKBot) -> None: assert response.message != INTERNAL_ERROR_MESSAGE response = bot.unbook( - CommandInfo(now=NOW, format_user=lambda user: str(user), author_id=1), + command_info(), date_str="today", user_id=0, desk_num=1, @@ -251,3 +269,29 @@ def test_unbook_unbooked_desk(bot: EADKBot) -> None: ) assert response.ephemeral is True assert response.message != INTERNAL_ERROR_MESSAGE + + +def test_unbook_for_other(bot: EADKBot) -> None: + database = bot.database + + database.state.day(TODAY)[0].desk(3).booker = 7 + + response = bot.unbook( + command_info(author_role_ids=[]), + date_str=None, + user_id=7, + desk_num=4, + end_date_str=None, + ) + assert response.ephemeral is True + assert database.state.day(TODAY)[0].desk(3).booker == 7 + + response = bot.unbook( + command_info(author_role_ids=[ADMIN_ROLE_ID]), + date_str=None, + user_id=None, + desk_num=4, + end_date_str=None, + ) + assert response.ephemeral is False + assert database.state.day(TODAY)[0].desk(3).booker is None diff --git a/uv.lock b/uv.lock index ab415e2..f0161d4 100644 --- a/uv.lock +++ b/uv.lock @@ -214,6 +214,7 @@ dependencies = [ { name = "beartype" }, { name = "discord-py" }, { name = "pydantic" }, + { name = "pydantic-settings" }, ] [package.dev-dependencies] @@ -230,6 +231,7 @@ requires-dist = [ { name = "beartype", specifier = ">=0.19.0" }, { name = "discord-py", specifier = ">=2.4.0" }, { name = "pydantic", specifier = ">=2.9.2" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, ] [package.metadata.requires-dev] @@ -515,6 +517,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, ] +[[package]] +name = "pydantic-settings" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/27/0bed9dd26b93328b60a1402febc780e7be72b42847fa8b5c94b7d0aeb6d1/pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0", size = 70938 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/8d/29e82e333f32d9e2051c10764b906c2a6cd140992910b5f49762790911ba/pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907", size = 26864 }, +] + [[package]] name = "pytest" version = "8.3.3" @@ -543,6 +558,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, ] +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + [[package]] name = "pyyaml" version = "6.0.2"