admin nice

This commit is contained in:
root 2025-10-05 19:07:26 +00:00
parent c170ca5433
commit 721af9bc83
2 changed files with 883 additions and 1 deletions

View File

@ -36,16 +36,19 @@ from app.api.routes.admin import (
s_api_v1_admin_blockchain, s_api_v1_admin_blockchain,
s_api_v1_admin_cache_cleanup, s_api_v1_admin_cache_cleanup,
s_api_v1_admin_cache_setlimits, s_api_v1_admin_cache_setlimits,
s_api_v1_admin_licenses,
s_api_v1_admin_login, s_api_v1_admin_login,
s_api_v1_admin_logout, s_api_v1_admin_logout,
s_api_v1_admin_node_setrole, s_api_v1_admin_node_setrole,
s_api_v1_admin_nodes, s_api_v1_admin_nodes,
s_api_v1_admin_overview, s_api_v1_admin_overview,
s_api_v1_admin_stars,
s_api_v1_admin_status, s_api_v1_admin_status,
s_api_v1_admin_storage, s_api_v1_admin_storage,
s_api_v1_admin_sync_setlimits, s_api_v1_admin_sync_setlimits,
s_api_v1_admin_system, s_api_v1_admin_system,
s_api_v1_admin_uploads, s_api_v1_admin_uploads,
s_api_v1_admin_users,
) )
from app.api.routes.tonconnect import s_api_v1_tonconnect_new, s_api_v1_tonconnect_logout from app.api.routes.tonconnect import s_api_v1_tonconnect_new, s_api_v1_tonconnect_logout
from app.api.routes.keys import s_api_v1_keys_request from app.api.routes.keys import s_api_v1_keys_request
@ -98,6 +101,9 @@ app.add_route(s_api_v1_admin_logout, "/api/v1/admin.logout", methods=["POST", "O
app.add_route(s_api_v1_admin_overview, "/api/v1/admin.overview", methods=["GET", "OPTIONS"]) app.add_route(s_api_v1_admin_overview, "/api/v1/admin.overview", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_admin_storage, "/api/v1/admin.storage", methods=["GET", "OPTIONS"]) app.add_route(s_api_v1_admin_storage, "/api/v1/admin.storage", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_admin_uploads, "/api/v1/admin.uploads", methods=["GET", "OPTIONS"]) app.add_route(s_api_v1_admin_uploads, "/api/v1/admin.uploads", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_admin_users, "/api/v1/admin.users", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_admin_licenses, "/api/v1/admin.licenses", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_admin_stars, "/api/v1/admin.stars", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_admin_system, "/api/v1/admin.system", methods=["GET", "OPTIONS"]) app.add_route(s_api_v1_admin_system, "/api/v1/admin.system", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_admin_blockchain, "/api/v1/admin.blockchain", methods=["GET", "OPTIONS"]) app.add_route(s_api_v1_admin_blockchain, "/api/v1/admin.blockchain", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_admin_node_setrole, "/api/v1/admin.node.setRole", methods=["POST", "OPTIONS"]) app.add_route(s_api_v1_admin_node_setrole, "/api/v1/admin.node.setRole", methods=["POST", "OPTIONS"])

View File

@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional
from base58 import b58encode from base58 import b58encode
from sanic import response from sanic import response
from sqlalchemy import func, select from sqlalchemy import Integer, String, and_, case, cast, func, or_, select
from app.api.routes._system import get_git_info from app.api.routes._system import get_git_info
from app.core._blockchain.ton.platform import platform from app.core._blockchain.ton.platform import platform
@ -39,6 +39,9 @@ from app.core.models.tasks import BlockchainTask
from app.core.models.node_storage import StoredContent from app.core.models.node_storage import StoredContent
from app.core.models.user import User from app.core.models.user import User
from app.core.models.content.user_content import UserContent from app.core.models.content.user_content import UserContent
from app.core.models.transaction import StarsInvoice
from app.core.models.wallet_connection import WalletConnection
from app.core.models.user_activity import UserActivity
from app.core._utils.share_links import build_content_links from app.core._utils.share_links import build_content_links
from app.core.content.content_id import ContentId from app.core.content.content_id import ContentId
@ -194,6 +197,17 @@ def _pick_primary_download(candidates: List[tuple[str, Optional[str], Optional[i
return None return None
def _parse_bool_arg(value: Optional[str]) -> Optional[bool]:
if value is None:
return None
normalized = value.strip().lower()
if normalized in {'1', 'true', 'yes', 'y', 'on'}:
return True
if normalized in {'0', 'false', 'no', 'n', 'off'}:
return False
return None
async def s_api_v1_admin_login(request): async def s_api_v1_admin_login(request):
token = os.getenv('ADMIN_API_TOKEN') token = os.getenv('ADMIN_API_TOKEN')
if not token: if not token:
@ -738,6 +752,868 @@ async def s_api_v1_admin_uploads(request):
return response.json(payload) return response.json(payload)
async def s_api_v1_admin_users(request):
if (unauth := _ensure_admin(request)):
return unauth
session = request.ctx.db_session
try:
limit = int(request.args.get('limit') or 50)
except (TypeError, ValueError):
limit = 50
limit = max(1, min(limit, 200))
try:
offset = int(request.args.get('offset') or 0)
except (TypeError, ValueError):
offset = 0
offset = max(0, offset)
search = (request.args.get('search') or '').strip()
filters = []
if search:
search_like = f"%{search}%"
clauses = [
cast(User.id, String).ilike(search_like),
cast(User.telegram_id, String).ilike(search_like),
User.username.ilike(search_like),
User.meta['first_name'].astext.ilike(search_like),
User.meta['last_name'].astext.ilike(search_like),
]
numeric_candidate = search.lstrip('-')
if numeric_candidate.isdigit():
try:
search_int = int(search)
clauses.append(User.id == search_int)
clauses.append(User.telegram_id == search_int)
except ValueError:
pass
filters.append(or_(*clauses))
total_stmt = select(func.count()).select_from(User)
if filters:
total_stmt = total_stmt.where(and_(*filters))
total = (await session.execute(total_stmt)).scalar_one()
query_stmt = (
select(User)
.order_by(User.last_use.desc())
.offset(offset)
.limit(limit)
)
if filters:
query_stmt = query_stmt.where(and_(*filters))
user_rows = (await session.execute(query_stmt)).scalars().all()
base_payload = {
'total': int(total or 0),
'limit': limit,
'offset': offset,
'search': search or None,
'has_more': (offset + limit) < int(total or 0),
}
if not user_rows:
base_payload.update({
'items': [],
'summary': {
'users_returned': 0,
'wallets_total': 0,
'wallets_active': 0,
'licenses_total': 0,
'licenses_active': 0,
'stars_total': 0,
'stars_paid': 0,
'stars_unpaid': 0,
'stars_amount_paid': 0,
'stars_amount_unpaid': 0,
'unique_ips_total': 0,
},
})
return response.json(base_payload)
user_ids = [user.id for user in user_rows if user.id is not None]
wallet_map: Dict[int, List[WalletConnection]] = defaultdict(list)
if user_ids:
wallet_rows = (await session.execute(
select(WalletConnection)
.where(WalletConnection.user_id.in_(user_ids))
.order_by(WalletConnection.created.desc())
)).scalars().all()
for wallet in wallet_rows:
if wallet.user_id is not None:
wallet_map[wallet.user_id].append(wallet)
content_stats_map: Dict[int, Dict[str, int]] = {}
if user_ids:
content_rows = (await session.execute(
select(
StoredContent.user_id,
func.count().label('total'),
func.sum(case((StoredContent.type.like('onchain%'), 1), else_=0)).label('onchain'),
func.sum(case((StoredContent.disabled.isnot(None), 1), else_=0)).label('disabled'),
)
.where(StoredContent.user_id.in_(user_ids))
.group_by(StoredContent.user_id)
)).all()
for user_id, total_cnt, onchain_cnt, disabled_cnt in content_rows:
if user_id is None:
continue
total_value = int(total_cnt or 0)
onchain_value = int(onchain_cnt or 0)
disabled_value = int(disabled_cnt or 0)
content_stats_map[user_id] = {
'total': total_value,
'onchain': onchain_value,
'local': max(total_value - onchain_value, 0),
'disabled': disabled_value,
}
license_type_map: Dict[int, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
license_status_map: Dict[int, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
if user_ids:
license_type_rows = (await session.execute(
select(UserContent.user_id, UserContent.type, func.count())
.where(UserContent.user_id.in_(user_ids))
.group_by(UserContent.user_id, UserContent.type)
)).all()
for user_id, ltype, count_value in license_type_rows:
if user_id is None:
continue
license_type_map[user_id][ltype or 'unknown'] += int(count_value or 0)
license_status_rows = (await session.execute(
select(UserContent.user_id, UserContent.status, func.count())
.where(UserContent.user_id.in_(user_ids))
.group_by(UserContent.user_id, UserContent.status)
)).all()
for user_id, status_value, count_value in license_status_rows:
if user_id is None:
continue
license_status_map[user_id][status_value or 'unknown'] += int(count_value or 0)
stars_stats_map: Dict[int, Dict[str, int]] = {}
if user_ids:
stars_rows = (await session.execute(
select(
StarsInvoice.user_id,
func.count().label('total'),
func.sum(case((StarsInvoice.paid.is_(True), 1), else_=0)).label('paid'),
func.sum(case((or_(StarsInvoice.paid.is_(False), StarsInvoice.paid.is_(None)), 1), else_=0)).label('unpaid'),
func.sum(StarsInvoice.amount).label('amount_total'),
func.sum(case((StarsInvoice.paid.is_(True), StarsInvoice.amount), else_=0)).label('amount_paid'),
)
.where(StarsInvoice.user_id.in_(user_ids))
.group_by(StarsInvoice.user_id)
)).all()
for user_id, total_cnt, paid_cnt, unpaid_cnt, amt_total, amt_paid in stars_rows:
if user_id is None:
continue
total_value = int(total_cnt or 0)
paid_value = int(paid_cnt or 0)
unpaid_value = int(unpaid_cnt or max(total_value - paid_value, 0))
amount_total_value = int(amt_total or 0)
amount_paid_value = int(amt_paid or 0)
amount_unpaid_value = max(amount_total_value - amount_paid_value, 0)
stars_stats_map[user_id] = {
'total': total_value,
'paid': paid_value,
'unpaid': unpaid_value,
'amount_total': amount_total_value,
'amount_paid': amount_paid_value,
'amount_unpaid': amount_unpaid_value,
}
ip_counts_map: Dict[int, int] = {}
if user_ids:
ip_count_rows = (await session.execute(
select(
UserActivity.user_id,
func.count(func.distinct(UserActivity.user_ip)),
)
.where(UserActivity.user_id.in_(user_ids), UserActivity.user_ip.isnot(None))
.group_by(UserActivity.user_id)
)).all()
for user_id, ip_count in ip_count_rows:
if user_id is None:
continue
ip_counts_map[user_id] = int(ip_count or 0)
latest_ip_map: Dict[int, Dict[str, Optional[str]]] = {}
if user_ids:
latest_ip_rows = (await session.execute(
select(
UserActivity.user_id,
UserActivity.user_ip,
UserActivity.type,
UserActivity.created,
)
.where(UserActivity.user_id.in_(user_ids), UserActivity.user_ip.isnot(None))
.order_by(UserActivity.user_id, UserActivity.created.desc())
.distinct(UserActivity.user_id)
)).all()
for user_id, ip_value, activity_type, created_at in latest_ip_rows:
if user_id is None:
continue
latest_ip_map[user_id] = {
'ip': ip_value,
'type': activity_type,
'seen_at': _format_dt(created_at),
}
recent_ip_map: Dict[int, List[Dict[str, Optional[str]]]] = defaultdict(list)
if user_ids:
activity_limit = max(200, len(user_ids) * 10)
recent_ip_rows = (await session.execute(
select(
UserActivity.user_id,
UserActivity.user_ip,
UserActivity.type,
UserActivity.created,
)
.where(UserActivity.user_id.in_(user_ids), UserActivity.user_ip.isnot(None))
.order_by(UserActivity.created.desc())
.limit(activity_limit)
)).all()
for user_id, ip_value, activity_type, created_at in recent_ip_rows:
if user_id is None or not ip_value:
continue
bucket = recent_ip_map[user_id]
if len(bucket) >= 5:
continue
bucket.append({
'ip': ip_value,
'type': activity_type,
'seen_at': _format_dt(created_at),
})
items: List[Dict[str, Any]] = []
summary = {
'users_returned': 0,
'wallets_total': 0,
'wallets_active': 0,
'licenses_total': 0,
'licenses_active': 0,
'stars_total': 0,
'stars_paid': 0,
'stars_unpaid': 0,
'stars_amount_total': 0,
'stars_amount_paid': 0,
'stars_amount_unpaid': 0,
'unique_ips_total': 0,
}
for user in user_rows:
summary['users_returned'] += 1
meta = user.meta or {}
wallet_list = wallet_map.get(user.id, [])
active_wallets = [wallet for wallet in wallet_list if not wallet.invalidated]
primary_wallet = active_wallets[0] if active_wallets else None
last_connected_wallet = active_wallets[0] if active_wallets else (wallet_list[0] if wallet_list else None)
wallet_payload = {
'primary_address': primary_wallet.wallet_address if primary_wallet else None,
'active_count': len(active_wallets),
'total_count': len(wallet_list),
'last_connected_at': _format_dt(last_connected_wallet.created) if last_connected_wallet else None,
'connections': [
{
'id': wallet.id,
'address': wallet.wallet_address,
'network': wallet.network,
'invalidated': wallet.invalidated,
'created_at': _format_dt(wallet.created),
'updated_at': _format_dt(wallet.updated),
}
for wallet in wallet_list[:3]
],
}
summary['wallets_total'] += wallet_payload['total_count']
summary['wallets_active'] += wallet_payload['active_count']
content_stats = content_stats_map.get(user.id, {'total': 0, 'onchain': 0, 'local': 0, 'disabled': 0})
license_types = dict(license_type_map.get(user.id, {}))
license_statuses = dict(license_status_map.get(user.id, {}))
licenses_total = sum(license_types.values()) if license_types else sum(license_statuses.values())
licenses_active = license_statuses.get('active', 0)
summary['licenses_total'] += licenses_total
summary['licenses_active'] += licenses_active
stars_stats = stars_stats_map.get(
user.id,
{
'total': 0,
'paid': 0,
'unpaid': 0,
'amount_total': 0,
'amount_paid': 0,
'amount_unpaid': 0,
},
)
summary['stars_total'] += stars_stats['total']
summary['stars_paid'] += stars_stats['paid']
summary['stars_unpaid'] += stars_stats['unpaid']
summary['stars_amount_total'] += stars_stats['amount_total']
summary['stars_amount_paid'] += stars_stats['amount_paid']
summary['stars_amount_unpaid'] += stars_stats['amount_unpaid']
unique_ips = ip_counts_map.get(user.id, 0)
summary['unique_ips_total'] += unique_ips
recent_ips = recent_ip_map.get(user.id, [])
latest_ip = latest_ip_map.get(user.id)
items.append({
'id': user.id,
'telegram_id': user.telegram_id,
'username': user.username,
'first_name': meta.get('first_name'),
'last_name': meta.get('last_name'),
'lang_code': user.lang_code,
'created_at': _format_dt(user.created),
'updated_at': _format_dt(user.updated),
'last_use': _format_dt(user.last_use),
'meta': {
'ref_id': meta.get('ref_id'),
'referrer_id': meta.get('referrer_id'),
},
'wallets': wallet_payload,
'content': content_stats,
'licenses': {
'total': licenses_total,
'active': licenses_active,
'by_type': license_types,
'by_status': license_statuses,
},
'stars': {
'total': stars_stats['total'],
'paid': stars_stats['paid'],
'unpaid': stars_stats['unpaid'],
'amount_total': stars_stats['amount_total'],
'amount_paid': stars_stats['amount_paid'],
'amount_unpaid': stars_stats['amount_unpaid'],
},
'ip_activity': {
'last': latest_ip or None,
'unique_ips': unique_ips,
'recent': recent_ips,
},
})
base_payload.update({
'items': items,
'summary': summary,
})
return response.json(base_payload)
async def s_api_v1_admin_licenses(request):
if (unauth := _ensure_admin(request)):
return unauth
session = request.ctx.db_session
try:
limit = int(request.args.get('limit') or 50)
except (TypeError, ValueError):
limit = 50
limit = max(1, min(limit, 200))
try:
offset = int(request.args.get('offset') or 0)
except (TypeError, ValueError):
offset = 0
offset = max(0, offset)
search = (request.args.get('search') or '').strip()
type_param = (request.args.get('type') or '').strip()
status_param = (request.args.get('status') or '').strip()
license_type_param = (request.args.get('license_type') or '').strip()
user_id_param = (request.args.get('user_id') or '').strip()
owner_address_param = (request.args.get('owner') or '').strip()
onchain_address_param = (request.args.get('address') or '').strip()
content_hash_param = (request.args.get('content_hash') or '').strip()
filters = []
applied_filters: Dict[str, Any] = {}
if type_param:
type_values = [value.strip() for value in type_param.split(',') if value.strip()]
if type_values:
filters.append(UserContent.type.in_(type_values))
applied_filters['type'] = type_values
if status_param:
status_values = [value.strip() for value in status_param.split(',') if value.strip()]
if status_values:
filters.append(UserContent.status.in_(status_values))
applied_filters['status'] = status_values
if license_type_param:
lt_values: List[int] = []
for part in license_type_param.split(','):
part = part.strip()
if not part:
continue
try:
lt_values.append(int(part))
except ValueError:
continue
if lt_values:
license_type_expr = cast(UserContent.meta['license_type'].astext, Integer)
filters.append(license_type_expr.in_(lt_values))
applied_filters['license_type'] = lt_values
if user_id_param:
try:
filters.append(UserContent.user_id == int(user_id_param))
applied_filters['user_id'] = int(user_id_param)
except ValueError:
pass
if owner_address_param:
filters.append(UserContent.owner_address.ilike(f"%{owner_address_param}%"))
applied_filters['owner'] = owner_address_param
if onchain_address_param:
filters.append(UserContent.onchain_address.ilike(f"%{onchain_address_param}%"))
applied_filters['address'] = onchain_address_param
if content_hash_param:
stored_target = (await session.execute(
select(StoredContent.id).where(StoredContent.hash == content_hash_param)
)).scalars().first()
if stored_target is None:
payload = {
'total': 0,
'limit': limit,
'offset': offset,
'search': search or None,
'filters': {**applied_filters, 'content_hash': content_hash_param},
'items': [],
'counts': {
'status': {},
'type': {},
'license_type': {},
},
'has_more': False,
}
return response.json(payload)
filters.append(UserContent.content_id == int(stored_target))
applied_filters['content_hash'] = content_hash_param
if search:
search_like = f"%{search}%"
clauses = [
cast(UserContent.id, String).ilike(search_like),
UserContent.onchain_address.ilike(search_like),
UserContent.owner_address.ilike(search_like),
]
filters.append(or_(*clauses))
total_stmt = select(func.count()).select_from(UserContent)
if filters:
total_stmt = total_stmt.where(and_(*filters))
total = (await session.execute(total_stmt)).scalar_one()
base_payload = {
'total': int(total or 0),
'limit': limit,
'offset': offset,
'search': search or None,
'filters': applied_filters,
'has_more': (offset + limit) < int(total or 0),
}
if not total:
base_payload.update({
'items': [],
'counts': {
'status': {},
'type': {},
'license_type': {},
},
})
return response.json(base_payload)
query_stmt = (
select(UserContent)
.order_by(UserContent.created.desc())
.offset(offset)
.limit(limit)
)
if filters:
query_stmt = query_stmt.where(and_(*filters))
license_rows = (await session.execute(query_stmt)).scalars().all()
user_ids = [row.user_id for row in license_rows if row.user_id is not None]
content_ids = [row.content_id for row in license_rows if row.content_id is not None]
wallet_ids = [row.wallet_connection_id for row in license_rows if row.wallet_connection_id is not None]
users_map: Dict[int, User] = {}
if user_ids:
user_records = (await session.execute(
select(User).where(User.id.in_(user_ids))
)).scalars().all()
for user in user_records:
users_map[user.id] = user
wallet_map: Dict[int, WalletConnection] = {}
if wallet_ids:
wallet_records = (await session.execute(
select(WalletConnection).where(WalletConnection.id.in_(wallet_ids))
)).scalars().all()
for wallet in wallet_records:
wallet_map[wallet.id] = wallet
content_map: Dict[int, StoredContent] = {}
if content_ids:
content_records = (await session.execute(
select(StoredContent).where(StoredContent.id.in_(content_ids))
)).scalars().all()
for content in content_records:
content_map[content.id] = content
status_stmt = select(UserContent.status, func.count()).group_by(UserContent.status)
if filters:
status_stmt = status_stmt.where(and_(*filters))
status_counts_rows = (await session.execute(status_stmt)).all()
status_counts = {status or 'unknown': int(count or 0) for status, count in status_counts_rows}
type_stmt = select(UserContent.type, func.count()).group_by(UserContent.type)
if filters:
type_stmt = type_stmt.where(and_(*filters))
type_counts_rows = (await session.execute(type_stmt)).all()
type_counts = {ctype or 'unknown': int(count or 0) for ctype, count in type_counts_rows}
license_type_expr = func.coalesce(cast(UserContent.meta['license_type'].astext, Integer), -1)
license_type_stmt = select(license_type_expr.label('license_type'), func.count()).group_by('license_type')
if filters:
license_type_stmt = license_type_stmt.where(and_(*filters))
license_type_counts_rows = (await session.execute(license_type_stmt)).all()
license_type_counts: Dict[str, int] = {}
for lt_value, count in license_type_counts_rows:
key = 'unknown' if lt_value in (None, -1) else str(int(lt_value))
license_type_counts[key] = int(count or 0)
items: List[Dict[str, Any]] = []
for license_row in license_rows:
meta = license_row.meta or {}
license_type_value = meta.get('license_type')
owner_user = users_map.get(license_row.user_id)
wallet_connection = wallet_map.get(license_row.wallet_connection_id)
stored_content = content_map.get(license_row.content_id)
owner_payload = None
if owner_user:
owner_meta = owner_user.meta or {}
owner_payload = {
'id': owner_user.id,
'telegram_id': owner_user.telegram_id,
'username': owner_user.username,
'first_name': owner_meta.get('first_name'),
'last_name': owner_meta.get('last_name'),
}
wallet_payload = None
if wallet_connection:
wallet_payload = {
'id': wallet_connection.id,
'address': wallet_connection.wallet_address,
'network': wallet_connection.network,
'invalidated': wallet_connection.invalidated,
'created_at': _format_dt(wallet_connection.created),
'updated_at': _format_dt(wallet_connection.updated),
}
content_payload = None
if stored_content:
stored_meta = stored_content.meta or {}
metadata_candidate = stored_meta.get('metadata') if isinstance(stored_meta.get('metadata'), dict) else {}
title_candidates = [
stored_meta.get('title'),
metadata_candidate.get('name') if isinstance(metadata_candidate, dict) else None,
stored_meta.get('license', {}).get('title') if isinstance(stored_meta.get('license'), dict) else None,
]
title_value = next((value for value in title_candidates if isinstance(value, str) and value.strip()), None)
try:
cid_value = stored_content.cid.serialize_v2()
except Exception:
cid_value = None
content_payload = {
'id': stored_content.id,
'hash': stored_content.hash,
'cid': cid_value,
'title': title_value or stored_content.hash,
'type': stored_content.type,
'owner_address': stored_content.owner_address,
'onchain_index': stored_content.onchain_index,
'user_id': stored_content.user_id,
'download_url': stored_content.web_url,
}
items.append({
'id': license_row.id,
'type': license_row.type,
'status': license_row.status,
'license_type': license_type_value,
'onchain_address': license_row.onchain_address,
'owner_address': license_row.owner_address,
'user': owner_payload,
'wallet_connection': wallet_payload,
'content': content_payload,
'created_at': _format_dt(license_row.created),
'updated_at': _format_dt(license_row.updated),
'meta': meta,
'links': {
'tonviewer': f"https://tonviewer.com/{license_row.onchain_address}" if license_row.onchain_address else None,
'content_view': f"{PROJECT_HOST}/api/v1/content.view/{license_row.onchain_address}" if license_row.onchain_address else None,
},
})
base_payload.update({
'items': items,
'counts': {
'status': status_counts,
'type': type_counts,
'license_type': license_type_counts,
},
})
return response.json(base_payload)
async def s_api_v1_admin_stars(request):
if (unauth := _ensure_admin(request)):
return unauth
session = request.ctx.db_session
try:
limit = int(request.args.get('limit') or 50)
except (TypeError, ValueError):
limit = 50
limit = max(1, min(limit, 200))
try:
offset = int(request.args.get('offset') or 0)
except (TypeError, ValueError):
offset = 0
offset = max(0, offset)
search = (request.args.get('search') or '').strip()
type_param = (request.args.get('type') or '').strip()
paid_param = _parse_bool_arg(request.args.get('paid'))
user_id_param = (request.args.get('user_id') or '').strip()
content_hash_param = (request.args.get('content_hash') or '').strip()
filters = []
applied_filters: Dict[str, Any] = {}
if paid_param is True:
filters.append(StarsInvoice.paid.is_(True))
applied_filters['paid'] = True
elif paid_param is False:
filters.append(or_(StarsInvoice.paid.is_(False), StarsInvoice.paid.is_(None)))
applied_filters['paid'] = False
if type_param:
type_values = [value.strip() for value in type_param.split(',') if value.strip()]
if type_values:
filters.append(StarsInvoice.type.in_(type_values))
applied_filters['type'] = type_values
if user_id_param:
try:
filters.append(StarsInvoice.user_id == int(user_id_param))
applied_filters['user_id'] = int(user_id_param)
except ValueError:
pass
if content_hash_param:
filters.append(StarsInvoice.content_hash == content_hash_param)
applied_filters['content_hash'] = content_hash_param
if search:
search_like = f"%{search}%"
clauses = [
StarsInvoice.external_id.ilike(search_like),
StarsInvoice.invoice_url.ilike(search_like),
cast(StarsInvoice.id, String).ilike(search_like),
]
filters.append(or_(*clauses))
total_stmt = select(func.count()).select_from(StarsInvoice)
if filters:
total_stmt = total_stmt.where(and_(*filters))
total = (await session.execute(total_stmt)).scalar_one()
base_payload = {
'total': int(total or 0),
'limit': limit,
'offset': offset,
'search': search or None,
'filters': applied_filters,
'has_more': (offset + limit) < int(total or 0),
}
if not total:
base_payload.update({
'items': [],
'stats': {
'total': 0,
'paid': 0,
'unpaid': 0,
'amount_total': 0,
'amount_paid': 0,
'amount_unpaid': 0,
'by_type': {},
},
})
return response.json(base_payload)
query_stmt = (
select(StarsInvoice)
.order_by(StarsInvoice.created.desc())
.offset(offset)
.limit(limit)
)
if filters:
query_stmt = query_stmt.where(and_(*filters))
invoice_rows = (await session.execute(query_stmt)).scalars().all()
user_ids = [row.user_id for row in invoice_rows if row.user_id is not None]
content_hashes = [row.content_hash for row in invoice_rows if row.content_hash]
users_map: Dict[int, User] = {}
if user_ids:
user_records = (await session.execute(select(User).where(User.id.in_(user_ids)))).scalars().all()
for user in user_records:
users_map[user.id] = user
content_map: Dict[str, StoredContent] = {}
if content_hashes:
content_records = (await session.execute(
select(StoredContent).where(StoredContent.hash.in_(content_hashes))
)).scalars().all()
for content in content_records:
content_map[content.hash] = content
stats_stmt = select(
func.count().label('total'),
func.sum(case((StarsInvoice.paid.is_(True), 1), else_=0)).label('paid'),
func.sum(StarsInvoice.amount).label('amount_total'),
func.sum(case((StarsInvoice.paid.is_(True), StarsInvoice.amount), else_=0)).label('amount_paid'),
)
if filters:
stats_stmt = stats_stmt.where(and_(*filters))
stats_row = (await session.execute(stats_stmt)).first()
total_filtered = int((stats_row.total if stats_row else 0) or 0)
paid_count = int((stats_row.paid if stats_row else 0) or 0)
amount_total_value = int((stats_row.amount_total if stats_row else 0) or 0)
amount_paid_value = int((stats_row.amount_paid if stats_row else 0) or 0)
unpaid_count = max(total_filtered - paid_count, 0)
amount_unpaid_value = max(amount_total_value - amount_paid_value, 0)
type_stats_stmt = select(
StarsInvoice.type,
func.count().label('total'),
func.sum(case((StarsInvoice.paid.is_(True), 1), else_=0)).label('paid'),
func.sum(StarsInvoice.amount).label('amount_total'),
func.sum(case((StarsInvoice.paid.is_(True), StarsInvoice.amount), else_=0)).label('amount_paid'),
).group_by(StarsInvoice.type)
if filters:
type_stats_stmt = type_stats_stmt.where(and_(*filters))
type_stats_rows = (await session.execute(type_stats_stmt)).all()
type_stats: Dict[str, Dict[str, int]] = {}
for invoice_type, total_cnt, paid_cnt, amt_total, amt_paid in type_stats_rows:
total_value = int(total_cnt or 0)
paid_value = int(paid_cnt or 0)
amt_total_value = int(amt_total or 0)
amt_paid_value = int(amt_paid or 0)
type_stats[invoice_type or 'unknown'] = {
'total': total_value,
'paid': paid_value,
'unpaid': max(total_value - paid_value, 0),
'amount_total': amt_total_value,
'amount_paid': amt_paid_value,
'amount_unpaid': max(amt_total_value - amt_paid_value, 0),
}
items: List[Dict[str, Any]] = []
for invoice in invoice_rows:
user_payload = None
if invoice.user_id and invoice.user_id in users_map:
user = users_map[invoice.user_id]
user_meta = user.meta or {}
user_payload = {
'id': user.id,
'telegram_id': user.telegram_id,
'username': user.username,
'first_name': user_meta.get('first_name'),
'last_name': user_meta.get('last_name'),
}
content_payload = None
if invoice.content_hash and invoice.content_hash in content_map:
stored_content = content_map[invoice.content_hash]
stored_meta = stored_content.meta or {}
metadata_candidate = stored_meta.get('metadata') if isinstance(stored_meta.get('metadata'), dict) else {}
title_candidates = [
stored_meta.get('title'),
metadata_candidate.get('name') if isinstance(metadata_candidate, dict) else None,
stored_meta.get('license', {}).get('title') if isinstance(stored_meta.get('license'), dict) else None,
]
title_value = next((value for value in title_candidates if isinstance(value, str) and value.strip()), None)
try:
cid_value = stored_content.cid.serialize_v2()
except Exception:
cid_value = None
content_payload = {
'id': stored_content.id,
'hash': stored_content.hash,
'cid': cid_value,
'title': title_value or stored_content.hash,
'onchain_index': stored_content.onchain_index,
'owner_address': stored_content.owner_address,
'user_id': stored_content.user_id,
'download_url': stored_content.web_url,
}
items.append({
'id': invoice.id,
'external_id': invoice.external_id,
'type': invoice.type,
'amount': invoice.amount,
'paid': bool(invoice.paid),
'invoice_url': invoice.invoice_url,
'created_at': _format_dt(invoice.created),
'user': user_payload,
'content': content_payload,
'status': 'paid' if invoice.paid else 'pending',
})
base_payload.update({
'items': items,
'stats': {
'total': total_filtered,
'paid': paid_count,
'unpaid': unpaid_count,
'amount_total': amount_total_value,
'amount_paid': amount_paid_value,
'amount_unpaid': amount_unpaid_value,
'by_type': type_stats,
},
})
return response.json(base_payload)
async def s_api_v1_admin_system(request): async def s_api_v1_admin_system(request):
if (unauth := _ensure_admin(request)): if (unauth := _ensure_admin(request)):
return unauth return unauth