diff --git a/app/database/crud/web_api_token.py b/app/database/crud/web_api_token.py index c84b9426..e8c3c71f 100644 --- a/app/database/crud/web_api_token.py +++ b/app/database/crud/web_api_token.py @@ -6,6 +6,7 @@ from typing import Iterable, List, Optional from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database.models import WebApiToken @@ -14,8 +15,12 @@ async def list_tokens( db: AsyncSession, *, include_inactive: bool = False, + user_id: Optional[int] = None, ) -> List[WebApiToken]: - query = select(WebApiToken) + query = select(WebApiToken).options(selectinload(WebApiToken.user)) + + if user_id is not None: + query = query.where(WebApiToken.user_id == user_id) if not include_inactive: query = query.where(WebApiToken.is_active.is_(True)) @@ -27,12 +32,20 @@ async def list_tokens( async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]: - return await db.get(WebApiToken, token_id) + query = ( + select(WebApiToken) + .options(selectinload(WebApiToken.user)) + .where(WebApiToken.id == token_id) + ) + result = await db.execute(query) + return result.scalar_one_or_none() async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]: - query = select(WebApiToken).where( - WebApiToken.token_hash == token_hash + query = ( + select(WebApiToken) + .options(selectinload(WebApiToken.user)) + .where(WebApiToken.token_hash == token_hash) ) result = await db.execute(query) return result.scalar_one_or_none() @@ -47,8 +60,10 @@ async def create_token( description: Optional[str] = None, expires_at: Optional[datetime] = None, created_by: Optional[str] = None, + user_id: Optional[int] = None, ) -> WebApiToken: token = WebApiToken( + user_id=user_id, name=name, token_hash=token_hash, token_prefix=token_prefix, diff --git a/app/database/models.py b/app/database/models.py index 1780d75a..8ac07338 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -387,6 +387,7 @@ class User(Base): referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") discount_offers = relationship("DiscountOffer", back_populates="user") promo_offer_logs = relationship("PromoOfferLog", back_populates="user") + web_api_tokens = relationship("WebApiToken", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0) @@ -1238,6 +1239,7 @@ class WebApiToken(Base): __tablename__ = "web_api_tokens" id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) name = Column(String(255), nullable=False) token_hash = Column(String(128), nullable=False, unique=True, index=True) token_prefix = Column(String(32), nullable=False, index=True) @@ -1249,6 +1251,7 @@ class WebApiToken(Base): last_used_ip = Column(String(64), nullable=True) is_active = Column(Boolean, default=True, nullable=False) created_by = Column(String(255), nullable=True) + user = relationship("User", back_populates="web_api_tokens") def __repr__(self) -> str: status = "active" if self.is_active else "revoked" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 90d637ad..f6e112d0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -2434,6 +2434,7 @@ async def create_web_api_tokens_table() -> bool: create_sql = """ CREATE TABLE web_api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NULL, name VARCHAR(255) NOT NULL, token_hash VARCHAR(128) NOT NULL UNIQUE, token_prefix VARCHAR(32) NOT NULL, @@ -2444,16 +2445,19 @@ async def create_web_api_tokens_table() -> bool: last_used_at DATETIME NULL, last_used_ip VARCHAR(64) NULL, is_active BOOLEAN NOT NULL DEFAULT 1, - created_by VARCHAR(255) NULL + created_by VARCHAR(255) NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active); CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix); CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at); + CREATE INDEX ix_web_api_tokens_user_id ON web_api_tokens(user_id); """ elif db_type == "postgresql": create_sql = """ CREATE TABLE web_api_tokens ( id SERIAL PRIMARY KEY, + user_id INTEGER NULL, name VARCHAR(255) NOT NULL, token_hash VARCHAR(128) NOT NULL UNIQUE, token_prefix VARCHAR(32) NOT NULL, @@ -2464,16 +2468,19 @@ async def create_web_api_tokens_table() -> bool: last_used_at TIMESTAMP NULL, last_used_ip VARCHAR(64) NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_by VARCHAR(255) NULL + created_by VARCHAR(255) NULL, + CONSTRAINT fk_web_api_tokens_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active); CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix); CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at); + CREATE INDEX ix_web_api_tokens_user_id ON web_api_tokens(user_id); """ else: create_sql = """ CREATE TABLE web_api_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, name VARCHAR(255) NOT NULL, token_hash VARCHAR(128) NOT NULL UNIQUE, token_prefix VARCHAR(32) NOT NULL, @@ -2484,7 +2491,8 @@ async def create_web_api_tokens_table() -> bool: last_used_at TIMESTAMP NULL, last_used_ip VARCHAR(64) NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_by VARCHAR(255) NULL + created_by VARCHAR(255) NULL, + CONSTRAINT fk_web_api_tokens_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active); CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix); @@ -2500,6 +2508,71 @@ async def create_web_api_tokens_table() -> bool: return False +async def ensure_web_api_tokens_user_column() -> bool: + column_exists = await check_column_exists("web_api_tokens", "user_id") + index_name = "ix_web_api_tokens_user_id" + constraint_name = "fk_web_api_tokens_user_id" + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if not column_exists: + if db_type == "sqlite": + await conn.execute(text("ALTER TABLE web_api_tokens ADD COLUMN user_id INTEGER")) + elif db_type == "postgresql": + await conn.execute(text("ALTER TABLE web_api_tokens ADD COLUMN user_id INTEGER")) + elif db_type == "mysql": + await conn.execute(text("ALTER TABLE web_api_tokens ADD COLUMN user_id INT NULL")) + else: + logger.error(f"Неподдерживаемый тип БД для web_api_tokens.user_id: {db_type}") + return False + logger.info("Добавлена колонка web_api_tokens.user_id") + + if db_type in {"postgresql", "mysql"}: + constraint_exists = await check_constraint_exists("web_api_tokens", constraint_name) + if not constraint_exists: + try: + await conn.execute( + text( + "ALTER TABLE web_api_tokens " + "ADD CONSTRAINT fk_web_api_tokens_user_id " + "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL" + ) + ) + logger.info("Добавлено ограничение fk_web_api_tokens_user_id") + except Exception as constraint_error: + logger.warning( + "Не удалось создать ограничение fk_web_api_tokens_user_id: %s", + constraint_error, + ) + + index_exists = await check_index_exists("web_api_tokens", index_name) + if not index_exists: + try: + async with engine.begin() as conn: + db_type = await get_database_type() + if db_type == "sqlite": + await conn.execute( + text("CREATE INDEX IF NOT EXISTS ix_web_api_tokens_user_id ON web_api_tokens(user_id)") + ) + elif db_type == "postgresql": + await conn.execute( + text("CREATE INDEX IF NOT EXISTS ix_web_api_tokens_user_id ON web_api_tokens(user_id)") + ) + elif db_type == "mysql": + await conn.execute(text("CREATE INDEX ix_web_api_tokens_user_id ON web_api_tokens(user_id)")) + logger.info("Создан индекс ix_web_api_tokens_user_id") + except Exception as index_error: + logger.warning(f"Не удалось создать индекс ix_web_api_tokens_user_id: {index_error}") + + return True + + except Exception as error: + logger.error(f"Ошибка обновления web_api_tokens.user_id: {error}") + return False + + async def create_privacy_policies_table() -> bool: table_exists = await check_table_exists("privacy_policies") if table_exists: @@ -2799,6 +2872,7 @@ async def run_universal_migration(): web_api_tokens_ready = await create_web_api_tokens_table() if web_api_tokens_ready: logger.info("✅ Таблица web_api_tokens готова") + await ensure_web_api_tokens_user_column() else: logger.warning("⚠️ Проблемы с таблицей web_api_tokens") diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 479d5dfb..10a2e6b1 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -12,10 +12,12 @@ from app.keyboards.inline import ( get_main_menu_keyboard, get_language_selection_keyboard, get_info_menu_keyboard, + get_api_access_keyboard, ) from app.localization.texts import get_texts, get_rules -from app.database.models import User +from app.database.models import User, WebApiToken from app.database.crud.user_message import get_random_active_message +from app.database.crud import web_api_token as web_api_token_crud from app.services.subscription_checkout_service import ( has_subscription_checkout_draft, should_offer_checkout_resume, @@ -29,6 +31,8 @@ from app.utils.promo_offer import ( from app.services.privacy_policy_service import PrivacyPolicyService from app.services.public_offer_service import PublicOfferService from app.services.faq_service import FaqService +from app.services.web_api_token_service import web_api_token_service +from app.utils.formatters import format_datetime logger = logging.getLogger(__name__) @@ -132,6 +136,150 @@ async def show_info_menu( await callback.answer() +def _build_api_access_caption( + texts, + token: WebApiToken | None, + *, + has_active_token: bool, +) -> str: + header = texts.t("API_ACCESS_HEADER", "🔑 API доступ") + + parts: list[str] = [header] + + if not token: + parts.append( + texts.t( + "API_ACCESS_NO_TOKEN", + ( + "У вас еще нет активного API ключа.\n" + "Нажмите кнопку ниже, чтобы выпустить первый ключ." + ), + ) + ) + else: + created_at = format_datetime(token.created_at) if token.created_at else "—" + last_used = ( + format_datetime(token.last_used_at) + if token.last_used_at + else texts.t("API_ACCESS_LAST_USED_NEVER", "еще не использовался") + ) + last_ip_line = "" + if token.last_used_ip: + last_ip_line = "\n" + texts.t( + "API_ACCESS_LAST_IP_LINE", + "Последний IP: {ip}", + ).format(ip=token.last_used_ip) + + template_key = "API_ACCESS_ACTIVE_TOKEN" if has_active_token else "API_ACCESS_INACTIVE_TOKEN" + parts.append( + texts.t( + template_key, + ( + "Ваш текущий API ключ выпущен {created_at}.\n" + "Первые символы: {prefix}.\n" + "Последнее использование: {last_used}.{last_ip_line}\n" + "Чтобы выпустить новый ключ, нажмите кнопку ниже — предыдущий будет отключен." + ) + if has_active_token + else ( + "Последний ключ выпущен {created_at}.\n" + "Первые символы: {prefix}.\n" + "Последнее использование: {last_used}.{last_ip_line}\n" + "Чтобы получить доступ, создайте новый ключ." + ), + ).format( + created_at=created_at, + prefix=token.token_prefix, + last_used=last_used, + last_ip_line=last_ip_line, + ) + ) + + parts.append( + texts.t( + "API_ACCESS_SECURITY_HINT", + "Берегите ключ и не передавайте его другим людям.", + ) + ) + + return "\n\n".join(part for part in parts if part) + + +async def show_api_access( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + *, + skip_callback_answer: bool = False, +) -> None: + texts = get_texts(db_user.language) + tokens = await web_api_token_crud.list_tokens( + db, + include_inactive=True, + user_id=db_user.id, + ) + + active_token = next((token for token in tokens if token.is_active), None) + latest_token = tokens[0] if tokens else None + token_to_show = active_token or latest_token + + caption = _build_api_access_caption( + texts, + token_to_show, + has_active_token=bool(active_token), + ) + + await edit_or_answer_photo( + callback=callback, + caption=caption, + keyboard=get_api_access_keyboard(db_user.language), + parse_mode="HTML", + ) + + if not skip_callback_answer: + await callback.answer() + + +async def generate_api_access_token( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + texts = get_texts(db_user.language) + + token_value, token = await web_api_token_service.issue_user_token(db, db_user) + await db.commit() + + logger.info( + "🔑 Пользователь %s выпустил новый API ключ (token_id=%s)", + db_user.telegram_id, + token.id, + ) + + message_text = texts.t( + "API_ACCESS_NEW_TOKEN", + ( + "Ваш новый API ключ:\n" + "{token}\n\n" + "Сохраните его — повторно показать не получится." + ), + ).format(token=token_value) + + await callback.message.answer(message_text, parse_mode="HTML") + + await show_api_access( + callback, + db_user, + db, + skip_callback_answer=True, + ) + + await callback.answer( + texts.t("API_ACCESS_GENERATED_ALERT", "Новый API ключ создан"), + show_alert=True, + ) + + async def show_faq_pages( callback: types.CallbackQuery, db_user: User, @@ -782,6 +930,16 @@ def register_handlers(dp: Dispatcher): F.data == "menu_info", ) + dp.callback_query.register( + show_api_access, + F.data == "menu_api_access", + ) + + dp.callback_query.register( + generate_api_access_token, + F.data == "api_access_generate", + ) + dp.callback_query.register( show_faq_pages, F.data == "menu_faq", diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 0d86e119..847c2ff5 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -314,6 +314,13 @@ def get_info_menu_keyboard( ) ]) + buttons.append([ + InlineKeyboardButton( + text=texts.t("MENU_API_ACCESS", "🔑 API доступ"), + callback_data="menu_api_access", + ) + ]) + buttons.append([ InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules") ]) @@ -347,6 +354,22 @@ def get_info_menu_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) +def get_api_access_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + texts = get_texts(language) + + buttons = [ + [ + InlineKeyboardButton( + text=texts.t("API_ACCESS_GENERATE_BUTTON", "🔄 Выпустить ключ"), + callback_data="api_access_generate", + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")], + ] + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: if not settings.is_happ_download_button_enabled(): return None diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index ffa15770..767340fb 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -305,6 +305,7 @@ "MENU_INFO": "ℹ️ Info", "MENU_INFO_HEADER": "ℹ️ Info", "MENU_INFO_PROMPT": "Choose a section:", + "MENU_API_ACCESS": "🔑 API key", "MENU_PROMOCODE": "🎫 Promo code", "MENU_REFERRALS": "🤝 Referral program", "MENU_RULES": "📋 Service rules", @@ -759,3 +760,13 @@ "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing", "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled." } + "API_ACCESS_HEADER": "🔑 API access", + "API_ACCESS_NO_TOKEN": "You don't have an active API key yet.\nTap the button below to issue your first key.\n\nEach new key disables the previous one.", + "API_ACCESS_LAST_USED_NEVER": "not used yet", + "API_ACCESS_LAST_IP_LINE": "Last IP: {ip}", + "API_ACCESS_ACTIVE_TOKEN": "Your current API key was issued on {created_at}.\nFirst characters: {prefix}.\nLast used: {last_used}.{last_ip_line}\nTap the button below to issue a new key — the previous one will be revoked.", + "API_ACCESS_INACTIVE_TOKEN": "The last key was issued on {created_at}.\nFirst characters: {prefix}.\nLast used: {last_used}.{last_ip_line}\nIssue a new key to regain access — old keys no longer work.", + "API_ACCESS_SECURITY_HINT": "Store the key securely and never share it with anyone.", + "API_ACCESS_GENERATE_BUTTON": "🔄 Issue key", + "API_ACCESS_NEW_TOKEN": "Here is your new API key:\n{token}\n\nSave it now — it cannot be shown again. Previous keys have been revoked.", + "API_ACCESS_GENERATED_ALERT": "New API key issued", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 06d7a051..9de7e32e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -236,6 +236,17 @@ "MENU_INFO_PROMPT": "Выберите раздел:", "MENU_PRIVACY_POLICY": "🛡️ Политика конф.", "MENU_PUBLIC_OFFER": "📄 Оферта", + "MENU_API_ACCESS": "🔑 API доступ", + "API_ACCESS_HEADER": "🔑 API доступ", + "API_ACCESS_NO_TOKEN": "У вас ещё нет активного API ключа.\nНажмите кнопку ниже, чтобы выпустить первый ключ.\n\nКаждый новый ключ отключает предыдущие.", + "API_ACCESS_LAST_USED_NEVER": "ещё не использовался", + "API_ACCESS_LAST_IP_LINE": "Последний IP: {ip}", + "API_ACCESS_ACTIVE_TOKEN": "Ваш текущий API ключ выпущен {created_at}.\nПервые символы: {prefix}.\nПоследнее использование: {last_used}.{last_ip_line}\nЧтобы выпустить новый ключ, нажмите кнопку ниже — предыдущий будет отключен.", + "API_ACCESS_INACTIVE_TOKEN": "Последний ключ выпущен {created_at}.\nПервые символы: {prefix}.\nПоследнее использование: {last_used}.{last_ip_line}\nЧтобы получить доступ, создайте новый ключ — старые больше не работают.", + "API_ACCESS_SECURITY_HINT": "Храните ключ в безопасном месте и не передавайте его другим людям.", + "API_ACCESS_GENERATE_BUTTON": "🔄 Выпустить ключ", + "API_ACCESS_NEW_TOKEN": "Вот ваш новый API ключ:\n{token}\n\nСохраните его сейчас — повторно показать не получится. Предыдущие ключи отключены.", + "API_ACCESS_GENERATED_ALERT": "Новый API ключ создан", "PRIVACY_POLICY_HEADER": "🛡️ Политика конфиденциальности", "PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.", "PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.", diff --git a/app/services/web_api_token_service.py b/app/services/web_api_token_service.py index 6beb06fd..d08b6dcf 100644 --- a/app/services/web_api_token_service.py +++ b/app/services/web_api_token_service.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud import web_api_token as crud -from app.database.models import WebApiToken +from app.database.models import User, WebApiToken from app.database.universal_migration import ensure_default_web_api_token from app.utils.security import generate_api_token, hash_api_token @@ -66,10 +66,14 @@ class WebApiTokenService: expires_at: Optional[datetime] = None, created_by: Optional[str] = None, token_value: Optional[str] = None, + user: Optional[User] = None, + user_id: Optional[int] = None, ) -> Tuple[str, WebApiToken]: plain_token = token_value or generate_api_token() token_hash = self.hash_token(plain_token) + resolved_user_id = user.id if user else user_id + token = await crud.create_token( db, name=name, @@ -78,6 +82,43 @@ class WebApiTokenService: description=description, expires_at=expires_at, created_by=created_by, + user_id=resolved_user_id, + ) + + return plain_token, token + + async def issue_user_token( + self, + db: AsyncSession, + user: User, + ) -> Tuple[str, WebApiToken]: + existing_tokens = await crud.list_tokens( + db, + include_inactive=True, + user_id=user.id, + ) + + now = datetime.utcnow() + has_updates = False + for token in existing_tokens: + if token.is_active: + token.is_active = False + token.updated_at = now + has_updates = True + + if has_updates: + await db.flush() + + token_name = f"User {user.telegram_id}" + description = "Generated via Telegram bot" + created_by = f"telegram:{user.telegram_id}" + + plain_token, token = await self.create_token( + db, + name=token_name, + description=description, + created_by=created_by, + user=user, ) return plain_token, token diff --git a/app/webapi/routes/tokens.py b/app/webapi/routes/tokens.py index cef1fd91..55908be4 100644 --- a/app/webapi/routes/tokens.py +++ b/app/webapi/routes/tokens.py @@ -1,8 +1,11 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Response, Security, status +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status from sqlalchemy.ext.asyncio import AsyncSession +from app.database.crud.user import get_user_by_id from app.database.crud.web_api_token import ( delete_token, get_token_by_id, @@ -30,6 +33,8 @@ def _serialize(token: WebApiToken) -> TokenResponse: last_used_at=token.last_used_at, last_used_ip=token.last_used_ip, created_by=token.created_by, + user_id=token.user_id, + user_telegram_id=getattr(token.user, "telegram_id", None), ) @@ -37,8 +42,13 @@ def _serialize(token: WebApiToken) -> TokenResponse: async def get_tokens( _: WebApiToken = Security(require_api_token), db: AsyncSession = Depends(get_db_session), + user_id: Optional[int] = Query(default=None, description="Фильтр по ID пользователя"), ) -> list[TokenResponse]: - tokens = await list_tokens(db, include_inactive=True) + tokens = await list_tokens( + db, + include_inactive=True, + user_id=user_id, + ) return [_serialize(token) for token in tokens] @@ -48,12 +58,19 @@ async def create_token( actor: WebApiToken = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TokenCreateResponse: + target_user = None + if payload.user_id is not None: + target_user = await get_user_by_id(db, payload.user_id) + if not target_user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + token_value, token = await web_api_token_service.create_token( db, name=payload.name.strip(), description=payload.description, expires_at=payload.expires_at, created_by=actor.name, + user=target_user, ) await db.commit() diff --git a/app/webapi/schemas/tokens.py b/app/webapi/schemas/tokens.py index d923ab65..0019a29f 100644 --- a/app/webapi/schemas/tokens.py +++ b/app/webapi/schemas/tokens.py @@ -18,12 +18,24 @@ class TokenResponse(BaseModel): last_used_at: Optional[datetime] = None last_used_ip: Optional[str] = None created_by: Optional[str] = None + user_id: Optional[int] = Field( + default=None, + description="ID пользователя, которому принадлежит токен", + ) + user_telegram_id: Optional[int] = Field( + default=None, + description="Telegram ID владельца токена", + ) class TokenCreateRequest(BaseModel): name: str description: Optional[str] = None expires_at: Optional[datetime] = None + user_id: Optional[int] = Field( + default=None, + description="ID пользователя, для которого выпускается токен", + ) class TokenCreateResponse(TokenResponse): diff --git a/migrations/alembic/versions/d2a9443a9f47_add_user_link_to_web_api_tokens.py b/migrations/alembic/versions/d2a9443a9f47_add_user_link_to_web_api_tokens.py new file mode 100644 index 00000000..90cef86d --- /dev/null +++ b/migrations/alembic/versions/d2a9443a9f47_add_user_link_to_web_api_tokens.py @@ -0,0 +1,72 @@ +"""add user link to web api tokens""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine import reflection + +revision: str = "d2a9443a9f47" +down_revision: Union[str, None] = "8fd1e338eb45" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +TABLE_NAME = "web_api_tokens" +COLUMN_NAME = "user_id" +FK_NAME = "fk_web_api_tokens_user_id" +INDEX_NAME = "ix_web_api_tokens_user_id" + + +def _column_exists(inspector: reflection.Inspector) -> bool: + return COLUMN_NAME in [column["name"] for column in inspector.get_columns(TABLE_NAME)] + + +def _fk_exists(inspector: reflection.Inspector) -> bool: + return any(fk.get("name") == FK_NAME for fk in inspector.get_foreign_keys(TABLE_NAME)) + + +def _index_exists(inspector: reflection.Inspector) -> bool: + return any(index.get("name") == INDEX_NAME for index in inspector.get_indexes(TABLE_NAME)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = reflection.Inspector.from_engine(bind) + + if not _column_exists(inspector): + op.add_column(TABLE_NAME, sa.Column(COLUMN_NAME, sa.Integer(), nullable=True)) + inspector = reflection.Inspector.from_engine(bind) + + if bind.dialect.name in {"postgresql", "mysql"} and not _fk_exists(inspector): + try: + op.create_foreign_key( + FK_NAME, + TABLE_NAME, + "users", + [COLUMN_NAME], + ["id"], + ondelete="SET NULL", + ) + except Exception: + # Constraint creation can fail if duplicates exist; skip to keep migration resilient. + pass + + inspector = reflection.Inspector.from_engine(bind) + if not _index_exists(inspector): + op.create_index(INDEX_NAME, TABLE_NAME, [COLUMN_NAME]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = reflection.Inspector.from_engine(bind) + + if _index_exists(inspector): + op.drop_index(INDEX_NAME, table_name=TABLE_NAME) + + inspector = reflection.Inspector.from_engine(bind) + if _fk_exists(inspector): + op.drop_constraint(FK_NAME, TABLE_NAME, type_="foreignkey") + + inspector = reflection.Inspector.from_engine(bind) + if _column_exists(inspector): + op.drop_column(TABLE_NAME, COLUMN_NAME)