diff --git a/sggwbot/__init__.py b/sggwbot/__init__.py index 95c0736..fef83a7 100644 --- a/sggwbot/__init__.py +++ b/sggwbot/__init__.py @@ -19,7 +19,7 @@ __author__ = "Wiktor Jaworski" __license__ = "MIT" __copyright__ = "Copyright 2023, 2024 Wiktor Jaworski" -__version__ = "0.8.0-beta.3" +__version__ = "0.8.0-beta.4" from . import console, errors, utils from .sggw_bot import SGGWBot diff --git a/sggwbot/calendar.py b/sggwbot/calendar.py index efb08dd..ae58fd1 100644 --- a/sggwbot/calendar.py +++ b/sggwbot/calendar.py @@ -203,6 +203,18 @@ async def _add(self, interaction: Interaction) -> None: modal = EventModal(EventModalType.ADD, self._ctrl) await interaction.response.send_modal(modal) + @_calendar.subcommand( + name="add_hidden", + description="Add a new hidden event.", + ) + @InteractionUtils.with_info( + catch_exceptions=[UpdateEmbedError, InvalidSettingsFile, ValueError] + ) + @InteractionUtils.with_log() + async def _add_hidden(self, interaction: Interaction) -> None: + modal = EventModal(EventModalType.ADD_HIDDEN, self._ctrl) + await interaction.response.send_modal(modal) + @_calendar.subcommand( name="edit", description="Edit an event.", @@ -222,11 +234,11 @@ async def _add(self, interaction: Interaction) -> None: async def _edit( self, interaction: Interaction, - index: int = SlashOption( + index: str = SlashOption( description="The index of the event to edit started from 1.", ), ) -> None: - event = self._model.get_event_at_index(index) + event = self._model.get_event_with_index(index) modal = EventModal( EventModalType.EDIT, self._ctrl, @@ -234,6 +246,68 @@ async def _edit( ) await interaction.response.send_modal(modal) + @_calendar.subcommand( + name="hide", + description="Hide an event.", + ) + @InteractionUtils.with_info( + before="Hiding event with index **{index}**...", + after="The event has been hidden.", + catch_exceptions=[ + UpdateEmbedError, + ValueError, + ExceptionData( + IndexError, + with_traceback_in_response=False, + with_traceback_in_log=False, + ), + ], + ) + @InteractionUtils.with_log() + async def _hide( + self, + _: Interaction, + index: str = SlashOption( + description="The index of the event to hide started from 1.", + ), + ) -> None: + event = self._model.get_event_with_index(index) + if event.is_hidden: + raise ValueError("The event is already hidden.") + event.is_hidden = True + await self._ctrl.update_embed() + + @_calendar.subcommand( + name="show", + description="Show an event.", + ) + @InteractionUtils.with_info( + before="Showing event with index **{index}**...", + after="The event has been showed.", + catch_exceptions=[ + UpdateEmbedError, + ValueError, + ExceptionData( + IndexError, + with_traceback_in_response=False, + with_traceback_in_log=False, + ), + ], + ) + @InteractionUtils.with_log() + async def _show( + self, + _: Interaction, + index: str = SlashOption( + description="The index of the event to show started from 1.", + ), + ) -> None: + event = self._model.get_event_with_index(index) + if not event.is_hidden: + raise ValueError("The event is already visible.") + event.is_hidden = False + await self._ctrl.update_embed() + @_calendar.subcommand( name="events_summary", description="Show summary of events.", @@ -242,7 +316,7 @@ async def _edit( catch_exceptions=[DiscordException, InvalidSettingsFile] ) @InteractionUtils.with_log() - async def _show(self, interaction: Interaction) -> None: + async def _events_summary(self, interaction: Interaction) -> None: """Shows summary of all events. Parameters @@ -274,7 +348,7 @@ async def _show(self, interaction: Interaction) -> None: async def _remove( self, interaction: Interaction, - index: int = SlashOption( + index: str = SlashOption( description="The index of the event to remove started from 1.", ), ) -> None: @@ -293,7 +367,7 @@ async def _remove( The embed could not be updated. """ - event = self._model.get_event_at_index(index) + event = self._model.get_event_with_index(index) self._model.remove_event_from_json(event) await self._ctrl.update_embed() @@ -328,7 +402,7 @@ async def _remove_expired_events(self, _: Interaction) -> None: """ removed_events = self._model.remove_expired_events() - if removed_events: + if any(map(lambda i: not i.is_hidden, removed_events)): await self._ctrl.update_embed() @tasks.loop(count=1) @@ -370,7 +444,7 @@ async def _remove_expired_events_task(self) -> None: async def _reminder( self, interaction: Interaction, - index: int = SlashOption( + index: str = SlashOption( description="The index of the event to edit started from 1.", ), ) -> None: @@ -388,7 +462,7 @@ async def _reminder( UpdateEmbedError The embed could not be updated. """ - event = self._model.get_event_at_index(index) + event = self._model.get_event_with_index(index) guild: Guild = interaction.guild # type: ignore modal = ReminderModal(event, guild) await interaction.response.send_modal(modal) @@ -414,7 +488,7 @@ async def _reminder( async def _remove_reminder( self, _: Interaction, - index: int = SlashOption( + index: str = SlashOption( description="The index of the event to edit started from 1.", ), ) -> None: @@ -432,7 +506,7 @@ async def _remove_reminder( ValueError The event has no reminder set. """ - event = self._model.get_event_at_index(index) + event = self._model.get_event_with_index(index) if event.reminder is None: raise ValueError("The event has no reminder set.") event.reminder = None @@ -461,7 +535,7 @@ async def _remove_reminder( async def _reminder_preview( self, interaction: Interaction, - index: int = SlashOption( + index: str = SlashOption( description="The index of the event to edit started from 1.", ), ) -> None: @@ -479,7 +553,7 @@ async def _reminder_preview( ValueError The event has no reminder set. """ - event = self._model.get_event_at_index(index) + event = self._model.get_event_with_index(index) if event.reminder is None: raise ValueError("The event has no reminder set.") @@ -537,6 +611,7 @@ class Event: # pylint: disable=too-many-instance-attributes _time: datetime.time | None _prefix: str _location: str + _is_hidden: bool = field(default=False) _reminder: Reminder | None = field(default=None) on_update: list[Callable[[Event], None]] = field( @@ -575,6 +650,7 @@ def from_dict(cls, _uuid: str, data: dict[str, Any]) -> Event: ), data["prefix"], data["location"], + data.get("is_hidden", False), Reminder.from_dict(d) if (d := data["reminder"]) else None, ) except KeyError as e: @@ -649,6 +725,16 @@ def location(self, value: str) -> None: self._location = value self._on_update_invoke() + @property + def is_hidden(self) -> bool: + """Whether the event is hidden in the calendar embed.""" + return self._is_hidden + + @is_hidden.setter + def is_hidden(self, value: bool) -> None: + self._is_hidden = value + self._on_update_invoke() + @property def reminder(self) -> Reminder | None: """The reminder for the event.""" @@ -726,11 +812,16 @@ def full_info(self) -> str: """The full information of the event in format: `(date) [prefix if exists] **description** - [location if exists] (time if not an all-day event) + [location if exists] (time if not an all-day event) (hidden if hidden)` - Similar to :attr:`.Event.full_name` but with the date at the beginning. + Similar to :attr:`.Event.full_name` but with the date at the beginning + and the hidden status at the end. """ - return f"({self.date.strftime('%d.%m.%Y')}) {self.full_name}" + return ( + f"({self.date.strftime('%d.%m.%Y')}) " + f"{self.full_name}" + f"{' (hidden)' if self.is_hidden else ''}" + ) @property def weekday(self) -> str: @@ -764,6 +855,7 @@ def to_dict(self) -> dict[str, Any]: "time": self.time.strftime("%H.%M") if self.time else None, "prefix": self.prefix, "location": self.location, + "is_hidden": self.is_hidden, "reminder": self.reminder.to_dict() if self.reminder else None, } @@ -798,13 +890,40 @@ def calendar_data(self) -> list[Event]: result.sort(key=functools.cmp_to_key(Event.compare_method)) return result - def get_event_at_index(self, index: int) -> Event: + def get_event_with_index(self, index: str) -> Event: + """Returns the event with the specified index. + + If the index starts with `_`, the event is hidden. + + Parameters + ---------- + index: :class:`str` + The index of the event to get. + + Returns + ------- + :class:`.Event` + The found event. + + Raises + ------ + IndexError + - If the index is out of bounds (less than 1 or greater than the number of events). + - If there are no events. + """ + is_hidden = index.startswith("_") + idx = int(index[1:] if is_hidden else index) + return self.get_event_at_index(idx, is_hidden=is_hidden) + + def get_event_at_index(self, index: int, *, is_hidden: bool = False) -> Event: """Returns the event at the specified index. Parameters ---------- index: :class:`int` The index of the event to get. + is_hidden: :class:`bool` + Whether the event is hidden in the calendar embed. Returns ------- @@ -818,7 +937,7 @@ def get_event_at_index(self, index: int) -> Event: - If there are no events. """ - events = self.calendar_data + events = [e for e in self.calendar_data if e.is_hidden == is_hidden] number_of_events = len(events) if number_of_events == 0: @@ -870,11 +989,22 @@ def summary_of_events(self) -> str: """A summary of all events in the calendar. Includes the index, the event full_info and the reminder status. In the format: `index. 🔔 full_info`. + If the event is hidden, it is prefixed with `_`. """ result = [] - events = self.calendar_data - for i, event in enumerate(events): - result.append(f"{i+1}.{' 🔔' if event.reminder else ''} {event.full_info}") + visible_events = [e for e in self.calendar_data if not e.is_hidden] + hidden_events = [e for e in self.calendar_data if e.is_hidden] + + if visible_events: + result.append("Visible events:") + for i, event in enumerate(visible_events): + result.append(f"{i+1}.{' 🔔' if event.reminder else ''} {event.full_info}") + + if hidden_events: + result.append("\nHidden events:") + for i, event in enumerate(hidden_events): + result.append(f"_{i+1}.{' 🔔' if event.reminder else ''} {event.full_info}") + return "\n".join(result) def add_event_to_json(self, event: Event) -> None: @@ -892,7 +1022,7 @@ def add_event_to_json(self, event: Event) -> None: def get_grouped_events( self, ) -> Generator[tuple[datetime.date, list[Event]], None, None]: - """An iterator that reads all events from settings and sorts them. + """An iterator that reads all visible events from settings and sorts them. Yields ------ @@ -902,6 +1032,9 @@ def get_grouped_events( calendar: dict[datetime.date, list[Event]] = {} for event in self.calendar_data: + if event.is_hidden: + continue + try: calendar[event.date].append(event) except KeyError: @@ -1097,6 +1230,7 @@ class EventModalType(Enum): """Represents type of event modal.""" ADD = auto() + ADD_HIDDEN = auto() EDIT = auto() @@ -1170,6 +1304,8 @@ def __init__( match modal_type: case EventModalType.ADD: title = "Add a new event" + case EventModalType.ADD_HIDDEN: + title = "Add a new hidden event" case EventModalType.EDIT: title = "Edit the event" case _: @@ -1267,9 +1403,13 @@ async def callback(self, interaction: Interaction) -> None: response_content += generator.preview_message embed = generator.embed + async def update_embed_if_event_is_visible() -> None: + if not event.is_hidden: + await self._controller.update_embed() + await asyncio.gather( *( - self._controller.update_embed(), + update_embed_if_event_is_visible(), interaction.followup.send( response_content, embed=embed, ephemeral=True ), @@ -1285,10 +1425,15 @@ def _create_new_event(self) -> Event: self._validate_datetime(date, time) - return self._controller.add_event_from_input( + event = self._controller.add_event_from_input( description, date, time, prefix, location ) + if self.modal_type is EventModalType.ADD_HIDDEN: + event.is_hidden = True + + return event + def _validate_datetime(self, date: str, time: str | None) -> None: dt = CalendarModel.convert_datetime_input(date, time) is_all_day = time is None @@ -1343,6 +1488,8 @@ def _send_info_to_console( match self.modal_type: case EventModalType.ADD: content += "added a new event." + case EventModalType.ADD_HIDDEN: + content += "added a new hidden event." case EventModalType.EDIT: assert old_event is not None content += f"edited the event '{old_event.full_info}' -> " @@ -1360,7 +1507,7 @@ def _generate_response_content( result = f"Event '{new_event.full_info}' has been " match self.modal_type: - case EventModalType.ADD: + case EventModalType.ADD | EventModalType.ADD_HIDDEN: result += "added." case EventModalType.EDIT: result += "edited." diff --git a/tests/test_calendar.py b/tests/test_calendar.py index b57bcdf..96c8961 100644 --- a/tests/test_calendar.py +++ b/tests/test_calendar.py @@ -73,6 +73,27 @@ def test_add_event_to_json(model: CalendarModel) -> None: "time": "14.15", "prefix": "TestPrefix", "location": "TestLocation", + "is_hidden": False, + "reminder": None, + } + } + + +def test_add_hidden_event_to_json(model: CalendarModel) -> None: + dt = datetime.datetime(2012, 12, 2, 14, 15) + event = Event( + "TestDescription", dt.date(), dt.time(), "TestPrefix", "TestLocation", True + ) + model.add_event_to_json(event) + file_data = _load_data_from_json() + assert file_data.get("events") == { + event.uuid: { + "description": "TestDescription", + "date": "02.12.2012", + "time": "14.15", + "prefix": "TestPrefix", + "location": "TestLocation", + "is_hidden": True, "reminder": None, } } @@ -121,6 +142,7 @@ def test_add_event_by_command(ctrl: CalendarController) -> None: "time": "11.22", "prefix": "TestPrefix", "location": "TestLocation", + "is_hidden": False, "reminder": None, } @@ -400,7 +422,10 @@ def test_summary_of_events(model: CalendarModel, date_now: datetime.date) -> Non model.add_event_to_json(event1) event2 = Event("test2", date_now, None, "", "location") model.add_event_to_json(event2) - assert model.summary_of_events == f"1. {event1.full_info}\n2. {event2.full_info}" + assert ( + model.summary_of_events + == f"Visible events:\n1. {event1.full_info}\n2. {event2.full_info}" + ) def test_get_event_from_empty_calendar(model: CalendarModel) -> None: