Implement basic team features

master
John Montagu, the 4th Earl of Sandvich 2024-11-09 15:24:30 -08:00
parent 2fda11bc9a
commit 050a012318
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
13 changed files with 590 additions and 28 deletions

View File

@ -35,9 +35,12 @@
--mantle: #e6e9ef;
--crust: #dce0e8;
--flamingo: #f0c6c6;
--red: #d20f39;
--flamingo: #dd7878;
--flamingo-transparent: #f0c6c655;
--green: #40a02b;
--peach: #fe640b;
--yellow: #df8e1d;
--lavender: #7287fd;
--accent: var(--lavender);

View File

@ -70,6 +70,12 @@ button.accent.dark {
color: var(--base);
}
button.destructive {
background-color: var(--flamingo);
color: var(--base);
}
button.accent:hover {
background-color: var(--text);
color: var(--base);
@ -108,10 +114,14 @@ h1 {
}
h2 {
font-weight: 700;
font-weight: 800;
}
em.aside {
span.small {
font-size: 9pt;
}
em.aside, span.aside {
color: var(--overlay-0);
}

View File

@ -15,6 +15,8 @@ export type { CreateTeamJson } from './models/CreateTeamJson';
export type { EditMemberRolesJson } from './models/EditMemberRolesJson';
export type { PutScheduleForm } from './models/PutScheduleForm';
export type { RoleSchema } from './models/RoleSchema';
export type { TeamInviteSchema } from './models/TeamInviteSchema';
export type { TeamInviteSchemaList } from './models/TeamInviteSchemaList';
export { TeamRole } from './models/TeamRole';
export type { TeamSchema } from './models/TeamSchema';
export type { ValidationError } from './models/ValidationError';

View File

@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TeamInviteSchema = {
createdAt: string;
key: string;
teamId: number;
};

View File

@ -0,0 +1,6 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TeamInviteSchema } from './TeamInviteSchema';
export type TeamInviteSchemaList = Array<TeamInviteSchema>;

View File

@ -6,6 +6,8 @@ import type { AddPlayerJson } from '../models/AddPlayerJson';
import type { CreateTeamJson } from '../models/CreateTeamJson';
import type { EditMemberRolesJson } from '../models/EditMemberRolesJson';
import type { PutScheduleForm } from '../models/PutScheduleForm';
import type { TeamInviteSchema } from '../models/TeamInviteSchema';
import type { TeamInviteSchemaList } from '../models/TeamInviteSchemaList';
import type { ViewScheduleResponse } from '../models/ViewScheduleResponse';
import type { ViewTeamMembersResponseList } from '../models/ViewTeamMembersResponseList';
import type { ViewTeamResponse } from '../models/ViewTeamResponse';
@ -211,6 +213,30 @@ export class DefaultService {
},
});
}
/**
* consume_invite <POST>
* @param teamId
* @param key
* @returns void
* @throws ApiError
*/
public consumeInvite(
teamId: string,
key: string,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'POST',
url: '/api/team/id/{team_id}/consume-invite/{key}',
path: {
'team_id': teamId,
'key': key,
},
errors: {
404: `Not Found`,
422: `Unprocessable Entity`,
},
});
}
/**
* edit_member_roles <PATCH>
* @param teamId
@ -240,6 +266,72 @@ export class DefaultService {
},
});
}
/**
* get_invites <GET>
* @param teamId
* @returns TeamInviteSchemaList OK
* @throws ApiError
*/
public getInvites(
teamId: string,
): CancelablePromise<TeamInviteSchemaList> {
return this.httpRequest.request({
method: 'GET',
url: '/api/team/id/{team_id}/invite',
path: {
'team_id': teamId,
},
errors: {
404: `Not Found`,
422: `Unprocessable Entity`,
},
});
}
/**
* create_invite <POST>
* @param teamId
* @returns TeamInviteSchema OK
* @throws ApiError
*/
public createInvite(
teamId: string,
): CancelablePromise<TeamInviteSchema> {
return this.httpRequest.request({
method: 'POST',
url: '/api/team/id/{team_id}/invite',
path: {
'team_id': teamId,
},
errors: {
404: `Not Found`,
422: `Unprocessable Entity`,
},
});
}
/**
* revoke_invite <DELETE>
* @param teamId
* @param key
* @returns void
* @throws ApiError
*/
public revokeInvite(
teamId: string,
key: string,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'DELETE',
url: '/api/team/id/{team_id}/invite/{key}',
path: {
'team_id': teamId,
'key': key,
},
errors: {
404: `Not Found`,
422: `Unprocessable Entity`,
},
});
}
/**
* add_player <PUT>
* @param teamId

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { type TeamInviteSchema } from "../client";
import { useTeamsStore } from "../stores/teams";
import { computed } from "vue";
const teamsStore = useTeamsStore();
const props = defineProps({
invite: Object as PropType<TeamInviteSchema>,
});
const inviteLink = computed(() => {
let teamId = props.invite.teamId;
let key = props.invite.key;
return `${window.location.origin}/team/id/${teamId}?key=${key}`;
})
function copyLink() {
navigator.clipboard.writeText(inviteLink.value);
}
function revokeInvite() {
teamsStore.revokeInvite(props.invite.teamId, props.invite.key);
}
</script>
<template>
<tr>
<td>
<a class="key" :href="inviteLink">
<code>
{{ invite.key }}
</code>
</a>
</td>
<td>
{{ invite.createdAt }}
</td>
<td class="buttons">
<button @click="copyLink">
<i class="bi bi-link margin" />
Copy Link
</button>
<button class="destructive" @click="revokeInvite">
<i class="bi bi-trash margin" />
Revoke
</button>
</td>
</tr>
</template>
<style scoped>
tr .buttons {
opacity: 0;
transition-duration: 200ms;
}
tr:hover .buttons {
opacity: 1;
}
td {
padding: 8px;
}
.key {
color: var(--text);
background-color: var(--text);
padding: 2px 4px;
}
.key:hover {
color: var(--base);
transition-duration: 200ms;
}
.buttons {
display: flex;
align-content: center;
justify-content: end;
gap: 8px;
}
</style>

View File

@ -75,7 +75,10 @@ function updateRoles() {
<tr class="player-card">
<td>
<div class="status flex-middle" :availability="player.availability">
<span class="dot"></span>
<div class="status-indicators">
<span class="indicator left-indicator" />
<span class="indicator right-indicator" />
</div>
<h3>
{{ player.username }}
</h3>
@ -129,8 +132,6 @@ function updateRoles() {
user-select: none;
gap: 1em;
align-items: center;
border: 2px solid white;
box-shadow: 1px 1px 8px var(--surface-0);
}
.player-card > td {
@ -142,24 +143,37 @@ function updateRoles() {
font-size: 12pt;
}
.dot {
.status-indicators {
display: flex;
flex-direction: row;
gap: 2px;
}
.status-indicators > .indicator {
display: block;
border-radius: 50%;
height: 8px;
width: 8px;
width: 12px;
background-color: var(--overlay-0);
}
.left-indicator {
border-radius: 8px 0 0 8px;
}
.right-indicator {
border-radius: 0 8px 8px 0;
}
.status[availability="0"] h3 {
color: var(--overlay-0);
font-weight: 400;
}
.status[availability="1"] .dot {
.status[availability="1"] .indicator {
background-color: var(--yellow);
}
.status[availability="2"] .dot {
.status[availability="2"] .indicator {
background-color: var(--green);
}

View File

@ -1,17 +1,18 @@
import Cacheable from "@/cacheable";
import { AvailabilitfClient, type RoleSchema, type TeamSpec, type ViewTeamMembersResponse, type ViewTeamResponse, type ViewTeamsResponse } from "@/client";
import { AvailabilitfClient, type TeamInviteSchema, type RoleSchema, type TeamSchema, type ViewTeamMembersResponse, type ViewTeamResponse, type ViewTeamsResponse } from "@/client";
import { defineStore } from "pinia";
import { computed, reactive, ref, type Reactive, type Ref } from "vue";
import { useClientStore } from "./client";
export type TeamMap = { [id: number]: TeamSpec };
export type TeamMap = { [id: number]: TeamSchema };
export const useTeamsStore = defineStore("teams", () => {
const clientStore = useClientStore();
const client = clientStore.client;
const teams: Reactive<{ [id: number]: TeamSpec }> = reactive({ });
const teams: Reactive<{ [id: number]: TeamSchema }> = reactive({ });
const teamMembers: Reactive<{ [id: number]: ViewTeamMembersResponse[] }> = reactive({ });
const teamInvites: Reactive<{ [id: number]: TeamInviteSchema[] }> = reactive({ });
const isFetchingTeams = ref(false);
@ -78,13 +79,55 @@ export const useTeamsStore = defineStore("teams", () => {
});
}
async function getInvites(teamId: number) {
return clientStore.call(
getInvites.name,
() => client.default.getInvites(teamId.toString()),
(response) => {
teamInvites[teamId] = response;
return response;
}
);
}
async function createInvite(teamId: number) {
return client.default.createInvite(teamId.toString())
.then((response) => {
teamInvites[teamId].push(response);
return response;
})
}
async function consumeInvite(teamId: number, key: string) {
return client.default.consumeInvite(teamId.toString(), key)
.then((response) => {
teamInvites[teamId] = teamInvites[teamId]
.filter((invite) => invite.key != key);
return response;
});
}
async function revokeInvite(teamId: number, key: string) {
return client.default.revokeInvite(teamId.toString(), key)
.then((response) => {
teamInvites[teamId] = teamInvites[teamId]
.filter((invite) => invite.key != key);
return response;
});
}
return {
teams,
teamInvites,
teamMembers,
fetchTeams,
fetchTeam,
fetchTeamMembers,
createTeam,
updateRoles
updateRoles,
getInvites,
createInvite,
consumeInvite,
revokeInvite,
};
});

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { useRoute, useRouter, RouterLink } from "vue-router";
import { useTeamsStore } from "../stores/teams";
import { computed, onMounted } from "vue";
import { computed, onMounted, ref } from "vue";
import PlayerTeamCard from "../components/PlayerTeamCard.vue";
import InviteEntry from "../components/InviteEntry.vue";
const route = useRoute();
const router = useRouter();
@ -12,14 +13,34 @@ const team = computed(() => {
return teamsStore.teams[route.params.id];
});
const invites = computed(() => {
return teamsStore.teamInvites[route.params.id];
});
const availableMembers = computed(() => {
return teamsStore.teamMembers[route.params.id]
.filter((member) => member.availability > 0);
});
onMounted(() => {
teamsStore.fetchTeam(route.params.id)
.then(() => teamsStore.fetchTeamMembers(route.params.id));
function createInvite() {
teamsStore.createInvite(team.value.id);
}
function revokeInvite(key) {
teamsStore.revokeInvite(team.value.id, key)
}
onMounted(async () => {
let key = route.query.key;
let teamId = route.params.id;
if (key) {
await teamsStore.consumeInvite(teamId, key);
}
teamsStore.fetchTeam(teamId)
.then(() => teamsStore.fetchTeamMembers(teamId))
.then(() => teamsStore.getInvites(teamId));
});
</script>
@ -29,15 +50,17 @@ onMounted(() => {
<h1>
{{ team.teamName }}
<RouterLink :to="'/schedule?teamId=' + team.id">
<button class="accent">
<i class="bi bi-calendar-fill margin"></i>
View schedule
</button>
</RouterLink>
<em class="aside" v-if="teamsStore.teamMembers[route.params.id]">
{{ teamsStore.teamMembers[route.params.id]?.length }} member(s),
{{ availableMembers?.length }} currently available
</em>
<div class="team-details-button-group">
<button class="accent">
<i class="bi bi-calendar-fill margin"></i>
View schedule
</button>
</div>
</h1>
<table class="member-table">
<!--thead>
@ -65,6 +88,42 @@ onMounted(() => {
/>
</tbody>
</table>
<h2>Active Invites</h2>
<div>
<details>
<summary>View all invites</summary>
<span v-if="invites?.length == 0">
There are currently no active invites to this team.
</span>
<table id="invite-table" v-else>
<thead>
<tr>
<th>
Key (hover to reveal)
</th>
<th>
Creation time
</th>
</tr>
</thead>
<tbody>
<InviteEntry
v-for="invite in invites"
:invite="invite"
/>
</tbody>
</table>
<div class="create-invite-group">
<button class="accent" @click="createInvite">
<i class="bi bi-person-fill-add margin" />
Create Invite
</button>
<span class="small aside">
Invites are usable once and expire after 24 hours.
</span>
</div>
</details>
</div>
</template>
</main>
</template>
@ -98,4 +157,29 @@ div.member-grid {
flex-wrap: wrap;
}
*/
th {
text-align: left;
font-weight: 600;
padding: 8px;
}
#invite-table {
width: 100%;
border: 1px solid var(--text);
margin: 8px 0;
}
.team-details-button-group {
flex: 1;
display: flex;
align-items: center;
justify-content: end;
}
.create-invite-group {
display: flex;
gap: 8px;
align-items: center;
}
</style>

View File

@ -0,0 +1,35 @@
"""Add TeamInvite
Revision ID: 65714d7e78f8
Revises: f50a79c4ae22
Create Date: 2024-11-08 23:16:04.669526
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '65714d7e78f8'
down_revision = 'f50a79c4ae22'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('team_invites',
sa.Column('key', sa.String(length=31), nullable=False),
sa.Column('team_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('delete_on_use', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.PrimaryKeyConstraint('key')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('team_invites')
# ### end Alembic commands ###

View File

@ -50,6 +50,7 @@ class Team(db.Model):
minute_offset: Mapped[int] = mapped_column(SmallInteger, default=0)
players: Mapped[List["PlayerTeam"]] = relationship(back_populates="team")
invites: Mapped[List["TeamInvite"]] = relationship(back_populates="team")
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
@ -145,6 +146,21 @@ class PlayerTeamAvailability(db.Model):
),
)
class TeamInvite(db.Model):
__tablename__ = "team_invites"
key: Mapped[str] = mapped_column(String(31), primary_key=True)
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"))
delete_on_use: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
team: Mapped["Team"] = relationship(back_populates="invites")
class TeamInviteSchema(spec.BaseModel):
key: str
team_id: int
created_at: datetime
class AuthSession(db.Model):
__tablename__ = "auth_sessions"

View File

@ -1,11 +1,13 @@
import datetime
from datetime import datetime, timezone
from random import randint, random
import sys
import time
from typing import List
from flask import Blueprint, abort, jsonify, make_response, request
from pydantic.v1 import validator
from spectree import Response
from sqlalchemy.orm import joinedload, subqueryload
from models import Player, PlayerSchema, PlayerTeam, PlayerTeamAvailability, PlayerTeamRole, PlayerTeamSchema, Team, TeamSchema, db
from models import Player, PlayerSchema, PlayerTeam, PlayerTeamAvailability, PlayerTeamRole, PlayerTeamSchema, Team, TeamInvite, TeamInviteSchema, TeamSchema, db
from middleware import requires_authentication
import models
from spec import spec, BaseModel
@ -238,7 +240,7 @@ class ViewTeamMembersResponse(PlayerSchema):
roles: list[RoleSchema]
availability: int
playtime: float
created_at: datetime.datetime
created_at: datetime
@api_team.get("/id/<team_id>/players")
@spec.validate(
@ -251,7 +253,7 @@ class ViewTeamMembersResponse(PlayerSchema):
)
@requires_authentication
def view_team_members(player: Player, team_id: int, **kwargs):
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.now(timezone.utc)
player_teams_query = db.session.query(
PlayerTeam
@ -318,7 +320,6 @@ def edit_member_roles(
target_player_id: int,
**kwargs,
):
print("hiiii lol")
target_player = db.session.query(
PlayerTeam
).where(
@ -353,3 +354,166 @@ def edit_member_roles(
db.session.commit()
return make_response({ }, 204)
@api_team.get("/id/<team_id>/invite")
@spec.validate(
resp=Response(
HTTP_200=list[TeamInviteSchema],
HTTP_404=None,
),
operation_id="get_invites"
)
@requires_authentication
def get_invites(player: Player, team_id: int, **kwargs):
player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.player_id == player.steam_id
).where(
PlayerTeam.team_id == team_id
).one_or_none()
if not player_team:
abort(404)
invites = db.session.query(
TeamInvite
).where(
TeamInvite.team_id == team_id
).all()
def map_invite_to_schema(invite: TeamInvite):
return TeamInviteSchema(
key=invite.key,
team_id=invite.team_id,
created_at=invite.created_at,
).dict(by_alias=True)
return list(map(map_invite_to_schema, invites)), 200
@api_team.post("/id/<team_id>/invite")
@spec.validate(
resp=Response(
HTTP_200=TeamInviteSchema,
HTTP_404=None,
),
operation_id="create_invite"
)
@requires_authentication
def create_invite(player: Player, team_id: int, **kwargs):
player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.player_id == player.steam_id
).where(
PlayerTeam.team_id == team_id
).one_or_none()
if not player_team:
abort(404)
team_id_shifted = int(team_id) << 48
random_value_shifted = int(randint(0, (1 << 16) - 1)) << 32
timestamp = int(time.time()) & ((1 << 32) - 1)
key_int = timestamp | team_id_shifted | random_value_shifted
key_hex = "%0.16X" % key_int
invite = TeamInvite()
invite.team_id = team_id
invite.key = key_hex
db.session.add(invite)
db.session.flush()
db.session.refresh(invite)
response = TeamInviteSchema(
key=key_hex,
team_id=team_id,
created_at=invite.created_at
)
db.session.commit()
return response.dict(by_alias=True), 200
@api_team.post("/id/<team_id>/consume-invite/<key>")
@spec.validate(
resp=Response(
HTTP_204=None,
HTTP_404=None,
),
operation_id="consume_invite"
)
@requires_authentication
def consume_invite(player: Player, team_id: int, key: str, **kwargs):
invite = db.session.query(
TeamInvite
).where(
TeamInvite.team_id == team_id
).where(
TeamInvite.key == key
).one_or_none()
if not invite:
abort(404)
player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.player_id == player.steam_id
).where(
PlayerTeam.team_id == team_id
).one_or_none()
if player_team:
abort(409)
player_team = PlayerTeam()
player_team.player = player
player_team.team_id = team_id
db.session.add(player_team)
if invite.delete_on_use:
db.session.delete(invite)
db.session.commit()
return make_response({ }, 204)
@api_team.delete("/id/<team_id>/invite/<key>")
@spec.validate(
resp=Response(
HTTP_204=None,
HTTP_404=None,
),
operation_id="revoke_invite"
)
@requires_authentication
def revoke_invite(player: Player, team_id: int, key: str, **kwargs):
player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.player_id == player.steam_id
).where(
PlayerTeam.team_id == team_id
).one_or_none()
if not player_team:
abort(404)
invite = db.session.query(
TeamInvite
).where(
TeamInvite.team_id == team_id
).where(
TeamInvite.key == key
).one_or_none()
if not invite:
abort(404)
db.session.delete(invite)
db.session.commit()
return make_response({ }, 204)