Revert "Add user API tokens and verification endpoint"

This commit is contained in:
Egor
2025-10-08 01:26:42 +03:00
committed by GitHub
parent 147dc0343c
commit 422686dd2c
17 changed files with 4 additions and 615 deletions

View File

@@ -255,7 +255,6 @@ class Settings(BaseSettings):
WEB_API_DEFAULT_TOKEN: Optional[str] = None
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
USER_API_TOKEN_HASH_ALGORITHM: str = "sha256"
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = "app-config.json"

View File

@@ -39,7 +39,6 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.api_token),
)
.where(User.id == user_id)
)
@@ -57,7 +56,6 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.api_token),
)
.where(User.telegram_id == telegram_id)
)
@@ -72,10 +70,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.promo_group),
selectinload(User.api_token),
)
.options(selectinload(User.promo_group))
.where(User.referral_code == referral_code)
)
return result.scalar_one_or_none()

View File

@@ -1,88 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import UserApiToken
async def get_token_by_user_id(
db: AsyncSession,
user_id: int,
) -> Optional[UserApiToken]:
query = select(UserApiToken).where(UserApiToken.user_id == user_id)
result = await db.execute(query)
return result.scalar_one_or_none()
async def get_token_by_hash(
db: AsyncSession,
token_hash: str,
) -> Optional[UserApiToken]:
query = select(UserApiToken).where(UserApiToken.token_hash == token_hash)
result = await db.execute(query)
return result.scalar_one_or_none()
async def create_token(
db: AsyncSession,
*,
user_id: int,
token_hash: str,
token_prefix: str,
token_last_digits: str,
) -> UserApiToken:
token = UserApiToken(
user_id=user_id,
token_hash=token_hash,
token_prefix=token_prefix,
token_last_digits=token_last_digits,
is_active=True,
)
db.add(token)
await db.flush()
await db.refresh(token)
return token
async def update_token(
db: AsyncSession,
token: UserApiToken,
*,
token_hash: str,
token_prefix: str,
token_last_digits: str,
) -> UserApiToken:
token.token_hash = token_hash
token.token_prefix = token_prefix
token.token_last_digits = token_last_digits
token.is_active = True
token.updated_at = datetime.utcnow()
token.last_used_at = None
token.last_used_ip = None
await db.flush()
await db.refresh(token)
return token
async def deactivate_token(
db: AsyncSession,
token: UserApiToken,
) -> UserApiToken:
token.is_active = False
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
__all__ = [
"get_token_by_user_id",
"get_token_by_hash",
"create_token",
"update_token",
"deactivate_token",
]

View File

@@ -400,12 +400,6 @@ class User(Base):
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
api_token = relationship(
"UserApiToken",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
)
@property
def balance_rubles(self) -> float:
@@ -1258,34 +1252,4 @@ class WebApiToken(Base):
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
class UserApiToken(Base):
__tablename__ = "user_api_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)
token_hash = Column(String(128), nullable=False, unique=True, index=True)
token_prefix = Column(String(32), nullable=False)
token_last_digits = Column(String(16), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_used_at = Column(DateTime, nullable=True)
last_used_ip = Column(String(64), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
user = relationship("User", back_populates="api_token")
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return (
f"<UserApiToken id={self.id} user_id={self.user_id} "
f"prefix='{self.token_prefix}' status={status}>"
)
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"

View File

@@ -2500,79 +2500,6 @@ async def create_web_api_tokens_table() -> bool:
return False
async def create_user_api_tokens_table() -> bool:
table_exists = await check_table_exists("user_api_tokens")
if table_exists:
logger.info(" Таблица user_api_tokens уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE user_api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
token_hash VARCHAR(128) NOT NULL UNIQUE,
token_prefix VARCHAR(32) NOT NULL,
token_last_digits VARCHAR(16) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL,
last_used_ip VARCHAR(64) NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_user_api_tokens_hash ON user_api_tokens(token_hash);
CREATE INDEX idx_user_api_tokens_last_used ON user_api_tokens(last_used_at);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE user_api_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(128) NOT NULL UNIQUE,
token_prefix VARCHAR(32) NOT NULL,
token_last_digits VARCHAR(16) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP NULL,
last_used_ip VARCHAR(64) NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_user_api_tokens_hash ON user_api_tokens(token_hash);
CREATE INDEX idx_user_api_tokens_last_used ON user_api_tokens(last_used_at);
"""
else:
create_sql = """
CREATE TABLE user_api_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
token_hash VARCHAR(128) NOT NULL UNIQUE,
token_prefix VARCHAR(32) NOT NULL,
token_last_digits VARCHAR(16) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL,
last_used_ip VARCHAR(64) NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_user_api_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX idx_user_api_tokens_hash ON user_api_tokens(token_hash);
CREATE INDEX idx_user_api_tokens_last_used ON user_api_tokens(last_used_at);
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица user_api_tokens создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы user_api_tokens: {error}")
return False
async def create_privacy_policies_table() -> bool:
table_exists = await check_table_exists("privacy_policies")
if table_exists:
@@ -2875,13 +2802,6 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_API_TOKENS ===")
user_api_tokens_ready = await create_user_api_tokens_table()
if user_api_tokens_ready:
logger.info("✅ Таблица user_api_tokens готова")
else:
logger.warning("⚠️ Проблемы с таблицей user_api_tokens")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===")
privacy_policies_ready = await create_privacy_policies_table()
if privacy_policies_ready:

View File

@@ -12,7 +12,6 @@ from app.keyboards.inline import (
get_main_menu_keyboard,
get_language_selection_keyboard,
get_info_menu_keyboard,
get_user_api_token_keyboard,
)
from app.localization.texts import get_texts, get_rules
from app.database.models import User
@@ -30,7 +29,6 @@ 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.user_api_token_service import user_api_token_service
logger = logging.getLogger(__name__)
@@ -134,77 +132,6 @@ async def show_info_menu(
await callback.answer()
async def show_user_api_token(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
token = await user_api_token_service.get_token_for_user(db, db_user)
is_active = bool(token and token.is_active)
if token and token.is_active:
token_hint = f"{token.token_prefix}{token.token_last_digits}"
caption = texts.t(
"USER_API_TOKEN_EXISTS",
(
"🔑 <b>Ваш API ключ</b>\n\n"
"Сейчас активен ключ с префиксом <code>{token_hint}</code>.\n"
"Чтобы получить полный ключ, выпустите новый — предыдущий станет недействительным."
),
).format(token_hint=token_hint)
else:
caption = texts.t(
"USER_API_TOKEN_EMPTY",
(
"🔑 <b>API ключ не выпущен</b>\n\n"
"Нажмите кнопку ниже, чтобы получить персональный ключ для внешней админки."
),
)
await callback.message.edit_text(
caption,
reply_markup=get_user_api_token_keyboard(
db_user.language,
has_active_token=is_active,
),
)
await callback.answer()
async def generate_user_api_token(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_token, _ = await user_api_token_service.generate_token(db, db_user)
await db.commit()
message_text = texts.t(
"USER_API_TOKEN_NEW",
(
"🎉 <b>Новый API ключ</b>\n\n"
"<code>{token}</code>\n\n"
"Сохраните ключ — повторно показать его невозможно. При необходимости вы можете выпустить новый."
),
).format(token=new_token)
await callback.message.edit_text(
message_text,
reply_markup=get_user_api_token_keyboard(
db_user.language,
has_active_token=True,
),
)
await callback.answer(
texts.t("USER_API_TOKEN_GENERATED_TOAST", "✅ Новый ключ создан"),
show_alert=True,
)
async def show_faq_pages(
callback: types.CallbackQuery,
db_user: User,
@@ -855,11 +782,6 @@ def register_handlers(dp: Dispatcher):
F.data == "menu_info",
)
dp.callback_query.register(
show_user_api_token,
F.data == "menu_api_token",
)
dp.callback_query.register(
show_faq_pages,
F.data == "menu_faq",
@@ -900,8 +822,3 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("language_select:"),
StateFilter(None)
)
dp.callback_query.register(
generate_user_api_token,
F.data == "user_api_token_generate",
)

View File

@@ -248,13 +248,6 @@ def get_main_menu_keyboard(
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MENU_API_TOKEN", "🔑 API ключ"),
callback_data="menu_api_token",
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MENU_INFO", " Инфо"),
@@ -354,30 +347,6 @@ def get_info_menu_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_user_api_token_keyboard(
language: str = DEFAULT_LANGUAGE,
*,
has_active_token: bool,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
action_text = (
texts.t("USER_API_TOKEN_REGENERATE", "🔄 Выпустить новый ключ")
if has_active_token
else texts.t("USER_API_TOKEN_GENERATE", "🔑 Выпустить API ключ")
)
buttons: List[List[InlineKeyboardButton]] = [
[InlineKeyboardButton(text=action_text, callback_data="user_api_token_generate")]
]
buttons.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
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

View File

@@ -310,13 +310,6 @@
"MENU_RULES": "📋 Service rules",
"MENU_SERVER_STATUS": "📊 Server status",
"MENU_SUPPORT": "🛠️ Support",
"MENU_API_TOKEN": "🔑 API key",
"USER_API_TOKEN_EMPTY": "🔑 <b>API key not issued</b>\n\nPress the button below to generate your personal key for the external admin panel.",
"USER_API_TOKEN_EXISTS": "🔑 <b>Your API key</b>\n\nAn active key with prefix <code>{token_hint}</code> is available.\nTo get the full key, issue a new one — the previous key will become invalid.",
"USER_API_TOKEN_GENERATE": "🔑 Generate API key",
"USER_API_TOKEN_REGENERATE": "🔄 Generate new key",
"USER_API_TOKEN_NEW": "🎉 <b>New API key</b>\n\n<code>{token}</code>\n\nSave this key — it cannot be shown again. You can issue a new one if needed.",
"USER_API_TOKEN_GENERATED_TOAST": "✅ New key created",
"OPERATION_CANCELLED": "❌ Operation cancelled",
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",

View File

@@ -251,13 +251,6 @@
"MENU_SERVER_STATUS": "📊 Статус серверов",
"MENU_SUBSCRIPTION": "📱 Подписка",
"MENU_SUPPORT": "🛠️ Техподдержка",
"MENU_API_TOKEN": "🔑 API ключ",
"USER_API_TOKEN_EMPTY": "🔑 <b>API ключ не выпущен</b>\n\nНажмите кнопку ниже, чтобы получить персональный ключ для внешней админки.",
"USER_API_TOKEN_EXISTS": "🔑 <b>Ваш API ключ</b>\n\nСейчас активен ключ с префиксом <code>{token_hint}</code>.\nЧтобы получить полный ключ, выпустите новый — предыдущий станет недействительным.",
"USER_API_TOKEN_GENERATE": "🔑 Выпустить API ключ",
"USER_API_TOKEN_REGENERATE": "🔄 Выпустить новый ключ",
"USER_API_TOKEN_NEW": "🎉 <b>Новый API ключ</b>\n\n<code>{token}</code>\n\nСохраните ключ — повторно показать его невозможно. При необходимости вы можете выпустить новый.",
"USER_API_TOKEN_GENERATED_TOAST": "✅ Новый ключ создан",
"MENU_TRIAL": "🧪 Тестовая подписка",
"MY_BALANCE_BUTTON": "💰 Мой баланс",
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",

View File

@@ -727,7 +727,7 @@ class BackupService:
"transactions", "welcome_texts", "subscriptions",
"promocodes", "users", "promo_groups",
"server_squads", "squads", "service_rules",
"system_settings", "web_api_tokens", "user_api_tokens", "monitoring_logs"
"system_settings", "web_api_tokens", "monitoring_logs"
]
for table_name in tables_order:

View File

@@ -1,102 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud import user_api_token as crud
from app.database.models import User, UserApiToken
from app.utils.security import generate_api_token, hash_api_token
class UserApiTokenService:
"""Service for issuing and validating user-facing API tokens."""
def __init__(self) -> None:
self.algorithm = settings.USER_API_TOKEN_HASH_ALGORITHM or "sha256"
def hash_token(self, token: str) -> str:
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
async def get_token_for_user(
self,
db: AsyncSession,
user: User,
) -> Optional[UserApiToken]:
if user.api_token:
return user.api_token
return await crud.get_token_by_user_id(db, user.id)
async def generate_token(
self,
db: AsyncSession,
user: User,
) -> Tuple[str, UserApiToken]:
plain_token = generate_api_token()
token_hash = self.hash_token(plain_token)
token_prefix = plain_token[:12]
token_last_digits = plain_token[-6:]
existing = await crud.get_token_by_user_id(db, user.id)
if existing:
token = await crud.update_token(
db,
existing,
token_hash=token_hash,
token_prefix=token_prefix,
token_last_digits=token_last_digits,
)
else:
token = await crud.create_token(
db,
user_id=user.id,
token_hash=token_hash,
token_prefix=token_prefix,
token_last_digits=token_last_digits,
)
if user.api_token is None:
user.api_token = token
return plain_token, token
async def deactivate_token(
self,
db: AsyncSession,
token: UserApiToken,
) -> UserApiToken:
return await crud.deactivate_token(db, token)
async def authenticate(
self,
db: AsyncSession,
token_value: str,
*,
remote_ip: Optional[str] = None,
) -> Optional[UserApiToken]:
normalized_value = (token_value or "").strip()
if not normalized_value:
return None
token_hash = self.hash_token(normalized_value)
token = await crud.get_token_by_hash(db, token_hash)
if not token or not token.is_active:
return None
if token.user is None:
token.user = await db.get(User, token.user_id)
token.last_used_at = datetime.utcnow()
if remote_ip:
token.last_used_ip = remote_ip
await db.flush()
return token
user_api_token_service = UserApiTokenService()
__all__ = ["user_api_token_service", "UserApiTokenService"]

View File

@@ -24,7 +24,6 @@ from .routes import (
tokens,
transactions,
users,
user_api,
)
@@ -84,10 +83,6 @@ OPENAPI_TAGS = [
"name": "pages",
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
},
{
"name": "user-api",
"description": "Публичные эндпоинты для внешней админки, доступные по пользовательским API ключам.",
},
]
@@ -133,6 +128,5 @@ def create_web_api_app() -> FastAPI:
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
app.include_router(user_api.router, prefix="/user-api", tags=["user-api"])
return app

View File

@@ -7,13 +7,11 @@ from fastapi.security import APIKeyHeader
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import AsyncSessionLocal
from app.database.models import UserApiToken, WebApiToken
from app.services.user_api_token_service import user_api_token_service
from app.database.models import WebApiToken
from app.services.web_api_token_service import web_api_token_service
api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
user_api_key_header_scheme = APIKeyHeader(name="X-User-API-Key", auto_error=False)
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
@@ -59,40 +57,3 @@ async def require_api_token(
await db.commit()
return token
async def require_user_api_token(
request: Request,
api_key_header: str | None = Security(user_api_key_header_scheme),
db: AsyncSession = Depends(get_db_session),
) -> UserApiToken:
api_key = api_key_header
if not api_key:
authorization = request.headers.get("Authorization")
if authorization:
scheme, _, credentials = authorization.partition(" ")
if scheme.lower() == "bearer" and credentials:
api_key = credentials
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key",
)
token = await user_api_token_service.authenticate(
db,
api_key,
remote_ip=request.client.host if request.client else None,
)
if not token:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired API key",
)
await db.commit()
return token

View File

@@ -12,7 +12,6 @@ from . import (
tokens,
transactions,
users,
user_api,
)
__all__ = [
@@ -29,5 +28,4 @@ __all__ = [
"tokens",
"transactions",
"users",
"user_api",
]

View File

@@ -1,43 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user import get_user_by_id
from app.database.models import UserApiToken
from ..dependencies import get_db_session, require_user_api_token
from ..schemas.user_api import UserApiProfileResponse
router = APIRouter()
@router.get("/profile", response_model=UserApiProfileResponse)
async def get_profile(
token: UserApiToken = Security(require_user_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserApiProfileResponse:
user = token.user
if user is None:
user = await get_user_by_id(db, token.user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
balance_rubles = round(user.balance_kopeks / 100, 2)
return UserApiProfileResponse(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
status=user.status,
language=user.language,
balance_kopeks=user.balance_kopeks,
balance_rubles=balance_rubles,
created_at=user.created_at,
updated_at=user.updated_at,
last_activity=user.last_activity,
api_token_prefix=token.token_prefix,
api_token_last_digits=token.token_last_digits,
)

View File

@@ -1,28 +0,0 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class UserApiProfileResponse(BaseModel):
user_id: int = Field(..., description="Internal database identifier of the user")
telegram_id: int = Field(..., description="Telegram identifier of the user")
username: Optional[str] = Field(None, description="Telegram @username if available")
first_name: Optional[str] = Field(None, description="Telegram first name")
last_name: Optional[str] = Field(None, description="Telegram last name")
status: str = Field(..., description="Account status")
language: str = Field(..., description="Preferred interface language")
balance_kopeks: int = Field(..., description="Account balance in kopeks")
balance_rubles: float = Field(..., description="Account balance in rubles")
created_at: datetime = Field(..., description="Account creation timestamp")
updated_at: datetime = Field(..., description="Last profile update timestamp")
last_activity: Optional[datetime] = Field(None, description="Last bot interaction time")
api_token_prefix: str = Field(..., description="First characters of the API token")
api_token_last_digits: str = Field(..., description="Last characters of the API token")
model_config = {
"from_attributes": True,
}
__all__ = ["UserApiProfileResponse"]

View File

@@ -1,53 +0,0 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
revision: str = "9c1b5f0c4e7b"
down_revision: Union[str, None] = "8fd1e338eb45"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLE_NAME = "user_api_tokens"
def _table_exists(inspector: Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector):
return
op.create_table(
TABLE_NAME,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True),
sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True),
sa.Column("token_prefix", sa.String(length=32), nullable=False),
sa.Column("token_last_digits", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("last_used_ip", sa.String(length=64), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.create_index("ix_user_api_tokens_last_used_at", TABLE_NAME, ["last_used_at"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector):
return
op.drop_index("ix_user_api_tokens_last_used_at", table_name=TABLE_NAME)
op.drop_table(TABLE_NAME)