MVP
Some checks failed
continuous-integration/drone Build is failing

This commit is contained in:
2025-07-27 22:17:28 +03:00
commit 5662d8877a
26 changed files with 1390 additions and 0 deletions

0
app/__init__.py Normal file
View File

66
app/config.py Normal file
View File

@@ -0,0 +1,66 @@
from dataclasses import dataclass
from environs import Env
@dataclass
class DatabaseConfig:
database: str
host: str
user: str
password: str
@dataclass
class TgBot:
token: str
admin_ids: list[int]
superadmin: int
@dataclass
class TableSchemas:
main_table: str
main_table_cols: str
@dataclass
class ChatBot:
url: str
api_key: str
bot_model: str
@dataclass
class Config:
tg_bot: TgBot
db: DatabaseConfig
table: TableSchemas
chat_bot: ChatBot
def config_loader(path: str | None = None) -> Config:
env: Env = Env()
env.read_env(path)
# Загружаем конфигурацию из .env файла и возвращаем его экземпляром Config dataclass'а'
return Config(
tg_bot=TgBot(
token=env('BOT_TOKEN'),
admin_ids=list(map(int, env.list('ADMIN_IDS'))),
superadmin=env('superadmin')
),
db=DatabaseConfig(
database=env('DATABASE'),
host=env('DB_HOST'),
user=env('DB_USER'),
password=env('DB_PASSWORD')
),
table=TableSchemas(
main_table=env('MAIN_TABLE'),
main_table_cols=env('MAIN_TABLE_COLS'),
),
chat_bot=ChatBot(
url=env('CHAT_BOT_URL'),
api_key=env('CHAT_BOT_API_KEY'),
bot_model=env('BOT_MODEL')
)
)

5
app/database/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .database_engine import async_session_
from .models import Worker,Component, Order
__all__ = ["Worker", "Component", "Order", "async_session_"]

View File

@@ -0,0 +1,14 @@
import os
import asyncpg
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, session
import dotenv
# connection = psycopg2.connect(*(os.getenv(key) for key in ["DATABASE", "DB_HOST", "DB_USER", "DB_PASSWORD"]))
# connection.autocommit = True
dotenv.load_dotenv(".env")
DATABASE_URL = (f"postgresql+asyncpg://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}@"
f"{os.getenv('DB_HOST')}:9432/{os.getenv('DATABASE')}")
print(DATABASE_URL)
engine = create_async_engine(DATABASE_URL, echo=True)
async_session_ = async_sessionmaker(bind=engine, expire_on_commit=False)

89
app/database/models.py Normal file
View File

@@ -0,0 +1,89 @@
from sqlalchemy import Column, Integer, String, Date, ForeignKey, func, Null
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship, DeclarativeBase
status_enum = ENUM('Выполнено', 'В процессе', 'Создано', 'Ожидание комплектующих', name='status')
class Base(DeclarativeBase):
pass
class Worker(Base):
"""
id SERIAL PRIMARY KEY,
telegram_id INTEGER UNIQUE NOT NULL,
name VARCHAR NOT NULL,
email VARCHAR(50),
phone_number VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
"""
__tablename__ = "workers"
id = Column(Integer, primary_key=True, autoincrement=True)
telegram_id = Column(Integer, unique=True, nullable=False)
name = Column(String, nullable=False)
email = Column(String, nullable=True)
phone_number = Column(String, nullable=False)
created_at = Column(Date, server_default=func.now())
updated_at = Column(Date, onupdate=func.now())
class Order(Base):
"""
id SERIAL PRIMARY KEY,
name VARCHAR,
worker_id INTEGER REFERENCES workers (id),
status_id status DEFAULT 'Создано',
counterparty VARCHAR(50),
customer VARCHAR NOT NULL,
commencement_work DATE,
end_work DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
description VARCHAR DEFAULT NULL
"""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String)
worker_id = Column(Integer, ForeignKey('workers.telegram_id'), nullable=False)
status_id = Column(status_enum)
counterparty = Column(String)
customer = Column(String, nullable=False)
commencement_work = Column(Date, nullable=True)
end_work = Column(Date, nullable=True)
created_at = Column(Date, server_default=func.now())
description = Column(String, default=Null)
user = relationship("Worker", backref="orders")
class Component(Base):
"""
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR NULL
"""
__tablename__ = "components"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
description = Column(String, default=Null)
class OrderComponent(Base):
"""
id SERIAL PRIMARY KEY,
order_id INTEGER REFERENCES orders (id),
component_id INTEGER REFERENCES components (id),
quantity INTEGER DEFAULT 1
"""
__tablename__ = "order_components"
id = Column(Integer, primary_key=True)
order_id = Column(Integer, ForeignKey('orders.id'))
component_id = Column(Integer, ForeignKey('components.id'))
quantity = Column(Integer, default=1)
order = relationship("Order", backref="order_components")
component = relationship("Component", backref="order_components")

25
app/filters/Filters.py Normal file
View File

@@ -0,0 +1,25 @@
import logging
from aiogram.types import Message, CallbackQuery
from aiogram.filters import BaseFilter
from keyboards.menu_commands import commands
import os
loggger = logging.getLogger(__name__)
class IsAdmin(BaseFilter):
def __init__(self):
self.admins_ids = os.getenv("BOT_ADMINS").split(",")
async def __call__(self, message: Message | CallbackQuery) -> bool:
return str(message.from_user.id) in self.admins_ids
class CommandFilter(BaseFilter):
def __init__(self):
self.commands = commands
async def __call__(self, message: Message) -> bool:
return message.text.startswith(tuple(self.commands.keys()))

1
app/filters/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .Filters import IsAdmin

6
app/handlers/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .admin import admin_router
from .registration import registration_router
from .orders import orders_router
from .components import components_router
__all__ = ["admin_router", "orders_router", "registration_router", "components_router"]

34
app/handlers/admin.py Normal file
View File

@@ -0,0 +1,34 @@
import time
import re
from aiogram import Router, Bot, F
from aiogram.types import (Message, ChatMemberUpdated, FSInputFile, CallbackQuery, ReplyKeyboardRemove)
from loguru import logger
from handlers.registration import registration_confirm
from filters.Filters import IsAdmin, CommandFilter
from database import async_session_
admin_router = Router()
admin_router.message.filter(IsAdmin())
regex = re.compile(r'(del|reg) @.+')
@admin_router.callback_query(lambda x: re.fullmatch(regex, x.data))
async def reg_del_command(callback: CallbackQuery, bot: Bot):
logger.warning(f'Received command: {callback.data}')
new_user_id = int(re.search(r'\d+', callback.data).group())
if callback.data.startswith('reg'):
registration_confirm[new_user_id].set()
await callback.answer("Новый пользователь зарегистрирован")
await callback.message.delete()
@admin_router.message(F.text.startswith('@msg'))
async def send_message_command(message: Message, bot: Bot):
chat_id = re.search(r'(\d+)', message.text).group()
print(chat_id)
await bot.send_message(text='Ronis->' + message.text.strip('@msg_' + chat_id), chat_id=chat_id)

View File

@@ -0,0 +1,9 @@
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
components_router = Router()
@components_router.message(Command(commands="components"))
async def components(message: Message):
await message.answer("Функция пока не доступна")
await message.delete()

230
app/handlers/orders.py Normal file
View File

@@ -0,0 +1,230 @@
import asyncio
import os
from pathlib import Path
import re
from aiogram import Router, Bot, F
from aiogram.filters import CommandStart, Command
from aiogram.types import Message, CallbackQuery, FSInputFile, InputMediaPhoto, InputMediaVideo
from aiogram.exceptions import AiogramError
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext
from sqlalchemy import select, insert
from loguru import logger
from filters import IsAdmin
from keyboards import create_inline_kb, commands, button_create
from database import async_session_, Order, Worker
orders_router = Router()
# orders_router.message.filter()
order_operation_base = {"add_order_photo": "Добавить фото",
"get_order_photo": "Получить фото",
"get_order_components": "Получить список комплектующих",
"get_order_documentation": "Получить документацию"
}
order_operation_update = {"add_order_documentation": "Добавить документацию"}
order_main = {"find_orders": "Найти заказ"}
order_main_update = {"create_order": "Создать заказ"}
find_order_params = {"search_by_name": "Поиск по названию", "search_by_description": "Поиск по описанию ",
"search_by_id": "Поиск по номеру заказа", "search_by_customer": "Поиск по заказчику"}
class SearchForm(StatesGroup):
search_option = State()
data_to_search = State()
search_result = State()
class OrderForm(StatesGroup):
id = State()
worker_id = State()
status_id = State()
counterparty = State()
customer = State()
commencement_work = State()
end_work = State()
description = State()
@orders_router.message(Command(commands="orders"))
async def orders_menu(message: Message):
order_main_upd = order_main_update if await IsAdmin()(message) else {}
await message.answer(text="Доступные действия с заказами:",
reply_markup=create_inline_kb(width=1, **order_main, **order_main_upd))
@orders_router.callback_query(lambda x: x.data.startswith("create_order"))
async def get_order_worker_id(callback: CallbackQuery, state: FSMContext):
await state.set_state(OrderForm.worker_id)
await callback.message.answer("Введите id сборщика который будет собирать заказ:",
reply_markup=create_inline_kb(**{f"{callback.from_user.id}": "Ввести мой id"}))
await callback.message.delete()
@orders_router.callback_query(OrderForm.worker_id)
async def get_order_counterparty(message: Message | CallbackQuery, state: FSMContext):
if isinstance(message, Message):
worker_id = int(message.text)
msg = message
else:
worker_id = int(message.data)
msg = message.message
await msg.answer("Введите данные заказчика ")
await msg.delete()
await state.update_data(worker_id=worker_id)
await state.set_state(OrderForm.customer)
# @orders_router.callback_query(OrderForm.counterparty)
# async def get_order_customer(message: Message, state: FSMContext):
#
@orders_router.message(OrderForm.customer)
async def create_order(message: Message, state: FSMContext):
await message.answer("Введите описание заказа в виде ключевых слов (АВР, ПСС, НКУ и т.д.) )")
await state.update_data(customer=message.text)
await state.set_state(OrderForm.description)
@orders_router.message(OrderForm.description)
async def create_order(message: Message, state: FSMContext):
await state.update_data(description=message.text)
order_ = await state.get_data()
async with async_session_() as session:
async with session.begin():
session.add(Order(**order_))
await state.clear()
await message.answer("Заказ успешно создан ")
@orders_router.callback_query(lambda x: x.data == "find_orders")
async def find_orders_menu(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(text="Выберите параметры поиска:")
await callback.message.edit_reply_markup(reply_markup=create_inline_kb(width=1, **find_order_params))
await state.set_state(SearchForm.search_option)
@orders_router.callback_query(SearchForm.search_option)
async def search_by_item(callback: CallbackQuery, state: FSMContext):
search_option = re.search(r"^search_by_(\w+)", callback.data).group(1)
await state.update_data(search_option=search_option)
await state.set_state(SearchForm.data_to_search)
await callback.message.answer(text="🔍 Введите данные для поиска")
await callback.message.delete()
@orders_router.message(SearchForm.data_to_search)
async def search_by_item(message: Message, state: FSMContext):
async with async_session_() as local_session:
search_opt = await state.get_value("search_option")
col = getattr(Order, search_opt)
await message.answer(message.text)
result = await local_session.execute(
select(Order).where(col.ilike(f"%{message.text}%") if search_opt != "id" else col == int(message.text)))
selected_orders = result.scalars().all()
if selected_orders:
await message.answer(text="Список заказов", reply_markup=create_inline_kb(width=1, **dict(
(f"show_order_{order.id}", order.description or "Отсутствует") for order in selected_orders)))
await state.update_data(search_result=selected_orders)
await state.set_state(SearchForm.search_result)
else:
await message.answer(text="Заказов по вашему запросу не найдено")
await state.clear()
@orders_router.callback_query(SearchForm.search_result)
async def show_order(callback: CallbackQuery, state: FSMContext):
order_id = int(re.search(r"(\d+)", callback.data).group())
try:
async with async_session_() as local_session:
result = await local_session.execute(select(Order).filter(Order.id == order_id))
order = result.scalars().first()
except Exception as err:
logger.warning(err)
order_operation_upd = order_operation_update if await IsAdmin()(callback) else {}
if order:
await callback.message.answer(text=f"Номер заказа: {order.id}\n"
f"Заказчик: {order.customer}\n"
f"Статус: {order.status_id}\n"
f"Дата начала работ: {order.commencement_work}\n"
f"Дата отгрузки: {order.end_work}\n"
f"Дата создания: {order.created_at}\n"
f"Описание: {order.description}",
reply_markup=create_inline_kb(width=2, **dict(
(f"{clbk}_{order.id}", text) for clbk, text in order_operation_base.items()))
)
await callback.message.delete()
await state.clear()
@orders_router.callback_query(lambda x: x.data.startswith("get_order_photo"))
async def send_order_photos(callback: CallbackQuery, bot: Bot):
order_id = callback.data.split("_")[-1]
media_item: Path
media_group = []
os.makedirs(Path(f"./photos/{order_id}/"), exist_ok=True)
media_path = Path(f"./photos/{order_id}/").iterdir()
if not (media_item := next(media_path, None)):
text = f"Фото по заказу \"{order_id}\" отсутствуют "
else:
text = (f"Заказ: №{order_id}\n"
f"")
await bot.send_message(chat_id=callback.from_user.id, text=text)
while media_item or media_group:
if len(media_group) == 10 or (media_group and not media_item):
await bot.send_media_group(chat_id=callback.from_user.id, media=media_group)
media_group.clear()
if media_item:
try:
input_type = InputMediaPhoto if media_item.suffix == ".jpg" else InputMediaVideo
media_group.append(input_type(media=FSInputFile(media_item)))
except Exception as err:
logger.error(f"Ошибка при обработке {media_path}: {err}")
media_item = next(media_path, None)
await asyncio.sleep(600)
await callback.message.delete()
@orders_router.callback_query(lambda x: x.data.startswith("add_order_photo"))
async def reply_for_photo(callback: CallbackQuery, bot: Bot):
await bot.send_message(text=f"Заказ: {callback.data.split("_")[-1]}\n"
"Прикрепите файлы ответив на это сообщение сдвинув его влево, либо выберите 'ответить'",
chat_id=callback.from_user.id)
await callback.message.delete()
@orders_router.message(
F.reply_to_message)
async def add_order_photo(message: Message, bot: Bot):
order_id = re.search(r"(\d+)", message.reply_to_message.text).group()
order_photos_path = f"/app/photos/{order_id}/"
os.makedirs(order_photos_path, exist_ok=True)
item = message.video or message.photo[-1]
file_extension = "jpg" if message.photo else "mp4"
try:
file = await bot.get_file(item.file_id)
await bot.download_file(file.file_path, destination=f"{order_photos_path}/{file.file_id}.{file_extension}",
timeout=3600)
await message.answer(f"Медиа файл {file.file_id[:10]} успешно прикреплен к заказу №{order_id}")
except AiogramError as err:
logger.error(err)
await message.delete()
try:
await bot.delete_message(chat_id=message.chat.id, message_id=message.reply_to_message.message_id)
except:
pass

View File

@@ -0,0 +1,50 @@
import os
from asyncio import Event
from aiogram import Router, Bot
from aiogram.filters import CommandStart
from aiogram.types import Message, User
from sqlalchemy import insert, select
from sqlalchemy.orm import selectinload
from keyboards import create_inline_kb
from database import async_session_, Worker
registration_router = Router()
registration_confirm: dict[int, Event] = {}
user_info_template = ("Новый пользователь ждет регистрации:\n"
"Имя: {}\n"
"Фамилия: {}\n"
"Юзернейм: @{}\n"
"ID: @msg_{}\n")
@registration_router.message(CommandStart())
async def registration_command(message: Message, bot: Bot):
admins_ids = os.getenv("BOT_ADMINS").split(",")
async with async_session_() as session:
async with session.begin():
result = await session.execute(select(Worker).where(Worker.telegram_id == message.from_user.id))
user = result.scalars().first()
if not user:
user = message.from_user
dict_for_inline = {f'reg @{user.id}': 'Allow', f'del @{user.id}': 'Reject'}
user_info = user_info_template.format(user.first_name, user.last_name if user.last_name else 'Не указана',
user.username if user.username else 'Не указан', user.id)
for admin in admins_ids:
await bot.send_message(chat_id=admin, text=user_info)
await bot.send_message(chat_id=admin, text='Зарегистрировать пользователя',
reply_markup=create_inline_kb(width=2, **dict_for_inline))
reg_confirm = Event()
registration_confirm[user.id] = reg_confirm
if await reg_confirm:
async with async_session_() as local_session:
async with local_session.begin():
local_session.add(Worker(telegram_id=int(user.id), name=user.first_name))
del registration_confirm[user.id]
else:
await message.answer("Работа бота возобновлена")

View File

@@ -0,0 +1,5 @@
from .inline import create_inline_kb, button_create
from .menu_commands import set_main_menu,commands
__all__ = ["create_inline_kb", "button_create", "set_main_menu","commands"]

35
app/keyboards/inline.py Normal file
View File

@@ -0,0 +1,35 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
def button_create(iterable: iter, width: int = 1):
""" Функуия для преобразования списка в кнопки для клавиатуры """
kb_builder = ReplyKeyboardBuilder()
kb_builder.row(*[KeyboardButton(text=f'{iterable[i]}') for i in range(len(iterable))], width=width)
return kb_builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
def create_inline_kb(width: int = 1,
*args: str, **kwargs: str) -> InlineKeyboardMarkup:
# Инициализируем билдер
kb_builder = InlineKeyboardBuilder()
# Инициализируем список для кнопок
buttons: list[InlineKeyboardButton] = []
# Заполняем список кнопками из аргументов args и kwargs
if args:
for button in args:
buttons.append(InlineKeyboardButton(
text=button,
callback_data=button))
if kwargs:
for button, text in kwargs.items():
buttons.append(InlineKeyboardButton(
text=text,
callback_data=button))
# Распаковываем список с кнопками в билдер методом row c параметром width
kb_builder.row(*buttons, width=width)
# Возвращаем объект инлайн-клавиатуры
return kb_builder.as_markup(resize_keyboard=True, one_time_keyboard=True)

View File

@@ -0,0 +1,10 @@
from aiogram import Bot
from aiogram.types import BotCommand
commands = {'/help': 'Помощь по работе с ботом', '/support': 'Получить контакты техподдержки',
'/orders': 'Заказы', '/components': 'Товары', '/start': 'Запуск/перезапуск бота'}
async def set_main_menu(bot: Bot):
await bot.set_my_commands([
BotCommand(command=command, description=description) for command, description in commands.items()])

27
app/main.py Normal file
View File

@@ -0,0 +1,27 @@
import os
import asyncio
from dotenv import load_dotenv
from aiogram import Dispatcher, Bot
from handlers import *
from keyboards import set_main_menu
from middlewares import AccessCheckMiddleware
load_dotenv(".env")
bot = Bot(token=os.getenv("TOKEN"))
async def main() -> None:
dp = Dispatcher()
dp.startup.register(set_main_menu)
dp.include_router(registration_router)
dp.include_router(admin_router)
dp.update.outer_middleware(AccessCheckMiddleware())
dp.include_router(orders_router)
dp.include_router(components_router)
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot)
asyncio.run(main())

View File

@@ -0,0 +1,4 @@
from .outer_middlewares import AccessCheckMiddleware
__all__ = ["AccessCheckMiddleware"]

View File

@@ -0,0 +1,29 @@
import logging
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware, Bot
from aiogram.types import TelegramObject
from database import async_session_, Worker
from sqlalchemy import select
class AccessCheckMiddleware(BaseMiddleware):
sessions_in_memory_db = set()
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
event_data = event.message or event.callback_query
user = event_data.from_user.id
if user not in self.sessions_in_memory_db:
async with async_session_() as session:
async with session.begin():
result = await session.execute(select(Worker).where(Worker.telegram_id == event_data.from_user.id))
user = result.scalars().first()
if user:
self.sessions_in_memory_db.add(event_data.from_user.id)
return await handler(event, data)
return None