Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d7af2d18 | ||
|
|
896c26bd57 |
+7
-22
@@ -1,25 +1,10 @@
|
|||||||
# Python
|
.env
|
||||||
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.egg-info/
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
env/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea/
|
||||||
# Project
|
.vscode/
|
||||||
data/*.json
|
*.egg-info/
|
||||||
logs/
|
dist/
|
||||||
backups/
|
build/
|
||||||
.env
|
|
||||||
|
|
||||||
# Backups
|
|
||||||
*.bak
|
|
||||||
|
|
||||||
# Runtime logs
|
|
||||||
*.log
|
|
||||||
|
|||||||
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.14 (PythonProject) (2)" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredPackages">
|
||||||
|
<list>
|
||||||
|
<option value="aiogram" />
|
||||||
|
<option value="python-dotenv" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.14 (PythonProject) (2)" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (PythonProject) (2)" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/PythonProject.iml" filepath="$PROJECT_DIR$/.idea/PythonProject.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
CMD ["python", "-m", "app.main"]
|
|
||||||
+11
-39
@@ -1,4 +1,3 @@
|
|||||||
"""Configuration for SPA Telegram Bot."""
|
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -6,55 +5,28 @@ load_dotenv()
|
|||||||
|
|
||||||
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
|
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
|
||||||
ADMIN_CHAT_ID = int(os.getenv("ADMIN_CHAT_ID", "0")) if os.getenv("ADMIN_CHAT_ID", "") else 0
|
|
||||||
|
|
||||||
if not BOT_TOKEN:
|
if not BOT_TOKEN:
|
||||||
raise ValueError("BOT_TOKEN not found in environment")
|
raise ValueError("BOT_TOKEN not found in environment")
|
||||||
|
|
||||||
if not ADMIN_PASSWORD:
|
if not ADMIN_PASSWORD:
|
||||||
raise ValueError("ADMIN_PASSWORD not found in environment")
|
raise ValueError("ADMIN_PASSWORD not found in environment (no default allowed)")
|
||||||
|
|
||||||
USE_POSTGRES = bool(os.getenv("DB_HOST", ""))
|
# Services catalog
|
||||||
|
|
||||||
# Rich service catalog
|
|
||||||
SERVICES = {
|
SERVICES = {
|
||||||
"service_massage": {
|
"service_massage": "💪 Массаж",
|
||||||
"key": "service_massage",
|
"service_spa": "🎈 SPA",
|
||||||
"name": "\u041c\u0430\u0441\u0441\u0430\u0436",
|
"service_cosmetology": "💄 Косметология",
|
||||||
"emoji": "\U0001f4aa",
|
|
||||||
"desc": "\u0420\u0430\u0441\u0441\u043b\u0430\u0431\u043b\u044f\u044e\u0449\u0438\u0439 \u0438 \u043b\u0435\u0447\u0435\u0431\u043d\u044b\u0439 \u043c\u0430\u0441\u0441\u0430\u0436.\n\u0421\u043d\u0438\u043c\u0430\u0435\u0442 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0443\u043b\u0443\u0447\u0448\u0430\u0435\u0442 \u043a\u0440\u043e\u0432\u043e\u043e\u0431\u0440\u0430\u0449\u0435\u043d\u0438\u0435 \u0438 \u0434\u0430\u0440\u0438\u0442 \u043b\u0451\u0433\u043a\u043e\u0441\u0442\u044c \u0432\u043e \u0432\u0441\u0451\u043c \u0442\u0435\u043b\u0435.",
|
|
||||||
"image": "assets/massage.png",
|
|
||||||
"duration": 60,
|
|
||||||
"price": 2500,
|
|
||||||
"category": "body",
|
|
||||||
},
|
|
||||||
"service_spa": {
|
|
||||||
"key": "service_spa",
|
|
||||||
"name": "SPA-\u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441",
|
|
||||||
"emoji": "\U0001f388",
|
|
||||||
"desc": "\u041a\u043e\u043c\u043f\u043b\u0435\u043a\u0441\u043d\u044b\u0435 SPA-\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b: \u043e\u0431\u0451\u0440\u0442\u044b\u0432\u0430\u043d\u0438\u044f, \u0430\u0440\u043e\u043c\u0430\u0442\u0435\u0440\u0430\u043f\u0438\u044f, \u0433\u0438\u0434\u0440\u043e\u043c\u0430\u0441\u0441\u0430\u0436.\n\u041f\u043e\u043b\u043d\u043e\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0438 \u0440\u0435\u043b\u0430\u043a\u0441\u0430\u0446\u0438\u044f.",
|
|
||||||
"image": "assets/spa.png",
|
|
||||||
"duration": 90,
|
|
||||||
"price": 4000,
|
|
||||||
"category": "spa",
|
|
||||||
},
|
|
||||||
"service_cosmetology": {
|
|
||||||
"key": "service_cosmetology",
|
|
||||||
"name": "\u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f",
|
|
||||||
"emoji": "\U0001f484",
|
|
||||||
"desc": "\u041f\u0440\u043e\u0444\u0435\u0441\u0441\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0443\u0445\u043e\u0434 \u0437\u0430 \u043b\u0438\u0446\u043e\u043c: \u0447\u0438\u0441\u0442\u043a\u0430, \u043f\u0438\u043b\u0438\u043d\u0433, \u043c\u0430\u0441\u043a\u0438, \u043b\u0438\u043c\u0444\u043e\u0434\u0440\u0435\u043d\u0430\u0436.\n\u0421\u0438\u044f\u044e\u0449\u0430\u044f \u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u0430\u044f \u043a\u043e\u0436\u0430.",
|
|
||||||
"image": "assets/cosmetology.png",
|
|
||||||
"duration": 45,
|
|
||||||
"price": 2000,
|
|
||||||
"category": "face",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Masters list
|
||||||
MASTERS = {
|
MASTERS = {
|
||||||
"master_anna": {"key": "master_anna", "name": "\U0001f478 \u0410\u043d\u043d\u0430", "specialty": ["massage", "spa"]},
|
"master_anna": "👸 Анна",
|
||||||
"master_maria": {"key": "master_maria", "name": "\U0001f483 \u041c\u0430\u0440\u0438\u044f", "specialty": ["cosmetology"]},
|
"master_maria": "💃 Мария",
|
||||||
"master_alex": {"key": "master_alex", "name": "\U0001f9d1\U0000200d\U00002696\U0000fe0f \u0410\u043b\u0435\u043a\u0441\u0435\u0439", "specialty": ["massage", "spa"]},
|
"master_alex": "🧑⚖️ Алексей",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Available time slots
|
||||||
TIME_SLOTS = [
|
TIME_SLOTS = [
|
||||||
"09:00", "10:00", "11:00",
|
"09:00", "10:00", "11:00",
|
||||||
"12:00", "13:00", "14:00",
|
"12:00", "13:00", "14:00",
|
||||||
|
|||||||
+14
-10
@@ -1,17 +1,13 @@
|
|||||||
"""Fallback JSON-based storage (sync).
|
|
||||||
Used only when DB_HOST is not set in .env.
|
|
||||||
"""
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
DB_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
DB_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
||||||
DB_DIR.mkdir(exist_ok=True)
|
DB_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
USERS_FILE = DB_DIR / "users.json"
|
USERS_FILE = DB_DIR / "users.json"
|
||||||
BOOKINGS_FILE = DB_DIR / "bookings.json"
|
BOOKINGS_FILE = DB_DIR / "bookings.json"
|
||||||
|
|
||||||
from app.config import USE_POSTGRES
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path, default):
|
def _load_json(path, default):
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@@ -28,7 +24,6 @@ def _save_json(path, data):
|
|||||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
# --- Users ---
|
|
||||||
def load_users():
|
def load_users():
|
||||||
return _load_json(USERS_FILE, {})
|
return _load_json(USERS_FILE, {})
|
||||||
|
|
||||||
@@ -48,19 +43,19 @@ def set_user(user_id, data):
|
|||||||
save_users(users)
|
save_users(users)
|
||||||
|
|
||||||
|
|
||||||
# --- Bookings ---
|
|
||||||
def load_bookings():
|
def load_bookings():
|
||||||
return _load_json(BOOKINGS_FILE, [])
|
return _load_json(BOOKINGS_FILE, [])
|
||||||
|
|
||||||
|
|
||||||
def save_bookings(bookings):
|
def save_bookings(bookings):
|
||||||
|
from app.logger import logger
|
||||||
logger.debug("save_bookings: saving {n} bookings", n=len(bookings))
|
logger.debug("save_bookings: saving {n} bookings", n=len(bookings))
|
||||||
_save_json(BOOKINGS_FILE, bookings)
|
_save_json(BOOKINGS_FILE, bookings)
|
||||||
|
|
||||||
|
|
||||||
def create_booking(user_id, service, master, date, time, user_name=""):
|
def create_booking(user_id, service, master, date, time, user_name=""):
|
||||||
logger.debug("create_booking called: uid={uid}, svc={svc}, mst={mst}, dt={dt}, tm={tm}",
|
from app.logger import logger
|
||||||
uid=user_id, svc=service, mst=master, dt=date, tm=time)
|
logger.debug("create_booking called: uid={uid}, svc={svc}, master={mst}, date={dt}, time={tm}", uid=user_id, svc=service, mst=master, dt=date, tm=time)
|
||||||
bookings = load_bookings()
|
bookings = load_bookings()
|
||||||
booking = {
|
booking = {
|
||||||
"id": len(bookings) + 1,
|
"id": len(bookings) + 1,
|
||||||
@@ -93,6 +88,15 @@ def get_user_bookings(user_id):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_booking(booking_id):
|
||||||
|
"""Return a booking by id regardless of status, or None."""
|
||||||
|
bookings = load_bookings()
|
||||||
|
for b in bookings:
|
||||||
|
if b["id"] == booking_id:
|
||||||
|
return b
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def cancel_booking(booking_id):
|
def cancel_booking(booking_id):
|
||||||
bookings = load_bookings()
|
bookings = load_bookings()
|
||||||
for b in bookings:
|
for b in bookings:
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Migrate data from JSON files to PostgreSQL."""
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.db.postgres import PostgresDB
|
|
||||||
|
|
||||||
DB_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
|
||||||
USERS_FILE = DB_DIR / "users.json"
|
|
||||||
BOOKINGS_FILE = DB_DIR / "bookings.json"
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate():
|
|
||||||
print("Starting migration from JSON to PostgreSQL...")
|
|
||||||
await PostgresDB.init_pool()
|
|
||||||
|
|
||||||
# Migrate users
|
|
||||||
if USERS_FILE.exists():
|
|
||||||
with open(USERS_FILE) as f:
|
|
||||||
users = json.load(f)
|
|
||||||
for uid, data in users.items():
|
|
||||||
name = data.get("name", "")
|
|
||||||
phone = data.get("phone", "")
|
|
||||||
await PostgresDB.set_user(int(uid), name, phone)
|
|
||||||
print(f"Migrated {len(users)} users")
|
|
||||||
|
|
||||||
# Migrate bookings
|
|
||||||
if BOOKINGS_FILE.exists():
|
|
||||||
with open(BOOKINGS_FILE) as f:
|
|
||||||
bookings = json.load(f)
|
|
||||||
for b in bookings:
|
|
||||||
# Re-create via raw INSERT to preserve IDs
|
|
||||||
pool = await PostgresDB.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO bookings (id, user_id, user_name, service, master, date, time, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
""", b.get("id"), b.get("user_id"), b.get("user_name", ""),
|
|
||||||
b.get("service"), b.get("master"), b.get("date"),
|
|
||||||
b.get("time"), b.get("status", "active"))
|
|
||||||
print(f"Migrated {len(bookings)} bookings")
|
|
||||||
|
|
||||||
print("Migration complete!")
|
|
||||||
await PostgresDB.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(migrate())
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
"""PostgreSQL storage for SPA Telegram Bot."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
import asyncpg
|
|
||||||
from app.logger import logger
|
|
||||||
|
|
||||||
DB_HOST = os.getenv("DB_HOST", "10.0.77.9")
|
|
||||||
DB_PORT = int(os.getenv("DB_PORT", "5433"))
|
|
||||||
DB_USER = os.getenv("DB_USER", "spabot")
|
|
||||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
|
||||||
DB_NAME = os.getenv("DB_NAME", "spabot")
|
|
||||||
|
|
||||||
if not DB_PASSWORD:
|
|
||||||
raise ValueError(
|
|
||||||
"DB_PASSWORD not set in environment. "
|
|
||||||
"Set it in .env or export DB_PASSWORD=your_password"
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_CONFIG = {
|
|
||||||
"host": DB_HOST,
|
|
||||||
"port": DB_PORT,
|
|
||||||
"user": DB_USER,
|
|
||||||
"password": DB_PASSWORD,
|
|
||||||
"database": DB_NAME,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PostgresDB:
|
|
||||||
_pool: Optional[asyncpg.Pool] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def init_pool(cls):
|
|
||||||
"""Initialize connection pool."""
|
|
||||||
if cls._pool is None:
|
|
||||||
cls._pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10)
|
|
||||||
await cls._migrate()
|
|
||||||
logger.info("Postgres pool initialized (host={host}, db={db})",
|
|
||||||
host=DB_CONFIG["host"], db=DB_CONFIG["database"])
|
|
||||||
return cls._pool
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _migrate(cls):
|
|
||||||
"""Create tables if not exist."""
|
|
||||||
async with cls._pool.acquire() as conn:
|
|
||||||
await conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
user_id BIGINT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL DEFAULT '',
|
|
||||||
phone TEXT DEFAULT '',
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS bookings (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
|
||||||
user_name TEXT DEFAULT '',
|
|
||||||
service TEXT NOT NULL,
|
|
||||||
master TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
time TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bookings_user_id ON bookings(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bookings_master_date ON bookings(master, date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
|
|
||||||
""")
|
|
||||||
logger.info("Postgres migrations applied")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def close(cls):
|
|
||||||
if cls._pool:
|
|
||||||
await cls._pool.close()
|
|
||||||
cls._pool = None
|
|
||||||
logger.info("Postgres pool closed")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_pool(cls):
|
|
||||||
if cls._pool is None:
|
|
||||||
await cls.init_pool()
|
|
||||||
return cls._pool
|
|
||||||
|
|
||||||
# --- Users ---
|
|
||||||
@classmethod
|
|
||||||
async def get_user(cls, user_id: int) -> Optional[dict]:
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow("SELECT * FROM users WHERE user_id = $1", user_id)
|
|
||||||
if row:
|
|
||||||
return dict(row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def set_user(cls, user_id: int, name: str, phone: str = ""):
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO users (user_id, name, phone, updated_at)
|
|
||||||
VALUES ($1, $2, $3, NOW())
|
|
||||||
ON CONFLICT (user_id)
|
|
||||||
DO UPDATE SET name = $2, phone = $3, updated_at = NOW()
|
|
||||||
""", user_id, name, phone)
|
|
||||||
|
|
||||||
# --- Bookings ---
|
|
||||||
@classmethod
|
|
||||||
async def create_booking(cls, user_id: int, service: str, master: str,
|
|
||||||
date: str, time: str, user_name: str = "") -> dict:
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow("""
|
|
||||||
INSERT INTO bookings (user_id, user_name, service, master, date, time)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING *
|
|
||||||
""", user_id, user_name, service, master, date, time)
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_busy_times(cls, master: str, date: str) -> list:
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"SELECT time FROM bookings WHERE master = $1 AND date = $2 AND status = 'active'",
|
|
||||||
master, date
|
|
||||||
)
|
|
||||||
return [r["time"] for r in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_user_bookings(cls, user_id: int, status: str = "active") -> list:
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"SELECT * FROM bookings WHERE user_id = $1 AND status = $2 ORDER BY id DESC",
|
|
||||||
user_id, status
|
|
||||||
)
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def cancel_booking(cls, booking_id: int) -> bool:
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
"UPDATE bookings SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status = 'active'",
|
|
||||||
booking_id
|
|
||||||
)
|
|
||||||
return "UPDATE 1" in result
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def update_booking(cls, booking_id: int, service: str, master: str,
|
|
||||||
date: str, time: str) -> Optional[dict]:
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow("""
|
|
||||||
UPDATE bookings
|
|
||||||
SET service = $1, master = $2, date = $3, time = $4, updated_at = NOW()
|
|
||||||
WHERE id = $5 AND status = 'active'
|
|
||||||
RETURNING *
|
|
||||||
""", service, master, date, time, booking_id)
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_all_active_bookings(cls) -> list:
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"SELECT * FROM bookings WHERE status = 'active' ORDER BY id DESC"
|
|
||||||
)
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_booking_by_id(cls, booking_id: int) -> Optional[dict]:
|
|
||||||
pool = await cls.get_pool()
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
row = await conn.fetchrow("SELECT * FROM bookings WHERE id = $1", booking_id)
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_user_by_id(cls, user_id: int) -> Optional[dict]:
|
|
||||||
return await cls.get_user(user_id)
|
|
||||||
+5
-16
@@ -1,23 +1,12 @@
|
|||||||
"""About handler - salon info page."""
|
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
@router.message(F.text == "ℹ️ О нас")
|
||||||
@router.message(F.text == "\u2139\ufe0f \u041e \u043d\u0430\u0441")
|
|
||||||
async def about(message: Message, state: FSMContext):
|
async def about(message: Message, state: FSMContext):
|
||||||
await state.clear()
|
await state.clear() # 🔥 СБРОС FSM
|
||||||
logger.info("User {uid} opened About", uid=message.from_user.id)
|
|
||||||
text = (
|
await message.answer("Мы demo SPA бот 💆♂️")
|
||||||
"\u262f\ufe0f SPA-\u0441\u0430\u043b\u043e\u043d Demo\n\n"
|
|
||||||
"\u041c\u044b - \u0443\u044e\u0442\u043d\u044b\u0439 SPA-\u0441\u0430\u043b\u043e\u043d \u0432 \u0446\u0435\u043d\u0442\u0440\u0435 \u0433\u043e\u0440\u043e\u0434\u0430.\n\n"
|
|
||||||
"\U0001f4aa \u041f\u0440\u043e\u0444\u0435\u0441\u0441\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0435 \u043c\u0430\u0441\u0442\u0435\u0440\u0430\n"
|
|
||||||
"\U0001f9ec \u041d\u0430\u0442\u0443\u0440\u0430\u043b\u044c\u043d\u0430\u044f \u043a\u043e\u0441\u043c\u0435\u0442\u0438\u043a\u0430\n"
|
|
||||||
"\U0001f3e1 \u0423\u044e\u0442\u043d\u0430\u044f \u0430\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u0430\n\n"
|
|
||||||
"\u0420\u0430\u0431\u043e\u0442\u0430\u0435\u043c \u0435\u0436\u0435\u0434\u043d\u0435\u0432\u043d\u043e \u0441 09:00 \u0434\u043e 19:00\n\n"
|
|
||||||
"\U0001f447 \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u00bb \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f!"
|
|
||||||
)
|
|
||||||
await message.answer(text)
|
|
||||||
+134
-78
@@ -1,120 +1,176 @@
|
|||||||
"""Admin handler for spa-telegram-bot — secure with password."""
|
"""Admin handler for spa-telegram-bot — secure with password."""
|
||||||
from aiogram import Router, F
|
import hmac
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
import time
|
||||||
from aiogram.filters import Command, CommandObject
|
|
||||||
from aiogram import Bot
|
|
||||||
|
|
||||||
from app.config import USE_POSTGRES
|
from aiogram import Router, F
|
||||||
from app.config import ADMIN_PASSWORD, BOT_TOKEN
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from aiogram.filters import Command, CommandObject, StateFilter
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import StatesGroup, State
|
||||||
|
from app.db.memory import load_bookings
|
||||||
|
from app.config import ADMIN_PASSWORD
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
ADMIN_IDS = {991309145}
|
ADMIN_IDS = {991309145}
|
||||||
_approved = set()
|
# Store approved sessions — user_ids that passed password check
|
||||||
_notify_bot = None
|
_approved: dict[int, float] = {}
|
||||||
|
APPROVED_TTL_SECONDS = 60 * 60 # 1 hour
|
||||||
|
|
||||||
|
# Failed attempt tracking (per user) to slow down brute force
|
||||||
|
_failed_attempts: dict[int, list[float]] = {}
|
||||||
|
MAX_ATTEMPTS = 5
|
||||||
|
ATTEMPT_WINDOW_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
def _get_bot():
|
class AdminState(StatesGroup):
|
||||||
global _notify_bot
|
waiting_password = State()
|
||||||
if _notify_bot is None:
|
|
||||||
_notify_bot = Bot(token=BOT_TOKEN)
|
|
||||||
return _notify_bot
|
def _is_rate_limited(user_id: int) -> bool:
|
||||||
|
"""Return True if the user exceeded the allowed number of failed attempts."""
|
||||||
|
now = time.time()
|
||||||
|
attempts = [t for t in _failed_attempts.get(user_id, []) if now - t < ATTEMPT_WINDOW_SECONDS]
|
||||||
|
_failed_attempts[user_id] = attempts
|
||||||
|
return len(attempts) >= MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
|
def _register_failure(user_id: int) -> None:
|
||||||
|
_failed_attempts.setdefault(user_id, []).append(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_failures(user_id: int) -> None:
|
||||||
|
_failed_attempts.pop(user_id, None)
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int, password: str | None = None) -> bool:
|
def is_admin(user_id: int, password: str | None = None) -> bool:
|
||||||
|
"""Check if user is admin: by user_id, by password, or by approved session."""
|
||||||
if user_id in ADMIN_IDS:
|
if user_id in ADMIN_IDS:
|
||||||
return True
|
return True
|
||||||
if user_id in _approved:
|
now = time.time()
|
||||||
|
expires_at = _approved.get(user_id)
|
||||||
|
if expires_at and expires_at > now:
|
||||||
return True
|
return True
|
||||||
if password and password == ADMIN_PASSWORD:
|
if expires_at:
|
||||||
_approved.add(user_id)
|
_approved.pop(user_id, None) # expired session
|
||||||
|
if password and hmac.compare_digest(password.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")):
|
||||||
|
_approved[user_id] = now + APPROVED_TTL_SECONDS
|
||||||
|
_clear_failures(user_id)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("admin"))
|
async def _show_admin_panel(message: Message) -> None:
|
||||||
async def admin_panel(message: Message, command: CommandObject):
|
logger.info("Admin {uid} opened admin panel", uid=message.from_user.id)
|
||||||
uid = message.from_user.id
|
|
||||||
password = command.args
|
|
||||||
|
|
||||||
if not is_admin(uid, password):
|
bookings = load_bookings()
|
||||||
return
|
active = [b for b in bookings if b.get("status") == "active"]
|
||||||
|
|
||||||
logger.info("Admin {uid} opened admin panel", uid=uid)
|
|
||||||
|
|
||||||
if USE_POSTGRES:
|
|
||||||
from app.db.postgres import PostgresDB
|
|
||||||
active = await PostgresDB.get_all_active_bookings()
|
|
||||||
else:
|
|
||||||
from app.db.memory import load_bookings
|
|
||||||
bookings = load_bookings()
|
|
||||||
active = [b for b in bookings if b.get("status") == "active"]
|
|
||||||
|
|
||||||
if not active:
|
if not active:
|
||||||
await message.answer("\U0001f4cb \u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u0435\u0439 \u043d\u0435\u0442")
|
await message.answer("📋 Активных записей нет")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
for booking in active:
|
for booking in active:
|
||||||
buttons = [[
|
buttons = [[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text="\u274c \u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c",
|
text="❌ Отменить",
|
||||||
callback_data=f"admin_cancel_{booking['id']}"
|
callback_data=f"admin_cancel_{booking['id']}"
|
||||||
)
|
)
|
||||||
]]
|
]]
|
||||||
user_name = booking.get("user_name") or f"id{booking['user_id']}"
|
user_name = booking.get("user_name") or f"id{booking['user_id']}"
|
||||||
text = (
|
await message.answer(
|
||||||
"\U0001f4cb \u0417\u0430\u043f\u0438\u0441\u044c #" + str(booking['id']) + "\n"
|
f"📋 Запись #{booking['id']}\n"
|
||||||
"\U0001f464 \u041a\u043b\u0438\u0435\u043d\u0442: " + user_name + "\n"
|
f"👤 Клиент: {user_name}\n"
|
||||||
"\U0001f486 \u0423\u0441\u043b\u0443\u0433\u0430: " + booking.get('service', '?') + "\n"
|
f"💆 Услуги: {booking.get('service', '?')}\n"
|
||||||
"\U0001f464 \u041c\u0430\u0441\u0442\u0435\u0440: " + booking.get('master', '?') + "\n"
|
f"👤 Мастер: {booking.get('master', '?')}\n"
|
||||||
"\U0001f4c5 \u0414\u0430\u0442\u0430: " + booking.get('date', '?') + "\n"
|
f"📅 Дата: {booking.get('date', '?')}\n"
|
||||||
"\U0001f552 \u0412\u0440\u0435\u043c\u044f: " + booking.get('time', '?')
|
f"🕒 Время: {booking.get('time', '?')}",
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
)
|
)
|
||||||
await message.answer(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
|
||||||
|
|
||||||
|
async def _scrub_message(message: Message) -> None:
|
||||||
|
"""Delete a message that may contain a password. Best effort."""
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not delete message with password (no rights?)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("admin"))
|
||||||
|
async def admin_panel(message: Message, command: CommandObject, state: FSMContext):
|
||||||
|
uid = message.from_user.id
|
||||||
|
|
||||||
|
# Never accept the password via command arguments — it stays in chat history.
|
||||||
|
# Scrub the command message too, in case someone typed a password after /admin.
|
||||||
|
if command.args:
|
||||||
|
await _scrub_message(message)
|
||||||
|
await message.answer("🔒 Не передавайте пароль в команде — он остаётся в истории чата.")
|
||||||
|
if is_admin(uid):
|
||||||
|
await _show_admin_panel(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_admin(uid):
|
||||||
|
await _show_admin_panel(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
if _is_rate_limited(uid):
|
||||||
|
logger.warning("Admin password brute-force blocked for {uid}", uid=uid)
|
||||||
|
await message.answer("⛔ Слишком много попыток. Подождите 5 минут.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.set_state(AdminState.waiting_password)
|
||||||
|
await message.answer(
|
||||||
|
"🔐 Введите пароль администратора отдельным сообщением.\n"
|
||||||
|
"Сообщение с паролем будет удалено и не останется в истории чата."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(StateFilter(AdminState.waiting_password), F.text)
|
||||||
|
async def admin_password_input(message: Message, state: FSMContext):
|
||||||
|
uid = message.from_user.id
|
||||||
|
password = message.text or ""
|
||||||
|
|
||||||
|
# Always delete the message containing the password — do not keep it in history.
|
||||||
|
await _scrub_message(message)
|
||||||
|
|
||||||
|
if _is_rate_limited(uid):
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("⛔ Слишком много попыток. Подождите 5 минут.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_admin(uid, password=password):
|
||||||
|
await state.clear()
|
||||||
|
await _show_admin_panel(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
_register_failure(uid)
|
||||||
|
await state.clear()
|
||||||
|
logger.warning("Failed admin password attempt from {uid}", uid=uid)
|
||||||
|
await message.answer("⛔ Неверный пароль. Попробуйте ещё раз: /admin")
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("admin_cancel_"))
|
@router.callback_query(F.data.startswith("admin_cancel_"))
|
||||||
async def admin_cancel_booking(callback: CallbackQuery):
|
async def admin_cancel_booking(callback: CallbackQuery):
|
||||||
if not is_admin(callback.from_user.id):
|
if not is_admin(callback.from_user.id):
|
||||||
await callback.answer("\u26d4 \u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u0430")
|
await callback.answer("⛔ Нет доступа")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
booking_id = int(callback.data.split("_", 2)[2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
await callback.answer("Некорректные данные")
|
||||||
return
|
return
|
||||||
|
|
||||||
booking_id = int(callback.data.split("_")[2])
|
|
||||||
logger.info("Admin {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
|
logger.info("Admin {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
|
||||||
|
|
||||||
if USE_POSTGRES:
|
from app.db.memory import cancel_booking
|
||||||
from app.db.postgres import PostgresDB
|
cancel_booking(booking_id)
|
||||||
success = await PostgresDB.cancel_booking(booking_id)
|
|
||||||
booking = await PostgresDB.get_booking_by_id(booking_id) if success else None
|
|
||||||
else:
|
|
||||||
from app.db.memory import cancel_booking as json_cancel, load_bookings
|
|
||||||
success = json_cancel(booking_id)
|
|
||||||
bookings = load_bookings()
|
|
||||||
booking = next((b for b in bookings if b["id"] == booking_id), None)
|
|
||||||
|
|
||||||
if not success:
|
await callback.message.edit_text(
|
||||||
await callback.message.edit_text("\u0417\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430 \u0438\u043b\u0438 \u0443\u0436\u0435 \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430")
|
f"✅ Запись #{booking_id} отменена админом"
|
||||||
await callback.answer()
|
)
|
||||||
return
|
await callback.answer("Запись отменена")
|
||||||
|
|
||||||
await callback.message.edit_text("\u2705 \u0417\u0430\u043f\u0438\u0441\u044c #" + str(booking_id) + " \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430 \u0430\u0434\u043c\u0438\u043d\u043e\u043c")
|
|
||||||
|
|
||||||
if booking:
|
|
||||||
try:
|
|
||||||
bot = _get_bot()
|
|
||||||
user_id = booking["user_id"]
|
|
||||||
notify_text = (
|
|
||||||
"\u26a0\ufe0f \u0412\u0430\u0448\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u043e\u043c.\n\n"
|
|
||||||
"\U0001f486 \u0423\u0441\u043b\u0443\u0433\u0430: " + booking.get('service', '?') + "\n"
|
|
||||||
"\U0001f464 \u041c\u0430\u0441\u0442\u0435\u0440: " + booking.get('master', '?') + "\n"
|
|
||||||
"\U0001f4c5 \u0414\u0430\u0442\u0430: " + booking.get('date', '?') + "\n"
|
|
||||||
"\U0001f552 \u0412\u0440\u0435\u043c\u044f: " + booking.get('time', '?') + "\n\n"
|
|
||||||
"\U0001f4cb \u0427\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f \u0441\u043d\u043e\u0432\u0430, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f\u00bb"
|
|
||||||
)
|
|
||||||
await bot.send_message(user_id, notify_text)
|
|
||||||
logger.info("Notified user {uid} about cancellation", uid=user_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to notify user: {err}", err=e)
|
|
||||||
|
|
||||||
await callback.answer()
|
|
||||||
|
|||||||
+26
-112
@@ -1,14 +1,12 @@
|
|||||||
"""Booking handler - full FSM flow for service reservation."""
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.filters import StateFilter
|
from aiogram.filters import StateFilter
|
||||||
from aiogram.types import Message, CallbackQuery, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery
|
||||||
from app.keyboards.services import get_services_keyboard, get_extra_services_keyboard
|
from app.keyboards.services import get_services_keyboard, get_extra_services_keyboard
|
||||||
from app.keyboards.masters import get_masters_keyboard
|
from app.keyboards.masters import get_masters_keyboard
|
||||||
from app.states.booking import BookingState
|
from app.states.booking import BookingState
|
||||||
from app.config import SERVICES as ALL_SERVICES, ADMIN_CHAT_ID
|
|
||||||
from app.services.booking_service import (
|
from app.services.booking_service import (
|
||||||
create_booking,
|
create_booking,
|
||||||
update_booking,
|
update_booking,
|
||||||
@@ -34,17 +32,6 @@ def _services_to_str(data):
|
|||||||
return main
|
return main
|
||||||
|
|
||||||
|
|
||||||
def _make_service_card(key):
|
|
||||||
"""Recreate the service card text for 'back' navigation from a photo card."""
|
|
||||||
svc = ALL_SERVICES[key]
|
|
||||||
return (
|
|
||||||
svc["emoji"] + " " + svc["name"] + "\n\n"
|
|
||||||
+ svc["desc"] + "\n\n"
|
|
||||||
+ "\u23f1 Продолжительность: " + str(svc["duration"]) + " мин\n"
|
|
||||||
+ "\U0001f4b0 Цена: " + str(svc["price"]) + "\u20bd"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# отмена записи на любом шаге
|
# отмена записи на любом шаге
|
||||||
@router.message(F.text.lower() == "отмена")
|
@router.message(F.text.lower() == "отмена")
|
||||||
async def cancel(message: Message, state: FSMContext):
|
async def cancel(message: Message, state: FSMContext):
|
||||||
@@ -66,14 +53,19 @@ async def start_booking(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# выбор первой услуги (через кнопку "📅 Записаться")
|
# выбор первой услуги
|
||||||
@router.callback_query(
|
@router.callback_query(
|
||||||
StateFilter(BookingState.service),
|
StateFilter(BookingState.service),
|
||||||
F.data.startswith("service_")
|
F.data.startswith("service_")
|
||||||
)
|
)
|
||||||
async def choose_service(callback: CallbackQuery, state: FSMContext):
|
async def choose_service(callback: CallbackQuery, state: FSMContext):
|
||||||
svc_data = ALL_SERVICES[callback.data]
|
from app.config import SERVICES
|
||||||
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
|
|
||||||
|
service_name = SERVICES.get(callback.data)
|
||||||
|
if not service_name:
|
||||||
|
await callback.answer("Услуга не найдена")
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name)
|
logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name)
|
||||||
|
|
||||||
await state.update_data(service=service_name, extra_services=[])
|
await state.update_data(service=service_name, extra_services=[])
|
||||||
@@ -94,7 +86,10 @@ async def choose_service(callback: CallbackQuery, state: FSMContext):
|
|||||||
F.data.startswith("extra_")
|
F.data.startswith("extra_")
|
||||||
)
|
)
|
||||||
async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
|
async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
|
||||||
|
from app.config import SERVICES
|
||||||
|
|
||||||
if callback.data == "extra_done":
|
if callback.data == "extra_done":
|
||||||
|
# Go to master selection
|
||||||
await state.set_state(BookingState.master)
|
await state.set_state(BookingState.master)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
"👤 Выберите специалиста:",
|
"👤 Выберите специалиста:",
|
||||||
@@ -103,12 +98,12 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Toggle extra service
|
||||||
actual_key = callback.data.replace("extra_", "", 1)
|
actual_key = callback.data.replace("extra_", "", 1)
|
||||||
svc_data = ALL_SERVICES.get(actual_key)
|
service_name = SERVICES.get(actual_key)
|
||||||
if not svc_data:
|
if not service_name:
|
||||||
await callback.answer("Услуга не найдена")
|
await callback.answer("Услуга не найдена")
|
||||||
return
|
return
|
||||||
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
|
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
extra = data.get("extra_services", [])
|
extra = data.get("extra_services", [])
|
||||||
@@ -142,8 +137,11 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
|
|||||||
async def choose_master(callback: CallbackQuery, state: FSMContext):
|
async def choose_master(callback: CallbackQuery, state: FSMContext):
|
||||||
from app.config import MASTERS
|
from app.config import MASTERS
|
||||||
|
|
||||||
master_data = MASTERS[callback.data]
|
master_name = MASTERS.get(callback.data)
|
||||||
master_name = master_data["name"] if isinstance(master_data, dict) else master_data
|
if not master_name:
|
||||||
|
await callback.answer("Мастер не найден")
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name)
|
logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name)
|
||||||
|
|
||||||
await state.update_data(master=master_name)
|
await state.update_data(master=master_name)
|
||||||
@@ -232,14 +230,6 @@ async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext):
|
|||||||
date = data.get("date", "?")
|
date = data.get("date", "?")
|
||||||
time = data.get("time", "?")
|
time = data.get("time", "?")
|
||||||
|
|
||||||
from app.db.postgres import PostgresDB as _PG
|
|
||||||
from app.config import USE_POSTGRES as _UPG
|
|
||||||
if _UPG:
|
|
||||||
_u = await _PG.get_user(callback.from_user.id)
|
|
||||||
if not _u:
|
|
||||||
_n = callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id)
|
|
||||||
await _PG.set_user(callback.from_user.id, _n)
|
|
||||||
|
|
||||||
if await is_slot_busy(master, date, time):
|
if await is_slot_busy(master, date, time):
|
||||||
logger.warning("Slot already busy: {mst} {dt} {tm}", mst=master, dt=date, tm=time)
|
logger.warning("Slot already busy: {mst} {dt} {tm}", mst=master, dt=date, tm=time)
|
||||||
await callback.message.edit_text("❌ Это время уже занято. Пожалуйста, выберите другое время.")
|
await callback.message.edit_text("❌ Это время уже занято. Пожалуйста, выберите другое время.")
|
||||||
@@ -258,26 +248,6 @@ async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext):
|
|||||||
cleanup_cancelled()
|
cleanup_cancelled()
|
||||||
result_text = "✅ Запись успешно создана"
|
result_text = "✅ Запись успешно создана"
|
||||||
|
|
||||||
# Notify admin about new booking
|
|
||||||
try:
|
|
||||||
if ADMIN_CHAT_ID:
|
|
||||||
from aiogram import Bot
|
|
||||||
from app.config import BOT_TOKEN
|
|
||||||
admin_bot = Bot(token=BOT_TOKEN)
|
|
||||||
user_str = callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id)
|
|
||||||
await admin_bot.send_message(
|
|
||||||
ADMIN_CHAT_ID,
|
|
||||||
"🆕 Новая запись!\n\n"
|
|
||||||
f"👤 Клиент: {user_str} (id={callback.from_user.id})\n"
|
|
||||||
f"💆 Услуги: {services_str}\n"
|
|
||||||
f"👤 Мастер: {master}\n"
|
|
||||||
f"📅 Дата: {date}\n"
|
|
||||||
f"🕒 Время: {time}"
|
|
||||||
)
|
|
||||||
await admin_bot.session.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to notify admin: {error}", error=e)
|
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await callback.message.edit_text(result_text)
|
await callback.message.edit_text(result_text)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -309,80 +279,24 @@ async def edit_booking_callback(callback: CallbackQuery, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# прямая запись с карточки услуги (без предварительного выбора)
|
|
||||||
@router.callback_query(F.data.startswith("service_"))
|
|
||||||
async def quick_book_service(callback: CallbackQuery, state: FSMContext):
|
|
||||||
svc_data = ALL_SERVICES[callback.data]
|
|
||||||
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
|
|
||||||
svc_key = callback.data
|
|
||||||
logger.info("User {uid} quick book from card: {srv}", uid=callback.from_user.id, srv=service_name)
|
|
||||||
|
|
||||||
await state.clear()
|
|
||||||
await state.update_data(
|
|
||||||
service=service_name,
|
|
||||||
extra_services=[],
|
|
||||||
_from_card=True,
|
|
||||||
_card_key=svc_key
|
|
||||||
)
|
|
||||||
await state.set_state(BookingState.extra_services)
|
|
||||||
|
|
||||||
text = (
|
|
||||||
"✅ Выбрано: " + service_name + "\n\n"
|
|
||||||
"Хотите добавить ещё услугу?\n"
|
|
||||||
"Нажмите нужную или «Готово»:"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Карточка с фото — удаляем и отправляем новое сообщение
|
|
||||||
try:
|
|
||||||
await callback.message.delete()
|
|
||||||
await callback.message.answer(text, reply_markup=get_extra_services_keyboard([]))
|
|
||||||
except Exception:
|
|
||||||
await callback.message.edit_text(text, reply_markup=get_extra_services_keyboard([]))
|
|
||||||
|
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "go_back")
|
@router.callback_query(F.data == "go_back")
|
||||||
async def go_back_handler(callback: CallbackQuery, state: FSMContext):
|
async def go_back_handler(callback: CallbackQuery, state: FSMContext):
|
||||||
current_state = await state.get_state()
|
current_state = await state.get_state()
|
||||||
|
|
||||||
if current_state == BookingState.extra_services:
|
if current_state == BookingState.extra_services:
|
||||||
# Назад к списку услуг — проверяем, с карточки пришёл или из меню
|
# Back to first service
|
||||||
await state.set_state(BookingState.service)
|
await state.set_state(BookingState.service)
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
data.pop("extra_services", None)
|
data.pop("extra_services", None)
|
||||||
data.pop("service", None)
|
data.pop("service", None)
|
||||||
from_card = data.pop("_from_card", False)
|
|
||||||
card_key = data.pop("_card_key", None)
|
|
||||||
await state.set_data(data)
|
await state.set_data(data)
|
||||||
|
await callback.message.edit_text(
|
||||||
if from_card and card_key and card_key in ALL_SERVICES:
|
"Выберите услугу:",
|
||||||
# Возвращаем карточку с фото
|
reply_markup=get_services_keyboard()
|
||||||
svc = ALL_SERVICES[card_key]
|
)
|
||||||
card_text = _make_service_card(card_key)
|
|
||||||
book_btn = InlineKeyboardMarkup(inline_keyboard=[[
|
|
||||||
InlineKeyboardButton(text="\U0001f4c5 Записаться", callback_data=card_key)
|
|
||||||
]])
|
|
||||||
try:
|
|
||||||
img = FSInputFile(svc["image"])
|
|
||||||
await callback.message.answer_photo(photo=img, caption=card_text, reply_markup=book_btn)
|
|
||||||
try:
|
|
||||||
await callback.message.delete()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"Выберите услугу:",
|
|
||||||
reply_markup=get_services_keyboard()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
"Выберите услугу:",
|
|
||||||
reply_markup=get_services_keyboard()
|
|
||||||
)
|
|
||||||
|
|
||||||
elif current_state == BookingState.master:
|
elif current_state == BookingState.master:
|
||||||
# Назад к доп. услугам
|
# Back to extra services
|
||||||
await state.set_state(BookingState.extra_services)
|
await state.set_state(BookingState.extra_services)
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
extra = data.get("extra_services", [])
|
extra = data.get("extra_services", [])
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
"""Contacts handler - address, phone, social."""
|
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
@router.message(F.text == "📞 Контакты")
|
||||||
@router.message(F.text == "\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b")
|
|
||||||
async def contacts(message: Message, state: FSMContext):
|
async def contacts(message: Message, state: FSMContext):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
logger.info("User {uid} requested contacts", uid=message.from_user.id)
|
|
||||||
text = (
|
await message.answer("📍 Москва\n☎ +7 999 999 99 99")
|
||||||
"\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b\n\n"
|
|
||||||
"\U0001f4cd \u0433. \u041c\u043e\u0441\u043a\u0432\u0430, \u0443\u043b. \u0426\u0432\u0435\u0442\u043e\u0447\u043d\u0430\u044f, 15\n\n"
|
|
||||||
"\U0001f4de +7 (999) 123-45-67\n\n"
|
|
||||||
"\U0001f4e7 info@spa-demo.ru\n\n"
|
|
||||||
"\U0001f310 @spa_demo_salon\n\n"
|
|
||||||
"\u0420\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b:\n"
|
|
||||||
"\u041f\u043d-\u0412\u0441: 09:00 - 19:00"
|
|
||||||
)
|
|
||||||
await message.answer(text)
|
|
||||||
+79
-65
@@ -1,95 +1,109 @@
|
|||||||
"""My bookings handler - view, edit, cancel bookings."""
|
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
from app.config import USE_POSTGRES
|
from app.db.memory import get_user_bookings, get_booking, cancel_booking, update_booking
|
||||||
from app.services.booking_service import get_user_bookings, cancel_booking
|
from app.keyboards.menu import menu
|
||||||
from app.keyboards.my_booking import get_booking_actions
|
from app.keyboards.my_booking import get_booking_actions
|
||||||
from app.keyboards.services import get_services_keyboard
|
|
||||||
from app.states.booking import BookingState
|
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "\U0001f4c4 \u041c\u043e\u0438 \u0437\u0430\u043f\u0438\u0441\u0438")
|
@router.message(
|
||||||
async def my_bookings(message: Message):
|
F.text == "📄 Мои записи"
|
||||||
|
)
|
||||||
|
async def my_bookings(
|
||||||
|
message: Message
|
||||||
|
):
|
||||||
logger.info("User {uid} opened My Bookings", uid=message.from_user.id)
|
logger.info("User {uid} opened My Bookings", uid=message.from_user.id)
|
||||||
try:
|
try:
|
||||||
bookings = await get_user_bookings(message.from_user.id)
|
bookings = get_user_bookings(
|
||||||
|
message.from_user.id
|
||||||
|
)
|
||||||
logger.debug("Found {n} bookings", n=len(bookings))
|
logger.debug("Found {n} bookings", n=len(bookings))
|
||||||
|
|
||||||
if not bookings:
|
if not bookings:
|
||||||
await message.answer("У вас нет активных записей")
|
await message.answer(
|
||||||
|
"У вас нет активных записей"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Group all bookings into one message
|
for booking in bookings:
|
||||||
lines = []
|
svc = booking["service"]
|
||||||
for b in bookings:
|
mst = booking["master"]
|
||||||
bid = b["id"]
|
dat = booking["date"]
|
||||||
svc = b["service"]
|
tim = booking["time"]
|
||||||
mst = b["master"]
|
text = "💆 " + svc + "\n"
|
||||||
dat = b["date"]
|
text += "👤 " + mst + "\n"
|
||||||
tim = b["time"]
|
text += "📅 " + dat + "\n"
|
||||||
lines.append(
|
text += "🕒 " + tim
|
||||||
f"#{bid} \U0001f486 {svc}\n"
|
|
||||||
f"\U0001f464 {mst}\n"
|
|
||||||
f"\U0001f4c5 {dat}\U0001f552 {tim}"
|
|
||||||
)
|
|
||||||
|
|
||||||
full_text = "\u0423 \u0432\u0430\u0441 " + str(len(bookings)) + " \u0437\u0430\u043f\u0438\u0441\u0438:\n\n" + "\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n".join(lines)
|
|
||||||
await message.answer(full_text)
|
|
||||||
|
|
||||||
# Send action buttons for each booking
|
|
||||||
for b in bookings:
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"#{b['id']}: {b['service']}",
|
text,
|
||||||
reply_markup=get_booking_actions(b["id"])
|
reply_markup=get_booking_actions(
|
||||||
|
booking["id"]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error in my_bookings: {error}", error=e)
|
logger.exception("Error in my_bookings: {error}", error=e)
|
||||||
await message.answer("Произошла ошибка. Попробуйте позже.")
|
await message.answer(
|
||||||
|
"Произошла ошибка. Попробуйте позже."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("cancel_booking_"))
|
@router.callback_query(
|
||||||
|
F.data.startswith("cancel_booking_")
|
||||||
|
)
|
||||||
async def cancel_booking_handler(callback: CallbackQuery):
|
async def cancel_booking_handler(callback: CallbackQuery):
|
||||||
booking_id = int(callback.data.split("_")[2])
|
try:
|
||||||
logger.info("User {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
|
booking_id = int(callback.data.split("_", 2)[2])
|
||||||
await cancel_booking(booking_id)
|
except (ValueError, IndexError):
|
||||||
await callback.message.edit_text("\u2705 Запись отменена")
|
await callback.answer("Некорректные данные")
|
||||||
await callback.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("edit_booking_"))
|
|
||||||
async def edit_booking_handler(callback: CallbackQuery, state: FSMContext):
|
|
||||||
booking_id = int(callback.data.split("_")[2])
|
|
||||||
|
|
||||||
if USE_POSTGRES:
|
|
||||||
from app.db.postgres import PostgresDB
|
|
||||||
booking = await PostgresDB.get_booking_by_id(booking_id)
|
|
||||||
else:
|
|
||||||
from app.db.memory import load_bookings
|
|
||||||
bookings = load_bookings()
|
|
||||||
booking = next((b for b in bookings if b["id"] == booking_id), None)
|
|
||||||
|
|
||||||
if not booking:
|
|
||||||
await callback.message.edit_text("Запись не найдена")
|
|
||||||
await callback.answer()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
await state.set_data({
|
booking = get_booking(booking_id)
|
||||||
"editing_booking_id": booking_id,
|
if not booking or booking.get("user_id") != callback.from_user.id:
|
||||||
"service": booking.get("service", ""),
|
logger.warning("User {uid} tried to cancel foreign booking #{bid}", uid=callback.from_user.id, bid=booking_id)
|
||||||
"master": booking.get("master", ""),
|
await callback.answer("⛔ Это не ваша запись")
|
||||||
"date": booking.get("date", ""),
|
return
|
||||||
"time": booking.get("time", ""),
|
|
||||||
})
|
|
||||||
|
|
||||||
await state.set_state(BookingState.service)
|
logger.info("User {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
|
||||||
|
cancel_booking(
|
||||||
|
booking_id
|
||||||
|
)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
"\u270f\ufe0f Редактирование записи #" + str(booking_id) + "\n\nВыберите новую услугу:",
|
"Запись отменена"
|
||||||
reply_markup=get_services_keyboard()
|
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
F.data.startswith("edit_booking_")
|
||||||
|
)
|
||||||
|
async def edit_booking_handler(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
state: FSMContext
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
booking_id = int(callback.data.split("_", 2)[2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
await callback.answer("Некорректные данные")
|
||||||
|
return
|
||||||
|
|
||||||
|
booking = get_booking(booking_id)
|
||||||
|
if not booking or booking.get("user_id") != callback.from_user.id:
|
||||||
|
logger.warning("User {uid} tried to edit foreign booking #{bid}", uid=callback.from_user.id, bid=booking_id)
|
||||||
|
await callback.answer("⛔ Это не ваша запись")
|
||||||
|
return
|
||||||
|
|
||||||
|
update_booking(
|
||||||
|
booking_id=booking_id,
|
||||||
|
service="",
|
||||||
|
master="",
|
||||||
|
date="",
|
||||||
|
time=""
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer(
|
||||||
|
"Редактирование не поддерживается"
|
||||||
|
)
|
||||||
|
|||||||
+7
-23
@@ -1,34 +1,18 @@
|
|||||||
"""User profile handler - auto-registers if missing."""
|
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
from app.db.postgres import PostgresDB
|
from app.db.memory import get_user
|
||||||
from app.config import USE_POSTGRES
|
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c")
|
@router.message(F.text == "👤 Профиль")
|
||||||
async def profile(message: Message, state: FSMContext):
|
async def profile(message: Message, state: FSMContext):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
user_id = message.from_user.id
|
user = get_user(message.from_user.id)
|
||||||
name = message.from_user.full_name or "\u0413\u043e\u0441\u0442\u044c"
|
if not user:
|
||||||
|
await message.answer("Профиль не найден")
|
||||||
if USE_POSTGRES:
|
return
|
||||||
user = await PostgresDB.get_user(user_id)
|
await message.answer(f"👤 Имя: {user['name']}")
|
||||||
if not user:
|
|
||||||
logger.info("Auto-registering user {uid} in PG", uid=user_id)
|
|
||||||
await PostgresDB.set_user(user_id, name)
|
|
||||||
user = await PostgresDB.get_user(user_id)
|
|
||||||
user_name = user.get("name") or user.get("full_name", "\u0413\u043e\u0441\u0442\u044c")
|
|
||||||
else:
|
|
||||||
from app.db.memory import get_user, set_user
|
|
||||||
user = get_user(user_id)
|
|
||||||
if not user:
|
|
||||||
set_user(user_id, {"name": name})
|
|
||||||
user = get_user(user_id)
|
|
||||||
user_name = user.get("name", "\u0413\u043e\u0441\u0442\u044c")
|
|
||||||
|
|
||||||
await message.answer(f"\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c\n\n\U0001f464 \u0418\u043c\u044f: {user_name}")
|
|
||||||
|
|||||||
+11
-29
@@ -1,41 +1,23 @@
|
|||||||
"""Services handler - show rich service cards with images."""
|
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
|
from aiogram.types import Message
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram import Bot
|
|
||||||
|
|
||||||
from app.config import SERVICES, BOT_TOKEN
|
from app.keyboards.menu import menu
|
||||||
|
from app.keyboards.services import get_services_keyboard
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
_bot = None
|
|
||||||
|
|
||||||
|
|
||||||
def bot():
|
|
||||||
global _bot
|
|
||||||
if _bot is None:
|
|
||||||
_bot = Bot(token=BOT_TOKEN)
|
|
||||||
return _bot
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "\U0001f4cb \u0423\u0441\u043b\u0443\u0433\u0438")
|
@router.message(F.text == "\U0001f4cb \u0423\u0441\u043b\u0443\u0433\u0438")
|
||||||
async def show_services(message: Message, state: FSMContext):
|
async def show_services(message: Message, state: FSMContext):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
logger.info("User {uid} requested services list", uid=message.from_user.id)
|
logger.info("User {uid} requested services list", uid=message.from_user.id)
|
||||||
|
await message.answer(
|
||||||
for key, svc in SERVICES.items():
|
"\U0001f4cb \u041d\u0430\u0448\u0438 \u0443\u0441\u043b\u0443\u0433\u0438:\n\n"
|
||||||
try:
|
"\U0001f4aa \u041c\u0430\u0441\u0441\u0430\u0436 - \u0440\u0430\u0441\u0441\u043b\u0430\u0431\u043b\u044f\u044e\u0449\u0438\u0439 \u0438 \u043b\u0435\u0447\u0435\u0431\u043d\u044b\u0439\n"
|
||||||
card = (
|
"\U0001f388 SPA - \u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441\u043d\u044b\u0435 \u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b\n"
|
||||||
svc["emoji"] + " " + svc["name"] + "\n\n"
|
"\U0001f484 \u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f - \u0443\u0445\u043e\u0434 \u0437\u0430 \u043b\u0438\u0446\u043e\u043c\n\n"
|
||||||
+ svc["desc"] + "\n\n"
|
"\u0427\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f\u00bb",
|
||||||
+ "\u23f1 \u0414\u043b\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c: " + str(svc["duration"]) + " \u043c\u0438\u043d\n"
|
reply_markup=get_services_keyboard()
|
||||||
+ "\U0001f4b0 \u0426\u0435\u043d\u0430: " + str(svc["price"]) + "\u20bd"
|
)
|
||||||
)
|
|
||||||
book_btn = InlineKeyboardMarkup(inline_keyboard=[[
|
|
||||||
InlineKeyboardButton(text="\U0001f4c5 \u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f", callback_data=key)
|
|
||||||
]])
|
|
||||||
img = FSInputFile(svc["image"])
|
|
||||||
await bot().send_photo(message.chat.id, photo=img, caption=card, reply_markup=book_btn)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error sending service card for {key}: {err}", key=key, err=e)
|
|
||||||
|
|||||||
+7
-24
@@ -1,11 +1,9 @@
|
|||||||
"""Start handler with welcome greeting and prices."""
|
|
||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
from aiogram.filters import CommandStart
|
from aiogram.filters import CommandStart
|
||||||
from aiogram.types import Message, FSInputFile
|
from aiogram.types import Message
|
||||||
|
|
||||||
from app.keyboards.menu import menu
|
from app.keyboards.menu import menu
|
||||||
from app.db.postgres import PostgresDB
|
from app.db.memory import get_user, set_user
|
||||||
from app.config import SERVICES
|
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -14,25 +12,10 @@ router = Router()
|
|||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
async def start(message: Message):
|
async def start(message: Message):
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
name = message.from_user.full_name or "\u0413\u043e\u0441\u0442\u044c"
|
if not get_user(user_id):
|
||||||
from app.config import USE_POSTGRES
|
set_user(user_id, {"name": message.from_user.full_name})
|
||||||
if USE_POSTGRES:
|
|
||||||
await PostgresDB.set_user(user_id, name)
|
|
||||||
else:
|
|
||||||
from app.db.memory import get_user, set_user
|
|
||||||
if not get_user(user_id):
|
|
||||||
set_user(user_id, {"name": name})
|
|
||||||
|
|
||||||
greet = (
|
await message.answer(
|
||||||
"\U0001f44b \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c, " + name + "!\n\n"
|
"👋 Добро пожаловать в Demo Bot",
|
||||||
"\u262f\ufe0f SPA-\u0441\u0430\u043b\u043e\u043d Demo - \u0432\u0430\u0448 \u043e\u0441\u0442\u0440\u043e\u0432\u043e\u043a \u0440\u0435\u043b\u0430\u043a\u0441\u0430\u0446\u0438\u0438\n\n"
|
reply_markup=menu,
|
||||||
"\U0001f4aa \u041c\u0430\u0441\u0441\u0430\u0436 - \u043e\u0442 " + str(SERVICES["service_massage"]["price"]) + "\u20bd\n"
|
|
||||||
"\U0001f388 SPA-\u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441 - \u043e\u0442 " + str(SERVICES["service_spa"]["price"]) + "\u20bd\n"
|
|
||||||
"\U0001f484 \u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f - \u043e\u0442 " + str(SERVICES["service_cosmetology"]["price"]) + "\u20bd\n\n"
|
|
||||||
"\U0001f447 \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u00bb \u0447\u0442\u043e\u0431\u044b \u043d\u0430\u0447\u0430\u0442\u044c"
|
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
img = FSInputFile("assets/welcome.png")
|
|
||||||
await message.answer_photo(photo=img, caption=greet, reply_markup=menu)
|
|
||||||
except Exception:
|
|
||||||
await message.answer(greet, reply_markup=menu)
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Healthcheck endpoint for monitoring."""
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from aiohttp import web
|
|
||||||
from app.logger import logger
|
|
||||||
|
|
||||||
routes = web.RouteTableDef()
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/health")
|
|
||||||
async def health(request):
|
|
||||||
"""Basic health check — returns bot status."""
|
|
||||||
health_data = {
|
|
||||||
"status": "ok",
|
|
||||||
"service": "spa-telegram-bot",
|
|
||||||
"timestamp": asyncio.get_event_loop().time(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if data files exist
|
|
||||||
data_dir = Path(__file__).resolve().parent.parent / "data"
|
|
||||||
health_data["data_dir_exists"] = data_dir.exists()
|
|
||||||
|
|
||||||
return web.json_response(health_data, status=200)
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/health/ready")
|
|
||||||
async def ready(request):
|
|
||||||
"""Readiness check — can the bot accept requests?"""
|
|
||||||
return web.json_response({"status": "ready"}, status=200)
|
|
||||||
|
|
||||||
|
|
||||||
async def start_health_server(port: int = 8080):
|
|
||||||
"""Start healthcheck HTTP server on given port."""
|
|
||||||
app = web.Application()
|
|
||||||
app.add_routes(routes)
|
|
||||||
runner = web.AppRunner(app)
|
|
||||||
await runner.setup()
|
|
||||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
|
||||||
await site.start()
|
|
||||||
logger.info("Healthcheck server started on port {port}", port=port)
|
|
||||||
return runner
|
|
||||||
@@ -15,7 +15,7 @@ def get_dates_keyboard():
|
|||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=day.strftime("%d.%m"),
|
text=day.strftime("%d.%m"),
|
||||||
callback_data=f"date_{day.strftime('%d.%m.%Y')}"
|
callback_data=f"date_{day.strftime('%d.%m')}"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -23,4 +23,4 @@ def get_dates_keyboard():
|
|||||||
buttons.append(get_back_button())
|
buttons.append(get_back_button())
|
||||||
return InlineKeyboardMarkup(
|
return InlineKeyboardMarkup(
|
||||||
inline_keyboard=buttons
|
inline_keyboard=buttons
|
||||||
)
|
)
|
||||||
@@ -1,21 +1,14 @@
|
|||||||
"""Masters keyboard with specialty filtering."""
|
|
||||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from app.config import MASTERS
|
from app.config import MASTERS
|
||||||
|
|
||||||
|
|
||||||
from app.keyboards.back import get_back_button
|
from app.keyboards.back import get_back_button
|
||||||
|
|
||||||
|
|
||||||
def get_masters_keyboard(service_key=None):
|
def get_masters_keyboard():
|
||||||
buttons = []
|
buttons = [
|
||||||
svc_cats = {
|
[InlineKeyboardButton(text=name, callback_data=key)]
|
||||||
"service_massage": "massage",
|
for key, name in MASTERS.items()
|
||||||
"service_spa": "spa",
|
]
|
||||||
"service_cosmetology": "cosmetology",
|
|
||||||
}
|
|
||||||
for key, master in MASTERS.items():
|
|
||||||
if service_key and "specialty" in master:
|
|
||||||
needed = svc_cats.get(service_key, "")
|
|
||||||
if needed and needed not in master["specialty"]:
|
|
||||||
continue
|
|
||||||
buttons.append([InlineKeyboardButton(text=master["name"], callback_data=key)])
|
|
||||||
buttons.append(get_back_button())
|
buttons.append(get_back_button())
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
"""Main menu keyboard."""
|
|
||||||
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||||
|
|
||||||
menu = ReplyKeyboardMarkup(
|
menu = ReplyKeyboardMarkup(
|
||||||
keyboard=[
|
keyboard=[
|
||||||
[KeyboardButton(text="\U0001f4cb \u0423\u0441\u043b\u0443\u0433\u0438"),
|
[KeyboardButton(text="📋 Услуги"),
|
||||||
KeyboardButton(text="\U0001f4c5 \u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f")],
|
KeyboardButton(text="📅 Записаться")],
|
||||||
[KeyboardButton(text="\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c"),
|
[KeyboardButton(text="👤 Профиль"),
|
||||||
KeyboardButton(text="\U0001f4c4 \u041c\u043e\u0438 \u0437\u0430\u043f\u0438\u0441\u0438")],
|
KeyboardButton(text="📄 Мои записи")],
|
||||||
[KeyboardButton(text="\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b"),
|
[KeyboardButton(text="📞 Контакты"),
|
||||||
KeyboardButton(text="\u2139\ufe0f \u041e \u043d\u0430\u0441")],
|
KeyboardButton(text="ℹ️ О нас")],
|
||||||
],
|
],
|
||||||
resize_keyboard=True,
|
resize_keyboard=True,
|
||||||
input_field_placeholder="\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435..."
|
input_field_placeholder="Выберите действие..."
|
||||||
)
|
)
|
||||||
|
|
||||||
cancel_menu = ReplyKeyboardMarkup(
|
cancel_menu = ReplyKeyboardMarkup(
|
||||||
keyboard=[
|
keyboard=[
|
||||||
[KeyboardButton(text="\u274c \u041e\u0442\u043c\u0435\u043d\u0430")]
|
[KeyboardButton(text="❌ Отмена")]
|
||||||
],
|
],
|
||||||
resize_keyboard=True
|
resize_keyboard=True
|
||||||
)
|
)
|
||||||
|
|||||||
+15
-10
@@ -1,28 +1,33 @@
|
|||||||
"""Services keyboard with price & duration."""
|
|
||||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from app.config import SERVICES
|
from app.config import SERVICES
|
||||||
|
|
||||||
|
|
||||||
from app.keyboards.back import get_back_button
|
from app.keyboards.back import get_back_button
|
||||||
|
|
||||||
|
|
||||||
def get_services_keyboard():
|
def get_services_keyboard():
|
||||||
buttons = []
|
buttons = [
|
||||||
for key, svc in SERVICES.items():
|
[InlineKeyboardButton(text=name, callback_data=key)]
|
||||||
label = svc["emoji"] + " " + svc["name"] + " - " + str(svc["price"]) + "\u20bd / " + str(svc["duration"]) + "\u043c\u0438\u043d"
|
for key, name in SERVICES.items()
|
||||||
buttons.append([InlineKeyboardButton(text=label, callback_data=key)])
|
]
|
||||||
buttons.append(get_back_button())
|
buttons.append(get_back_button())
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
def get_extra_services_keyboard(already_selected=None):
|
def get_extra_services_keyboard(already_selected: list[str] | None = None):
|
||||||
|
"""Keyboard for selecting extra services. Shows ticked/unticked."""
|
||||||
if already_selected is None:
|
if already_selected is None:
|
||||||
already_selected = []
|
already_selected = []
|
||||||
|
|
||||||
buttons = []
|
buttons = []
|
||||||
for key, svc in SERVICES.items():
|
for key, name in SERVICES.items():
|
||||||
if svc["name"] in already_selected:
|
if name in already_selected:
|
||||||
label = "\u2705 " + svc["emoji"] + " " + svc["name"]
|
label = "\u2705 " + name
|
||||||
else:
|
else:
|
||||||
label = svc["emoji"] + " " + svc["name"]
|
label = name
|
||||||
buttons.append([InlineKeyboardButton(text=label, callback_data="extra_" + key)])
|
buttons.append([InlineKeyboardButton(text=label, callback_data="extra_" + key)])
|
||||||
|
|
||||||
|
# Done button
|
||||||
buttons.append([InlineKeyboardButton(text="\u2705 \u0413\u043e\u0442\u043e\u0432\u043e", callback_data="extra_done")])
|
buttons.append([InlineKeyboardButton(text="\u2705 \u0413\u043e\u0442\u043e\u0432\u043e", callback_data="extra_done")])
|
||||||
buttons.append(get_back_button())
|
buttons.append(get_back_button())
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|||||||
+4
-40
@@ -4,9 +4,8 @@ import signal
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
|
|
||||||
|
|
||||||
from app.config import BOT_TOKEN, USE_POSTGRES
|
from app.config import BOT_TOKEN
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
|
||||||
# Import routers
|
# Import routers
|
||||||
@@ -20,13 +19,7 @@ from app.handlers.contacts import router as contacts_router
|
|||||||
from app.handlers.admin import router as admin_router
|
from app.handlers.admin import router as admin_router
|
||||||
|
|
||||||
bot = Bot(token=BOT_TOKEN)
|
bot = Bot(token=BOT_TOKEN)
|
||||||
|
dp = Dispatcher()
|
||||||
# Redis FSM storage — survives bot restarts, persistent across polling sessions
|
|
||||||
redis_storage = RedisStorage.from_url(
|
|
||||||
"redis://localhost:6379/0",
|
|
||||||
key_builder=DefaultKeyBuilder(with_destiny=True)
|
|
||||||
)
|
|
||||||
dp = Dispatcher(storage=redis_storage)
|
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
dp.include_routers(
|
dp.include_routers(
|
||||||
@@ -40,20 +33,9 @@ dp.include_routers(
|
|||||||
admin_router,
|
admin_router,
|
||||||
)
|
)
|
||||||
|
|
||||||
_health_runner = None
|
|
||||||
|
|
||||||
|
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
"""Called when bot starts."""
|
"""Called when bot starts."""
|
||||||
# Initialize Postgres pool if available
|
|
||||||
if USE_POSTGRES:
|
|
||||||
try:
|
|
||||||
from app.db.postgres import PostgresDB
|
|
||||||
await PostgresDB.init_pool()
|
|
||||||
logger.info("PostgreSQL pool initialized")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("PostgreSQL init failed, falling back to JSON: {error}", error=e)
|
|
||||||
|
|
||||||
me = await bot.get_me()
|
me = await bot.get_me()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Bot started | @{username} (id={id}) | Token: {token_preview}...",
|
"Bot started | @{username} (id={id}) | Token: {token_preview}...",
|
||||||
@@ -61,31 +43,12 @@ async def on_startup():
|
|||||||
id=me.id,
|
id=me.id,
|
||||||
token_preview=BOT_TOKEN[:10],
|
token_preview=BOT_TOKEN[:10],
|
||||||
)
|
)
|
||||||
# Start healthcheck server
|
|
||||||
try:
|
|
||||||
from app.health import start_health_server
|
|
||||||
global _health_runner
|
|
||||||
_health_runner = await start_health_server(port=8080)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Healthcheck server not started: {error}", error=e)
|
|
||||||
|
|
||||||
|
|
||||||
async def on_shutdown():
|
async def on_shutdown():
|
||||||
"""Called when bot shuts down."""
|
"""Called when bot shuts down."""
|
||||||
logger.info("Bot shutting down...")
|
logger.info("Bot shutting down...")
|
||||||
if USE_POSTGRES:
|
# Give running tasks time to finish
|
||||||
try:
|
|
||||||
from app.db.postgres import PostgresDB
|
|
||||||
await PostgresDB.close()
|
|
||||||
logger.info("PostgreSQL pool closed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("PostgreSQL pool close error: {error}", error=e)
|
|
||||||
if _health_runner:
|
|
||||||
await _health_runner.cleanup()
|
|
||||||
logger.info("Healthcheck server stopped")
|
|
||||||
if redis_storage:
|
|
||||||
await redis_storage.close()
|
|
||||||
logger.info("Redis storage closed")
|
|
||||||
pending = asyncio.all_tasks()
|
pending = asyncio.all_tasks()
|
||||||
if pending:
|
if pending:
|
||||||
logger.debug("Cancelling {count} pending tasks", count=len(pending))
|
logger.debug("Cancelling {count} pending tasks", count=len(pending))
|
||||||
@@ -111,6 +74,7 @@ async def main():
|
|||||||
try:
|
try:
|
||||||
loop.add_signal_handler(sig, lambda s=sig: _signal_handler(s, None))
|
loop.add_signal_handler(sig, lambda s=sig: _signal_handler(s, None))
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
|
# Windows fallback
|
||||||
signal.signal(sig, _signal_handler)
|
signal.signal(sig, _signal_handler)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,83 +1,64 @@
|
|||||||
"""Booking service — async layer between handlers and DB."""
|
|
||||||
from app.db.postgres import PostgresDB
|
|
||||||
from app.db.memory import (
|
from app.db.memory import (
|
||||||
create_booking as json_create_booking,
|
create_booking as db_create_booking,
|
||||||
get_busy_times as json_get_busy_times,
|
get_busy_times as db_get_busy_times,
|
||||||
get_user_bookings as json_get_user_bookings,
|
get_user_bookings as db_get_user_bookings,
|
||||||
cancel_booking as json_cancel_booking,
|
cancel_booking as db_cancel_booking,
|
||||||
update_booking as json_update_booking,
|
update_booking as db_update_booking,
|
||||||
load_bookings as json_load_bookings,
|
load_bookings as db_load_bookings,
|
||||||
save_bookings as json_save_bookings,
|
save_bookings as db_save_bookings,
|
||||||
USE_POSTGRES,
|
BOOKINGS_FILE,
|
||||||
)
|
)
|
||||||
from app.logger import logger
|
from app.logger import logger
|
||||||
|
|
||||||
|
|
||||||
async def create_booking(user_id, service, master, date, time, user_name=""):
|
async def create_booking(user_id, service, master, date, time, user_name=""):
|
||||||
if USE_POSTGRES:
|
return db_create_booking(user_id, service, master, date, time, user_name)
|
||||||
booking = await PostgresDB.create_booking(user_id, service, master, date, time, user_name)
|
|
||||||
return {"id": booking["id"], "user_id": booking["user_id"], "service": booking["service"],
|
|
||||||
"master": booking["master"], "date": booking["date"], "time": booking["time"],
|
|
||||||
"status": booking["status"]}
|
|
||||||
return json_create_booking(user_id, service, master, date, time, user_name)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_busy_times(master, date):
|
async def get_busy_times(master, date):
|
||||||
if USE_POSTGRES:
|
return db_get_busy_times(master, date)
|
||||||
return await PostgresDB.get_busy_times(master, date)
|
|
||||||
return json_get_busy_times(master, date)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_bookings(user_id):
|
async def get_user_bookings(user_id):
|
||||||
if USE_POSTGRES:
|
return db_get_user_bookings(user_id)
|
||||||
bookings = await PostgresDB.get_user_bookings(user_id)
|
|
||||||
return [{"id": b["id"], "user_id": b["user_id"], "service": b["service"],
|
|
||||||
"master": b["master"], "date": b["date"], "time": b["time"],
|
|
||||||
"status": b["status"]} for b in bookings]
|
|
||||||
return json_get_user_bookings(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def cancel_booking(booking_id):
|
async def cancel_booking(booking_id):
|
||||||
if USE_POSTGRES:
|
return db_cancel_booking(booking_id)
|
||||||
return await PostgresDB.cancel_booking(booking_id)
|
|
||||||
return json_cancel_booking(booking_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_booking(booking_id, service, master, date, time):
|
async def update_booking(booking_id, service, master, date, time):
|
||||||
if USE_POSTGRES:
|
return db_update_booking(booking_id, service, master, date, time)
|
||||||
result = await PostgresDB.update_booking(booking_id, service, master, date, time)
|
|
||||||
if result:
|
|
||||||
return {"id": result["id"], "service": result["service"], "master": result["master"],
|
|
||||||
"date": result["date"], "time": result["time"]}
|
|
||||||
return None
|
|
||||||
return json_update_booking(booking_id, service, master, date, time)
|
|
||||||
|
|
||||||
|
|
||||||
async def is_slot_busy(master, date, time):
|
async def is_slot_busy(master, date, time):
|
||||||
busy = await get_busy_times(master, date)
|
"""Check if a time slot is already taken by an active booking."""
|
||||||
|
busy = db_get_busy_times(master, date)
|
||||||
return time in busy
|
return time in busy
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_cancelled():
|
def cleanup_cancelled():
|
||||||
"""Remove cancelled bookings older than the head (keep only last 5 cancelled per user)."""
|
"""Remove cancelled bookings older than the head (keep only last 5 cancelled per user)."""
|
||||||
if USE_POSTGRES:
|
bookings = db_load_bookings()
|
||||||
# Postgres handles this with status column, no cleanup needed
|
active = [b for b in bookings if b["status"] == "active"]
|
||||||
pass
|
cancelled = [b for b in bookings if b["status"] == "cancelled"]
|
||||||
else:
|
|
||||||
bookings = json_load_bookings()
|
# Group cancelled by user, keep last 5 per user
|
||||||
active = [b for b in bookings if b["status"] == "active"]
|
from collections import defaultdict
|
||||||
cancelled = [b for b in bookings if b["status"] == "cancelled"]
|
by_user = defaultdict(list)
|
||||||
from collections import defaultdict
|
for b in cancelled:
|
||||||
by_user = defaultdict(list)
|
by_user[b["user_id"]].append(b)
|
||||||
for b in cancelled:
|
|
||||||
by_user[b["user_id"]].append(b)
|
keep_ids = set()
|
||||||
keep_ids = set()
|
for uid, user_cancel in by_user.items():
|
||||||
for uid, user_cancel in by_user.items():
|
# Keep the 5 most recent
|
||||||
user_cancel.sort(key=lambda x: x.get("id", 0), reverse=True)
|
user_cancel.sort(key=lambda x: x.get("id", 0), reverse=True)
|
||||||
for b in user_cancel[:5]:
|
for b in user_cancel[:5]:
|
||||||
keep_ids.add(b["id"])
|
keep_ids.add(b["id"])
|
||||||
bookings = active + [b for b in cancelled if b["id"] in keep_ids]
|
|
||||||
json_save_bookings(bookings)
|
# Rebuild: keep active + keep recent cancelled
|
||||||
removed = len(cancelled) - len(keep_ids)
|
bookings = active + [b for b in cancelled if b["id"] in keep_ids]
|
||||||
if removed > 0:
|
db_save_bookings(bookings)
|
||||||
logger.info("Cleaned up {n} old cancelled bookings", n=removed)
|
removed = len(cancelled) - len(keep_ids)
|
||||||
|
if removed > 0:
|
||||||
|
logger.info("Cleaned up {n} old cancelled bookings", n=removed)
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
"""Sticker helper."""
|
|
||||||
from aiogram import Bot
|
|
||||||
from app.config import BOT_TOKEN
|
|
||||||
from app.logger import logger
|
|
||||||
|
|
||||||
_bot = None
|
|
||||||
|
|
||||||
|
|
||||||
def _sticker_bot():
|
|
||||||
global _bot
|
|
||||||
if _bot is None:
|
|
||||||
_bot = Bot(token=BOT_TOKEN)
|
|
||||||
return _bot
|
|
||||||
|
|
||||||
|
|
||||||
async def send_sticker(chat_id, sticker_id: str):
|
|
||||||
try:
|
|
||||||
await _sticker_bot().send_sticker(chat_id, sticker=sticker_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Sticker send failed: {err}", err=e)
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 155 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB |
@@ -1,32 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
bot:
|
|
||||||
build: .
|
|
||||||
container_name: spa-bot
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./logs:/app/logs
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: spa-bot-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: spabot
|
|
||||||
POSTGRES_PASSWORD: spabot_password
|
|
||||||
POSTGRES_DB: spabot
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
+2
-4
@@ -1,4 +1,2 @@
|
|||||||
aiogram==3.17.0
|
aiogram
|
||||||
python-dotenv==1.1.0
|
python-dotenv
|
||||||
loguru==0.7.3
|
|
||||||
asyncpg==0.30.0
|
|
||||||
Reference in New Issue
Block a user