Enhance Telegram bot with database integration and API features
- Added SQLite database support with Alembic for migrations. - Implemented FastAPI for HTTP API to manage duties. - Updated configuration to include database URL and HTTP port. - Created entrypoint script for Docker to handle migrations and permissions. - Expanded command handlers to register users and display duties. - Developed a web application for calendar display of duties. - Included necessary Pydantic schemas and SQLAlchemy models for data handling. - Updated requirements.txt to include new dependencies for FastAPI and SQLAlchemy.
This commit is contained in:
@@ -1 +1,4 @@
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
DATABASE_URL=sqlite:///data/duty_teller.db
|
||||
MINI_APP_BASE_URL=
|
||||
HTTP_PORT=8080
|
||||
|
||||
33
Dockerfile
33
Dockerfile
@@ -1,18 +1,37 @@
|
||||
# Multi-stage: builder installs deps; runtime copies only site-packages and app code.
|
||||
# Single image for both dev and prod; Compose files differentiate behavior.
|
||||
|
||||
# --- Stage 1: builder (dependencies only) ---
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# --- Stage 2: runtime (minimal final image) ---
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install gosu (drop privileges in entrypoint)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed packages and console scripts from builder (no requirements.txt, no pip layer)
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# Application code
|
||||
COPY config.py main.py ./
|
||||
COPY config.py main.py alembic.ini entrypoint.sh ./
|
||||
COPY db/ ./db/
|
||||
COPY api/ ./api/
|
||||
COPY handlers/ ./handlers/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY webapp/ ./webapp/
|
||||
|
||||
# Run as non-root
|
||||
RUN adduser --disabled-password --gecos "" botuser && chown -R botuser:botuser /app
|
||||
USER botuser
|
||||
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
||||
RUN adduser --disabled-password --gecos "" botuser \
|
||||
&& mkdir -p /app/data && chown -R botuser:botuser /app
|
||||
|
||||
# Entrypoint runs as root: fix /app/data ownership (for volume mount), run migrations, then exec as botuser
|
||||
ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
39
alembic.ini
Normal file
39
alembic.ini
Normal file
@@ -0,0 +1,39 @@
|
||||
# Alembic config; url is set in env.py from config
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
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
|
||||
50
alembic/env.py
Normal file
50
alembic/env.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Alembic env: use config DATABASE_URL and db.models.Base."""
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
from alembic import context
|
||||
|
||||
load_dotenv()
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from db.models import Base
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
connect_args = {"check_same_thread": False} if "sqlite" in database_url else {}
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
engine = create_engine(database_url, connect_args=connect_args)
|
||||
with engine.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
44
alembic/versions/001_initial_users_and_duties.py
Normal file
44
alembic/versions/001_initial_users_and_duties.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Initial users and duties tables
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2025-02-17
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("telegram_user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("full_name", sa.Text(), nullable=False),
|
||||
sa.Column("username", sa.Text(), nullable=True),
|
||||
sa.Column("first_name", sa.Text(), nullable=True),
|
||||
sa.Column("last_name", sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("telegram_user_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"duties",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("start_at", sa.Text(), nullable=False),
|
||||
sa.Column("end_at", sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("duties")
|
||||
op.drop_table("users")
|
||||
1
api/__init__.py
Normal file
1
api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# HTTP API for Mini App
|
||||
47
api/app.py
Normal file
47
api/app.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""FastAPI app: /api/duties and static webapp."""
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from db.session import get_session
|
||||
from db.repository import get_duties
|
||||
from db.schemas import DutyWithUser
|
||||
|
||||
app = FastAPI(title="Duty Teller API")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/duties", response_model=list[DutyWithUser])
|
||||
def list_duties(
|
||||
from_date: str = Query(..., description="ISO date YYYY-MM-DD"),
|
||||
to_date: str = Query(..., description="ISO date YYYY-MM-DD"),
|
||||
) -> list[DutyWithUser]:
|
||||
session = get_session(config.DATABASE_URL)
|
||||
try:
|
||||
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
return [
|
||||
DutyWithUser(
|
||||
id=duty.id,
|
||||
user_id=duty.user_id,
|
||||
start_at=duty.start_at,
|
||||
end_at=duty.end_at,
|
||||
full_name=full_name,
|
||||
)
|
||||
for duty, full_name in rows
|
||||
]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
webapp_path = Path(__file__).resolve().parent.parent / "webapp"
|
||||
if webapp_path.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Load configuration from environment. Fail fast if BOT_TOKEN is missing."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -8,3 +9,8 @@ load_dotenv()
|
||||
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||
if not BOT_TOKEN:
|
||||
raise SystemExit("BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather.")
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
|
||||
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
|
||||
DATA_DIR = Path(__file__).resolve().parent / "data"
|
||||
|
||||
BIN
data/duty_teller.db
Normal file
BIN
data/duty_teller.db
Normal file
Binary file not shown.
11
db/__init__.py
Normal file
11
db/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Database layer: SQLAlchemy models, Pydantic schemas, repository, init."""
|
||||
from db.models import Base, User, Duty
|
||||
from db.schemas import UserCreate, UserInDb, DutyCreate, DutyInDb, DutyWithUser
|
||||
from db.session import get_engine, get_session_factory, get_session
|
||||
from db.repository import get_or_create_user, get_duties, insert_duty
|
||||
|
||||
|
||||
def init_db(database_url: str) -> None:
|
||||
"""Create tables from metadata (Alembic migrations handle schema in production)."""
|
||||
engine = get_engine(database_url)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
32
db/models.py
Normal file
32
db/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""SQLAlchemy ORM models for users and duties."""
|
||||
from sqlalchemy import ForeignKey, Integer, BigInteger, Text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all models."""
|
||||
pass
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
telegram_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
username: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
first_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
duties: Mapped[list["Duty"]] = relationship("Duty", back_populates="user")
|
||||
|
||||
|
||||
class Duty(Base):
|
||||
__tablename__ = "duties"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
start_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601
|
||||
end_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="duties")
|
||||
61
db/repository.py
Normal file
61
db/repository.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Repository: get_or_create_user, get_duties, insert_duty."""
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db.models import User, Duty
|
||||
|
||||
|
||||
def get_or_create_user(
|
||||
session: Session,
|
||||
telegram_user_id: int,
|
||||
full_name: str,
|
||||
username: str | None = None,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
) -> User:
|
||||
user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()
|
||||
if user:
|
||||
user.full_name = full_name
|
||||
user.username = username
|
||||
user.first_name = first_name
|
||||
user.last_name = last_name
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
user = User(
|
||||
telegram_user_id=telegram_user_id,
|
||||
full_name=full_name,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def get_duties(
|
||||
session: Session,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
"""Return list of (Duty, full_name) overlapping the given date range (ISO date strings)."""
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
.filter(Duty.start_at <= to_date, Duty.end_at >= from_date)
|
||||
)
|
||||
return list(q.all())
|
||||
|
||||
|
||||
def insert_duty(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
start_at: str,
|
||||
end_at: str,
|
||||
) -> Duty:
|
||||
duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at)
|
||||
session.add(duty)
|
||||
session.commit()
|
||||
session.refresh(duty)
|
||||
return duty
|
||||
43
db/schemas.py
Normal file
43
db/schemas.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Pydantic schemas for API and validation."""
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
full_name: str
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
telegram_user_id: int
|
||||
|
||||
|
||||
class UserInDb(UserBase):
|
||||
id: int
|
||||
telegram_user_id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DutyBase(BaseModel):
|
||||
user_id: int
|
||||
start_at: str # ISO 8601
|
||||
end_at: str # ISO 8601
|
||||
|
||||
|
||||
class DutyCreate(DutyBase):
|
||||
pass
|
||||
|
||||
|
||||
class DutyInDb(DutyBase):
|
||||
id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DutyWithUser(DutyInDb):
|
||||
"""Duty with full_name for calendar display."""
|
||||
full_name: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
31
db/session.py
Normal file
31
db/session.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""SQLAlchemy engine and session factory."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from db.models import Base
|
||||
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
|
||||
def get_engine(database_url: str):
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
database_url,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in database_url else {},
|
||||
echo=False,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory(database_url: str) -> sessionmaker[Session]:
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
engine = get_engine(database_url)
|
||||
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
return _SessionLocal
|
||||
|
||||
|
||||
def get_session(database_url: str) -> Session:
|
||||
return get_session_factory(database_url)()
|
||||
@@ -5,8 +5,15 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
env_file: .env
|
||||
restart: always
|
||||
ports:
|
||||
- "${HTTP_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- duty_data:/app/data
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
duty_data: {}
|
||||
|
||||
11
entrypoint.sh
Normal file
11
entrypoint.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
# Ensure data dir exists and is writable by botuser (volume may be root-owned)
|
||||
mkdir -p /app/data
|
||||
chown botuser:botuser /app/data
|
||||
# Apply Alembic migrations (runs as root, creates DB in /app/data)
|
||||
alembic upgrade head
|
||||
# Ensure new DB file is owned by botuser so the app can write
|
||||
chown -R botuser:botuser /app/data
|
||||
# Run the app as botuser
|
||||
exec gosu botuser "$@"
|
||||
@@ -1,19 +1,59 @@
|
||||
"""Command handlers: /start, /help (and placeholder for more)."""
|
||||
from telegram import Update
|
||||
"""Command handlers: /start, /help; /start registers user and shows Calendar button."""
|
||||
import asyncio
|
||||
|
||||
import config
|
||||
from telegram import Update, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup
|
||||
from telegram.ext import CommandHandler, ContextTypes
|
||||
|
||||
from db.session import get_session
|
||||
from db.repository import get_or_create_user
|
||||
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if update.message:
|
||||
await update.message.reply_text("Hello! I'm your bot. Use /help to see available commands.")
|
||||
if not update.message:
|
||||
return
|
||||
user = update.effective_user
|
||||
if not user:
|
||||
return
|
||||
full_name = " ".join(filter(None, [user.first_name or "", user.last_name or ""])).strip() or "User"
|
||||
telegram_user_id = user.id
|
||||
username = user.username
|
||||
first_name = user.first_name
|
||||
last_name = user.last_name
|
||||
|
||||
def do_get_or_create() -> None:
|
||||
session = get_session(config.DATABASE_URL)
|
||||
try:
|
||||
get_or_create_user(
|
||||
session,
|
||||
telegram_user_id=telegram_user_id,
|
||||
full_name=full_name,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(None, do_get_or_create)
|
||||
|
||||
text = "Привет! Я бот календаря дежурств. Используй /help для списка команд."
|
||||
if config.MINI_APP_BASE_URL:
|
||||
keyboard = ReplyKeyboardMarkup(
|
||||
[[KeyboardButton("📅 Календарь", web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"))]],
|
||||
resize_keyboard=True,
|
||||
)
|
||||
await update.message.reply_text(text, reply_markup=keyboard)
|
||||
else:
|
||||
await update.message.reply_text(text)
|
||||
|
||||
|
||||
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
"Available commands:\n"
|
||||
"/start - Start the bot\n"
|
||||
"/help - Show this help"
|
||||
"Доступные команды:\n"
|
||||
"/start — Начать и открыть календарь\n"
|
||||
"/help — Показать эту справку"
|
||||
)
|
||||
|
||||
|
||||
|
||||
26
main.py
26
main.py
@@ -1,5 +1,7 @@
|
||||
"""Single entry point: build Application, register handlers, run polling."""
|
||||
"""Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint."""
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import config
|
||||
from telegram.ext import ApplicationBuilder
|
||||
@@ -13,10 +15,30 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_uvicorn(web_app, port: int) -> None:
|
||||
"""Run uvicorn in a dedicated thread with its own event loop."""
|
||||
import uvicorn
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(web_app, host="0.0.0.0", port=port, log_level="info"),
|
||||
)
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = ApplicationBuilder().token(config.BOT_TOKEN).build()
|
||||
register_handlers(app)
|
||||
logger.info("Bot starting (polling)...")
|
||||
|
||||
from api.app import app as web_app
|
||||
t = threading.Thread(
|
||||
target=_run_uvicorn,
|
||||
args=(web_app, config.HTTP_PORT),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
|
||||
app.run_polling(allowed_updates=["message"])
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
python-telegram-bot[job-queue]>=22.0,<23.0
|
||||
python-dotenv>=1.0,<2.0
|
||||
fastapi>=0.115,<1.0
|
||||
uvicorn[standard]>=0.32,<1.0
|
||||
sqlalchemy>=2.0,<3.0
|
||||
alembic>=1.14,<2.0
|
||||
pydantic>=2.0,<3.0
|
||||
|
||||
145
webapp/app.js
Normal file
145
webapp/app.js
Normal file
@@ -0,0 +1,145 @@
|
||||
(function () {
|
||||
const MONTHS = [
|
||||
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
|
||||
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
|
||||
];
|
||||
|
||||
let current = new Date();
|
||||
const calendarEl = document.getElementById("calendar");
|
||||
const monthTitleEl = document.getElementById("monthTitle");
|
||||
const dutyListEl = document.getElementById("dutyList");
|
||||
const loadingEl = document.getElementById("loading");
|
||||
const errorEl = document.getElementById("error");
|
||||
|
||||
function isoDate(d) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function firstDayOfMonth(d) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
function lastDayOfMonth(d) {
|
||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||
}
|
||||
|
||||
function getMonday(d) {
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
return new Date(d.getFullYear(), d.getMonth(), diff);
|
||||
}
|
||||
|
||||
async function fetchDuties(from, to) {
|
||||
const base = window.location.origin;
|
||||
const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error("Ошибка загрузки");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function renderCalendar(year, month, dutiesByDate) {
|
||||
const first = firstDayOfMonth(new Date(year, month, 1));
|
||||
const last = lastDayOfMonth(new Date(year, month, 1));
|
||||
const start = getMonday(first);
|
||||
const today = isoDate(new Date());
|
||||
|
||||
calendarEl.innerHTML = "";
|
||||
let d = new Date(start);
|
||||
const cells = 42;
|
||||
for (let i = 0; i < cells; i++) {
|
||||
const key = isoDate(d);
|
||||
const isOther = d.getMonth() !== month;
|
||||
const dayDuties = dutiesByDate[key] || [];
|
||||
const isToday = key === today;
|
||||
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "");
|
||||
cell.innerHTML =
|
||||
"<span class=\"num\">" + d.getDate() + "</span>" +
|
||||
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return x.full_name; }).join(", ") + "</span>" : "");
|
||||
calendarEl.appendChild(cell);
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
monthTitleEl.textContent = MONTHS[month] + " " + year;
|
||||
}
|
||||
|
||||
function renderDutyList(duties) {
|
||||
if (duties.length === 0) {
|
||||
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
|
||||
return;
|
||||
}
|
||||
const grouped = {};
|
||||
duties.forEach(function (d) {
|
||||
const date = d.start_at.slice(0, 10);
|
||||
if (!grouped[date]) grouped[date] = [];
|
||||
grouped[date].push(d);
|
||||
});
|
||||
const dates = Object.keys(grouped).sort();
|
||||
let html = "";
|
||||
dates.forEach(function (date) {
|
||||
const list = grouped[date];
|
||||
html += "<h2>" + date + "</h2>";
|
||||
list.forEach(function (d) {
|
||||
const start = d.start_at.slice(11, 16);
|
||||
const end = d.end_at.slice(11, 16);
|
||||
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " – " + end + "</div></div>";
|
||||
});
|
||||
});
|
||||
dutyListEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function dutiesByDate(duties) {
|
||||
const byDate = {};
|
||||
duties.forEach(function (d) {
|
||||
const start = new Date(d.start_at);
|
||||
const end = new Date(d.end_at);
|
||||
for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) {
|
||||
const key = isoDate(t);
|
||||
if (!byDate[key]) byDate[key] = [];
|
||||
byDate[key].push(d);
|
||||
}
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.hidden = false;
|
||||
loadingEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function loadMonth() {
|
||||
loadingEl.classList.remove("hidden");
|
||||
errorEl.hidden = true;
|
||||
const from = isoDate(firstDayOfMonth(current));
|
||||
const to = isoDate(lastDayOfMonth(current));
|
||||
try {
|
||||
const duties = await fetchDuties(from, to);
|
||||
const byDate = dutiesByDate(duties);
|
||||
renderCalendar(current.getFullYear(), current.getMonth(), byDate);
|
||||
renderDutyList(duties);
|
||||
} catch (e) {
|
||||
showError(e.message || "Не удалось загрузить данные.");
|
||||
return;
|
||||
}
|
||||
loadingEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("prevMonth").addEventListener("click", function () {
|
||||
current.setMonth(current.getMonth() - 1);
|
||||
loadMonth();
|
||||
});
|
||||
document.getElementById("nextMonth").addEventListener("click", function () {
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
loadMonth();
|
||||
});
|
||||
|
||||
loadMonth();
|
||||
})();
|
||||
26
webapp/index.html
Normal file
26
webapp/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Календарь дежурств</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<button type="button" class="nav" id="prevMonth" aria-label="Предыдущий месяц">‹</button>
|
||||
<h1 class="title" id="monthTitle"></h1>
|
||||
<button type="button" class="nav" id="nextMonth" aria-label="Следующий месяц">›</button>
|
||||
</header>
|
||||
<div class="weekdays">
|
||||
<span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span>
|
||||
</div>
|
||||
<div class="calendar" id="calendar"></div>
|
||||
<div class="duty-list" id="dutyList"></div>
|
||||
<div class="loading" id="loading">Загрузка…</div>
|
||||
<div class="error" id="error" hidden></div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
152
webapp/style.css
Normal file
152
webapp/style.css
Normal file
@@ -0,0 +1,152 @@
|
||||
:root {
|
||||
--bg: #1a1b26;
|
||||
--surface: #24283b;
|
||||
--text: #c0caf5;
|
||||
--muted: #565f89;
|
||||
--accent: #7aa2f7;
|
||||
--duty: #9ece6a;
|
||||
--today: #bb9af7;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 12px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.day.other-month {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
background: var(--today);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.day.has-duty .num {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.day-duties {
|
||||
font-size: 0.6rem;
|
||||
color: var(--duty);
|
||||
margin-top: 2px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.duty-list {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.duty-list h2 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.duty-item {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
border-left: 3px solid var(--duty);
|
||||
}
|
||||
|
||||
.duty-item .name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.duty-item .time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f7768e;
|
||||
}
|
||||
|
||||
.error[hidden], .loading.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
Reference in New Issue
Block a user