293 lines
13 KiB
Python
293 lines
13 KiB
Python
"""
|
||
МАРКЕТ ЛКМ37 (v2.0)
|
||
Доска объявлений с поддержкой фото и удобной навигацией.
|
||
"""
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from aiogram import Router, F
|
||
from aiogram.types import Message, CallbackQuery
|
||
from aiogram.filters import Command, StateFilter
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from aiogram.fsm.context import FSMContext
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select
|
||
from database.db import AsyncSessionLocal
|
||
from database.models import Ad, User
|
||
from utils.ui_styles import fmt_msg
|
||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||
from aiogram.types import InlineKeyboardButton
|
||
import config
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = Router()
|
||
|
||
class AdState(StatesGroup):
|
||
waiting_for_category = State()
|
||
waiting_for_title = State()
|
||
waiting_for_description = State()
|
||
waiting_for_price = State()
|
||
waiting_for_photo = State()
|
||
|
||
# =============================================================================
|
||
# КЛАВИАТУРЫ
|
||
# =============================================================================
|
||
|
||
def get_ads_main_menu():
|
||
builder = InlineKeyboardBuilder()
|
||
builder.row(InlineKeyboardButton(text="🛒 Все товары", callback_data="ads_view_all"))
|
||
builder.row(InlineKeyboardButton(text="➕ Создать лот", callback_data="ads_create"))
|
||
builder.row(InlineKeyboardButton(text="👤 Мои лоты", callback_data="ads_my_items"))
|
||
builder.row(InlineKeyboardButton(text="🏠 В главное меню", callback_data="main_menu"))
|
||
return builder.as_markup()
|
||
|
||
def get_categories_kb():
|
||
builder = InlineKeyboardBuilder()
|
||
cats = [
|
||
("Продам 💰", "sell"), ("Куплю 🛒", "buy"),
|
||
("Отдам 🎁", "give"), ("Услуги 🔧", "service")
|
||
]
|
||
for name, code in cats:
|
||
builder.add(InlineKeyboardButton(text=name, callback_data=f"adcat_{code}"))
|
||
builder.adjust(2)
|
||
builder.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
|
||
return builder.as_markup()
|
||
|
||
# =============================================================================
|
||
# ХЕНДЛЕРЫ
|
||
# =============================================================================
|
||
|
||
@router.message(Command("market"))
|
||
async def cmd_market(message: Message):
|
||
"""Главный вход в маркет"""
|
||
text = (
|
||
f"{fmt_msg('МАРКЕТ ЛКМ37', 'system')}\n\n"
|
||
"Здесь вы можете найти товары от соседей или предложить свои.\n"
|
||
"<i>Все лоты проходят модерацию.</i>"
|
||
)
|
||
await message.answer(text, reply_markup=get_ads_main_menu())
|
||
|
||
@router.callback_query(F.data == "market")
|
||
@router.callback_query(F.data == "ads_back")
|
||
async def cb_market(callback: CallbackQuery, state: FSMContext):
|
||
"""Главный вход в маркет из инлайн-кнопок"""
|
||
await state.clear()
|
||
text = (
|
||
f"{fmt_msg('МАРКЕТ ЛКМ37', 'system')}\n\n"
|
||
"Здесь вы можете найти товары от соседей или предложить свои.\n"
|
||
"<i>Все лоты проходят модерацию.</i>"
|
||
)
|
||
from handlers.users import safe_edit_or_reply
|
||
await safe_edit_or_reply(callback, text, reply_markup=get_ads_main_menu())
|
||
|
||
@router.callback_query(F.data == "ads_create")
|
||
async def start_ad_creation(callback: CallbackQuery, state: FSMContext):
|
||
await callback.message.answer("Выберите категорию для вашего лота:", reply_markup=get_categories_kb())
|
||
await state.set_state(AdState.waiting_for_category)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(AdState.waiting_for_category, F.data.startswith("adcat_"))
|
||
async def process_cat(callback: CallbackQuery, state: FSMContext):
|
||
cat = callback.data.split("_")[1]
|
||
await state.update_data(category=cat)
|
||
await callback.message.answer("Введите краткое название (заголовок) товара:")
|
||
await state.set_state(AdState.waiting_for_title)
|
||
await callback.answer()
|
||
|
||
@router.message(AdState.waiting_for_title)
|
||
async def process_title(message: Message, state: FSMContext):
|
||
await state.update_data(title=message.text)
|
||
await message.answer("Опишите товар подробнее (состояние, причина продажи и т.д.):")
|
||
await state.set_state(AdState.waiting_for_description)
|
||
|
||
@router.message(AdState.waiting_for_description)
|
||
async def process_desc(message: Message, state: FSMContext):
|
||
await state.update_data(description=message.text)
|
||
await message.answer("Укажите цену в рублях (или 0 если даром):")
|
||
await state.set_state(AdState.waiting_for_price)
|
||
|
||
@router.message(AdState.waiting_for_price)
|
||
async def process_price(message: Message, state: FSMContext):
|
||
if not message.text.isdigit():
|
||
return await message.answer("Пожалуйста, введите только число.")
|
||
await state.update_data(price=int(message.text))
|
||
await message.answer("Пришлите фото товара (или нажмите /skip если нет фото):")
|
||
await state.set_state(AdState.waiting_for_photo)
|
||
|
||
@router.message(AdState.waiting_for_photo, F.photo)
|
||
async def process_photo(message: Message, state: FSMContext):
|
||
photo_id = message.photo[-1].file_id
|
||
data = await state.get_data()
|
||
await save_ad(message, data, photo_id)
|
||
await state.clear()
|
||
|
||
@router.message(AdState.waiting_for_photo, Command("skip"))
|
||
async def skip_photo(message: Message, state: FSMContext):
|
||
data = await state.get_data()
|
||
await save_ad(message, data, None)
|
||
await state.clear()
|
||
|
||
async def save_ad(message: Message, data, photo_id):
|
||
async with AsyncSessionLocal() as session:
|
||
new_ad = Ad(
|
||
user_id=message.from_user.id,
|
||
category=data['category'],
|
||
title=data['title'],
|
||
description=data['description'],
|
||
price=data['price'],
|
||
photo_file_id=photo_id,
|
||
expires_at=datetime.utcnow() + timedelta(days=30),
|
||
is_moderated=True
|
||
)
|
||
session.add(new_ad)
|
||
await session.commit()
|
||
|
||
await message.answer(
|
||
f"{fmt_msg('ЛОТ СОЗДАН', 'system')}\n\n"
|
||
"Ваше объявление успешно создано и опубликовано в маркете LKM37!"
|
||
)
|
||
|
||
@router.callback_query(F.data == "ads_view_all")
|
||
async def view_ads_trigger(callback: CallbackQuery):
|
||
await view_ads(callback, page=0)
|
||
|
||
async def view_ads(callback: CallbackQuery, page: int = 0):
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(Ad).where(Ad.is_active == True, Ad.is_moderated == True).order_by(Ad.created_at.desc()).offset(page).limit(1)
|
||
res = await session.execute(stmt)
|
||
ad = res.scalar_one_or_none()
|
||
|
||
if not ad:
|
||
await callback.answer("К сожалению, пока лотов нет.", show_alert=True)
|
||
return
|
||
|
||
price_text = f"{ad.price} ₽" if ad.price > 0 else "ДАРOM"
|
||
text = (
|
||
f"<b>{ad.title}</b>\n\n"
|
||
f"💰 Цена: {price_text}\n"
|
||
f"📝 {ad.description}\n\n"
|
||
f"👤 Продавец: ID{ad.user_id}"
|
||
)
|
||
|
||
kb = InlineKeyboardBuilder()
|
||
kb.row(InlineKeyboardButton(text="📧 Написать продавцу", url=f"tg://user?id={ad.user_id}"))
|
||
if not ad.reserved_by:
|
||
kb.row(InlineKeyboardButton(text="🤝 БРОНИРОВАТЬ", callback_data=f"ad_reserve_{ad.ad_id}"))
|
||
else:
|
||
kb.row(InlineKeyboardButton(text="🤝 ЗАБРОНИРОВАНО", callback_data="ad_reserved_info"))
|
||
|
||
# Пагинация
|
||
nav_buttons = []
|
||
if page > 0:
|
||
nav_buttons.append(InlineKeyboardButton(text="⬅️ Предыдущий", callback_data=f"ad_page_{page-1}"))
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
next_stmt = select(Ad).where(Ad.is_active == True, Ad.is_moderated == True).offset(page + 1).limit(1)
|
||
next_res = await session.execute(next_stmt)
|
||
has_next = next_res.scalar_one_or_none() is not None
|
||
|
||
if has_next:
|
||
nav_buttons.append(InlineKeyboardButton(text="Следующий ➡️", callback_data=f"ad_page_{page+1}"))
|
||
|
||
if nav_buttons:
|
||
kb.row(*nav_buttons)
|
||
|
||
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
|
||
kb.row(InlineKeyboardButton(text="🏠 В главное меню", callback_data="main_menu"))
|
||
|
||
try:
|
||
await callback.message.delete()
|
||
except Exception:
|
||
pass
|
||
|
||
if ad.photo_file_id:
|
||
await callback.message.answer_photo(ad.photo_file_id, caption=text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||
else:
|
||
await callback.message.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("ad_page_"))
|
||
async def cb_ad_page(callback: CallbackQuery):
|
||
page = int(callback.data.split("_")[2])
|
||
if page < 0:
|
||
await callback.answer("Это первое объявление.")
|
||
return
|
||
await view_ads(callback, page=page)
|
||
|
||
@router.callback_query(F.data.startswith("ad_reserve_"))
|
||
async def reserve_ad(callback: CallbackQuery):
|
||
ad_id = int(callback.data.split("_")[2])
|
||
async with AsyncSessionLocal() as session:
|
||
ad = await session.get(Ad, ad_id)
|
||
if ad and not ad.reserved_by:
|
||
ad.reserved_by = callback.from_user.id
|
||
await session.commit()
|
||
|
||
# Уведомляем продавца
|
||
try:
|
||
await callback.bot.send_message(
|
||
ad.user_id,
|
||
f"🤝 <b>Ваш товар забронирован!</b>\n\n"
|
||
f"Товар: <i>{ad.title}</i>\n"
|
||
f"Покупатель: ID{callback.from_user.id} (@{callback.from_user.username or '—'})",
|
||
parse_mode='HTML'
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
await callback.answer("✅ Товар забронирован. Свяжитесь с продавцом!", show_alert=True)
|
||
# Обновим клавиатуру
|
||
kb = InlineKeyboardBuilder()
|
||
kb.row(InlineKeyboardButton(text="📧 Написать продавцу", url=f"tg://user?id={ad.user_id}"))
|
||
kb.row(InlineKeyboardButton(text="🤝 ЗАБРОНИРОВАНО", callback_data="ad_reserved_info"))
|
||
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
|
||
try:
|
||
await callback.message.edit_reply_markup(reply_markup=kb.as_markup())
|
||
except Exception:
|
||
pass
|
||
else:
|
||
await callback.answer("❌ Товар уже забронирован или не найден.", show_alert=True)
|
||
|
||
@router.callback_query(F.data == "ad_reserved_info")
|
||
async def cb_reserved_info(callback: CallbackQuery):
|
||
await callback.answer("Этот товар уже забронирован кем-то из соседей.", show_alert=True)
|
||
|
||
@router.callback_query(F.data == "ads_my_items")
|
||
async def view_my_ads(callback: CallbackQuery):
|
||
user_id = callback.from_user.id
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(Ad).where(Ad.user_id == user_id, Ad.is_active == True).order_by(Ad.created_at.desc())
|
||
res = await session.execute(stmt)
|
||
ads = res.scalars().all()
|
||
|
||
if not ads:
|
||
await callback.answer("У вас пока нет активных объявлений.", show_alert=True)
|
||
return
|
||
|
||
text = "<b>👤 ВАШИ ОБЪЯВЛЕНИЯ:</b>\n\n"
|
||
kb = InlineKeyboardBuilder()
|
||
for ad in ads:
|
||
status = "🟢 Активно" if ad.is_moderated else "⏳ На модерации"
|
||
price_text = f"{ad.price} ₽" if ad.price > 0 else "Даром"
|
||
text += f"🔹 <b>{ad.title}</b> ({price_text})\n └ Статус: {status}\n\n"
|
||
kb.row(
|
||
InlineKeyboardButton(text=f"🗑️ Удалить '{ad.title[:15]}'", callback_data=f"ad_delete_{ad.ad_id}")
|
||
)
|
||
|
||
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
|
||
|
||
from handlers.users import safe_edit_or_reply
|
||
await safe_edit_or_reply(callback, text, reply_markup=kb.as_markup())
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("ad_delete_"))
|
||
async def delete_my_ad(callback: CallbackQuery):
|
||
ad_id = int(callback.data.split("_")[2])
|
||
async with AsyncSessionLocal() as session:
|
||
ad = await session.get(Ad, ad_id)
|
||
if ad and ad.user_id == callback.from_user.id:
|
||
ad.is_active = False
|
||
await session.commit()
|
||
await callback.answer("✅ Объявление удалено.", show_alert=True)
|
||
else:
|
||
await callback.answer("❌ Объявление не найдено или вы не автор.", show_alert=True)
|
||
await view_my_ads(callback)
|