feat: information pages — CRUD model, admin API, public API
- InfoPage model: slug, title (JSONB locale dict), content (JSONB),
is_active, sort_order, icon, created_at/updated_at
- CRUD: create, get by id/slug, list, update, delete, reorder
- Admin routes: /admin/info-pages with full CRUD, toggle-active, reorder
(permissions: settings:read/settings:edit)
- Public routes: /info-pages list active, /info-pages/{slug} get by slug
- Migration 0065: create info_pages table with unique slug index
- Custom pages support: admins can create any info page with any slug
This commit is contained in:
@@ -12,6 +12,7 @@ from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_channels import router as admin_channels_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_info_pages import router as admin_info_pages_router
|
||||
from .admin_landings import router as admin_landings_router
|
||||
from .admin_menu_layout import router as admin_menu_layout_router
|
||||
from .admin_news import router as admin_news_router
|
||||
@@ -45,6 +46,7 @@ from .branding import router as branding_router
|
||||
from .contests import router as contests_router
|
||||
from .gift import router as gift_router
|
||||
from .info import router as info_router
|
||||
from .info_pages import router as info_pages_router
|
||||
from .landing import router as landing_router
|
||||
from .media import router as media_router
|
||||
from .news import router as news_router
|
||||
@@ -94,6 +96,7 @@ router.include_router(branding_router)
|
||||
router.include_router(landing_router)
|
||||
router.include_router(media_router)
|
||||
router.include_router(news_router)
|
||||
router.include_router(info_pages_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
@@ -142,6 +145,7 @@ router.include_router(admin_news_categories_router)
|
||||
router.include_router(admin_news_tags_router)
|
||||
router.include_router(admin_news_media_router)
|
||||
router.include_router(admin_news_router)
|
||||
router.include_router(admin_info_pages_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Admin routes for managing info pages in cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import (
|
||||
create_info_page,
|
||||
delete_info_page,
|
||||
get_all_info_pages,
|
||||
get_info_page_by_id,
|
||||
reorder_info_pages,
|
||||
update_info_page,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.info_pages import (
|
||||
InfoPageCreateRequest,
|
||||
InfoPageListItem,
|
||||
InfoPageResponse,
|
||||
InfoPageUpdateRequest,
|
||||
ReorderRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_all_info_pages(
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all info pages (admin view, includes inactive)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=True)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{page_id}', response_model=InfoPageResponse)
|
||||
async def get_info_page_detail(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by ID (admin view)."""
|
||||
page = await get_info_page_by_id(db, page_id)
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_page(
|
||||
request: InfoPageCreateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Create a new info page."""
|
||||
try:
|
||||
page = await create_info_page(
|
||||
db,
|
||||
slug=request.slug,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
icon=request.icon,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create info page')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create info page',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.put('/{page_id}', response_model=InfoPageResponse)
|
||||
async def update_page(
|
||||
page_id: int,
|
||||
request: InfoPageUpdateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Update an existing info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
page = await update_info_page(db, page_id, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update info page',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after update',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_page(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_info_page(db, page_id)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete info page',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
|
||||
async def toggle_active(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Toggle the active status of an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
page = await update_info_page(db, page_id, is_active=not existing.is_active)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle info page active status', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle active status',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after toggle',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def reorder_pages(
|
||||
request: ReorderRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Bulk update sort_order for info pages."""
|
||||
try:
|
||||
await reorder_info_pages(db, request.items)
|
||||
except Exception:
|
||||
logger.exception('Failed to reorder info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reorder info pages',
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Public info page routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug
|
||||
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_active_info_pages(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all active info pages (public, no auth required)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=False)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except Exception:
|
||||
logger.exception('Failed to list active info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=InfoPageResponse)
|
||||
async def get_info_page_by_slug_public(
|
||||
slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by slug (public, no auth required)."""
|
||||
page = await get_info_page_by_slug(db, slug)
|
||||
|
||||
if not page or not page.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Schemas for info pages in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class InfoPageResponse(BaseModel):
|
||||
"""Full info page response."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: dict[str, str]
|
||||
content: dict[str, str]
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
icon: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InfoPageListItem(BaseModel):
|
||||
"""Compact info page for list views."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: dict[str, str]
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
icon: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InfoPageCreateRequest(BaseModel):
|
||||
"""Request to create an info page."""
|
||||
|
||||
slug: str = Field(min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
|
||||
title: dict[str, str] = Field(default_factory=dict)
|
||||
content: dict[str, str] = Field(default_factory=dict)
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
|
||||
|
||||
class InfoPageUpdateRequest(BaseModel):
|
||||
"""Request to update an info page."""
|
||||
|
||||
slug: str | None = Field(None, min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
|
||||
title: dict[str, str] | None = None
|
||||
content: dict[str, str] | None = None
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
|
||||
|
||||
class ReorderRequest(BaseModel):
|
||||
"""Request to bulk-reorder info pages."""
|
||||
|
||||
items: list[dict] = Field(..., min_length=1)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""CRUD operations for info pages."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import InfoPage
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Fields that can be set via update_info_page
|
||||
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'slug',
|
||||
'title',
|
||||
'content',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
'icon',
|
||||
}
|
||||
)
|
||||
|
||||
# Fields that can be explicitly set to None
|
||||
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'icon',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def create_info_page(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
title: dict[str, str],
|
||||
content: dict[str, str],
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0,
|
||||
icon: str | None = None,
|
||||
) -> InfoPage:
|
||||
"""Create a new info page.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug is not unique (caller must handle).
|
||||
"""
|
||||
page = InfoPage(
|
||||
slug=slug,
|
||||
title=title,
|
||||
content=content,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
icon=icon,
|
||||
)
|
||||
|
||||
db.add(page)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info('Created info page', page_id=page.id, slug=page.slug)
|
||||
return page
|
||||
|
||||
|
||||
async def get_info_page_by_id(db: AsyncSession, page_id: int) -> InfoPage | None:
|
||||
"""Get an info page by ID."""
|
||||
result = await db.execute(select(InfoPage).where(InfoPage.id == page_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_info_page_by_slug(db: AsyncSession, slug: str) -> InfoPage | None:
|
||||
"""Get an info page by slug."""
|
||||
result = await db.execute(select(InfoPage).where(InfoPage.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_info_pages(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> list[InfoPage]:
|
||||
"""Get all info pages, ordered by sort_order ascending."""
|
||||
stmt = select(InfoPage)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(InfoPage.is_active.is_(True))
|
||||
|
||||
stmt = stmt.order_by(InfoPage.sort_order.asc(), InfoPage.id.asc())
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_info_page(
|
||||
db: AsyncSession,
|
||||
page_id: int,
|
||||
**kwargs: Any,
|
||||
) -> InfoPage | None:
|
||||
"""Update an info page. Only whitelisted fields are applied.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug conflicts with another page (caller must handle).
|
||||
"""
|
||||
update_data: dict[str, Any] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key not in _ALLOWED_UPDATE_FIELDS:
|
||||
continue
|
||||
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
|
||||
continue
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return await get_info_page_by_id(db, page_id)
|
||||
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(update(InfoPage).where(InfoPage.id == page_id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
page = await get_info_page_by_id(db, page_id)
|
||||
if page:
|
||||
logger.info(
|
||||
'Updated info page',
|
||||
page_id=page_id,
|
||||
updated_fields=list(update_data.keys()),
|
||||
)
|
||||
return page
|
||||
|
||||
|
||||
async def delete_info_page(db: AsyncSession, page_id: int) -> None:
|
||||
"""Delete an info page."""
|
||||
await db.execute(delete(InfoPage).where(InfoPage.id == page_id))
|
||||
await db.commit()
|
||||
|
||||
logger.info('Deleted info page', page_id=page_id)
|
||||
|
||||
|
||||
async def reorder_info_pages(db: AsyncSession, items: list[dict]) -> None:
|
||||
"""Bulk update sort_order for info pages.
|
||||
|
||||
Each dict in *items* must have ``id`` and ``sort_order`` keys.
|
||||
"""
|
||||
for item in items:
|
||||
page_id = item.get('id')
|
||||
sort_order = item.get('sort_order')
|
||||
if page_id is None or sort_order is None:
|
||||
continue
|
||||
await db.execute(
|
||||
update(InfoPage).where(InfoPage.id == page_id).values(sort_order=sort_order, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info('Reordered info pages', count=len(items))
|
||||
@@ -3699,3 +3699,20 @@ class YandexClientIdMap(Base):
|
||||
subid = Column(String(255), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class InfoPage(Base):
|
||||
"""Static informational page with multilingual title/content (JSONB)."""
|
||||
|
||||
__tablename__ = 'info_pages'
|
||||
__table_args__ = (Index('ix_info_pages_slug', 'slug', unique=True),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
slug = Column(String(200), unique=True, nullable=False, index=True)
|
||||
title = Column(JSONB, nullable=False, server_default='{}')
|
||||
content = Column(JSONB, nullable=False, server_default='{}')
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default='true')
|
||||
sort_order = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
icon = Column(String(50), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""create info_pages table
|
||||
|
||||
Revision ID: 0065
|
||||
Revises: 0064
|
||||
Create Date: 2026-04-21
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0065'
|
||||
down_revision: Union[str, None] = '0064'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists('info_pages'):
|
||||
op.create_table(
|
||||
'info_pages',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('slug', sa.String(200), unique=True, nullable=False, index=True),
|
||||
sa.Column('title', sa.dialects.postgresql.JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('content', sa.dialects.postgresql.JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('icon', sa.String(50), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_info_pages_slug', 'info_pages', ['slug'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_info_pages_slug', table_name='info_pages')
|
||||
op.drop_table('info_pages')
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
"""Check if a table already exists (idempotent migration guard)."""
|
||||
bind = op.get_bind()
|
||||
result = bind.execute(
|
||||
sa.text('SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :name)'),
|
||||
{'name': table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
Reference in New Issue
Block a user