Compare commits
3 Commits
ac8c2cd835
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0729e9196b | |||
| 9af84db429 | |||
| b282c44e7c |
@@ -9,7 +9,7 @@ steps:
|
|||||||
- name: dockersock
|
- name: dockersock
|
||||||
path: /var/run
|
path: /var/run
|
||||||
commands:
|
commands:
|
||||||
- docker compose -f docker-compose.yml build --pull
|
- docker compose build --no-cache
|
||||||
|
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: docker:27
|
image: docker:27
|
||||||
|
|||||||
4
.env
4
.env
@@ -10,4 +10,6 @@ DATABASE_URL=mysql+pymysql://match_user:match_password_change_me@db:3306/match_a
|
|||||||
|
|
||||||
BOT_TOKEN=7839684534:AAGAUmO756P1T81q8ImndWgIBU9OsZkY06s
|
BOT_TOKEN=7839684534:AAGAUmO756P1T81q8ImndWgIBU9OsZkY06s
|
||||||
PRIMARY_ADMIN_TELEGRAM_ID=556399210
|
PRIMARY_ADMIN_TELEGRAM_ID=556399210
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
BOT_LOCALE=ru
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,3 @@
|
|||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
@@ -15,4 +13,5 @@ alembic/versions/__pycache__/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.history
|
.history
|
||||||
>>>>>>> 8024239 (init)
|
|
||||||
|
.fake
|
||||||
@@ -36,6 +36,9 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- PY_VERSION=3.12
|
- PY_VERSION=3.12
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ}
|
||||||
|
PYTHONPATH: /app
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -1,3 +1,36 @@
|
|||||||
[alembic]
|
[alembic]
|
||||||
script_location = alembic
|
script_location = alembic
|
||||||
sqlalchemy.url = ${DATABASE_URL}
|
sqlalchemy.url = ${DATABASE_URL}
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers = console
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
@@ -1,24 +1,42 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os, sys
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
# Подменяем URL из переменных окружения контейнера
|
|
||||||
if os.getenv("DATABASE_URL"):
|
|
||||||
config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL"))
|
|
||||||
|
|
||||||
fileConfig(config.config_file_name)
|
# Возьми URL из переменных контейнера
|
||||||
|
db_url = os.getenv("DATABASE_URL")
|
||||||
|
if db_url:
|
||||||
|
config.set_main_option("sqlalchemy.url", db_url)
|
||||||
|
|
||||||
|
# Лог-конфиг (без падения, если секций нет)
|
||||||
|
cfg_name = config.config_file_name
|
||||||
|
if cfg_name:
|
||||||
|
try:
|
||||||
|
fileConfig(cfg_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Обеспечим PYTHONPATH=/app для импорта пакета app
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
# Метаданные моделей
|
# Метаданные моделей
|
||||||
from app.models.base import Base # noqa: E402
|
from app.models.base import Base # noqa
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
def run_migrations_offline():
|
def run_migrations_offline():
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True,
|
context.configure(
|
||||||
dialect_opts={"paramstyle": "named"})
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ def upgrade():
|
|||||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
sa.Column('telegram_id', sa.Integer(), unique=True, index=True),
|
sa.Column('telegram_id', sa.Integer(), unique=True, index=True),
|
||||||
sa.Column('username', sa.String(length=100), nullable=True),
|
sa.Column('username', sa.String(length=100), nullable=True),
|
||||||
|
|
||||||
sa.Column('full_name', sa.String(length=120), nullable=True),
|
sa.Column('full_name', sa.String(length=120), nullable=True),
|
||||||
sa.Column('gender', sa.String(length=20), nullable=True),
|
sa.Column('gender', sa.String(length=20), nullable=True),
|
||||||
sa.Column('birth_date', sa.Date(), nullable=True),
|
sa.Column('birth_date', sa.Date(), nullable=True),
|
||||||
@@ -30,6 +31,7 @@ def upgrade():
|
|||||||
sa.Column('citizenship', sa.String(length=80), nullable=True),
|
sa.Column('citizenship', sa.String(length=80), nullable=True),
|
||||||
sa.Column('visa_status', sa.String(length=60), nullable=True),
|
sa.Column('visa_status', sa.String(length=60), nullable=True),
|
||||||
sa.Column('languages', sa.String(length=200), nullable=True),
|
sa.Column('languages', sa.String(length=200), nullable=True),
|
||||||
|
|
||||||
sa.Column('education', sa.String(length=120), nullable=True),
|
sa.Column('education', sa.String(length=120), nullable=True),
|
||||||
sa.Column('occupation', sa.String(length=120), nullable=True),
|
sa.Column('occupation', sa.String(length=120), nullable=True),
|
||||||
sa.Column('income_range', sa.String(length=60), nullable=True),
|
sa.Column('income_range', sa.String(length=60), nullable=True),
|
||||||
@@ -37,19 +39,25 @@ def upgrade():
|
|||||||
sa.Column('marital_status', sa.String(length=60), nullable=True),
|
sa.Column('marital_status', sa.String(length=60), nullable=True),
|
||||||
sa.Column('has_children', sa.Boolean(), nullable=True),
|
sa.Column('has_children', sa.Boolean(), nullable=True),
|
||||||
sa.Column('children_notes', sa.String(length=200), nullable=True),
|
sa.Column('children_notes', sa.String(length=200), nullable=True),
|
||||||
|
|
||||||
sa.Column('smoking', sa.String(length=20), nullable=True),
|
sa.Column('smoking', sa.String(length=20), nullable=True),
|
||||||
sa.Column('alcohol', sa.String(length=20), nullable=True),
|
sa.Column('alcohol', sa.String(length=20), nullable=True),
|
||||||
sa.Column('health_notes', sa.String(length=300), nullable=True),
|
sa.Column('health_notes', sa.String(length=300), nullable=True),
|
||||||
|
|
||||||
sa.Column('hobbies_tags', sa.String(length=300), nullable=True),
|
sa.Column('hobbies_tags', sa.String(length=300), nullable=True),
|
||||||
sa.Column('hobbies_free', sa.Text(), nullable=True),
|
sa.Column('hobbies_free', sa.Text(), nullable=True),
|
||||||
|
|
||||||
sa.Column('goal', sa.String(length=120), nullable=True),
|
sa.Column('goal', sa.String(length=120), nullable=True),
|
||||||
sa.Column('partner_prefs', sa.Text(), nullable=True),
|
sa.Column('partner_prefs', sa.Text(), nullable=True),
|
||||||
|
|
||||||
sa.Column('avatar_file_id', sa.String(length=200), nullable=True),
|
sa.Column('avatar_file_id', sa.String(length=200), nullable=True),
|
||||||
sa.Column('gallery_file_ids', sa.Text(), nullable=True),
|
sa.Column('gallery_file_ids', sa.Text(), nullable=True),
|
||||||
|
|
||||||
sa.Column('consent_personal', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
sa.Column('consent_personal', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||||
sa.Column('consent_policy', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
sa.Column('consent_policy', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||||
sa.Column('is_verified', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
sa.Column('is_verified', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||||
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')),
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ import os
|
|||||||
class Settings:
|
class Settings:
|
||||||
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
|
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||||
|
PRIMARY_ADMIN_TELEGRAM_ID = os.getenv("PRIMARY_ADMIN_TELEGRAM_ID", "")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
22
services/bot/app/core/db.py
Normal file
22
services/bot/app/core/db.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# Создаём движок SQLAlchemy
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
future=True,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фабрика сессий
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def db_session() -> Session:
|
||||||
|
"""Получить новую сессию БД."""
|
||||||
|
return SessionLocal()
|
||||||
110
services/bot/app/core/i18n.py
Normal file
110
services/bot/app/core/i18n.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
|
||||||
|
def _lang() -> str:
|
||||||
|
# Язык берём из переменной окружения, по умолчанию RU
|
||||||
|
return (os.getenv("BOT_LOCALE") or "ru").lower()
|
||||||
|
|
||||||
|
def t(key: str, **kwargs) -> str:
|
||||||
|
lang = _lang()
|
||||||
|
base = LOCALES.get("ru", {})
|
||||||
|
s = LOCALES.get(lang, {}).get(key, base.get(key, key))
|
||||||
|
try:
|
||||||
|
return s.format(**kwargs)
|
||||||
|
except Exception:
|
||||||
|
return s # на случай, если забыли передать плейсхолдеры
|
||||||
|
|
||||||
|
LOCALES = {
|
||||||
|
"ru": {
|
||||||
|
# общие
|
||||||
|
"err.not_allowed": "Недостаточно прав.",
|
||||||
|
"err.not_found": "Анкета не найдена.",
|
||||||
|
"ok.done": "Готово!",
|
||||||
|
|
||||||
|
# /candidates и карточка
|
||||||
|
"cand.caption": "#{id} {name} @{username}\nГород: {city} | Цель: {goal}\nСтатус: {verified} | Активна: {active}",
|
||||||
|
"cand.verified_yes": "✅ проверена",
|
||||||
|
"cand.verified_no": "⏳ на проверке",
|
||||||
|
"cand.active_yes": "да",
|
||||||
|
"cand.active_no": "нет",
|
||||||
|
|
||||||
|
"btn.open": "👁 Открыть",
|
||||||
|
"btn.verify": "✅ Проверить",
|
||||||
|
"btn.hide": "🚫 Скрыть",
|
||||||
|
"btn.restore": "♻️ Вернуть",
|
||||||
|
"btn.prev": "◀️ Назад",
|
||||||
|
"btn.next": "Вперёд ▶️",
|
||||||
|
|
||||||
|
# /list фильтры
|
||||||
|
"list.start_gender": "Фильтр по полу:",
|
||||||
|
"list.gender_f": "жен",
|
||||||
|
"list.gender_m": "муж",
|
||||||
|
"list.skip": "Пропустить",
|
||||||
|
"list.ask_city": "Укажи город (или напиши «пропустить»):",
|
||||||
|
"list.ask_verified": "Показывать только проверенные?",
|
||||||
|
"list.ask_active": "Показывать только активные?",
|
||||||
|
"list.searching": "Подбираю анкеты по фильтрам…",
|
||||||
|
"list.empty": "По фильтрам ничего не нашлось.",
|
||||||
|
|
||||||
|
# профайл/визард (пример — можешь расширить)
|
||||||
|
"wiz.hello": "Привет! Это анкета для брачного сервиса с сопровождением до визы F‑6 🇰🇷\nКак тебя зовут?",
|
||||||
|
"wiz.exists": "Анкета уже существует. Посмотреть — /my, редактирование — /edit (скоро), начать заново — /new",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ЗАГОТОВКИ под en/ko — при желании дополни
|
||||||
|
"en": {
|
||||||
|
"err.not_allowed": "Not allowed.",
|
||||||
|
"err.not_found": "Profile not found.",
|
||||||
|
"ok.done": "Done!",
|
||||||
|
"cand.caption": "#{id} {name} @{username}\nCity: {city} | Goal: {goal}\nStatus: {verified} | Active: {active}",
|
||||||
|
"cand.verified_yes": "✅ verified",
|
||||||
|
"cand.verified_no": "⏳ pending",
|
||||||
|
"cand.active_yes": "yes",
|
||||||
|
"cand.active_no": "no",
|
||||||
|
"btn.open": "👁 Open",
|
||||||
|
"btn.verify": "✅ Verify",
|
||||||
|
"btn.hide": "🚫 Hide",
|
||||||
|
"btn.restore": "♻️ Restore",
|
||||||
|
"btn.prev": "◀️ Prev",
|
||||||
|
"btn.next": "Next ▶️",
|
||||||
|
"list.start_gender": "Filter by gender:",
|
||||||
|
"list.gender_f": "female",
|
||||||
|
"list.gender_m": "male",
|
||||||
|
"list.skip": "Skip",
|
||||||
|
"list.ask_city": "City (or type 'skip'):",
|
||||||
|
"list.ask_verified": "Only verified?",
|
||||||
|
"list.ask_active": "Only active?",
|
||||||
|
"list.searching": "Searching…",
|
||||||
|
"list.empty": "No results by filters.",
|
||||||
|
"wiz.hello": "Hi! This is the application for our marriage service (visa F‑6 support). What's your name?",
|
||||||
|
"wiz.exists": "Your profile already exists. View: /my, edit: /edit (soon), restart: /new",
|
||||||
|
},
|
||||||
|
|
||||||
|
"ko": {
|
||||||
|
"err.not_allowed": "권한이 없습니다.",
|
||||||
|
"err.not_found": "프로필을 찾을 수 없습니다.",
|
||||||
|
"ok.done": "완료!",
|
||||||
|
"cand.caption": "#{id} {name} @{username}\n도시: {city} | 목표: {goal}\n상태: {verified} | 활성: {active}",
|
||||||
|
"cand.verified_yes": "✅ 인증됨",
|
||||||
|
"cand.verified_no": "⏳ 검토중",
|
||||||
|
"cand.active_yes": "예",
|
||||||
|
"cand.active_no": "아니오",
|
||||||
|
"btn.open": "👁 열기",
|
||||||
|
"btn.verify": "✅ 인증",
|
||||||
|
"btn.hide": "🚫 숨기기",
|
||||||
|
"btn.restore": "♻️ 복구",
|
||||||
|
"btn.prev": "◀️ 이전",
|
||||||
|
"btn.next": "다음 ▶️",
|
||||||
|
"list.start_gender": "성별 필터:",
|
||||||
|
"list.gender_f": "여",
|
||||||
|
"list.gender_m": "남",
|
||||||
|
"list.skip": "건너뛰기",
|
||||||
|
"list.ask_city": "도시를 입력하거나 '건너뛰기'를 입력하세요:",
|
||||||
|
"list.ask_verified": "인증된 프로필만 표시할까요?",
|
||||||
|
"list.ask_active": "활성 프로필만 표시할까요?",
|
||||||
|
"list.searching": "검색 중…",
|
||||||
|
"list.empty": "필터에 해당하는 결과가 없습니다.",
|
||||||
|
"wiz.hello": "안녕하세요! 혼인 서비스(F‑6 비자 지원) 신청서입니다. 이름이 무엇인가요?",
|
||||||
|
"wiz.exists": "프로필이 이미 있습니다. 보기: /my, 편집: /edit(곧), 다시 시작: /new",
|
||||||
|
},
|
||||||
|
}
|
||||||
371
services/bot/app/handlers/admin.py
Normal file
371
services/bot/app/handlers/admin.py
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from telegram import (
|
||||||
|
Update,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InputMediaPhoto,
|
||||||
|
)
|
||||||
|
from telegram.ext import (
|
||||||
|
ContextTypes,
|
||||||
|
CommandHandler,
|
||||||
|
CallbackQueryHandler,
|
||||||
|
ConversationHandler,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
from app.core.db import db_session
|
||||||
|
from app.core.i18n import t
|
||||||
|
from app.repositories.admin_repo import AdminRepository
|
||||||
|
from app.repositories.candidate_repo import CandidateRepository
|
||||||
|
from app.models.candidate import Candidate
|
||||||
|
from app.utils.common import csv_to_list
|
||||||
|
|
||||||
|
PAGE_SIZE = 5 # можно менять
|
||||||
|
|
||||||
|
# ========================== Утилиты отрисовки ===============================
|
||||||
|
|
||||||
|
def _caption(c: Candidate) -> str:
|
||||||
|
return t(
|
||||||
|
"cand.caption",
|
||||||
|
id=c.id,
|
||||||
|
name=(c.full_name or "—"),
|
||||||
|
username=(c.username or ""),
|
||||||
|
city=(c.city or "—"),
|
||||||
|
goal=(c.goal or "—"),
|
||||||
|
verified=(t("cand.verified_yes") if c.is_verified else t("cand.verified_no")),
|
||||||
|
active=(t("cand.active_yes") if c.is_active else t("cand.active_no")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def cand_kb(c: Candidate) -> InlineKeyboardMarkup:
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(t("btn.open"), callback_data=f"cand:view:{c.id}"),
|
||||||
|
InlineKeyboardButton(t("btn.verify"), callback_data=f"cand:verify:{c.id}"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
t("btn.hide") if c.is_active else t("btn.restore"),
|
||||||
|
callback_data=f"cand:toggle:{c.id}",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
def pager_kb(prefix: str, page: int, has_prev: bool, has_next: bool) -> InlineKeyboardMarkup | None:
|
||||||
|
# prefix: "candlist" или "listres"
|
||||||
|
btns = []
|
||||||
|
row = []
|
||||||
|
if has_prev:
|
||||||
|
row.append(InlineKeyboardButton(t("btn.prev"), callback_data=f"{prefix}:page:{page-1}"))
|
||||||
|
if has_next:
|
||||||
|
row.append(InlineKeyboardButton(t("btn.next"), callback_data=f"{prefix}:page:{page+1}"))
|
||||||
|
if row:
|
||||||
|
btns.append(row)
|
||||||
|
return InlineKeyboardMarkup(btns)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# =================== /candidates с пагинацией ===============================
|
||||||
|
|
||||||
|
async def list_candidates_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
with db_session() as s:
|
||||||
|
if not AdminRepository(s).is_admin(update.effective_user.id):
|
||||||
|
await update.message.reply_text(t("err.not_allowed"))
|
||||||
|
return
|
||||||
|
|
||||||
|
page = 0
|
||||||
|
context.user_data["candlist_page"] = page
|
||||||
|
total = s.query(Candidate).count()
|
||||||
|
rows = (s.query(Candidate)
|
||||||
|
.order_by(desc(Candidate.created_at))
|
||||||
|
.offset(page * PAGE_SIZE).limit(PAGE_SIZE).all())
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
await update.message.reply_text(t("list.empty"))
|
||||||
|
return
|
||||||
|
|
||||||
|
for c in rows:
|
||||||
|
if c.avatar_file_id:
|
||||||
|
await update.message.reply_photo(photo=c.avatar_file_id, caption=_caption(c), reply_markup=cand_kb(c))
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(text=_caption(c), reply_markup=cand_kb(c))
|
||||||
|
|
||||||
|
kb = pager_kb("candlist", page, has_prev=False, has_next=(total > (page + 1) * PAGE_SIZE))
|
||||||
|
if kb:
|
||||||
|
await update.message.reply_text(" ", reply_markup=kb) # отдельным сообщением панель пагинации
|
||||||
|
|
||||||
|
async def candlist_pager(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
_, _, page_str = q.data.split(":") # candlist:page:<n>
|
||||||
|
page = max(0, int(page_str))
|
||||||
|
context.user_data["candlist_page"] = page
|
||||||
|
|
||||||
|
with db_session() as s:
|
||||||
|
if not AdminRepository(s).is_admin(update.effective_user.id):
|
||||||
|
await q.edit_message_text(t("err.not_allowed"))
|
||||||
|
return
|
||||||
|
|
||||||
|
total = s.query(Candidate).count()
|
||||||
|
rows = (s.query(Candidate)
|
||||||
|
.order_by(desc(Candidate.created_at))
|
||||||
|
.offset(page * PAGE_SIZE).limit(PAGE_SIZE).all())
|
||||||
|
|
||||||
|
# Обновляем "панель" пагинации (это отдельное сообщение)
|
||||||
|
kb = pager_kb("candlist", page, has_prev=(page > 0), has_next=(total > (page + 1) * PAGE_SIZE))
|
||||||
|
try:
|
||||||
|
await q.edit_message_reply_markup(kb)
|
||||||
|
except Exception:
|
||||||
|
# если не панель, а текст — просто обновим текст
|
||||||
|
await q.edit_message_text(" ", reply_markup=kb)
|
||||||
|
|
||||||
|
# а список карточек отправляем следующими сообщениями
|
||||||
|
chat_id = q.message.chat.id
|
||||||
|
for c in rows:
|
||||||
|
if c.avatar_file_id:
|
||||||
|
await q.bot.send_photo(chat_id=chat_id, photo=c.avatar_file_id, caption=_caption(c), reply_markup=cand_kb(c))
|
||||||
|
else:
|
||||||
|
await q.bot.send_message(chat_id=chat_id, text=_caption(c), reply_markup=cand_kb(c))
|
||||||
|
|
||||||
|
# ======================== Кнопки карточек ===================================
|
||||||
|
|
||||||
|
async def callback_router(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
data = q.data or ""
|
||||||
|
if not data.startswith("cand:"):
|
||||||
|
return
|
||||||
|
_, action, id_str = data.split(":")
|
||||||
|
cid = int(id_str)
|
||||||
|
|
||||||
|
with db_session() as s:
|
||||||
|
if not AdminRepository(s).is_admin(update.effective_user.id):
|
||||||
|
if q.message.photo:
|
||||||
|
await q.edit_message_caption(caption=t("err.not_allowed"))
|
||||||
|
else:
|
||||||
|
await q.edit_message_text(t("err.not_allowed"))
|
||||||
|
return
|
||||||
|
|
||||||
|
c = s.get(Candidate, cid)
|
||||||
|
if not c:
|
||||||
|
if q.message.photo:
|
||||||
|
await q.edit_message_caption(caption=t("err.not_found"))
|
||||||
|
else:
|
||||||
|
await q.edit_message_text(t("err.not_found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "view":
|
||||||
|
cap = _caption(c)
|
||||||
|
if q.message.photo:
|
||||||
|
await q.edit_message_caption(caption=cap, reply_markup=cand_kb(c))
|
||||||
|
else:
|
||||||
|
await q.edit_message_text(text=cap, reply_markup=cand_kb(c))
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "verify":
|
||||||
|
c.is_verified = True
|
||||||
|
s.commit()
|
||||||
|
cap = _caption(c)
|
||||||
|
if q.message.photo:
|
||||||
|
await q.edit_message_caption(caption=cap, reply_markup=cand_kb(c))
|
||||||
|
else:
|
||||||
|
await q.edit_message_text(text=cap, reply_markup=cand_kb(c))
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "toggle":
|
||||||
|
c.is_active = not c.is_active
|
||||||
|
s.commit()
|
||||||
|
cap = _caption(c)
|
||||||
|
if q.message.photo:
|
||||||
|
await q.edit_message_caption(caption=cap, reply_markup=cand_kb(c))
|
||||||
|
else:
|
||||||
|
await q.edit_message_text(text=cap, reply_markup=cand_kb(c))
|
||||||
|
return
|
||||||
|
|
||||||
|
# ============================= /view <id> ===================================
|
||||||
|
|
||||||
|
async def view_candidate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
with db_session() as s:
|
||||||
|
if not AdminRepository(s).is_admin(update.effective_user.id):
|
||||||
|
await update.message.reply_text(t("err.not_allowed"))
|
||||||
|
return
|
||||||
|
|
||||||
|
args = context.args or []
|
||||||
|
if not args:
|
||||||
|
await update.message.reply_text("Формат: /view 12")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = int(args[0])
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("Укажи числовой id анкеты.")
|
||||||
|
return
|
||||||
|
|
||||||
|
c = s.get(Candidate, cid)
|
||||||
|
if not c:
|
||||||
|
await update.message.reply_text(t("err.not_found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
caption = _caption(c)
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
if c.avatar_file_id:
|
||||||
|
await context.bot.send_photo(chat_id=chat_id, photo=c.avatar_file_id, caption=caption, reply_markup=cand_kb(c))
|
||||||
|
else:
|
||||||
|
await context.bot.send_message(chat_id=chat_id, text=caption, reply_markup=cand_kb(c))
|
||||||
|
|
||||||
|
gallery_ids = csv_to_list(c.gallery_file_ids)
|
||||||
|
if gallery_ids:
|
||||||
|
batch: List[InputMediaPhoto] = []
|
||||||
|
for fid in gallery_ids:
|
||||||
|
batch.append(InputMediaPhoto(media=fid))
|
||||||
|
if len(batch) == 10:
|
||||||
|
await context.bot.send_media_group(chat_id=chat_id, media=batch)
|
||||||
|
batch = []
|
||||||
|
if batch:
|
||||||
|
await context.bot.send_media_group(chat_id=chat_id, media=batch)
|
||||||
|
|
||||||
|
# ===================== /list — фильтры + пагинация ==========================
|
||||||
|
|
||||||
|
F_GENDER, F_CITY, F_VERIFIED, F_ACTIVE = range(4)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListFilters:
|
||||||
|
gender: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
verified: Optional[bool] = None
|
||||||
|
active: Optional[bool] = True
|
||||||
|
|
||||||
|
def _kb_gender() -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup([
|
||||||
|
[InlineKeyboardButton(t("list.gender_f"), callback_data="list:gender:f"),
|
||||||
|
InlineKeyboardButton(t("list.gender_m"), callback_data="list:gender:m")],
|
||||||
|
[InlineKeyboardButton(t("list.skip"), callback_data="list:gender:skip")],
|
||||||
|
])
|
||||||
|
|
||||||
|
def _kb_yesno(prefix: str) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup([
|
||||||
|
[InlineKeyboardButton("Да", callback_data=f"list:{prefix}:yes"),
|
||||||
|
InlineKeyboardButton("Нет", callback_data=f"list:{prefix}:no")],
|
||||||
|
[InlineKeyboardButton(t("list.skip"), callback_data=f"list:{prefix}:skip")],
|
||||||
|
])
|
||||||
|
|
||||||
|
async def list_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
with db_session() as s:
|
||||||
|
if not AdminRepository(s).is_admin(update.effective_user.id):
|
||||||
|
await update.message.reply_text(t("err.not_allowed"))
|
||||||
|
return ConversationHandler.END
|
||||||
|
context.user_data["list_filters"] = ListFilters()
|
||||||
|
context.user_data["list_page"] = 0
|
||||||
|
await update.message.reply_text(t("list.start_gender"), reply_markup=_kb_gender())
|
||||||
|
return F_GENDER
|
||||||
|
|
||||||
|
async def list_gender(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
_, _, val = q.data.split(":") # list:gender:<val>
|
||||||
|
lf: ListFilters = context.user_data.get("list_filters") # type: ignore
|
||||||
|
if val == "f":
|
||||||
|
lf.gender = t("list.gender_f")
|
||||||
|
elif val == "m":
|
||||||
|
lf.gender = t("list.gender_m")
|
||||||
|
await q.edit_message_text(t("list.ask_city"),
|
||||||
|
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(t("list.skip"), callback_data="list:city:skip")]]))
|
||||||
|
return F_CITY
|
||||||
|
|
||||||
|
async def list_city(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
lf: ListFilters = context.user_data.get("list_filters") # type: ignore
|
||||||
|
if update.callback_query:
|
||||||
|
await update.callback_query.answer()
|
||||||
|
await update.callback_query.edit_message_text(t("list.ask_verified"))
|
||||||
|
await update.callback_query.message.reply_text(t("list.ask_verified"), reply_markup=_kb_yesno("verified"))
|
||||||
|
return F_VERIFIED
|
||||||
|
|
||||||
|
city = (update.message.text or "").strip()
|
||||||
|
if city.lower() != "пропустить":
|
||||||
|
lf.city = city
|
||||||
|
await update.message.reply_text(t("list.ask_verified"), reply_markup=_kb_yesno("verified"))
|
||||||
|
return F_VERIFIED
|
||||||
|
|
||||||
|
async def list_verified(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
_, _, val = q.data.split(":")
|
||||||
|
lf: ListFilters = context.user_data.get("list_filters") # type: ignore
|
||||||
|
if val == "yes":
|
||||||
|
lf.verified = True
|
||||||
|
elif val == "no":
|
||||||
|
lf.verified = False
|
||||||
|
await q.edit_message_text(t("list.ask_active"))
|
||||||
|
await q.message.reply_text(t("list.ask_active"), reply_markup=_kb_yesno("active"))
|
||||||
|
return F_ACTIVE
|
||||||
|
|
||||||
|
async def list_active(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
_, _, val = q.data.split(":")
|
||||||
|
lf: ListFilters = context.user_data.get("list_filters") # type: ignore
|
||||||
|
if val == "yes":
|
||||||
|
lf.active = True
|
||||||
|
elif val == "no":
|
||||||
|
lf.active = False
|
||||||
|
|
||||||
|
context.user_data["list_page"] = 0
|
||||||
|
await q.edit_message_text(t("list.searching"))
|
||||||
|
await _list_show_results(update, context)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def _list_show_results(update: Update, context: ContextTypes.DEFAULT_TYPE, page: Optional[int] = None):
|
||||||
|
lf: ListFilters = context.user_data.get("list_filters") # type: ignore
|
||||||
|
page = context.user_data.get("list_page", 0) if page is None else page
|
||||||
|
|
||||||
|
with db_session() as s:
|
||||||
|
if not AdminRepository(s).is_admin(update.effective_user.id):
|
||||||
|
await context.bot.send_message(chat_id=update.effective_chat.id, text=t("err.not_allowed"))
|
||||||
|
return
|
||||||
|
|
||||||
|
q = s.query(Candidate)
|
||||||
|
if lf.gender:
|
||||||
|
q = q.filter(Candidate.gender == lf.gender)
|
||||||
|
if lf.city:
|
||||||
|
q = q.filter(Candidate.city.ilike(lf.city))
|
||||||
|
if lf.verified is not None:
|
||||||
|
q = q.filter(Candidate.is_verified.is_(lf.verified))
|
||||||
|
if lf.active is not None:
|
||||||
|
q = q.filter(Candidate.is_active.is_(lf.active))
|
||||||
|
|
||||||
|
total = q.count()
|
||||||
|
rows = q.order_by(desc(Candidate.created_at)).offset(page * PAGE_SIZE).limit(PAGE_SIZE).all()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
await context.bot.send_message(chat_id=update.effective_chat.id, text=t("list.empty"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Отправим карточки
|
||||||
|
for c in rows:
|
||||||
|
cap = _caption(c)
|
||||||
|
if c.avatar_file_id:
|
||||||
|
await context.bot.send_photo(chat_id=update.effective_chat.id, photo=c.avatar_file_id, caption=cap, reply_markup=cand_kb(c))
|
||||||
|
else:
|
||||||
|
await context.bot.send_message(chat_id=update.effective_chat.id, text=cap, reply_markup=cand_kb(c))
|
||||||
|
|
||||||
|
# Панель пагинации результатов /list
|
||||||
|
kb = pager_kb("listres", page, has_prev=(page > 0), has_next=(total > (page + 1) * PAGE_SIZE))
|
||||||
|
if kb:
|
||||||
|
await context.bot.send_message(chat_id=update.effective_chat.id, text=" ", reply_markup=kb)
|
||||||
|
|
||||||
|
async def list_results_pager(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
_, _, page_str = q.data.split(":") # listres:page:<n>
|
||||||
|
page = max(0, int(page_str))
|
||||||
|
context.user_data["list_page"] = page
|
||||||
|
# просто обновляем "панель" и заново выводим карточки
|
||||||
|
try:
|
||||||
|
await q.edit_message_reply_markup(reply_markup=None) # убираем старую панель (по возможности)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await _list_show_results(update, context)
|
||||||
348
services/bot/app/handlers/conversation.py
Normal file
348
services/bot/app/handlers/conversation.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, InputMediaPhoto
|
||||||
|
from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters
|
||||||
|
|
||||||
|
from app.core.db import db_session
|
||||||
|
from app.models.candidate import Candidate
|
||||||
|
from app.repositories.admin_repo import AdminRepository
|
||||||
|
from app.utils.common import csv_to_list, list_to_csv, calc_age
|
||||||
|
from app.services.admin_bootstrap import ensure_primary_admin
|
||||||
|
|
||||||
|
(
|
||||||
|
S_NAME, S_GENDER, S_BIRTH, S_HEIGHT, S_WEIGHT,
|
||||||
|
S_COUNTRY, S_CITY, S_CITIZEN, S_VISA, S_LANGS,
|
||||||
|
S_EDU, S_OCCUP, S_INCOME, S_RELIGION, S_MARITAL, S_CHILDREN, S_CHILDREN_NOTES,
|
||||||
|
S_SMOKING, S_ALCOHOL, S_HEALTH,
|
||||||
|
S_HOBBIES_TAGS, S_HOBBIES_FREE,
|
||||||
|
S_GOAL, S_PARTNER,
|
||||||
|
S_AVATAR, S_GALLERY, S_CONSENTS, S_CONFIRM
|
||||||
|
) = range(28)
|
||||||
|
|
||||||
|
GENDER_KB = [["жен", "муж"], ["другое"]]
|
||||||
|
YESNO_KB = [["да", "нет"]]
|
||||||
|
SMOKE_ALC_KB = [["нет", "иногда", "да"]]
|
||||||
|
MARITAL_KB = [["никогда не был(а) в браке"], ["в разводе"], ["вдова/вдовец"]]
|
||||||
|
INCOME_KB = [["до 2 млн KRW", "2–4 млн"], ["4–6 млн", "6+ млн"], ["не указывать"]]
|
||||||
|
|
||||||
|
def build_conversation() -> ConversationHandler:
|
||||||
|
return ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("start", start), CommandHandler("new", start)],
|
||||||
|
states={
|
||||||
|
S_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_name)],
|
||||||
|
S_GENDER: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_gender)],
|
||||||
|
S_BIRTH: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_birth)],
|
||||||
|
S_HEIGHT: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_height)],
|
||||||
|
S_WEIGHT: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_weight)],
|
||||||
|
S_COUNTRY: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_country)],
|
||||||
|
S_CITY: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_city)],
|
||||||
|
S_CITIZEN: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_citizen)],
|
||||||
|
S_VISA: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_visa)],
|
||||||
|
S_LANGS: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_langs)],
|
||||||
|
S_EDU: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_edu)],
|
||||||
|
S_OCCUP: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_occup)],
|
||||||
|
S_INCOME: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_income)],
|
||||||
|
S_RELIGION: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_religion)],
|
||||||
|
S_MARITAL: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_marital)],
|
||||||
|
S_CHILDREN: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_children)],
|
||||||
|
S_CHILDREN_NOTES: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_children_notes)],
|
||||||
|
S_SMOKING: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_smoking)],
|
||||||
|
S_ALCOHOL: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_alcohol)],
|
||||||
|
S_HEALTH: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_health)],
|
||||||
|
S_HOBBIES_TAGS: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_hobbies_tags)],
|
||||||
|
S_HOBBIES_FREE: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_hobbies_free)],
|
||||||
|
S_GOAL: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_goal)],
|
||||||
|
S_PARTNER: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_partner)],
|
||||||
|
S_AVATAR: [
|
||||||
|
MessageHandler(filters.PHOTO, s_avatar),
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, s_avatar),
|
||||||
|
],
|
||||||
|
S_GALLERY: [
|
||||||
|
MessageHandler(filters.PHOTO, s_gallery),
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, s_gallery),
|
||||||
|
],
|
||||||
|
S_CONSENTS: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_consents)],
|
||||||
|
S_CONFIRM: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_confirm)],
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("cancel", cancel)],
|
||||||
|
allow_reentry=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(update, context):
|
||||||
|
with db_session() as s:
|
||||||
|
ensure_primary_admin(s)
|
||||||
|
exists = s.query(Candidate).filter(Candidate.telegram_id == update.effective_user.id).first()
|
||||||
|
if exists:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Анкета уже существует. Посмотреть — /my, редактирование — /edit (скоро), начать заново — /new",
|
||||||
|
reply_markup=ReplyKeyboardRemove(),
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Привет! Это анкета для брачного сервиса с сопровождением до визы F‑6 🇰🇷\nКак тебя зовут?",
|
||||||
|
reply_markup=ReplyKeyboardRemove(),
|
||||||
|
)
|
||||||
|
return S_NAME
|
||||||
|
|
||||||
|
async def s_name(update, context):
|
||||||
|
context.user_data["full_name"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Твой пол?", reply_markup=ReplyKeyboardMarkup(GENDER_KB, one_time_keyboard=True, resize_keyboard=True))
|
||||||
|
return S_GENDER
|
||||||
|
|
||||||
|
async def s_gender(update, context):
|
||||||
|
context.user_data["gender"] = update.message.text.strip().lower()
|
||||||
|
await update.message.reply_text("Дата рождения (ДД.ММ.ГГГГ):", reply_markup=ReplyKeyboardRemove())
|
||||||
|
return S_BIRTH
|
||||||
|
|
||||||
|
async def s_birth(update, context):
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(update.message.text.strip(), "%d.%m.%Y").date()
|
||||||
|
except Exception:
|
||||||
|
await update.message.reply_text("Неверный формат. Пример: 31.12.1995")
|
||||||
|
return S_BIRTH
|
||||||
|
context.user_data["birth_date"] = d
|
||||||
|
await update.message.reply_text("Рост (см):")
|
||||||
|
return S_HEIGHT
|
||||||
|
|
||||||
|
async def s_height(update, context):
|
||||||
|
try:
|
||||||
|
context.user_data["height_cm"] = float(update.message.text.replace(",", "."))
|
||||||
|
except Exception:
|
||||||
|
await update.message.reply_text("Введи число, например 168")
|
||||||
|
return S_HEIGHT
|
||||||
|
await update.message.reply_text("Вес (кг):")
|
||||||
|
return S_WEIGHT
|
||||||
|
|
||||||
|
async def s_weight(update, context):
|
||||||
|
try:
|
||||||
|
context.user_data["weight_kg"] = float(update.message.text.replace(",", "."))
|
||||||
|
except Exception:
|
||||||
|
await update.message.reply_text("Введи число, например 55")
|
||||||
|
return S_WEIGHT
|
||||||
|
await update.message.reply_text("Страна проживания:")
|
||||||
|
return S_COUNTRY
|
||||||
|
|
||||||
|
async def s_country(update, context):
|
||||||
|
context.user_data["country"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Город:")
|
||||||
|
return S_CITY
|
||||||
|
|
||||||
|
async def s_city(update, context):
|
||||||
|
context.user_data["city"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Гражданство (РФ/Казахстан/Узбекистан и т.п.):")
|
||||||
|
return S_CITIZEN
|
||||||
|
|
||||||
|
async def s_citizen(update, context):
|
||||||
|
context.user_data["citizenship"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Текущий визовый статус в Корее (нет/F‑4/H‑1 и т.п.):")
|
||||||
|
return S_VISA
|
||||||
|
|
||||||
|
async def s_visa(update, context):
|
||||||
|
context.user_data["visa_status"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Языки (через запятую, напр.: ru, ko, en):")
|
||||||
|
return S_LANGS
|
||||||
|
|
||||||
|
async def s_langs(update, context):
|
||||||
|
context.user_data["languages"] = list_to_csv(csv_to_list(update.message.text))
|
||||||
|
await update.message.reply_text("Образование (высшее/колледж/иное):")
|
||||||
|
return S_EDU
|
||||||
|
|
||||||
|
async def s_edu(update, context):
|
||||||
|
context.user_data["education"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Профессия:")
|
||||||
|
return S_OCCUP
|
||||||
|
|
||||||
|
async def s_occup(update, context):
|
||||||
|
context.user_data["occupation"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Доход (диапазон):",
|
||||||
|
reply_markup=ReplyKeyboardMarkup(INCOME_KB, one_time_keyboard=True, resize_keyboard=True),
|
||||||
|
)
|
||||||
|
return S_INCOME
|
||||||
|
|
||||||
|
async def s_income(update, context):
|
||||||
|
context.user_data["income_range"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Религия (если нет, напиши «нет»):", reply_markup=ReplyKeyboardRemove())
|
||||||
|
return S_RELIGION
|
||||||
|
|
||||||
|
async def s_religion(update, context):
|
||||||
|
context.user_data["religion"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Семейное положение:",
|
||||||
|
reply_markup=ReplyKeyboardMarkup(MARITAL_KB, one_time_keyboard=True, resize_keyboard=True),
|
||||||
|
)
|
||||||
|
return S_MARITAL
|
||||||
|
|
||||||
|
async def s_marital(update, context):
|
||||||
|
context.user_data["marital_status"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Есть дети?", reply_markup=ReplyKeyboardMarkup(YESNO_KB, one_time_keyboard=True, resize_keyboard=True))
|
||||||
|
return S_CHILDREN
|
||||||
|
|
||||||
|
async def s_children(update, context):
|
||||||
|
ans = update.message.text.strip().lower()
|
||||||
|
context.user_data["has_children"] = (ans == "да")
|
||||||
|
if ans == "да":
|
||||||
|
await update.message.reply_text("Коротко про детей (кол-во/возраст/с кем проживают):", reply_markup=ReplyKeyboardRemove())
|
||||||
|
return S_CHILDREN_NOTES
|
||||||
|
context.user_data["children_notes"] = None
|
||||||
|
await update.message.reply_text("Курение:", reply_markup=ReplyKeyboardMarkup(SMOKE_ALC_KB, one_time_keyboard=True, resize_keyboard=True))
|
||||||
|
return S_SMOKING
|
||||||
|
|
||||||
|
async def s_children_notes(update, context):
|
||||||
|
context.user_data["children_notes"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Курение:", reply_markup=ReplyKeyboardMarkup(SMOKE_ALC_KB, one_time_keyboard=True, resize_keyboard=True))
|
||||||
|
return S_SMOKING
|
||||||
|
|
||||||
|
async def s_smoking(update, context):
|
||||||
|
context.user_data["smoking"] = update.message.text.strip().lower()
|
||||||
|
await update.message.reply_text("Алкоголь:", reply_markup=ReplyKeyboardMarkup(SMOKE_ALC_KB, one_time_keyboard=True, resize_keyboard=True))
|
||||||
|
return S_ALCOHOL
|
||||||
|
|
||||||
|
async def s_alcohol(update, context):
|
||||||
|
context.user_data["alcohol"] = update.message.text.strip().lower()
|
||||||
|
await update.message.reply_text("Здоровье/особенности (если есть; иначе «нет»):", reply_markup=ReplyKeyboardRemove())
|
||||||
|
return S_HEALTH
|
||||||
|
|
||||||
|
async def s_health(update, context):
|
||||||
|
context.user_data["health_notes"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Хобби/интересы (теги через запятую, напр.: пение, путешествия, k‑drama):")
|
||||||
|
return S_HOBBIES_TAGS
|
||||||
|
|
||||||
|
async def s_hobbies_tags(update, context):
|
||||||
|
context.user_data["hobbies_tags"] = list_to_csv(csv_to_list(update.message.text))
|
||||||
|
await update.message.reply_text("Расскажи подробнее про интересы (свободный текст):")
|
||||||
|
return S_HOBBIES_FREE
|
||||||
|
|
||||||
|
async def s_hobbies_free(update, context):
|
||||||
|
context.user_data["hobbies_free"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Твоя основная цель (например: «брак с гражданином Кореи (F‑6)»):")
|
||||||
|
return S_GOAL
|
||||||
|
|
||||||
|
async def s_goal(update, context):
|
||||||
|
context.user_data["goal"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Пожелания к партнёру (возраст, ценности, город, религия и т.п.):")
|
||||||
|
return S_PARTNER
|
||||||
|
|
||||||
|
async def s_partner(update, context):
|
||||||
|
context.user_data["partner_prefs"] = update.message.text.strip()
|
||||||
|
await update.message.reply_text("Загрузи своё фото (аватар):")
|
||||||
|
return S_AVATAR
|
||||||
|
|
||||||
|
async def s_avatar(update, context):
|
||||||
|
if not update.message.photo:
|
||||||
|
await update.message.reply_text("Пожалуйста, пришли фотографию.")
|
||||||
|
return S_AVATAR
|
||||||
|
context.user_data["avatar_file_id"] = update.message.photo[-1].file_id
|
||||||
|
context.user_data["gallery_file_ids"] = []
|
||||||
|
await update.message.reply_text("Можно добавить до 9 дополнительных фото. Пришли их. Когда закончишь — напиши «готово».")
|
||||||
|
return S_GALLERY
|
||||||
|
|
||||||
|
async def s_gallery(update, context):
|
||||||
|
if update.message.photo:
|
||||||
|
fid = update.message.photo[-1].file_id
|
||||||
|
gallery: List[str] = context.user_data.get("gallery_file_ids", [])
|
||||||
|
if len(gallery) < 9:
|
||||||
|
gallery.append(fid)
|
||||||
|
context.user_data["gallery_file_ids"] = gallery
|
||||||
|
await update.message.reply_text(f"Добавлено фото ({len(gallery)}/9). Ещё? Или напиши «готово».")
|
||||||
|
else:
|
||||||
|
await update.message.reply_text("Лимит 9 фото достигнут. Напиши «готово».")
|
||||||
|
return S_GALLERY
|
||||||
|
|
||||||
|
if (update.message.text or "").strip().lower() != "готово":
|
||||||
|
await update.message.reply_text("Если с фото закончили — напиши «готово».")
|
||||||
|
return S_GALLERY
|
||||||
|
|
||||||
|
await update.message.reply_text("Подтверди согласие на обработку персональных данных. Напиши: «согласен» или «согласна».")
|
||||||
|
return S_CONSENTS
|
||||||
|
|
||||||
|
async def s_consents(update, context):
|
||||||
|
ans = (update.message.text or "").strip().lower()
|
||||||
|
if ans not in ("согласен", "согласна"):
|
||||||
|
await update.message.reply_text("Нужно написать «согласен» или «согласна».")
|
||||||
|
return S_CONSENTS
|
||||||
|
context.user_data["consent_personal"] = True
|
||||||
|
context.user_data["consent_policy"] = True
|
||||||
|
|
||||||
|
d = context.user_data
|
||||||
|
age = calc_age(d.get("birth_date"))
|
||||||
|
summary = (
|
||||||
|
f"Проверь данные:\n\n"
|
||||||
|
f"Имя: {d.get('full_name')}\n"
|
||||||
|
f"Пол: {d.get('gender')}\n"
|
||||||
|
f"Дата рождения: {d.get('birth_date')} (возраст: {age})\n"
|
||||||
|
f"Рост/вес: {d.get('height_cm')} см / {d.get('weight_kg')} кг\n"
|
||||||
|
f"Страна/город: {d.get('country')}, {d.get('city')}\n"
|
||||||
|
f"Гражданство: {d.get('citizenship')}\n"
|
||||||
|
f"Виза/статус: {d.get('visa_status')}\n"
|
||||||
|
f"Языки: {d.get('languages')}\n"
|
||||||
|
f"Образование/Профессия: {d.get('education')} / {d.get('occupation')}\n"
|
||||||
|
f"Доход: {d.get('income_range')}\n"
|
||||||
|
f"Религия: {d.get('religion')}\n"
|
||||||
|
f"Семейное положение: {d.get('marital_status')}\n"
|
||||||
|
f"Дети: {'да' if d.get('has_children') else 'нет'} {'(' + d.get('children_notes') + ')' if d.get('children_notes') else ''}\n"
|
||||||
|
f"Курение/Алкоголь: {d.get('smoking')} / {d.get('alcohol')}\n"
|
||||||
|
f"Здоровье: {d.get('health_notes')}\n"
|
||||||
|
f"Хобби (теги): {d.get('hobbies_tags')}\n"
|
||||||
|
f"Интересы (текст): {d.get('hobbies_free')}\n"
|
||||||
|
f"Цель: {d.get('goal')}\n"
|
||||||
|
f"Пожелания к партнёру: {d.get('partner_prefs')}\n"
|
||||||
|
f"\nЕсли всё ок — напиши «подтверждаю». Иначе /cancel и /new."
|
||||||
|
)
|
||||||
|
await update.message.reply_text(summary)
|
||||||
|
return S_CONFIRM
|
||||||
|
|
||||||
|
async def s_confirm(update, context):
|
||||||
|
if (update.message.text or "").strip().lower() != "подтверждаю":
|
||||||
|
await update.message.reply_text('Напиши «подтверждаю» для сохранения, либо /cancel.')
|
||||||
|
return S_CONFIRM
|
||||||
|
|
||||||
|
u = update.effective_user
|
||||||
|
d = context.user_data
|
||||||
|
with db_session() as s:
|
||||||
|
cand = s.query(Candidate).filter(Candidate.telegram_id == u.id).first()
|
||||||
|
if not cand:
|
||||||
|
cand = Candidate(telegram_id=u.id, username=u.username or None)
|
||||||
|
s.add(cand)
|
||||||
|
|
||||||
|
cand.full_name = d.get("full_name")
|
||||||
|
cand.gender = d.get("gender")
|
||||||
|
cand.birth_date = d.get("birth_date")
|
||||||
|
cand.height_cm = d.get("height_cm")
|
||||||
|
cand.weight_kg = d.get("weight_kg")
|
||||||
|
cand.country = d.get("country")
|
||||||
|
cand.city = d.get("city")
|
||||||
|
cand.citizenship = d.get("citizenship")
|
||||||
|
cand.visa_status = d.get("visa_status")
|
||||||
|
cand.languages = d.get("languages")
|
||||||
|
cand.education = d.get("education")
|
||||||
|
cand.occupation = d.get("occupation")
|
||||||
|
cand.income_range = d.get("income_range")
|
||||||
|
cand.religion = d.get("religion")
|
||||||
|
cand.marital_status = d.get("marital_status")
|
||||||
|
cand.has_children = d.get("has_children")
|
||||||
|
cand.children_notes = d.get("children_notes")
|
||||||
|
cand.smoking = d.get("smoking")
|
||||||
|
cand.alcohol = d.get("alcohol")
|
||||||
|
cand.health_notes = d.get("health_notes")
|
||||||
|
cand.hobbies_tags = d.get("hobbies_tags")
|
||||||
|
cand.hobbies_free = d.get("hobbies_free")
|
||||||
|
cand.goal = d.get("goal")
|
||||||
|
cand.partner_prefs = d.get("partner_prefs")
|
||||||
|
cand.avatar_file_id = d.get("avatar_file_id")
|
||||||
|
cand.gallery_file_ids = list_to_csv(d.get("gallery_file_ids", []))
|
||||||
|
cand.consent_personal = d.get("consent_personal", False)
|
||||||
|
cand.consent_policy = d.get("consent_policy", False)
|
||||||
|
cand.is_verified = False
|
||||||
|
cand.is_active = True
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
await update.message.reply_text("Готово! Анкета сохранена. Свяжемся после модерации. Спасибо!")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def cancel(update, context):
|
||||||
|
await update.message.reply_text("Ок, отменил. Для нового старта — /new")
|
||||||
|
return ConversationHandler.END
|
||||||
41
services/bot/app/handlers/profile.py
Normal file
41
services/bot/app/handlers/profile.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from telegram import Update, InputMediaPhoto
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
|
||||||
|
from app.core.db import db_session
|
||||||
|
from app.repositories.candidate_repo import CandidateRepository
|
||||||
|
from app.utils.common import csv_to_list
|
||||||
|
|
||||||
|
|
||||||
|
async def my_profile_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Команда /my — показать свою анкету."""
|
||||||
|
with db_session() as s:
|
||||||
|
repo = CandidateRepository(s)
|
||||||
|
c = repo.by_tg(update.effective_user.id)
|
||||||
|
if not c:
|
||||||
|
await update.message.reply_text("Анкета не найдена. Начни с /new")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"Твоя анкета:\n"
|
||||||
|
f"{c.full_name or '—'} (@{update.effective_user.username or ''})\n"
|
||||||
|
f"Город: {c.city or '—'}\n"
|
||||||
|
f"Цель: {c.goal or '—'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if c.avatar_file_id:
|
||||||
|
await update.message.reply_photo(photo=c.avatar_file_id, caption=text)
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(text)
|
||||||
|
|
||||||
|
gallery = csv_to_list(c.gallery_file_ids)
|
||||||
|
if gallery:
|
||||||
|
media = [InputMediaPhoto(g) for g in gallery[:10]]
|
||||||
|
await update.message.reply_media_group(media)
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_hint_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Команда /edit — пока только подсказка о редактировании."""
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Точечное редактирование будет добавлено командами /edit_*. Пока что для правок — /new (перезаполнить анкету)."
|
||||||
|
)
|
||||||
@@ -1,14 +1,64 @@
|
|||||||
import asyncio
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
from telegram.ext import Application, CommandHandler
|
from telegram.ext import Application, CommandHandler
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
async def start(update, context):
|
from app.handlers.conversation import build_conversation
|
||||||
await update.message.reply_text("Привет! Бот запущен.")
|
from app.handlers.profile import my_profile_handler, edit_hint_handler
|
||||||
async def main():
|
from app.handlers.admin import (
|
||||||
|
list_candidates_handler, candlist_pager, callback_router,
|
||||||
|
view_candidate_handler, list_start, list_gender, list_city, list_verified, list_active, list_results_pager
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
log = logging.getLogger("bot")
|
||||||
|
|
||||||
|
async def start_cmd(update, context):
|
||||||
|
await update.message.reply_text("Бот запущен. Набери /new, чтобы заполнить анкету.")
|
||||||
|
|
||||||
|
def build_app() -> Application:
|
||||||
|
if not settings.BOT_TOKEN:
|
||||||
|
raise RuntimeError("BOT_TOKEN is empty. Set it in .env")
|
||||||
app = Application.builder().token(settings.BOT_TOKEN).build()
|
app = Application.builder().token(settings.BOT_TOKEN).build()
|
||||||
app.add_handler(CommandHandler("start", start))
|
|
||||||
await app.initialize()
|
# визард пользователя
|
||||||
await app.start()
|
app.add_handler(build_conversation())
|
||||||
await app.updater.start_polling()
|
|
||||||
await app.updater.wait_until_closed()
|
# базовые
|
||||||
|
app.add_handler(CommandHandler("start", start_cmd))
|
||||||
|
app.add_handler(CommandHandler("my", my_profile_handler))
|
||||||
|
app.add_handler(CommandHandler("edit", edit_hint_handler))
|
||||||
|
|
||||||
|
# админские
|
||||||
|
app.add_handler(CommandHandler("candidates", list_candidates_handler))
|
||||||
|
app.add_handler(CommandHandler("view", view_candidate_handler))
|
||||||
|
app.add_handler(CommandHandler("list", list_start))
|
||||||
|
app.add_handler(CommandHandler("verify", lambda u, c: None)) # заглушка если где-то есть старая команда
|
||||||
|
|
||||||
|
# callback-и
|
||||||
|
from telegram.ext import CallbackQueryHandler, ConversationHandler, MessageHandler, filters
|
||||||
|
app.add_handler(CallbackQueryHandler(candlist_pager, pattern=r"^candlist:page:"))
|
||||||
|
app.add_handler(CallbackQueryHandler(callback_router, pattern=r"^cand:"))
|
||||||
|
list_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("list", list_start)],
|
||||||
|
states={
|
||||||
|
0: [CallbackQueryHandler(list_gender, pattern=r"^list:gender:")],
|
||||||
|
1: [CallbackQueryHandler(list_city, pattern=r"^list:city:"), MessageHandler(filters.TEXT & ~filters.COMMAND, list_city)],
|
||||||
|
2: [CallbackQueryHandler(list_verified, pattern=r"^list:verified:")],
|
||||||
|
3: [CallbackQueryHandler(list_active, pattern=r"^list:active:")],
|
||||||
|
},
|
||||||
|
fallbacks=[],
|
||||||
|
allow_reentry=True,
|
||||||
|
)
|
||||||
|
app.add_handler(list_conv)
|
||||||
|
app.add_handler(CallbackQueryHandler(list_results_pager, pattern=r"^listres:page:"))
|
||||||
|
return app
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
try:
|
||||||
|
log.info("Starting bot…")
|
||||||
|
log.info("DB URL masked is set: %s", "***" if settings.DATABASE_URL else "<empty>")
|
||||||
|
application = build_app()
|
||||||
|
application.run_polling(close_loop=False)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Bot crashed on startup: %s", e)
|
||||||
|
raise
|
||||||
|
|||||||
3
services/bot/app/models/__init__.py
Normal file
3
services/bot/app/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .base import Base # noqa
|
||||||
|
from .admin import Admin # noqa
|
||||||
|
from .candidate import Candidate # noqa
|
||||||
14
services/bot/app/models/admin.py
Normal file
14
services/bot/app/models/admin.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Integer, String, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
class Admin(Base):
|
||||||
|
"""Модель администратора бота."""
|
||||||
|
__tablename__ = "admins"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
telegram_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||||
|
full_name: Mapped[str | None] = mapped_column(String(120), default=None)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
6
services/bot/app/models/base.py
Normal file
6
services/bot/app/models/base.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Базовый класс для всех моделей."""
|
||||||
|
pass
|
||||||
53
services/bot/app/models/candidate.py
Normal file
53
services/bot/app/models/candidate.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import date, datetime
|
||||||
|
from sqlalchemy import Integer, String, Date, DateTime, Boolean, Float, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
class Candidate(Base):
|
||||||
|
"""Модель анкеты кандидата."""
|
||||||
|
__tablename__ = "candidates"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
telegram_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||||
|
username: Mapped[str | None] = mapped_column(String(100), default=None)
|
||||||
|
|
||||||
|
full_name: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
gender: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
birth_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
height_cm: Mapped[float | None] = mapped_column(Float)
|
||||||
|
weight_kg: Mapped[float | None] = mapped_column(Float)
|
||||||
|
country: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
city: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
citizenship: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
visa_status: Mapped[str | None] = mapped_column(String(60))
|
||||||
|
languages: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
|
||||||
|
education: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
occupation: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
income_range: Mapped[str | None] = mapped_column(String(60))
|
||||||
|
religion: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
marital_status: Mapped[str | None] = mapped_column(String(60))
|
||||||
|
has_children: Mapped[bool | None] = mapped_column(Boolean, default=None)
|
||||||
|
children_notes: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
|
||||||
|
smoking: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
alcohol: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
health_notes: Mapped[str | None] = mapped_column(String(300))
|
||||||
|
|
||||||
|
hobbies_tags: Mapped[str | None] = mapped_column(String(300))
|
||||||
|
hobbies_free: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
goal: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
partner_prefs: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
avatar_file_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
gallery_file_ids: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
consent_personal: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
consent_policy: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
24
services/bot/app/repositories/admin_repo.py
Normal file
24
services/bot/app/repositories/admin_repo.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models.admin import Admin
|
||||||
|
|
||||||
|
class AdminRepository:
|
||||||
|
"""Работа с таблицей администраторов."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
self.s = session
|
||||||
|
|
||||||
|
def is_admin(self, telegram_id: int) -> bool:
|
||||||
|
"""Проверка, является ли пользователь администратором."""
|
||||||
|
return self.s.query(Admin).filter(Admin.telegram_id == telegram_id).first() is not None
|
||||||
|
|
||||||
|
def add_admin(self, telegram_id: int, full_name: str | None = None) -> Admin:
|
||||||
|
"""Добавить нового администратора."""
|
||||||
|
admin = Admin(telegram_id=telegram_id, full_name=full_name)
|
||||||
|
self.s.add(admin)
|
||||||
|
self.s.commit()
|
||||||
|
return admin
|
||||||
|
|
||||||
|
def get_all_admins(self) -> list[Admin]:
|
||||||
|
"""Получить всех администраторов."""
|
||||||
|
return self.s.query(Admin).all()
|
||||||
40
services/bot/app/repositories/candidate_repo.py
Normal file
40
services/bot/app/repositories/candidate_repo.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models.candidate import Candidate
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRepository:
|
||||||
|
"""Работа с таблицей кандидатов."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
self.s = session
|
||||||
|
|
||||||
|
def by_tg(self, telegram_id: int) -> Candidate | None:
|
||||||
|
"""Найти анкету по Telegram ID."""
|
||||||
|
return self.s.query(Candidate).filter(Candidate.telegram_id == telegram_id).first()
|
||||||
|
|
||||||
|
def by_id(self, candidate_id: int) -> Candidate | None:
|
||||||
|
"""Найти анкету по ID."""
|
||||||
|
return self.s.get(Candidate, candidate_id)
|
||||||
|
|
||||||
|
def all(self, limit: int = 100) -> list[Candidate]:
|
||||||
|
"""Получить список всех анкет (с лимитом)."""
|
||||||
|
return self.s.query(Candidate).order_by(Candidate.created_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
def add(self, candidate: Candidate) -> Candidate:
|
||||||
|
"""Добавить новую анкету."""
|
||||||
|
self.s.add(candidate)
|
||||||
|
self.s.commit()
|
||||||
|
self.s.refresh(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def update(self, candidate: Candidate) -> Candidate:
|
||||||
|
"""Обновить существующую анкету."""
|
||||||
|
self.s.commit()
|
||||||
|
self.s.refresh(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def delete(self, candidate: Candidate) -> None:
|
||||||
|
"""Удалить анкету."""
|
||||||
|
self.s.delete(candidate)
|
||||||
|
self.s.commit()
|
||||||
12
services/bot/app/services/admin_bootstrap.py
Normal file
12
services/bot/app/services/admin_bootstrap.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.admin import Admin
|
||||||
|
|
||||||
|
def ensure_primary_admin(session: Session) -> None:
|
||||||
|
if settings.PRIMARY_ADMIN_TELEGRAM_ID and not session.query(Admin).filter(
|
||||||
|
Admin.telegram_id == settings.PRIMARY_ADMIN_TELEGRAM_ID
|
||||||
|
).first():
|
||||||
|
session.add(Admin(telegram_id=settings.PRIMARY_ADMIN_TELEGRAM_ID, full_name="Primary Admin", created_at=datetime.utcnow()))
|
||||||
|
session.commit()
|
||||||
15
services/bot/app/utils/common.py
Normal file
15
services/bot/app/utils/common.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
def csv_to_list(s: Optional[str]) -> List[str]:
|
||||||
|
return [x.strip() for x in (s or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
def list_to_csv(items: List[str]) -> str:
|
||||||
|
return ", ".join([i.strip() for i in items if i.strip()])
|
||||||
|
|
||||||
|
def calc_age(born: Optional[date]) -> Optional[int]:
|
||||||
|
if not born:
|
||||||
|
return None
|
||||||
|
today = date.today()
|
||||||
|
return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||||
Reference in New Issue
Block a user