feat: refine market admin controls and user filters

parent b17195c8
...@@ -1503,7 +1503,8 @@ class DatabaseManager: ...@@ -1503,7 +1503,8 @@ class DatabaseManager:
def get_users_paginated(self, page: int = 1, limit: int = 25, search: str = None, def get_users_paginated(self, page: int = 1, limit: int = 25, search: str = None,
order_by: str = 'created_at', direction: str = 'desc', order_by: str = 'created_at', direction: str = 'desc',
status_filter: str = None, role_filter: str = None) -> Dict: status_filter: str = None, role_filter: str = None,
tier_filter: str = None, market_export_filter: str = None) -> Dict:
""" """
Get paginated users with search, sorting, and filtering support. Get paginated users with search, sorting, and filtering support.
...@@ -1515,6 +1516,8 @@ class DatabaseManager: ...@@ -1515,6 +1516,8 @@ class DatabaseManager:
direction: Sort direction (asc or desc) direction: Sort direction (asc or desc)
status_filter: Optional status filter ('active', 'inactive', or None) status_filter: Optional status filter ('active', 'inactive', or None)
role_filter: Optional role filter ('admin', 'user', or None) role_filter: Optional role filter ('admin', 'user', or None)
tier_filter: Optional account tier id filter
market_export_filter: Optional market export filter ('exporting', 'not_exporting', or None)
Returns: Returns:
Dictionary with 'users' list and 'total' count Dictionary with 'users' list and 'total' count
...@@ -1575,6 +1578,19 @@ class DatabaseManager: ...@@ -1575,6 +1578,19 @@ class DatabaseManager:
where_conditions.append(f'u.role = {placeholder}') where_conditions.append(f'u.role = {placeholder}')
params.append(role_filter) params.append(role_filter)
if tier_filter:
where_conditions.append(f'u.tier_id = {placeholder}')
params.append(tier_filter)
if market_export_filter == 'exporting':
where_conditions.append(
f'EXISTS (SELECT 1 FROM market_listings ml WHERE ml.owner_user_id = u.id AND ml.is_active = 1)'
)
elif market_export_filter == 'not_exporting':
where_conditions.append(
f'NOT EXISTS (SELECT 1 FROM market_listings ml WHERE ml.owner_user_id = u.id AND ml.is_active = 1)'
)
# Build final WHERE clause # Build final WHERE clause
where_clause = 'WHERE ' + ' AND '.join(where_conditions) if where_conditions else '' where_clause = 'WHERE ' + ' AND '.join(where_conditions) if where_conditions else ''
......
...@@ -1510,6 +1510,7 @@ async def dashboard_admin_payment_settings(request: Request): ...@@ -1510,6 +1510,7 @@ async def dashboard_admin_payment_settings(request: Request):
"session": request.session, "session": request.session,
"currency_symbol": DatabaseRegistry.get_config_database().get_currency_settings().get('currency_symbol', '$'), "currency_symbol": DatabaseRegistry.get_config_database().get_currency_settings().get('currency_symbol', '$'),
"market_settings": DatabaseRegistry.get_config_database().get_market_settings(), "market_settings": DatabaseRegistry.get_config_database().get_market_settings(),
"market_listings": DatabaseRegistry.get_config_database().list_market_listings(active_only=False),
} }
) )
......
...@@ -761,7 +761,9 @@ async def dashboard_users( ...@@ -761,7 +761,9 @@ async def dashboard_users(
order_by: str = Query('created_at', pattern='^(username|last_login|created_at|tier_name)$'), order_by: str = Query('created_at', pattern='^(username|last_login|created_at|tier_name)$'),
direction: str = Query('desc', pattern='^(asc|desc)$'), direction: str = Query('desc', pattern='^(asc|desc)$'),
status_filter: str = Query(None, pattern='^(active|inactive)$'), status_filter: str = Query(None, pattern='^(active|inactive)$'),
role_filter: str = Query(None, pattern='^(admin|user)$') role_filter: str = Query(None, pattern='^(admin|user)$'),
tier_filter: str = Query(None),
market_export_filter: str = Query(None, pattern='^(exporting|not_exporting)$')
): ):
"""Admin user management page""" """Admin user management page"""
auth_check = require_admin(request) auth_check = require_admin(request)
...@@ -778,7 +780,9 @@ async def dashboard_users( ...@@ -778,7 +780,9 @@ async def dashboard_users(
order_by=order_by, order_by=order_by,
direction=direction, direction=direction,
status_filter=status_filter, status_filter=status_filter,
role_filter=role_filter role_filter=role_filter,
tier_filter=tier_filter,
market_export_filter=market_export_filter,
) )
users = result['users'] users = result['users']
...@@ -816,7 +820,9 @@ async def dashboard_users( ...@@ -816,7 +820,9 @@ async def dashboard_users(
"order_by": order_by, "order_by": order_by,
"direction": direction, "direction": direction,
"status_filter": status_filter, "status_filter": status_filter,
"role_filter": role_filter "role_filter": role_filter,
"tier_filter": tier_filter,
"market_export_filter": market_export_filter,
} }
} }
) )
......
...@@ -834,7 +834,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -834,7 +834,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/rotations') }}" {% if '/rotations' in request.path %}class="active"{% endif %} data-i18n="nav.rotations">Rotations</a> <a href="{{ url_for(request, '/dashboard/rotations') }}" {% if '/rotations' in request.path %}class="active"{% endif %} data-i18n="nav.rotations">Rotations</a>
<a href="{{ url_for(request, '/dashboard/autoselect') }}" {% if '/autoselect' in request.path %}class="active"{% endif %} data-i18n="nav.autoselect">Autoselect</a> <a href="{{ url_for(request, '/dashboard/autoselect') }}" {% if '/autoselect' in request.path %}class="active"{% endif %} data-i18n="nav.autoselect">Autoselect</a>
<a href="{{ url_for(request, '/dashboard/studio') }}" {% if '/studio' in request.path %}class="active"{% endif %} data-i18n="nav.studio">Studio</a> <a href="{{ url_for(request, '/dashboard/studio') }}" {% if '/studio' in request.path %}class="active"{% endif %} data-i18n="nav.studio">Studio</a>
{% if request.state.market_enabled is not defined or request.state.market_enabled %}
<a href="{{ url_for(request, '/dashboard/market') }}" {% if '/market' in request.path and '/admin/market' not in request.path %}class="active"{% endif %}>Market</a> <a href="{{ url_for(request, '/dashboard/market') }}" {% if '/market' in request.path and '/admin/market' not in request.path %}class="active"{% endif %}>Market</a>
{% endif %}
<a href="{{ url_for(request, '/dashboard/prompts') }}" {% if '/prompts' in request.path %}class="active"{% endif %} data-i18n="nav.prompts">Prompts</a> <a href="{{ url_for(request, '/dashboard/prompts') }}" {% if '/prompts' in request.path %}class="active"{% endif %} data-i18n="nav.prompts">Prompts</a>
<a href="{{ url_for(request, '/dashboard/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %} data-i18n="nav.analytics">Analytics</a> <a href="{{ url_for(request, '/dashboard/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %} data-i18n="nav.analytics">Analytics</a>
{% if request.session.user_id %} {% if request.session.user_id %}
...@@ -846,7 +848,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -846,7 +848,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/users') }}" {% if '/users' in request.path %}class="active"{% endif %} data-i18n="nav.users">Users</a> <a href="{{ url_for(request, '/dashboard/users') }}" {% if '/users' in request.path %}class="active"{% endif %} data-i18n="nav.users">Users</a>
<a href="{{ url_for(request, '/dashboard/settings') }}" {% if '/settings' in request.path %}class="active"{% endif %} data-i18n="nav.settings">Settings</a> <a href="{{ url_for(request, '/dashboard/settings') }}" {% if '/settings' in request.path %}class="active"{% endif %} data-i18n="nav.settings">Settings</a>
<a href="{{ url_for(request, '/dashboard/admin/tiers') }}" {% if '/admin/tiers' in request.path %}class="active"{% endif %} data-i18n="nav.tiers">Tiers</a> <a href="{{ url_for(request, '/dashboard/admin/tiers') }}" {% if '/admin/tiers' in request.path %}class="active"{% endif %} data-i18n="nav.tiers">Tiers</a>
<a href="{{ url_for(request, '/dashboard/admin/market') }}" {% if '/admin/market' in request.path %}class="active"{% endif %}>Market Admin</a>
<a href="{{ url_for(request, '/dashboard/admin/payment-settings') }}" {% if '/admin/payment-settings' in request.path %}class="active"{% endif %} data-i18n="nav.payment_settings">Payment Settings</a> <a href="{{ url_for(request, '/dashboard/admin/payment-settings') }}" {% if '/admin/payment-settings' in request.path %}class="active"{% endif %} data-i18n="nav.payment_settings">Payment Settings</a>
{% endif %} {% endif %}
{% if show_upgrade_button %} {% if show_upgrade_button %}
......
...@@ -44,6 +44,50 @@ ...@@ -44,6 +44,50 @@
</form> </form>
</div> </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);">
<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>
</div>
<!-- Global Currency Settings --> <!-- Global Currency Settings -->
<div style="background: var(--bg-panel); border: 2px solid #17a2b8; border-radius: 8px; padding: 20px; margin-bottom: 20px;"> <div style="background: var(--bg-panel); border: 2px solid #17a2b8; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: var(--color-link);"> <h3 style="margin: 0 0 20px 0; color: var(--color-link);">
......
...@@ -82,6 +82,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -82,6 +82,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<option value="user" {% if filters.role_filter == 'user' %}selected{% endif %}>User</option> <option value="user" {% if filters.role_filter == 'user' %}selected{% endif %}>User</option>
</select> </select>
<label for="tier-filter" style="color: var(--color-text); margin-left: 10px; margin-right: 5px;" data-i18n="users_page.col_tier">Tier:</label>
<select id="tier-filter" style="padding: 8px; border-radius: 4px; border: 1px solid var(--bg-accent); background: var(--bg-page); color: var(--color-text);">
<option value="">All</option>
{% for tier in tiers %}
<option value="{{ tier.id }}" {% if filters.tier_filter == tier.id|string %}selected{% endif %}>
{{ tier.name }}{% if not tier.is_visible %} (Hidden){% endif %}
</option>
{% endfor %}
</select>
<label for="market-export-filter" style="color: var(--color-text); margin-left: 10px; margin-right: 5px;">Market:</label>
<select id="market-export-filter" style="padding: 8px; border-radius: 4px; border: 1px solid var(--bg-accent); background: var(--bg-page); color: var(--color-text);">
<option value="">All</option>
<option value="exporting" {% if filters.market_export_filter == 'exporting' %}selected{% endif %}>Exporting</option>
<option value="not_exporting" {% if filters.market_export_filter == 'not_exporting' %}selected{% endif %}>Not Exporting</option>
</select>
<button id="search-btn" class="btn" style="padding: 8px 16px;" data-i18n="users_page.search_btn">Search</button> <button id="search-btn" class="btn" style="padding: 8px 16px;" data-i18n="users_page.search_btn">Search</button>
<button id="clear-btn" class="btn btn-secondary" style="padding: 8px 16px;" data-i18n="users_page.clear_btn">Clear</button> <button id="clear-btn" class="btn btn-secondary" style="padding: 8px 16px;" data-i18n="users_page.clear_btn">Clear</button>
</div> </div>
...@@ -370,6 +387,10 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -370,6 +387,10 @@ document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn'); const searchBtn = document.getElementById('search-btn');
const clearBtn = document.getElementById('clear-btn'); const clearBtn = document.getElementById('clear-btn');
const statusFilter = document.getElementById('status-filter');
const roleFilter = document.getElementById('role-filter');
const tierFilter = document.getElementById('tier-filter');
const marketExportFilter = document.getElementById('market-export-filter');
const pageSize = document.getElementById('page-size'); const pageSize = document.getElementById('page-size');
const prevBtn = document.getElementById('prev-btn'); const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn'); const nextBtn = document.getElementById('next-btn');
...@@ -543,19 +564,54 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -543,19 +564,54 @@ document.addEventListener('DOMContentLoaded', function() {
clearBtn.addEventListener('click', function() { clearBtn.addEventListener('click', function() {
if (searchInput) { if (searchInput) {
searchInput.value = ''; searchInput.value = '';
updateUsers({ search: '', page: 1 });
} }
if (statusFilter) {
statusFilter.value = '';
}
if (roleFilter) {
roleFilter.value = '';
}
if (tierFilter) {
tierFilter.value = '';
}
if (marketExportFilter) {
marketExportFilter.value = '';
}
updateUsers({
search: '',
status_filter: null,
role_filter: null,
tier_filter: null,
market_export_filter: null,
page: 1,
});
}); });
} }
// Filter change handlers // Filter change handlers
document.getElementById('status-filter').addEventListener('change', function() { if (statusFilter) {
updateUsers({ status_filter: this.value || null }); statusFilter.addEventListener('change', function() {
}); updateUsers({ status_filter: this.value || null });
});
}
document.getElementById('role-filter').addEventListener('change', function() { if (roleFilter) {
updateUsers({ role_filter: this.value || null }); roleFilter.addEventListener('change', function() {
}); updateUsers({ role_filter: this.value || null });
});
}
if (tierFilter) {
tierFilter.addEventListener('change', function() {
updateUsers({ tier_filter: this.value || null });
});
}
if (marketExportFilter) {
marketExportFilter.addEventListener('change', function() {
updateUsers({ market_export_filter: this.value || null });
});
}
// Initial sorting listeners // Initial sorting listeners
attachSortingListeners(); attachSortingListeners();
...@@ -1070,4 +1126,4 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -1070,4 +1126,4 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
This diff is collapsed.
import pytest
from aisbf.database import DatabaseManager
@pytest.fixture
def db_manager(tmp_path):
db_path = tmp_path / "users.db"
db = DatabaseManager({
'type': 'sqlite',
'sqlite_path': str(db_path),
})
pro_tier_id = db.create_tier(
name='Pro Tier',
description='Paid plan',
price_monthly=10.0,
price_yearly=100.0,
)
users = {
'alice': db.create_user(
'alice',
'hash',
role='user',
email='alice@example.com',
display_name='Alice Exporter',
),
'bob': db.create_user(
'bob',
'hash',
role='user',
email='bob@example.com',
display_name='Bob Browser',
),
'carol': db.create_user(
'carol',
'hash',
role='admin',
email='carol@example.com',
display_name='Carol Admin',
),
'dave': db.create_user(
'dave',
'hash',
role='user',
email='dave@example.com',
display_name='Dave Disabled',
),
}
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE users SET tier_id = ? WHERE id = ?',
(pro_tier_id, users['alice']),
)
cursor.execute(
'UPDATE users SET is_active = 0 WHERE id = ?',
(users['dave'],),
)
conn.commit()
db.upsert_market_listing(
users['alice'],
'alice',
{
'source_scope': 'user',
'source_type': 'provider',
'source_id': 'alice-provider',
'listing_key': 'provider:alice-provider',
'title': 'Alice Provider',
'description': 'Alice export listing',
'provider_id': 'alice-provider',
'model_id': None,
'endpoint': 'https://example.test/alice',
'currency_code': 'USD',
'price_per_million_tokens': 2.5,
'price_per_1000_requests': 0.0,
'provider_price_per_million_tokens': 2.5,
'provider_price_per_1000_requests': 0.0,
'metadata': {'provider_type': 'openai'},
'config_snapshot': {'provider': {'type': 'openai'}},
'is_active': True,
},
)
db.upsert_market_listing(
users['carol'],
'carol',
{
'source_scope': 'user',
'source_type': 'provider',
'source_id': 'carol-provider',
'listing_key': 'provider:carol-provider',
'title': 'Carol Provider',
'description': 'Carol export listing',
'provider_id': 'carol-provider',
'model_id': None,
'endpoint': 'https://example.test/carol',
'currency_code': 'USD',
'price_per_million_tokens': 4.0,
'price_per_1000_requests': 0.0,
'provider_price_per_million_tokens': 4.0,
'provider_price_per_1000_requests': 0.0,
'metadata': {'provider_type': 'openai'},
'config_snapshot': {'provider': {'type': 'openai'}},
'is_active': True,
},
)
db.upsert_market_listing(
users['dave'],
'dave',
{
'source_scope': 'user',
'source_type': 'provider',
'source_id': 'dave-provider',
'listing_key': 'provider:dave-provider',
'title': 'Dave Provider',
'description': 'Dave inactive export listing',
'provider_id': 'dave-provider',
'model_id': None,
'endpoint': 'https://example.test/dave',
'currency_code': 'USD',
'price_per_million_tokens': 1.0,
'price_per_1000_requests': 0.0,
'provider_price_per_million_tokens': 1.0,
'provider_price_per_1000_requests': 0.0,
'metadata': {'provider_type': 'openai'},
'config_snapshot': {'provider': {'type': 'openai'}},
'is_active': False,
},
)
return {
'db': db,
'pro_tier_id': pro_tier_id,
}
def _usernames(result):
return [user['username'] for user in result['users']]
def test_get_users_paginated_filters_by_tier_id(db_manager):
result = db_manager['db'].get_users_paginated(
tier_filter=db_manager['pro_tier_id'],
order_by='username',
direction='asc',
)
assert result['total'] == 1
assert _usernames(result) == ['alice']
def test_get_users_paginated_nonexistent_tier_id_returns_no_matches(db_manager):
result = db_manager['db'].get_users_paginated(
tier_filter=999999,
order_by='username',
direction='asc',
)
assert result['total'] == 0
assert _usernames(result) == []
def test_get_users_paginated_filters_users_with_market_exports(db_manager):
result = db_manager['db'].get_users_paginated(
market_export_filter='exporting',
order_by='username',
direction='asc',
)
assert result['total'] == 2
assert _usernames(result) == ['alice', 'carol']
def test_get_users_paginated_filters_users_without_market_exports(db_manager):
result = db_manager['db'].get_users_paginated(
market_export_filter='not_exporting',
order_by='username',
direction='asc',
)
assert result['total'] == 2
assert _usernames(result) == ['bob', 'dave']
def test_get_users_paginated_ignores_inactive_only_market_exports(db_manager):
result = db_manager['db'].get_users_paginated(
market_export_filter='exporting',
search='dave',
order_by='username',
direction='asc',
)
assert result['total'] == 0
assert _usernames(result) == []
def test_get_users_paginated_unsupported_market_export_filter_falls_back_to_unfiltered(db_manager):
result = db_manager['db'].get_users_paginated(
market_export_filter='maybe',
order_by='username',
direction='asc',
)
assert result['total'] == 4
assert _usernames(result) == ['alice', 'bob', 'carol', 'dave']
def test_get_users_paginated_combines_market_export_and_existing_filters(db_manager):
result = db_manager['db'].get_users_paginated(
search='alice',
status_filter='active',
role_filter='user',
market_export_filter='exporting',
order_by='username',
direction='asc',
)
assert result['total'] == 1
assert _usernames(result) == ['alice']
def test_get_users_paginated_combines_tier_and_existing_filters(db_manager):
result = db_manager['db'].get_users_paginated(
search='alice',
status_filter='active',
role_filter='user',
tier_filter=db_manager['pro_tier_id'],
order_by='username',
direction='asc',
)
assert result['total'] == 1
assert _usernames(result) == ['alice']
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