Add integration management for teams

- Add new models for team integrations
- Create IntegrationDetails component for managing integrations
- Update teams store with integration actions
- Modify IntegrationsView to display and manage integrations
master
John Montagu, the 4th Earl of Sandvich 2024-11-18 20:25:07 -08:00
parent 8a00c53479
commit c67bf14980
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
12 changed files with 318 additions and 20 deletions

View File

@ -10,6 +10,7 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';
export type { AbstractTeamIntegrationSchema } from './models/AbstractTeamIntegrationSchema';
export type { AddPlayerJson } from './models/AddPlayerJson';
export type { AvailabilitySchema } from './models/AvailabilitySchema';
export type { CreateTeamJson } from './models/CreateTeamJson';
@ -19,6 +20,9 @@ export type { PlayerTeamAvailabilityRoleSchema } from './models/PlayerTeamAvaila
export type { PutScheduleForm } from './models/PutScheduleForm';
export type { RoleSchema } from './models/RoleSchema';
export type { SetUsernameJson } from './models/SetUsernameJson';
export type { TeamDiscordIntegrationSchema } from './models/TeamDiscordIntegrationSchema';
export type { TeamIntegrationSchema } from './models/TeamIntegrationSchema';
export type { TeamIntegrationSchemaList } from './models/TeamIntegrationSchemaList';
export type { TeamInviteSchema } from './models/TeamInviteSchema';
export type { TeamInviteSchemaList } from './models/TeamInviteSchemaList';
export { TeamRole } from './models/TeamRole';

View File

@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TeamDiscordIntegrationSchema } from './TeamDiscordIntegrationSchema';
import type { TeamIntegrationSchema } from './TeamIntegrationSchema';
export type AbstractTeamIntegrationSchema = (TeamDiscordIntegrationSchema | TeamIntegrationSchema);

View File

@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TeamDiscordIntegrationSchema = {
id: number;
integrationType: string;
teamId: number;
webhookUrl: string;
};

View File

@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TeamIntegrationSchema = {
id: number;
integrationType: 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 { TeamIntegrationSchema } from './TeamIntegrationSchema';
export type TeamIntegrationSchemaList = Array<TeamIntegrationSchema>;

View File

@ -2,12 +2,15 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AbstractTeamIntegrationSchema } from '../models/AbstractTeamIntegrationSchema';
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 { SetUsernameJson } from '../models/SetUsernameJson';
import type { TeamIntegrationSchema } from '../models/TeamIntegrationSchema';
import type { TeamIntegrationSchemaList } from '../models/TeamIntegrationSchemaList';
import type { TeamInviteSchema } from '../models/TeamInviteSchema';
import type { TeamInviteSchemaList } from '../models/TeamInviteSchemaList';
import type { ViewAvailablePlayersResponse } from '../models/ViewAvailablePlayersResponse';
@ -314,6 +317,100 @@ export class DefaultService {
},
});
}
/**
* get_integrations <GET>
* @param teamId
* @returns TeamIntegrationSchemaList OK
* @throws ApiError
*/
public getIntegrations(
teamId: string,
): CancelablePromise<TeamIntegrationSchemaList> {
return this.httpRequest.request({
method: 'GET',
url: '/api/team/id/{team_id}/integrations',
path: {
'team_id': teamId,
},
errors: {
404: `Not Found`,
422: `Unprocessable Entity`,
},
});
}
/**
* delete_integration <DELETE>
* @param teamId
* @param integrationId
* @returns void
* @throws ApiError
*/
public deleteIntegration(
teamId: string,
integrationId: string,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'DELETE',
url: '/api/team/id/{team_id}/integrations/{integration_id}',
path: {
'team_id': teamId,
'integration_id': integrationId,
},
errors: {
422: `Unprocessable Entity`,
},
});
}
/**
* update_integration <PATCH>
* @param teamId
* @param integrationId
* @param requestBody
* @returns TeamIntegrationSchema OK
* @throws ApiError
*/
public updateIntegration(
teamId: string,
integrationId: string,
requestBody?: AbstractTeamIntegrationSchema,
): CancelablePromise<TeamIntegrationSchema> {
return this.httpRequest.request({
method: 'PATCH',
url: '/api/team/id/{team_id}/integrations/{integration_id}',
path: {
'team_id': teamId,
'integration_id': integrationId,
},
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Unprocessable Entity`,
},
});
}
/**
* create_integration <POST>
* @param teamId
* @param integrationType
* @returns TeamIntegrationSchema OK
* @throws ApiError
*/
public createIntegration(
teamId: string,
integrationType: string,
): CancelablePromise<TeamIntegrationSchema> {
return this.httpRequest.request({
method: 'POST',
url: '/api/team/id/{team_id}/integrations/{integration_type}',
path: {
'team_id': teamId,
'integration_type': integrationType,
},
errors: {
422: `Unprocessable Entity`,
},
});
}
/**
* get_invites <GET>
* @param teamId

View File

@ -35,22 +35,22 @@ const isShiftDown = ref(false);
const lowerBoundX = computed(() => {
return isShiftDown.value ? 0 :
Math.min(selectionStart.x, selectionEnd.x)
Math.min(selectionStart.x ?? NaN, selectionEnd.x ?? NaN)
});
const upperBoundX = computed(() => {
return isShiftDown.value ? 6 :
Math.max(selectionStart.x, selectionEnd.x)
Math.max(selectionStart.x ?? NaN, selectionEnd.x ?? NaN)
});
const lowerBoundY = computed(() => {
return isCtrlDown.value ? props.firstHour :
Math.min(selectionStart.y, selectionEnd.y)
Math.min(selectionStart.y ?? NaN, selectionEnd.y ?? NaN)
});
const upperBoundY = computed(() => {
return isCtrlDown.value ? props.lastHour :
Math.max(selectionStart.y, selectionEnd.y)
Math.max(selectionStart.y ?? NaN, selectionEnd.y ?? NaN)
});
function selectionInside(dayIndex, hour) {
function selectionInside(dayIndex: number, hour: number) {
if (selectionStart.x != undefined) {
return (dayIndex >= lowerBoundX.value && dayIndex <= upperBoundX.value) &&
(hour >= lowerBoundY.value && hour <= upperBoundY.value);

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import type { TeamIntegrationSchema, TeamDiscordIntegrationSchema } from "@/client";
import { useTeamDetails } from "@/composables/team-details";
import { useTeamsStore } from "@/stores/teams";
import { computed } from "vue";
const props = defineProps<{
integration: TeamIntegrationSchema,
}>();
const teamsStore = useTeamsStore();
const { teamId } = useTeamDetails();
/*
const isDiscord = (x: TeamIntegrationSchema): x is TeamDiscordIntegrationSchema => x.integrationType === "team_discord_integrations";
const isDiscordIntegration = computed(() => {
return isDiscord(props.integration);
});
*/
const discordIntegration = computed(() => props.integration as TeamDiscordIntegrationSchema);
function deleteIntegration() {
teamsStore.deleteIntegration(teamId.value, props.integration.id);
}
function saveIntegration() {
teamsStore.updateIntegration(teamId.value, props.integration);
}
</script>
<template>
<details class="accordion">
<summary>
<span class="title">
<h2 v-if="discordIntegration">
Discord Integration
</h2>
<span class="aside">(id: {{ props.integration.id }})</span>
</span>
</summary>
<div class="form-group margin">
<h3>Webhook URL</h3>
<input v-model="discordIntegration.webhookUrl" />
</div>
<div class="button-group">
<button class="destructive-on-hover" @click="deleteIntegration">
<i class="bi bi-trash margin" />
Delete
</button>
<button @click="saveIntegration">Save</button>
</div>
</details>
</template>
<style scoped>
.button-group {
display: flex;
gap: 4px;
justify-content: end;
}
summary > .title {
display: flex;
align-items: center;
gap: 0.5em;
}
summary .aside {
font-size: 1rem;
}
</style>

View File

@ -1,5 +1,5 @@
import Cacheable from "@/cacheable";
import { AvailabilitfClient, type TeamInviteSchema, type RoleSchema, type TeamSchema, type ViewTeamMembersResponse, type ViewTeamResponse, type ViewTeamsResponse } from "@/client";
import { AvailabilitfClient, type TeamInviteSchema, type RoleSchema, type TeamSchema, type ViewTeamMembersResponse, type ViewTeamResponse, type ViewTeamsResponse, type TeamIntegrationSchema, type AbstractTeamIntegrationSchema } from "@/client";
import { defineStore } from "pinia";
import { computed, reactive, ref, type Reactive, type Ref } from "vue";
import { useClientStore } from "./client";
@ -17,6 +17,7 @@ export const useTeamsStore = defineStore("teams", () => {
const teams: Reactive<{ [id: number]: TeamSchema }> = reactive({ });
const teamMembers: Reactive<{ [id: number]: ViewTeamMembersResponse[] }> = reactive({ });
const teamInvites: Reactive<{ [id: number]: TeamInviteSchema[] }> = reactive({ });
const teamIntegrations = reactive<{ [id: number]: TeamIntegrationSchema[] }>({ });
async function fetchTeams() {
return clientStore.call(
@ -118,6 +119,47 @@ export const useTeamsStore = defineStore("teams", () => {
});
}
async function getIntegrations(teamId: number) {
return client.default.getIntegrations(teamId.toString())
.then((response) => {
teamIntegrations[teamId] = response;
return response;
});
}
async function createIntegration(teamId: number, integrationType: string) {
return client.default
.createIntegration(teamId.toString(), integrationType)
.then((response) => {
teamIntegrations[teamId].push(response);
return response;
});
}
async function deleteIntegration(teamId: number, integrationId: number) {
return client.default
.deleteIntegration(teamId.toString(), integrationId.toString())
.then((response) => {
teamIntegrations[teamId] = teamIntegrations[teamId]
.filter((integration) => integration.id != integrationId);
return response;
});
}
async function updateIntegration(
teamId: number,
integration: AbstractTeamIntegrationSchema,
) {
return client.default
.updateIntegration(teamId.toString(), integration.id.toString(), integration)
.then((response) => {
const index = teamIntegrations[teamId]
.findIndex((x) => x.id == integration.id);
teamIntegrations[teamId][index] = response;
return response;
});
}
async function leaveTeam(teamId: number) {
return client.default
.removePlayerFromTeam(teamId.toString(), authStore.steamId);
@ -137,5 +179,11 @@ export const useTeamsStore = defineStore("teams", () => {
consumeInvite,
revokeInvite,
leaveTeam,
// TODO: move to separate store
teamIntegrations,
getIntegrations,
createIntegration,
deleteIntegration,
updateIntegration,
};
});

View File

@ -1,20 +1,41 @@
<script setup lang="ts">
import IntegrationDetails from "@/components/IntegrationDetails.vue";
import { useTeamDetails } from "@/composables/team-details";
import { useTeamsStore } from "@/stores/teams";
import { computed, onMounted, ref } from "vue";
const teamsStore = useTeamsStore();
const {
teamId,
} = useTeamDetails();
const integrations = computed(() => teamsStore.teamIntegrations[teamId.value]);
function createIntegration() {
teamsStore.createIntegration(teamId.value, "discord");
}
onMounted(() => {
teamsStore.fetchTeam(teamId.value)
.then(() => teamsStore.getIntegrations(teamId.value));
});
</script>
<template>
<div class="team-integrations">
<h2>Team Integrations</h2>
<div v-if="true">
<div v-if="integrations?.length == 0">
This team currently does not have any integrations.
</div>
<div v-else>
<details class="accordion">
<summary>
<h2>Discord Webhook</h2>
</summary>
<h3>Webhook URL</h3>
<input hidden />
</details>
<IntegrationDetails
v-for="integration in integrations"
:integration="integration"
/>
</div>
<button class="accent" @click="createIntegration">
<i class="bi bi-database-fill-add margin" />
Create Integration
</button>
</div>
</template>

View File

@ -1,4 +1,6 @@
#from typing import cast, override
from typing import TypeAlias, Union
from pydantic_core.core_schema import UnionSchema
from sqlalchemy.orm import mapped_column, relationship
from sqlalchemy.orm.attributes import Mapped
from sqlalchemy.orm.properties import ForeignKey
@ -56,4 +58,11 @@ class TeamDiscordIntegrationSchema(TeamIntegrationSchema):
webhook_url=model.webhook_url
)
class ExampleIntegrationSchema(TeamIntegrationSchema):
test: str
class AbstractTeamIntegrationSchema(spec.BaseModel):
__root__: TeamDiscordIntegrationSchema | TeamIntegrationSchema
from models.team import Team

View File

@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta, timezone
from random import randint, random
import sys
import time
from typing import List
from typing import List, cast
from flask import Blueprint, abort, jsonify, make_response, request
from pydantic.v1 import validator
from spectree import Response
@ -14,7 +14,7 @@ from models.player_team_availability import PlayerTeamAvailability
from models.player_team_role import PlayerTeamRole, RoleSchema
from models.team import Team, TeamSchema
from models.team_invite import TeamInvite, TeamInviteSchema
from models.team_integration import TeamDiscordIntegration, TeamDiscordIntegrationSchema, TeamIntegration, TeamIntegrationSchema
from models.team_integration import AbstractTeamIntegrationSchema, TeamDiscordIntegration, TeamDiscordIntegrationSchema, TeamIntegration, TeamIntegrationSchema
from middleware import assert_team_authority, requires_authentication, requires_team_membership
import models
from spec import spec, BaseModel
@ -630,7 +630,9 @@ def create_integration(player_team: PlayerTeam, integration_type: str, **_):
),
operation_id="delete_integration"
)
def delete_integration(player_team: PlayerTeam, integration_id: int):
@requires_authentication
@requires_team_membership
def delete_integration(player_team: PlayerTeam, integration_id: int, **_):
assert_team_authority(player_team)
integration = db.session.query(
@ -656,10 +658,12 @@ def delete_integration(player_team: PlayerTeam, integration_id: int):
),
operation_id="update_integration"
)
@requires_authentication
@requires_team_membership
def update_integration(
player_team: PlayerTeam,
integration_id: int,
json: TeamIntegrationSchema,
json: AbstractTeamIntegrationSchema,
**_
):
assert_team_authority(player_team)
@ -676,8 +680,12 @@ def update_integration(
abort(404)
if isinstance(integration, TeamDiscordIntegration):
if isinstance(json, TeamDiscordIntegrationSchema):
integration.webhook_url = json.webhook_url
print(json.dict(), file=sys.stderr)
if json.__root__.integration_type == "team_discord_integrations":
discord_integration = cast(TeamDiscordIntegration, json.__root__)
integration.webhook_url = discord_integration.webhook_url
#if isinstance(json, TeamDiscordIntegrationSchema):
# integration.webhook_url = json.webhook_url
else:
abort(400)
else: