feat: render market references as locked resources

parent 70e0b5e3
......@@ -27,6 +27,32 @@ _server_config = None
logger = logging.getLogger(__name__)
def _serialize_market_reference(reference: dict, listing: dict | None) -> dict:
listing = listing or {}
return {
'id': f"market-ref:{reference['id']}",
'name': reference.get('display_name') or reference.get('source_id') or reference.get('reference_type') or 'Market Reference',
'type': reference.get('reference_type'),
'market_reference': True,
'read_only': True,
'owner_username': reference.get('owner_username'),
'listing_id': reference.get('listing_id'),
'source_type': reference.get('source_type'),
'source_id': reference.get('source_id'),
'availability': 'active' if listing.get('is_active') else 'unavailable',
}
def _list_dashboard_market_references(db, user_id: int, reference_type: str) -> list[dict]:
references = []
for reference in db.list_market_import_references(user_id) or []:
if reference.get('reference_type') != reference_type:
continue
listing = db.get_market_listing(reference.get('listing_id')) if reference.get('listing_id') else None
references.append(_serialize_market_reference(reference, listing))
return references
def _serialize_provider_usage_snapshot(snapshot):
if not snapshot:
return None
......@@ -432,6 +458,14 @@ async def dashboard_providers(request: Request):
_ensure_coderai_token(provider['config']),
broker_status_map,
)
provider_references = _list_dashboard_market_references(db, current_user_id, 'provider')
for reference in provider_references:
user_providers.append({
'provider_id': reference['id'],
'config': reference,
'created_at': None,
'updated_at': None,
})
providers_data = user_providers
# Check for success parameter
......@@ -1180,6 +1214,8 @@ async def dashboard_rotations(request: Request):
rotations_data = {"rotations": {}, "notifyerrors": False}
for rotation in user_rotations:
rotations_data["rotations"][rotation['rotation_id']] = rotation['config']
for reference in _list_dashboard_market_references(db, current_user_id, 'rotation'):
rotations_data["rotations"][reference['id']] = reference
# Get available providers - user-specific for database users
if is_config_admin:
......@@ -1404,6 +1440,8 @@ async def dashboard_autoselect(request: Request):
autoselect_data = {}
for autoselect in user_autoselects:
autoselect_data[autoselect['autoselect_id']] = autoselect['config']
for reference in _list_dashboard_market_references(db, current_user_id, 'autoselect'):
autoselect_data[reference['id']] = reference
# Check for success parameter
success = request.query_params.get('success')
......
......@@ -292,6 +292,7 @@ function renderAutoselectList() {
visibleKeys.forEach(key => {
const autoselect = autoselectConfig[key];
const isMarketReference = !!(autoselect && autoselect.market_reference);
const autoselectItem = document.createElement('div');
autoselectItem.className = 'autoselect-item';
autoselectItem.dataset.sortKey = key;
......@@ -308,10 +309,11 @@ function renderAutoselectList() {
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span>
<strong style="font-size: 16px;">${escHtml(autoselect.model_name || key)}</strong>
<span style="color: var(--color-muted); font-size: 14px;">(${modelCount} ${modelCount !== 1 ? window.i18n.t('autoselect.models_plural') : window.i18n.t('autoselect.models_singular')})</span>
${isMarketReference ? '<span class="badge" data-market-reference="true" style="background: var(--color-link); color: white; padding: 2px 8px; border-radius: 999px; font-size: 12px;">Market-linked</span><span class="badge" style="background: var(--bg-accent); color: var(--color-text); padding: 2px 8px; border-radius: 999px; font-size: 12px;">Read-only</span>' : ''}
</div>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); copyAutoselect('${safeKey}')" style="padding: 5px 15px;">${window.i18n.t('autoselect.copy')}</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">${window.i18n.t('autoselect.remove')}</button>
${isMarketReference ? '' : `<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); copyAutoselect('${safeKey}')" style="padding: 5px 15px;">${window.i18n.t('autoselect.copy')}</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">${window.i18n.t('autoselect.remove')}</button>`}
</div>
</div>
<div id="autoselect-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid var(--color-border); background: var(--bg-panel);">
......@@ -357,6 +359,19 @@ function toggleAutoselect(key) {
function renderAutoselectDetails(autoselectKey) {
const container = document.getElementById(`autoselect-details-${autoselectKey}`);
const autoselect = autoselectConfig[autoselectKey];
if (autoselect && autoselect.market_reference) {
container.innerHTML = `
<div data-market-reference="true" style="border: 1px solid var(--color-border); border-radius: 6px; padding: 16px; background: var(--bg-page);">
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;">
<span class="badge" data-market-reference="true" style="background: var(--color-link); color: white; padding: 2px 8px; border-radius: 999px; font-size: 12px;">Market-linked</span>
<span class="badge" style="background: var(--bg-accent); color: var(--color-text); padding: 2px 8px; border-radius: 999px; font-size: 12px;">Read-only</span>
</div>
<p style="margin:0 0 8px 0;"><strong>Source:</strong> ${escHtml(autoselect.owner_username || 'Unknown')} / ${escHtml(autoselect.source_id || '')}</p>
<p style="margin:0;"><strong>Availability:</strong> ${escHtml(autoselect.availability || 'unavailable')}</p>
</div>
`;
return;
}
const safeAKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const inheritedCaps = Array.isArray(autoselect.capabilities) ? autoselect.capabilities : [];
const partialCaps = Array.isArray(autoselect.partial_capabilities) ? autoselect.partial_capabilities : [];
......
......@@ -340,6 +340,11 @@ function _providerSupportsUsage(key) {
return p && p.type === 'codex';
}
function isMarketReferenceProvider(key) {
const provider = providersData[key];
return !!(provider && provider.market_reference);
}
function _fmtSeconds(s) {
if (!s || s <= 0) return '0s';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
......@@ -653,6 +658,7 @@ function renderProvidersList() {
} else {
pageKeys.forEach(key => {
const provider = providersData[key];
const isMarketReference = isMarketReferenceProvider(key);
const providerItem = document.createElement('div');
providerItem.className = 'provider-item';
providerItem.dataset.sortKey = key;
......@@ -672,9 +678,10 @@ function renderProvidersList() {
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span>
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: var(--color-muted); font-size: 14px;">(${escHtmlAttr(provider.name || key)})</span>
${isMarketReference ? '<span class="badge" data-market-reference="true" style="background: var(--color-link); color: white; padding: 2px 8px; border-radius: 999px; font-size: 12px;">Market-linked</span><span class="badge" style="background: var(--bg-accent); color: var(--color-text); padding: 2px 8px; border-radius: 999px; font-size: 12px;">Read-only</span>' : ''}
${compactBadge}
</div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeProvider('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">${window.i18n.t('providers.remove')}</button>
${isMarketReference ? '' : `<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeProvider('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">${window.i18n.t('providers.remove')}</button>`}
</div>
<div id="provider-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid var(--color-border); background: var(--bg-panel);">
</div>
......@@ -762,6 +769,19 @@ function toggleProvider(key) {
function renderProviderDetails(key) {
const container = document.getElementById(`provider-details-${key}`);
const provider = providersData[key];
if (provider && provider.market_reference) {
container.innerHTML = `
<div data-market-reference="true" style="border: 1px solid var(--color-border); border-radius: 6px; padding: 16px; background: var(--bg-page);">
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;">
<span class="badge" data-market-reference="true" style="background: var(--color-link); color: white; padding: 2px 8px; border-radius: 999px; font-size: 12px;">Market-linked</span>
<span class="badge" style="background: var(--bg-accent); color: var(--color-text); padding: 2px 8px; border-radius: 999px; font-size: 12px;">Read-only</span>
</div>
<p style="margin:0 0 8px 0;"><strong>Source:</strong> ${escHtmlAttr(provider.owner_username || 'Unknown')} / ${escHtmlAttr(provider.source_id || '')}</p>
<p style="margin:0;"><strong>Availability:</strong> ${escHtmlAttr(provider.availability || 'unavailable')}</p>
</div>
`;
return;
}
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const isKiroProvider = provider.type === 'kiro';
const isClaudeProvider = provider.type === 'claude';
......
......@@ -273,6 +273,7 @@ function renderRotationsList() {
rotationMasterOrder.filter(k => k in (rotationsConfig.rotations || {})).forEach(key => {
const rotation = rotationsConfig.rotations[key];
const isMarketReference = !!(rotation && rotation.market_reference);
const rotationItem = document.createElement('div');
rotationItem.className = 'rotation-item';
rotationItem.dataset.sortKey = key;
......@@ -289,10 +290,11 @@ function renderRotationsList() {
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span>
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: var(--color-muted); font-size: 14px;">(${providerCount} ${window.i18n.t(providerCount !== 1 ? 'rotations.providers_plural' : 'rotations.providers_singular')})</span>
${isMarketReference ? '<span class="badge" data-market-reference="true" style="background: var(--color-link); color: white; padding: 2px 8px; border-radius: 999px; font-size: 12px;">Market-linked</span><span class="badge" style="background: var(--bg-accent); color: var(--color-text); padding: 2px 8px; border-radius: 999px; font-size: 12px;">Read-only</span>' : ''}
</div>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); copyRotation('${safeKey}')" style="padding: 5px 15px;">${window.i18n.t('rotations.copy')}</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">${window.i18n.t('rotations.remove')}</button>
${isMarketReference ? '' : `<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); copyRotation('${safeKey}')" style="padding: 5px 15px;">${window.i18n.t('rotations.copy')}</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">${window.i18n.t('rotations.remove')}</button>`}
</div>
</div>
<div id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid var(--color-border); background: var(--bg-panel);">
......@@ -337,6 +339,19 @@ function toggleRotation(key) {
function renderRotationDetails(rotationKey) {
const container = document.getElementById(`rotation-details-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey];
if (rotation && rotation.market_reference) {
container.innerHTML = `
<div data-market-reference="true" style="border: 1px solid var(--color-border); border-radius: 6px; padding: 16px; background: var(--bg-page);">
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;">
<span class="badge" data-market-reference="true" style="background: var(--color-link); color: white; padding: 2px 8px; border-radius: 999px; font-size: 12px;">Market-linked</span>
<span class="badge" style="background: var(--bg-accent); color: var(--color-text); padding: 2px 8px; border-radius: 999px; font-size: 12px;">Read-only</span>
</div>
<p style="margin:0 0 8px 0;"><strong>Source:</strong> ${escHtmlAttr(rotation.owner_username || 'Unknown')} / ${escHtmlAttr(rotation.source_id || '')}</p>
<p style="margin:0;"><strong>Availability:</strong> ${escHtmlAttr(rotation.availability || 'unavailable')}</p>
</div>
`;
return;
}
const safeRKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const inheritedCaps = Array.isArray(rotation.capabilities) ? rotation.capabilities : [];
const partialCaps = Array.isArray(rotation.partial_capabilities) ? rotation.partial_capabilities : [];
......
......@@ -46,6 +46,9 @@ class MarketReferenceImportDbStub:
self.recorded_imports = []
self.created_references = []
self.reference_rows = []
self.user_providers = []
self.user_rotations = []
self.user_autoselects = []
self.listing = {
"id": 55,
"owner_user_id": 7,
......@@ -196,6 +199,12 @@ class MarketReferenceImportDbStub:
return dict(row)
return None
def list_market_import_references(self, user_id):
return [dict(row) for row in self.reference_rows if row["user_id"] == user_id]
def get_sort_order(self, user_id, resource_type):
return None
def save_user_provider(self, user_id, provider_name, config):
self.saved_user_providers.append((user_id, provider_name, config))
......@@ -217,13 +226,13 @@ class MarketReferenceImportDbStub:
return len(self.recorded_imports)
def get_user_providers(self, user_id):
return []
return [dict(row) for row in self.user_providers]
def get_user_rotations(self, user_id):
return []
return [dict(row) for row in self.user_rotations]
def get_user_autoselects(self, user_id):
return []
return [dict(row) for row in self.user_autoselects]
class RegistryStub:
......@@ -263,6 +272,154 @@ def _login_as_user(client: TestClient, user_id: int = 11) -> None:
)
def _seed_dashboard_market_reference_mix(db: MarketReferenceImportDbStub) -> None:
provider_reference = {
"id": 1,
"user_id": 11,
"listing_id": 55,
"reference_type": "provider",
"display_name": "Alice Provider",
"owner_username": "alice",
"source_type": "provider",
"source_id": "alice-provider",
"is_active": True,
}
rotation_reference = {
"id": 2,
"user_id": 11,
"listing_id": 56,
"reference_type": "rotation",
"display_name": "Alice Rotation",
"owner_username": "alice",
"source_type": "rotation",
"source_id": "alice-rotation",
"is_active": True,
}
autoselect_reference = {
"id": 3,
"user_id": 11,
"listing_id": 57,
"reference_type": "autoselect",
"display_name": "Alice Autoselect",
"owner_username": "alice",
"source_type": "autoselect",
"source_id": "alice-autoselect",
"is_active": True,
}
db.reference_rows = [provider_reference, rotation_reference, autoselect_reference]
db.user_providers = [
{
"provider_id": "local-provider",
"config": {"name": "Local Provider", "type": "openai", "models": []},
"created_at": None,
"updated_at": None,
}
]
db.user_rotations = [
{
"rotation_id": "local-rotation",
"config": {"model_name": "Local Rotation", "providers": []},
}
]
db.user_autoselects = [
{
"autoselect_id": "local-autoselect",
"config": {
"model_name": "Local Autoselect",
"description": "Local chooser",
"selection_model": "internal",
"fallback": "",
"available_models": [],
},
}
]
def test_dashboard_providers_renders_market_reference_alongside_local_provider(monkeypatch):
db = MarketReferenceImportDbStub()
_seed_dashboard_market_reference_mix(db)
capture = TemplateCapture()
client = TestClient(app)
_login_as_user(client)
monkeypatch.setattr(dashboard_market, "DatabaseRegistry", RegistryStub(db))
from aisbf.routes.dashboard import providers as dashboard_providers
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get("/dashboard/providers")
assert response.status_code == 200
assert "Local Provider" in response.text
assert "Alice Provider" in response.text
assert "Market-linked" in response.text
assert "Read-only" in response.text
def test_market_references_do_not_render_local_edit_controls(monkeypatch):
db = MarketReferenceImportDbStub()
_seed_dashboard_market_reference_mix(db)
capture = TemplateCapture()
client = TestClient(app)
_login_as_user(client)
monkeypatch.setattr(dashboard_market, "DatabaseRegistry", RegistryStub(db))
from aisbf.routes.dashboard import providers as dashboard_providers
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get("/dashboard/providers")
assert response.status_code == 200
assert 'data-market-reference="true"' in response.text
assert 'removeProvider(\'market-ref:1\')' not in response.text
assert 'Edit Market Reference' not in response.text
def test_dashboard_rotations_renders_market_reference_alongside_local_rotation(monkeypatch):
db = MarketReferenceImportDbStub()
_seed_dashboard_market_reference_mix(db)
capture = TemplateCapture()
client = TestClient(app)
_login_as_user(client)
monkeypatch.setattr(dashboard_market, "DatabaseRegistry", RegistryStub(db))
from aisbf.routes.dashboard import providers as dashboard_providers
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get("/dashboard/rotations")
assert response.status_code == 200
assert "Local Rotation" in response.text
assert "Alice Rotation" in response.text
assert "Market-linked" in response.text
assert "Read-only" in response.text
assert 'copyRotation(\'market-ref:2\')' not in response.text
def test_dashboard_autoselect_renders_market_reference_alongside_local_entry(monkeypatch):
db = MarketReferenceImportDbStub()
_seed_dashboard_market_reference_mix(db)
capture = TemplateCapture()
client = TestClient(app)
_login_as_user(client)
monkeypatch.setattr(dashboard_market, "DatabaseRegistry", RegistryStub(db))
from aisbf.routes.dashboard import providers as dashboard_providers
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get("/dashboard/autoselect")
assert response.status_code == 200
assert "Local Autoselect" in response.text
assert "Alice Autoselect" in response.text
assert "Market-linked" in response.text
assert "Read-only" in response.text
assert 'copyAutoselect(\'market-ref:3\')' not in response.text
def test_import_market_listing_creates_market_reference_for_provider(monkeypatch):
db = MarketReferenceImportDbStub()
client = TestClient(app)
......
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