From 8a9004a9a03d439bc7e8fdb7cd714ad5f50db17e Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 13 Feb 2026 11:57:32 +0100 Subject: [PATCH] integrate database layer --- sql/teams_patch.sql | 63 +++++ .../components/TeamRevealer.py | 11 +- src/ezgg_lan_manager/pages/TeamsPage.py | 23 +- .../services/DatabaseService.py | 216 +++++++++++++++--- src/ezgg_lan_manager/services/TeamService.py | 44 +++- src/ezgg_lan_manager/types/Team.py | 11 + src/ezgg_lan_manager/types/User.py | 7 +- 7 files changed, 326 insertions(+), 49 deletions(-) create mode 100644 sql/teams_patch.sql diff --git a/sql/teams_patch.sql b/sql/teams_patch.sql new file mode 100644 index 0000000..16283ff --- /dev/null +++ b/sql/teams_patch.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- Teams +-- ===================================================== + +DROP TABLE IF EXISTS `team_members`; +DROP TABLE IF EXISTS `teams`; + + +-- ----------------------------------------------------- +-- Teams table +-- ----------------------------------------------------- +CREATE TABLE `teams` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + abbreviation VARCHAR(10) NOT NULL, + join_password VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_team_name (name), + UNIQUE KEY uq_team_abbr (abbreviation) + +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + + +-- ----------------------------------------------------- +-- Team Members (Junction Table) +-- ----------------------------------------------------- +CREATE TABLE `team_members` ( + team_id INT NOT NULL, + user_id INT NOT NULL, + + status ENUM('MEMBER','OFFICER','LEADER') + NOT NULL DEFAULT 'MEMBER', + + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (team_id, user_id), + + CONSTRAINT fk_tm_team + FOREIGN KEY (team_id) + REFERENCES teams(id) + ON DELETE CASCADE, + + CONSTRAINT fk_tm_user + FOREIGN KEY (user_id) + REFERENCES users(user_id) + ON DELETE CASCADE + +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + + +-- ----------------------------------------------------- +-- Indexes +-- ----------------------------------------------------- +CREATE INDEX idx_tm_user + ON team_members(user_id); + +CREATE INDEX idx_tm_team_status + ON team_members(team_id, status); diff --git a/src/ezgg_lan_manager/components/TeamRevealer.py b/src/ezgg_lan_manager/components/TeamRevealer.py index c2f8126..4c04701 100644 --- a/src/ezgg_lan_manager/components/TeamRevealer.py +++ b/src/ezgg_lan_manager/components/TeamRevealer.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Callable, Optional +from typing import Callable, Optional, Literal from rio import Component, Revealer, TextStyle, Column, Row, Tooltip, Icon, Spacer, Text, Button @@ -10,7 +10,8 @@ from src.ezgg_lan_manager.types.User import User class TeamRevealer(Component): user: Optional[User] team: Team - on_join_button_pressed: Callable + mode: Literal["join", "leave", "display"] + on_button_pressed: Callable def build(self) -> Component: return Revealer( @@ -29,12 +30,12 @@ class TeamRevealer(Component): for member in self.team.members ], Row(Button( - content=f"{self.team.name} beitreten", + content=f"{self.team.name} beitreten" if self.mode == "join" else f"{self.team.name} verlassen", shape="rectangle", style="major", color="hud", - on_press=partial(self.on_join_button_pressed, self.team), - ) if self.user not in self.team.members.keys() and self.user is not None else Spacer(grow_x=False, grow_y=False), margin_top=1, margin_bottom=1), + on_press=partial(self.on_button_pressed, self.team), + ), margin_top=1, margin_bottom=1), margin_right=1, margin_left=1 ), diff --git a/src/ezgg_lan_manager/pages/TeamsPage.py b/src/ezgg_lan_manager/pages/TeamsPage.py index b3e5ae0..397c2d3 100644 --- a/src/ezgg_lan_manager/pages/TeamsPage.py +++ b/src/ezgg_lan_manager/pages/TeamsPage.py @@ -1,6 +1,6 @@ from typing import Optional -from rio import Column, Component, event, Text, TextStyle, ProgressCircle, Spacer, Revealer, Row, Button, Icon, Tooltip, Rectangle, PointerEventListener, PointerEvent +from rio import Column, Component, event, Text, TextStyle, ProgressCircle, Spacer, Row, Rectangle, PointerEventListener, PointerEvent from src.ezgg_lan_manager import ConfigurationService from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox @@ -15,15 +15,11 @@ from src.ezgg_lan_manager.types.User import User class TeamsPage(Component): all_teams: Optional[list[Team]] = None user: Optional[User] = None - user_teams: list[Team] = [] @event.on_populate async def on_populate(self) -> None: self.all_teams = await self.session[TeamService].get_all_teams() - #self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) - self.user = await self.session[UserService].get_user("Typhus") # FixMe: Only debug - if self.user is not None: - self.user_teams = await self.session[TeamService].get_teams_for_user_by_id(self.user.user_id) + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Teams") self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.on_populate) @@ -55,7 +51,14 @@ class TeamsPage(Component): team_list = [] for team in self.all_teams: - team_list.append(TeamRevealer(user=self.user, team=team, on_join_button_pressed=self.on_join_button_pressed)) + team_list.append( + TeamRevealer( + user=self.user, + team=team, + mode="leave" if self.user in team.members.keys() else "join", + on_button_pressed=self.on_leave_button_pressed if self.user in team.members.keys() else self.on_join_button_pressed + ) + ) if team_list: team_list[-1].margin_bottom = 1 @@ -63,9 +66,9 @@ class TeamsPage(Component): own_teams_content = Spacer(grow_x=False, grow_y=False) if self.user is not None: user_team_list = [] - for team in self.user_teams: - # ToDo: Remove from team instead of join - user_team_list.append(TeamRevealer(user=self.user, team=team, on_join_button_pressed=self.on_join_button_pressed)) + for team in self.all_teams: + if self.user in team.members.keys(): + user_team_list.append(TeamRevealer(user=self.user, team=team, mode="leave", on_button_pressed=self.on_leave_button_pressed)) if not user_team_list: user_team_list.append(Text( diff --git a/src/ezgg_lan_manager/services/DatabaseService.py b/src/ezgg_lan_manager/services/DatabaseService.py index 14fb294..3d9a319 100644 --- a/src/ezgg_lan_manager/services/DatabaseService.py +++ b/src/ezgg_lan_manager/services/DatabaseService.py @@ -970,42 +970,200 @@ class DatabaseService: logger.warning(f"Error removing participant from tournament: {e}") async def get_teams(self) -> list[Team]: - # ToDo: Implement - return [ - Team( - id=1, - name="MockTeamAlpha", - abbreviation="-=MTA=-", - members={User(0, "DemoUserA", "", "abuvbwer", None, None, None, True, False, False, datetime.now(), datetime.now()): TeamStatus.LEADER}, - join_password="abc" - ), - Team( - id=1, - name="MockTeamBeta", - abbreviation="[MTB]", - members={ - User(1, "DemoUserB", "", "abuvbwer", None, None, None, True, False, False, datetime.now(), datetime.now()): TeamStatus.LEADER, - User(2, "DemoUserC", "", "abuvbwer", None, None, None, True, False, False, datetime.now(), datetime.now()): TeamStatus.OFFICER, - User(3, "DemoUserD", "", "abuvbwer", None, None, None, True, False, False, datetime.now(), datetime.now()): TeamStatus.MEMBER - }, - join_password="abc" - ) - ] + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + query = """ + SELECT + t.id AS team_id, + t.name AS team_name, + t.abbreviation AS team_abbr, + t.join_password, + t.created_at AS team_created_at, + + tm.status AS team_status, + tm.joined_at AS member_joined_at, + + u.* + + FROM teams t + + LEFT JOIN team_members tm + ON t.id = tm.team_id + + LEFT JOIN users u + ON tm.user_id = u.user_id + + ORDER BY + t.id, + CASE tm.status + WHEN 'LEADER' THEN 1 + WHEN 'OFFICER' THEN 2 + WHEN 'MEMBER' THEN 3 + ELSE 4 + END, + u.user_name; + """ + try: + await cursor.execute(query) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_teams() + except Exception as e: + logger.warning(f"Error getting teams: {e}") + return [] + + current_team: Optional[Team] = None + all_teams = [] + + for row in await cursor.fetchall(): + if row[5] is None: # Teams without single member are ignored + continue + if current_team is None: + user = self._map_db_result_to_user(row[7:]) + current_team = Team(id=row[0], name=row[1], abbreviation=row[2], join_password=row[3], members={user: TeamStatus.from_str(row[5])}) + elif current_team.id == row[0]: # Still same team + current_team.members[self._map_db_result_to_user(row[7:])] = TeamStatus.from_str(row[5]) + else: + all_teams.append(current_team) + user = self._map_db_result_to_user(row[7:]) + current_team = Team(id=row[0], name=row[1], abbreviation=row[2], join_password=row[3], members={user: TeamStatus.from_str(row[5])}) + + all_teams.append(current_team) + + return all_teams async def get_team_by_id(self, team_id: int) -> Optional[Team]: - return + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + query = """ + SELECT + t.id AS team_id, + t.name AS team_name, + t.abbreviation AS team_abbr, + t.join_password, + t.created_at AS team_created_at, + + tm.status AS team_status, + tm.joined_at AS member_joined_at, + + u.* + + FROM teams t + + LEFT JOIN team_members tm + ON t.id = tm.team_id + + LEFT JOIN users u + ON tm.user_id = u.user_id + + WHERE t.id = %s + + ORDER BY + t.id, + CASE tm.status + WHEN 'LEADER' THEN 1 + WHEN 'OFFICER' THEN 2 + WHEN 'MEMBER' THEN 3 + ELSE 4 + END, + u.user_name; + """ + try: + await cursor.execute(query, (team_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.get_team_by_id(team_id) + except Exception as e: + logger.warning(f"Error getting team: {e}") + return None - async def get_teams_for_user_by_id(self, user_id: int) -> list[Team]: - return [] + team: Optional[Team] = None + + for row in await cursor.fetchall(): + if team is None: + user = self._map_db_result_to_user(row[7:]) + team = Team(id=row[0], name=row[1], abbreviation=row[2], join_password=row[3], members={user: TeamStatus.from_str(row[5])}) + elif team.id == row[0]: + team.members[self._map_db_result_to_user(row[7:])] = TeamStatus.from_str(row[5]) + + return team async def create_team(self, team_name: str, team_abbr: str, join_password: str) -> Team: - return # ToDo: Raise on existing team + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO teams (name, abbreviation, join_password) " + "VALUES (%s, %s, %s)", (team_name, team_abbr, join_password) + ) + await conn.commit() + return await self.get_team_by_id(cursor.lastrowid) + + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.create_team(team_name, team_abbr, join_password) + except aiomysql.IntegrityError as e: + logger.warning(f"Aborted duplication entry: {e}") + raise DuplicationError async def update_team(self, team: Team) -> Team: - pass + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "UPDATE teams SET name = %s, abbreviation = %s, join_password = %s WHERE (id = %s)", + (team.name, team.abbreviation, team.join_password, team.id) + ) + await conn.commit() + return await self.get_team_by_id(team.id) + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.update_team(team) + except aiomysql.IntegrityError as e: + logger.warning(f"Aborted duplication entry: {e}") + raise DuplicationError + + + async def add_member_to_team(self, team: Team, user: User, status: TeamStatus = TeamStatus.MEMBER) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO team_members (team_id, user_id, status) VALUES (%s, %s, %s)", + (team.id, user.user_id, status.name) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.add_member_to_team(team, user, status) + except aiomysql.IntegrityError as e: + logger.warning(f"Failed to add member {user.user_name} to team {team.name}: {e}") + raise DuplicationError - async def add_member_to_team(self, team: Team, user: User) -> None: - pass async def remove_user_from_team(self, team: Team, user: User) -> None: - pass + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "DELETE FROM team_members WHERE team_id = %s AND user_id = %s", + (team.id, user.user_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_user_from_team(team, user) diff --git a/src/ezgg_lan_manager/services/TeamService.py b/src/ezgg_lan_manager/services/TeamService.py index 96e0194..043eef1 100644 --- a/src/ezgg_lan_manager/services/TeamService.py +++ b/src/ezgg_lan_manager/services/TeamService.py @@ -2,7 +2,7 @@ from hashlib import sha256 from typing import Union, Optional from string import ascii_letters, digits -from src.ezgg_lan_manager.services.DatabaseService import DatabaseService +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 @@ -23,9 +23,24 @@ class TeamLeadRemovalError(Exception): def __init__(self) -> None: pass +class TeamNameTooLongError(Exception): + def __init__(self) -> None: + pass + +class TeamNameAlreadyTaken(Exception): + def __init__(self) -> None: + pass + +class TeamAbbrInvalidError(Exception): + def __init__(self) -> None: + pass + + class TeamService: ALLOWED_TEAM_NAME_SYMBOLS = ascii_letters + digits + "!#$%&*+,-./:;<=>?[]^_{|}~" + MAX_TEAM_NAME_LENGTH = 24 + MAX_TEAM_ABBR_LENGTH = 8 def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service @@ -37,7 +52,12 @@ class TeamService: return await self._db_service.get_team_by_id(team_id) async def get_teams_for_user_by_id(self, user_id: int) -> list[Team]: - return await self._db_service.get_teams_for_user_by_id(user_id) + all_teams = await self.get_all_teams() + user_teams = [] + for team in all_teams: + if user_id in [u.user_id for u in team.members.keys()]: + user_teams.append(team) + return user_teams async def create_team(self, team_name: str, team_abbr: str, join_password: str) -> Team: disallowed_char = self._check_for_disallowed_char(team_name) @@ -47,7 +67,16 @@ class TeamService: if disallowed_char: raise NameNotAllowedError(disallowed_char) - created_team = await self._db_service.create_team(team_name, team_abbr, join_password) + if not team_name or len(team_name) > self.MAX_TEAM_NAME_LENGTH: + raise TeamNameTooLongError() + + if not team_abbr or len(team_abbr) > self.MAX_TEAM_ABBR_LENGTH: + raise TeamAbbrInvalidError() + + try: + created_team = await self._db_service.create_team(team_name, team_abbr, join_password) + except DuplicationError: + raise TeamNameAlreadyTaken return created_team async def update_team(self, team: Team) -> Team: @@ -62,6 +91,13 @@ class TeamService: disallowed_char = self._check_for_disallowed_char(team.abbreviation) if disallowed_char: raise NameNotAllowedError(disallowed_char) + + if not team.name or len(team.name) > self.MAX_TEAM_NAME_LENGTH: + raise TeamNameTooLongError() + + if not team.abbreviation or len(team.abbreviation) > self.MAX_TEAM_ABBR_LENGTH: + raise TeamAbbrInvalidError() + return await self._db_service.update_team(team) async def add_member_to_team(self, team: Team, user: User) -> Team: @@ -75,7 +111,7 @@ class TeamService: if user not in team.members: raise NotMemberError() - if team.members[user] is TeamStatus.LEADER: + if team.members[user] == TeamStatus.LEADER: raise TeamLeadRemovalError() await self._db_service.remove_user_from_team(team, user) diff --git a/src/ezgg_lan_manager/types/Team.py b/src/ezgg_lan_manager/types/Team.py index b4063f8..aed4454 100644 --- a/src/ezgg_lan_manager/types/Team.py +++ b/src/ezgg_lan_manager/types/Team.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from enum import Enum +from typing import Self from src.ezgg_lan_manager.types.User import User @@ -8,6 +9,16 @@ class TeamStatus(Enum): OFFICER = 1 LEADER = 2 + @classmethod + def from_str(cls, team_status: str) -> Self: + if team_status == "MEMBER": + return TeamStatus.MEMBER + elif team_status == "OFFICER": + return TeamStatus.OFFICER + elif team_status == "LEADER": + return TeamStatus.LEADER + raise ValueError + @dataclass(frozen=True) class Team: diff --git a/src/ezgg_lan_manager/types/User.py b/src/ezgg_lan_manager/types/User.py index a397962..d91f6d5 100644 --- a/src/ezgg_lan_manager/types/User.py +++ b/src/ezgg_lan_manager/types/User.py @@ -19,4 +19,9 @@ class User: last_updated_at: datetime def __hash__(self) -> int: - return hash(f"{self.user_id}{self.user_name}{self.user_mail}") + return hash(self.user_id) + + def __eq__(self, other): + if not isinstance(other, User): + return NotImplemented + return self.user_id == other.user_id