mirror of
https://github.com/Shadik23/telegram_greeting_bot.git
synced 2025-12-10 13:29:45 +05:00
feat: Add GitHub Actions CI and professional README
This commit is contained in:
17
.devcontainer/devcontainer.json
Normal file
17
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Telegram Bot Dev Environment",
|
||||||
|
"dockerComposeFile": "../docker-compose.yml",
|
||||||
|
"service": "bot",
|
||||||
|
"workspaceFolder": "/app",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {
|
||||||
|
"python.defaultInterpreterPath": "/usr/local/bin/python"
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-azuretools.vscode-docker"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN
|
||||||
|
|
||||||
|
POSTGRES_DB=greeting_db
|
||||||
|
POSTGRES_USER=user
|
||||||
|
POSTGRES_PASSWORD=password
|
||||||
|
|
||||||
|
WEB_USERNAME=admin
|
||||||
|
WEB_PASSWORD=secure_password
|
||||||
6
.env.test
Normal file
6
.env.test
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
BOT_TOKEN=dummy_token_for_testing
|
||||||
|
POSTGRES_DB=greeting_db_test
|
||||||
|
POSTGRES_USER=user
|
||||||
|
POSTGRES_PASSWORD=password
|
||||||
|
WEB_USERNAME=admin
|
||||||
|
WEB_PASSWORD=supersecret
|
||||||
52
.github/workflows/main.yml
vendored
Normal file
52
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# .github/workflows/main.yml
|
||||||
|
|
||||||
|
name: CI Pipeline
|
||||||
|
|
||||||
|
# --- Triggers ---
|
||||||
|
# This workflow runs on pushes to the 'main' branch and on pull requests targeting 'main'.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
# --- Jobs ---
|
||||||
|
# We define a single job called 'build-and-test'.
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
# The type of virtual machine to run the job on. 'ubuntu-latest' is a standard choice.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# --- Steps ---
|
||||||
|
# A sequence of tasks that will be executed as part of the job.
|
||||||
|
steps:
|
||||||
|
# Step 1: Check out the repository code
|
||||||
|
# This action checks out your repository under $GITHUB_WORKSPACE, so your job can access it.
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Step 2: Set up Docker Buildx
|
||||||
|
# This is a required step for building multi-platform images and using advanced Docker features.
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# Step 3: Run the test suite
|
||||||
|
# This is the core of our CI. It executes the same script we used locally.
|
||||||
|
# We provide a dummy BOT_TOKEN because the tests don't need a real one.
|
||||||
|
- name: Run integration tests
|
||||||
|
run: ./run_tests.sh
|
||||||
|
env:
|
||||||
|
# We need to provide the BOT_TOKEN variable, as the script expects it,
|
||||||
|
# but it can be a dummy value since it's only used for the bot service
|
||||||
|
# which is idle during tests.
|
||||||
|
BOT_TOKEN: "dummy_token_for_ci"
|
||||||
|
|
||||||
|
# Step 4 (Optional but recommended): Clean up Docker resources
|
||||||
|
# This ensures that any lingering containers or images are removed to free up space on the GitHub runner.
|
||||||
|
- name: Docker cleanup
|
||||||
|
if: always() # This step runs even if previous steps fail.
|
||||||
|
run: |
|
||||||
|
docker-compose down -v --remove-orphans
|
||||||
|
docker system prune -af --volumes
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.vscode/
|
||||||
|
venv/
|
||||||
|
.DS_Store
|
||||||
|
db_data/
|
||||||
132
README.md
Normal file
132
README.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Telegram Greeting Bot
|
||||||
|
|
||||||
|
[](https://github.com/averageencoreenjoer/telegram_greeting_bot/actions/workflows/main.yml)
|
||||||
|
|
||||||
|
A fully containerized Telegram bot project featuring a dynamic, database-driven greeting message that can be updated in real-time via a simple web admin panel. This project is built with a senior-level approach, emphasizing clean architecture, separation of concerns, containerization, and automated testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Dynamic Greetings**: The `/start` command greeting is fetched directly from a PostgreSQL database.
|
||||||
|
- **Real-time Updates**: No need to restart the bot. Changes made in the admin panel are reflected instantly.
|
||||||
|
- **Mini Admin Panel**: A secure, password-protected web interface (built with FastAPI) to update the greeting text.
|
||||||
|
- **Fully Containerized**: Uses Docker and Docker Compose to run the entire stack (Bot, Web, DB) with a single command.
|
||||||
|
- **Automated Integration Tests**: A robust test suite using Pytest ensures the entire system works correctly.
|
||||||
|
- **CI/CD Ready**: Includes a GitHub Actions workflow to automatically run tests on every push and pull request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python"/>
|
||||||
|
<img src="https://img.shields.io/badge/Telegram-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram"/>
|
||||||
|
<img src="https://img.shields.io/badge/FastAPI-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="FastAPI"/>
|
||||||
|
<img src="https://img.shields.io/badge/PostgreSQL-4169E1?style=for-the-badge&logo=postgresql&logoColor=white" alt="PostgreSQL"/>
|
||||||
|
<img src="https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white" alt="Docker"/>
|
||||||
|
<img src="https://img.shields.io/badge/Pytest-0A9B71?style=for-the-badge&logo=pytest&logoColor=white" alt="Pytest"/>
|
||||||
|
<img src="https://img.shields.io/badge/GitHub_Actions-2088FF?style=for-the-badge&logo=github-actions&logoColor=white" alt="GitHub Actions"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
- **Bot Framework**: `aiogram`
|
||||||
|
- **Web Framework**: `FastAPI`
|
||||||
|
- **Database**: `PostgreSQL` with `asyncpg` driver
|
||||||
|
- **Containerization**: `Docker` & `Docker Compose`
|
||||||
|
- **Testing**: `Pytest`, `pytest-asyncio`, `httpx`
|
||||||
|
- **CI/CD**: `GitHub Actions`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://www.docker.com/products/docker-desktop/) installed and running.
|
||||||
|
- A Telegram Bot Token obtained from [@BotFather](https://t.me/BotFather).
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/averageencoreenjoer/telegram_greeting_bot.git
|
||||||
|
cd telegram_greeting_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment Variables
|
||||||
|
|
||||||
|
Create a local environment file by copying the example.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, open the `.env` file and fill in your details:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# .env
|
||||||
|
|
||||||
|
# 1. Your unique Telegram Bot Token from @BotFather
|
||||||
|
BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u1234567
|
||||||
|
|
||||||
|
# 2. Credentials for the database (can be left as default for local development)
|
||||||
|
POSTGRES_DB=greeting_db
|
||||||
|
POSTGRES_USER=user
|
||||||
|
POSTGRES_PASSWORD=password
|
||||||
|
|
||||||
|
# 3. Credentials for the web admin panel (you will use these to log in)
|
||||||
|
WEB_USERNAME=admin
|
||||||
|
WEB_PASSWORD=supersecret
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the Application
|
||||||
|
|
||||||
|
Launch the entire stack using Docker Compose.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The services will start in detached mode (`-d`). You can view the logs with `docker-compose logs -f`.
|
||||||
|
|
||||||
|
### 4. Interact with the System
|
||||||
|
|
||||||
|
- **Telegram Bot**: Find your bot on Telegram and send the `/start` command. It will reply with the default greeting.
|
||||||
|
- **Admin Panel**: Open your browser and navigate to `http://localhost:8000/docs`.
|
||||||
|
- You will be prompted for authentication. Use the `WEB_USERNAME` and `WEB_PASSWORD` from your `.env` file.
|
||||||
|
- Use the `/update_greeting` endpoint to change the greeting text.
|
||||||
|
- Go back to Telegram and send `/start` again. The bot will now use the new greeting!
|
||||||
|
|
||||||
|
### 5. Stopping the Application
|
||||||
|
|
||||||
|
To stop all services, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
To stop services and remove the database volume (delete all data), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Running Tests
|
||||||
|
|
||||||
|
This project comes with a fully automated integration test suite that spins up a separate, isolated environment to validate the system's behavior.
|
||||||
|
|
||||||
|
To run the tests, execute the provided script from the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run_tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Start the services using a dedicated test database (`.env.test`).
|
||||||
|
2. Wait for the database to be fully ready.
|
||||||
|
3. Install test dependencies.
|
||||||
|
4. Run the Pytest suite, which makes real API calls.
|
||||||
|
5. Shut down and clean up all test-related containers and volumes.
|
||||||
|
|
||||||
|
This is the same script that runs in the GitHub Actions CI pipeline.
|
||||||
14
bot/Dockerfile
Normal file
14
bot/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.9-slim-buster
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем файл зависимостей из папки bot
|
||||||
|
COPY bot/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копируем исходный код из папки bot/src
|
||||||
|
COPY bot/src/. /app/
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
3
bot/requirements.txt
Normal file
3
bot/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
aiogram==3.*
|
||||||
|
asyncpg==0.*
|
||||||
|
python-dotenv==1.*
|
||||||
0
bot/src/__init__.py
Normal file
0
bot/src/__init__.py
Normal file
17
bot/src/config.py
Normal file
17
bot/src/config.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class BotConfig:
|
||||||
|
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "greeting_db")
|
||||||
|
DB_USER = os.getenv("DB_USER", "user")
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "password")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_db_url(cls):
|
||||||
|
return f"postgresql://{cls.DB_USER}:{cls.DB_PASSWORD}@{cls.DB_HOST}/{cls.DB_NAME}"
|
||||||
|
|
||||||
28
bot/src/db.py
Normal file
28
bot/src/db.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import asyncpg
|
||||||
|
from config import BotConfig
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db_pool():
|
||||||
|
return await asyncpg.create_pool(BotConfig.get_db_url())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_greeting(pool):
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT greeting_text FROM greetings ORDER BY id LIMIT 1")
|
||||||
|
return row['greeting_text'] if row else "Привет! Добро пожаловать!"
|
||||||
|
|
||||||
|
|
||||||
|
async def set_greeting(pool, new_text: str):
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE greetings SET greeting_text = $1 WHERE id = 1",
|
||||||
|
new_text
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO greetings (id, greeting_text)
|
||||||
|
VALUES (1, $1)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
""",
|
||||||
|
new_text
|
||||||
|
)
|
||||||
11
bot/src/handlers.py
Normal file
11
bot/src/handlers.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from aiogram import Router, types
|
||||||
|
from aiogram.filters import CommandStart
|
||||||
|
from db import get_greeting
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def command_start_handler(message: types.Message, db_pool) -> None:
|
||||||
|
greeting_text = await get_greeting(db_pool)
|
||||||
|
await message.answer(greeting_text + "!!!")
|
||||||
0
bot/src/keyboards.py
Normal file
0
bot/src/keyboards.py
Normal file
29
bot/src/main.py
Normal file
29
bot/src/main.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from config import BotConfig
|
||||||
|
from handlers import router
|
||||||
|
from db import get_db_pool
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
bot = Bot(token=BotConfig.BOT_TOKEN)
|
||||||
|
dp = Dispatcher()
|
||||||
|
|
||||||
|
db_pool = await get_db_pool()
|
||||||
|
dp["db_pool"] = db_pool
|
||||||
|
|
||||||
|
dp.include_router(router)
|
||||||
|
|
||||||
|
logging.info("Starting bot...")
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
|
||||||
|
await db_pool.close()
|
||||||
|
logging.info("Bot stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
8
db/init.sql
Normal file
8
db/init.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS greetings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
greeting_text TEXT NOT NULL DEFAULT 'Привет! Добро пожаловать!'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO greetings (greeting_text)
|
||||||
|
SELECT 'Привет! Добро пожаловать!'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM greetings WHERE id = 1);
|
||||||
5
docker-compose.override.yml
Normal file
5
docker-compose.override.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
command: tail -f /dev/null
|
||||||
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:13
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "pg_isready && psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -c 'SELECT 1'" ]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: bot/Dockerfile
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
BOT_TOKEN: ${BOT_TOKEN}
|
||||||
|
DB_HOST: db
|
||||||
|
DB_NAME: ${POSTGRES_DB}
|
||||||
|
DB_USER: ${POSTGRES_USER}
|
||||||
|
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./bot/src:/app
|
||||||
|
tty: true
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: web/Dockerfile
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_NAME: ${POSTGRES_DB}
|
||||||
|
DB_USER: ${POSTGRES_USER}
|
||||||
|
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
WEB_USERNAME: ${WEB_USERNAME}
|
||||||
|
WEB_PASSWORD: ${WEB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./web/src:/app
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
tty: true
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
4
requirements.test.txt
Normal file
4
requirements.test.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pytest
|
||||||
|
pytest-asyncio
|
||||||
|
httpx
|
||||||
|
python-dotenv
|
||||||
33
run_tests.sh
Executable file
33
run_tests.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
set -e
|
||||||
|
|
||||||
|
echo "--- 1. Запуск тестового окружения (используя .env.test) ---"
|
||||||
|
docker-compose --env-file .env.test up -d --build --force-recreate
|
||||||
|
|
||||||
|
echo "--- 2. Ожидание готовности Базы Данных ---"
|
||||||
|
|
||||||
|
retries=15
|
||||||
|
while [ $retries -gt 0 ]; do
|
||||||
|
docker-compose exec -T db pg_isready -U user -d greeting_db_test && break
|
||||||
|
retries=$((retries-1))
|
||||||
|
echo "Ожидание БД... Осталось попыток: $retries"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ $retries -eq 0 ]; then
|
||||||
|
echo "!!! База Данных не запустилась вовремя. Проверьте логи: docker-compose logs db"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "--- База Данных готова! ---"
|
||||||
|
|
||||||
|
echo "--- 3. Установка Python-зависимостей для тестов ---"
|
||||||
|
|
||||||
|
pip3 install -r requirements.test.txt
|
||||||
|
|
||||||
|
echo "--- 4. ЗАПУСК ТЕСТОВ ---"
|
||||||
|
pytest tests/
|
||||||
|
|
||||||
|
TEST_EXIT_CODE=$?
|
||||||
|
|
||||||
|
echo "--- 5. Очистка: остановка и удаление тестового окружения ---"
|
||||||
|
docker-compose --env-file .env.test down -v
|
||||||
|
|
||||||
|
exit $TEST_EXIT_CODE
|
||||||
30
tests/test_integration.py
Normal file
30
tests/test_integration.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv(dotenv_path='.env.test')
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
ADMIN_USER = os.getenv("WEB_USERNAME")
|
||||||
|
ADMIN_PASSWORD = os.getenv("WEB_PASSWORD")
|
||||||
|
AUTH = (ADMIN_USER, ADMIN_PASSWORD)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_greeting_cycle():
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response_before = await client.get(BASE_URL + "/", auth=AUTH)
|
||||||
|
assert response_before.status_code == 200
|
||||||
|
assert response_before.json()["current_greeting_text"] == "Привет! Добро пожаловать!"
|
||||||
|
|
||||||
|
new_text = "Автотест прошел успешно!"
|
||||||
|
update_payload = {"new_greeting_text": new_text}
|
||||||
|
response_update = await client.post(BASE_URL + "/update_greeting", json=update_payload, auth=AUTH)
|
||||||
|
assert response_update.status_code == 200
|
||||||
|
assert response_update.json()["current_greeting_text"] == new_text
|
||||||
|
|
||||||
|
response_after = await client.get(BASE_URL + "/", auth=AUTH)
|
||||||
|
assert response_after.status_code == 200
|
||||||
|
assert response_after.json()["current_greeting_text"] == new_text
|
||||||
|
|
||||||
14
web/Dockerfile
Normal file
14
web/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.9-slim-buster
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем файл зависимостей из папки web
|
||||||
|
COPY web/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копируем исходный код из папки web/src
|
||||||
|
COPY web/src/. /app/
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
4
web/requirements.txt
Normal file
4
web/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.*
|
||||||
|
uvicorn==0.*
|
||||||
|
asyncpg==0.*
|
||||||
|
python-dotenv==1.*
|
||||||
0
web/src/__init__.py
Normal file
0
web/src/__init__.py
Normal file
18
web/src/config.py
Normal file
18
web/src/config.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class WebConfig:
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "greeting_db")
|
||||||
|
DB_USER = os.getenv("DB_USER", "user")
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "password")
|
||||||
|
WEB_USERNAME = os.getenv("WEB_USERNAME", "admin")
|
||||||
|
WEB_PASSWORD = os.getenv("WEB_PASSWORD", "secure_password")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_db_url(cls):
|
||||||
|
return f"postgresql://{cls.DB_USER}:{cls.DB_PASSWORD}@{cls.DB_HOST}/{cls.DB_NAME}"
|
||||||
|
|
||||||
94
web/src/main.py
Normal file
94
web/src/main.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import asyncpg
|
||||||
|
from fastapi import FastAPI, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
from config import WebConfig
|
||||||
|
from schemas import GreetingUpdate, GreetingResponse
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Greeting Bot Admin Panel")
|
||||||
|
security = HTTPBasic()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
db_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db_pool():
|
||||||
|
global db_pool
|
||||||
|
if db_pool is None:
|
||||||
|
for i in range(5):
|
||||||
|
try:
|
||||||
|
logging.info(f"Attempting to connect to DB (try {i+1}/5)...")
|
||||||
|
db_pool = await asyncpg.create_pool(WebConfig.get_db_url())
|
||||||
|
logging.info("DB pool created successfully.")
|
||||||
|
break
|
||||||
|
except (ConnectionRefusedError, asyncpg.exceptions.InvalidPasswordError) as e:
|
||||||
|
logging.warning(f"DB connection failed: {e}. Retrying in 3 seconds...")
|
||||||
|
if i == 4:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
return db_pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_username(credentials: HTTPBasicCredentials = Depends(security)):
|
||||||
|
correct_username = secrets.compare_digest(credentials.username, WebConfig.WEB_USERNAME)
|
||||||
|
correct_password = secrets.compare_digest(credentials.password, WebConfig.WEB_PASSWORD)
|
||||||
|
if not (correct_username and correct_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
|
)
|
||||||
|
return credentials.username
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
await get_db_pool()
|
||||||
|
logging.info("Web service started and DB pool initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
global db_pool
|
||||||
|
if db_pool:
|
||||||
|
await db_pool.close()
|
||||||
|
logging.info("DB pool closed.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_model=GreetingResponse)
|
||||||
|
async def get_greeting_text(username: str = Depends(get_current_username)):
|
||||||
|
pool = await get_db_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT greeting_text FROM greetings ORDER BY id LIMIT 1")
|
||||||
|
if row:
|
||||||
|
return GreetingResponse(current_greeting_text=row['greeting_text'])
|
||||||
|
else:
|
||||||
|
return GreetingResponse(current_greeting_text="Привет! Добро пожаловать!")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/update_greeting", response_model=GreetingResponse)
|
||||||
|
async def update_greeting_text(
|
||||||
|
greeting_update: GreetingUpdate,
|
||||||
|
username: str = Depends(get_current_username)
|
||||||
|
):
|
||||||
|
pool = await get_db_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing_greeting = await conn.fetchrow("SELECT id FROM greetings WHERE id = 1")
|
||||||
|
|
||||||
|
if existing_greeting:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE greetings SET greeting_text = $1 WHERE id = 1",
|
||||||
|
greeting_update.new_greeting_text
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO greetings (id, greeting_text) VALUES (1, $1)",
|
||||||
|
greeting_update.new_greeting_text
|
||||||
|
)
|
||||||
|
logging.info(f"Greeting updated to: {greeting_update.new_greeting_text}")
|
||||||
|
return GreetingResponse(current_greeting_text=greeting_update.new_greeting_text)
|
||||||
|
|
||||||
9
web/src/schemas.py
Normal file
9
web/src/schemas.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class GreetingUpdate(BaseModel):
|
||||||
|
new_greeting_text: str
|
||||||
|
|
||||||
|
|
||||||
|
class GreetingResponse(BaseModel):
|
||||||
|
current_greeting_text: str
|
||||||
Reference in New Issue
Block a user