From efa1c26e5da1864b45e0e4929a78797684dd0ae8 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Fri, 29 May 2026 16:10:40 +0000 Subject: [PATCH] Email improvements: domain-aware portal URL, embedded logo, fix product list color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store request origin domain in PendingAccessRequest.portal_url - Use per-request portal URL in approval/rejection emails - Embed logo as base64 so it displays without external image loading - Fix 'Предоставлен доступ к продуктам' text color to match body color - Switch Telegram polling to 30-second interval with single-worker flock fix --- app/main.py | 242 ++++++++++++++++++++++++++++++++++++++++++++++++- app/models.py | 1 + app/runtime.py | 1 + 3 files changed, 239 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index a66fa47..0712553 100644 --- a/app/main.py +++ b/app/main.py @@ -142,6 +142,223 @@ def _make_approval_keyboard(req_id: str) -> dict: ] } + +_tg_poll_offset: int = 0 +_tg_poll_lock_file = None + +async def _telegram_poll_loop(): + """Poll Telegram for callback_query updates every 5 minutes.""" + import asyncio as _asyncio + import json as _jp + global _tg_poll_offset + await _asyncio.sleep(10) # wait for app to fully start + while True: + try: + result = _tg_api("getUpdates", { + "offset": _tg_poll_offset, + "timeout": 0, + "allowed_updates": ["callback_query"], + }) + updates = result.get("result", []) + for upd in updates: + _tg_poll_offset = upd["update_id"] + 1 + cq = upd.get("callback_query") + if cq: + try: + await _process_callback_query(cq) + except Exception as ex: + log_event("tg_callback_error", error=str(ex)) + except Exception as ex: + log_event("tg_poll_error", error=str(ex)) + await _asyncio.sleep(30) # 30 seconds + +async def _process_callback_query(cq: dict): + import json as _jc + import datetime as _dtc + import re as _rec + from database import SessionLocal as _SL + + cq_id = cq["id"] + cb_data = cq.get("data", "") + chat_id = cq["message"]["chat"]["id"] + msg_id = cq["message"]["message_id"] + + try: + _tg_api("answerCallbackQuery", {"callback_query_id": cq_id}) + except Exception: + pass + + approve_match = _rec.match(r'^a(\d+)_(.+)$', cb_data) + reject_match = _rec.match(r'^r_(.+)$', cb_data) + + if not approve_match and not reject_match: + return + + req_id = approve_match.group(2) if approve_match else reject_match.group(1) + + db = _SL() + try: + pending = db.get(PendingAccessRequest, req_id) + if not pending: + _tg_api("editMessageText", { + "chat_id": chat_id, "message_id": msg_id, + "text": "Запрос не найден (возможно уже обработан).", + }) + return + + if pending.status != "pending": + _tg_api("editMessageText", { + "chat_id": chat_id, "message_id": msg_id, + "text": f"Запрос уже обработан: {pending.status}.", + }) + return + + products = _jc.loads(pending.products_json or "[]") + portal_url = pending.portal_url or PORTAL_URL + + if approve_match: + days = int(approve_match.group(1)) + password = _generate_password() + username = pending.email + + if db.scalar(select(User).where(User.username == username)): + import secrets as _sec + username = pending.email.split("@")[0] + "_" + _sec.token_hex(3) + + expires = _dtc.datetime.now(_dtc.timezone.utc) + _dtc.timedelta(days=days) + parts = pending.name.strip().split(None, 1) + new_user = User( + username=username, + password_hash=hash_password(password), + expires_at=expires, + active=True, + is_admin=False, + first_name=parts[0] if parts else "", + last_name=parts[1] if len(parts) > 1 else "", + ) + db.add(new_user) + db.flush() + + if products: + from sqlalchemy import func as _func2 + matched = db.scalars( + select(Service).where( + _func2.lower(Service.name).in_([p.lower() for p in products]), + Service.active == True, + ) + ).all() + for svc in matched: + db.add(UserServiceAccess(user_id=new_user.id, service_id=svc.id)) + + db.commit() + + products_html = "" + if products: + items = "".join(f"
  • {p}
  • " for p in products) + products_html = ( + '

    Предоставлен доступ к продуктам:

    ' + f'' + ) + + day_word = "день" if days == 1 else ("дня" if days < 5 else "дней") + html_email = f""" + + +
    + + + + +
    + MONT
    +

    Доступ к Инфраструктурному полигону MONT

    +

    Ваш запрос одобрен

    +
    +
    +

    Здравствуйте, {pending.name}!
    + Вам предоставлен доступ к полигону на {days} {day_word}.

    + + + + + + + + + +
    Адрес портала{portal_url}
    Логин{username}
    Пароль{password}
    Доступ до{expires.strftime("%d.%m.%Y")}
    + {products_html} + +
    + Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на mont@mont.ru +
    +""" + + pending.status = "approved" + db.commit() + + try: + _send_email(pending.email, "Доступ к Инфраструктурному полигону MONT", html_email) + email_status = "Email отправлен" + except Exception as ex: + log_event("email_send_error", error=str(ex)) + email_status = f"Ошибка email: {ex}" + + _tg_api("editMessageText", { + "chat_id": chat_id, "message_id": msg_id, + "text": ( + f"✅ Одобрено на {days} {day_word}\n" + f"👤 Логин: `{username}`\n" + f"🔑 Пароль: `{password}`\n" + f"📧 {email_status}" + ), + "parse_mode": "Markdown", + }) + + elif reject_match: + manager_contact = pending.manager if pending.manager else "менеджера MONT" + html_email = f""" + + +
    + + + + +
    + MONT
    +

    Запрос на доступ к полигону MONT

    +
    +
    +

    Здравствуйте, {pending.name}!

    + К сожалению, на данный момент мы не можем предоставить доступ к полигону.

    +

    + Для уточнения деталей свяжитесь с {manager_contact}.
    + Если не знаете кто ваш менеджер — напишите на mont@mont.ru.

    +
    + С уважением, команда MONT +
    +""" + + pending.status = "rejected" + db.commit() + + try: + _send_email(pending.email, "Запрос на доступ к полигону MONT", html_email) + email_status = "Email отправлен" + except Exception as ex: + log_event("email_send_error", error=str(ex)) + email_status = f"Ошибка email: {ex}" + + _tg_api("editMessageText", { + "chat_id": chat_id, "message_id": msg_id, + "text": f"❌ Отклонено. {email_status}", + }) + finally: + db.close() + app = FastAPI(title="MONT - инфрастуктурный полигон") app.mount("/static", StaticFiles(directory="static"), name="static") @@ -241,8 +458,17 @@ async def mobile_block_middleware(request: Request, call_next): @app.on_event("startup") -def startup_event(): +async def startup_event(): + global _tg_poll_lock_file on_startup() + import asyncio as _aio, fcntl as _fcntl + _lf = open("/tmp/portal-tg-poll.lock", "w") + try: + _fcntl.flock(_lf.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB) + _tg_poll_lock_file = _lf + _aio.create_task(_telegram_poll_loop()) + except BlockingIOError: + _lf.close() @app.get("/", response_class=HTMLResponse) @@ -550,7 +776,7 @@ async def telegram_webhook(request: Request, db: Session = Depends(get_db)): return {"ok": True} products = _jw.loads(pending.products_json or "[]") - portal_url = PORTAL_URL + portal_url = pending.portal_url or PORTAL_URL if approve_match: days = int(approve_match.group(1)) @@ -593,7 +819,7 @@ async def telegram_webhook(request: Request, db: Session = Depends(get_db)): products_html = "" if products: items = "".join(f"
  • {p}
  • " for p in products) - products_html = f"

    Предоставлен доступ к продуктам:

    " + products_html = f"

    Предоставлен доступ к продуктам:

    " html_email = f""" @@ -601,7 +827,7 @@ async def telegram_webhook(request: Request, db: Session = Depends(get_db)):
    - MONT
    + MONT

    Доступ к Инфраструктурному полигону MONT

    Ваш запрос одобрен


    @@ -662,7 +888,7 @@ async def telegram_webhook(request: Request, db: Session = Depends(get_db)):
    @@ -793,10 +1019,16 @@ async def request_access(request: Request, db: Session = Depends(get_db)): # save pending request import json as _j2 req_id = _secrets.token_urlsafe(8)[:12] + _origin = request.headers.get('origin', '') + if 'stand.mont.ru' in _origin: + _req_portal_url = 'https://stand.mont.ru' + else: + _req_portal_url = PORTAL_URL pending = PendingAccessRequest( id=req_id, name=name, company=company, email=email, phone=phone, manager=manager, products_json=_j2.dumps(products, ensure_ascii=False), + portal_url=_req_portal_url, ) db.add(pending) db.commit() diff --git a/app/models.py b/app/models.py index dac7a96..96ebb9a 100644 --- a/app/models.py +++ b/app/models.py @@ -127,5 +127,6 @@ class PendingAccessRequest(Base): phone: Mapped[str] = mapped_column(String(64)) manager: Mapped[str] = mapped_column(String(256), default="") products_json: Mapped[str] = mapped_column(Text, default="[]") + portal_url: Mapped[str] = mapped_column(String(256), default="") status: Mapped[str] = mapped_column(String(16), default="pending") created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) diff --git a/app/runtime.py b/app/runtime.py index 16b4107..8b74e10 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -808,6 +808,7 @@ def ensure_schema_compatibility() -> None: conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''")) + conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute( text( """
    - MONT
    + MONT

    Запрос на доступ к полигону MONT