finalize core feature

This commit is contained in:
David Rodenkirchen 2026-02-15 01:02:01 +01:00
parent 8a9004a9a0
commit 6cc77f26b5
4 changed files with 303 additions and 35 deletions

View File

@ -0,0 +1,222 @@
import logging
from typing import Optional, Callable
from rio import Component, Text, Spacer, Rectangle, Column, TextStyle, Row, Button, TextInput, ThemeContextSwitcher
from src.ezgg_lan_manager.services.TeamService import TeamService, NotMemberError, TeamLeadRemovalError, AlreadyMemberError, NameNotAllowedError, TeamNameTooLongError, \
TeamAbbrInvalidError, TeamNameAlreadyTaken
from src.ezgg_lan_manager.types.Team import Team
from src.ezgg_lan_manager.types.User import User
logger = logging.getLogger(__name__.split(".")[-1])
class ErrorBox(Component):
error_message: str
cancel: Callable
def build(self) -> Component:
return Rectangle(
content=Column(
Text(self.error_message, style=TextStyle(fill=self.session.theme.background_color), margin_bottom=1.5),
Row(
Button(
content="Ok",
shape="rectangle",
style="major",
color="hud",
on_press=self.cancel,
)
),
margin=1
),
fill=self.session.theme.primary_color
)
class TeamsDialogJoinHandler(Component):
is_active: bool
cancel: Callable
user: Optional[User] = None
team: Optional[Team] = None
error_message: Optional[str] = None
password: str = ""
async def join(self) -> None:
if self.user is None or self.team is None:
return
if self.password != self.team.join_password:
self.error_message = "Falsches Passwort!"
return
try:
await self.session[TeamService].add_member_to_team(self.team, self.user)
except AlreadyMemberError:
self.error_message = "Du bist bereits Mitglied dieses Teams"
else:
await self.cancel_with_reset()
async def cancel_with_reset(self) -> None:
await self.cancel()
self.error_message = None
self.password = ""
def build(self) -> Component:
if not self.is_active or self.user is None or self.team is None:
return Spacer()
if self.error_message is not None:
return ErrorBox(error_message=self.error_message, cancel=self.cancel_with_reset)
return Rectangle(
content=Column(
Text(f"Team {self.team.name} beitreten", style=TextStyle(fill=self.session.theme.background_color), margin_bottom=1, justify="center"),
ThemeContextSwitcher(content=TextInput(text=self.bind().password, label="Beitrittspasswort", margin_bottom=1), color="secondary"),
Row(
Button(
content="Abbrechen",
shape="rectangle",
style="major",
color=self.session.theme.danger_color,
on_press=self.cancel_with_reset,
),
Button(
content="Beitreten",
shape="rectangle",
style="major",
color=self.session.theme.success_color,
on_press=self.join,
),
spacing=1
),
margin=1
),
fill=self.session.theme.primary_color
)
class TeamsDialogLeaveHandler(Component):
is_active: bool
cancel: Callable
user: Optional[User] = None
team: Optional[Team] = None
error_message: Optional[str] = None
async def leave(self) -> None:
if self.user is not None and self.team is not None:
try:
await self.session[TeamService].remove_member_from_team(self.team, self.user)
except NotMemberError:
self.error_message = "Du bist kein Mitglied in diesem Team"
except TeamLeadRemovalError:
self.error_message = "Als Teamleiter kannst du das Team nicht verlassen"
else:
await self.cancel_with_reset()
async def cancel_with_reset(self) -> None:
await self.cancel()
self.error_message = None
def build(self) -> Component:
if not self.is_active or self.user is None or self.team is None:
return Spacer()
if self.error_message is not None:
return ErrorBox(error_message=self.error_message, cancel=self.cancel_with_reset)
return Rectangle(
content=Column(
Text(f"Team {self.team.name} wirklich verlassen?", style=TextStyle(fill=self.session.theme.background_color), margin_bottom=1.5, justify="center"),
Row(
Button(
content="Nein",
shape="rectangle",
style="major",
color=self.session.theme.danger_color,
on_press=self.cancel_with_reset,
),
Button(
content="Ja",
shape="rectangle",
style="major",
color=self.session.theme.success_color,
on_press=self.leave,
),
spacing=1
),
margin=1
),
fill=self.session.theme.primary_color
)
class TeamsDialogCreateHandler(Component):
is_active: bool
cancel: Callable
user: Optional[User] = None
error_message: Optional[str] = None
team_name: str = ""
team_abbr: str = ""
team_join_password: str = ""
async def cancel_with_reset(self) -> None:
await self.cancel()
self.error_message = None
self.team_name, self.team_abbr, self.team_join_password = "", "", ""
async def create(self) -> None:
if self.user is None:
return
if not self.team_name or not self.team_abbr or not self.team_join_password:
self.error_message = "Angaben unvollständig"
return
try:
await self.session[TeamService].create_team(self.team_name, self.team_abbr, self.team_join_password, self.user)
except NameNotAllowedError as e:
self.error_message = f"Angaben ungültig. Darf kein '{e.disallowed_char}' enthalten."
except TeamNameTooLongError:
self.error_message = f"Name zu lang. Maximal {TeamService.MAX_TEAM_NAME_LENGTH} Zeichen."
except TeamAbbrInvalidError:
self.error_message = f"Name zu lang. Maximal {TeamService.MAX_TEAM_ABBR_LENGTH} Zeichen."
except TeamNameAlreadyTaken:
self.error_message = "Ein Team mit diesem Namen existiert bereits."
else:
await self.cancel_with_reset()
def build(self) -> Component:
if not self.is_active or self.user is None:
return Spacer()
if self.error_message is not None:
return ErrorBox(error_message=self.error_message, cancel=self.cancel_with_reset)
return Rectangle(
content=Column(
Text(f"Team gründen", style=TextStyle(fill=self.session.theme.background_color), margin_bottom=1.5, justify="center"),
ThemeContextSwitcher(content=TextInput(text=self.bind().team_name, label="Team Name", margin_bottom=1), color="secondary"),
ThemeContextSwitcher(content=TextInput(text=self.bind().team_abbr, label="Team Abkürzung", margin_bottom=1), color="secondary"),
ThemeContextSwitcher(content=TextInput(text=self.bind().team_join_password, label="Beitrittspasswort", margin_bottom=1), color="secondary"),
Row(
Button(
content="Abbrechen",
shape="rectangle",
style="major",
color=self.session.theme.danger_color,
on_press=self.cancel_with_reset,
),
Button(
content="Gründen",
shape="rectangle",
style="major",
color=self.session.theme.success_color,
on_press=self.create,
),
spacing=1
),
margin=1
),
fill=self.session.theme.primary_color
)

View File

@ -1,10 +1,11 @@
from typing import Optional from asyncio import sleep
from rio import Column, Component, event, Text, TextStyle, ProgressCircle, Spacer, Row, Rectangle, PointerEventListener, PointerEvent from rio import event, ProgressCircle, PointerEventListener, PointerEvent, Popup, Color
from src.ezgg_lan_manager import ConfigurationService from src.ezgg_lan_manager import ConfigurationService
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ezgg_lan_manager.components.TeamRevealer import TeamRevealer from src.ezgg_lan_manager.components.TeamRevealer import TeamRevealer
from src.ezgg_lan_manager.components.TeamsDialogHandler import *
from src.ezgg_lan_manager.services.TeamService import TeamService from src.ezgg_lan_manager.services.TeamService import TeamService
from src.ezgg_lan_manager.services.UserService import UserService from src.ezgg_lan_manager.services.UserService import UserService
from src.ezgg_lan_manager.types.SessionStorage import SessionStorage from src.ezgg_lan_manager.types.SessionStorage import SessionStorage
@ -16,6 +17,13 @@ class TeamsPage(Component):
all_teams: Optional[list[Team]] = None all_teams: Optional[list[Team]] = None
user: Optional[User] = None user: Optional[User] = None
# Dialog handling
popup_open: bool = False
join_active: bool = False
leave_active: bool = True
create_active: bool = False
selected_team_for_join_or_leave: Optional[Team] = None
@event.on_populate @event.on_populate
async def on_populate(self) -> None: async def on_populate(self) -> None:
self.all_teams = await self.session[TeamService].get_all_teams() self.all_teams = await self.session[TeamService].get_all_teams()
@ -24,16 +32,31 @@ class TeamsPage(Component):
self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.on_populate) self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.on_populate)
async def on_join_button_pressed(self, team: Team) -> None: async def on_join_button_pressed(self, team: Team) -> None:
# ToDo: handle joining by displaying password popup if self.user is None:
print(f"Starting join process for team {team.abbreviation}") return
self.selected_team_for_join_or_leave = team
self.join_active, self.leave_active, self.create_active = True, False, False
self.popup_open = True
async def on_leave_button_pressed(self, team: Team) -> None: async def on_leave_button_pressed(self, team: Team) -> None:
# ToDo: handle leaving if self.user is None:
print(f"Starting leaving process for team {team.abbreviation}") return
self.selected_team_for_join_or_leave = team
self.join_active, self.leave_active, self.create_active = False, True, False
self.popup_open = True
async def on_create_button_pressed(self, _: PointerEvent) -> None: async def on_create_button_pressed(self, _: PointerEvent) -> None:
# ToDo: Add Popup creation dialog if self.user is None:
print(f"Starting creation process for team") return
self.join_active, self.leave_active, self.create_active = False, False, True
self.popup_open = True
async def popup_action_cancelled(self) -> None:
self.popup_open = False
await sleep(0.2) # Waits for the animation to play before resetting its contents
self.join_active, self.leave_active, self.create_active = False, False, False
self.selected_team_for_join_or_leave = None
self.all_teams = await self.session[TeamService].get_all_teams()
def build(self) -> Component: def build(self) -> Component:
if self.all_teams is None: if self.all_teams is None:
@ -115,7 +138,8 @@ class TeamsPage(Component):
) )
) )
return Column( return Popup(
anchor=Column(
own_teams_content, own_teams_content,
MainViewContentBox( MainViewContentBox(
Column( Column(
@ -132,5 +156,17 @@ class TeamsPage(Component):
*team_list *team_list
) )
), ),
align_y=0 align_y=0,
),
content=Column(
TeamsDialogJoinHandler(is_active=self.join_active, cancel=self.popup_action_cancelled, user=self.user, team=self.selected_team_for_join_or_leave),
TeamsDialogLeaveHandler(is_active=self.leave_active, cancel=self.popup_action_cancelled, user=self.user, team=self.selected_team_for_join_or_leave),
TeamsDialogCreateHandler(is_active=self.create_active, cancel=self.popup_action_cancelled, user=self.user)
),
is_open=self.popup_open,
modal=False,
corner_radius=(0.5, 0.5, 0.5, 0.5),
color=Color.TRANSPARENT,
user_closable=False,
position="top"
) )

View File

@ -1094,7 +1094,7 @@ class DatabaseService:
return team return team
async def create_team(self, team_name: str, team_abbr: str, join_password: str) -> Team: async def create_team(self, team_name: str, team_abbr: str, join_password: str, leader: User) -> Team:
async with self._connection_pool.acquire() as conn: async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor: async with conn.cursor(aiomysql.Cursor) as cursor:
try: try:
@ -1103,7 +1103,13 @@ class DatabaseService:
"VALUES (%s, %s, %s)", (team_name, team_abbr, join_password) "VALUES (%s, %s, %s)", (team_name, team_abbr, join_password)
) )
await conn.commit() await conn.commit()
return await self.get_team_by_id(cursor.lastrowid) team_id = cursor.lastrowid
await cursor.execute(
"INSERT INTO team_members (team_id, user_id, status) VALUES (%s, %s, %s)",
(team_id, leader.user_id, TeamStatus.LEADER.name)
)
await conn.commit()
return await self.get_team_by_id(team_id)
except aiomysql.InterfaceError: except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool() pool_init_result = await self.init_db_pool()

View File

@ -1,44 +1,48 @@
from hashlib import sha256
from typing import Union, Optional
from string import ascii_letters, digits from string import ascii_letters, digits
from typing import Optional
from src.ezgg_lan_manager.services.DatabaseService import DatabaseService, DuplicationError from src.ezgg_lan_manager.services.DatabaseService import DatabaseService, DuplicationError
from src.ezgg_lan_manager.types.User import User
from src.ezgg_lan_manager.types.Team import TeamStatus, Team from src.ezgg_lan_manager.types.Team import TeamStatus, Team
from src.ezgg_lan_manager.types.User import User
class NameNotAllowedError(Exception): class NameNotAllowedError(Exception):
def __init__(self, disallowed_char: str) -> None: def __init__(self, disallowed_char: str) -> None:
self.disallowed_char = disallowed_char self.disallowed_char = disallowed_char
class AlreadyMemberError(Exception): class AlreadyMemberError(Exception):
def __init__(self) -> None: def __init__(self) -> None:
pass pass
class NotMemberError(Exception): class NotMemberError(Exception):
def __init__(self) -> None: def __init__(self) -> None:
pass pass
class TeamLeadRemovalError(Exception): class TeamLeadRemovalError(Exception):
def __init__(self) -> None: def __init__(self) -> None:
pass pass
class TeamNameTooLongError(Exception): class TeamNameTooLongError(Exception):
def __init__(self) -> None: def __init__(self) -> None:
pass pass
class TeamNameAlreadyTaken(Exception): class TeamNameAlreadyTaken(Exception):
def __init__(self) -> None: def __init__(self) -> None:
pass pass
class TeamAbbrInvalidError(Exception): class TeamAbbrInvalidError(Exception):
def __init__(self) -> None: def __init__(self) -> None:
pass pass
class TeamService: class TeamService:
ALLOWED_TEAM_NAME_SYMBOLS = ascii_letters + digits + "!#$%&*+,-./:;<=>?[]^_{|}~" ALLOWED_TEAM_NAME_SYMBOLS = ascii_letters + digits + "!#$%&*+,-./:;<=>?[]^_{|}~ "
MAX_TEAM_NAME_LENGTH = 24 MAX_TEAM_NAME_LENGTH = 24
MAX_TEAM_ABBR_LENGTH = 8 MAX_TEAM_ABBR_LENGTH = 8
@ -59,7 +63,7 @@ class TeamService:
user_teams.append(team) user_teams.append(team)
return user_teams return user_teams
async def create_team(self, team_name: str, team_abbr: str, join_password: str) -> Team: async def create_team(self, team_name: str, team_abbr: str, join_password: str, leader: User) -> Team:
disallowed_char = self._check_for_disallowed_char(team_name) disallowed_char = self._check_for_disallowed_char(team_name)
if disallowed_char: if disallowed_char:
raise NameNotAllowedError(disallowed_char) raise NameNotAllowedError(disallowed_char)
@ -74,7 +78,7 @@ class TeamService:
raise TeamAbbrInvalidError() raise TeamAbbrInvalidError()
try: try:
created_team = await self._db_service.create_team(team_name, team_abbr, join_password) created_team = await self._db_service.create_team(team_name, team_abbr, join_password, leader)
except DuplicationError: except DuplicationError:
raise TeamNameAlreadyTaken raise TeamNameAlreadyTaken
return created_team return created_team
@ -100,11 +104,11 @@ class TeamService:
return await self._db_service.update_team(team) return await self._db_service.update_team(team)
async def add_member_to_team(self, team: Team, user: User) -> Team: async def add_member_to_team(self, team: Team, user: User, status: TeamStatus = TeamStatus.MEMBER) -> Team:
if user in team.members: if user in team.members:
raise AlreadyMemberError() raise AlreadyMemberError()
await self._db_service.add_member_to_team(team, user) await self._db_service.add_member_to_team(team, user, status)
return await self.get_team_by_id(team.id) return await self.get_team_by_id(team.id)
async def remove_member_from_team(self, team: Team, user: User) -> Team: async def remove_member_from_team(self, team: Team, user: User) -> Team: