integrate database layer

This commit is contained in:
David Rodenkirchen 2026-02-13 11:57:32 +01:00
parent b34269cdb5
commit 8a9004a9a0
7 changed files with 326 additions and 49 deletions

63
sql/teams_patch.sql Normal file
View File

@ -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);

View File

@ -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
),

View File

@ -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(

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -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