Compare commits

...

3 Commits

18 changed files with 521 additions and 50 deletions

View File

@ -4,6 +4,7 @@
/* eslint-disable */
import type { PlayerSchema } from './PlayerSchema';
export type GetUserResponse = {
discordId?: (string | null);
isAdmin?: boolean;
realUser: (PlayerSchema | null);
steamId: string;

View File

@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */
export type PlayerSchema = {
discordId?: (string | null);
isAdmin?: boolean;
steamId: string;
username: string;

View File

@ -6,6 +6,7 @@ import type { RoleSchema } from './RoleSchema';
export type ViewTeamMembersResponse = {
availability: Array<number>;
createdAt: string;
discordId?: (string | null);
isAdmin?: boolean;
isTeamLeader?: boolean;
playtime: number;

View File

@ -16,6 +16,7 @@ export const useAuthStore = defineStore("auth", () => {
const hasCheckedAuth = ref(false);
const isAdmin = ref(false);
const realUser = ref<PlayerSchema | null>(null);
const discordId = ref<string | null>("");
const router = useRouter();
@ -39,6 +40,7 @@ export const useAuthStore = defineStore("auth", () => {
user.value = response;
isAdmin.value = response.isAdmin || (response.realUser?.isAdmin ?? false);
realUser.value = response.realUser ?? null;
discordId.value = response.discordId ?? "";
return response;
},
@ -116,6 +118,7 @@ export const useAuthStore = defineStore("auth", () => {
username,
isAdmin,
realUser,
discordId,
isLoggedIn,
hasCheckedAuth,
isRegistering,

View File

@ -1,6 +1,20 @@
<script setup lang="ts">
import { useAuthStore } from "@/stores/auth";
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
//const baseUrl = window.location.origin;
const redirectUri = computed(() => {
return encodeURIComponent(window.location.origin + "/settings");
});
const oauthUrl = computed(() => {
return "https://discord.com/oauth2/authorize" +
"?client_id=1372254613692219392" +
"&response_type=code" +
`&redirect_uri=${redirectUri.value}` +
"&scope=identify";
});
const displayName = ref("");
@ -25,6 +39,29 @@ onMounted(() => {
</h3>
<input v-model="displayName" />
</div>
<div class="form-group margin">
<h3>
Discord Account
</h3>
<!--p>
Link your Discord account to your profile to enable Discord
integration features. Contact
<a href="https://discord.com/users/195789918474207233">@pyrofromcsgo</a>
if you would like to manually link your account without logging in
through Discord.
</p-->
<p v-if="authStore.discordId">
Linked to Discord account <code>{{ authStore.discordId }}</code>.
</p>
<p v-else>
Discord OAuth is not yet implemented.
Contact <a href="https://discord.com/users/195789918474207233">@pyrofromcsgo</a>
if you would like to link your Discord account.
</p>
<!--a :href="oauthUrl">
<button>Link Discord Account</button>
</a-->
</div>
<div class="form-group margin">
<div class="action-buttons">
<button class="accent" @click="save">Save</button>

View File

@ -1,5 +1,5 @@
# Use an official Python runtime as a parent image
FROM python:3.12-slim
FROM python:3.13-slim
COPY requirements.txt /

View File

@ -3,7 +3,8 @@ from flask import Flask
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.engine import create_engine
from sqlalchemy.orm import DeclarativeBase, scoped_session, sessionmaker
from celery import Celery, Task
class BaseModel(DeclarativeBase):
@ -74,7 +75,24 @@ metadata = MetaData(naming_convention=convention)
def create_db() -> SQLAlchemy:
return SQLAlchemy(model_class=BaseModel, metadata=metadata)
def create_isolated_db_session(database_uri: str | None):
database_uri = environ.get("DATABASE_URI") or DATABASE_URI
if not database_uri:
raise ValueError("Database URI is not provided")
engine = create_engine(database_uri)
isolated_db = scoped_session(
sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
)
)
return isolated_db
app = create_app()
#db = SQLAlchemy(model_class=BaseModel, metadata=metadata)
db = create_db()
db_session: scoped_session = db.session
migrate = Migrate(app, db, render_as_batch=True)

View File

@ -0,0 +1,192 @@
import os
import discord
from discord.ext import commands
import app_db
import models
from models.event import Event
from models.player import Player
from models.player_event import PlayerEvent
from models.player_team import PlayerTeam
app_db.db_session = app_db.create_isolated_db_session(None)
guild_id_str = os.getenv("GUILD_ID")
guild_id = discord.Object(id=int(guild_id_str)) if guild_id_str else None
discord_token = os.getenv("DISCORD_TOKEN")
if not discord_token:
raise ValueError("DISCORD_TOKEN environment variable not set.")
class EventModal(discord.ui.Modal):
event_name = discord.ui.TextInput(
label="Event Name",
placeholder="Enter the event name",
)
event_description = discord.ui.TextInput(
label="Event Description",
placeholder="Describe the event",
style=discord.TextStyle.long,
)
def __init__(self, event: Event):
self.event = event
self.event_name.default = event.name
self.event_description.default = event.description
super().__init__(title="Event Details")
async def on_submit(self, interaction: discord.Interaction):
player_team = app_db.db_session.query(
PlayerTeam
).where(
PlayerTeam.team_id == self.event.team_id,
).join(
Player,
Player.steam_id == PlayerTeam.player_id
).where(
Player.discord_id == interaction.user.id
).one_or_none()
if not player_team or not player_team.is_team_leader:
await interaction.response.send_message(
"You are not authorized to edit this event.",
ephemeral=True
)
self.event.name = self.event_name.value
self.event.description = self.event_description.value
app_db.db_session.commit()
self.event.update_discord_message()
await interaction.response.send_message("Event details updated.", ephemeral=True)
async def handle_update_attendance(
player: Player,
event: Event,
interaction: discord.Interaction,
custom_id: str
):
player_event = app_db.db_session.query(
PlayerEvent
).where(
PlayerEvent.player_id == player.steam_id,
PlayerEvent.event_id == event.id
).one_or_none()
if custom_id == "click_not_attending":
if player_event:
app_db.db_session.delete(player_event)
app_db.db_session.commit()
event.update_discord_message()
await interaction.response.defer()
if not player_event:
player_event = PlayerEvent()
player_event.event_id = event.id
player_event.player_id = player.steam_id
app_db.db_session.add(player_event)
player_event.has_confirmed = custom_id == "click_attending"
app_db.db_session.commit()
event.update_discord_message()
await interaction.response.defer()
class Client(commands.Bot):
async def on_ready(self):
if guild_id:
try:
synced = await self.tree.sync(guild=guild_id)
print(f"Ready! Synced {len(synced)} commands.")
except Exception as e:
print(f"Failed to sync commands: {e}")
pass
async def on_interaction(self, interaction: discord.Interaction):
if interaction.response.is_done():
return
if interaction.type == discord.InteractionType.component and interaction.message:
# Handle button interactions here
if interaction.data is None or not "custom_id" in interaction.data:
return
interactions = [
"click_attending",
"click_pending",
"click_not_attending",
"click_edit_event",
]
if interaction.data["custom_id"] in interactions:
interaction_type = interaction.data["custom_id"]
player = app_db.db_session.query(
Player
).where(
Player.discord_id == interaction.user.id
).one_or_none()
if not player:
await interaction.response.send_message(
"This Discord account is not linked to a player. " +
"Contact <@195789918474207233> to link your account.",
ephemeral=True
)
# log the interaction
user = await self.fetch_user(195789918474207233)
if user:
await user.send(
f"User <@{interaction.user.id}> tried to " +
"interact with an event but their account is " +
"not linked to a player."
)
return
event = app_db.db_session.query(
Event
).where(
Event.discord_message_id == interaction.message.id
).one_or_none()
if event and player:
if interaction_type == "click_edit_event":
await interaction.response.send_modal(EventModal(event))
else:
await handle_update_attendance(player, event, interaction, interaction_type)
intents = discord.Intents.default()
client = Client(command_prefix="!", intents=intents)
@client.tree.command(
name="setup-announcement-webhook",
description="Set up announcements webhook in this channel",
guild=guild_id
)
@discord.app_commands.guild_only()
@discord.app_commands.default_permissions(manage_webhooks=True)
async def setup_announcements(interaction: discord.Interaction):
await interaction.response.send_message(
"Setting up announcement webhook. Any existing webhooks madde by " +
"this command will be deleted.",
ephemeral=True
)
channel = interaction.channel
assert isinstance(channel, discord.TextChannel)
for webhook in await channel.webhooks():
if webhook.user == client.user:
await webhook.delete()
webhook = await channel.create_webhook(name="availabili.tf webhook")
content = (
f"Webhook created: {webhook.url}\n" +
"Use this webhook URL in the Discord integration settings of your " +
"team to receive interactive announcements."
)
await interaction.followup.send(content, ephemeral=True)
client.run(discord_token)

View File

@ -25,11 +25,12 @@ class GetUserResponse(PlayerSchema):
real_user: PlayerSchema | None
@classmethod
def from_model(cls, model: Player):
def from_model(cls, player: Player):
return GetUserResponse(
steam_id=str(model.steam_id),
username=model.username,
is_admin=model.is_admin,
steam_id=str(player.steam_id),
username=player.username,
is_admin=player.is_admin,
discord_id=str(player.discord_id),
real_user=None,
)
@ -44,12 +45,9 @@ class GetUserResponse(PlayerSchema):
@requires_authentication
def get_user(player: Player, auth_session: AuthSession):
if auth_session.player.steam_id != player.steam_id:
return GetUserResponse(
steam_id=str(player.steam_id),
username=player.username,
is_admin=player.is_admin,
real_user=PlayerSchema.from_model(auth_session.player)
).dict(by_alias=True)
response = GetUserResponse.from_model(player)
response.real_user = PlayerSchema.from_model(auth_session.player)
return response.dict(by_alias=True)
return GetUserResponse.from_model(player).dict(by_alias=True)
@api_login.post("/authenticate")

View File

@ -0,0 +1,32 @@
"""Make players.discord_id unique
Revision ID: 251a8108d7ff
Revises: f4330df94c44
Create Date: 2025-05-14 16:44:16.817690
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '251a8108d7ff'
down_revision = 'f4330df94c44'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players', schema=None) as batch_op:
batch_op.create_unique_constraint("uq_players_discord_id", ['discord_id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players', schema=None) as batch_op:
batch_op.drop_constraint("uq_players_discord_id", type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Add uniqueness constraints
Revision ID: 8a37924fd1be
Revises: 251a8108d7ff
Create Date: 2025-05-15 09:52:01.887077
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8a37924fd1be'
down_revision = '251a8108d7ff'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('events', schema=None) as batch_op:
batch_op.create_unique_constraint('uq_events_discord_message_id', ['discord_message_id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('events', schema=None) as batch_op:
batch_op.drop_constraint('uq_events_discord_message_id', type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Add players.discord_id field
Revision ID: f4330df94c44
Revises: f8588cdf998e
Create Date: 2025-05-14 14:06:29.368594
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f4330df94c44'
down_revision = 'f8588cdf998e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players', schema=None) as batch_op:
batch_op.add_column(sa.Column('discord_id', sa.BigInteger(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players', schema=None) as batch_op:
batch_op.drop_column('discord_id')
# ### end Alembic commands ###

View File

@ -1,13 +1,14 @@
from datetime import datetime
import requests
import threading
from sqlalchemy.orm import mapped_column, relationship
from sqlalchemy.orm import mapped_column, relationship, scoped_session
from sqlalchemy.orm.attributes import Mapped
from sqlalchemy.orm.properties import ForeignKey
from sqlalchemy.orm.session import Session
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.types import TIMESTAMP, BigInteger, Integer, String, Text
from sqlalchemy.sql import func
from sqlalchemy_utc import UtcDateTime
from discord_webhook import DiscordWebhook
import app_db
import spec
import os
@ -26,7 +27,7 @@ class Event(app_db.BaseModel):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
discord_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
discord_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, unique=True)
team: Mapped["Team"] = relationship("Team", back_populates="events")
players: Mapped[list["PlayerEvent"]] = relationship(
@ -40,7 +41,7 @@ class Event(app_db.BaseModel):
)
def get_maximum_matching(self):
players_teams_roles = app_db.db.session.query(
players_teams_roles = app_db.db_session.query(
PlayerTeamRole
).join(
PlayerTeam
@ -89,7 +90,10 @@ class Event(app_db.BaseModel):
if player.role:
player_info += f"**{player.role.role.name}:** "
player_info += f"{player.player.username}"
if player.player.discord_id:
player_info += f"<@{player.player.discord_id}>"
else:
player_info += f"{player.player.username}"
if player.has_confirmed:
player_info += ""
@ -105,8 +109,6 @@ class Event(app_db.BaseModel):
else:
ringers_needed_msg = f" **({ringers_needed} ringers needed)**"
domain = os.environ.get("DOMAIN", "availabili.tf")
return "\n".join([
f"# {self.name}",
"",
@ -115,47 +117,95 @@ class Event(app_db.BaseModel):
f"<t:{start_timestamp}:f>",
"\n".join(players_info),
f"Maximum roles filled: {matchings}" + ringers_needed_msg,
"",
"[Confirm attendance here]" +
f"(https://{domain}/team/id/{self.team.id})",
#"",
#"[Confirm attendance here]" +
# f"(https://{domain}/team/id/{self.team.id})",
])
def get_discord_message_components(self):
domain = os.environ.get("DOMAIN", "availabili.tf")
return [
{
"type": 10,
"content": self.get_discord_content(),
},
{
"type": 1,
"components": [
{
"type": 2,
"label": "",
"style": 3,
"custom_id": "click_attending"
},
{
"type": 2,
"label": "",
"style": 2,
"custom_id": "click_pending"
},
{
"type": 2,
"label": "",
"style": 2,
"custom_id": "click_not_attending"
},
{
"type": 2,
"label": "Edit",
"style": 2,
"custom_id": "click_edit_event"
},
{
"type": 2,
"label": "View in browser",
"style": 5,
"url": f"https://{domain}/team/id/{self.team_id}"
}
]
}
]
def get_or_create_webhook(self):
integration = app_db.db.session.query(
integration = app_db.db_session.query(
TeamDiscordIntegration
).where(
TeamDiscordIntegration.team_id == self.team_id
).first()
if not integration:
return None
return None, ""
if self.discord_message_id:
return DiscordWebhook(
integration.webhook_url,
id=str(self.discord_message_id),
username=integration.webhook_bot_name,
avatar_url=integration.webhook_bot_profile_picture,
)
else:
return DiscordWebhook(
integration.webhook_url,
username=integration.webhook_bot_name,
avatar_url=integration.webhook_bot_profile_picture,
)
webhook = {
"username": integration.webhook_bot_name,
"avatar_url": integration.webhook_bot_profile_picture,
"flags": 1 << 15,
}
return webhook, integration.webhook_url
def update_discord_message(self):
webhook = self.get_or_create_webhook()
domain = os.environ.get("DOMAIN", "availabili.tf")
webhook, webhook_url = self.get_or_create_webhook()
if webhook:
webhook.content = self.get_discord_content()
if webhook.id:
params = "?with_components=true&wait=true"
webhook["components"] = self.get_discord_message_components()
if self.discord_message_id:
# fire and forget
threading.Thread(target=webhook.edit).start()
#threading.Thread(target=webhook.edit).start()
del webhook["username"]
del webhook["avatar_url"]
webhook_url += f"/messages/{self.discord_message_id}"
requests.patch(webhook_url + params, json=webhook)
else:
webhook.execute()
if webhook_id := webhook.id:
#webhook.execute()
response = requests.post(webhook_url + params, json=webhook)
response = response.json()
if webhook_id := response["id"]:
self.discord_message_id = int(webhook_id)
app_db.db.session.commit()
app_db.db_session.commit()
else:
raise Exception("Failed to create webhook")

View File

@ -13,6 +13,7 @@ class Player(app_db.BaseModel):
steam_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
username: Mapped[str] = mapped_column(String(63))
is_admin: Mapped[bool] = mapped_column(default=False)
discord_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, unique=True)
teams: Mapped[list["PlayerTeam"]] = relationship(back_populates="player")
auth_sessions: Mapped[list["AuthSession"]] = relationship(back_populates="player")
@ -25,13 +26,15 @@ class PlayerSchema(spec.BaseModel):
steam_id: str
username: str
is_admin: bool = False
discord_id: str | None = None
@classmethod
def from_model(cls, player: Player):
return cls(
steam_id=str(player.steam_id),
username=player.username,
is_admin=player.is_admin
is_admin=player.is_admin,
discord_id=str(player.discord_id) if player.discord_id else None
)

View File

@ -9,7 +9,7 @@ Flask-SQLAlchemy
SQLAlchemy-Utc
# form/data validation
pydantic==2.9.2
pydantic==2.11.4
spectree==1.4.1 # generates OpenAPI documents for us to make TypeScript API
# clients based on our pydantic models
@ -22,6 +22,9 @@ requests
pytz # timezone handling
discord-webhook # for sending messages to Discord webhooks
discord.py
https://github.com/HumanoidSandvichDispenser/Clyde/releases/download/test/discord_clyde-0.2.0-py3-none-any.whl
#discord-clyde
celery[redis]

View File

@ -1,4 +1,6 @@
from flask import Blueprint, abort, make_response
import os
from flask import Blueprint, abort, make_response, request
import requests
from spectree import Response
from middleware import requires_admin, requires_authentication
from models.player import Player, PlayerSchema
@ -8,6 +10,11 @@ from app_db import db
api_user = Blueprint("user", __name__, url_prefix="/user")
# TODO: use env vars
DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token"
DISCORD_CLIENT_ID = "1372254613692219392" #os.getenv("DISCORD_CLIENT_ID")
DISCORD_CLIENT_SECRET = os.getenv("DISCORD_CLIENT_SECRET")
class SetUsernameJson(BaseModel):
username: str
@ -78,3 +85,34 @@ def unset_doas(**_):
resp = make_response({ }, 204)
resp.delete_cookie("doas", httponly=True)
return resp
#class DiscordAuthQuery(BaseModel):
# code: str
# redirect_uri: str
#
#@api_user.post("/discord-authenticate")
#@spec.validate(
# operation_id="discord_authenticate"
#)
#@requires_authentication
#def discord_authenticate(query: DiscordAuthQuery, player: Player, **_):
# if not DISCORD_CLIENT_ID or not DISCORD_CLIENT_SECRET:
# abort(500, "The site is not configured to use Discord authentication")
#
# data = {
# "client_id": DISCORD_CLIENT_ID,
# "client_secret": DISCORD_CLIENT_SECRET,
# "grant_type": "authorization_code",
# "code": query.code,
# "redirect_uri": query.redirect_uri,
# "scope": "identify"
# }
# response = requests.post(DISCORD_TOKEN_URL, data)
# access_token = response.json()["access_token"]
#
# headers = {
# "authorization": f"Bearer {access_token}"
# }
# response = requests.get("https://discord.com/api/v10/users/@me", headers=headers)
#
# id = response.json()[]

View File

@ -30,6 +30,8 @@ services:
- ./backend-flask:/app
networks:
- prod-network
env_file:
- .env
environment:
- FLASK_DEBUG=0
- FLASK_CELERY_BROKER_URL=redis://redis:6379/0
@ -43,7 +45,7 @@ services:
# ETL job (runs with the same source as the backend)
celery-worker:
container_name: worker-production
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1 -B
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1
image: backend-flask-production
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
@ -53,10 +55,24 @@ services:
- ./backend-flask:/app
networks:
- prod-network
env_file:
- .env
depends_on:
- redis
- db
# discord bot
discord-bot:
container_name: discord-bot
command: python discord_bot.py
image: backend-flask-production
volumes:
- ./backend-flask:/app
networks:
- prod-network
env_file:
- .env
# message broker
redis:
image: redis:alpine

View File

@ -22,7 +22,7 @@ services:
# ETL job (runs with the same source as the backend)
celery-worker:
container_name: worker
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1 -B
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
@ -35,6 +35,20 @@ services:
depends_on:
- redis
# discord bot
discord-bot:
container_name: discord-bot
command: python discord_bot.py
image: backend-flask
volumes:
- ./backend-flask:/app
networks:
- dev-network
env_file:
- .env
environment:
- DATABASE_URI=sqlite:///instance/db.sqlite3
# message broker
redis:
image: redis:alpine