refactor: Change team integrations structure

Refactor the team integrations structure to use one-to-one relationships
for Discord and logs.tf integrations. Update the frontend to handle the
new structure and remove unused integration types. Adjust backend
endpoints and models accordingly. Add migration scripts to update the
database schema.
master
John Montagu, the 4th Earl of Sandvich 2024-11-25 20:16:01 -08:00
parent 77aff078da
commit 71cc25dbb2
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
19 changed files with 435 additions and 309 deletions

View File

@ -247,8 +247,8 @@ input {
} }
.form-group.margin { .form-group.margin {
margin-top: 16px; margin-top: 1rem;
margin-bottom: 16px; margin-bottom: 1rem;
} }
.form-group.row { .form-group.row {
@ -259,6 +259,7 @@ input {
.form-group .action-buttons { .form-group .action-buttons {
display: flex; display: flex;
justify-content: end; justify-content: end;
gap: 0.5rem;
} }
hr { hr {

View File

@ -10,7 +10,6 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI'; export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI';
export type { AbstractTeamIntegrationSchema } from './models/AbstractTeamIntegrationSchema';
export type { AddPlayerJson } from './models/AddPlayerJson'; export type { AddPlayerJson } from './models/AddPlayerJson';
export type { AvailabilitySchema } from './models/AvailabilitySchema'; export type { AvailabilitySchema } from './models/AvailabilitySchema';
export type { CreateEventJson } from './models/CreateEventJson'; export type { CreateEventJson } from './models/CreateEventJson';
@ -25,9 +24,9 @@ export type { RoleSchema } from './models/RoleSchema';
export type { SetUsernameJson } from './models/SetUsernameJson'; export type { SetUsernameJson } from './models/SetUsernameJson';
export type { TeamDiscordIntegrationSchema } from './models/TeamDiscordIntegrationSchema'; export type { TeamDiscordIntegrationSchema } from './models/TeamDiscordIntegrationSchema';
export type { TeamIntegrationSchema } from './models/TeamIntegrationSchema'; export type { TeamIntegrationSchema } from './models/TeamIntegrationSchema';
export type { TeamIntegrationSchemaList } from './models/TeamIntegrationSchemaList';
export type { TeamInviteSchema } from './models/TeamInviteSchema'; export type { TeamInviteSchema } from './models/TeamInviteSchema';
export type { TeamInviteSchemaList } from './models/TeamInviteSchemaList'; export type { TeamInviteSchemaList } from './models/TeamInviteSchemaList';
export type { TeamLogsTfIntegrationSchema } from './models/TeamLogsTfIntegrationSchema';
export { TeamRole } from './models/TeamRole'; export { TeamRole } from './models/TeamRole';
export type { TeamSchema } from './models/TeamSchema'; export type { TeamSchema } from './models/TeamSchema';
export type { ValidationError } from './models/ValidationError'; export type { ValidationError } from './models/ValidationError';

View File

@ -1,8 +0,0 @@
/* 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

@ -4,7 +4,7 @@
/* eslint-disable */ /* eslint-disable */
export type EventSchema = { export type EventSchema = {
createdAt: string; createdAt: string;
description: string; description?: string;
id: number; id: number;
name: string; name: string;
startTime: string; startTime: string;

View File

@ -3,9 +3,7 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type TeamDiscordIntegrationSchema = { export type TeamDiscordIntegrationSchema = {
id: number; webhookBotName: string;
integrationType: string;
teamId: number;
webhookUrl: string; webhookUrl: string;
}; };

View File

@ -2,9 +2,10 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { TeamDiscordIntegrationSchema } from './TeamDiscordIntegrationSchema';
import type { TeamLogsTfIntegrationSchema } from './TeamLogsTfIntegrationSchema';
export type TeamIntegrationSchema = { export type TeamIntegrationSchema = {
id: number; discordIntegration?: TeamDiscordIntegrationSchema;
integrationType: string; logsTfIntegration?: TeamLogsTfIntegrationSchema;
teamId: number;
}; };

View File

@ -1,6 +0,0 @@
/* 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

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

View File

@ -2,7 +2,6 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { AbstractTeamIntegrationSchema } from '../models/AbstractTeamIntegrationSchema';
import type { AddPlayerJson } from '../models/AddPlayerJson'; import type { AddPlayerJson } from '../models/AddPlayerJson';
import type { CreateEventJson } from '../models/CreateEventJson'; import type { CreateEventJson } from '../models/CreateEventJson';
import type { CreateTeamJson } from '../models/CreateTeamJson'; import type { CreateTeamJson } from '../models/CreateTeamJson';
@ -13,7 +12,6 @@ import type { PlayerSchema } from '../models/PlayerSchema';
import type { PutScheduleForm } from '../models/PutScheduleForm'; import type { PutScheduleForm } from '../models/PutScheduleForm';
import type { SetUsernameJson } from '../models/SetUsernameJson'; import type { SetUsernameJson } from '../models/SetUsernameJson';
import type { TeamIntegrationSchema } from '../models/TeamIntegrationSchema'; import type { TeamIntegrationSchema } from '../models/TeamIntegrationSchema';
import type { TeamIntegrationSchemaList } from '../models/TeamIntegrationSchemaList';
import type { TeamInviteSchema } from '../models/TeamInviteSchema'; import type { TeamInviteSchema } from '../models/TeamInviteSchema';
import type { TeamInviteSchemaList } from '../models/TeamInviteSchemaList'; import type { TeamInviteSchemaList } from '../models/TeamInviteSchemaList';
import type { ViewAvailablePlayersResponse } from '../models/ViewAvailablePlayersResponse'; import type { ViewAvailablePlayersResponse } from '../models/ViewAvailablePlayersResponse';
@ -421,12 +419,12 @@ export class DefaultService {
/** /**
* get_integrations <GET> * get_integrations <GET>
* @param teamId * @param teamId
* @returns TeamIntegrationSchemaList OK * @returns TeamIntegrationSchema OK
* @throws ApiError * @throws ApiError
*/ */
public getIntegrations( public getIntegrations(
teamId: string, teamId: string,
): CancelablePromise<TeamIntegrationSchemaList> { ): CancelablePromise<TeamIntegrationSchema> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'GET', method: 'GET',
url: '/api/team/id/{team_id}/integrations', url: '/api/team/id/{team_id}/integrations',
@ -434,53 +432,26 @@ export class DefaultService {
'team_id': teamId, 'team_id': teamId,
}, },
errors: { errors: {
404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Entity`,
}, },
}); });
} }
/** /**
* delete_integration <DELETE> * update_integration <PUT>
* @param teamId * @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 * @param requestBody
* @returns TeamIntegrationSchema OK * @returns TeamIntegrationSchema OK
* @throws ApiError * @throws ApiError
*/ */
public updateIntegration( public updateIntegrations(
teamId: string, teamId: string,
integrationId: string, requestBody?: TeamIntegrationSchema,
requestBody?: AbstractTeamIntegrationSchema,
): CancelablePromise<TeamIntegrationSchema> { ): CancelablePromise<TeamIntegrationSchema> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'PATCH', method: 'PUT',
url: '/api/team/id/{team_id}/integrations/{integration_id}', url: '/api/team/id/{team_id}/integrations',
path: { path: {
'team_id': teamId, 'team_id': teamId,
'integration_id': integrationId,
}, },
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
@ -489,29 +460,6 @@ export class DefaultService {
}, },
}); });
} }
/**
* 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> * get_invites <GET>
* @param teamId * @param teamId

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import { type TeamDiscordIntegrationSchema } from "@/client";
import { useTeamDetails } from "@/composables/team-details";
import { useIntegrationsStore } from "@/stores/teams/integrations";
const model = defineModel<TeamDiscordIntegrationSchema>();
const integrationsStore = useIntegrationsStore();
const { teamId } = useTeamDetails();
function saveIntegration() {
integrationsStore.updateIntegrations(teamId.value);
}
function enableIntegration() {
model.value = {
webhookUrl: "",
webhookBotName: "",
};
saveIntegration();
}
function disableIntegration() {
model.value = undefined;
saveIntegration();
}
</script>
<template>
<h2>Discord Webhook</h2>
<p>Receive notifications in Discord for event updates.</p>
<div v-if="model">
<div class="form-group margin">
<h3>Webhook URL</h3>
<input v-model="model.webhookUrl">
</div>
<div class="form-group margin">
<h3>Webhook Bot Name</h3>
<input v-model="model.webhookBotName">
</div>
<div class="form-group margin">
<div class="action-buttons">
<button class="destructive-on-hover" @click="disableIntegration">
<i class="bi bi-trash" />
Disable integration
</button>
<button class="accent" @click="saveIntegration">
<i class="bi bi-check" />
Save
</button>
</div>
</div>
</div>
<div v-else>
<button class="accent" @click="enableIntegration">
<i class="bi bi-check" />
Enable Discord Integration
</button>
</div>
</template>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { type TeamLogsTfIntegrationSchema } from "@/client";
import { useTeamDetails } from "@/composables/team-details";
import { useIntegrationsStore } from "@/stores/teams/integrations";
const model = defineModel<TeamLogsTfIntegrationSchema>();
const integrationsStore = useIntegrationsStore();
const { teamId } = useTeamDetails();
function saveIntegration() {
integrationsStore.updateIntegrations(teamId.value);
}
function enableIntegration() {
model.value = {
logsTfApiKey: "",
minTeamMemberCount: 4,
};
integrationsStore.updateIntegrations(teamId.value);
}
function disableIntegration() {
model.value = undefined;
integrationsStore.updateIntegrations(teamId.value);
}
</script>
<template>
<h2>logs.tf Integration</h2>
<p>Automatically track match history from logs.tf.</p>
<div v-if="model">
<div class="form-group margin">
<h3>logs.tf API key (optional)</h3>
<input v-model="model.logsTfApiKey">
</div>
<div class="form-group margin">
<h3>Minimum Team Members</h3>
<p>
Minimum number of team members needed to appear in the logs.tf match to
automatically be included in the team match history.
</p>
<input v-model="model.minTeamMemberCount" type="number">
</div>
<div class="form-group margin">
<div class="action-buttons">
<button class="destructive-on-hover" @click="disableIntegration">
<i class="bi bi-trash" />
Disable integration
</button>
<button class="accent" @click="saveIntegration">
<i class="bi bi-check" />
Save
</button>
</div>
</div>
</div>
<div v-else>
<button class="accent" @click="enableIntegration">
<i class="bi bi-check" />
Enable logs.tf Integration
</button>
</div>
</template>

View File

@ -1,44 +1,49 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { reactive, type Reactive } from "vue"; import { ref } from "vue";
import { useClientStore } from "../client"; import { useClientStore } from "../client";
import { type TeamIntegrationSchema, type AbstractTeamIntegrationSchema } from "@/client"; import type {
TeamIntegrationSchema,
TeamDiscordIntegrationSchema,
TeamLogsTfIntegrationSchema
} from "@/client";
export const useIntegrationsStore = defineStore("integrations", () => { export const useIntegrationsStore = defineStore("integrations", () => {
const clientStore = useClientStore(); const hasLoaded = ref(false);
const client = clientStore.client;
const teamIntegrations = reactive<{ [id: number]: TeamIntegrationSchema[] }>({}); const client = useClientStore().client;
const discordIntegration = ref<TeamDiscordIntegrationSchema | undefined>();
const logsTfIntegration = ref<TeamLogsTfIntegrationSchema | undefined>();
async function getIntegrations(teamId: number) { async function getIntegrations(teamId: number) {
hasLoaded.value = false;
const response = await client.default.getIntegrations(teamId.toString()); const response = await client.default.getIntegrations(teamId.toString());
teamIntegrations[teamId] = response; setIntegrations(response);
return response; return response;
} }
async function createIntegration(teamId: number, integrationType: string) { function setIntegrations(schema: TeamIntegrationSchema) {
const response = await client.default.createIntegration(teamId.toString(), integrationType); discordIntegration.value = schema.discordIntegration;
teamIntegrations[teamId].push(response); logsTfIntegration.value = schema.logsTfIntegration;
return response; hasLoaded.value = true;
} }
async function deleteIntegration(teamId: number, integrationId: number) { async function updateIntegrations(teamId: number) {
const response = await client.default.deleteIntegration(teamId.toString(), integrationId.toString()); const body: TeamIntegrationSchema = {
teamIntegrations[teamId] = teamIntegrations[teamId].filter((integration) => integration.id != integrationId); discordIntegration: discordIntegration.value,
return response; logsTfIntegration: logsTfIntegration.value,
} };
const response = await client.default.updateIntegrations(teamId.toString(), body);
async function updateIntegration(teamId: number, integration: AbstractTeamIntegrationSchema) { setIntegrations(response);
const response = await client.default.updateIntegration(teamId.toString(), integration.id.toString(), integration);
const index = teamIntegrations[teamId].findIndex((x) => x.id == integration.id);
teamIntegrations[teamId][index] = response;
return response; return response;
} }
return { return {
teamIntegrations, hasLoaded,
discordIntegration,
logsTfIntegration,
getIntegrations, getIntegrations,
createIntegration, updateIntegrations,
deleteIntegration,
updateIntegration,
}; };
}); });

View File

@ -46,32 +46,42 @@ onMounted(() => {
<template> <template>
<main> <main>
<template v-if="team"> <template v-if="team">
<center class="margin">
<h1>
{{ team.teamName }}
</h1>
<span class="aside">
Formed on {{ creationDate }}
</span>
<div class="icons">
<RouterLink class="button" :to="'/schedule?teamId=' + team.id">
<button class="icon" v-tooltip="'Schedule'">
<i class="bi bi-calendar-fill"></i>
</button>
</RouterLink>
<RouterLink class="button" :to="{ name: 'team-settings/' }">
<button class="icon" v-tooltip="'Settings'">
<i class="bi bi-gear-fill"></i>
</button>
</RouterLink>
</div>
</center>
<div class="content-container"> <div class="content-container">
<div class="left"> <div class="left">
<center class="margin">
<h1>
{{ team.teamName }}
</h1>
<span class="aside">
Formed on {{ creationDate }}
</span>
<div class="icons">
<RouterLink class="button" :to="'/schedule?teamId=' + team.id">
<button class="icon" v-tooltip="'Schedule'">
<i class="bi bi-calendar-fill"></i>
</button>
</RouterLink>
<RouterLink class="button" :to="{ name: 'team-settings/' }">
<button class="icon" v-tooltip="'Settings'">
<i class="bi bi-gear-fill"></i>
</button>
</RouterLink>
</div>
</center>
<MembersList /> <MembersList />
</div> </div>
<div class="right"> <div class="right">
<h2>Upcoming Events</h2>
<EventList :events="events" /> <EventList :events="events" />
<h2 id="recent-matches-header">
Recent Matches
<RouterLink class="button" to="/">
<button class="icon" v-tooltip="'View all'">
<i class="bi bi-arrow-right-circle-fill"></i>
</button>
</RouterLink>
</h2>
<em class="subtext">No recent matches.</em>
</div> </div>
</div> </div>
</template> </template>
@ -79,6 +89,12 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
#recent-matches-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.content-container { .content-container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -89,7 +105,11 @@ onMounted(() => {
} }
.content-container > div.right { .content-container > div.right {
display: flex;
flex-direction: column;
flex: 1; flex: 1;
margin-top: 4em;
gap: 1rem;
} }
.margin { .margin {

View File

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import DiscordIntegrationForm from "@/components/DiscordIntegrationForm.vue";
import IntegrationDetails from "@/components/IntegrationDetails.vue"; import IntegrationDetails from "@/components/IntegrationDetails.vue";
import LogsTfIntegrationForm from "@/components/LogsTfIntegrationForm.vue";
import { useTeamDetails } from "@/composables/team-details"; import { useTeamDetails } from "@/composables/team-details";
import { useTeamsStore } from "@/stores/teams"; import { useTeamsStore } from "@/stores/teams";
import { useIntegrationsStore } from "@/stores/teams/integrations"; import { useIntegrationsStore } from "@/stores/teams/integrations";
@ -9,11 +11,9 @@ const teamsStore = useTeamsStore();
const integrationsStore = useIntegrationsStore(); const integrationsStore = useIntegrationsStore();
const { teamId } = useTeamDetails(); const { teamId } = useTeamDetails();
const integrations = computed(() => integrationsStore.teamIntegrations[teamId.value]); //function createIntegration() {
// integrationsStore.createIntegration(teamId.value, "discord");
function createIntegration() { //}
integrationsStore.createIntegration(teamId.value, "discord");
}
onMounted(() => { onMounted(() => {
teamsStore.fetchTeam(teamId.value) teamsStore.fetchTeam(teamId.value)
@ -23,19 +23,15 @@ onMounted(() => {
<template> <template>
<div class="team-integrations"> <div class="team-integrations">
<h2>Team Integrations</h2> <DiscordIntegrationForm v-model="integrationsStore.discordIntegration" />
<div v-if="integrations?.length == 0"> <LogsTfIntegrationForm v-model="integrationsStore.logsTfIntegration" />
This team currently does not have any integrations.
</div>
<div v-else>
<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> </div>
</template> </template>
<style scoped>
.team-integrations {
display: flex;
flex-direction: column;
gap: 1em;
}
</style>

View File

@ -0,0 +1,42 @@
"""Change integrations to one-to-one
Revision ID: 392454b91293
Revises: f802d763a7b4
Create Date: 2024-11-25 18:36:15.293593
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '392454b91293'
down_revision = 'f802d763a7b4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('team_discord_integrations',
sa.Column('team_id', sa.Integer(), nullable=False),
sa.Column('webhook_url', sa.String(), nullable=False),
sa.Column('webhook_bot_name', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.PrimaryKeyConstraint('team_id')
)
op.create_table('team_logs_tf_integrations',
sa.Column('team_id', sa.Integer(), nullable=False),
sa.Column('logs_tf_api_key', sa.String(), nullable=True),
sa.Column('min_team_member_count', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.PrimaryKeyConstraint('team_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('team_logs_tf_integrations')
op.drop_table('team_discord_integrations')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""Drop integrations tables
Revision ID: f802d763a7b4
Revises: dcf5ffd0ec73
Create Date: 2024-11-25 18:34:08.136071
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f802d763a7b4'
down_revision = 'dcf5ffd0ec73'
branch_labels = None
depends_on = None
def upgrade():
# drop integrations tables
op.drop_table("team_discord_integrations")
op.drop_table("team_logs_tf_integrations")
op.drop_table("team_integrations")
pass
def downgrade():
pass

View File

@ -18,9 +18,68 @@ class Team(app_db.BaseModel):
players: Mapped[list["PlayerTeam"]] = relationship(back_populates="team") players: Mapped[list["PlayerTeam"]] = relationship(back_populates="team")
invites: Mapped[list["TeamInvite"]] = relationship(back_populates="team") invites: Mapped[list["TeamInvite"]] = relationship(back_populates="team")
integrations: Mapped[list["TeamIntegration"]] = relationship(back_populates="team")
events: Mapped[list["Event"]] = relationship(back_populates="team") events: Mapped[list["Event"]] = relationship(back_populates="team")
discord_integration: Mapped["TeamDiscordIntegration"] = relationship(
"TeamDiscordIntegration",
back_populates="team",
uselist=False,
lazy="raise",
)
logs_tf_integration: Mapped["TeamLogsTfIntegration"] = relationship(
"TeamLogsTfIntegration",
back_populates="team",
uselist=False,
lazy="raise",
)
def update_integrations(self, integrations: "TeamIntegrationSchema"):
if integrations.discord_integration:
print("DISCORD!!!")
discord_integration = self.discord_integration \
or TeamDiscordIntegration()
discord_integration.webhook_url = integrations \
.discord_integration.webhook_url
discord_integration.webhook_bot_name = integrations \
.discord_integration.webhook_bot_name
if discord_integration.team_id is None:
discord_integration.team_id = self.id
app_db.db.session.add(discord_integration)
elif self.discord_integration:
app_db.db.session.delete(self.discord_integration)
if integrations.logs_tf_integration:
logs_tf_integration = self.logs_tf_integration \
or TeamLogsTfIntegration()
logs_tf_integration.logs_tf_api_key = integrations \
.logs_tf_integration.logs_tf_api_key or ""
logs_tf_integration.min_team_member_count = integrations \
.logs_tf_integration.min_team_member_count
if logs_tf_integration.team_id is None:
logs_tf_integration.team_id = self.id
app_db.db.session.add(logs_tf_integration)
elif self.logs_tf_integration:
app_db.db.session.delete(self.logs_tf_integration)
def get_integrations(self) -> "TeamIntegrationSchema":
discord_integration = None
logs_tf_integration = None
if self.discord_integration:
discord_integration = TeamDiscordIntegrationSchema.from_model(
self.discord_integration
)
if self.logs_tf_integration:
logs_tf_integration = TeamLogsTfIntegrationSchema.from_model(
self.logs_tf_integration
)
return TeamIntegrationSchema(
discord_integration=discord_integration,
logs_tf_integration=logs_tf_integration,
)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
class TeamSchema(spec.BaseModel): class TeamSchema(spec.BaseModel):
@ -41,6 +100,12 @@ class TeamSchema(spec.BaseModel):
) )
from models.player_team import PlayerTeam from models.player_team import PlayerTeam
from models.team_integration import TeamIntegration
from models.team_invite import TeamInvite from models.team_invite import TeamInvite
from models.team_integration import (
TeamDiscordIntegration,
TeamDiscordIntegrationSchema,
TeamIntegrationSchema,
TeamLogsTfIntegration,
TeamLogsTfIntegrationSchema,
)
from models.event import Event from models.event import Event

View File

@ -1,6 +1,3 @@
#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 import mapped_column, relationship
from sqlalchemy.orm.attributes import Mapped from sqlalchemy.orm.attributes import Mapped
from sqlalchemy.orm.properties import ForeignKey from sqlalchemy.orm.properties import ForeignKey
@ -9,60 +6,52 @@ import app_db
import spec import spec
class TeamIntegration(app_db.BaseModel): class TeamDiscordIntegration(app_db.BaseModel):
__tablename__ = "team_integrations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
team_id: Mapped[int] = mapped_column(Integer, ForeignKey("teams.id"))
integration_type: Mapped[str]
team: Mapped["Team"] = relationship(back_populates="integrations")
__mapper_args__ = {
"polymorphic_identity": "team_integrations",
"polymorphic_on": "integration_type",
}
class TeamDiscordIntegration(TeamIntegration):
__tablename__ = "team_discord_integrations" __tablename__ = "team_discord_integrations"
integration_id: Mapped[int] = mapped_column(ForeignKey("team_integrations.id"), primary_key=True) team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True)
webhook_url: Mapped[str] = mapped_column(String(255), nullable=True) webhook_url: Mapped[str] = mapped_column(String)
webhook_bot_name: Mapped[str] = mapped_column(String)
__mapper_args__ = { team: Mapped["Team"] = relationship("Team", back_populates="discord_integration")
"polymorphic_identity": "team_discord_integrations",
}
class TeamIntegrationSchema(spec.BaseModel): class TeamDiscordIntegrationSchema(spec.BaseModel):
id: int
team_id: int
integration_type: str
@classmethod
def from_model(cls, model: TeamIntegration):
if model.integration_type == "team_discord_integrations":
if isinstance(model, TeamDiscordIntegration):
return TeamDiscordIntegrationSchema._from_model_discord(model)
raise TypeError()
class TeamDiscordIntegrationSchema(TeamIntegrationSchema):
webhook_url: str webhook_url: str
webhook_bot_name: str
@classmethod @classmethod
def _from_model_discord(cls, model: TeamDiscordIntegration): def from_model(cls, model: TeamDiscordIntegration) -> "TeamDiscordIntegrationSchema":
assert model.integration_id != None
return cls( return cls(
id=model.integration_id, webhook_url=model.webhook_url,
team_id=model.team_id, webhook_bot_name=model.webhook_bot_name,
integration_type=model.integration_type,
webhook_url=model.webhook_url
) )
class ExampleIntegrationSchema(TeamIntegrationSchema): class TeamLogsTfIntegration(app_db.BaseModel):
test: str __tablename__ = "team_logs_tf_integrations"
class AbstractTeamIntegrationSchema(spec.BaseModel): team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True)
__root__: TeamDiscordIntegrationSchema | TeamIntegrationSchema logs_tf_api_key: Mapped[str | None] = mapped_column(String, nullable=True)
# requires at least this many team members in a single team in the log to
# be automatically loaded into the database
min_team_member_count: Mapped[int] = mapped_column(Integer, default=4)
team: Mapped["Team"] = relationship("Team", back_populates="logs_tf_integration")
class TeamLogsTfIntegrationSchema(spec.BaseModel):
logs_tf_api_key: str | None
min_team_member_count: int
@classmethod
def from_model(cls, model: TeamLogsTfIntegration) -> "TeamLogsTfIntegrationSchema":
return cls(
logs_tf_api_key=model.logs_tf_api_key,
min_team_member_count=model.min_team_member_count,
)
class TeamIntegrationSchema(spec.BaseModel):
discord_integration: TeamDiscordIntegrationSchema | None
logs_tf_integration: TeamLogsTfIntegrationSchema | None
from models.team import Team from models.team import Team

View File

@ -1,14 +1,12 @@
from flask import Blueprint, abort, make_response from flask import Blueprint
from spectree import Response from spectree import Response
from typing import cast from sqlalchemy.orm import joinedload
from app_db import db
from middleware import assert_team_authority, requires_authentication, requires_team_membership from middleware import assert_team_authority, requires_authentication, requires_team_membership
from models.player import Player
from models.player_team import PlayerTeam from models.player_team import PlayerTeam
from models.team_integration import AbstractTeamIntegrationSchema, TeamDiscordIntegration, TeamIntegration, TeamIntegrationSchema from models.team import Team
from models.team_integration import TeamIntegrationSchema
from spec import spec from spec import spec
from app_db import db
api_team_integration = Blueprint("team_integration", __name__) api_team_integration = Blueprint("team_integration", __name__)
@ -16,130 +14,47 @@ api_team_integration = Blueprint("team_integration", __name__)
@api_team_integration.get("/id/<team_id>/integrations") @api_team_integration.get("/id/<team_id>/integrations")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=list[TeamIntegrationSchema], HTTP_200=TeamIntegrationSchema,
HTTP_404=None,
), ),
operation_id="get_integrations" operation_id="get_integrations"
) )
@requires_authentication @requires_authentication
def get_integrations(player: Player, team_id: int, **_): @requires_team_membership()
player_team = db.session.query( def get_integrations(player_team: PlayerTeam, **_):
PlayerTeam team = db.session.query(
Team
).where( ).where(
PlayerTeam.player_id == player.steam_id Team.id == player_team.team_id
).where( ).options(
PlayerTeam.team_id == team_id joinedload(Team.discord_integration),
).one_or_none() joinedload(Team.logs_tf_integration),
).one()
if not player_team: return team.get_integrations().dict(by_alias=True)
abort(404)
integrations = db.session.query( @api_team_integration.put("/id/<team_id>/integrations")
TeamIntegration
).where(
TeamIntegration.team_id == team_id
).all()
def map_integration_to_schema(integration: TeamIntegration):
return TeamIntegrationSchema.from_model(
integration
).dict(by_alias=True)
return list(map(map_integration_to_schema, integrations))
@api_team_integration.post("/id/<team_id>/integrations/<integration_type>")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=TeamIntegrationSchema, HTTP_200=TeamIntegrationSchema,
), ),
operation_id="create_integration" operation_id="update_integrations"
) )
@requires_authentication @requires_authentication
@requires_team_membership() @requires_team_membership()
def create_integration(player_team: PlayerTeam, integration_type: str, **_): def update_integrations(
assert_team_authority(player_team)
if integration_type == "discord":
integration = TeamDiscordIntegration()
integration.team_id = player_team.team_id
integration.webhook_url = ""
else:
abort(404)
db.session.add(integration)
db.session.commit()
return TeamIntegrationSchema.from_model(
integration
).dict(by_alias=True), 200
@api_team_integration.delete("/id/<team_id>/integrations/<integration_id>")
@spec.validate(
resp=Response(
HTTP_204=None,
),
operation_id="delete_integration"
)
@requires_authentication
@requires_team_membership()
def delete_integration(player_team: PlayerTeam, integration_id: int, **_):
assert_team_authority(player_team)
integration = db.session.query(
TeamIntegration
).where(
TeamIntegration.team_id == player_team.team_id
).where(
TeamIntegration.id == integration_id
).one_or_none()
if not integration:
abort(404)
db.session.delete(integration)
db.session.commit()
return make_response({ }, 204)
@api_team_integration.patch("/id/<team_id>/integrations/<integration_id>")
@spec.validate(
resp=Response(
HTTP_200=TeamIntegrationSchema,
),
operation_id="update_integration"
)
@requires_authentication
@requires_team_membership()
def update_integration(
player_team: PlayerTeam, player_team: PlayerTeam,
integration_id: int, json: TeamIntegrationSchema,
json: AbstractTeamIntegrationSchema,
**_ **_
): ):
assert_team_authority(player_team) assert_team_authority(player_team)
team = db.session.query(
integration = db.session.query( Team
TeamIntegration
).where( ).where(
TeamIntegration.team_id == player_team.team_id Team.id == player_team.team_id
).where( ).options(
TeamIntegration.id == integration_id joinedload(Team.discord_integration),
).one_or_none() joinedload(Team.logs_tf_integration),
).one()
if not integration: team.update_integrations(json)
abort(404)
if isinstance(integration, TeamDiscordIntegration):
if json.__root__.integration_type == "team_discord_integrations":
discord_integration = cast(TeamDiscordIntegration, json.__root__)
integration.webhook_url = discord_integration.webhook_url
else:
abort(400)
else:
abort(404)
db.session.commit() db.session.commit()
return json.dict(by_alias=True)
return TeamIntegrationSchema.from_model(
integration
).dict(by_alias=True), 200