Implement leaving team

master
John Montagu, the 4th Earl of Sandvich 2024-11-10 01:40:03 -08:00
parent 050a012318
commit f5bcbb85b5
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
18 changed files with 312 additions and 37 deletions

View File

@ -8,8 +8,11 @@
"name": "availabili.tf",
"version": "0.0.0",
"dependencies": {
"@jamescoyle/vue-icon": "^0.1.2",
"@mdi/js": "^7.4.47",
"axios": "^1.7.7",
"bootstrap-icons": "^1.11.3",
"css.gg": "^2.1.4",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"pinia": "^2.2.4",
@ -838,6 +841,12 @@
"node": ">=12"
}
},
"node_modules/@jamescoyle/vue-icon": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@jamescoyle/vue-icon/-/vue-icon-0.1.2.tgz",
"integrity": "sha512-KFrImXx5TKIi6iQXlnyLEBl4rNosNKbTeRnr70ucTdUaciVmd9qK9d/pZAaKt1Ob/8xNnX2GMp8LisyHdKtEgw==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
@ -897,6 +906,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
"license": "Apache-2.0"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2816,6 +2831,12 @@
"node": ">= 8"
}
},
"node_modules/css.gg": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/css.gg/-/css.gg-2.1.4.tgz",
"integrity": "sha512-7eyhXQLNJus5q3AVlYhDFjvVkB1ng1D9EjaBJzvboLfNx60RcFdZ1NinEgJMEA8bkwPwRLfbZ0ADTBXsbdrRgw==",
"license": "SEE LICENSE"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",

View File

@ -15,8 +15,11 @@
"openapi-generate": "openapi --input 'http://localhost:8000/apidoc/openapi.json' --output src/client --name AvailabilitfClient"
},
"dependencies": {
"@jamescoyle/vue-icon": "^0.1.2",
"@mdi/js": "^7.4.47",
"axios": "^1.7.7",
"bootstrap-icons": "^1.11.3",
"css.gg": "^2.1.4",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"pinia": "^2.2.4",

View File

@ -1,5 +1,7 @@
@import url("tf2icons.css");
@import url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css");
/*@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css");*/
@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/solid.min.css");
/* color palette from <https://github.com/vuejs/theme> */
:root {
@ -39,9 +41,20 @@
--flamingo: #dd7878;
--flamingo-transparent: #f0c6c655;
--green: #40a02b;
--peach: #fe640b;
--green: #40a02b;
--yellow: #df8e1d;
/*
--green: #a6e3a1;
--yellow: #f9e2af;
*/
--green-transparent: color-mix(in srgb, var(--green), transparent 80%);
--yellow-transparent: color-mix(in srgb, var(--yellow), transparent 80%);
/*
--green-transparent-50: #a6e3a1;
--yellow-transparent-50: #f9e2af;
*/
--lavender: #7287fd;
--accent: var(--lavender);
--accent-transparent-80: color-mix(in srgb, var(--accent), transparent 80%);

View File

@ -15,6 +15,10 @@ a,
padding: 3px;
}
a.button {
padding: unset;
}
@media (hover: hover) {
a:hover {
background-color: var(--accent-transparent);

View File

@ -13,6 +13,7 @@ export type { OpenAPIConfig } from './core/OpenAPI';
export type { AddPlayerJson } from './models/AddPlayerJson';
export type { CreateTeamJson } from './models/CreateTeamJson';
export type { EditMemberRolesJson } from './models/EditMemberRolesJson';
export type { PlayerSchema } from './models/PlayerSchema';
export type { PutScheduleForm } from './models/PutScheduleForm';
export type { RoleSchema } from './models/RoleSchema';
export type { TeamInviteSchema } from './models/TeamInviteSchema';

View File

@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PlayerSchema = {
steamId: string;
username: string;
};

View File

@ -4,8 +4,9 @@
/* eslint-disable */
import type { RoleSchema } from './RoleSchema';
export type ViewTeamMembersResponse = {
availability: number;
availability: Array<number>;
createdAt: string;
isTeamLeader?: boolean;
playtime: number;
roles: Array<RoleSchema>;
steamId: string;

View File

@ -5,6 +5,7 @@
import type { AddPlayerJson } from '../models/AddPlayerJson';
import type { CreateTeamJson } from '../models/CreateTeamJson';
import type { EditMemberRolesJson } from '../models/EditMemberRolesJson';
import type { PlayerSchema } from '../models/PlayerSchema';
import type { PutScheduleForm } from '../models/PutScheduleForm';
import type { TeamInviteSchema } from '../models/TeamInviteSchema';
import type { TeamInviteSchemaList } from '../models/TeamInviteSchemaList';
@ -71,6 +72,21 @@ export class DefaultService {
url: '/api/login/authenticate',
});
}
/**
* get_user <GET>
* @returns PlayerSchema OK
* @throws ApiError
*/
public getUser(): CancelablePromise<PlayerSchema> {
return this.httpRequest.request({
method: 'GET',
url: '/api/login/get-user',
errors: {
401: `Unauthorized`,
422: `Unprocessable Entity`,
},
});
}
/**
* get <GET>
* @param windowStart
@ -361,6 +377,31 @@ export class DefaultService {
},
});
}
/**
* remove_player_from_team <DELETE>
* @param teamId
* @param targetPlayerId
* @returns any OK
* @throws ApiError
*/
public removePlayerFromTeam(
teamId: string,
targetPlayerId: string,
): CancelablePromise<any> {
return this.httpRequest.request({
method: 'DELETE',
url: '/api/team/id/{team_id}/player/{target_player_id}/',
path: {
'team_id': teamId,
'target_player_id': targetPlayerId,
},
errors: {
403: `Forbidden`,
404: `Not Found`,
422: `Unprocessable Entity`,
},
});
}
/**
* view_team_members <GET>
* @param teamId

View File

@ -272,10 +272,12 @@ onUnmounted(() => {
}
.time-slot[selection="1"] {
background-color: var(--accent-transparent-50);
/*background-color: var(--accent-transparent-50);*/
background-color: var(--yellow-transparent);
}
.time-slot[selection="2"] {
background-color: var(--accent);
/*background-color: var(--accent);*/
background-color: var(--green-transparent);
}
</style>

View File

@ -4,6 +4,8 @@ import { computed, type PropType, ref, watch } from "vue";
import { useTeamsStore } from "../stores/teams";
import { useRosterStore } from "../stores/roster";
import { type ViewTeamMembersResponse, type TeamSchema } from "@/client";
import SvgIcon from "@jamescoyle/vue-icon";
import { mdiCrown } from "@mdi/js";
import RoleTag from "../components/RoleTag.vue";
const props = defineProps({
@ -69,19 +71,34 @@ function updateRoles() {
console.log(updatedRoles.value);
teamsStore.updateRoles(props.team.id, props.player.steamId, updatedRoles.value);
}
const isUnavailable = computed(() => {
return props.player?.availability[0] == 0 &&
props.player?.availability[1] == 0;
});
</script>
<template>
<tr class="player-card">
<td>
<div class="status flex-middle" :availability="player.availability">
<div
class="status flex-middle"
:is-unavailable="isUnavailable"
>
<div class="status-indicators">
<span class="indicator left-indicator" />
<span class="indicator right-indicator" />
<span
class="indicator left-indicator"
:availability="player.availability[0]"
/>
<span
class="indicator right-indicator"
:availability="player.availability[1]"
/>
</div>
<h3>
{{ player.username }}
</h3>
<svg-icon v-if="player.isTeamLeader" type="mdi" :path="mdiCrown" />
</div>
</td>
<td>
@ -164,16 +181,16 @@ function updateRoles() {
border-radius: 0 8px 8px 0;
}
.status[availability="0"] h3 {
.status[is-unavailable="true"] {
color: var(--overlay-0);
font-weight: 400;
}
.status[availability="1"] .indicator {
.status .indicator[availability="1"] {
background-color: var(--yellow);
}
.status[availability="2"] .indicator {
.status .indicator[availability="2"] {
background-color: var(--green);
}

View File

@ -5,6 +5,7 @@ import RosterBuilderView from "../views/RosterBuilderView.vue";
import LoginView from "../views/LoginView.vue";
import TeamRegistrationView from "../views/TeamRegistrationView.vue";
import TeamDetailsView from "../views/TeamDetailsView.vue";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -40,6 +41,16 @@ const router = createRouter({
component: TeamDetailsView
},
]
})
});
export default router
router
.beforeEach(async (to, from) => {
const authStore = useAuthStore();
console.log("test");
if (!authStore.isLoggedIn && !authStore.hasCheckedAuth) {
await authStore.getUser();
}
});
export default router;

View File

@ -1,11 +1,29 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { useClientStore } from "./client";
export const useAuthStore = defineStore("auth", () => {
const steamId = ref(NaN);
const clientStore = useClientStore();
const client = clientStore.client;
const steamId = ref("");
const username = ref("");
const isLoggedIn = ref(false);
const isRegistering = ref(false);
const hasCheckedAuth = ref(false);
async function getUser() {
hasCheckedAuth.value = true;
return clientStore.call(
getUser.name,
() => client.default.getUser(),
(response) => {
steamId.value = response.steamId;
username.value = username.value;
return response;
}
);
}
async function login(queryParams: { [key: string]: string }) {
return fetch(import.meta.env.VITE_API_BASE_URL + "/login/authenticate", {
@ -32,7 +50,9 @@ export const useAuthStore = defineStore("auth", () => {
steamId,
username,
isLoggedIn,
hasCheckedAuth,
isRegistering,
getUser,
login,
}
});

View File

@ -1,4 +1,4 @@
import { AvailabilitfClient } from "@/client";
import { AvailabilitfClient, CancelablePromise } from "@/client";
import { defineStore } from "pinia";
export const useClientStore = defineStore("client", () => {
@ -10,7 +10,7 @@ export const useClientStore = defineStore("client", () => {
function call<T>(
key: string,
apiCall: () => Promise<T>,
apiCall: () => CancelablePromise<T>,
thenOnce?: (result: T) => T
): Promise<T> {
console.log("Fetching call " + key);

View File

@ -3,10 +3,13 @@ import { AvailabilitfClient, type TeamInviteSchema, type RoleSchema, type TeamSc
import { defineStore } from "pinia";
import { computed, reactive, ref, type Reactive, type Ref } from "vue";
import { useClientStore } from "./client";
import { useAuthStore } from "./auth";
export type TeamMap = { [id: number]: TeamSchema };
export const useTeamsStore = defineStore("teams", () => {
const authStore = useAuthStore();
const clientStore = useClientStore();
const client = clientStore.client;
@ -14,8 +17,6 @@ export const useTeamsStore = defineStore("teams", () => {
const teamMembers: Reactive<{ [id: number]: ViewTeamMembersResponse[] }> = reactive({ });
const teamInvites: Reactive<{ [id: number]: TeamInviteSchema[] }> = reactive({ });
const isFetchingTeams = ref(false);
async function fetchTeams() {
return clientStore.call(
fetchTeams.name,
@ -116,6 +117,11 @@ export const useTeamsStore = defineStore("teams", () => {
});
}
async function leaveTeam(teamId: number) {
return client.default
.removePlayerFromTeam(teamId.toString(), authStore.steamId);
}
return {
teams,
teamInvites,
@ -129,5 +135,6 @@ export const useTeamsStore = defineStore("teams", () => {
createInvite,
consumeInvite,
revokeInvite,
leaveTeam,
};
});

View File

@ -164,17 +164,27 @@ button.radio:hover {
button.radio.selected {
color: var(--accent);
background-color: var(--accent-transparent);
color: var(--accent-transparent);
}
button.left {
border-radius: 4px 0 0 4px;
}
button.radio.left.selected {
color: var(--yellow);
background-color: var(--yellow-transparent);
}
button.right {
border-radius: 0 4px 4px 0;
}
button.right.radio.selected {
color: var(--green);
background-color: var(--green-transparent);
}
.v-select {
display: inline-block;
width: auto;

View File

@ -19,7 +19,12 @@ const invites = computed(() => {
const availableMembers = computed(() => {
return teamsStore.teamMembers[route.params.id]
.filter((member) => member.availability > 0);
.filter((member) => member.availability[0] > 0);
});
const availableMembersNextHour = computed(() => {
return teamsStore.teamMembers[route.params.id]
.filter((member) => member.availability[1] > 0);
});
function createInvite() {
@ -30,17 +35,33 @@ function revokeInvite(key) {
teamsStore.revokeInvite(team.value.id, key)
}
function leaveTeam() {
teamsStore.leaveTeam(team.value.id)
.then(() => {
teamsStore.fetchTeams()
.then(() => {
router.push("/");
})
});
}
onMounted(async () => {
let key = route.query.key;
let teamId = route.params.id;
let doFetchTeam = () => {
teamsStore.fetchTeam(teamId)
.then(() => teamsStore.fetchTeamMembers(teamId))
.then(() => teamsStore.getInvites(teamId));
};
if (key) {
await teamsStore.consumeInvite(teamId, key);
teamsStore.consumeInvite(teamId, key)
.finally(doFetchTeam);
} else {
doFetchTeam();
}
teamsStore.fetchTeam(teamId)
.then(() => teamsStore.fetchTeamMembers(teamId))
.then(() => teamsStore.getInvites(teamId));
});
</script>
@ -49,16 +70,23 @@ onMounted(async () => {
<template v-if="team">
<h1>
{{ team.teamName }}
<RouterLink :to="'/schedule?teamId=' + team.id">
</RouterLink>
<em class="aside" v-if="teamsStore.teamMembers[route.params.id]">
{{ teamsStore.teamMembers[route.params.id]?.length }} member(s),
{{ availableMembers?.length }} currently available
{{ availableMembers?.length }} currently available,
{{ availableMembersNextHour?.length }} available in the next hour
</em>
<div class="team-details-button-group">
<button class="accent">
<i class="bi bi-calendar-fill margin"></i>
View schedule
<RouterLink class="button" :to="'/schedule?teamId=' + team.id">
<button class="accent">
<i class="bi bi-calendar-fill margin"></i>
View schedule
</button>
</RouterLink>
<button
class="destructive"
@click="leaveTeam"
>
Leave
</button>
</div>
</h1>
@ -175,6 +203,7 @@ th {
display: flex;
align-items: center;
justify-content: end;
gap: 4px;
}
.create-invite-group {

View File

@ -4,8 +4,10 @@ import string
import urllib.parse
from flask import Blueprint, abort, make_response, redirect, request, url_for
import requests
from spectree import Response
from spec import spec
import models
from models import AuthSession, Player, db
from models import AuthSession, Player, PlayerSchema, db
from middleware import requires_authentication
api_login = Blueprint("login", __name__, url_prefix="/login")
@ -16,6 +18,21 @@ STEAM_OPENID_URL = "https://steamcommunity.com/openid/login"
def index():
return "test"
@api_login.get("/get-user")
@requires_authentication
@spec.validate(
resp=Response(
HTTP_200=PlayerSchema,
HTTP_401=None,
),
operation_id="get_user"
)
def get_user(player: Player, auth_session: AuthSession):
return PlayerSchema(
steam_id=str(player.steam_id),
username=player.username,
).dict(by_alias=True), 200
@api_login.post("/authenticate")
def steam_authenticate():
params = request.get_json()

View File

@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from random import randint, random
import sys
import time
@ -116,6 +116,69 @@ def delete_team(player: Player, team_id: int):
db.session.commit()
return make_response(200)
@api_team.delete("/id/<team_id>/player/<target_player_id>/")
@spec.validate(
resp=Response(
HTTP_200=None,
HTTP_403=None,
HTTP_404=None,
),
operation_id="remove_player_from_team"
)
@requires_authentication
def remove_player_from_team(player: Player, team_id: int, target_player_id: int, **kwargs):
player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.team_id == team_id
).where(
PlayerTeam.player_id == player.steam_id
).one_or_none()
if not player_team:
abort(404)
target_player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.team_id == team_id
).where(
PlayerTeam.player_id == target_player_id
).one_or_none()
if not target_player_team:
abort(404)
is_team_leader = player_team.is_team_leader
if not is_team_leader and player_team != target_player_team:
abort(403)
team = target_player_team.team
db.session.delete(target_player_team)
db.session.refresh(team)
if len(team.players) == 0:
# delete the team if the only member
db.session.delete(team)
else:
# if there doesn't exist another team leader, promote the first player
team_leaders = db.session.query(
PlayerTeam
).where(
PlayerTeam.team_id == team_id
).where(
PlayerTeam.is_team_leader == True
).all()
if len(team_leaders) == 0:
team.players[0].is_team_leader = True
db.session.commit()
return make_response({ }, 200)
class AddPlayerJson(BaseModel):
team_role: PlayerTeam.TeamRole = PlayerTeam.TeamRole.Player
is_team_leader: bool = False
@ -238,9 +301,10 @@ class ViewTeamMembersResponse(PlayerSchema):
is_main: bool
roles: list[RoleSchema]
availability: int
availability: list[int]
playtime: float
created_at: datetime
is_team_leader: bool = False
@api_team.get("/id/<team_id>/players")
@spec.validate(
@ -254,6 +318,7 @@ class ViewTeamMembersResponse(PlayerSchema):
@requires_authentication
def view_team_members(player: Player, team_id: int, **kwargs):
now = datetime.now(timezone.utc)
next_hour = now + timedelta(hours=1)
player_teams_query = db.session.query(
PlayerTeam
@ -264,7 +329,7 @@ def view_team_members(player: Player, team_id: int, **kwargs):
joinedload(PlayerTeam.player),
joinedload(PlayerTeam.player_roles),
joinedload(PlayerTeam.availability.and_(
(PlayerTeamAvailability.start_time <= now) &
(PlayerTeamAvailability.start_time <= next_hour) &
(PlayerTeamAvailability.end_time > now)
)),
)
@ -284,10 +349,13 @@ def view_team_members(player: Player, team_id: int, **kwargs):
roles = player_team.player_roles
player = player_team.player
availability = 0
if len(player_team.availability) > 0:
print(player_team.availability)
availability = player_team.availability[0].availability
availability = [0, 0]
for record in player_team.availability:
if record.start_time <= now < record.end_time:
availability[0] = record.availability
if record.start_time <= next_hour < record.end_time:
availability[1] = record.availability
return ViewTeamMembersResponse(
username=player.username,
@ -296,6 +364,7 @@ def view_team_members(player: Player, team_id: int, **kwargs):
availability=availability,
playtime=player_team.playtime.total_seconds() / 3600,
created_at=player_team.created_at,
is_team_leader=player_team.is_team_leader,
).dict(by_alias=True)
return list(map(map_to_response, player_teams))