feat: add dedicated market admin page

parent 09f195d6
......@@ -1510,7 +1510,6 @@ async def dashboard_admin_payment_settings(request: Request):
"session": request.session,
"currency_symbol": DatabaseRegistry.get_config_database().get_currency_settings().get('currency_symbol', '$'),
"market_settings": DatabaseRegistry.get_config_database().get_market_settings(),
"market_listings": DatabaseRegistry.get_config_database().list_market_listings(active_only=False),
}
)
......
......@@ -719,17 +719,50 @@ async def api_toggle_market_listing(request: Request, listing_id: int):
@router.get('/dashboard/admin/market', response_class=HTMLResponse)
async def dashboard_admin_market(request: Request):
async def dashboard_admin_market(
request: Request,
q: str = '',
source_type: str = '',
active_filter: str = '',
online_filter: str = '',
owner_username: str = '',
page: int = 1,
limit: int = 25,
):
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
listings = db.list_market_listings(active_only=False)
page = max(page, 1)
limit = max(1, min(limit, 100))
result = db.list_market_listings_paginated(
page=page,
limit=limit,
search=q or None,
source_type=source_type or None,
active_filter=active_filter or None,
online_filter=online_filter or None,
owner_username=owner_username or None,
)
listings = result['items']
enriched_listings = []
for listing in listings:
listing_copy = dict(listing)
_apply_listing_derived_fields(listing_copy, db)
enriched_listings.append(listing_copy)
total = int(result.get('total') or 0)
total_pages = max((total + limit - 1) // limit, 1)
if page > total_pages:
page = total_pages
filters = {
'q': q,
'source_type': source_type,
'active_filter': active_filter,
'online_filter': online_filter,
'owner_username': owner_username,
'page': page,
'limit': limit,
}
return _templates.TemplateResponse(
request=request,
name='dashboard/admin_market.html',
......@@ -737,5 +770,16 @@ async def dashboard_admin_market(request: Request):
'request': request,
'session': request.session,
'market_listings': enriched_listings,
'filters': filters,
'pagination': {
'page': page,
'limit': limit,
'total': total,
'total_pages': total_pages,
'has_prev': page > 1,
'has_next': page < total_pages,
'prev_page': page - 1,
'next_page': page + 1,
},
},
)
......@@ -4,6 +4,54 @@
{% block content %}
<h2 style="margin-bottom: 20px;"><i class="fas fa-store-alt" style="color: var(--color-link);"></i> Market Administration</h2>
<div style="background: var(--bg-panel); border: 1px solid var(--color-border); border-radius: 10px; padding: 16px; margin-bottom: 16px;">
<form method="get" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; align-items:end;">
<div>
<label for="market-search" style="display:block; margin-bottom: 6px; color: var(--color-text);">Search</label>
<input id="market-search" type="text" name="q" value="{{ filters.q }}" placeholder="Search listings, owner, provider, model" style="width:100%; padding: 10px; border:1px solid var(--color-border); border-radius: 8px; background: var(--bg-page); color: var(--color-text);">
</div>
<div>
<label for="source-type" style="display:block; margin-bottom: 6px; color: var(--color-text);">Source Type</label>
<select id="source-type" name="source_type" style="width:100%; padding: 10px; border:1px solid var(--color-border); border-radius: 8px; background: var(--bg-page); color: var(--color-text);">
<option value="">All sources</option>
<option value="provider" {% if filters.source_type == 'provider' %}selected{% endif %}>Provider</option>
<option value="model" {% if filters.source_type == 'model' %}selected{% endif %}>Model</option>
<option value="rotation" {% if filters.source_type == 'rotation' %}selected{% endif %}>Rotation</option>
<option value="autoselect" {% if filters.source_type == 'autoselect' %}selected{% endif %}>Autoselect</option>
</select>
</div>
<div>
<label for="active-filter" style="display:block; margin-bottom: 6px; color: var(--color-text);">Active State</label>
<select id="active-filter" name="active_filter" style="width:100%; padding: 10px; border:1px solid var(--color-border); border-radius: 8px; background: var(--bg-page); color: var(--color-text);">
<option value="">All states</option>
<option value="active" {% if filters.active_filter == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if filters.active_filter == 'inactive' %}selected{% endif %}>Inactive</option>
</select>
</div>
<div>
<label for="online-filter" style="display:block; margin-bottom: 6px; color: var(--color-text);">Availability</label>
<select id="online-filter" name="online_filter" style="width:100%; padding: 10px; border:1px solid var(--color-border); border-radius: 8px; background: var(--bg-page); color: var(--color-text);">
<option value="">All</option>
<option value="online" {% if filters.online_filter == 'online' %}selected{% endif %}>Online</option>
<option value="offline" {% if filters.online_filter == 'offline' %}selected{% endif %}>Offline</option>
</select>
</div>
<div>
<label for="owner-username" style="display:block; margin-bottom: 6px; color: var(--color-text);">Owner</label>
<input id="owner-username" type="text" name="owner_username" value="{{ filters.owner_username }}" placeholder="Filter by owner" style="width:100%; padding: 10px; border:1px solid var(--color-border); border-radius: 8px; background: var(--bg-page); color: var(--color-text);">
</div>
<div>
<label for="limit" style="display:block; margin-bottom: 6px; color: var(--color-text);">Per Page</label>
<select id="limit" name="limit" style="width:100%; padding: 10px; border:1px solid var(--color-border); border-radius: 8px; background: var(--bg-page); color: var(--color-text);">
{% for option in [10, 25, 50, 100] %}
<option value="{{ option }}" {% if filters.limit == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
<input type="hidden" name="page" value="1">
<button type="submit" class="btn" style="background: var(--color-link); color: white;">Apply</button>
</form>
</div>
<div style="background: var(--bg-panel); border: 1px solid var(--color-border); border-radius: 10px; padding: 16px; overflow:auto;">
<table style="width:100%; border-collapse: collapse;">
<thead>
......@@ -34,8 +82,26 @@
<td style="padding:10px; text-align:right;">{{ (listing.stats or {}).total_requests or 0 }}</td>
<td style="padding:10px; text-align:center;">{{ 'Yes' if listing.is_active else 'No' }}</td>
</tr>
{% else %}
<tr>
<td colspan="10" style="padding:16px; text-align:center; color: var(--color-muted);">No market listings match the current filters.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-top: 16px; flex-wrap: wrap;">
<div style="color: var(--color-text);">
Showing page {{ pagination.page }} of {{ pagination.total_pages }} ({{ pagination.total }} listings)
</div>
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
{% if pagination.has_prev %}
<a class="btn" href="{{ url_for(request, '/dashboard/admin/market') }}?q={{ filters.q|urlencode }}&source_type={{ filters.source_type|urlencode }}&active_filter={{ filters.active_filter|urlencode }}&online_filter={{ filters.online_filter|urlencode }}&owner_username={{ filters.owner_username|urlencode }}&limit={{ filters.limit }}&page={{ pagination.prev_page }}" style="background: var(--bg-accent); color: var(--color-text);">Previous</a>
{% endif %}
<a class="btn" href="{{ url_for(request, '/dashboard/admin/market') }}?q={{ filters.q|urlencode }}&source_type={{ filters.source_type|urlencode }}&active_filter={{ filters.active_filter|urlencode }}&online_filter={{ filters.online_filter|urlencode }}&owner_username={{ filters.owner_username|urlencode }}&limit={{ filters.limit }}&page={{ pagination.page }}" style="background: var(--color-link); color: white;">Current Page</a>
{% if pagination.has_next %}
<a class="btn" href="{{ url_for(request, '/dashboard/admin/market') }}?q={{ filters.q|urlencode }}&source_type={{ filters.source_type|urlencode }}&active_filter={{ filters.active_filter|urlencode }}&online_filter={{ filters.online_filter|urlencode }}&owner_username={{ filters.owner_username|urlencode }}&limit={{ filters.limit }}&page={{ pagination.next_page }}" style="background: var(--bg-accent); color: var(--color-text);">Next</a>
{% endif %}
</div>
</div>
{% endblock %}
......@@ -45,47 +45,15 @@
</div>
<div style="background: var(--bg-panel); border: 2px solid var(--color-link); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: var(--color-link);">
<h3 style="margin: 0 0 12px 0; color: var(--color-link);">
<i class="fas fa-store-alt me-2"></i>Market Administration
</h3>
<div style="overflow:auto; border: 1px solid var(--color-border); border-radius: 8px; background: var(--bg-page);">
<table style="width:100%; border-collapse: collapse;">
<thead>
<tr style="background: var(--bg-accent);">
<th style="padding:10px; text-align:left;">ID</th>
<th style="padding:10px; text-align:left;">Title</th>
<th style="padding:10px; text-align:left;">Owner</th>
<th style="padding:10px; text-align:left;">Source</th>
<th style="padding:10px; text-align:left;">Status</th>
<th style="padding:10px; text-align:right;">1M Tokens</th>
<th style="padding:10px; text-align:right;">1K Requests</th>
<th style="padding:10px; text-align:right;">Revenue</th>
<th style="padding:10px; text-align:right;">Requests</th>
<th style="padding:10px; text-align:center;">Active</th>
</tr>
</thead>
<tbody>
{% for listing in market_listings %}
<tr style="border-top:1px solid var(--color-border);">
<td style="padding:10px;">{{ listing.id }}</td>
<td style="padding:10px;">{{ listing.title }}</td>
<td style="padding:10px;">{{ listing.owner_username }}</td>
<td style="padding:10px;">{{ listing.source_type }} / {{ listing.source_id }}</td>
<td style="padding:10px;">{{ 'Online' if listing.online else 'Offline' }}</td>
<td style="padding:10px; text-align:right;">{{ listing.price_per_million_tokens }}</td>
<td style="padding:10px; text-align:right;">{{ listing.price_per_1000_requests }}</td>
<td style="padding:10px; text-align:right;">{{ '%.2f'|format((listing.stats or {}).gross_revenue or 0) }}</td>
<td style="padding:10px; text-align:right;">{{ (listing.stats or {}).total_requests or 0 }}</td>
<td style="padding:10px; text-align:center;">{{ 'Yes' if listing.is_active else 'No' }}</td>
</tr>
{% else %}
<tr>
<td colspan="10" style="padding:16px; text-align:center; color: var(--color-muted);">No market listings yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p style="margin: 0 0 16px 0; color: var(--color-text);">
Review, search, filter, and moderate exported market listings on the dedicated administration page.
</p>
<a class="btn" href="{{ url_for(request, '/dashboard/admin/market') }}" style="background: var(--color-link); color: white;">
<i class="fas fa-arrow-right me-2"></i>Open Market Administration
</a>
</div>
<!-- Global Currency Settings -->
......
import json
import sys
from base64 import b64encode
from pathlib import Path
from fastapi.responses import HTMLResponse
from fastapi.testclient import TestClient
from itsdangerous import TimestampSigner
from jinja2 import Environment, FileSystemLoader, select_autoescape
from aisbf.routes.dashboard import admin as dashboard_admin
from aisbf.routes.dashboard import market as dashboard_market
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from main import app
class TemplateCapture:
def __init__(self):
self.calls = []
self._env = Environment(
loader=FileSystemLoader(str(Path(__file__).resolve().parents[2] / "templates")),
autoescape=select_autoescape(["html", "xml"]),
)
self._env.globals["url_for"] = lambda request, path, **kwargs: path
def TemplateResponse(self, *args, **kwargs):
request = kwargs["request"]
name = kwargs["name"]
context = kwargs["context"]
self.calls.append({"request": request, "name": name, "context": context})
template = self._env.get_template(name)
return HTMLResponse(template.render(**context))
class MarketAdminDbStub:
def __init__(self):
self.current_market_settings = {
"enabled": True,
"allow_user_publish": True,
"allow_admin_publish": True,
"allow_import": True,
}
self.last_paginated_call = None
self.items = [
{
"id": 7,
"title": "Alice Provider",
"owner_username": "alice",
"source_type": "provider",
"source_id": "alice-provider",
"provider_id": "alice-provider",
"model_id": None,
"price_per_million_tokens": 1.5,
"price_per_1000_requests": 0.2,
"stats": {"gross_revenue": 42.0, "total_requests": 12},
"is_active": True,
"metadata": {},
}
]
self.total = 1
def get_market_settings(self):
return dict(self.current_market_settings)
def get_currency_settings(self):
return {"currency_symbol": "$"}
def list_market_listings_paginated(self, **kwargs):
self.last_paginated_call = kwargs
return {
"items": list(self.items),
"total": self.total,
}
def get_provider_disabled_until(self, owner_user_id, provider_id):
return None
def get_provider_usage(self, owner_user_id, provider_id):
return {"usage_data": {"ok": True}}
def get_user_by_id(self, user_id):
return {"display_name": "Alice"}
def get_market_vote_summary(self, listing_id):
return {
"listing": {"upvotes": 0, "downvotes": 0, "score": 0},
"provider": {"upvotes": 0, "downvotes": 0, "score": 0},
"model": {"upvotes": 0, "downvotes": 0, "score": 0},
"user": {"upvotes": 0, "downvotes": 0, "score": 0},
}
def get_market_listing_stats(self, listing_id):
return {"gross_revenue": 42.0, "total_requests": 12}
class RegistryStub:
def __init__(self, db):
self._db = db
def get_config_database(self):
return self._db
def _find_session_secret() -> str:
for middleware in app.user_middleware:
kwargs = getattr(middleware, "kwargs", {})
secret_key = kwargs.get("secret_key")
if secret_key:
return secret_key
raise AssertionError("Session middleware secret key not found")
def _set_session_cookie(client: TestClient, data: dict) -> None:
signer = TimestampSigner(_find_session_secret())
serialized = b64encode(json.dumps(data).encode("utf-8"))
signed = signer.sign(serialized).decode("utf-8")
client.cookies.set("session", signed)
def _login_as_admin(client: TestClient) -> None:
_set_session_cookie(
client,
{
"logged_in": True,
"username": "admin",
"role": "admin",
"user_id": None,
"expires_at": 4102444800,
},
)
def test_payment_settings_shows_market_admin_link_not_embedded_table(monkeypatch):
db = MarketAdminDbStub()
templates = TemplateCapture()
client = TestClient(app)
_login_as_admin(client)
monkeypatch.setattr(dashboard_admin, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_admin, "_templates", templates)
response = client.get("/dashboard/admin/payment-settings")
assert response.status_code == 200
assert templates.calls[-1]["name"] == "dashboard/admin_payment_settings.html"
assert "Open Market Administration" in response.text
assert "Review, search, filter, and moderate exported market listings" in response.text
assert "<table style=\"width:100%; border-collapse: collapse;\">" not in response.text
def test_admin_market_page_supports_search_filters_and_pagination(monkeypatch):
db = MarketAdminDbStub()
db.total = 25
templates = TemplateCapture()
client = TestClient(app)
_login_as_admin(client)
monkeypatch.setattr(dashboard_market, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_market, "_templates", templates)
response = client.get(
"/dashboard/admin/market",
params={
"q": "alice",
"source_type": "provider",
"active_filter": "active",
"online_filter": "online",
"owner_username": "alice",
"page": 2,
"limit": 10,
},
)
assert response.status_code == 200
assert db.last_paginated_call == {
"page": 2,
"limit": 10,
"search": "alice",
"source_type": "provider",
"active_filter": "active",
"online_filter": "online",
"owner_username": "alice",
}
assert templates.calls[-1]["name"] == "dashboard/admin_market.html"
assert templates.calls[-1]["context"]["filters"] == {
"q": "alice",
"source_type": "provider",
"active_filter": "active",
"online_filter": "online",
"owner_username": "alice",
"page": 2,
"limit": 10,
}
assert 'value="alice"' in response.text
assert 'value="provider" selected' in response.text
assert 'value="active" selected' in response.text
assert 'value="online" selected' in response.text
assert 'page=2' in response.text
def test_admin_market_page_clamps_page_to_last_result_page(monkeypatch):
db = MarketAdminDbStub()
db.items = []
db.total = 1
templates = TemplateCapture()
client = TestClient(app)
_login_as_admin(client)
monkeypatch.setattr(dashboard_market, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_market, "_templates", templates)
response = client.get(
"/dashboard/admin/market",
params={
"q": "missing",
"page": 9,
"limit": 1,
},
)
assert response.status_code == 200
assert templates.calls[-1]["context"]["pagination"]["page"] == 1
assert templates.calls[-1]["context"]["pagination"]["total_pages"] == 1
assert "No market listings match the current filters." in response.text
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment