Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Build and Deploy

on:
push:
branches:
- main

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v3

- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin

- name: Build and Push bot
run: |
docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-bot:latest -f app/bot/Dockerfile .
docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-bot:latest

- name: Build and Push admin
run: |
docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-admin:latest -f app/admin/Dockerfile .
docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-admin:latest

- name: Build and Push poller
run: |
docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-poller:latest -f app/poller/Dockerfile .
docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-poller:latest

- name: Build and Push migrator
run: |
docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-migrator:latest -f app/admin/Dockerfile .
docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-migrator:latest

- name: Deploy on remote VPS
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd jeopardybot
git pull
docker-compose -f prod.docker-compose.yml pull
docker-compose -f prod.docker-compose.yml up -d
23 changes: 21 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
run:
python main.py
migrations:
alembic revision --autogenerate

migrate:
alembic upgrade head

db: migrate migrations migrate

dumpdata:
python -m app.fixtures.fixtures dump ./app/fixtures/data.json

loaddata:
python -m app.fixtures.fixtures load ./app/fixtures/data.json

down:
docker compose down

up:
docker compose up --build -d

reset: down up migrate loaddata

ruff:
ruff check .
Expand Down
51 changes: 51 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
[alembic]
script_location = app/core/database/migrations
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s

prepend_sys_path = .
version_path_separator = os

[post_write_hooks]
hooks = ruff_format, ruff_fix

ruff_format.type = exec
ruff_format.executable = ruff
ruff_format.options = format REVISION_SCRIPT_FILENAME

ruff_fix.type = exec
ruff_fix.executable = ruff
ruff_fix.options = check --fix REVISION_SCRIPT_FILENAME

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARNING
handlers = console
qualname =

[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
12 changes: 12 additions & 0 deletions app/admin/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM python:3.12-alpine
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /project
COPY pyproject.toml uv.lock ./

RUN uv sync --compile-bytecode --no-cache --no-dev
ENV PATH="/project/.venv/bin:$PATH"

COPY . .

CMD python -m app.admin.main
File renamed without changes.
86 changes: 86 additions & 0 deletions app/admin/accessor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from typing import cast

from sqlalchemy import insert, select
from sqlalchemy.orm import selectinload

from app.admin.models import AdminModel
from app.bot.models import QuestionModel, ThemeModel
from app.core.accessor_base import BaseAccessor


class AdminAccessor(BaseAccessor):
async def connect(self, *args, **kwargs) -> None:
await self.create_admin(
email=self.app.config.admin.login,
password=self.app.config.admin.password,
)

async def get_by_email(self, email: str) -> AdminModel | None:
query = select(AdminModel).filter(AdminModel.email == email)
return await self.scalar(query)

async def get_by_id(self, admin_id: int) -> AdminModel | None:
query = select(AdminModel).filter(AdminModel.id == admin_id)
return (await self.execute(query)).scalar_one_or_none()

async def create_admin(self, email: str, password: str) -> AdminModel:
async with self.app.database.session() as session:
query = select(AdminModel).where(AdminModel.email == email)
existing = (await session.execute(query)).scalar_one_or_none()

if existing:
return existing

admin = AdminModel(email=email)
admin.set_password(password)
session.add(admin)
await session.commit()
return admin


class ThemeAccessor(BaseAccessor):
async def get_theme_by_id(self, theme_id: int) -> ThemeModel | None:
exp = (
select(ThemeModel)
.where(ThemeModel.id == theme_id)
.options(selectinload(ThemeModel.questions))
)
return await self.scalar(exp)

async def get_all_themes(self) -> list[ThemeModel]:
exp = select(ThemeModel).options(selectinload(ThemeModel.questions))
return list(await self.scalars(exp))

async def get_all_questions(self) -> list[QuestionModel]:
exp = select(QuestionModel)
return list(await self.scalars(exp))

async def create_theme(self, title: str) -> ThemeModel:
exp = insert(ThemeModel).values(title=title).returning(ThemeModel)
return cast(ThemeModel, await self.scalar(exp))

async def get_question_by_id(self, question_id: int) -> QuestionModel | None:
exp = (
select(QuestionModel)
.where(QuestionModel.id == question_id)
.options(selectinload(QuestionModel.theme))
)
return await self.scalar(exp)

async def get_questions_by_theme(self, theme_id: int) -> list[QuestionModel]:
exp = (
select(QuestionModel)
.where(QuestionModel.theme_id == theme_id)
.options(selectinload(QuestionModel.theme))
)
return list(await self.scalars(exp))

async def create_question(
self, text: str, answer: str, hard_level: int, theme_id: int
) -> QuestionModel:
exp = (
insert(QuestionModel)
.values(text=text, answer=answer, hard_level=hard_level, theme_id=theme_id)
.returning(QuestionModel)
)
return cast(QuestionModel, await self.scalar(exp))
11 changes: 11 additions & 0 deletions app/admin/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from aiohttp.web import run_app

from app.app import setup_app

if __name__ == "__main__":
app = setup_app()

app.database.connect()
app.on_startup.append(app.accessors.admin_accessor.connect)

run_app(app)
21 changes: 21 additions & 0 deletions app/admin/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from aiohttp.web_exceptions import HTTPUnauthorized
from aiohttp_session import get_session
from app.app import app


class AuthRequiredMixin:
async def _iter(self):
session = await get_session(request=self.request)
admin_email = session.get("admin_email", None)

if admin_email is None:
raise HTTPUnauthorized()

admin = await app.accessors.admin_accessor.get_by_email(admin_email)

if admin is None:
raise HTTPUnauthorized()

self.request.admin = admin

return await super()._iter()
19 changes: 19 additions & 0 deletions app/admin/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from hashlib import sha256

from sqlalchemy import Column, Integer, String

from app.core.database.sqlalchemy_base import BaseModel


class AdminModel(BaseModel):
__tablename__ = "admin_model"

id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String, nullable=False, unique=True)
password = Column(String, nullable=True)

def check_password(self, password: str) -> bool:
return self.password == sha256(password.encode("utf-8")).hexdigest()

def set_password(self, password: str) -> None:
self.password = sha256(password.encode("utf-8")).hexdigest()
18 changes: 18 additions & 0 deletions app/admin/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import typing

if typing.TYPE_CHECKING:
from app.app import Application


def setup_routes(app: "Application"):
from app.admin.views import (
AdminCurrentView,
AdminLoginView,
QuestionsView,
ThemesView,
)

app.router.add_view("/admin/current", AdminCurrentView)
app.router.add_view("/admin/login", AdminLoginView)
app.router.add_view("/admin/questions", QuestionsView)
app.router.add_view("/admin/themes", ThemesView)
61 changes: 61 additions & 0 deletions app/admin/schemes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from pydantic import BaseModel


class AdminSchema(BaseModel):
email: str
password: str

class Config:
from_attributes = True


class AdminResponseSchema(BaseModel):
id: int
email: str

class Config:
from_attributes = True


class OkResponseSchema(BaseModel):
status: str
data: dict

class Config:
from_attributes = True


class ThemeSchema(BaseModel):
title: str

class Config:
from_attributes = True


class ThemeResponseSchema(BaseModel):
id: int
title: str

class Config:
from_attributes = True


class QuestionSchema(BaseModel):
text: str
answer: str
hard_level: int
theme_id: int

class Config:
from_attributes = True


class QuestionResponseSchema(BaseModel):
id: int
text: str
answer: str
hard_level: int
theme_id: int

class Config:
from_attributes = True
Loading
Loading