Compare commits

...

4 Commits

Author SHA1 Message Date
d9ad903798
feat: add back files in new location 2025-03-21 14:48:55 +01:00
b28752830a
feat: towards multi-team support
also testing at different points whether team association is correct
2025-03-21 14:44:55 +01:00
7f4f6142c9
feat: don't rely on secure JWT when it comes to scopes 2025-03-20 17:04:20 +01:00
ded2b79db7
feat: begin to add support for multiple teams 2025-03-19 15:08:18 +01:00
14 changed files with 383 additions and 206 deletions

0
cutt/__init__.py Normal file
View File

View File

@ -1,15 +1,18 @@
import io import io
import base64 import base64
from fastapi import APIRouter from typing import Annotated
from fastapi import APIRouter, HTTPException, Security, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlmodel import Session, func, select from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar from sqlmodel.sql.expression import SelectOfScalar
from db import Chemistry, MVPRanking, Player, engine from cutt.db import Chemistry, MVPRanking, Player, Team, engine
import networkx as nx import networkx as nx
import numpy as np import numpy as np
import matplotlib import matplotlib
from cutt.security import TeamScopedRequest, verify_team_scope
matplotlib.use("agg") matplotlib.use("agg")
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
@ -51,24 +54,37 @@ def sociogram_json():
return JSONResponse({"nodes": nodes, "edges": edges}) return JSONResponse({"nodes": nodes, "edges": edges})
def graph_json(): def graph_json(
request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
):
nodes = [] nodes = []
edges = [] edges = []
players = {} player_map = {}
with Session(engine) as session: with Session(engine) as session:
for p in session.exec(select(P)).fetchall(): statement = select(Team).where(Team.id == request.team_id)
players[p.id] = p.display_name players = [t.players for t in session.exec(statement)][0]
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
for p in players:
player_map[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name}) nodes.append({"id": p.display_name, "label": p.display_name})
subquery = ( subquery = (
select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery() select(C.user, func.max(C.time).label("latest"))
.where(C.team == request.team_id)
.group_by(C.user)
.subquery()
) )
statement2 = select(C).join( statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
) )
for c in session.exec(statement2): for c in session.exec(statement2):
user = players[c.user] user = player_map[c.user]
for i, p_id in enumerate(c.love): for i, p_id in enumerate(c.love):
p = players[p_id] p = player_map[p_id]
edges.append( edges.append(
{ {
"id": f"{user}->{p}", "id": f"{user}->{p}",
@ -83,7 +99,7 @@ def graph_json():
} }
) )
for p_id in c.hate: for p_id in c.hate:
p = players[p_id] p = player_map[p_id]
edges.append( edges.append(
{ {
"id": f"{user}-x>{p}", "id": f"{user}-x>{p}",
@ -95,6 +111,10 @@ def graph_json():
} }
) )
if not edges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
G = nx.DiGraph() G = nx.DiGraph()
G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges]) G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges])
in_degrees = G.in_degree(weight="weight") in_degrees = G.in_degree(weight="weight")
@ -199,20 +219,36 @@ async def render_sociogram(params: Params):
return {"image": encoded_image} return {"image": encoded_image}
def mvp(): def mvp(
request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
):
ranks = dict() ranks = dict()
with Session(engine) as session: with Session(engine) as session:
players = {p.id: p.display_name for p in session.exec(select(P)).fetchall()} statement = select(Team).where(Team.id == request.team_id)
players = [t.players for t in session.exec(statement)][0]
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p.display_name for p in players}
subquery = ( subquery = (
select(R.user, func.max(R.time).label("latest")).group_by(R.user).subquery() select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id)
.group_by(R.user)
.subquery()
) )
statement2 = select(R).join( statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
) )
for r in session.exec(statement2): for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps): for i, p_id in enumerate(r.mvps):
p = players[p_id] p = player_map[p_id]
ranks[p] = ranks.get(p, []) + [i + 1] ranks[p] = ranks.get(p, []) + [i + 1]
if not ranks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
return [ return [
{ {
"name": p, "name": p,
@ -224,10 +260,12 @@ def mvp():
] ]
analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"]) # analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
analysis_router.add_api_route("/graph_json", endpoint=graph_json, methods=["GET"]) analysis_router.add_api_route(
"/graph_json/{team_id}", endpoint=graph_json, methods=["GET"]
)
analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"]) analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"])
analysis_router.add_api_route("/mvp", endpoint=mvp, methods=["GET"]) analysis_router.add_api_route("/mvp/{team_id}", endpoint=mvp, methods=["GET"])
if __name__ == "__main__": if __name__ == "__main__":
with Session(engine) as session: with Session(engine) as session:

View File

@ -51,7 +51,7 @@ class Player(SQLModel, table=True):
disabled: bool | None = None disabled: bool | None = None
hashed_password: str | None = None hashed_password: str | None = None
number: str | None = None number: str | None = None
teams: list[Team] | None = Relationship( teams: list[Team] = Relationship(
back_populates="players", link_model=PlayerTeamLink back_populates="players", link_model=PlayerTeamLink
) )
scopes: str = "" scopes: str = ""
@ -60,17 +60,19 @@ class Player(SQLModel, table=True):
class Chemistry(SQLModel, table=True): class Chemistry(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime) time: datetime | None = Field(default_factory=utctime)
user: int | None = Field(default=None, foreign_key="player.id") user: int = Field(default=None, foreign_key="player.id")
hate: list[int] = Field(sa_column=Column(ARRAY(Integer))) hate: list[int] = Field(sa_column=Column(ARRAY(Integer)))
undecided: list[int] = Field(sa_column=Column(ARRAY(Integer))) undecided: list[int] = Field(sa_column=Column(ARRAY(Integer)))
love: list[int] = Field(sa_column=Column(ARRAY(Integer))) love: list[int] = Field(sa_column=Column(ARRAY(Integer)))
team: int = Field(default=None, foreign_key="team.id")
class MVPRanking(SQLModel, table=True): class MVPRanking(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime) time: datetime | None = Field(default_factory=utctime)
user: int | None = Field(default=None, foreign_key="player.id") user: int = Field(default=None, foreign_key="player.id")
mvps: list[int] = Field(sa_column=Column(ARRAY(Integer))) mvps: list[int] = Field(sa_column=Column(ARRAY(Integer)))
team: int = Field(default=None, foreign_key="team.id")
class TokenDB(SQLModel, table=True): class TokenDB(SQLModel, table=True):

View File

@ -2,23 +2,21 @@ from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from db import Player, Team, Chemistry, MVPRanking, engine from cutt.db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import ( from sqlmodel import (
Session, Session,
func, func,
select, select,
) )
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from analysis import analysis_router from cutt.analysis import analysis_router
from security import ( from cutt.security import (
change_password,
get_current_active_user, get_current_active_user,
login_for_access_token, login_for_access_token,
logout, logout,
read_player_me,
read_own_items,
set_first_password, set_first_password,
) )
from cutt.player import player_router
C = Chemistry C = Chemistry
R = MVPRanking R = MVPRanking
@ -46,50 +44,12 @@ def add_team(team: Team):
session.commit() session.commit()
def add_player(player: Player):
with Session(engine) as session:
session.add(player)
session.commit()
def add_players(players: list[Player]):
with Session(engine) as session:
for player in players:
session.add(player)
session.commit()
async def list_players():
with Session(engine) as session:
statement = select(Player).order_by(Player.display_name)
players = session.exec(statement).fetchall()
return [
player.model_dump(include={"id", "display_name", "number"})
for player in players
]
async def read_teams_me(user: Annotated[Player, Depends(get_current_active_user)]):
with Session(engine) as session:
return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0]
def list_teams(): def list_teams():
with Session(engine) as session: with Session(engine) as session:
statement = select(Team) statement = select(Team)
return session.exec(statement).fetchall() return session.exec(statement).fetchall()
player_router = APIRouter(prefix="/player")
player_router.add_api_route("/list", endpoint=list_players, methods=["GET"])
player_router.add_api_route("/add", endpoint=add_player, methods=["POST"])
player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"])
player_router.add_api_route("/me/teams", endpoint=read_teams_me, methods=["GET"])
player_router.add_api_route("/me/items", endpoint=read_own_items, methods=["GET"])
player_router.add_api_route(
"/change_password", endpoint=change_password, methods=["POST"]
)
team_router = APIRouter( team_router = APIRouter(
prefix="/team", prefix="/team",
dependencies=[Security(get_current_active_user, scopes=["admin"])], dependencies=[Security(get_current_active_user, scopes=["admin"])],
@ -102,6 +62,9 @@ wrong_user_id_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="you're not who you think you are...", detail="you're not who you think you are...",
) )
somethings_fishy = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="something up..."
)
@api_router.put("/mvps") @api_router.put("/mvps")
@ -111,9 +74,15 @@ def submit_mvps(
): ):
if user.id == mvps.user: if user.id == mvps.user:
with Session(engine) as session: with Session(engine) as session:
session.add(mvps) statement = select(Team).where(Team.id == mvps.team)
session.commit() players = [t.players for t in session.exec(statement)][0]
return JSONResponse("success!") if players:
player_ids = {p.id for p in players}
if player_ids >= set(mvps.mvps):
session.add(mvps)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else: else:
raise wrong_user_id_exception raise wrong_user_id_exception
@ -148,9 +117,17 @@ def submit_chemistry(
): ):
if user.id == chemistry.user: if user.id == chemistry.user:
with Session(engine) as session: with Session(engine) as session:
session.add(chemistry) statement = select(Team).where(Team.id == chemistry.team)
session.commit() players = [t.players for t in session.exec(statement)][0]
return JSONResponse("success!") if players:
player_ids = {p.id for p in players}
if player_ids >= (
set(chemistry.love) | set(chemistry.hate) | set(chemistry.undecided)
):
session.add(chemistry)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else: else:
raise wrong_user_id_exception raise wrong_user_id_exception
@ -189,10 +166,7 @@ api_router.include_router(
player_router, dependencies=[Depends(get_current_active_user)] player_router, dependencies=[Depends(get_current_active_user)]
) )
api_router.include_router(team_router, dependencies=[Depends(get_current_active_user)]) api_router.include_router(team_router, dependencies=[Depends(get_current_active_user)])
api_router.include_router( api_router.include_router(analysis_router)
analysis_router,
dependencies=[Security(get_current_active_user, scopes=["analysis"])],
)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"]) api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"])
api_router.add_api_route("/logout", endpoint=logout, methods=["POST"]) api_router.add_api_route("/logout", endpoint=logout, methods=["POST"])

47
cutt/player.py Normal file
View File

@ -0,0 +1,47 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from cutt.db import Player, Team, engine
from cutt.security import change_password, get_current_active_user, read_player_me
P = Player
def add_player(player: P):
with Session(engine) as session:
session.add(player)
session.commit()
def add_players(players: list[P]):
with Session(engine) as session:
for player in players:
session.add(player)
session.commit()
async def list_players(team_id: int):
with Session(engine) as session:
statement = select(Team).where(Team.id == team_id)
players = [t.players for t in session.exec(statement)][0]
if players:
return [
player.model_dump(include={"id", "display_name", "number"})
for player in players
]
async def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]):
with Session(engine) as session:
return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0]
player_router = APIRouter(prefix="/player")
player_router.add_api_route("/list/{team_id}", endpoint=list_players, methods=["GET"])
player_router.add_api_route("/add", endpoint=add_player, methods=["POST"])
player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"])
player_router.add_api_route("/me/teams", endpoint=read_teams_me, methods=["GET"])
player_router.add_api_route(
"/change_password", endpoint=change_password, methods=["POST"]
)

View File

@ -6,7 +6,7 @@ from pydantic import BaseModel, ValidationError
import jwt import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from sqlmodel import Session, select from sqlmodel import Session, select
from db import TokenDB, engine, Player from cutt.db import TokenDB, engine, Player
from fastapi.security import ( from fastapi.security import (
OAuth2PasswordBearer, OAuth2PasswordBearer,
OAuth2PasswordRequestForm, OAuth2PasswordRequestForm,
@ -141,8 +141,9 @@ async def get_current_user(
user = get_user(username=token_data.username) user = get_user(username=token_data.username)
if user is None: if user is None:
raise credentials_exception raise credentials_exception
allowed_scopes = set(user.scopes.split())
for scope in security_scopes.scopes: for scope in security_scopes.scopes:
if scope not in token_data.scopes: if scope not in allowed_scopes or scope not in token_data.scopes:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions", detail="Not enough permissions",
@ -159,6 +160,24 @@ async def get_current_active_user(
return current_user return current_user
class TeamScopedRequest(BaseModel):
user: Player
team_id: int
async def verify_team_scope(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
allowed_scopes = set(user.scopes.split())
if f"team:{team_id}" not in allowed_scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
)
else:
return TeamScopedRequest(user=user, team_id=team_id)
async def login_for_access_token( async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response
) -> Token: ) -> Token:

View File

@ -408,15 +408,31 @@ button {
} }
} }
.avatars {
margin: 16px auto;
}
.avatar { .avatar {
background-color: #f0f8ff88;
font-weight: bold; font-weight: bold;
font-size: 110%; font-size: 110%;
padding: 3px 1em; padding: 3px 1em;
width: fit-content; width: fit-content;
border: 3px solid; border: 3px solid;
border-radius: 1em; border-radius: 1em;
margin: 0 auto 16px auto; margin: 4px auto;
}
.group-avatar {
background-color: #f0f8ff88;
color: inherit;
font-weight: bold;
font-size: 90%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 4px auto;
} }
.user-info { .user-info {
@ -532,6 +548,7 @@ button {
position: relative; position: relative;
height: 12px; height: 12px;
width: 96%; width: 96%;
margin: auto;
border: 4px solid black; border: 4px solid black;
overflow: hidden; overflow: hidden;
} }

View File

@ -1,5 +1,5 @@
import { createRef, MouseEventHandler, useEffect, useState } from "react"; import { createRef, MouseEventHandler, useEffect, useState } from "react";
import { useSession } from "./Session"; import { TeamState, useSession } from "./Session";
import { User } from "./api"; import { User } from "./api";
import { useTheme } from "./ThemeProvider"; import { useTheme } from "./ThemeProvider";
import { colourTheme, darkTheme, normalTheme, rainbowTheme } from "./themes"; import { colourTheme, darkTheme, normalTheme, rainbowTheme } from "./themes";
@ -11,7 +11,7 @@ interface ContextMenuItem {
onClick: () => void; onClick: () => void;
} }
const UserInfo = (user: User, teams: Team[] | undefined) => { const UserInfo = (user: User, teams: TeamState | undefined) => {
return ( return (
<div className="user-info"> <div className="user-info">
<div> <div>
@ -42,9 +42,9 @@ const UserInfo = (user: User, teams: Team[] | undefined) => {
textAlign: "left", textAlign: "left",
}} }}
> >
{teams.map((team) => ( {teams.teams.map((team, index) => (
<li> <li>
{team.name} ( {teams.activeTeam === index ? <b>{team.name}</b> : team.name} (
{team.location || team.country || "location unknown"}) {team.location || team.country || "location unknown"})
</li> </li>
))} ))}
@ -56,7 +56,7 @@ const UserInfo = (user: User, teams: Team[] | undefined) => {
}; };
export default function Avatar() { export default function Avatar() {
const { user, teams, onLogout } = useSession(); const { user, teams, setTeams, onLogout } = useSession();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
@ -147,20 +147,40 @@ export default function Avatar() {
} }
return ( return (
<div <>
className="avatar" <div className="avatars">
onContextMenu={handleMenuClick} <div
style={{ display: user ? "block" : "none" }} className="avatar"
onClick={(event) => { onContextMenu={handleMenuClick}
if (contextMenu.open && event.target === avatarRef.current) { style={{ display: user ? "block" : "none" }}
handleMenuClose(); onClick={(event) => {
} else { if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClick(event); handleMenuClose();
} } else {
}} handleMenuClick(event);
ref={avatarRef} }
> }}
👤 {user?.username} ref={avatarRef}
>
👤 {user?.username}
</div>
{teams && teams?.teams.length > 1 && (
<select
className="group-avatar"
value={teams.activeTeam}
onChange={(e) =>
setTeams({ ...teams, activeTeam: Number(e.target.value) })
}
>
{teams.teams.map((team) => (
<option key={team.id} value={team.id}>
👥 {team.name}
</option>
))}
</select>
)}
</div>
{contextMenu.open && ( {contextMenu.open && (
<ul <ul
className="context-menu" className="context-menu"
@ -193,6 +213,6 @@ export default function Avatar() {
> >
{dialog} {dialog}
</dialog> </dialog>
</div> </>
); );
} }

View File

@ -1,36 +1,45 @@
import { useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { apiAuth } from "./api"; import { apiAuth } from "./api";
import { PlayerRanking } from "./types"; import { PlayerRanking } from "./types";
import RaceChart from "./RaceChart"; import RaceChart from "./RaceChart";
import { useSession } from "./Session";
const MVPChart = () => { const MVPChart = () => {
const [data, setData] = useState({} as PlayerRanking[]); let initialData = {} as PlayerRanking[];
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showStd, setShowStd] = useState(false); const [showStd, setShowStd] = useState(false);
const { teams } = useSession();
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
await apiAuth("analysis/mvp", null) if (teams) {
.then((json) => json as Promise<PlayerRanking[]>) await apiAuth(`analysis/mvp/${teams?.activeTeam}`, null)
.then((json) => { .then((data) => {
setData(json.sort((a, b) => a.rank - b.rank)); if (data.detail) {
}); setError(data.detail);
setLoading(false); return initialData;
} else {
setError("");
return data as Promise<PlayerRanking[]>;
}
})
.then((data) => {
setData(data.sort((a, b) => a.rank - b.rank));
})
.catch(() => setError("no access"));
setLoading(false);
} else setError("team unknown");
} }
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []); }, [teams]);
return ( if (loading) return <span className="loader" />;
<> else if (error) return <span>{error}</span>;
{loading ? ( else return <RaceChart std={showStd} players={data} />;
<span className="loader" />
) : (
<RaceChart std={showStd} players={data} />
)}
</>
);
}; };
export default MVPChart; export default MVPChart;

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import { apiAuth } from "./api"; import { apiAuth } from "./api";
import { import {
GraphCanvas, GraphCanvas,
@ -10,6 +10,7 @@ import {
useSelection, useSelection,
} from "reagraph"; } from "reagraph";
import { customTheme } from "./NetworkTheme"; import { customTheme } from "./NetworkTheme";
import { useSession } from "./Session";
interface NetworkData { interface NetworkData {
nodes: GraphNode[]; nodes: GraphNode[];
@ -36,26 +37,38 @@ const useCustomSelection = (props: CustomSelectionProps): SelectionResult => {
}; };
export const GraphComponent = () => { export const GraphComponent = () => {
const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData); let initialData = { nodes: [], edges: [] } as NetworkData;
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [threed, setThreed] = useState(false); const [threed, setThreed] = useState(false);
const [likes, setLikes] = useState(2); const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false); const [popularity, setPopularity] = useState(false);
const [mutuality, setMutuality] = useState(false); const [mutuality, setMutuality] = useState(false);
const { teams } = useSession();
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
await apiAuth("analysis/graph_json", null) if (teams) {
.then((json) => json as Promise<NetworkData>) await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null)
.then((json) => { .then((data) => {
setData(json); if (data.detail) {
}); setError(data.detail);
setLoading(false); return initialData;
} else {
setError("");
return data as Promise<NetworkData>;
}
})
.then((data) => setData(data))
.catch(() => setError("no access"));
setLoading(false);
} else setError("team unknown");
} }
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []); }, [teams]);
const graphRef = useRef<GraphCanvasRef | null>(null); const graphRef = useRef<GraphCanvasRef | null>(null);
@ -161,6 +174,74 @@ export const GraphComponent = () => {
type: "multiModifier", type: "multiModifier",
}); });
let content: ReactNode;
if (loading) {
content = <span className="loader" />;
} else if (error) {
content = <span>{error}</span>;
} else {
content = (
<>
<GraphCanvas
draggable
cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{
nodeStrength: -200,
linkDistance: 100,
}}
labelType="nodes"
sizingType="attribute"
sizingAttribute={popularity ? "inDegree" : undefined}
ref={graphRef}
theme={customTheme}
nodes={data.nodes}
edges={data.edges.filter(
(edge) => edge.data.relation === likes || likes === 1
)}
selections={selections}
actives={actives}
onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick}
onNodePointerOut={onNodePointerOut}
onNodePointerOver={onNodePointerOver}
/>
<button
className="infobutton"
onClick={() => {
const dialog = document.querySelector("dialog[id='InfoDialog']");
(dialog as HTMLDialogElement).showModal();
}}
>
info
</button>
<dialog
id="InfoDialog"
style={{ textAlign: "left" }}
onClick={(event) => {
event.currentTarget.close();
}}
>
scroll to zoom
<br />
<br />
<b>hover</b>: show inbound links
<br />
<b>click</b>: show outward links
<br />
<br />
multi-selection possible
<br />
with <i>Ctrl</i> or <i>Shift</i>
<br />
<br />
drag to pan/rotate
</dialog>
</>
);
}
return ( return (
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}> <div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls"> <div className="controls">
@ -225,67 +306,7 @@ export const GraphComponent = () => {
</span> </span>
</div> </div>
)} )}
{content}
{loading ? (
<span className="loader" />
) : (
<GraphCanvas
draggable
cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{
nodeStrength: -200,
linkDistance: 100,
}}
labelType="nodes"
sizingType="attribute"
sizingAttribute={popularity ? "inDegree" : undefined}
ref={graphRef}
theme={customTheme}
nodes={data.nodes}
edges={data.edges.filter(
(edge) => edge.data.relation === likes || likes === 1
)}
selections={selections}
actives={actives}
onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick}
onNodePointerOut={onNodePointerOut}
onNodePointerOver={onNodePointerOver}
/>
)}
<button
className="infobutton"
onClick={() => {
const dialog = document.querySelector("dialog[id='InfoDialog']");
(dialog as HTMLDialogElement).showModal();
}}
>
info
</button>
<dialog
id="InfoDialog"
style={{ textAlign: "left" }}
onClick={(event) => {
event.currentTarget.close();
}}
>
scroll to zoom
<br />
<br />
<b>hover</b>: show inbound links
<br />
<b>click</b>: show outward links
<br />
<br />
multi-selection possible
<br />
with <i>Ctrl</i> or <i>Shift</i>
<br />
<br />
drag to pan/rotate
</dialog>
</div> </div>
); );
}; };

View File

@ -8,7 +8,7 @@ import {
} from "react"; } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs"; import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, User } from "./api"; import { apiAuth, User } from "./api";
import { useSession } from "./Session"; import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types"; import { Chemistry, MVPRanking } from "./types";
import TabController from "./TabController"; import TabController from "./TabController";
@ -56,10 +56,11 @@ function filterSort(list: User[], ids: number[]): User[] {
interface PlayerInfoProps { interface PlayerInfoProps {
user: User; user: User;
teams: TeamState;
players: User[]; players: User[];
} }
function ChemistryDnD({ user, players }: PlayerInfoProps) { function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
var otherPlayers = players.filter((player) => player.id !== user.id); var otherPlayers = players.filter((player) => player.id !== user.id);
const [playersLeft, setPlayersLeft] = useState<User[]>([]); const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers); const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
@ -68,6 +69,11 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
useEffect(() => { useEffect(() => {
setPlayersMiddle(otherPlayers); setPlayersMiddle(otherPlayers);
}, [players]); }, [players]);
useEffect(() => {
setPlayersLeft([]);
setPlayersMiddle(otherPlayers);
setPlayersRight([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
@ -78,7 +84,13 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
let left = playersLeft.map(({ id }) => id); let left = playersLeft.map(({ id }) => id);
let middle = playersMiddle.map(({ id }) => id); let middle = playersMiddle.map(({ id }) => id);
let right = playersRight.map(({ id }) => id); let right = playersRight.map(({ id }) => id);
const data = { user: user.id, hate: left, undecided: middle, love: right }; const data = {
user: user.id,
hate: left,
undecided: middle,
love: right,
team: teams.activeTeam,
};
const response = await apiAuth("chemistry", data, "PUT"); const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again"); setDialog(response || "try sending again");
} }
@ -163,7 +175,7 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
); );
} }
function MVPDnD({ user, players }: PlayerInfoProps) { function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players); const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]); const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
@ -171,6 +183,11 @@ function MVPDnD({ user, players }: PlayerInfoProps) {
setAvailablePlayers(players); setAvailablePlayers(players);
}, [players]); }, [players]);
useEffect(() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
@ -178,7 +195,7 @@ function MVPDnD({ user, players }: PlayerInfoProps) {
if (dialogRef.current) dialogRef.current.showModal(); if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending..."); setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id); let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps }; const data = { user: user.id, mvps: mvps, team: teams.activeTeam };
const response = await apiAuth("mvps", data, "PUT"); const response = await apiAuth("mvps", data, "PUT");
response ? setDialog(response) : setDialog("try sending again"); response ? setDialog(response) : setDialog("try sending again");
} }
@ -277,21 +294,27 @@ function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
} }
export default function Rankings() { export default function Rankings() {
const { user } = useSession(); const { user, teams } = useSession();
const [players, setPlayers] = useState<User[] | null>(null); const [players, setPlayers] = useState<User[] | null>(null);
async function loadPlayers() { async function loadPlayers() {
try { if (teams) {
const data = await apiAuth("player/list", null, "GET"); try {
setPlayers(data as User[]); const data = await apiAuth(
} catch (error) { `player/list/${teams?.activeTeam}`,
console.error(error); null,
"GET"
);
setPlayers(data as User[]);
} catch (error) {
console.error(error);
}
} }
} }
useEffect(() => { useEffect(() => {
loadPlayers(); loadPlayers();
}, [user]); }, [user, teams]);
const tabs = [ const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" }, { id: "Chemistry", label: "🧪 Chemistry" },
@ -300,10 +323,10 @@ export default function Rankings() {
return ( return (
<> <>
{user && players ? ( {user && teams && players ? (
<TabController tabs={tabs}> <TabController tabs={tabs}>
<ChemistryDnD {...{ user, players }} /> <ChemistryDnD {...{ user, teams, players }} />
<MVPDnD {...{ user, players }} /> <MVPDnD {...{ user, teams, players }} />
</TabController> </TabController>
) : ( ) : (
<span className="loader" /> <span className="loader" />

View File

@ -14,15 +14,22 @@ export interface SessionProviderProps {
children: ReactNode; children: ReactNode;
} }
export interface TeamState {
teams: Team[];
activeTeam: number;
}
export interface Session { export interface Session {
user: User | null; user: User | null;
teams: Team[] | null; teams: TeamState | null;
setTeams: (teams: TeamState) => void;
onLogout: () => void; onLogout: () => void;
} }
const sessionContext = createContext<Session>({ const sessionContext = createContext<Session>({
user: null, user: null,
teams: null, teams: null,
setTeams: () => {},
onLogout: () => {}, onLogout: () => {},
}); });
@ -30,7 +37,7 @@ export function SessionProvider(props: SessionProviderProps) {
const { children } = props; const { children } = props;
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [teams, setTeams] = useState<Team[] | null>(null); const [teams, setTeams] = useState<TeamState | null>(null);
const [err, setErr] = useState<unknown>(null); const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -50,12 +57,12 @@ export function SessionProvider(props: SessionProviderProps) {
async function loadTeam() { async function loadTeam() {
const teams: Team[] = await apiAuth("player/me/teams", null, "GET"); const teams: Team[] = await apiAuth("player/me/teams", null, "GET");
if (teams) setTeams(teams); if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
} }
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
setTimeout(() => loadTeam(), 1500); loadTeam();
}, []); }, []);
function onLogin(user: User) { function onLogin(user: User) {
@ -87,7 +94,7 @@ export function SessionProvider(props: SessionProviderProps) {
content = <Login onLogin={onLogin} />; content = <Login onLogin={onLogin} />;
} else } else
content = ( content = (
<sessionContext.Provider value={{ user, teams, onLogout }}> <sessionContext.Provider value={{ user, teams, setTeams, onLogout }}>
{children} {children}
</sessionContext.Provider> </sessionContext.Provider>
); );

View File

@ -1,5 +1,4 @@
import { useSession } from "./Session"; import { useSession } from "./Session";
import { Team } from "./types";
export const baseUrl = import.meta.env.VITE_BASE_URL as string; export const baseUrl = import.meta.env.VITE_BASE_URL as string;

View File

@ -34,6 +34,7 @@ export interface MVPRanking {
} }
export interface Team { export interface Team {
id: number;
name: string; name: string;
location: string; location: string;
country: string; country: string;