Skip to content

Commit

Permalink
Merge pull request #18 from albertsgarde/5-support-date-ranges-for-bo…
Browse files Browse the repository at this point in the history
…oking

5 support date ranges for booking
  • Loading branch information
albertsgarde authored Oct 5, 2024
2 parents 6ee7819 + 742cad3 commit 196912d
Show file tree
Hide file tree
Showing 11 changed files with 352 additions and 49 deletions.
102 changes: 86 additions & 16 deletions eadk_discord/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,23 @@ def info(self, info: CommandInfo, date_str: str | None) -> Response:
)

@beartype
def book(self, info: CommandInfo, date_str: str | None, user_id: int | None, desk_num: int | None) -> Response:
def book(
self,
info: CommandInfo,
date_str: str | None,
user_id: int | None,
desk_num: int | None,
end_date_str: str | None,
) -> Response:
if user_id is None:
user_id = info.author_id

booking_date = dates.get_booking_date(date_str, info.now)
booking_day, _ = self._database.state.day(booking_date)
date_str = fmt.date(booking_date)

end_date = dates.parse_date_arg(end_date_str, info.now.date()) if end_date_str is not None else None

if booking_date < info.now.date():
return Response(
message=f"Date {date_str} not available for booking. Desks cannot be unbooked in the past.",
Expand All @@ -102,42 +111,86 @@ def book(self, info: CommandInfo, date_str: str | None, user_id: int | None, des
if desk_num is not None:
desk_index = desk_num - 1
else:
if end_date is not None:
return Response(message="A desk must be specified for range bookings.", ephemeral=True)
desk_index_option = booking_day.get_available_desk()
if desk_index_option is not None:
desk_index = desk_index_option
desk_num = desk_index + 1
else:
return Response(message=f"No more desks are available for booking on {date_str}.", ephemeral=True)

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:
return Response(
message="Range bookings are only allowed for desks you own for the entire range.",
ephemeral=True,
)

self._database.handle_event(
Event(
author=info.author_id,
time=datetime.now(),
event=BookDesk(date=booking_date, desk_index=desk_index, user=user_id),
event=BookDesk(
start_date=booking_date, end_date=end_date or booking_date, desk_index=desk_index, user=user_id
),
)
)
return Response(message=f"Desk {desk_num} has been booked for {info.format_user(user_id)} on {date_str}.")
if end_date is not None:
return Response(
message=f"Desk {desk_num} has been booked for {info.format_user(user_id)} "
f"from {date_str} to {fmt.date(end_date)}."
)
else:
return Response(message=f"Desk {desk_num} has been booked for {info.format_user(user_id)} on {date_str}.")

@beartype
def unbook(self, info: CommandInfo, date_str: str | None, user_id: int | None, desk_num: int | None) -> Response:
def unbook(
self,
info: CommandInfo,
date_str: str | None,
user_id: int | None,
desk_num: int | None,
end_date_str: str | None,
) -> Response:
booking_date = dates.get_booking_date(date_str, info.now)
booking_day, _ = self._database.state.day(booking_date)
date_str = fmt.date(booking_date)

end_date = dates.parse_date_arg(end_date_str, info.now.date()) if end_date_str is not None else booking_date

booking_days = self._database.state.day_range(booking_date, end_date)

if booking_date < info.now.date():
return Response(
message=f"Date {date_str} not available for booking. Desks cannot be unbooked in the past.",
ephemeral=True,
)

if desk_num is not None:
if len(booking_days) > 1:
if desk_num is None:
return Response(message="A desk must be specified for range unbookings.", ephemeral=True)
desk_index = desk_num - 1
if user_id is not None:
if user_id != booking_day.desk(desk_index).booker:
for booking_day in booking_days:
if booking_day.desk(desk_index).owner != info.author_id:
return Response(
message=f"Desk {desk_num} is not booked by {info.format_user(user_id)} on {date_str}.",
message="Range unbookings are only allowed for desks you own for the entire range.",
ephemeral=True,
)

if desk_num is not None:
desk_index = desk_num - 1
if user_id is not None:
for booking_day in booking_days:
if user_id != booking_day.desk(desk_index).booker:
return Response(
message=f"Desk {desk_num} is not booked by {info.format_user(user_id)} on {date_str}.",
ephemeral=True,
)
else:
assert len(booking_days) == 1
booking_day = booking_days[0]
if user_id is None:
user_id = info.author_id
desk_indices = booking_day.booked_desks(user_id)
Expand All @@ -149,20 +202,31 @@ def unbook(self, info: CommandInfo, date_str: str | None, user_id: int | None, d
message=f"{info.format_user(user_id)} already has no desks booked for {date_str}.", ephemeral=True
)

desk_booker = booking_day.desk(desk_index).booker
if desk_booker is not None:
if len(booking_days) > 1:
self._database.handle_event(
Event(
author=info.author_id,
time=datetime.now(),
event=UnbookDesk(date=booking_date, desk_index=desk_index),
event=UnbookDesk(start_date=booking_date, end_date=end_date, desk_index=desk_index),
)
)
return Response(
message=f"Desk {desk_num} is no longer booked for {info.format_user(desk_booker)} on {date_str}."
)
return Response(message=(f"Desk {desk_num} has been unbooked from {date_str} to {fmt.date(end_date)}."))
else:
return Response(message=f"Desk {desk_num} is already free on {date_str}.", ephemeral=True)
[booking_day] = booking_days
desk_booker = booking_day.desk(desk_index).booker
if desk_booker is not None:
self._database.handle_event(
Event(
author=info.author_id,
time=datetime.now(),
event=UnbookDesk(start_date=booking_date, end_date=booking_date, desk_index=desk_index),
)
)
return Response(
message=f"Desk {desk_num} is no longer booked for {info.format_user(desk_booker)} on {date_str}."
)
else:
return Response(message=f"Desk {desk_num} is already free on {date_str}.", ephemeral=True)

@beartype
def makeowned(self, info: CommandInfo, start_date_str: str, user_id: int | None, desk_num: int) -> Response:
Expand Down Expand Up @@ -209,4 +273,10 @@ def handle_error(self, info: CommandInfo, error: AppCommandError) -> Response:
match error.__cause__:
case EventError() as event_error:
return Response(message=event_error.message(info.format_user), ephemeral=True)
case dates.DateParseError(argument):
return Response(
f"Date {argument} could not be parsed. "
"Please use the format YYYY-MM-DD, 'today', 'tomorrow', or specify a weekday.",
ephemeral=True,
)
raise error
17 changes: 12 additions & 5 deletions eadk_discord/bot_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,22 @@ async def info(

@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="desk_id")
@app_commands.rename(booking_date_arg="date", desk_num_arg="desk_id")
@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: Range[int, 1] | 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,
desk_num_arg,
end_date_arg,
).send(interaction)
database.save(database_path)

Expand All @@ -96,10 +98,15 @@ async def unbook(
interaction: Interaction,
booking_date_arg: str | None,
user: Member | None,
desk: Range[int, 1] | 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
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)

Expand Down
6 changes: 4 additions & 2 deletions eadk_discord/database/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ class SetNumDesks(BaseModel):


class BookDesk(BaseModel):
date: Date = Field()
start_date: Date = Field()
end_date: Date = Field()
desk_index: int = Field()
user: int = Field()


class UnbookDesk(BaseModel):
date: Date = Field()
start_date: Date = Field()
end_date: Date = Field()
desk_index: int = Field()


Expand Down
13 changes: 13 additions & 0 deletions eadk_discord/database/event_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ def message(self, format_user: Callable[[int], str]) -> str:
return f"Date {self.date} is before the start date {self.start_date}."


@dataclass
class InvalidDateRangeError(EventError):
"""
Raised when the end date of a date range is before the start date
"""

start_date: Date
end_date: Date

def message(self, format_user: Callable[[int], str]) -> str:
return f"End date {self.end_date} is before the start date {self.start_date}."


@dataclass
class RemoveDeskError(EventError):
"""
Expand Down
37 changes: 23 additions & 14 deletions eadk_discord/database/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
DeskAlreadyOwnedError,
DeskNotBookedError,
DeskNotOwnedError,
InvalidDateRangeError,
NonExistentDeskError,
RemoveDeskError,
)
Expand Down Expand Up @@ -112,6 +113,15 @@ def day(self, date: Date) -> tuple[Day, int]:
self.days.append(Day.create_from_previous(self.days[-1]))
return self.days[day_index], day_index

@beartype
def day_range(self, start_date: Date, end_date: Date) -> Sequence[Day]:
if end_date < start_date:
raise InvalidDateRangeError(start_date=start_date, end_date=end_date)
start_day, start_index = self.day(start_date)
end_day, end_index = self.day(end_date)
# The two lines above should ensure that this line is valid.
return self.days[start_index : end_index + 1]

@beartype
def handle_event(self, event: Event) -> None:
match event.event:
Expand Down Expand Up @@ -147,25 +157,24 @@ def _set_num_desks(self, event: SetNumDesks) -> None:

@beartype
def _book_desk(self, event: BookDesk) -> None:
day, _ = self.day(event.date)
days = self.day_range(event.start_date, event.end_date)
desk_index = event.desk_index
if desk_index >= len(day.desks) or desk_index < 0:
raise NonExistentDeskError(desk=desk_index, num_desks=len(day.desks), day=event.date)
desk = day.desks[desk_index]
if desk.booker is not None:
raise DeskAlreadyBookedError(booker=desk.booker, desk=desk_index, day=event.date)
desk.booker = event.user
for day in days:
booker = day.desk(desk_index).booker
if booker is not None:
raise DeskAlreadyBookedError(booker=booker, desk=desk_index, day=day.date)
for day in days:
day.desk(desk_index).booker = event.user

@beartype
def _unbook_desk(self, event: UnbookDesk) -> None:
day, _ = self.day(event.date)
days = self.day_range(event.start_date, event.end_date)
desk_index = event.desk_index
if desk_index >= len(day.desks) or desk_index < 0:
raise NonExistentDeskError(desk=desk_index, num_desks=len(day.desks), day=event.date)
desk = day.desks[desk_index]
if desk.booker is None:
raise DeskNotBookedError(desk=desk_index, day=event.date)
desk.booker = None
for day in days:
if day.desk(desk_index) is None:
raise DeskNotBookedError(desk=desk_index, day=day.date)
for day in days:
day.desk(desk_index).booker = None

@beartype
def _make_owned(self, event: MakeOwned) -> None:
Expand Down
19 changes: 19 additions & 0 deletions eadk_discord/migrate_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import json
import os
from pathlib import Path

if __name__ == "__main__":
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)

db_dict = json.loads(database_path.read_text())
for event in db_dict["history"]:
event_data = event["event"]
if "desk_index" in event_data and "date" in event_data:
event_data["start_date"] = event_data["date"]
event_data["end_date"] = event_data["date"]
del event_data["date"]
database_path.write_text(json.dumps(db_dict))
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ skip_covered = true

[tool.mypy]
disallow_untyped_defs = true
enable_error_code = "possibly-undefined"
warn_redundant_casts = true
warn_return_any = true
warn_unreachable = true
Expand Down
7 changes: 4 additions & 3 deletions start.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/bin/bash
# Hack to get repo location. This breaks if the server directory is moved.
# Hack to get repo location.
REPO_PATH=$(dirname $(readlink -f $0))
cd $REPO_PATH
if [ ! -d ".venv" ]; then
python -m venv .venv
fi
source .venv/bin/activate
python -m pip install -r requirements.txt
python -m pip install uv
uv sync
source .env
python -m eadk_discord
uv run eadk_discord
Loading

0 comments on commit 196912d

Please sign in to comment.