From 947a05ad14baef5a051d5099737ea8d133012adf Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 28 Nov 2024 18:52:51 +0100 Subject: [PATCH] add news mananger --- src/EzLanManager.py | 8 +- .../components/DesktopNavigation.py | 8 +- src/ez_lan_manager/components/LoginBox.py | 4 +- src/ez_lan_manager/components/NewsPost.py | 70 +++++++++- src/ez_lan_manager/helpers/LoggedInGuard.py | 10 +- src/ez_lan_manager/pages/ManageNewsPage.py | 131 ++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + .../services/DatabaseService.py | 40 ++++++ src/ez_lan_manager/services/NewsService.py | 6 + src/ez_lan_manager/types/SessionStorage.py | 10 +- 10 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 src/ez_lan_manager/pages/ManageNewsPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 84598de..f4b4910 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -9,7 +9,7 @@ from rio import App, Theme, Color, Font, ComponentPage, Session from from_root import from_root from src.ez_lan_manager import pages, init_services -from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard +from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard, team_guard from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -131,6 +131,12 @@ if __name__ == "__main__": build=pages.AccountPage, guard=logged_in_guard ), + ComponentPage( + name="ManageNewsPage", + url_segment="manage-news", + build=pages.ManageNewsPage, + guard=team_guard + ), ComponentPage( name="DbErrorPage", url_segment="db-error", diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index fa7a260..a20bc41 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -44,10 +44,10 @@ class DesktopNavigation(Component): team_navigation = [ Text("Verwaltung", align_x=0.5, margin_top=0.3, style=TextStyle(fill=Color.from_hex("F0EADE"), font_size=1.2)), Text("Vorsichtig sein!", align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.danger_color, font_size=0.6)), - DesktopNavigationButton("News", "./manage_news", is_team_navigation=True), - DesktopNavigationButton("Benutzer", "./manage_users", is_team_navigation=True), - DesktopNavigationButton("Catering", "./manage_catering", is_team_navigation=True), - DesktopNavigationButton("Turniere", "./manage_tournaments", is_team_navigation=True), + DesktopNavigationButton("News", "./manage-news", is_team_navigation=True), + DesktopNavigationButton("Benutzer", "./manage-users", is_team_navigation=True), + DesktopNavigationButton("Catering", "./manage-catering", is_team_navigation=True), + DesktopNavigationButton("Turniere", "./manage-tournaments", is_team_navigation=True), Spacer(min_height=1), Revealer( header="Normale Navigation", diff --git a/src/ez_lan_manager/components/LoginBox.py b/src/ez_lan_manager/components/LoginBox.py index e3f5ff5..6770043 100644 --- a/src/ez_lan_manager/components/LoginBox.py +++ b/src/ez_lan_manager/components/LoginBox.py @@ -4,6 +4,7 @@ from rio import Component, TextStyle, Color, TextInput, Button, Text, Rectangle, from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.User import User class LoginBox(Component): @@ -17,10 +18,11 @@ class LoginBox(Component): async def _on_login_pressed(self) -> None: if await self.session[UserService].is_login_valid(self.user_name_input_text[0], self.password_input_text[0]): + user: User = await self.session[UserService].get_user(self.user_name_input_text[0]) self.user_name_input_is_valid = True self.password_input_is_valid = True self.login_button_is_loading = False - await self.session[SessionStorage].set_user_id((await self.session[UserService].get_user(self.user_name_input_text[0])).user_id) + await self.session[SessionStorage].set_user_id_and_team_member_flag(user.user_id, user.is_team_member) await self.status_change_cb() else: self.user_name_input_is_valid = False diff --git a/src/ez_lan_manager/components/NewsPost.py b/src/ez_lan_manager/components/NewsPost.py index adac0ba..13e4b49 100644 --- a/src/ez_lan_manager/components/NewsPost.py +++ b/src/ez_lan_manager/components/NewsPost.py @@ -1,4 +1,8 @@ -from rio import Component, Rectangle, Text, TextStyle, Column, Row +from datetime import datetime +from functools import partial +from typing import Optional, Callable + +from rio import Component, Rectangle, Text, TextStyle, Column, Row, TextInput, DateInput, MultiLineTextInput, IconButton, Color, Button class NewsPost(Component): @@ -79,3 +83,67 @@ class NewsPost(Component): shadow_offset_y=0, corner_radius=0.2 ) + + +class EditableNewsPost(NewsPost): + news_id: int = -1 + save_cb: Callable = lambda _: None + delete_cb: Callable = lambda _: None + + def set_prop(self, prop, value) -> None: + self.__setattr__(prop, value) + + def build(self) -> Component: + return Rectangle( + content=Column( + Row( + TextInput( + text=self.title, + label="Titel", + style="rounded", + min_width=15, + on_change=lambda e: self.set_prop("title", e.text) + ), + DateInput( + value=datetime.strptime(self.date, "%d.%m.%Y"), + style="rounded", + on_change=lambda e: self.set_prop("date", e.value.strftime("%d.%m.%Y")) + ) + ), + TextInput( + text=self.subtitle, + label="Untertitel", + style="rounded", + grow_x=True, + on_change=lambda e: self.set_prop("subtitle", e.text) + ), + MultiLineTextInput( + text=self.text, + label="Text", + style="rounded", + grow_x=True, + min_height=12, + on_change=lambda e: self.set_prop("text", e.text) + ), + Row( + TextInput( + text=self.author, + label="Autor", + style="rounded", + grow_x=True, + on_change=lambda e: self.set_prop("author", e.text) + ), + Rectangle(content=Button(icon="material/delete", style="major", color="danger", shape="rectangle", on_press=partial(self.delete_cb, self.news_id)), fill=Color.from_hex("0b7372")), + Rectangle(content=Button(icon="material/save", style="major", color="success", shape="rectangle", on_press=partial(self.save_cb, self)), fill=Color.from_hex("0b7372")) + ) + ), + fill=self.session.theme.primary_color, + margin_left=1, + margin_right=1, + margin_top=2, + margin_bottom=1, + shadow_radius=0.2, + shadow_color=self.session.theme.background_color, + shadow_offset_y=0, + corner_radius=0.2 + ) diff --git a/src/ez_lan_manager/helpers/LoggedInGuard.py b/src/ez_lan_manager/helpers/LoggedInGuard.py index 0e50427..e09f2a5 100644 --- a/src/ez_lan_manager/helpers/LoggedInGuard.py +++ b/src/ez_lan_manager/helpers/LoggedInGuard.py @@ -1,7 +1,8 @@ from typing import Optional -from rio import Session, URL, GuardEvent +from rio import URL, GuardEvent +from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -14,3 +15,10 @@ def logged_in_guard(event: GuardEvent) -> Optional[URL]: def not_logged_in_guard(event: GuardEvent) -> Optional[URL]: if event.session[SessionStorage].user_id is not None: return URL("./") + +# Guards pages against access from users that are NOT logged in and NOT team members +def team_guard(event: GuardEvent) -> Optional[URL]: + user_id = event.session[SessionStorage].user_id + is_team_member = event.session[SessionStorage].is_team_member + if user_id is None or not is_team_member: + return URL("./") diff --git a/src/ez_lan_manager/pages/ManageNewsPage.py b/src/ez_lan_manager/pages/ManageNewsPage.py new file mode 100644 index 0000000..fa20800 --- /dev/null +++ b/src/ez_lan_manager/pages/ManageNewsPage.py @@ -0,0 +1,131 @@ +import logging +from asyncio import sleep +from datetime import datetime +from time import strptime + +from rio import Column, Component, event, TextStyle, Text + +from src.ez_lan_manager import ConfigurationService, UserService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.NewsPost import EditableNewsPost +from src.ez_lan_manager.services.NewsService import NewsService +from src.ez_lan_manager.types.News import News + +logger = logging.getLogger(__name__.split(".")[-1]) + +class ManageNewsPage(Component): + news_posts: list[News] = [] + show_success_message = False + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - News Verwaltung") + self.news_posts = (await self.session[NewsService].get_news())[:8] + + async def on_new_news_post(self, post: EditableNewsPost) -> None: + # @todo: For some reason, new posts do not appear through a force_refresh, only after visiting the page again + author = await self.session[UserService].get_user(post.author) + if author is None: + logger.warning(f"Tried to set news post author to '{post.author}', which does not exist.") + return + await self.session[NewsService].add_news(News( + news_id=None, + title=post.title, + subtitle=post.subtitle, + content=post.text, + author=author, + news_date=strptime(post.date, "%d.%m.%Y"), + )) + self.news_posts = (await self.session[NewsService].get_news())[:8] + self.show_success_message = True + await self.force_refresh() + await sleep(3) + self.show_success_message = False + await self.force_refresh() + + async def on_news_post_changed(self, post: EditableNewsPost) -> None: + author = await self.session[UserService].get_user(post.author) + if author is None: + logger.warning(f"Tried to set news post author to '{post.author}', which does not exist.") + return + await self.session[NewsService].update_news(News( + news_id=post.news_id, + title=post.title, + subtitle=post.subtitle, + content=post.text, + author=author, + news_date=strptime(post.date, "%d.%m.%Y"), + )) + self.news_posts = (await self.session[NewsService].get_news())[:8] + + async def on_news_post_deleted(self, news_id: int) -> None: + await self.session[NewsService].delete_news(news_id) + self.news_posts = (await self.session[NewsService].get_news())[:8] + + def build(self) -> Component: + posts = sorted([EditableNewsPost( + news_id=news.news_id, + title=news.title, + subtitle=news.subtitle, + text=news.content, + date=news.news_date.strftime("%d.%m.%Y"), + author=news.author.user_name, + save_cb=self.on_news_post_changed, + delete_cb=self.on_news_post_deleted + ) for news in self.news_posts], key=lambda p: p.date) + return Column( + MainViewContentBox( + Column( + Text( + text="News Verwaltung", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="Neuen News Post erstellen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.1 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + EditableNewsPost( + title="", + subtitle="", + text="", + date=datetime.now().strftime("%d.%m.%Y"), + author="", + save_cb=self.on_new_news_post + ), + Text( + text="Post erfolgreich erstellt", + style=TextStyle( + fill=self.session.theme.success_color, + font_size=0.7 if self.show_success_message else 0 + ), + margin_top=0.1, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="Bisherige Posts", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.1 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + *posts + ) + ), + align_y=0 + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 92d6210..ce5fa34 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -15,3 +15,4 @@ from .CateringPage import CateringPage from .DbErrorPage import DbErrorPage from .SeatingPlanPage import SeatingPlanPage from .BuyTicketPage import BuyTicketPage +from .ManageNewsPage import ManageNewsPage diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index d32cc95..a917edd 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -246,7 +246,47 @@ class DatabaseService: return results + async def update_news(self, news: News) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + """ + UPDATE news + SET news_content = %s, + news_title = %s, + news_subtitle = %s, + news_author = %s, + news_date = %s + WHERE news_id = %s + """, + (news.content, news.title, news.subtitle, news.author.user_id, news.news_date, news.news_id) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.update_news(news) + except Exception as e: + logger.warning(f"Error updating news: {e}") + async def remove_news(self, news_id: int) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "DELETE FROM news WHERE news_id = %s", + (news_id,) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.remove_news(news_id) + except Exception as e: + logger.warning(f"Error removing news with ID {news_id}: {e}") async def get_tickets(self) -> list[Ticket]: async with self._connection_pool.acquire() as conn: diff --git a/src/ez_lan_manager/services/NewsService.py b/src/ez_lan_manager/services/NewsService.py index 6c829a5..1211af5 100644 --- a/src/ez_lan_manager/services/NewsService.py +++ b/src/ez_lan_manager/services/NewsService.py @@ -24,6 +24,12 @@ class NewsService: dt_start = date(1900, 1, 1) return await self._db_service.get_news(dt_start, dt_end) + async def update_news(self, news: News) -> None: + return await self._db_service.update_news(news) + + async def delete_news(self, news_id: int) -> None: + return await self._db_service.remove_news(news_id) + async def get_latest_news(self) -> Optional[News]: try: all_news = await self.get_news(None, date.today()) diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py index 1968ed9..206cdd2 100644 --- a/src/ez_lan_manager/types/SessionStorage.py +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -11,10 +11,11 @@ logger = logging.getLogger(__name__.split(".")[-1]) @dataclass(frozen=False) class SessionStorage: _user_id: Optional[int] = None # DEBUG: Put user ID here to skip login + _is_team_member: bool = False _notification_callbacks: dict[str, Callable] = field(default_factory=dict) async def clear(self) -> None: - await self.set_user_id(None) + await self.set_user_id_and_team_member_flag(None, False) def subscribe_to_logged_in_or_out_event(self, component_id: str, callback: Callable) -> None: self._notification_callbacks[component_id] = callback @@ -23,8 +24,13 @@ class SessionStorage: def user_id(self) -> Optional[int]: return self._user_id - async def set_user_id(self, user_id: Optional[int]) -> None: + @property + def is_team_member(self) -> bool: + return self._is_team_member + + async def set_user_id_and_team_member_flag(self, user_id: Optional[int], is_team_member: bool) -> None: self._user_id = user_id + self._is_team_member = is_team_member for component_id, callback in self._notification_callbacks.items(): logger.debug(f"Calling logged in callback from {component_id}") await callback()