Spaces:
Running
Running
Let sponsors request a dataset from the Space, once DataBounty takes requests
Browse files- README.md +141 -135
- account.py +531 -518
- databounty.py +442 -426
- requesting.py +209 -0
- web/app.css +439 -424
- web/app.js +217 -2
- web/index.html +2 -2
- webapi.py +619 -536
README.md
CHANGED
|
@@ -1,135 +1,141 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: DataBounty
|
| 3 |
-
emoji: 🧭
|
| 4 |
-
colorFrom: green
|
| 5 |
-
colorTo: blue
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.30.0
|
| 8 |
-
python_version: "3.12"
|
| 9 |
-
app_file: app.py
|
| 10 |
-
short_description: Contribute and review dataset items, karma and MCP tools
|
| 11 |
-
license: apache-2.0
|
| 12 |
-
pinned: true
|
| 13 |
-
tags:
|
| 14 |
-
- datasets
|
| 15 |
-
- data-collection
|
| 16 |
-
- crowdsourcing
|
| 17 |
-
- mcp-server
|
| 18 |
-
datasets:
|
| 19 |
-
- databounty-io/graphql-schema-resolver-execution-validation-cmskdigf
|
| 20 |
-
- databounty-io/build-competitive-programming-problem-dataset-cmsokff2
|
| 21 |
-
- databounty-io/regex-pattern-generation-with-verified-match-sets-cmskdvm7
|
| 22 |
-
- databounty-io/extract-metrics-from-log-fixtures-cmskdlip
|
| 23 |
-
- databounty-io/python-execution-trace-output-prediction-cmskdimp
|
| 24 |
-
- databounty-io/python-data-transformation-scripts-cmskdijz
|
| 25 |
-
---
|
| 26 |
-
|
| 27 |
-
# DataBounty
|
| 28 |
-
|
| 29 |
-
A live window onto [DataBounty](https://databounty.io), the open-source, karma-based platform where the community
|
| 30 |
-
builds datasets, and a place to contribute to it. Sponsors request a dataset, contributors submit items, automated checks validate each
|
| 31 |
-
submission and human validators review the results. Finished datasets are published here on the Hub under
|
| 32 |
-
[databounty-io](https://huggingface.co/databounty-io), with contributor credit.
|
| 33 |
-
|
| 34 |
-
## What's inside
|
| 35 |
-
|
| 36 |
-
It looks and works like the console on databounty.io:
|
| 37 |
-
|
| 38 |
-
- **Contribute**: your karma, every open pool, and your submissions. Open a pool to see what it wants and how items
|
| 39 |
-
are checked, then add items one at a time or many from a file.
|
| 40 |
-
- **Validate**: review other people's items. Claim an open audit, check each item against the pool's spec and its
|
| 41 |
-
automated checks, then approve it or reject it with a reason and a note for the contributor.
|
| 42 |
-
- **Sponsor**:
|
| 43 |
-
|
| 44 |
-
- **Datasets**: DataBounty releases on the Hub, with download counts.
|
| 45 |
-
- **Leaderboard**: public karma standings and profiles. Private profiles, and any field a member hides, are never shown.
|
| 46 |
-
- **API & MCP**: connect Claude, Cursor or VS Code to DataBounty's own MCP server.
|
| 47 |
-
- **About**: live programme totals, the karma tiers and how it all works.
|
| 48 |
-
|
| 49 |
-
## Contributing from the Space
|
| 50 |
-
|
| 51 |
-
Press **sign in with DataBounty** and sign in with your DataBounty account, then approve the Space. On the Space's
|
| 52 |
-
own page, sign-in happens in the same tab. On the huggingface.co page, a small sign-in window opens and closes itself
|
| 53 |
-
when you're done, and the page signs you in where you are. The Space asks for six permissions: read pools and your
|
| 54 |
-
own items, contribute items, review other people's items, see the items in pools you requested, upload files for
|
| 55 |
-
file fields, and set up your account on first sign-in. You can untick any of them, but the Space can't contribute
|
| 56 |
-
without *contribute*, review without *validate*, or show your pools' items without *sponsor*.
|
| 57 |
-
|
| 58 |
-
Then pick a pool, and add one item with the form or many at once from a JSONL, JSON, CSV or TSV file (there's a
|
| 59 |
-
template to start from). A file is checked row by row before anything is sent. Each pool shows an example item when
|
| 60 |
-
it has one, and its full requirements one click away. **My submissions** shows where each item stands, check by check.
|
| 61 |
-
|
| 62 |
-
- DataBounty takes items only from accounts with a verified email and a public username. The Space walks you through
|
| 63 |
-
both on first sign-in. Choosing a username makes your profile public at `databounty.io/<username>` straight away.
|
| 64 |
-
- Your DataBounty tokens stay in the Space's memory and are never written to disk or sent to your browser, which
|
| 65 |
-
keeps only a random key to them. Signing out revokes the tokens. A restart of the Space signs everyone out.
|
| 66 |
-
- Revisions, contesting a reviewer's verdict, and requesting a dataset happen on [databounty.io](https://databounty.io).
|
| 67 |
-
|
| 68 |
-
## Reviewing from the Space
|
| 69 |
-
|
| 70 |
-
Open **Validate** and claim an open audit: a few items from one pool, yours for 24 hours. You never get your own work.
|
| 71 |
-
Go through the items one at a time and approve each one, or reject it with a reason and a note of at least 10
|
| 72 |
-
characters, which is all the contributor sees. Decisions are final. You earn karma for every decision when the audit
|
| 73 |
-
completes, and your validator rank sets how many audits you can hold at once.
|
| 74 |
-
|
| 75 |
-
## Sponsoring from the Space
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
``
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
``
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
and
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
##
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: DataBounty
|
| 3 |
+
emoji: 🧭
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.30.0
|
| 8 |
+
python_version: "3.12"
|
| 9 |
+
app_file: app.py
|
| 10 |
+
short_description: Contribute and review dataset items, karma and MCP tools
|
| 11 |
+
license: apache-2.0
|
| 12 |
+
pinned: true
|
| 13 |
+
tags:
|
| 14 |
+
- datasets
|
| 15 |
+
- data-collection
|
| 16 |
+
- crowdsourcing
|
| 17 |
+
- mcp-server
|
| 18 |
+
datasets:
|
| 19 |
+
- databounty-io/graphql-schema-resolver-execution-validation-cmskdigf
|
| 20 |
+
- databounty-io/build-competitive-programming-problem-dataset-cmsokff2
|
| 21 |
+
- databounty-io/regex-pattern-generation-with-verified-match-sets-cmskdvm7
|
| 22 |
+
- databounty-io/extract-metrics-from-log-fixtures-cmskdlip
|
| 23 |
+
- databounty-io/python-execution-trace-output-prediction-cmskdimp
|
| 24 |
+
- databounty-io/python-data-transformation-scripts-cmskdijz
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
# DataBounty
|
| 28 |
+
|
| 29 |
+
A live window onto [DataBounty](https://databounty.io), the open-source, karma-based platform where the community
|
| 30 |
+
builds datasets, and a place to contribute to it. Sponsors request a dataset, contributors submit items, automated checks validate each
|
| 31 |
+
submission and human validators review the results. Finished datasets are published here on the Hub under
|
| 32 |
+
[databounty-io](https://huggingface.co/databounty-io), with contributor credit.
|
| 33 |
+
|
| 34 |
+
## What's inside
|
| 35 |
+
|
| 36 |
+
It looks and works like the console on databounty.io:
|
| 37 |
+
|
| 38 |
+
- **Contribute**: your karma, every open pool, and your submissions. Open a pool to see what it wants and how items
|
| 39 |
+
are checked, then add items one at a time or many from a file.
|
| 40 |
+
- **Validate**: review other people's items. Claim an open audit, check each item against the pool's spec and its
|
| 41 |
+
automated checks, then approve it or reject it with a reason and a note for the contributor.
|
| 42 |
+
- **Sponsor**: request a dataset, add example items to it and follow it through review. If you requested a pool,
|
| 43 |
+
its page here also shows you every item in it, and you can dispute an accepted one.
|
| 44 |
+
- **Datasets**: DataBounty releases on the Hub, with download counts.
|
| 45 |
+
- **Leaderboard**: public karma standings and profiles. Private profiles, and any field a member hides, are never shown.
|
| 46 |
+
- **API & MCP**: connect Claude, Cursor or VS Code to DataBounty's own MCP server.
|
| 47 |
+
- **About**: live programme totals, the karma tiers and how it all works.
|
| 48 |
+
|
| 49 |
+
## Contributing from the Space
|
| 50 |
+
|
| 51 |
+
Press **sign in with DataBounty** and sign in with your DataBounty account, then approve the Space. On the Space's
|
| 52 |
+
own page, sign-in happens in the same tab. On the huggingface.co page, a small sign-in window opens and closes itself
|
| 53 |
+
when you're done, and the page signs you in where you are. The Space asks for six permissions: read pools and your
|
| 54 |
+
own items, contribute items, review other people's items, see the items in pools you requested, upload files for
|
| 55 |
+
file fields, and set up your account on first sign-in. You can untick any of them, but the Space can't contribute
|
| 56 |
+
without *contribute*, review without *validate*, or show your pools' items without *sponsor*.
|
| 57 |
+
|
| 58 |
+
Then pick a pool, and add one item with the form or many at once from a JSONL, JSON, CSV or TSV file (there's a
|
| 59 |
+
template to start from). A file is checked row by row before anything is sent. Each pool shows an example item when
|
| 60 |
+
it has one, and its full requirements one click away. **My submissions** shows where each item stands, check by check.
|
| 61 |
+
|
| 62 |
+
- DataBounty takes items only from accounts with a verified email and a public username. The Space walks you through
|
| 63 |
+
both on first sign-in. Choosing a username makes your profile public at `databounty.io/<username>` straight away.
|
| 64 |
+
- Your DataBounty tokens stay in the Space's memory and are never written to disk or sent to your browser, which
|
| 65 |
+
keeps only a random key to them. Signing out revokes the tokens. A restart of the Space signs everyone out.
|
| 66 |
+
- Revisions, contesting a reviewer's verdict, and requesting a dataset happen on [databounty.io](https://databounty.io).
|
| 67 |
+
|
| 68 |
+
## Reviewing from the Space
|
| 69 |
+
|
| 70 |
+
Open **Validate** and claim an open audit: a few items from one pool, yours for 24 hours. You never get your own work.
|
| 71 |
+
Go through the items one at a time and approve each one, or reject it with a reason and a note of at least 10
|
| 72 |
+
characters, which is all the contributor sees. Decisions are final. You earn karma for every decision when the audit
|
| 73 |
+
completes, and your validator rank sets how many audits you can hold at once.
|
| 74 |
+
|
| 75 |
+
## Sponsoring from the Space
|
| 76 |
+
|
| 77 |
+
To request a dataset, open **Sponsor**, pick a dataset type, and say how many items you want, how hard, and under
|
| 78 |
+
which licence. Then add up to three example items to the request, as JSON files (each request has a template to
|
| 79 |
+
start from). DataBounty's team reviews the request and its examples, and the pool can open once one example is
|
| 80 |
+
approved. **Your requests** shows where each one stands, with any note from the reviewer. Requesting from the
|
| 81 |
+
Space needs DataBounty's request tool; where it isn't live yet, the Sponsor page links to
|
| 82 |
+
[databounty.io](https://console.databounty.io/sponsor/create/) instead.
|
| 83 |
+
|
| 84 |
+
Once your pool is open, sign in here and open the pool's page: below its details you see every item in it, newest first, with its
|
| 85 |
+
fields, its automated checks and any reviewer flags, and you can filter by where items stand. While an accepted
|
| 86 |
+
item's dispute window is open, you can dispute it with a reason and an explanation of at least 10 characters. An
|
| 87 |
+
admin decides, and the contributor's karma is only reversed if the dispute is upheld. Nobody else sees this view:
|
| 88 |
+
DataBounty shows a pool's items only to the person who requested it.
|
| 89 |
+
|
| 90 |
+
## Use it from an agent
|
| 91 |
+
|
| 92 |
+
This Space is also an MCP server:
|
| 93 |
+
|
| 94 |
+
```text
|
| 95 |
+
https://databounty-io-databounty.hf.space/gradio_api/mcp/
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
- **Six tools browse, with nothing to set up:** `get_platform_stats`, `search_open_pools`, `get_pool_details`,
|
| 99 |
+
`list_published_datasets`, `get_karma_leaderboard` and `get_contributor_profile`.
|
| 100 |
+
- **Five tools submit items as you:** `get_my_account`, `get_pool_contract`, `submit_items`,
|
| 101 |
+
`list_my_submissions` and `check_submission`. They need a DataBounty API key:
|
| 102 |
+
1. Create one at [console.databounty.io/developers](https://console.databounty.io/developers/) with the
|
| 103 |
+
**contribute** scope.
|
| 104 |
+
2. Add it to your MCP client's settings for this server as the `X-DataBounty-Key` header. It never goes in a
|
| 105 |
+
message or a tool argument, so the model never sees it.
|
| 106 |
+
|
| 107 |
+
The Space passes the key to DataBounty for each call and keeps nothing. Revoke it on DataBounty any time.
|
| 108 |
+
- **Two tools for sponsors:** `get_pool_items_as_sponsor` lists the items in a pool you requested, with their
|
| 109 |
+
checks, and `dispute_accepted_item` disputes an accepted one. They need a key with the **sponsor** scope and
|
| 110 |
+
work only on your own pools.
|
| 111 |
+
|
| 112 |
+
Claude Code:
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
claude mcp add --transport http databounty-space https://databounty-io-databounty.hf.space/gradio_api/mcp/ \
|
| 116 |
+
--header "X-DataBounty-Key: db_live_sk_..."
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
Cursor (`~/.cursor/mcp.json`):
|
| 120 |
+
|
| 121 |
+
```json
|
| 122 |
+
{ "mcpServers": { "databounty-space": {
|
| 123 |
+
"url": "https://databounty-io-databounty.hf.space/gradio_api/mcp/",
|
| 124 |
+
"headers": { "X-DataBounty-Key": "db_live_sk_..." } } } }
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
An agent submits at most a pool's per-call limit at once; for more, upload a file on the Space's pool page. The
|
| 128 |
+
Hub prefixes each tool name with the Space name (`DataBounty_submit_items` and so on). Reviewing other people's
|
| 129 |
+
items, and items with files, go through DataBounty's own MCP server at `https://console.databounty.io/mcp`, which
|
| 130 |
+
signs in with OAuth; setup for each client is on [databounty.io/agents](https://databounty.io/agents/).
|
| 131 |
+
|
| 132 |
+
## Data
|
| 133 |
+
|
| 134 |
+
Browsing reads DataBounty's public API (`api.databounty.io`) and the Hub, caching responses for about two minutes,
|
| 135 |
+
and stores nothing about visitors. When you sign in, the Space acts for you through DataBounty's MCP server, and only
|
| 136 |
+
when you press a button. Files you attach pass straight through to DataBounty: the Space doesn't keep them.
|
| 137 |
+
|
| 138 |
+
## Source
|
| 139 |
+
|
| 140 |
+
Apache-2.0. The code lives in [Databounty-io/databounty](https://github.com/Databounty-io/databounty) under
|
| 141 |
+
`deploy/huggingface/databounty`.
|
account.py
CHANGED
|
@@ -1,518 +1,531 @@
|
|
| 1 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
-
"""Signing in to DataBounty from the Space, and acting as the person who signed in.
|
| 3 |
-
|
| 4 |
-
The Space is an OAuth 2.1 public client of DataBounty's MCP authorization server,
|
| 5 |
-
whose endpoints it reads from the server's own metadata document. It identifies
|
| 6 |
-
itself with a Client ID Metadata Document that it serves (/oauth/client-metadata.json),
|
| 7 |
-
so there is no registration step and no client secret to keep.
|
| 8 |
-
|
| 9 |
-
How a sign-in finishes where it started. Opened at its own address, the Space signs in
|
| 10 |
-
in the same tab. Inside the huggingface.co embed it can't: the embed may open windows
|
| 11 |
-
but not leave the page, and DataBounty's sign-in pages refuse to be framed. There it
|
| 12 |
-
opens a small sign-in window, which closes itself when done.
|
| 13 |
-
|
| 14 |
-
1. The page keeps a random secret in its own sessionStorage and starts the sign-in
|
| 15 |
-
with only the secret's hash (/oauth/start?n=<hash>). /oauth/start refuses a start
|
| 16 |
-
that didn't come from the Space's own page (Sec-Fetch-Site), so nobody can hand
|
| 17 |
-
someone a link that signs *them* in for the sender to collect.
|
| 18 |
-
2. /oauth/start sets a short-lived, signed, httpOnly cookie holding the `state`, the
|
| 19 |
-
PKCE verifier and the hash, then sends the browser to DataBounty. The callback
|
| 20 |
-
accepts only a `state` matching that cookie, so it ties the round trip to the
|
| 21 |
-
browser that started it.
|
| 22 |
-
3. The callback holds the new session under the hash for a few minutes
|
| 23 |
-
(hold_sign_in). The page that started it claims it with the secret itself
|
| 24 |
-
(claim_sign_in): nobody else has that secret, and it is never in a URL.
|
| 25 |
-
4. The page keeps the session handle in its own localStorage and sends it as a
|
| 26 |
-
bearer token (webapi.py), not in a cookie: inside the embed, the Space's cookies
|
| 27 |
-
are third-party and most browsers withhold them.
|
| 28 |
-
|
| 29 |
-
Starting a sign-in keeps nothing on the server, so hitting /oauth/start repeatedly
|
| 30 |
-
cannot crowd out real sign-ins. Tokens never reach the browser: it holds only a
|
| 31 |
-
random session handle. The access and refresh tokens stay in this process's memory,
|
| 32 |
-
so a restart signs everyone out; nothing is written to disk.
|
| 33 |
-
"""
|
| 34 |
-
|
| 35 |
-
from __future__ import annotations
|
| 36 |
-
|
| 37 |
-
import base64
|
| 38 |
-
import hashlib
|
| 39 |
-
import hmac
|
| 40 |
-
import itertools
|
| 41 |
-
import json
|
| 42 |
-
import os
|
| 43 |
-
import re
|
| 44 |
-
import secrets
|
| 45 |
-
import threading
|
| 46 |
-
import time
|
| 47 |
-
from dataclasses import dataclass, field
|
| 48 |
-
from pathlib import Path
|
| 49 |
-
from typing import Any
|
| 50 |
-
from urllib.parse import urlencode, urlsplit
|
| 51 |
-
|
| 52 |
-
import httpx
|
| 53 |
-
|
| 54 |
-
DATABOUNTY_MCP_URL = os.environ.get("DATABOUNTY_MCP_URL", "https://console.databounty.io/mcp").rstrip("/")
|
| 55 |
-
# Only for an upload URL DataBounty returns as a path (local storage); everything else goes through MCP.
|
| 56 |
-
DATABOUNTY_API_URL = os.environ.get("DATABOUNTY_API_URL", "https://api.databounty.io").rstrip("/")
|
| 57 |
-
SPACE_HOST = os.environ.get("SPACE_HOST", "databounty-io-databounty.hf.space")
|
| 58 |
-
SPACE_URL = os.environ.get("SPACE_PUBLIC_URL", f"https://{SPACE_HOST}").rstrip("/")
|
| 59 |
-
|
| 60 |
-
# DataBounty fetches this URL to learn who the Space is. DATABOUNTY_CLIENT_ID overrides it only for a
|
| 61 |
-
# local run, where DataBounty refuses to fetch a localhost document and a registered client id is used.
|
| 62 |
-
CLIENT_ID = os.environ.get("DATABOUNTY_CLIENT_ID") or f"{SPACE_URL}/oauth/client-metadata.json"
|
| 63 |
-
REDIRECT_URI = f"{SPACE_URL}/oauth/callback"
|
| 64 |
-
# Only what the Space's screens need: read pools and your own items, submit and
|
| 65 |
-
# revise them, upload files for file fields, review other people's items, see the
|
| 66 |
-
# items in a pool you requested, and set up a handle on first sign-in. Each is a
|
| 67 |
-
# box the person can untick.
|
| 68 |
-
SCOPES = "read contribute validate artifact sponsor account"
|
| 69 |
-
|
| 70 |
-
LOGIN_COOKIE = "dbspace_login"
|
| 71 |
-
LOGIN_TTL_SECONDS = 10 * 60
|
| 72 |
-
SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60
|
| 73 |
-
MAX_SESSIONS = 20_000
|
| 74 |
-
REFRESH_MARGIN_SECONDS = 60
|
| 75 |
-
# How long a finished sign-in waits for the page that started it to claim it.
|
| 76 |
-
CLAIM_TTL_SECONDS = 5 * 60
|
| 77 |
-
_NONCE_HASH = re.compile(r"^[0-9a-f]{64}$")
|
| 78 |
-
TIMEOUT = httpx.Timeout(20.0, connect=10.0)
|
| 79 |
-
USER_AGENT = "databounty-space/1.0 (+https://huggingface.co/spaces/databounty-io/DataBounty)"
|
| 80 |
-
|
| 81 |
-
# Secure cookies everywhere except a local run over plain http.
|
| 82 |
-
COOKIE_SECURE = SPACE_URL.startswith("https://")
|
| 83 |
-
# Signs the login cookie. A Space secret keeps sign-ins in flight valid across a
|
| 84 |
-
# restart; without one, a restart only means starting that sign-in again.
|
| 85 |
-
_COOKIE_KEY = (os.environ.get("SPACE_COOKIE_SECRET") or secrets.token_urlsafe(32)).encode()
|
| 86 |
-
|
| 87 |
-
_client = httpx.Client(timeout=TIMEOUT, headers={"User-Agent": USER_AGENT})
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
class SignInError(Exception):
|
| 91 |
-
"""A sign-in that cannot complete, with a message fit to show the person."""
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
class NotSignedIn(Exception):
|
| 95 |
-
"""The session has no usable tokens left; the person has to sign in again."""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
class ToolError(Exception):
|
| 99 |
-
"""DataBounty refused a tool call. The message is DataBounty's own, written for people."""
|
| 100 |
-
|
| 101 |
-
def __init__(self, message: str, code: str | None = None):
|
| 102 |
-
super().__init__(message)
|
| 103 |
-
self.code = code
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def client_metadata() -> dict[str, Any]:
|
| 107 |
-
"""The Client ID Metadata Document DataBounty fetches to learn who this client is."""
|
| 108 |
-
return {
|
| 109 |
-
"client_id": CLIENT_ID,
|
| 110 |
-
"client_name": "DataBounty on Hugging Face",
|
| 111 |
-
"client_uri": "https://huggingface.co/spaces/databounty-io/DataBounty",
|
| 112 |
-
"redirect_uris": [REDIRECT_URI],
|
| 113 |
-
"grant_types": ["authorization_code", "refresh_token"],
|
| 114 |
-
"response_types": ["code"],
|
| 115 |
-
"token_endpoint_auth_method": "none",
|
| 116 |
-
"scope": SCOPES,
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
_metadata: dict[str, Any] | None = None
|
| 121 |
-
_metadata_at = 0.0
|
| 122 |
-
_metadata_lock = threading.Lock()
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def server_metadata() -> dict[str, Any]:
|
| 126 |
-
"""DataBounty's authorization server metadata (RFC 8414), cached for an hour."""
|
| 127 |
-
global _metadata, _metadata_at
|
| 128 |
-
with _metadata_lock:
|
| 129 |
-
if _metadata and time.monotonic() - _metadata_at < 3600:
|
| 130 |
-
return _metadata
|
| 131 |
-
origin = "{0.scheme}://{0.netloc}".format(urlsplit(DATABOUNTY_MCP_URL))
|
| 132 |
-
res = _client.get(f"{origin}/.well-known/oauth-authorization-server")
|
| 133 |
-
res.raise_for_status()
|
| 134 |
-
doc = res.json()
|
| 135 |
-
for key in ("issuer", "authorization_endpoint", "token_endpoint"):
|
| 136 |
-
if not isinstance(doc.get(key), str):
|
| 137 |
-
raise SignInError("DataBounty's sign-in service is misconfigured. Try again later.")
|
| 138 |
-
_metadata, _metadata_at = doc, time.monotonic()
|
| 139 |
-
return doc
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
@dataclass
|
| 143 |
-
class Session:
|
| 144 |
-
access_token: str
|
| 145 |
-
refresh_token: str | None
|
| 146 |
-
expires_at: float
|
| 147 |
-
scope: str
|
| 148 |
-
last_used: float = field(default_factory=time.time)
|
| 149 |
-
me: dict[str, Any] | None = None
|
| 150 |
-
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
_sessions: dict[str, Session] = {}
|
| 154 |
-
_store_lock = threading.Lock()
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
def _digest(value: str) -> str:
|
| 158 |
-
return hashlib.sha256(value.encode()).hexdigest()
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
def _prune(now: float) -> None:
|
| 162 |
-
for key in [k for k, s in _sessions.items() if now - s.last_used > SESSION_IDLE_SECONDS]:
|
| 163 |
-
_sessions.pop(key, None)
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def _seal(data: dict[str, Any]) -> str:
|
| 167 |
-
# Unpadded base64url plus a hex MAC: only characters a cookie may carry unquoted, so the
|
| 168 |
-
# value never relies on every proxy and client handling a quoted cookie correctly.
|
| 169 |
-
payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).rstrip(b"=").decode()
|
| 170 |
-
mac = hmac.new(_COOKIE_KEY, payload.encode(), hashlib.sha256).hexdigest()
|
| 171 |
-
return f"{payload}.{mac}"
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
def _unseal(value: str | None) -> dict[str, Any] | None:
|
| 175 |
-
if not value or value.count(".") != 1:
|
| 176 |
-
return None
|
| 177 |
-
payload, mac = value.split(".")
|
| 178 |
-
expected = hmac.new(_COOKIE_KEY, payload.encode(), hashlib.sha256).hexdigest()
|
| 179 |
-
if not hmac.compare_digest(mac, expected):
|
| 180 |
-
return None
|
| 181 |
-
try:
|
| 182 |
-
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
| 183 |
-
except ValueError:
|
| 184 |
-
return None
|
| 185 |
-
return data if isinstance(data, dict) else None
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
def valid_nonce_hash(value: str | None) -> bool:
|
| 189 |
-
"""Whether `value` looks like the SHA-256 hex digest a page sends to /oauth/start."""
|
| 190 |
-
return bool(value) and bool(_NONCE_HASH.match(value))
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
def begin_sign_in(nonce_hash: str = "", popup: bool = False) -> tuple[str, str]:
|
| 194 |
-
"""A new sign-in: (the URL to send the browser to, the login cookie that binds it to this browser).
|
| 195 |
-
|
| 196 |
-
`nonce_hash` is the hash of the starting page's secret, kept for claim_sign_in; `popup`
|
| 197 |
-
says the sign-in runs in its own small window, which the callback closes when done."""
|
| 198 |
-
meta = server_metadata()
|
| 199 |
-
state = secrets.token_urlsafe(32)
|
| 200 |
-
verifier = secrets.token_urlsafe(48)
|
| 201 |
-
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
| 202 |
-
cookie = _seal({"s": state, "v": verifier, "t": int(time.time()), "n": nonce_hash, "p": bool(popup)})
|
| 203 |
-
query = urlencode(
|
| 204 |
-
{
|
| 205 |
-
"response_type": "code",
|
| 206 |
-
"client_id": CLIENT_ID,
|
| 207 |
-
"redirect_uri": REDIRECT_URI,
|
| 208 |
-
"scope": SCOPES,
|
| 209 |
-
"state": state,
|
| 210 |
-
"code_challenge": challenge,
|
| 211 |
-
"code_challenge_method": "S256",
|
| 212 |
-
"resource": DATABOUNTY_MCP_URL,
|
| 213 |
-
}
|
| 214 |
-
)
|
| 215 |
-
return f"{meta['authorization_endpoint']}?{query}", cookie
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
def _token_request(data: dict[str, str]) -> dict[str, Any]:
|
| 219 |
-
meta = server_metadata()
|
| 220 |
-
res = _client.post(meta["token_endpoint"], data={**data, "client_id": CLIENT_ID, "resource": DATABOUNTY_MCP_URL})
|
| 221 |
-
try:
|
| 222 |
-
body = res.json()
|
| 223 |
-
except ValueError:
|
| 224 |
-
body = {}
|
| 225 |
-
if res.status_code != 200 or not isinstance(body.get("access_token"), str):
|
| 226 |
-
raise SignInError(str(body.get("error_description") or body.get("error") or f"HTTP {res.status_code}"))
|
| 227 |
-
return body
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
def complete_sign_in(query: dict[str, str], login_cookie: str | None) -> str:
|
| 231 |
-
"""Finish the round trip that /oauth/start began. Returns the new session id."""
|
| 232 |
-
started = _unseal(login_cookie)
|
| 233 |
-
state = query.get("state") or ""
|
| 234 |
-
if not started or not state or not hmac.compare_digest(state, str(started.get("s", ""))):
|
| 235 |
-
raise SignInError("This sign-in was not started in this browser, or it expired. Start again from the Space.")
|
| 236 |
-
if time.time() - float(started.get("t", 0)) > LOGIN_TTL_SECONDS:
|
| 237 |
-
raise SignInError("This sign-in expired. Start again from the Space.")
|
| 238 |
-
if query.get("error"):
|
| 239 |
-
if query["error"] == "access_denied":
|
| 240 |
-
raise SignInError("You didn't approve the Space, so nothing was connected.")
|
| 241 |
-
raise SignInError(query.get("error_description") or "DataBounty couldn't complete the sign-in.")
|
| 242 |
-
meta = server_metadata()
|
| 243 |
-
# RFC 9207: the response names the server that issued it, so a code from a
|
| 244 |
-
# different authorization server can never be redeemed here by mistake.
|
| 245 |
-
if query.get("iss") and query["iss"] != meta["issuer"]:
|
| 246 |
-
raise SignInError("That sign-in came back from an unexpected server, so it was not used.")
|
| 247 |
-
code = query.get("code") or ""
|
| 248 |
-
if not code:
|
| 249 |
-
raise SignInError("DataBounty didn't return a sign-in code. Start again from the Space.")
|
| 250 |
-
try:
|
| 251 |
-
tokens = _token_request(
|
| 252 |
-
{"grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI, "code_verifier": str(started["v"])}
|
| 253 |
-
)
|
| 254 |
-
except SignInError as exc:
|
| 255 |
-
raise SignInError(f"DataBounty didn't accept the sign-in ({exc}). Start again from the Space.") from exc
|
| 256 |
-
session = Session(
|
| 257 |
-
access_token=tokens["access_token"],
|
| 258 |
-
refresh_token=tokens.get("refresh_token"),
|
| 259 |
-
expires_at=time.time() + float(tokens.get("expires_in") or 3600),
|
| 260 |
-
scope=str(tokens.get("scope") or SCOPES),
|
| 261 |
-
)
|
| 262 |
-
session_id = secrets.token_urlsafe(32)
|
| 263 |
-
with _store_lock:
|
| 264 |
-
_prune(time.time())
|
| 265 |
-
if len(_sessions) >= MAX_SESSIONS:
|
| 266 |
-
oldest = min(_sessions, key=lambda key: _sessions[key].last_used)
|
| 267 |
-
_sessions.pop(oldest, None)
|
| 268 |
-
_sessions[_digest(session_id)] = session
|
| 269 |
-
return session_id
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
def login_context(login_cookie: str | None) -> dict[str, Any]:
|
| 273 |
-
"""What /oauth/start recorded about this sign-in: the page's secret hash and whether it runs in a window."""
|
| 274 |
-
started = _unseal(login_cookie) or {}
|
| 275 |
-
return {"n": str(started.get("n") or ""), "p": bool(started.get("p"))}
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
_pending: dict[str, tuple[str, float]] = {}
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
def hold_sign_in(nonce_hash: str, session_id: str) -> None:
|
| 282 |
-
"""Keep a finished sign-in for the page that started it to claim."""
|
| 283 |
-
now = time.time()
|
| 284 |
-
with _store_lock:
|
| 285 |
-
for key in [k for k, (_, at) in _pending.items() if now - at > CLAIM_TTL_SECONDS]:
|
| 286 |
-
_pending.pop(key, None)
|
| 287 |
-
_pending[nonce_hash] = (session_id, now)
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
def sign_in_ready(nonce_hash: str | None) -> bool:
|
| 291 |
-
"""Whether a sign-in started with this hash has finished. Says nothing else, so it is safe to poll."""
|
| 292 |
-
if not valid_nonce_hash(nonce_hash):
|
| 293 |
-
return False
|
| 294 |
-
with _store_lock:
|
| 295 |
-
entry = _pending.get(nonce_hash)
|
| 296 |
-
return bool(entry) and time.time() - entry[1] <= CLAIM_TTL_SECONDS
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
def claim_sign_in(nonce: str | None) -> str | None:
|
| 300 |
-
"""The session a finished sign-in left for the page holding `nonce`, once. Only the page
|
| 301 |
-
that started the sign-in has the nonce; everyone else has at most its hash."""
|
| 302 |
-
if not nonce:
|
| 303 |
-
return None
|
| 304 |
-
with _store_lock:
|
| 305 |
-
entry = _pending.pop(_digest(nonce), None)
|
| 306 |
-
if not entry or time.time() - entry[1] > CLAIM_TTL_SECONDS:
|
| 307 |
-
return None
|
| 308 |
-
return entry[0]
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
def session_for(session_id: str | None) -> Session | None:
|
| 312 |
-
if not session_id:
|
| 313 |
-
return None
|
| 314 |
-
with _store_lock:
|
| 315 |
-
session = _sessions.get(_digest(session_id))
|
| 316 |
-
if session:
|
| 317 |
-
session.last_used = time.time()
|
| 318 |
-
return session
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
def sign_out(session_id: str | None) -> None:
|
| 322 |
-
"""Forget the session here and revoke its tokens at DataBounty (best effort)."""
|
| 323 |
-
if not session_id:
|
| 324 |
-
return
|
| 325 |
-
with _store_lock:
|
| 326 |
-
session = _sessions.pop(_digest(session_id), None)
|
| 327 |
-
if not session:
|
| 328 |
-
return
|
| 329 |
-
endpoint = server_metadata().get("revocation_endpoint")
|
| 330 |
-
if not endpoint:
|
| 331 |
-
return
|
| 332 |
-
for token, hint in ((session.refresh_token, "refresh_token"), (session.access_token, "access_token")):
|
| 333 |
-
if token:
|
| 334 |
-
try:
|
| 335 |
-
_client.post(endpoint, data={"token": token, "token_type_hint": hint, "client_id": CLIENT_ID})
|
| 336 |
-
except httpx.HTTPError:
|
| 337 |
-
pass
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
def _access_token(session: Session) -> str:
|
| 341 |
-
"""A live access token, refreshed first when it is about to expire."""
|
| 342 |
-
with session.lock:
|
| 343 |
-
if session.expires_at - REFRESH_MARGIN_SECONDS > time.time():
|
| 344 |
-
return session.access_token
|
| 345 |
-
if not session.refresh_token:
|
| 346 |
-
raise NotSignedIn()
|
| 347 |
-
try:
|
| 348 |
-
tokens = _token_request({"grant_type": "refresh_token", "refresh_token": session.refresh_token})
|
| 349 |
-
except SignInError as exc:
|
| 350 |
-
raise NotSignedIn() from exc
|
| 351 |
-
session.access_token = tokens["access_token"]
|
| 352 |
-
# Refresh tokens rotate: keep the new one, the old one is now spent.
|
| 353 |
-
session.refresh_token = tokens.get("refresh_token") or session.refresh_token
|
| 354 |
-
session.expires_at = time.time() + float(tokens.get("expires_in") or 3600)
|
| 355 |
-
return session.access_token
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
# --- Calling DataBounty's MCP tools as the signed-in person -------------------------------------
|
| 359 |
-
|
| 360 |
-
MCP_PROTOCOL = "2026-07-28"
|
| 361 |
-
_rpc_ids = itertools.count(1)
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
def _rpc_body(text: str) -> dict[str, Any]:
|
| 365 |
-
"""The JSON-RPC reply, whether DataBounty answered with plain JSON or a one-event SSE stream."""
|
| 366 |
-
trimmed = text.lstrip()
|
| 367 |
-
if trimmed.startswith("{"):
|
| 368 |
-
return json.loads(trimmed)
|
| 369 |
-
for line in text.splitlines():
|
| 370 |
-
line = line.strip()
|
| 371 |
-
if line.startswith("data:"):
|
| 372 |
-
return json.loads(line[len("data:"):].strip())
|
| 373 |
-
raise ToolError("DataBounty sent a reply the Space could not read. Try again.")
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
def _post_tool(token: str, name: str, arguments: dict[str, Any] | None) -> httpx.Response:
|
| 377 |
-
"""One tools/call to DataBounty's MCP server with this bearer token, in the stateless MCP
|
| 378 |
-
protocol: no handshake and no server-side MCP session to expire."""
|
| 379 |
-
payload = {
|
| 380 |
-
"jsonrpc": "2.0",
|
| 381 |
-
"id": next(_rpc_ids),
|
| 382 |
-
"method": "tools/call",
|
| 383 |
-
"params": {
|
| 384 |
-
"name": name,
|
| 385 |
-
"arguments": arguments or {},
|
| 386 |
-
"_meta": {
|
| 387 |
-
"io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL,
|
| 388 |
-
"io.modelcontextprotocol/clientCapabilities": {},
|
| 389 |
-
"io.modelcontextprotocol/clientInfo": {"name": "databounty-hf-space", "version": "1.0.0"},
|
| 390 |
-
},
|
| 391 |
-
},
|
| 392 |
-
}
|
| 393 |
-
try:
|
| 394 |
-
return _client.post(
|
| 395 |
-
DATABOUNTY_MCP_URL,
|
| 396 |
-
json=payload,
|
| 397 |
-
headers={
|
| 398 |
-
"Authorization": f"Bearer {token}",
|
| 399 |
-
"Accept": "application/json, text/event-stream",
|
| 400 |
-
"MCP-Protocol-Version": MCP_PROTOCOL,
|
| 401 |
-
"Mcp-Method": "tools/call",
|
| 402 |
-
"Mcp-Name": name,
|
| 403 |
-
},
|
| 404 |
-
)
|
| 405 |
-
except httpx.HTTPError as exc:
|
| 406 |
-
raise ToolError("DataBounty didn't answer in time. Try again in a moment.") from exc
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
def _tool_result(res: httpx.Response) -> Any:
|
| 410 |
-
"""A tool's parsed result, or ToolError with DataBounty's own message. (401 is the caller's.)"""
|
| 411 |
-
if res.status_code == 429:
|
| 412 |
-
wait = res.headers.get("Retry-After", "a few")
|
| 413 |
-
raise ToolError(f"DataBounty is limiting requests right now. Try again in {wait} seconds.")
|
| 414 |
-
if res.status_code >= 400:
|
| 415 |
-
try:
|
| 416 |
-
detail = res.json()
|
| 417 |
-
message = detail.get("error_description") or detail.get("message") or detail.get("error")
|
| 418 |
-
except ValueError:
|
| 419 |
-
message = None
|
| 420 |
-
raise ToolError(str(message or f"DataBounty refused the request (HTTP {res.status_code})."))
|
| 421 |
-
body = _rpc_body(res.text)
|
| 422 |
-
if body.get("error"):
|
| 423 |
-
raise ToolError(str(body["error"].get("message") or "DataBounty refused the request."))
|
| 424 |
-
result = body.get("result") or {}
|
| 425 |
-
content = result.get("content") or [{}]
|
| 426 |
-
text = content[0].get("text", "") if isinstance(content[0], dict) else ""
|
| 427 |
-
if result.get("isError"):
|
| 428 |
-
raise ToolError(text or "DataBounty refused the request.")
|
| 429 |
-
try:
|
| 430 |
-
return json.loads(text)
|
| 431 |
-
except ValueError:
|
| 432 |
-
return text
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
def call_tool(session: Session, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
| 436 |
-
"""Call one DataBounty tool as this session's person. Returns the tool's parsed result."""
|
| 437 |
-
for attempt in (1, 2):
|
| 438 |
-
res = _post_tool(_access_token(session), name, arguments)
|
| 439 |
-
if res.status_code == 401:
|
| 440 |
-
if attempt == 1:
|
| 441 |
-
# Revoked or expired early (another device signed out): refresh once and retry.
|
| 442 |
-
with session.lock:
|
| 443 |
-
session.expires_at = 0
|
| 444 |
-
continue
|
| 445 |
-
raise NotSignedIn()
|
| 446 |
-
return _tool_result(res)
|
| 447 |
-
raise NotSignedIn()
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
# DataBounty's personal API keys (apps/api services/api-keys.ts KEY_PREFIX). Only strings that
|
| 451 |
-
# look like one are ever forwarded, so a token pasted into the wrong setting goes nowhere.
|
| 452 |
-
API_KEY_PREFIX = "db_live_sk_"
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
def call_tool_with_key(api_key: str, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
| 456 |
-
"""Call one DataBounty tool as the owner of a DataBounty API key (the agent tools in
|
| 457 |
-
agent_tools.py). The key is used for this one request, and never stored or logged; DataBounty
|
| 458 |
-
checks it, its scopes and its owner on every call."""
|
| 459 |
-
if not api_key.startswith(API_KEY_PREFIX):
|
| 460 |
-
raise NotSignedIn()
|
| 461 |
-
res = _post_tool(api_key, name, arguments)
|
| 462 |
-
if res.status_code == 401:
|
| 463 |
-
raise NotSignedIn()
|
| 464 |
-
return _tool_result(res)
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
def upload_file(session: Session, bounty_id: str, path: str, filename: str, content_type: str) -> str:
|
| 468 |
-
"""Upload one file for a file field and wait for DataBounty's scan. Returns the artifact id
|
| 469 |
-
that goes in the item's payload."""
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
"
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
"""Signing in to DataBounty from the Space, and acting as the person who signed in.
|
| 3 |
+
|
| 4 |
+
The Space is an OAuth 2.1 public client of DataBounty's MCP authorization server,
|
| 5 |
+
whose endpoints it reads from the server's own metadata document. It identifies
|
| 6 |
+
itself with a Client ID Metadata Document that it serves (/oauth/client-metadata.json),
|
| 7 |
+
so there is no registration step and no client secret to keep.
|
| 8 |
+
|
| 9 |
+
How a sign-in finishes where it started. Opened at its own address, the Space signs in
|
| 10 |
+
in the same tab. Inside the huggingface.co embed it can't: the embed may open windows
|
| 11 |
+
but not leave the page, and DataBounty's sign-in pages refuse to be framed. There it
|
| 12 |
+
opens a small sign-in window, which closes itself when done.
|
| 13 |
+
|
| 14 |
+
1. The page keeps a random secret in its own sessionStorage and starts the sign-in
|
| 15 |
+
with only the secret's hash (/oauth/start?n=<hash>). /oauth/start refuses a start
|
| 16 |
+
that didn't come from the Space's own page (Sec-Fetch-Site), so nobody can hand
|
| 17 |
+
someone a link that signs *them* in for the sender to collect.
|
| 18 |
+
2. /oauth/start sets a short-lived, signed, httpOnly cookie holding the `state`, the
|
| 19 |
+
PKCE verifier and the hash, then sends the browser to DataBounty. The callback
|
| 20 |
+
accepts only a `state` matching that cookie, so it ties the round trip to the
|
| 21 |
+
browser that started it.
|
| 22 |
+
3. The callback holds the new session under the hash for a few minutes
|
| 23 |
+
(hold_sign_in). The page that started it claims it with the secret itself
|
| 24 |
+
(claim_sign_in): nobody else has that secret, and it is never in a URL.
|
| 25 |
+
4. The page keeps the session handle in its own localStorage and sends it as a
|
| 26 |
+
bearer token (webapi.py), not in a cookie: inside the embed, the Space's cookies
|
| 27 |
+
are third-party and most browsers withhold them.
|
| 28 |
+
|
| 29 |
+
Starting a sign-in keeps nothing on the server, so hitting /oauth/start repeatedly
|
| 30 |
+
cannot crowd out real sign-ins. Tokens never reach the browser: it holds only a
|
| 31 |
+
random session handle. The access and refresh tokens stay in this process's memory,
|
| 32 |
+
so a restart signs everyone out; nothing is written to disk.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import base64
|
| 38 |
+
import hashlib
|
| 39 |
+
import hmac
|
| 40 |
+
import itertools
|
| 41 |
+
import json
|
| 42 |
+
import os
|
| 43 |
+
import re
|
| 44 |
+
import secrets
|
| 45 |
+
import threading
|
| 46 |
+
import time
|
| 47 |
+
from dataclasses import dataclass, field
|
| 48 |
+
from pathlib import Path
|
| 49 |
+
from typing import Any
|
| 50 |
+
from urllib.parse import urlencode, urlsplit
|
| 51 |
+
|
| 52 |
+
import httpx
|
| 53 |
+
|
| 54 |
+
DATABOUNTY_MCP_URL = os.environ.get("DATABOUNTY_MCP_URL", "https://console.databounty.io/mcp").rstrip("/")
|
| 55 |
+
# Only for an upload URL DataBounty returns as a path (local storage); everything else goes through MCP.
|
| 56 |
+
DATABOUNTY_API_URL = os.environ.get("DATABOUNTY_API_URL", "https://api.databounty.io").rstrip("/")
|
| 57 |
+
SPACE_HOST = os.environ.get("SPACE_HOST", "databounty-io-databounty.hf.space")
|
| 58 |
+
SPACE_URL = os.environ.get("SPACE_PUBLIC_URL", f"https://{SPACE_HOST}").rstrip("/")
|
| 59 |
+
|
| 60 |
+
# DataBounty fetches this URL to learn who the Space is. DATABOUNTY_CLIENT_ID overrides it only for a
|
| 61 |
+
# local run, where DataBounty refuses to fetch a localhost document and a registered client id is used.
|
| 62 |
+
CLIENT_ID = os.environ.get("DATABOUNTY_CLIENT_ID") or f"{SPACE_URL}/oauth/client-metadata.json"
|
| 63 |
+
REDIRECT_URI = f"{SPACE_URL}/oauth/callback"
|
| 64 |
+
# Only what the Space's screens need: read pools and your own items, submit and
|
| 65 |
+
# revise them, upload files for file fields, review other people's items, see the
|
| 66 |
+
# items in a pool you requested, and set up a handle on first sign-in. Each is a
|
| 67 |
+
# box the person can untick.
|
| 68 |
+
SCOPES = "read contribute validate artifact sponsor account"
|
| 69 |
+
|
| 70 |
+
LOGIN_COOKIE = "dbspace_login"
|
| 71 |
+
LOGIN_TTL_SECONDS = 10 * 60
|
| 72 |
+
SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60
|
| 73 |
+
MAX_SESSIONS = 20_000
|
| 74 |
+
REFRESH_MARGIN_SECONDS = 60
|
| 75 |
+
# How long a finished sign-in waits for the page that started it to claim it.
|
| 76 |
+
CLAIM_TTL_SECONDS = 5 * 60
|
| 77 |
+
_NONCE_HASH = re.compile(r"^[0-9a-f]{64}$")
|
| 78 |
+
TIMEOUT = httpx.Timeout(20.0, connect=10.0)
|
| 79 |
+
USER_AGENT = "databounty-space/1.0 (+https://huggingface.co/spaces/databounty-io/DataBounty)"
|
| 80 |
+
|
| 81 |
+
# Secure cookies everywhere except a local run over plain http.
|
| 82 |
+
COOKIE_SECURE = SPACE_URL.startswith("https://")
|
| 83 |
+
# Signs the login cookie. A Space secret keeps sign-ins in flight valid across a
|
| 84 |
+
# restart; without one, a restart only means starting that sign-in again.
|
| 85 |
+
_COOKIE_KEY = (os.environ.get("SPACE_COOKIE_SECRET") or secrets.token_urlsafe(32)).encode()
|
| 86 |
+
|
| 87 |
+
_client = httpx.Client(timeout=TIMEOUT, headers={"User-Agent": USER_AGENT})
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class SignInError(Exception):
|
| 91 |
+
"""A sign-in that cannot complete, with a message fit to show the person."""
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class NotSignedIn(Exception):
|
| 95 |
+
"""The session has no usable tokens left; the person has to sign in again."""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class ToolError(Exception):
|
| 99 |
+
"""DataBounty refused a tool call. The message is DataBounty's own, written for people."""
|
| 100 |
+
|
| 101 |
+
def __init__(self, message: str, code: str | None = None):
|
| 102 |
+
super().__init__(message)
|
| 103 |
+
self.code = code
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def client_metadata() -> dict[str, Any]:
|
| 107 |
+
"""The Client ID Metadata Document DataBounty fetches to learn who this client is."""
|
| 108 |
+
return {
|
| 109 |
+
"client_id": CLIENT_ID,
|
| 110 |
+
"client_name": "DataBounty on Hugging Face",
|
| 111 |
+
"client_uri": "https://huggingface.co/spaces/databounty-io/DataBounty",
|
| 112 |
+
"redirect_uris": [REDIRECT_URI],
|
| 113 |
+
"grant_types": ["authorization_code", "refresh_token"],
|
| 114 |
+
"response_types": ["code"],
|
| 115 |
+
"token_endpoint_auth_method": "none",
|
| 116 |
+
"scope": SCOPES,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
_metadata: dict[str, Any] | None = None
|
| 121 |
+
_metadata_at = 0.0
|
| 122 |
+
_metadata_lock = threading.Lock()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def server_metadata() -> dict[str, Any]:
|
| 126 |
+
"""DataBounty's authorization server metadata (RFC 8414), cached for an hour."""
|
| 127 |
+
global _metadata, _metadata_at
|
| 128 |
+
with _metadata_lock:
|
| 129 |
+
if _metadata and time.monotonic() - _metadata_at < 3600:
|
| 130 |
+
return _metadata
|
| 131 |
+
origin = "{0.scheme}://{0.netloc}".format(urlsplit(DATABOUNTY_MCP_URL))
|
| 132 |
+
res = _client.get(f"{origin}/.well-known/oauth-authorization-server")
|
| 133 |
+
res.raise_for_status()
|
| 134 |
+
doc = res.json()
|
| 135 |
+
for key in ("issuer", "authorization_endpoint", "token_endpoint"):
|
| 136 |
+
if not isinstance(doc.get(key), str):
|
| 137 |
+
raise SignInError("DataBounty's sign-in service is misconfigured. Try again later.")
|
| 138 |
+
_metadata, _metadata_at = doc, time.monotonic()
|
| 139 |
+
return doc
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@dataclass
|
| 143 |
+
class Session:
|
| 144 |
+
access_token: str
|
| 145 |
+
refresh_token: str | None
|
| 146 |
+
expires_at: float
|
| 147 |
+
scope: str
|
| 148 |
+
last_used: float = field(default_factory=time.time)
|
| 149 |
+
me: dict[str, Any] | None = None
|
| 150 |
+
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
_sessions: dict[str, Session] = {}
|
| 154 |
+
_store_lock = threading.Lock()
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _digest(value: str) -> str:
|
| 158 |
+
return hashlib.sha256(value.encode()).hexdigest()
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _prune(now: float) -> None:
|
| 162 |
+
for key in [k for k, s in _sessions.items() if now - s.last_used > SESSION_IDLE_SECONDS]:
|
| 163 |
+
_sessions.pop(key, None)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _seal(data: dict[str, Any]) -> str:
|
| 167 |
+
# Unpadded base64url plus a hex MAC: only characters a cookie may carry unquoted, so the
|
| 168 |
+
# value never relies on every proxy and client handling a quoted cookie correctly.
|
| 169 |
+
payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).rstrip(b"=").decode()
|
| 170 |
+
mac = hmac.new(_COOKIE_KEY, payload.encode(), hashlib.sha256).hexdigest()
|
| 171 |
+
return f"{payload}.{mac}"
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _unseal(value: str | None) -> dict[str, Any] | None:
|
| 175 |
+
if not value or value.count(".") != 1:
|
| 176 |
+
return None
|
| 177 |
+
payload, mac = value.split(".")
|
| 178 |
+
expected = hmac.new(_COOKIE_KEY, payload.encode(), hashlib.sha256).hexdigest()
|
| 179 |
+
if not hmac.compare_digest(mac, expected):
|
| 180 |
+
return None
|
| 181 |
+
try:
|
| 182 |
+
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
| 183 |
+
except ValueError:
|
| 184 |
+
return None
|
| 185 |
+
return data if isinstance(data, dict) else None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def valid_nonce_hash(value: str | None) -> bool:
|
| 189 |
+
"""Whether `value` looks like the SHA-256 hex digest a page sends to /oauth/start."""
|
| 190 |
+
return bool(value) and bool(_NONCE_HASH.match(value))
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def begin_sign_in(nonce_hash: str = "", popup: bool = False) -> tuple[str, str]:
|
| 194 |
+
"""A new sign-in: (the URL to send the browser to, the login cookie that binds it to this browser).
|
| 195 |
+
|
| 196 |
+
`nonce_hash` is the hash of the starting page's secret, kept for claim_sign_in; `popup`
|
| 197 |
+
says the sign-in runs in its own small window, which the callback closes when done."""
|
| 198 |
+
meta = server_metadata()
|
| 199 |
+
state = secrets.token_urlsafe(32)
|
| 200 |
+
verifier = secrets.token_urlsafe(48)
|
| 201 |
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
| 202 |
+
cookie = _seal({"s": state, "v": verifier, "t": int(time.time()), "n": nonce_hash, "p": bool(popup)})
|
| 203 |
+
query = urlencode(
|
| 204 |
+
{
|
| 205 |
+
"response_type": "code",
|
| 206 |
+
"client_id": CLIENT_ID,
|
| 207 |
+
"redirect_uri": REDIRECT_URI,
|
| 208 |
+
"scope": SCOPES,
|
| 209 |
+
"state": state,
|
| 210 |
+
"code_challenge": challenge,
|
| 211 |
+
"code_challenge_method": "S256",
|
| 212 |
+
"resource": DATABOUNTY_MCP_URL,
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
return f"{meta['authorization_endpoint']}?{query}", cookie
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _token_request(data: dict[str, str]) -> dict[str, Any]:
|
| 219 |
+
meta = server_metadata()
|
| 220 |
+
res = _client.post(meta["token_endpoint"], data={**data, "client_id": CLIENT_ID, "resource": DATABOUNTY_MCP_URL})
|
| 221 |
+
try:
|
| 222 |
+
body = res.json()
|
| 223 |
+
except ValueError:
|
| 224 |
+
body = {}
|
| 225 |
+
if res.status_code != 200 or not isinstance(body.get("access_token"), str):
|
| 226 |
+
raise SignInError(str(body.get("error_description") or body.get("error") or f"HTTP {res.status_code}"))
|
| 227 |
+
return body
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def complete_sign_in(query: dict[str, str], login_cookie: str | None) -> str:
|
| 231 |
+
"""Finish the round trip that /oauth/start began. Returns the new session id."""
|
| 232 |
+
started = _unseal(login_cookie)
|
| 233 |
+
state = query.get("state") or ""
|
| 234 |
+
if not started or not state or not hmac.compare_digest(state, str(started.get("s", ""))):
|
| 235 |
+
raise SignInError("This sign-in was not started in this browser, or it expired. Start again from the Space.")
|
| 236 |
+
if time.time() - float(started.get("t", 0)) > LOGIN_TTL_SECONDS:
|
| 237 |
+
raise SignInError("This sign-in expired. Start again from the Space.")
|
| 238 |
+
if query.get("error"):
|
| 239 |
+
if query["error"] == "access_denied":
|
| 240 |
+
raise SignInError("You didn't approve the Space, so nothing was connected.")
|
| 241 |
+
raise SignInError(query.get("error_description") or "DataBounty couldn't complete the sign-in.")
|
| 242 |
+
meta = server_metadata()
|
| 243 |
+
# RFC 9207: the response names the server that issued it, so a code from a
|
| 244 |
+
# different authorization server can never be redeemed here by mistake.
|
| 245 |
+
if query.get("iss") and query["iss"] != meta["issuer"]:
|
| 246 |
+
raise SignInError("That sign-in came back from an unexpected server, so it was not used.")
|
| 247 |
+
code = query.get("code") or ""
|
| 248 |
+
if not code:
|
| 249 |
+
raise SignInError("DataBounty didn't return a sign-in code. Start again from the Space.")
|
| 250 |
+
try:
|
| 251 |
+
tokens = _token_request(
|
| 252 |
+
{"grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI, "code_verifier": str(started["v"])}
|
| 253 |
+
)
|
| 254 |
+
except SignInError as exc:
|
| 255 |
+
raise SignInError(f"DataBounty didn't accept the sign-in ({exc}). Start again from the Space.") from exc
|
| 256 |
+
session = Session(
|
| 257 |
+
access_token=tokens["access_token"],
|
| 258 |
+
refresh_token=tokens.get("refresh_token"),
|
| 259 |
+
expires_at=time.time() + float(tokens.get("expires_in") or 3600),
|
| 260 |
+
scope=str(tokens.get("scope") or SCOPES),
|
| 261 |
+
)
|
| 262 |
+
session_id = secrets.token_urlsafe(32)
|
| 263 |
+
with _store_lock:
|
| 264 |
+
_prune(time.time())
|
| 265 |
+
if len(_sessions) >= MAX_SESSIONS:
|
| 266 |
+
oldest = min(_sessions, key=lambda key: _sessions[key].last_used)
|
| 267 |
+
_sessions.pop(oldest, None)
|
| 268 |
+
_sessions[_digest(session_id)] = session
|
| 269 |
+
return session_id
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def login_context(login_cookie: str | None) -> dict[str, Any]:
|
| 273 |
+
"""What /oauth/start recorded about this sign-in: the page's secret hash and whether it runs in a window."""
|
| 274 |
+
started = _unseal(login_cookie) or {}
|
| 275 |
+
return {"n": str(started.get("n") or ""), "p": bool(started.get("p"))}
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
_pending: dict[str, tuple[str, float]] = {}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def hold_sign_in(nonce_hash: str, session_id: str) -> None:
|
| 282 |
+
"""Keep a finished sign-in for the page that started it to claim."""
|
| 283 |
+
now = time.time()
|
| 284 |
+
with _store_lock:
|
| 285 |
+
for key in [k for k, (_, at) in _pending.items() if now - at > CLAIM_TTL_SECONDS]:
|
| 286 |
+
_pending.pop(key, None)
|
| 287 |
+
_pending[nonce_hash] = (session_id, now)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def sign_in_ready(nonce_hash: str | None) -> bool:
|
| 291 |
+
"""Whether a sign-in started with this hash has finished. Says nothing else, so it is safe to poll."""
|
| 292 |
+
if not valid_nonce_hash(nonce_hash):
|
| 293 |
+
return False
|
| 294 |
+
with _store_lock:
|
| 295 |
+
entry = _pending.get(nonce_hash)
|
| 296 |
+
return bool(entry) and time.time() - entry[1] <= CLAIM_TTL_SECONDS
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def claim_sign_in(nonce: str | None) -> str | None:
|
| 300 |
+
"""The session a finished sign-in left for the page holding `nonce`, once. Only the page
|
| 301 |
+
that started the sign-in has the nonce; everyone else has at most its hash."""
|
| 302 |
+
if not nonce:
|
| 303 |
+
return None
|
| 304 |
+
with _store_lock:
|
| 305 |
+
entry = _pending.pop(_digest(nonce), None)
|
| 306 |
+
if not entry or time.time() - entry[1] > CLAIM_TTL_SECONDS:
|
| 307 |
+
return None
|
| 308 |
+
return entry[0]
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def session_for(session_id: str | None) -> Session | None:
|
| 312 |
+
if not session_id:
|
| 313 |
+
return None
|
| 314 |
+
with _store_lock:
|
| 315 |
+
session = _sessions.get(_digest(session_id))
|
| 316 |
+
if session:
|
| 317 |
+
session.last_used = time.time()
|
| 318 |
+
return session
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def sign_out(session_id: str | None) -> None:
|
| 322 |
+
"""Forget the session here and revoke its tokens at DataBounty (best effort)."""
|
| 323 |
+
if not session_id:
|
| 324 |
+
return
|
| 325 |
+
with _store_lock:
|
| 326 |
+
session = _sessions.pop(_digest(session_id), None)
|
| 327 |
+
if not session:
|
| 328 |
+
return
|
| 329 |
+
endpoint = server_metadata().get("revocation_endpoint")
|
| 330 |
+
if not endpoint:
|
| 331 |
+
return
|
| 332 |
+
for token, hint in ((session.refresh_token, "refresh_token"), (session.access_token, "access_token")):
|
| 333 |
+
if token:
|
| 334 |
+
try:
|
| 335 |
+
_client.post(endpoint, data={"token": token, "token_type_hint": hint, "client_id": CLIENT_ID})
|
| 336 |
+
except httpx.HTTPError:
|
| 337 |
+
pass
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def _access_token(session: Session) -> str:
|
| 341 |
+
"""A live access token, refreshed first when it is about to expire."""
|
| 342 |
+
with session.lock:
|
| 343 |
+
if session.expires_at - REFRESH_MARGIN_SECONDS > time.time():
|
| 344 |
+
return session.access_token
|
| 345 |
+
if not session.refresh_token:
|
| 346 |
+
raise NotSignedIn()
|
| 347 |
+
try:
|
| 348 |
+
tokens = _token_request({"grant_type": "refresh_token", "refresh_token": session.refresh_token})
|
| 349 |
+
except SignInError as exc:
|
| 350 |
+
raise NotSignedIn() from exc
|
| 351 |
+
session.access_token = tokens["access_token"]
|
| 352 |
+
# Refresh tokens rotate: keep the new one, the old one is now spent.
|
| 353 |
+
session.refresh_token = tokens.get("refresh_token") or session.refresh_token
|
| 354 |
+
session.expires_at = time.time() + float(tokens.get("expires_in") or 3600)
|
| 355 |
+
return session.access_token
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
# --- Calling DataBounty's MCP tools as the signed-in person -------------------------------------
|
| 359 |
+
|
| 360 |
+
MCP_PROTOCOL = "2026-07-28"
|
| 361 |
+
_rpc_ids = itertools.count(1)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _rpc_body(text: str) -> dict[str, Any]:
|
| 365 |
+
"""The JSON-RPC reply, whether DataBounty answered with plain JSON or a one-event SSE stream."""
|
| 366 |
+
trimmed = text.lstrip()
|
| 367 |
+
if trimmed.startswith("{"):
|
| 368 |
+
return json.loads(trimmed)
|
| 369 |
+
for line in text.splitlines():
|
| 370 |
+
line = line.strip()
|
| 371 |
+
if line.startswith("data:"):
|
| 372 |
+
return json.loads(line[len("data:"):].strip())
|
| 373 |
+
raise ToolError("DataBounty sent a reply the Space could not read. Try again.")
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _post_tool(token: str, name: str, arguments: dict[str, Any] | None) -> httpx.Response:
|
| 377 |
+
"""One tools/call to DataBounty's MCP server with this bearer token, in the stateless MCP
|
| 378 |
+
protocol: no handshake and no server-side MCP session to expire."""
|
| 379 |
+
payload = {
|
| 380 |
+
"jsonrpc": "2.0",
|
| 381 |
+
"id": next(_rpc_ids),
|
| 382 |
+
"method": "tools/call",
|
| 383 |
+
"params": {
|
| 384 |
+
"name": name,
|
| 385 |
+
"arguments": arguments or {},
|
| 386 |
+
"_meta": {
|
| 387 |
+
"io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL,
|
| 388 |
+
"io.modelcontextprotocol/clientCapabilities": {},
|
| 389 |
+
"io.modelcontextprotocol/clientInfo": {"name": "databounty-hf-space", "version": "1.0.0"},
|
| 390 |
+
},
|
| 391 |
+
},
|
| 392 |
+
}
|
| 393 |
+
try:
|
| 394 |
+
return _client.post(
|
| 395 |
+
DATABOUNTY_MCP_URL,
|
| 396 |
+
json=payload,
|
| 397 |
+
headers={
|
| 398 |
+
"Authorization": f"Bearer {token}",
|
| 399 |
+
"Accept": "application/json, text/event-stream",
|
| 400 |
+
"MCP-Protocol-Version": MCP_PROTOCOL,
|
| 401 |
+
"Mcp-Method": "tools/call",
|
| 402 |
+
"Mcp-Name": name,
|
| 403 |
+
},
|
| 404 |
+
)
|
| 405 |
+
except httpx.HTTPError as exc:
|
| 406 |
+
raise ToolError("DataBounty didn't answer in time. Try again in a moment.") from exc
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _tool_result(res: httpx.Response) -> Any:
|
| 410 |
+
"""A tool's parsed result, or ToolError with DataBounty's own message. (401 is the caller's.)"""
|
| 411 |
+
if res.status_code == 429:
|
| 412 |
+
wait = res.headers.get("Retry-After", "a few")
|
| 413 |
+
raise ToolError(f"DataBounty is limiting requests right now. Try again in {wait} seconds.")
|
| 414 |
+
if res.status_code >= 400:
|
| 415 |
+
try:
|
| 416 |
+
detail = res.json()
|
| 417 |
+
message = detail.get("error_description") or detail.get("message") or detail.get("error")
|
| 418 |
+
except ValueError:
|
| 419 |
+
message = None
|
| 420 |
+
raise ToolError(str(message or f"DataBounty refused the request (HTTP {res.status_code})."))
|
| 421 |
+
body = _rpc_body(res.text)
|
| 422 |
+
if body.get("error"):
|
| 423 |
+
raise ToolError(str(body["error"].get("message") or "DataBounty refused the request."))
|
| 424 |
+
result = body.get("result") or {}
|
| 425 |
+
content = result.get("content") or [{}]
|
| 426 |
+
text = content[0].get("text", "") if isinstance(content[0], dict) else ""
|
| 427 |
+
if result.get("isError"):
|
| 428 |
+
raise ToolError(text or "DataBounty refused the request.")
|
| 429 |
+
try:
|
| 430 |
+
return json.loads(text)
|
| 431 |
+
except ValueError:
|
| 432 |
+
return text
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def call_tool(session: Session, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
| 436 |
+
"""Call one DataBounty tool as this session's person. Returns the tool's parsed result."""
|
| 437 |
+
for attempt in (1, 2):
|
| 438 |
+
res = _post_tool(_access_token(session), name, arguments)
|
| 439 |
+
if res.status_code == 401:
|
| 440 |
+
if attempt == 1:
|
| 441 |
+
# Revoked or expired early (another device signed out): refresh once and retry.
|
| 442 |
+
with session.lock:
|
| 443 |
+
session.expires_at = 0
|
| 444 |
+
continue
|
| 445 |
+
raise NotSignedIn()
|
| 446 |
+
return _tool_result(res)
|
| 447 |
+
raise NotSignedIn()
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
# DataBounty's personal API keys (apps/api services/api-keys.ts KEY_PREFIX). Only strings that
|
| 451 |
+
# look like one are ever forwarded, so a token pasted into the wrong setting goes nowhere.
|
| 452 |
+
API_KEY_PREFIX = "db_live_sk_"
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def call_tool_with_key(api_key: str, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
| 456 |
+
"""Call one DataBounty tool as the owner of a DataBounty API key (the agent tools in
|
| 457 |
+
agent_tools.py). The key is used for this one request, and never stored or logged; DataBounty
|
| 458 |
+
checks it, its scopes and its owner on every call."""
|
| 459 |
+
if not api_key.startswith(API_KEY_PREFIX):
|
| 460 |
+
raise NotSignedIn()
|
| 461 |
+
res = _post_tool(api_key, name, arguments)
|
| 462 |
+
if res.status_code == 401:
|
| 463 |
+
raise NotSignedIn()
|
| 464 |
+
return _tool_result(res)
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def upload_file(session: Session, bounty_id: str, path: str, filename: str, content_type: str) -> str:
|
| 468 |
+
"""Upload one file for a file field and wait for DataBounty's scan. Returns the artifact id
|
| 469 |
+
that goes in the item's payload."""
|
| 470 |
+
file_id = _upload(session, {"kind": "submission_attachment", "bountyId": bounty_id}, path, filename, content_type)
|
| 471 |
+
deadline = time.time() + 120
|
| 472 |
+
while True:
|
| 473 |
+
status = call_tool(session, "get_file_status", {"fileId": file_id}) or {}
|
| 474 |
+
state = status.get("status")
|
| 475 |
+
if state == "ready":
|
| 476 |
+
return file_id
|
| 477 |
+
if state in {"quarantined", "deleted"}:
|
| 478 |
+
raise ToolError(f"{filename} did not pass DataBounty's file checks, so it can't be attached.")
|
| 479 |
+
progress = status.get("scanProgress") or {}
|
| 480 |
+
if progress.get("blocked"):
|
| 481 |
+
raise ToolError(f"{filename} is stuck in DataBounty's file scan and needs an operator. Try again later.")
|
| 482 |
+
# Pace by DataBounty's own estimate instead of polling blindly.
|
| 483 |
+
wait = (status.get("ingest") or {}).get("recheckAfterSeconds")
|
| 484 |
+
wait = min(max(float(wait), 2.0), 15.0) if isinstance(wait, (int, float)) else 3.0
|
| 485 |
+
if time.time() + wait > deadline:
|
| 486 |
+
raise ToolError(f"{filename} is still being checked. Submit again in a minute.")
|
| 487 |
+
time.sleep(wait)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def upload_sample(session: Session, request_id: str, path: str, filename: str, content_type: str) -> str:
|
| 491 |
+
"""Attach one reference sample (an example item) to the person's own dataset request. DataBounty
|
| 492 |
+
scans it in the background and an admin reviews it with the request, so this doesn't wait for
|
| 493 |
+
the scan. Returns its file id."""
|
| 494 |
+
return _upload(session, {"kind": "sponsor_reference", "datasetRequestId": request_id}, path, filename, content_type)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def _upload(session: Session, target: dict[str, Any], path: str, filename: str, content_type: str) -> str:
|
| 498 |
+
"""Upload one file to DataBounty for `target` (its kind and what it belongs to) and complete it.
|
| 499 |
+
Returns the file id; DataBounty starts its scan."""
|
| 500 |
+
data = Path(path).read_bytes()
|
| 501 |
+
slot = call_tool(
|
| 502 |
+
session,
|
| 503 |
+
"prepare_file_upload",
|
| 504 |
+
{
|
| 505 |
+
"filename": filename,
|
| 506 |
+
"contentType": content_type,
|
| 507 |
+
"sizeBytes": len(data),
|
| 508 |
+
"checksumSha256": hashlib.sha256(data).hexdigest(),
|
| 509 |
+
**target,
|
| 510 |
+
},
|
| 511 |
+
)
|
| 512 |
+
upload = slot.get("upload") or {}
|
| 513 |
+
# A reused slot is the same pending file with a fresh URL: it still needs the bytes.
|
| 514 |
+
url = str(upload.get("url") or "")
|
| 515 |
+
if url.startswith("/"):
|
| 516 |
+
url = f"{DATABOUNTY_API_URL}{url}"
|
| 517 |
+
try:
|
| 518 |
+
# A signed form POST: every policy field first, then the file as `file`.
|
| 519 |
+
res = _client.post(
|
| 520 |
+
url,
|
| 521 |
+
data=upload.get("fields") or {},
|
| 522 |
+
files={"file": (filename, data, content_type)},
|
| 523 |
+
timeout=httpx.Timeout(120.0, connect=10.0),
|
| 524 |
+
)
|
| 525 |
+
except httpx.HTTPError as exc:
|
| 526 |
+
raise ToolError(f"{filename} could not be uploaded. Try again.") from exc
|
| 527 |
+
if res.status_code >= 300:
|
| 528 |
+
raise ToolError(f"{filename} was refused by storage (HTTP {res.status_code}).")
|
| 529 |
+
file_id = str(slot.get("fileId") or slot.get("artifactId") or "")
|
| 530 |
+
call_tool(session, "complete_file_upload", {"fileId": file_id})
|
| 531 |
+
return file_id
|
databounty.py
CHANGED
|
@@ -1,426 +1,442 @@
|
|
| 1 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
-
"""Read-only client for DataBounty's public API and the Hugging Face Hub.
|
| 3 |
-
|
| 4 |
-
Everything here reads public data: the same unauthenticated endpoints that
|
| 5 |
-
databounty.io renders from. Each response is kept in a small in-process cache
|
| 6 |
-
for CACHE_TTL_SECONDS, so a burst of visitors, or an agent calling the MCP tools
|
| 7 |
-
in a loop, never turns into a burst of upstream requests. Errors are never
|
| 8 |
-
cached, so a recovered upstream is picked up on the next call.
|
| 9 |
-
|
| 10 |
-
Profiles are read only from /v1/profiles/handle/:handle, which answers 404 for a
|
| 11 |
-
private profile and leaves out every field the member chose to hide. Nothing
|
| 12 |
-
here calls /v1/community/members/:handle, which applies neither rule.
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
from __future__ import annotations
|
| 16 |
-
|
| 17 |
-
import os
|
| 18 |
-
import re
|
| 19 |
-
import threading
|
| 20 |
-
import time
|
| 21 |
-
from collections import OrderedDict
|
| 22 |
-
from collections.abc import Callable
|
| 23 |
-
from typing import Any
|
| 24 |
-
from urllib.parse import quote
|
| 25 |
-
|
| 26 |
-
import httpx
|
| 27 |
-
from huggingface_hub import HfApi
|
| 28 |
-
|
| 29 |
-
API_URL = os.environ.get("DATABOUNTY_API_URL", "https://api.databounty.io").rstrip("/")
|
| 30 |
-
SITE_URL = os.environ.get("DATABOUNTY_SITE_URL", "https://databounty.io").rstrip("/")
|
| 31 |
-
APP_URL = os.environ.get("DATABOUNTY_APP_URL", "https://console.databounty.io").rstrip("/")
|
| 32 |
-
HF_ORG = os.environ.get("DATABOUNTY_HF_ORG", "databounty-io")
|
| 33 |
-
CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "120"))
|
| 34 |
-
|
| 35 |
-
# DataBounty's own MCP server. The dashboard origin proxies /mcp to the API, and
|
| 36 |
-
# the API binds every OAuth credential to that origin, so this is the URL
|
| 37 |
-
# clients must use (not API_URL).
|
| 38 |
-
MCP_URL = f"{APP_URL}/mcp"
|
| 39 |
-
|
| 40 |
-
DOMAINS = ("coding", "legal", "healthcare", "finance", "science")
|
| 41 |
-
DIFFICULTIES = ("beginner", "intermediate", "advanced")
|
| 42 |
-
TIERS = ("dharma", "bodhi", "moksha", "nirvana")
|
| 43 |
-
|
| 44 |
-
# Same rule the dashboard applies when a member claims a handle.
|
| 45 |
-
_HANDLE = re.compile(r"^[a-z0-9-]{3,20}$")
|
| 46 |
-
_POOL_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
| 47 |
-
_PROFILE_URL = re.compile(r"^(?:https?://)?(?:www\.)?databounty\.io/+@?([^/?#]+)/*$", re.IGNORECASE)
|
| 48 |
-
_BADGE_STEP = re.compile(r"^(?P<ladder>.+)_(?P<value>\d+)$")
|
| 49 |
-
|
| 50 |
-
_PAGE_SIZE = {"/v1/community/catalog": 100, "/v1/community/pools": 50}
|
| 51 |
-
_MAX_PAGES = 5
|
| 52 |
-
_CACHE_MAX_ENTRIES = 256
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
class UpstreamError(RuntimeError):
|
| 56 |
-
"""DataBounty's API could not be reached, or answered with an error."""
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
_http = httpx.Client(
|
| 60 |
-
base_url=API_URL,
|
| 61 |
-
timeout=httpx.Timeout(15.0, connect=5.0),
|
| 62 |
-
headers={
|
| 63 |
-
"Accept": "application/json",
|
| 64 |
-
"User-Agent": "databounty-space/1.0 (+https://huggingface.co/spaces/databounty-io/DataBounty)",
|
| 65 |
-
},
|
| 66 |
-
)
|
| 67 |
-
_hub = HfApi()
|
| 68 |
-
_cache: OrderedDict[tuple, tuple[float, Any]] = OrderedDict()
|
| 69 |
-
_lock = threading.Lock()
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def _cached(key: tuple, load: Callable[[], Any]) -> Any:
|
| 73 |
-
now = time.monotonic()
|
| 74 |
-
with _lock:
|
| 75 |
-
hit = _cache.get(key)
|
| 76 |
-
if hit is not None and now - hit[0] < CACHE_TTL_SECONDS:
|
| 77 |
-
_cache.move_to_end(key)
|
| 78 |
-
return hit[1]
|
| 79 |
-
value = load() # raises on failure, so errors never reach the cache
|
| 80 |
-
with _lock:
|
| 81 |
-
_cache[key] = (now, value)
|
| 82 |
-
_cache.move_to_end(key)
|
| 83 |
-
# Keys include free-text search terms, so bound the cache rather than
|
| 84 |
-
# let a stream of unique queries grow it forever.
|
| 85 |
-
while len(_cache) > _CACHE_MAX_ENTRIES:
|
| 86 |
-
_cache.popitem(last=False)
|
| 87 |
-
return value
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _get(path: str, params: dict[str, Any] | None = None, *, not_found_ok: bool = False) -> Any:
|
| 91 |
-
query = {k: v for k, v in (params or {}).items() if v not in (None, "")}
|
| 92 |
-
|
| 93 |
-
def load() -> Any:
|
| 94 |
-
try:
|
| 95 |
-
resp = _http.get(path, params=query)
|
| 96 |
-
except httpx.HTTPError as exc:
|
| 97 |
-
raise UpstreamError(f"DataBounty's API could not be reached ({type(exc).__name__}).") from exc
|
| 98 |
-
if resp.status_code == 404 and not_found_ok:
|
| 99 |
-
return None
|
| 100 |
-
if resp.status_code >= 400:
|
| 101 |
-
raise UpstreamError(f"DataBounty's API answered HTTP {resp.status_code} for {path}.")
|
| 102 |
-
try:
|
| 103 |
-
return resp.json()
|
| 104 |
-
except ValueError as exc:
|
| 105 |
-
raise UpstreamError(f"DataBounty's API returned an unreadable response for {path}.") from exc
|
| 106 |
-
|
| 107 |
-
return _cached(("api", path, tuple(sorted(query.items()))), load)
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
def _all_pages(path: str, key: str) -> list[dict[str, Any]]:
|
| 111 |
-
rows: list[dict[str, Any]] = []
|
| 112 |
-
cursor = None
|
| 113 |
-
for _ in range(_MAX_PAGES):
|
| 114 |
-
page = _get(path, {"limit": _PAGE_SIZE[path], "cursor": cursor}) or {}
|
| 115 |
-
rows.extend(page.get(key) or [])
|
| 116 |
-
cursor = page.get("nextCursor")
|
| 117 |
-
if not cursor:
|
| 118 |
-
break
|
| 119 |
-
return rows
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def _int(value: Any) -> int:
|
| 123 |
-
# Counts arrive as numbers or as numeric strings (targetItems is a BigInt
|
| 124 |
-
# serialised as text), so accept both.
|
| 125 |
-
try:
|
| 126 |
-
return int(value)
|
| 127 |
-
except (TypeError, ValueError):
|
| 128 |
-
return 0
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
def _pct(part: int, whole: int) -> float | None:
|
| 132 |
-
return round(100 * part / whole, 1) if whole else None
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def _date(value: Any) -> str | None:
|
| 136 |
-
return value[:10] if isinstance(value, str) and len(value) >= 10 else None
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
# --- Programme totals and karma tiers -----------------------------------------
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def stats() -> dict[str, Any]:
|
| 143 |
-
"""Programme totals, the karma tier ladder and the top of the leaderboard."""
|
| 144 |
-
return _get("/v1/community/stats") or {}
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
def tiers() -> list[dict[str, Any]]:
|
| 148 |
-
"""The live karma tier ladder, lowest threshold first."""
|
| 149 |
-
return sorted(stats().get("tiers") or [], key=lambda t: _int(t.get("minKarma")))
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
def tier_for(karma: int, ladder: list[dict[str, Any]]) -> dict[str, Any] | None:
|
| 153 |
-
current = None
|
| 154 |
-
for tier in ladder:
|
| 155 |
-
if karma >= _int(tier.get("minKarma")):
|
| 156 |
-
current = tier
|
| 157 |
-
return current
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
# --- Pools and datasets ---------------------------------------------------------
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
def _programme(row: dict[str, Any]) -> dict[str, Any]:
|
| 164 |
-
dataset_type = row.get("datasetType") or {}
|
| 165 |
-
pricing = row.get("karmaPricing") or {}
|
| 166 |
-
target = _int(row.get("targetItems"))
|
| 167 |
-
accepted = _int(row.get("acceptedItems"))
|
| 168 |
-
cleared = _int(row.get("clearedItems"))
|
| 169 |
-
pool_id = str(row.get("id") or "")
|
| 170 |
-
return {
|
| 171 |
-
"id": pool_id,
|
| 172 |
-
"title": row.get("title") or "",
|
| 173 |
-
"status": row.get("status") or "",
|
| 174 |
-
"dataset_type": dataset_type.get("name") or "",
|
| 175 |
-
"domain": dataset_type.get("domain") or "",
|
| 176 |
-
"category": row.get("datasetCategory") or "",
|
| 177 |
-
"language": row.get("language") or "",
|
| 178 |
-
"difficulty": row.get("difficulty") or "",
|
| 179 |
-
"trust_tier": dataset_type.get("trustTier") or "",
|
| 180 |
-
"target_items": target,
|
| 181 |
-
"accepted_items": accepted,
|
| 182 |
-
# Cleared items passed the automated checks and hold a slot in the pool;
|
| 183 |
-
# the ones not yet accepted are waiting on a human validator.
|
| 184 |
-
"in_review_items": max(cleared - accepted, 0),
|
| 185 |
-
"progress_pct": _pct(accepted, target),
|
| 186 |
-
"karma_per_accepted_item": pricing.get("contributorPerItem"),
|
| 187 |
-
"validator_karma_per_audited_item": pricing.get("validatorPerAuditedItem"),
|
| 188 |
-
"audit_coverage_pct": row.get("auditCoveragePct"),
|
| 189 |
-
"license": row.get("communityLicense") or "",
|
| 190 |
-
"page_url": f"{SITE_URL}/pools/{pool_id}/",
|
| 191 |
-
"contribute_url": f"{APP_URL}/contributor/pool/{pool_id}/",
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
def _catalog() -> list[dict[str, Any]]:
|
| 196 |
-
return _cached(("catalog",), lambda: _all_pages("/v1/community/catalog", "bounties"))
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
def open_pools(query: str = "", domain: str = "", difficulty: str = "") -> list[dict[str, Any]]:
|
| 200 |
-
"""Pools currently open for contribution, optionally filtered.
|
| 201 |
-
|
| 202 |
-
"Open" is whatever the platform's own open-pools endpoint lists; the catalog
|
| 203 |
-
supplies the descriptive fields that endpoint leaves out.
|
| 204 |
-
"""
|
| 205 |
-
open_ids = {row.get("id") for row in _all_pages("/v1/community/pools", "pools")}
|
| 206 |
-
needle = query.strip().lower()
|
| 207 |
-
pools = []
|
| 208 |
-
for row in _catalog():
|
| 209 |
-
if row.get("id") not in open_ids:
|
| 210 |
-
continue
|
| 211 |
-
pool = _programme(row)
|
| 212 |
-
if domain and pool["domain"] != domain:
|
| 213 |
-
continue
|
| 214 |
-
if difficulty and pool["difficulty"] != difficulty:
|
| 215 |
-
continue
|
| 216 |
-
haystack = " ".join((pool["title"], pool["dataset_type"], pool["language"], row.get("description") or ""))
|
| 217 |
-
if needle and needle not in haystack.lower():
|
| 218 |
-
continue
|
| 219 |
-
pools.append(pool)
|
| 220 |
-
return pools
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
def pool_details(pool_id: str) -> dict[str, Any] | None:
|
| 224 |
-
"""One pool's full public record, or None when the id is unknown."""
|
| 225 |
-
pool_id = (pool_id or "").strip()
|
| 226 |
-
if not _POOL_ID.match(pool_id):
|
| 227 |
-
raise ValueError("That doesn't look like a DataBounty pool id.")
|
| 228 |
-
row = (_get(f"/v1/community/catalog/{quote(pool_id, safe='')}", not_found_ok=True) or {}).get("bounty")
|
| 229 |
-
if not row:
|
| 230 |
-
return None
|
| 231 |
-
pool = _programme(row)
|
| 232 |
-
dataset_type = row.get("datasetType") or {}
|
| 233 |
-
pool.update(
|
| 234 |
-
description=row.get("description") or "",
|
| 235 |
-
contributors=row.get("contributorCount"),
|
| 236 |
-
validators=row.get("validatorCount"),
|
| 237 |
-
duplicate_rate=row.get("duplicateRate"),
|
| 238 |
-
created_at=_date(row.get("createdAt")),
|
| 239 |
-
fields=[
|
| 240 |
-
{
|
| 241 |
-
"key": field.get("key"),
|
| 242 |
-
"label": field.get("label") or field.get("key"),
|
| 243 |
-
"role": field.get("role"),
|
| 244 |
-
"required": bool(field.get("required")),
|
| 245 |
-
"help": field.get("help") or "",
|
| 246 |
-
}
|
| 247 |
-
for field in dataset_type.get("fields") or []
|
| 248 |
-
],
|
| 249 |
-
example_items=[
|
| 250 |
-
sample["fields"]
|
| 251 |
-
for sample in dataset_type.get("sampleAssets") or []
|
| 252 |
-
if isinstance(sample, dict) and isinstance(sample.get("fields"), dict) and sample["fields"]
|
| 253 |
-
][:3],
|
| 254 |
-
hugging_face_dataset=row.get("huggingFaceDataset"),
|
| 255 |
-
)
|
| 256 |
-
return pool
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
def _hub_datasets() -> dict[str, dict[str, Any]]:
|
| 260 |
-
def load() -> dict[str, dict[str, Any]]:
|
| 261 |
-
return {
|
| 262 |
-
info.id: {"downloads": info.downloads, "likes": info.likes}
|
| 263 |
-
for info in _hub.list_datasets(author=HF_ORG, full=True)
|
| 264 |
-
}
|
| 265 |
-
|
| 266 |
-
try:
|
| 267 |
-
return _cached(("hub", HF_ORG), load)
|
| 268 |
-
except Exception:
|
| 269 |
-
# Download counts are decoration; the catalog is the source of truth,
|
| 270 |
-
# so a Hub hiccup must not take the datasets list down with it.
|
| 271 |
-
return {}
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
def published_datasets() -> list[dict[str, Any]]:
|
| 275 |
-
"""Datasets DataBounty has published, newest first, with Hub download counts."""
|
| 276 |
-
hub = _hub_datasets()
|
| 277 |
-
datasets = []
|
| 278 |
-
for row in _catalog():
|
| 279 |
-
if row.get("publicationStatus") != "published":
|
| 280 |
-
continue
|
| 281 |
-
pool = _programme(row)
|
| 282 |
-
targets = (row.get("publication") or {}).get("targets") or []
|
| 283 |
-
|
| 284 |
-
def published_url(target: str) -> str | None:
|
| 285 |
-
return next(
|
| 286 |
-
(t.get("url") for t in targets if t.get("target") == target and t.get("state") == "published" and t.get("url")),
|
| 287 |
-
None,
|
| 288 |
-
)
|
| 289 |
-
|
| 290 |
-
repo_id = row.get("huggingFaceDataset") or None
|
| 291 |
-
counts = hub.get(repo_id or "", {})
|
| 292 |
-
datasets.append(
|
| 293 |
-
{
|
| 294 |
-
"title": pool["title"],
|
| 295 |
-
"dataset_type": pool["dataset_type"],
|
| 296 |
-
"items": pool["accepted_items"],
|
| 297 |
-
"license": pool["license"],
|
| 298 |
-
"difficulty": pool["difficulty"],
|
| 299 |
-
"published_on": _date(row.get("deliveredAt")),
|
| 300 |
-
"hugging_face_id": repo_id,
|
| 301 |
-
"hugging_face_url": published_url("huggingface") or (f"https://huggingface.co/datasets/{repo_id}" if repo_id else None),
|
| 302 |
-
"github_url": published_url("github"),
|
| 303 |
-
"downloads": counts.get("downloads"),
|
| 304 |
-
"likes": counts.get("likes"),
|
| 305 |
-
"page_url": pool["page_url"],
|
| 306 |
-
}
|
| 307 |
-
)
|
| 308 |
-
datasets.sort(key=lambda d: d["published_on"] or "", reverse=True)
|
| 309 |
-
return datasets
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
# --- People -----------------------------------------------------------------------
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
def leaderboard(limit: int = 50, tier: str = "", query: str = "") -> list[dict[str, Any]]:
|
| 316 |
-
"""The all-time karma leaderboard.
|
| 317 |
-
|
| 318 |
-
The API already leaves out members who hide their karma, so this never
|
| 319 |
-
shows more than databounty.io's own leaderboard does.
|
| 320 |
-
"""
|
| 321 |
-
params = {
|
| 322 |
-
"limit": max(1, min(_int(limit) or 50, 100)),
|
| 323 |
-
"tier": tier if tier in TIERS else None,
|
| 324 |
-
"q": query.strip()[:60] or None,
|
| 325 |
-
}
|
| 326 |
-
rows = (_get("/v1/community/leaderboard", params) or {}).get("leaderboard") or []
|
| 327 |
-
ladder = tiers()
|
| 328 |
-
board = []
|
| 329 |
-
for row in rows:
|
| 330 |
-
karma = _int(row.get("karma"))
|
| 331 |
-
handle = row.get("handle")
|
| 332 |
-
board.append(
|
| 333 |
-
{
|
| 334 |
-
"rank": row.get("rank"),
|
| 335 |
-
"handle": handle,
|
| 336 |
-
"display_name": row.get("displayName") or handle,
|
| 337 |
-
"karma": karma,
|
| 338 |
-
"accepted_items": row.get("acceptedItems"),
|
| 339 |
-
"tier": (tier_for(karma, ladder) or {}).get("label"),
|
| 340 |
-
"profile_url": f"{SITE_URL}/{handle}/" if handle else None,
|
| 341 |
-
}
|
| 342 |
-
)
|
| 343 |
-
return board
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
def normalize_handle(handle: str) -> str:
|
| 347 |
-
"""Accept "starlord", "@starlord" or a pasted databounty.io profile URL."""
|
| 348 |
-
value = (handle or "").strip()
|
| 349 |
-
match = _PROFILE_URL.match(value)
|
| 350 |
-
if match:
|
| 351 |
-
value = match.group(1)
|
| 352 |
-
value = value.lstrip("@").lower()
|
| 353 |
-
if not _HANDLE.match(value):
|
| 354 |
-
raise ValueError("Handles are 3-20 characters long and use lowercase letters, digits and hyphens.")
|
| 355 |
-
return value
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
def _badge_labels(badges: list[dict[str, Any]]) -> list[str]:
|
| 359 |
-
"""Readable badge labels, keeping only the highest step of each tiered badge.
|
| 360 |
-
|
| 361 |
-
Tiered badges (10, 25, 100 audits and so on) share a "{value} ..." label and
|
| 362 |
-
carry their threshold as the id suffix (bdg_audits_100), which is how the
|
| 363 |
-
catalog is seeded. The public profile response does not include the
|
| 364 |
-
threshold itself, so the suffix is the only place to read it from.
|
| 365 |
-
"""
|
| 366 |
-
highest: dict[str, tuple[int, str]] = {}
|
| 367 |
-
plain: list[str] = []
|
| 368 |
-
for badge in badges:
|
| 369 |
-
label = badge.get("label") or ""
|
| 370 |
-
step = _BADGE_STEP.match(badge.get("id") or "")
|
| 371 |
-
if "{value}" in label and step:
|
| 372 |
-
value = int(step.group("value"))
|
| 373 |
-
if value > highest.get(step.group("ladder"), (-1, ""))[0]:
|
| 374 |
-
highest[step.group("ladder")] = (value, label.replace("{value}", f"{value:,}"))
|
| 375 |
-
elif label:
|
| 376 |
-
plain.append(" ".join(label.replace("{value}", "").split()))
|
| 377 |
-
return [label for _, label in highest.values()] + plain
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
def contributor(handle: str) -> dict[str, Any] | None:
|
| 381 |
-
"""A contributor's public profile, or None if there is no public profile.
|
| 382 |
-
|
| 383 |
-
Fields the member chose to hide are absent upstream and come back as None
|
| 384 |
-
here, so callers must treat every field except the handle as optional.
|
| 385 |
-
"""
|
| 386 |
-
handle = normalize_handle(handle)
|
| 387 |
-
data = _get(f"/v1/profiles/handle/{quote(handle, safe='')}", not_found_ok=True)
|
| 388 |
-
if not data:
|
| 389 |
-
return None
|
| 390 |
-
tier = data.get("tier") if isinstance(data.get("tier"), dict) else None
|
| 391 |
-
return {
|
| 392 |
-
"handle": data.get("handle") or handle,
|
| 393 |
-
"display_name": data.get("displayName") or data.get("handle") or handle,
|
| 394 |
-
"member_since": _date(data.get("memberSince")),
|
| 395 |
-
"karma": data.get("karma"),
|
| 396 |
-
"tier": tier.get("label") if tier else None,
|
| 397 |
-
"tier_color": tier.get("color") if tier else None,
|
| 398 |
-
"badges": _badge_labels(data["badges"]) if "badges" in data else None,
|
| 399 |
-
"accepted_items": data.get("acceptedItems"),
|
| 400 |
-
"audits": data.get("audits"),
|
| 401 |
-
"datasets_contributed_to": (
|
| 402 |
-
[{"title": d.get("title"), "items": d.get("items")} for d in data["datasets"]] if "datasets" in data else None
|
| 403 |
-
),
|
| 404 |
-
"published_credits": [
|
| 405 |
-
{"title": c.get("title"), "hugging_face_url": c.get("hfUrl")}
|
| 406 |
-
for c in data.get("publishedCredits") or []
|
| 407 |
-
if c.get("hfUrl")
|
| 408 |
-
],
|
| 409 |
-
"profile_url": f"{SITE_URL}/{handle}/",
|
| 410 |
-
}
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
# ---
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
def
|
| 417 |
-
"""DataBounty's
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
"""Read-only client for DataBounty's public API and the Hugging Face Hub.
|
| 3 |
+
|
| 4 |
+
Everything here reads public data: the same unauthenticated endpoints that
|
| 5 |
+
databounty.io renders from. Each response is kept in a small in-process cache
|
| 6 |
+
for CACHE_TTL_SECONDS, so a burst of visitors, or an agent calling the MCP tools
|
| 7 |
+
in a loop, never turns into a burst of upstream requests. Errors are never
|
| 8 |
+
cached, so a recovered upstream is picked up on the next call.
|
| 9 |
+
|
| 10 |
+
Profiles are read only from /v1/profiles/handle/:handle, which answers 404 for a
|
| 11 |
+
private profile and leaves out every field the member chose to hide. Nothing
|
| 12 |
+
here calls /v1/community/members/:handle, which applies neither rule.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
import threading
|
| 20 |
+
import time
|
| 21 |
+
from collections import OrderedDict
|
| 22 |
+
from collections.abc import Callable
|
| 23 |
+
from typing import Any
|
| 24 |
+
from urllib.parse import quote
|
| 25 |
+
|
| 26 |
+
import httpx
|
| 27 |
+
from huggingface_hub import HfApi
|
| 28 |
+
|
| 29 |
+
API_URL = os.environ.get("DATABOUNTY_API_URL", "https://api.databounty.io").rstrip("/")
|
| 30 |
+
SITE_URL = os.environ.get("DATABOUNTY_SITE_URL", "https://databounty.io").rstrip("/")
|
| 31 |
+
APP_URL = os.environ.get("DATABOUNTY_APP_URL", "https://console.databounty.io").rstrip("/")
|
| 32 |
+
HF_ORG = os.environ.get("DATABOUNTY_HF_ORG", "databounty-io")
|
| 33 |
+
CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "120"))
|
| 34 |
+
|
| 35 |
+
# DataBounty's own MCP server. The dashboard origin proxies /mcp to the API, and
|
| 36 |
+
# the API binds every OAuth credential to that origin, so this is the URL
|
| 37 |
+
# clients must use (not API_URL).
|
| 38 |
+
MCP_URL = f"{APP_URL}/mcp"
|
| 39 |
+
|
| 40 |
+
DOMAINS = ("coding", "legal", "healthcare", "finance", "science")
|
| 41 |
+
DIFFICULTIES = ("beginner", "intermediate", "advanced")
|
| 42 |
+
TIERS = ("dharma", "bodhi", "moksha", "nirvana")
|
| 43 |
+
|
| 44 |
+
# Same rule the dashboard applies when a member claims a handle.
|
| 45 |
+
_HANDLE = re.compile(r"^[a-z0-9-]{3,20}$")
|
| 46 |
+
_POOL_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
| 47 |
+
_PROFILE_URL = re.compile(r"^(?:https?://)?(?:www\.)?databounty\.io/+@?([^/?#]+)/*$", re.IGNORECASE)
|
| 48 |
+
_BADGE_STEP = re.compile(r"^(?P<ladder>.+)_(?P<value>\d+)$")
|
| 49 |
+
|
| 50 |
+
_PAGE_SIZE = {"/v1/community/catalog": 100, "/v1/community/pools": 50}
|
| 51 |
+
_MAX_PAGES = 5
|
| 52 |
+
_CACHE_MAX_ENTRIES = 256
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class UpstreamError(RuntimeError):
|
| 56 |
+
"""DataBounty's API could not be reached, or answered with an error."""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
_http = httpx.Client(
|
| 60 |
+
base_url=API_URL,
|
| 61 |
+
timeout=httpx.Timeout(15.0, connect=5.0),
|
| 62 |
+
headers={
|
| 63 |
+
"Accept": "application/json",
|
| 64 |
+
"User-Agent": "databounty-space/1.0 (+https://huggingface.co/spaces/databounty-io/DataBounty)",
|
| 65 |
+
},
|
| 66 |
+
)
|
| 67 |
+
_hub = HfApi()
|
| 68 |
+
_cache: OrderedDict[tuple, tuple[float, Any]] = OrderedDict()
|
| 69 |
+
_lock = threading.Lock()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _cached(key: tuple, load: Callable[[], Any]) -> Any:
|
| 73 |
+
now = time.monotonic()
|
| 74 |
+
with _lock:
|
| 75 |
+
hit = _cache.get(key)
|
| 76 |
+
if hit is not None and now - hit[0] < CACHE_TTL_SECONDS:
|
| 77 |
+
_cache.move_to_end(key)
|
| 78 |
+
return hit[1]
|
| 79 |
+
value = load() # raises on failure, so errors never reach the cache
|
| 80 |
+
with _lock:
|
| 81 |
+
_cache[key] = (now, value)
|
| 82 |
+
_cache.move_to_end(key)
|
| 83 |
+
# Keys include free-text search terms, so bound the cache rather than
|
| 84 |
+
# let a stream of unique queries grow it forever.
|
| 85 |
+
while len(_cache) > _CACHE_MAX_ENTRIES:
|
| 86 |
+
_cache.popitem(last=False)
|
| 87 |
+
return value
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _get(path: str, params: dict[str, Any] | None = None, *, not_found_ok: bool = False) -> Any:
|
| 91 |
+
query = {k: v for k, v in (params or {}).items() if v not in (None, "")}
|
| 92 |
+
|
| 93 |
+
def load() -> Any:
|
| 94 |
+
try:
|
| 95 |
+
resp = _http.get(path, params=query)
|
| 96 |
+
except httpx.HTTPError as exc:
|
| 97 |
+
raise UpstreamError(f"DataBounty's API could not be reached ({type(exc).__name__}).") from exc
|
| 98 |
+
if resp.status_code == 404 and not_found_ok:
|
| 99 |
+
return None
|
| 100 |
+
if resp.status_code >= 400:
|
| 101 |
+
raise UpstreamError(f"DataBounty's API answered HTTP {resp.status_code} for {path}.")
|
| 102 |
+
try:
|
| 103 |
+
return resp.json()
|
| 104 |
+
except ValueError as exc:
|
| 105 |
+
raise UpstreamError(f"DataBounty's API returned an unreadable response for {path}.") from exc
|
| 106 |
+
|
| 107 |
+
return _cached(("api", path, tuple(sorted(query.items()))), load)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _all_pages(path: str, key: str) -> list[dict[str, Any]]:
|
| 111 |
+
rows: list[dict[str, Any]] = []
|
| 112 |
+
cursor = None
|
| 113 |
+
for _ in range(_MAX_PAGES):
|
| 114 |
+
page = _get(path, {"limit": _PAGE_SIZE[path], "cursor": cursor}) or {}
|
| 115 |
+
rows.extend(page.get(key) or [])
|
| 116 |
+
cursor = page.get("nextCursor")
|
| 117 |
+
if not cursor:
|
| 118 |
+
break
|
| 119 |
+
return rows
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _int(value: Any) -> int:
|
| 123 |
+
# Counts arrive as numbers or as numeric strings (targetItems is a BigInt
|
| 124 |
+
# serialised as text), so accept both.
|
| 125 |
+
try:
|
| 126 |
+
return int(value)
|
| 127 |
+
except (TypeError, ValueError):
|
| 128 |
+
return 0
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _pct(part: int, whole: int) -> float | None:
|
| 132 |
+
return round(100 * part / whole, 1) if whole else None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _date(value: Any) -> str | None:
|
| 136 |
+
return value[:10] if isinstance(value, str) and len(value) >= 10 else None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# --- Programme totals and karma tiers -----------------------------------------
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def stats() -> dict[str, Any]:
|
| 143 |
+
"""Programme totals, the karma tier ladder and the top of the leaderboard."""
|
| 144 |
+
return _get("/v1/community/stats") or {}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def tiers() -> list[dict[str, Any]]:
|
| 148 |
+
"""The live karma tier ladder, lowest threshold first."""
|
| 149 |
+
return sorted(stats().get("tiers") or [], key=lambda t: _int(t.get("minKarma")))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def tier_for(karma: int, ladder: list[dict[str, Any]]) -> dict[str, Any] | None:
|
| 153 |
+
current = None
|
| 154 |
+
for tier in ladder:
|
| 155 |
+
if karma >= _int(tier.get("minKarma")):
|
| 156 |
+
current = tier
|
| 157 |
+
return current
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# --- Pools and datasets ---------------------------------------------------------
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _programme(row: dict[str, Any]) -> dict[str, Any]:
|
| 164 |
+
dataset_type = row.get("datasetType") or {}
|
| 165 |
+
pricing = row.get("karmaPricing") or {}
|
| 166 |
+
target = _int(row.get("targetItems"))
|
| 167 |
+
accepted = _int(row.get("acceptedItems"))
|
| 168 |
+
cleared = _int(row.get("clearedItems"))
|
| 169 |
+
pool_id = str(row.get("id") or "")
|
| 170 |
+
return {
|
| 171 |
+
"id": pool_id,
|
| 172 |
+
"title": row.get("title") or "",
|
| 173 |
+
"status": row.get("status") or "",
|
| 174 |
+
"dataset_type": dataset_type.get("name") or "",
|
| 175 |
+
"domain": dataset_type.get("domain") or "",
|
| 176 |
+
"category": row.get("datasetCategory") or "",
|
| 177 |
+
"language": row.get("language") or "",
|
| 178 |
+
"difficulty": row.get("difficulty") or "",
|
| 179 |
+
"trust_tier": dataset_type.get("trustTier") or "",
|
| 180 |
+
"target_items": target,
|
| 181 |
+
"accepted_items": accepted,
|
| 182 |
+
# Cleared items passed the automated checks and hold a slot in the pool;
|
| 183 |
+
# the ones not yet accepted are waiting on a human validator.
|
| 184 |
+
"in_review_items": max(cleared - accepted, 0),
|
| 185 |
+
"progress_pct": _pct(accepted, target),
|
| 186 |
+
"karma_per_accepted_item": pricing.get("contributorPerItem"),
|
| 187 |
+
"validator_karma_per_audited_item": pricing.get("validatorPerAuditedItem"),
|
| 188 |
+
"audit_coverage_pct": row.get("auditCoveragePct"),
|
| 189 |
+
"license": row.get("communityLicense") or "",
|
| 190 |
+
"page_url": f"{SITE_URL}/pools/{pool_id}/",
|
| 191 |
+
"contribute_url": f"{APP_URL}/contributor/pool/{pool_id}/",
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _catalog() -> list[dict[str, Any]]:
|
| 196 |
+
return _cached(("catalog",), lambda: _all_pages("/v1/community/catalog", "bounties"))
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def open_pools(query: str = "", domain: str = "", difficulty: str = "") -> list[dict[str, Any]]:
|
| 200 |
+
"""Pools currently open for contribution, optionally filtered.
|
| 201 |
+
|
| 202 |
+
"Open" is whatever the platform's own open-pools endpoint lists; the catalog
|
| 203 |
+
supplies the descriptive fields that endpoint leaves out.
|
| 204 |
+
"""
|
| 205 |
+
open_ids = {row.get("id") for row in _all_pages("/v1/community/pools", "pools")}
|
| 206 |
+
needle = query.strip().lower()
|
| 207 |
+
pools = []
|
| 208 |
+
for row in _catalog():
|
| 209 |
+
if row.get("id") not in open_ids:
|
| 210 |
+
continue
|
| 211 |
+
pool = _programme(row)
|
| 212 |
+
if domain and pool["domain"] != domain:
|
| 213 |
+
continue
|
| 214 |
+
if difficulty and pool["difficulty"] != difficulty:
|
| 215 |
+
continue
|
| 216 |
+
haystack = " ".join((pool["title"], pool["dataset_type"], pool["language"], row.get("description") or ""))
|
| 217 |
+
if needle and needle not in haystack.lower():
|
| 218 |
+
continue
|
| 219 |
+
pools.append(pool)
|
| 220 |
+
return pools
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def pool_details(pool_id: str) -> dict[str, Any] | None:
|
| 224 |
+
"""One pool's full public record, or None when the id is unknown."""
|
| 225 |
+
pool_id = (pool_id or "").strip()
|
| 226 |
+
if not _POOL_ID.match(pool_id):
|
| 227 |
+
raise ValueError("That doesn't look like a DataBounty pool id.")
|
| 228 |
+
row = (_get(f"/v1/community/catalog/{quote(pool_id, safe='')}", not_found_ok=True) or {}).get("bounty")
|
| 229 |
+
if not row:
|
| 230 |
+
return None
|
| 231 |
+
pool = _programme(row)
|
| 232 |
+
dataset_type = row.get("datasetType") or {}
|
| 233 |
+
pool.update(
|
| 234 |
+
description=row.get("description") or "",
|
| 235 |
+
contributors=row.get("contributorCount"),
|
| 236 |
+
validators=row.get("validatorCount"),
|
| 237 |
+
duplicate_rate=row.get("duplicateRate"),
|
| 238 |
+
created_at=_date(row.get("createdAt")),
|
| 239 |
+
fields=[
|
| 240 |
+
{
|
| 241 |
+
"key": field.get("key"),
|
| 242 |
+
"label": field.get("label") or field.get("key"),
|
| 243 |
+
"role": field.get("role"),
|
| 244 |
+
"required": bool(field.get("required")),
|
| 245 |
+
"help": field.get("help") or "",
|
| 246 |
+
}
|
| 247 |
+
for field in dataset_type.get("fields") or []
|
| 248 |
+
],
|
| 249 |
+
example_items=[
|
| 250 |
+
sample["fields"]
|
| 251 |
+
for sample in dataset_type.get("sampleAssets") or []
|
| 252 |
+
if isinstance(sample, dict) and isinstance(sample.get("fields"), dict) and sample["fields"]
|
| 253 |
+
][:3],
|
| 254 |
+
hugging_face_dataset=row.get("huggingFaceDataset"),
|
| 255 |
+
)
|
| 256 |
+
return pool
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _hub_datasets() -> dict[str, dict[str, Any]]:
|
| 260 |
+
def load() -> dict[str, dict[str, Any]]:
|
| 261 |
+
return {
|
| 262 |
+
info.id: {"downloads": info.downloads, "likes": info.likes}
|
| 263 |
+
for info in _hub.list_datasets(author=HF_ORG, full=True)
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
try:
|
| 267 |
+
return _cached(("hub", HF_ORG), load)
|
| 268 |
+
except Exception:
|
| 269 |
+
# Download counts are decoration; the catalog is the source of truth,
|
| 270 |
+
# so a Hub hiccup must not take the datasets list down with it.
|
| 271 |
+
return {}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def published_datasets() -> list[dict[str, Any]]:
|
| 275 |
+
"""Datasets DataBounty has published, newest first, with Hub download counts."""
|
| 276 |
+
hub = _hub_datasets()
|
| 277 |
+
datasets = []
|
| 278 |
+
for row in _catalog():
|
| 279 |
+
if row.get("publicationStatus") != "published":
|
| 280 |
+
continue
|
| 281 |
+
pool = _programme(row)
|
| 282 |
+
targets = (row.get("publication") or {}).get("targets") or []
|
| 283 |
+
|
| 284 |
+
def published_url(target: str) -> str | None:
|
| 285 |
+
return next(
|
| 286 |
+
(t.get("url") for t in targets if t.get("target") == target and t.get("state") == "published" and t.get("url")),
|
| 287 |
+
None,
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
repo_id = row.get("huggingFaceDataset") or None
|
| 291 |
+
counts = hub.get(repo_id or "", {})
|
| 292 |
+
datasets.append(
|
| 293 |
+
{
|
| 294 |
+
"title": pool["title"],
|
| 295 |
+
"dataset_type": pool["dataset_type"],
|
| 296 |
+
"items": pool["accepted_items"],
|
| 297 |
+
"license": pool["license"],
|
| 298 |
+
"difficulty": pool["difficulty"],
|
| 299 |
+
"published_on": _date(row.get("deliveredAt")),
|
| 300 |
+
"hugging_face_id": repo_id,
|
| 301 |
+
"hugging_face_url": published_url("huggingface") or (f"https://huggingface.co/datasets/{repo_id}" if repo_id else None),
|
| 302 |
+
"github_url": published_url("github"),
|
| 303 |
+
"downloads": counts.get("downloads"),
|
| 304 |
+
"likes": counts.get("likes"),
|
| 305 |
+
"page_url": pool["page_url"],
|
| 306 |
+
}
|
| 307 |
+
)
|
| 308 |
+
datasets.sort(key=lambda d: d["published_on"] or "", reverse=True)
|
| 309 |
+
return datasets
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# --- People -----------------------------------------------------------------------
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def leaderboard(limit: int = 50, tier: str = "", query: str = "") -> list[dict[str, Any]]:
|
| 316 |
+
"""The all-time karma leaderboard.
|
| 317 |
+
|
| 318 |
+
The API already leaves out members who hide their karma, so this never
|
| 319 |
+
shows more than databounty.io's own leaderboard does.
|
| 320 |
+
"""
|
| 321 |
+
params = {
|
| 322 |
+
"limit": max(1, min(_int(limit) or 50, 100)),
|
| 323 |
+
"tier": tier if tier in TIERS else None,
|
| 324 |
+
"q": query.strip()[:60] or None,
|
| 325 |
+
}
|
| 326 |
+
rows = (_get("/v1/community/leaderboard", params) or {}).get("leaderboard") or []
|
| 327 |
+
ladder = tiers()
|
| 328 |
+
board = []
|
| 329 |
+
for row in rows:
|
| 330 |
+
karma = _int(row.get("karma"))
|
| 331 |
+
handle = row.get("handle")
|
| 332 |
+
board.append(
|
| 333 |
+
{
|
| 334 |
+
"rank": row.get("rank"),
|
| 335 |
+
"handle": handle,
|
| 336 |
+
"display_name": row.get("displayName") or handle,
|
| 337 |
+
"karma": karma,
|
| 338 |
+
"accepted_items": row.get("acceptedItems"),
|
| 339 |
+
"tier": (tier_for(karma, ladder) or {}).get("label"),
|
| 340 |
+
"profile_url": f"{SITE_URL}/{handle}/" if handle else None,
|
| 341 |
+
}
|
| 342 |
+
)
|
| 343 |
+
return board
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def normalize_handle(handle: str) -> str:
|
| 347 |
+
"""Accept "starlord", "@starlord" or a pasted databounty.io profile URL."""
|
| 348 |
+
value = (handle or "").strip()
|
| 349 |
+
match = _PROFILE_URL.match(value)
|
| 350 |
+
if match:
|
| 351 |
+
value = match.group(1)
|
| 352 |
+
value = value.lstrip("@").lower()
|
| 353 |
+
if not _HANDLE.match(value):
|
| 354 |
+
raise ValueError("Handles are 3-20 characters long and use lowercase letters, digits and hyphens.")
|
| 355 |
+
return value
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _badge_labels(badges: list[dict[str, Any]]) -> list[str]:
|
| 359 |
+
"""Readable badge labels, keeping only the highest step of each tiered badge.
|
| 360 |
+
|
| 361 |
+
Tiered badges (10, 25, 100 audits and so on) share a "{value} ..." label and
|
| 362 |
+
carry their threshold as the id suffix (bdg_audits_100), which is how the
|
| 363 |
+
catalog is seeded. The public profile response does not include the
|
| 364 |
+
threshold itself, so the suffix is the only place to read it from.
|
| 365 |
+
"""
|
| 366 |
+
highest: dict[str, tuple[int, str]] = {}
|
| 367 |
+
plain: list[str] = []
|
| 368 |
+
for badge in badges:
|
| 369 |
+
label = badge.get("label") or ""
|
| 370 |
+
step = _BADGE_STEP.match(badge.get("id") or "")
|
| 371 |
+
if "{value}" in label and step:
|
| 372 |
+
value = int(step.group("value"))
|
| 373 |
+
if value > highest.get(step.group("ladder"), (-1, ""))[0]:
|
| 374 |
+
highest[step.group("ladder")] = (value, label.replace("{value}", f"{value:,}"))
|
| 375 |
+
elif label:
|
| 376 |
+
plain.append(" ".join(label.replace("{value}", "").split()))
|
| 377 |
+
return [label for _, label in highest.values()] + plain
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def contributor(handle: str) -> dict[str, Any] | None:
|
| 381 |
+
"""A contributor's public profile, or None if there is no public profile.
|
| 382 |
+
|
| 383 |
+
Fields the member chose to hide are absent upstream and come back as None
|
| 384 |
+
here, so callers must treat every field except the handle as optional.
|
| 385 |
+
"""
|
| 386 |
+
handle = normalize_handle(handle)
|
| 387 |
+
data = _get(f"/v1/profiles/handle/{quote(handle, safe='')}", not_found_ok=True)
|
| 388 |
+
if not data:
|
| 389 |
+
return None
|
| 390 |
+
tier = data.get("tier") if isinstance(data.get("tier"), dict) else None
|
| 391 |
+
return {
|
| 392 |
+
"handle": data.get("handle") or handle,
|
| 393 |
+
"display_name": data.get("displayName") or data.get("handle") or handle,
|
| 394 |
+
"member_since": _date(data.get("memberSince")),
|
| 395 |
+
"karma": data.get("karma"),
|
| 396 |
+
"tier": tier.get("label") if tier else None,
|
| 397 |
+
"tier_color": tier.get("color") if tier else None,
|
| 398 |
+
"badges": _badge_labels(data["badges"]) if "badges" in data else None,
|
| 399 |
+
"accepted_items": data.get("acceptedItems"),
|
| 400 |
+
"audits": data.get("audits"),
|
| 401 |
+
"datasets_contributed_to": (
|
| 402 |
+
[{"title": d.get("title"), "items": d.get("items")} for d in data["datasets"]] if "datasets" in data else None
|
| 403 |
+
),
|
| 404 |
+
"published_credits": [
|
| 405 |
+
{"title": c.get("title"), "hugging_face_url": c.get("hfUrl")}
|
| 406 |
+
for c in data.get("publishedCredits") or []
|
| 407 |
+
if c.get("hfUrl")
|
| 408 |
+
],
|
| 409 |
+
"profile_url": f"{SITE_URL}/{handle}/",
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
# --- Requesting a dataset ------------------------------------------------------------
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def request_catalog() -> dict[str, Any]:
|
| 417 |
+
"""DataBounty's planner catalog: the dataset types open for requests, with the languages,
|
| 418 |
+
difficulty levels and hand-check shares each one offers, and how many reference samples a
|
| 419 |
+
request wants. Public, like the rest of what the Space reads here."""
|
| 420 |
+
return _get("/v1/planner/catalog") or {}
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def requesting_open() -> bool:
|
| 424 |
+
"""Whether DataBounty takes dataset requests from other apps (its request_dataset MCP tool).
|
| 425 |
+
Until it does, the Space links to databounty.io instead."""
|
| 426 |
+
return any(tool["name"] == "request_dataset" for tool in platform_mcp_tools())
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# --- Agents -------------------------------------------------------------------------
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def platform_mcp_tools() -> list[dict[str, str]]:
|
| 433 |
+
"""DataBounty's own MCP tool catalog, with the credential scope each one needs."""
|
| 434 |
+
tools = (_get("/mcp/tools") or {}).get("tools") or []
|
| 435 |
+
return [
|
| 436 |
+
{
|
| 437 |
+
"name": tool.get("name") or "",
|
| 438 |
+
"scope": tool.get("scope") or "",
|
| 439 |
+
"description": " ".join((tool.get("description") or "").split()),
|
| 440 |
+
}
|
| 441 |
+
for tool in tools
|
| 442 |
+
]
|
requesting.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
"""Requesting a dataset from the Space: the form's choices, a request's arguments, and how a
|
| 3 |
+
person's requests read back.
|
| 4 |
+
|
| 5 |
+
Plain data in, plain data out, like contribute.py, validate.py and sponsor.py. The choices come from
|
| 6 |
+
DataBounty's public planner catalog (the dataset types open for requests, with the languages and
|
| 7 |
+
difficulty levels each can honour). A request goes to DataBounty's request_dataset tool, which checks
|
| 8 |
+
it all again, and an admin reviews it before a pool opens. The defaults are the console planner's.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
# The licences DataBounty can publish a dataset under (its lib/publication/license-texts.ts). It
|
| 17 |
+
# refuses any other.
|
| 18 |
+
LICENCES = [
|
| 19 |
+
("CC-BY-4.0", "CC BY 4.0", "Anyone may use it, crediting the contributors."),
|
| 20 |
+
("CC-BY-SA-4.0", "CC BY-SA 4.0", "Credit the contributors, and share changes under the same licence."),
|
| 21 |
+
("CC0-1.0", "CC0 1.0", "Public domain: no conditions at all."),
|
| 22 |
+
("ODC-By-1.0", "ODC-By 1.0", "Open data licence, crediting the contributors."),
|
| 23 |
+
]
|
| 24 |
+
MIXES = {"mostly_beginner": "Mostly beginner", "balanced": "Balanced", "mostly_advanced": "Mostly advanced"}
|
| 25 |
+
DEFAULTS = {"targetItems": 500, "difficultyMix": "balanced", "auditCoveragePct": 25, "proposedLicense": "CC-BY-4.0"}
|
| 26 |
+
TITLE_LENGTH = (5, 120)
|
| 27 |
+
DESCRIPTION_LENGTH = (20, 2000)
|
| 28 |
+
TARGET_ITEMS = (10, 100_000)
|
| 29 |
+
SHORT_TEXT = 60 # language and framework
|
| 30 |
+
# Characters DataBounty refuses in a request's text (control and bidirectional-override characters).
|
| 31 |
+
UNSAFE = re.compile("[\u0000-\u001f\u007f-\u009f--]")
|
| 32 |
+
KEY = re.compile(r"^[A-Za-z0-9_.:-]{8,200}$")
|
| 33 |
+
|
| 34 |
+
# Where a request stands, from the requester's side: (label, tone).
|
| 35 |
+
REQUEST_STATUS = {
|
| 36 |
+
"submitted": ("Waiting for review", "info"),
|
| 37 |
+
"under_review": ("In review", "review"),
|
| 38 |
+
"changes_requested": ("Changes asked for", "warn"),
|
| 39 |
+
"approved": ("Approved", "ok"),
|
| 40 |
+
"implemented": ("Pool is open", "ok"),
|
| 41 |
+
"declined": ("Declined", "bad"),
|
| 42 |
+
"disputed": ("Disputed", "warn"),
|
| 43 |
+
}
|
| 44 |
+
# DataBounty freezes a request's reference samples once it's decided (services/artifacts.ts).
|
| 45 |
+
SAMPLES_OPEN = {"submitted", "under_review", "changes_requested"}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def mixes_for(levels: Any) -> list[str]:
|
| 49 |
+
"""The difficulty mixes a dataset type can honour, by DataBounty's own rule (coherenceProblems in
|
| 50 |
+
services/planner.ts): mostly beginner needs a beginner level, mostly advanced an advanced or an
|
| 51 |
+
expert one. A type that declares no levels takes any mix."""
|
| 52 |
+
levels = {str(level) for level in levels or []}
|
| 53 |
+
if not levels:
|
| 54 |
+
return list(MIXES)
|
| 55 |
+
allowed = {"balanced"}
|
| 56 |
+
if "beginner" in levels:
|
| 57 |
+
allowed.add("mostly_beginner")
|
| 58 |
+
if levels & {"advanced", "expert"}:
|
| 59 |
+
allowed.add("mostly_advanced")
|
| 60 |
+
return [mix for mix in MIXES if mix in allowed]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def type_option(t: dict[str, Any]) -> dict[str, Any]:
|
| 64 |
+
"""One dataset type, as the form offers it."""
|
| 65 |
+
support = t.get("languageSupport") or {}
|
| 66 |
+
mode = support.get("mode") if support.get("mode") in {"fixed", "choice", "any", "none"} else "any"
|
| 67 |
+
languages = [str(l.get("label") or l.get("id") or "") for l in support.get("languages") or [] if isinstance(l, dict)]
|
| 68 |
+
audit = sorted({int(a) for a in t.get("auditOptions") or [] if isinstance(a, (int, float)) and 0 <= a <= 100})
|
| 69 |
+
return {
|
| 70 |
+
"id": str(t.get("id") or ""),
|
| 71 |
+
"name": str(t.get("name") or t.get("id") or ""),
|
| 72 |
+
"description": " ".join(str(t.get("description") or "").split()),
|
| 73 |
+
"category": str(t.get("category") or "other").replace("_", " "),
|
| 74 |
+
"languageMode": mode,
|
| 75 |
+
"languages": [l for l in languages if l],
|
| 76 |
+
"mixes": [{"value": m, "label": MIXES[m]} for m in mixes_for(t.get("difficultyLevels"))],
|
| 77 |
+
"auditOptions": audit or [DEFAULTS["auditCoveragePct"]],
|
| 78 |
+
# The item's field keys, for a sample template.
|
| 79 |
+
"fields": [str(f["key"]) for f in t.get("fields") or [] if isinstance(f, dict) and f.get("key")],
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def catalog_view(data: dict[str, Any]) -> dict[str, Any]:
|
| 84 |
+
"""The request form's choices, from DataBounty's planner catalog."""
|
| 85 |
+
types = [
|
| 86 |
+
type_option(t)
|
| 87 |
+
for t in data.get("datasetTypes") or []
|
| 88 |
+
if isinstance(t, dict) and t.get("id") and (t.get("status") or "active") == "active"
|
| 89 |
+
]
|
| 90 |
+
types.sort(key=lambda t: (t["category"], t["name"].lower()))
|
| 91 |
+
gate = data.get("sampleGate") or {}
|
| 92 |
+
wanted = gate.get("min")
|
| 93 |
+
return {
|
| 94 |
+
"types": types,
|
| 95 |
+
"sampleMin": wanted if isinstance(wanted, int) and wanted > 0 else 3,
|
| 96 |
+
"licences": [{"value": v, "label": label, "hint": hint} for v, label, hint in LICENCES],
|
| 97 |
+
"defaults": DEFAULTS,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _text(body: dict[str, Any], key: str) -> str:
|
| 102 |
+
return " ".join(str(body.get(key) or "").split()) if key != "description" else str(body.get(key) or "").strip()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def request_arguments(body: dict[str, Any], types: dict[str, dict[str, Any]]) -> tuple[dict[str, Any] | None, str]:
|
| 106 |
+
"""request_dataset's arguments for this form, or what to fix first. DataBounty checks it all again."""
|
| 107 |
+
t = types.get(str(body.get("datasetTypeId") or ""))
|
| 108 |
+
if not t:
|
| 109 |
+
return None, "Choose a dataset type."
|
| 110 |
+
title, description = _text(body, "title"), _text(body, "description")
|
| 111 |
+
language, framework = _text(body, "language"), _text(body, "framework")
|
| 112 |
+
if len(title) < TITLE_LENGTH[0]:
|
| 113 |
+
return None, f"Give the request a title of at least {TITLE_LENGTH[0]} characters."
|
| 114 |
+
if len(title) > TITLE_LENGTH[1]:
|
| 115 |
+
return None, f"Keep the title under {TITLE_LENGTH[1]} characters."
|
| 116 |
+
if len(description) < DESCRIPTION_LENGTH[0]:
|
| 117 |
+
return None, f"Describe the dataset in at least {DESCRIPTION_LENGTH[0]} characters."
|
| 118 |
+
if len(description) > DESCRIPTION_LENGTH[1]:
|
| 119 |
+
return None, f"Keep the description under {DESCRIPTION_LENGTH[1]} characters."
|
| 120 |
+
if any(UNSAFE.search(value) for value in (title, language, framework)):
|
| 121 |
+
return None, "Remove the hidden control characters from the title, language or framework."
|
| 122 |
+
if len(language) > SHORT_TEXT or len(framework) > SHORT_TEXT:
|
| 123 |
+
return None, f"Keep the language and framework under {SHORT_TEXT} characters."
|
| 124 |
+
try:
|
| 125 |
+
items = int(body.get("targetItems"))
|
| 126 |
+
except (TypeError, ValueError):
|
| 127 |
+
items = 0
|
| 128 |
+
if not TARGET_ITEMS[0] <= items <= TARGET_ITEMS[1]:
|
| 129 |
+
return None, f"Ask for between {TARGET_ITEMS[0]} and {TARGET_ITEMS[1]:,} items."
|
| 130 |
+
mix = str(body.get("difficultyMix") or "")
|
| 131 |
+
if mix not in {m["value"] for m in t["mixes"]}:
|
| 132 |
+
return None, "Choose a difficulty this dataset type offers."
|
| 133 |
+
try:
|
| 134 |
+
audit = int(body.get("auditCoveragePct"))
|
| 135 |
+
except (TypeError, ValueError):
|
| 136 |
+
audit = -1
|
| 137 |
+
if not 0 <= audit <= 100:
|
| 138 |
+
return None, "Choose how many items validators check by hand."
|
| 139 |
+
licence = str(body.get("proposedLicense") or "")
|
| 140 |
+
if licence not in {v for v, _, _ in LICENCES}:
|
| 141 |
+
return None, "Choose a licence."
|
| 142 |
+
|
| 143 |
+
arguments: dict[str, Any] = {
|
| 144 |
+
"datasetTypeId": t["id"],
|
| 145 |
+
"title": title,
|
| 146 |
+
"description": description,
|
| 147 |
+
"targetItems": items,
|
| 148 |
+
"difficultyMix": mix,
|
| 149 |
+
"auditCoveragePct": audit,
|
| 150 |
+
"proposedLicense": licence,
|
| 151 |
+
}
|
| 152 |
+
if t["languageMode"] == "fixed" and t["languages"]:
|
| 153 |
+
arguments["language"] = t["languages"][0]
|
| 154 |
+
elif t["languageMode"] == "choice":
|
| 155 |
+
match = next((l for l in t["languages"] if l.lower() == language.lower()), None)
|
| 156 |
+
if not match:
|
| 157 |
+
return None, f"Choose the language: {', '.join(t['languages'])}."
|
| 158 |
+
arguments["language"] = match
|
| 159 |
+
elif t["languageMode"] == "any" and language:
|
| 160 |
+
arguments["language"] = language
|
| 161 |
+
if framework:
|
| 162 |
+
arguments["framework"] = framework
|
| 163 |
+
key = str(body.get("idempotencyKey") or "")
|
| 164 |
+
if KEY.match(key):
|
| 165 |
+
arguments["idempotencyKey"] = key # the same form sent twice files one request
|
| 166 |
+
return arguments, ""
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _count(value: Any) -> int:
|
| 170 |
+
return value if isinstance(value, int) and value > 0 else 0
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def request_row(r: dict[str, Any], type_names: dict[str, str] | None = None) -> dict[str, Any]:
|
| 174 |
+
"""One of the person's requests, from list_my_dataset_requests."""
|
| 175 |
+
status = str(r.get("status") or "")
|
| 176 |
+
label, tone = REQUEST_STATUS.get(status, (status.replace("_", " ").capitalize() or "Unknown", "info"))
|
| 177 |
+
gate = r.get("sampleGate") or {}
|
| 178 |
+
approved, pending, rejected = _count(gate.get("approved")), _count(gate.get("pending")), _count(gate.get("rejected"))
|
| 179 |
+
wanted = _count(gate.get("min")) or 3
|
| 180 |
+
limit = _count(gate.get("max")) or wanted
|
| 181 |
+
if gate.get("ok"):
|
| 182 |
+
sample_text = "Example items approved."
|
| 183 |
+
elif approved:
|
| 184 |
+
sample_text = f"{approved} of {wanted} example items approved" + (f", {pending} waiting for review." if pending else ".")
|
| 185 |
+
elif pending:
|
| 186 |
+
sample_text = f"{pending} example item{'s' if pending != 1 else ''} waiting for review. The pool can open once one is approved."
|
| 187 |
+
else:
|
| 188 |
+
sample_text = f"Add up to {limit} example items. The pool can open once an admin approves one."
|
| 189 |
+
type_id = str(r.get("datasetTypeId") or "")
|
| 190 |
+
return {
|
| 191 |
+
"id": r.get("id"),
|
| 192 |
+
"title": r.get("title") or "",
|
| 193 |
+
"typeId": type_id,
|
| 194 |
+
"type": (type_names or {}).get(type_id) or type_id.replace("_", " "),
|
| 195 |
+
"status": status,
|
| 196 |
+
"statusLabel": label,
|
| 197 |
+
"statusTone": tone,
|
| 198 |
+
"date": str(r.get("createdAt") or "")[:10],
|
| 199 |
+
"targetItems": r.get("targetItems"),
|
| 200 |
+
"difficulty": MIXES.get(str(r.get("difficultyMix") or ""), ""),
|
| 201 |
+
"language": r.get("language") or "",
|
| 202 |
+
"licence": r.get("proposedLicense") or "",
|
| 203 |
+
"adminNote": r.get("adminNote") or "",
|
| 204 |
+
"samples": {"approved": approved, "pending": pending, "rejected": rejected, "wanted": wanted, "ok": bool(gate.get("ok"))},
|
| 205 |
+
"sampleText": sample_text,
|
| 206 |
+
# A rejected sample frees its slot, as DataBounty counts them.
|
| 207 |
+
"canAddSample": status in SAMPLES_OPEN and approved + pending < limit,
|
| 208 |
+
"poolId": r.get("mintedBountyId") or "",
|
| 209 |
+
}
|
web/app.css
CHANGED
|
@@ -1,424 +1,439 @@
|
|
| 1 |
-
/* SPDX-License-Identifier: Apache-2.0 */
|
| 2 |
-
/* The DataBounty console's look, for the Space. Tokens are the console's own
|
| 3 |
-
(apps/web/app/globals.css): ink on paper, white cards, lime accent, a dark sidebar. */
|
| 4 |
-
|
| 5 |
-
:root {
|
| 6 |
-
--ink: #14170f;
|
| 7 |
-
--ink-soft: #6b7360;
|
| 8 |
-
--paper: #f3f4f0;
|
| 9 |
-
--panel: #f7f8f4;
|
| 10 |
-
--line: #e4e6df;
|
| 11 |
-
--line-soft: #eef0e8;
|
| 12 |
-
--line-strong: #868d80;
|
| 13 |
-
--lime: #a3f60a;
|
| 14 |
-
--lime-ink: #4f7a00;
|
| 15 |
-
--dark: #0a0c0a;
|
| 16 |
-
--dark-line: #1c2a19;
|
| 17 |
-
--dark-line-soft: #2a3a26;
|
| 18 |
-
--dark-text: #eef2ec;
|
| 19 |
-
--dark-muted: #a4ad9c;
|
| 20 |
-
--dark-soft: #8a9382;
|
| 21 |
-
--dark-track: #152012;
|
| 22 |
-
--dark-nav-hover: #12160f;
|
| 23 |
-
--nav-active: #1a2810;
|
| 24 |
-
--karma: #7c5cc4;
|
| 25 |
-
--karma-soft: #f0ebfa;
|
| 26 |
-
--karma-border: #e2d8f6;
|
| 27 |
-
--accent-strong: #5f7a1e;
|
| 28 |
-
--accent-strong-hover: #4d6418;
|
| 29 |
-
--accent-soft: #f2f7e4;
|
| 30 |
-
--accent-soft-border: #d5deba;
|
| 31 |
-
--success: #1f8a5b;
|
| 32 |
-
--success-soft: #e6f5ec;
|
| 33 |
-
--success-border: #bfe6cd;
|
| 34 |
-
--danger: #c2334d;
|
| 35 |
-
--danger-soft: #fbe7ea;
|
| 36 |
-
--danger-border: #f2c4cd;
|
| 37 |
-
--warn: #8a6412;
|
| 38 |
-
--warn-soft: #fbf6e9;
|
| 39 |
-
--warn-border: #eadbb5;
|
| 40 |
-
--info: #2f5f96;
|
| 41 |
-
--info-soft: #eef4fb;
|
| 42 |
-
--info-border: #d4e3f5;
|
| 43 |
-
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 44 |
-
--font-mono: "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
* { box-sizing: border-box; }
|
| 48 |
-
html, body { margin: 0; }
|
| 49 |
-
body {
|
| 50 |
-
background: var(--paper);
|
| 51 |
-
color: var(--ink);
|
| 52 |
-
font-family: var(--font-sans);
|
| 53 |
-
font-size: 14px;
|
| 54 |
-
line-height: 1.5;
|
| 55 |
-
font-feature-settings: "tnum" 1;
|
| 56 |
-
-webkit-font-smoothing: antialiased;
|
| 57 |
-
}
|
| 58 |
-
a { color: inherit; }
|
| 59 |
-
a.plain { text-decoration: none; }
|
| 60 |
-
a.plain:hover { text-decoration: underline; text-underline-offset: 3px; }
|
| 61 |
-
a.pill.plain:hover { text-decoration: none; border-color: var(--karma); }
|
| 62 |
-
button, input, select, textarea { font: inherit; color: inherit; }
|
| 63 |
-
::selection { background: var(--lime); color: var(--dark); }
|
| 64 |
-
[hidden] { display: none !important; }
|
| 65 |
-
.i { width: 15px; height: 15px; flex: none; vertical-align: -2px; }
|
| 66 |
-
|
| 67 |
-
/* --- Shell ----------------------------------------------------------------------------------- */
|
| 68 |
-
.shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
|
| 69 |
-
.sidebar {
|
| 70 |
-
position: sticky; top: 0; height: 100vh;
|
| 71 |
-
display: flex; flex-direction: column;
|
| 72 |
-
background: var(--dark); color: var(--dark-text);
|
| 73 |
-
border-right: 1px solid var(--dark-line);
|
| 74 |
-
}
|
| 75 |
-
.brand {
|
| 76 |
-
display: flex; align-items: center; gap: 9px;
|
| 77 |
-
padding: 20px 20px 19px; border-bottom: 1px solid var(--dark-line);
|
| 78 |
-
text-decoration: none;
|
| 79 |
-
}
|
| 80 |
-
.brand img { width: 22px; height: 22px; }
|
| 81 |
-
.brand span { font-family: var(--font-mono); font-weight: 700; font-size: 17px; letter-spacing: -0.03em; color: #fff; }
|
| 82 |
-
.brand span b { color: var(--lime); font-weight: 700; }
|
| 83 |
-
.nav { display: grid; gap: 3px; padding: 16px 12px; }
|
| 84 |
-
.nav a {
|
| 85 |
-
display: flex; align-items: center; gap: 12px;
|
| 86 |
-
padding: 10px 12px; border-radius: 8px;
|
| 87 |
-
font-family: var(--font-mono); font-size: 13px; color: var(--dark-muted); text-decoration: none;
|
| 88 |
-
transition: background .12s, color .12s;
|
| 89 |
-
}
|
| 90 |
-
.nav a:hover { background: var(--dark-nav-hover); color: var(--dark-text); }
|
| 91 |
-
.nav a.active { background: var(--nav-active); color: var(--lime); }
|
| 92 |
-
.nav .badge { margin-left: auto; min-width: 18px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: var(--dark); font-size: 10px; font-weight: 700; line-height: 18px; text-align: center; }
|
| 93 |
-
.side-bottom { margin-top: auto; padding: 12px; border-top: 1px solid var(--dark-line); display: grid; gap: 8px; }
|
| 94 |
-
.side-karma { border: 1px solid var(--dark-line-soft); border-radius: 10px; padding: 11px 13px; background: #0c100b; }
|
| 95 |
-
.side-karma .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
| 96 |
-
.micro-dark { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--dark-soft); }
|
| 97 |
-
.side-karma .pts { font-family: var(--font-mono); font-weight: 600; font-size: 14px; color: #fff; }
|
| 98 |
-
.side-karma .to { font-family: var(--font-mono); font-size: 11px; color: var(--dark-soft); }
|
| 99 |
-
.chip-dark { font-family: var(--font-mono); font-size: 10px; padding: 1px 7px; border-radius: 999px; background: #1f2a17; color: var(--dark-muted); }
|
| 100 |
-
.bar-dark { height: 3px; margin-top: 9px; border-radius: 999px; background: var(--dark-track); overflow: hidden; }
|
| 101 |
-
.bar-dark span { display: block; height: 100%; background: var(--lime); }
|
| 102 |
-
.user-chip { display: flex; align-items: center; gap: 10px; padding: 6px 4px; }
|
| 103 |
-
.avatar {
|
| 104 |
-
width: 32px; height: 32px; flex: none; border-radius: 50%;
|
| 105 |
-
display: grid; place-items: center;
|
| 106 |
-
background: #161b13; border: 1px solid var(--dark-line-soft);
|
| 107 |
-
font-family: var(--font-mono); font-weight: 600; font-size: 13px; color: #fff;
|
| 108 |
-
}
|
| 109 |
-
.user-chip .who { min-width: 0; }
|
| 110 |
-
.user-chip b { display: block; font-family: var(--font-mono); font-size: 12.5px; font-weight: 600; color: #fff; }
|
| 111 |
-
.user-chip span { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--dark-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 112 |
-
.side-link { border: 0; background: none; padding: 4px; color: var(--dark-soft); font-family: var(--font-mono); font-size: 11.5px; text-align: left; cursor: pointer; }
|
| 113 |
-
.side-link:hover { color: var(--lime); }
|
| 114 |
-
.side-signin { display: grid; gap: 8px; padding: 4px; }
|
| 115 |
-
.side-signin p { margin: 0; font-size: 12px; color: var(--dark-soft); line-height: 1.45; }
|
| 116 |
-
.top-action { display: none; }
|
| 117 |
-
|
| 118 |
-
.main { min-width: 0; }
|
| 119 |
-
.page { max-width: 1120px; padding: 34px 40px 72px; animation: fade .18s ease-out; }
|
| 120 |
-
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
| 121 |
-
@media (prefers-reduced-motion: reduce) { .page { animation: none; } }
|
| 122 |
-
|
| 123 |
-
/* --- Type ------------------------------------------------------------------------------------- */
|
| 124 |
-
.page-head { margin-bottom: 22px; }
|
| 125 |
-
h1.title { margin: 0 0 6px; font-family: var(--font-mono); font-weight: 600; font-size: 23px; letter-spacing: -0.02em; overflow-wrap: anywhere; }
|
| 126 |
-
.sub { margin: 0; color: var(--ink-soft); font-size: 14px; }
|
| 127 |
-
h2.section { margin: 34px 0 4px; font-family: var(--font-mono); font-weight: 600; font-size: 16px; letter-spacing: -0.01em; }
|
| 128 |
-
.section-sub { margin: 0 0 14px; color: var(--ink-soft); font-size: 13px; }
|
| 129 |
-
.micro { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-soft); }
|
| 130 |
-
.mono { font-family: var(--font-mono); }
|
| 131 |
-
.small { font-size: 11.5px; }
|
| 132 |
-
.muted { color: var(--ink-soft); }
|
| 133 |
-
.strong { font-weight: 600; }
|
| 134 |
-
.karma-text { color: var(--karma); }
|
| 135 |
-
.back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 14px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); text-decoration: none; }
|
| 136 |
-
.back:hover { color: var(--ink); }
|
| 137 |
-
|
| 138 |
-
/* --- Surfaces ----------------------------------------------------------------------------------- */
|
| 139 |
-
.card { background: #fff; border: 1px solid var(--line); border-radius: 14px; }
|
| 140 |
-
.pad { padding: 20px 22px; }
|
| 141 |
-
.stack { display: grid; gap: 12px; }
|
| 142 |
-
.row { display: flex; align-items: center; gap: 10px; }
|
| 143 |
-
.between { justify-content: space-between; }
|
| 144 |
-
.wrap { flex-wrap: wrap; }
|
| 145 |
-
.grow { flex: 1; min-width: 0; }
|
| 146 |
-
|
| 147 |
-
.pill {
|
| 148 |
-
display: inline-flex; align-items: center; gap: 5px;
|
| 149 |
-
padding: 1px 9px; border-radius: 999px; border: 1px solid var(--line);
|
| 150 |
-
background: var(--panel); color: var(--ink-soft);
|
| 151 |
-
font-family: var(--font-mono); font-size: 11px; line-height: 1.7; white-space: nowrap;
|
| 152 |
-
}
|
| 153 |
-
.pill-karma, .pill-review { background: var(--karma-soft); border-color: var(--karma-border); color: var(--karma); }
|
| 154 |
-
.pill-ok { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
| 155 |
-
.pill-warn { background: var(--warn-soft); border-color: var(--warn-border); color: var(--warn); }
|
| 156 |
-
.pill-bad { background: var(--danger-soft); border-color: var(--danger-border); color: var(--danger); }
|
| 157 |
-
.pill-info { background: var(--info-soft); border-color: var(--info-border); color: var(--info); }
|
| 158 |
-
.pill-lime { background: var(--accent-soft); border-color: var(--accent-soft-border); color: var(--accent-strong); }
|
| 159 |
-
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; display: inline-block; }
|
| 160 |
-
|
| 161 |
-
.btn {
|
| 162 |
-
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
| 163 |
-
padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line-strong);
|
| 164 |
-
background: #fff; color: var(--ink); text-decoration: none; cursor: pointer;
|
| 165 |
-
font-family: var(--font-mono); font-size: 12.5px; font-weight: 500; white-space: nowrap;
|
| 166 |
-
transition: background .12s, border-color .12s, opacity .12s;
|
| 167 |
-
}
|
| 168 |
-
.btn:hover { border-color: var(--ink); }
|
| 169 |
-
.btn-primary { background: var(--ink); border-color: var(--ink); color: var(--lime); }
|
| 170 |
-
.btn-primary:hover { background: #000; border-color: #000; }
|
| 171 |
-
.btn-submit { background: var(--accent-strong); border-color: var(--accent-strong); color: #fff; }
|
| 172 |
-
.btn-submit:hover { background: var(--accent-strong-hover); border-color: var(--accent-strong-hover); }
|
| 173 |
-
.btn-lime { background: var(--lime); border-color: var(--lime); color: var(--dark); }
|
| 174 |
-
.btn-lime:hover { background: #b8ff3d; border-color: #b8ff3d; }
|
| 175 |
-
.btn-sm { padding: 6px 11px; font-size: 11.5px; }
|
| 176 |
-
.btn-wide { width: 100%; }
|
| 177 |
-
.btn[disabled], .btn[aria-disabled="true"] { opacity: .45; cursor: not-allowed; pointer-events: none; }
|
| 178 |
-
|
| 179 |
-
.input, .select, .textarea {
|
| 180 |
-
width: 100%; padding: 10px 12px; border-radius: 8px;
|
| 181 |
-
border: 1px solid var(--line); background: #fff; outline: none;
|
| 182 |
-
font-size: 14px; transition: border-color .12s, box-shadow .12s;
|
| 183 |
-
}
|
| 184 |
-
.input::placeholder, .textarea::placeholder { font-family: var(--font-mono); font-size: 12.5px; color: #9aa08d; }
|
| 185 |
-
.input:focus, .select:focus, .textarea:focus { border-color: var(--ink); box-shadow: 0 0 0 3px rgba(20, 23, 15, .06); }
|
| 186 |
-
.select { appearance: none; padding-right: 34px; font-family: var(--font-mono); font-size: 12.5px; cursor: pointer;
|
| 187 |
-
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7360' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 12px center; }
|
| 188 |
-
.textarea { resize: vertical; min-height: 92px; }
|
| 189 |
-
.textarea.code {
|
| 190 |
-
min-height: 170px; background: var(--panel); tab-size: 4;
|
| 191 |
-
font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; white-space: pre;
|
| 192 |
-
}
|
| 193 |
-
.search { position: relative; }
|
| 194 |
-
.search .i { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ink-soft); }
|
| 195 |
-
.search .input { padding-left: 34px; font-family: var(--font-mono); font-size: 12.5px; }
|
| 196 |
-
|
| 197 |
-
.progress { height: 4px; border-radius: 999px; background: var(--line-soft); overflow: hidden; }
|
| 198 |
-
.progress span { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--ink); }
|
| 199 |
-
|
| 200 |
-
/* --- Workspace ------------------------------------------------------------------------------------- */
|
| 201 |
-
.karma-card { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 22px; padding: 20px 22px; }
|
| 202 |
-
.karma-icon { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; background: var(--karma-soft); color: var(--karma); }
|
| 203 |
-
.karma-icon .i { width: 19px; height: 19px; }
|
| 204 |
-
.karma-num { display: flex; align-items: baseline; gap: 7px; }
|
| 205 |
-
.karma-num b { font-family: var(--font-mono); font-size: 26px; font-weight: 600; line-height: 1; }
|
| 206 |
-
.chip { padding: 1px 8px; border-radius: 6px; background: var(--line-soft); font-family: var(--font-mono); font-size: 11px; color: var(--ink); }
|
| 207 |
-
.karma-next .row { justify-content: space-between; margin-bottom: 7px; }
|
| 208 |
-
.stats { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 14px; overflow: hidden; }
|
| 209 |
-
.stat { padding: 14px 16px; border-left: 1px solid var(--line); border-bottom: 2px solid transparent; }
|
| 210 |
-
.stat:first-child { border-left: 0; }
|
| 211 |
-
.stat .micro { display: flex; align-items: center; gap: 6px; }
|
| 212 |
-
.stat .micro .i { width: 12px; height: 12px; }
|
| 213 |
-
.stat b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 21px; font-weight: 600; line-height: 1.1; }
|
| 214 |
-
.stat-ok { border-bottom-color: #8fbb3a; } .stat-ok b { color: var(--accent-strong); }
|
| 215 |
-
.stat-warn { border-bottom-color: #d9a43a; } .stat-warn b { color: var(--warn); }
|
| 216 |
-
|
| 217 |
-
.signin-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 20px; padding: 22px 24px; }
|
| 218 |
-
.signin-card img { width: 40px; height: 40px; }
|
| 219 |
-
.signin-card h2 { margin: 0 0 3px; font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
|
| 220 |
-
.signin-card p { margin: 0; color: var(--ink-soft); font-size: 13.5px; }
|
| 221 |
-
.signin-actions { display: grid; gap: 6px; justify-items: end; }
|
| 222 |
-
.signin-actions .note { font-size: 11.5px; color: var(--ink-soft); max-width: 260px; text-align: right; }
|
| 223 |
-
.setup-card { padding: 20px 22px; }
|
| 224 |
-
.setup-card h2 { margin: 0 0 4px; font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
|
| 225 |
-
.setup-card p { margin: 0 0 14px; color: var(--ink-soft); font-size: 13.5px; }
|
| 226 |
-
.setup-card .row .input { max-width: 280px; }
|
| 227 |
-
|
| 228 |
-
.filters { display: grid; grid-template-columns: minmax(200px, 320px) 200px 1fr; gap: 8px; align-items: center; margin-bottom: 12px; }
|
| 229 |
-
.filters .count { justify-self: end; font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-soft); }
|
| 230 |
-
.rows { display: grid; gap: 12px; }
|
| 231 |
-
.pool-row, .sub-row {
|
| 232 |
-
position: relative; display: grid; gap: 9px; padding: 18px 50px 18px 20px;
|
| 233 |
-
text-decoration: none; transition: border-color .12s, box-shadow .12s;
|
| 234 |
-
}
|
| 235 |
-
.pool-row:hover, .sub-row:hover { border-color: #cfd3c6; box-shadow: 0 1px 2px rgba(20, 23, 15, .04); }
|
| 236 |
-
.pool-row .head, .sub-row .head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
| 237 |
-
.pool-title, .sub-title { font-weight: 600; font-size: 14.5px; }
|
| 238 |
-
.pool-row .cap { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; }
|
| 239 |
-
.pool-row .cap .progress { height: 3px; }
|
| 240 |
-
.row-arrow { position: absolute; right: 20px; top: 20px; color: var(--ink-soft); }
|
| 241 |
-
.tabs-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
| 242 |
-
.tabs-pills button {
|
| 243 |
-
padding: 5px 13px; border-radius: 999px; border: 1px solid var(--line); background: #fff;
|
| 244 |
-
font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); cursor: pointer;
|
| 245 |
-
}
|
| 246 |
-
.tabs-pills button.on { background: var(--ink); border-color: var(--ink); color: var(--lime); }
|
| 247 |
-
.empty { padding: 26px; text-align: center; color: var(--ink-soft); font-size: 13.5px; }
|
| 248 |
-
.btn.more { justify-self: center; margin-top: 2px; }
|
| 249 |
-
|
| 250 |
-
/* --- Pool page ----------------------------------------------------------------------------------------- */
|
| 251 |
-
.cap-card { padding: 18px 22px; }
|
| 252 |
-
.cap-card .row { justify-content: space-between; flex-wrap: wrap; }
|
| 253 |
-
.contract { margin-top: 14px; padding: 22px; }
|
| 254 |
-
.contract h3 { margin: 4px 0 10px; font-size: 16px; font-weight: 600; }
|
| 255 |
-
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
| 256 |
-
.tip { margin: 12px 0 0; color: var(--ink-soft); font-size: 13.5px; }
|
| 257 |
-
.contract-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 16px; }
|
| 258 |
-
.mini { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; background: var(--panel); }
|
| 259 |
-
.mini .micro { margin-bottom: 8px; }
|
| 260 |
-
.kv { display: flex; justify-content: space-between; gap: 14px; padding: 4px 0; font-size: 13.5px; }
|
| 261 |
-
.kv span:last-child { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-soft); text-align: right; }
|
| 262 |
-
.steps { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 13px; color: var(--ink-soft); }
|
| 263 |
-
.steps b { color: var(--ink); font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
|
| 264 |
-
details.disclose { margin-top: 12px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
|
| 265 |
-
details.disclose > summary {
|
| 266 |
-
display: flex; align-items: center; gap: 8px; padding: 11px 14px; cursor: pointer; list-style: none;
|
| 267 |
-
font-family: var(--font-mono); font-size: 12.5px;
|
| 268 |
-
}
|
| 269 |
-
details.disclose > summary::-webkit-details-marker { display: none; }
|
| 270 |
-
details.disclose > summary::after { content: "+"; margin-left: auto; color: var(--ink-soft); }
|
| 271 |
-
details.disclose[open] > summary::after { content: "−"; }
|
| 272 |
-
details.disclose > .body { padding: 2px 14px 14px; border-top: 1px solid var(--line-soft); }
|
| 273 |
-
.spec { margin-top: 12px; }
|
| 274 |
-
.spec .micro { margin-bottom: 4px; }
|
| 275 |
-
.spec p { margin: 0; font-size: 13px; color: var(--ink-soft); line-height: 1.6; }
|
| 276 |
-
pre.block {
|
| 277 |
-
margin: 4px 0 0; padding: 11px 13px; border-radius: 8px; border: 1px solid var(--line-soft);
|
| 278 |
-
background: var(--panel); font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
|
| 279 |
-
white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow: auto;
|
| 280 |
-
}
|
| 281 |
-
.seg { display: inline-flex; gap: 3px; padding: 3px; margin: 22px 0 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
| 282 |
-
.seg button { padding: 6px 14px; border: 0; border-radius: 7px; background: transparent; font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-soft); cursor: pointer; }
|
| 283 |
-
.seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 2px rgba(20, 23, 15, .1); }
|
| 284 |
-
.seg-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
| 285 |
-
.form { display: grid; gap: 18px; padding: 22px; }
|
| 286 |
-
.field { display: grid; gap: 7px; }
|
| 287 |
-
.field-label { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
| 288 |
-
.req { color: var(--danger); font-weight: 700; }
|
| 289 |
-
.opt { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-soft); }
|
| 290 |
-
.key-chip, .lang-chip { padding: 0 7px; border-radius: 5px; border: 1px solid var(--line); background: var(--panel); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-soft); line-height: 1.8; }
|
| 291 |
-
.lang-chip { border-color: var(--accent-soft-border); background: var(--accent-soft); color: var(--accent-strong); }
|
| 292 |
-
.hint { font-size: 12.5px; color: var(--ink-soft); }
|
| 293 |
-
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
| 294 |
-
.attest { display: grid; gap: 9px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
| 295 |
-
.attest label { display: flex; align-items: flex-start; gap: 10px; font-size: 13.5px; cursor: pointer; }
|
| 296 |
-
.attest input { width: 15px; height: 15px; margin-top: 2px; accent-color: var(--accent-strong); }
|
| 297 |
-
.stage-bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 10px; background: var(--ink); color: #dfe5d8; font-family: var(--font-mono); font-size: 12px; }
|
| 298 |
-
.stage-bar .i { color: var(--lime); }
|
| 299 |
-
.form-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 4px; border-top: 1px solid var(--line-soft); }
|
| 300 |
-
.dropzone {
|
| 301 |
-
display: grid; place-items: center; gap: 6px; padding: 28px; margin-top: 12px;
|
| 302 |
-
border: 1.5px dashed var(--line-strong); border-radius: 12px; background: var(--panel);
|
| 303 |
-
color: var(--ink-soft); font-family: var(--font-mono); font-size: 12.5px; cursor: pointer; text-align: center;
|
| 304 |
-
}
|
| 305 |
-
.dropzone:hover, .dropzone.over { border-color: var(--ink); color: var(--ink); }
|
| 306 |
-
.dropzone input { display: none; }
|
| 307 |
-
table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 308 |
-
table.list th { padding: 10px 14px; text-align: left; font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-soft); border-bottom: 1px solid var(--line); background: var(--panel); }
|
| 309 |
-
table.list td { padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
| 310 |
-
table.list tr:last-child td { border-bottom: 0; }
|
| 311 |
-
table.list tr.click { cursor: pointer; }
|
| 312 |
-
table.list tr.click:hover td { background: var(--panel); }
|
| 313 |
-
.table-wrap { overflow: auto; max-height: 420px; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
|
| 314 |
-
.ok-text { color: var(--success); } .bad-text { color: var(--danger); }
|
| 315 |
-
|
| 316 |
-
/* --- Notices, checks, toast ----------------------------------------------------------------------------------- */
|
| 317 |
-
.notice { padding: 12px 16px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); font-size: 13.5px; }
|
| 318 |
-
.notice b { font-family: var(--font-mono); font-size: 12.5px; }
|
| 319 |
-
.notice-ok { background: var(--success-soft); border-color: var(--success-border); }
|
| 320 |
-
.notice-warn { background: var(--warn-soft); border-color: var(--warn-border); }
|
| 321 |
-
.notice-bad { background: var(--danger-soft); border-color: var(--danger-border); }
|
| 322 |
-
.checks { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 4px; }
|
| 323 |
-
.check { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-top: 1px solid var(--line-soft); }
|
| 324 |
-
.check:first-child { border-top: 0; }
|
| 325 |
-
.check-icon { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; }
|
| 326 |
-
.check-icon .i { width: 12px; height: 12px; }
|
| 327 |
-
.check-pass .check-icon { background: var(--success-soft); color: var(--success); }
|
| 328 |
-
.check-fail .check-icon { background: var(--danger-soft); color: var(--danger); }
|
| 329 |
-
.check-wait .check-icon { background: var(--line-soft); color: var(--ink-soft); }
|
| 330 |
-
.check-note .check-icon { background: var(--warn-soft); color: var(--warn); }
|
| 331 |
-
.check b { display: block; font-size: 13.5px; font-weight: 600; }
|
| 332 |
-
.check span { font-size: 12.5px; color: var(--ink-soft); }
|
| 333 |
-
.toast {
|
| 334 |
-
position: fixed; right: 20px; bottom: 20px; z-index: 50; max-width: 380px;
|
| 335 |
-
padding: 12px 16px; border-radius: 10px; background: var(--ink); color: #fff; font-size: 13px;
|
| 336 |
-
box-shadow: 0 10px 30px rgba(0, 0, 0, .18);
|
| 337 |
-
}
|
| 338 |
-
.skeleton { height: 92px; border-radius: 14px; background: linear-gradient(90deg, #fff 0%, #f6f7f2 50%, #fff 100%); background-size: 200% 100%; border: 1px solid var(--line); animation: shimmer 1.2s infinite; }
|
| 339 |
-
@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
| 340 |
-
|
| 341 |
-
/* --- Other pages ------------------------------------------------------------------------------------------------ */
|
| 342 |
-
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
|
| 343 |
-
.tile { padding: 16px 18px; }
|
| 344 |
-
.tile b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 22px; font-weight: 600; }
|
| 345 |
-
.tiers { display: flex; flex-wrap: wrap; gap: 8px; }
|
| 346 |
-
.how { margin: 0; padding: 0; list-style: none; counter-reset: how; display: grid; gap: 10px; }
|
| 347 |
-
.how li { counter-increment: how; display: grid; grid-template-columns: 30px 1fr; gap: 12px; align-items: start; font-size: 14px; }
|
| 348 |
-
.how li::before { content: counter(how); width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--ink); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
|
| 349 |
-
.code-box { position: relative; }
|
| 350 |
-
.code-box pre.block { padding-right: 76px; }
|
| 351 |
-
.code-box .btn { position: absolute; right: 8px; top: 8px; }
|
| 352 |
-
.profile-head { display: flex; align-items: center; gap: 16px; }
|
| 353 |
-
.profile-head .avatar { width: 52px; height: 52px; font-size: 18px; background: var(--ink); color: var(--lime); border: 0; }
|
| 354 |
-
|
| 355 |
-
/* --- Reviewing ------------------------------------------------------------------------------------------------- */
|
| 356 |
-
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
| 357 |
-
.btn-danger:hover { background: #a82a42; border-color: #a82a42; }
|
| 358 |
-
.pool-row.has-action { padding-right: 104px; }
|
| 359 |
-
.row-action { position: absolute; right: 18px; top: 15px; }
|
| 360 |
-
.audit-grid { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 14px; margin-top: 14px; align-items: start; }
|
| 361 |
-
.item-list { display: grid; gap: 2px; padding: 8px; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
|
| 362 |
-
.item-list button {
|
| 363 |
-
display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 9px; border: 0; border-radius: 8px;
|
| 364 |
-
background: none; color: var(--ink-soft); font-size: 13px; text-align: left; cursor: pointer;
|
| 365 |
-
}
|
| 366 |
-
.item-list button:hover { background: var(--panel); color: var(--ink); }
|
| 367 |
-
.item-list button.on { background: var(--nav-active); color: var(--lime); }
|
| 368 |
-
.item-list .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 369 |
-
.mark { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--line-soft); color: var(--ink-soft); font-family: var(--font-mono); font-size: 10.5px; }
|
| 370 |
-
.mark .i { width: 12px; height: 12px; }
|
| 371 |
-
.mark.ok { background: var(--success-soft); color: var(--success); }
|
| 372 |
-
.mark.flagged { background: var(--danger-soft); color: var(--danger); }
|
| 373 |
-
.item-card { display: grid; gap: 16px; padding: 22px; }
|
| 374 |
-
.item-card h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
| 375 |
-
.item-card pre.block { margin-top: 6px; }
|
| 376 |
-
.files { list-style: none; margin: 6px 0 8px; padding: 0; display: grid; gap: 4px; font-size: 13px; }
|
| 377 |
-
.files li { display: flex; align-items: center; gap: 7px; }
|
| 378 |
-
.decide { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
| 379 |
-
.decide .reject { display: grid; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
|
| 380 |
-
.item-nav { display: flex; justify-content: space-between; gap: 8px; }
|
| 381 |
-
.sponsor-card { margin-top: 14px; padding: 20px 22px; border-color: var(--karma-border); }
|
| 382 |
-
.sponsor-card details.disclose > summary { gap: 10px; }
|
| 383 |
-
|
| 384 |
-
/* --- Small screens ------------------------------------------------------------------------------------------------ */
|
| 385 |
-
@media (max-width: 600px) { .hide-sm { display: none; } }
|
| 386 |
-
@media (max-width: 900px) {
|
| 387 |
-
.shell { grid-template-columns: minmax(0, 1fr); }
|
| 388 |
-
.sidebar { position: sticky; top: 0; z-index: 20; min-width: 0; height: auto; flex-direction: row; align-items: center; border-right: 0; border-bottom: 1px solid var(--dark-line); }
|
| 389 |
-
.brand { border-bottom: 0; padding: 12px 14px; }
|
| 390 |
-
.brand span { font-size: 15px; }
|
| 391 |
-
.nav { display: flex; overflow-x: auto; padding: 8px; gap: 2px; flex: 1; min-width: 0; scrollbar-width: none; }
|
| 392 |
-
.nav a { padding: 8px 10px; font-size: 12px; white-space: nowrap; }
|
| 393 |
-
.nav a .i { display: none; }
|
| 394 |
-
.side-bottom { display: none; }
|
| 395 |
-
.top-action {
|
| 396 |
-
display: inline-flex; align-items: center; flex: none; margin-right: 10px; padding: 7px 10px;
|
| 397 |
-
border: 1px solid var(--dark-line-soft); border-radius: 8px; background: none; cursor: pointer;
|
| 398 |
-
color: var(--lime); font-family: var(--font-mono); font-size: 12px;
|
| 399 |
-
}
|
| 400 |
-
.page { padding: 22px 16px 60px; }
|
| 401 |
-
.karma-card { grid-template-columns: auto 1fr; }
|
| 402 |
-
.karma-next { grid-column: 1 / -1; }
|
| 403 |
-
.stats { grid-template-columns: repeat(2, 1fr); }
|
| 404 |
-
.stat:nth-child(3) { border-left: 0; }
|
| 405 |
-
.filters { grid-template-columns: 1fr 1fr; }
|
| 406 |
-
.filters .search { grid-column: 1 / -1; }
|
| 407 |
-
.signin-card { grid-template-columns: auto 1fr; }
|
| 408 |
-
.signin-actions { grid-column: 1 / -1; justify-items: stretch; }
|
| 409 |
-
.signin-actions .note { text-align: left; max-width: none; }
|
| 410 |
-
.contract-grid, .two { grid-template-columns: 1fr; }
|
| 411 |
-
.pool-row .cap { grid-template-columns: 1fr auto; }
|
| 412 |
-
.pool-row .cap .progress { grid-column: 1 / -1; grid-row: 2; }
|
| 413 |
-
h1.title { font-size: 20px; }
|
| 414 |
-
.kv { flex-direction: column; gap: 0; }
|
| 415 |
-
.kv span:last-child { text-align: left; }
|
| 416 |
-
.form, .contract { padding: 18px 16px; }
|
| 417 |
-
.form-foot { flex-direction: column; align-items: stretch; }
|
| 418 |
-
.form-foot .btn { width: 100%; }
|
| 419 |
-
.audit-grid { grid-template-columns: minmax(0, 1fr); }
|
| 420 |
-
.item-list { position: static; max-height: none; display: flex; overflow-x: auto; }
|
| 421 |
-
.item-list button { width: auto; flex: none; }
|
| 422 |
-
.item-list .t { display: none; }
|
| 423 |
-
.item-card { padding: 18px 16px; }
|
| 424 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* SPDX-License-Identifier: Apache-2.0 */
|
| 2 |
+
/* The DataBounty console's look, for the Space. Tokens are the console's own
|
| 3 |
+
(apps/web/app/globals.css): ink on paper, white cards, lime accent, a dark sidebar. */
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--ink: #14170f;
|
| 7 |
+
--ink-soft: #6b7360;
|
| 8 |
+
--paper: #f3f4f0;
|
| 9 |
+
--panel: #f7f8f4;
|
| 10 |
+
--line: #e4e6df;
|
| 11 |
+
--line-soft: #eef0e8;
|
| 12 |
+
--line-strong: #868d80;
|
| 13 |
+
--lime: #a3f60a;
|
| 14 |
+
--lime-ink: #4f7a00;
|
| 15 |
+
--dark: #0a0c0a;
|
| 16 |
+
--dark-line: #1c2a19;
|
| 17 |
+
--dark-line-soft: #2a3a26;
|
| 18 |
+
--dark-text: #eef2ec;
|
| 19 |
+
--dark-muted: #a4ad9c;
|
| 20 |
+
--dark-soft: #8a9382;
|
| 21 |
+
--dark-track: #152012;
|
| 22 |
+
--dark-nav-hover: #12160f;
|
| 23 |
+
--nav-active: #1a2810;
|
| 24 |
+
--karma: #7c5cc4;
|
| 25 |
+
--karma-soft: #f0ebfa;
|
| 26 |
+
--karma-border: #e2d8f6;
|
| 27 |
+
--accent-strong: #5f7a1e;
|
| 28 |
+
--accent-strong-hover: #4d6418;
|
| 29 |
+
--accent-soft: #f2f7e4;
|
| 30 |
+
--accent-soft-border: #d5deba;
|
| 31 |
+
--success: #1f8a5b;
|
| 32 |
+
--success-soft: #e6f5ec;
|
| 33 |
+
--success-border: #bfe6cd;
|
| 34 |
+
--danger: #c2334d;
|
| 35 |
+
--danger-soft: #fbe7ea;
|
| 36 |
+
--danger-border: #f2c4cd;
|
| 37 |
+
--warn: #8a6412;
|
| 38 |
+
--warn-soft: #fbf6e9;
|
| 39 |
+
--warn-border: #eadbb5;
|
| 40 |
+
--info: #2f5f96;
|
| 41 |
+
--info-soft: #eef4fb;
|
| 42 |
+
--info-border: #d4e3f5;
|
| 43 |
+
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 44 |
+
--font-mono: "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
* { box-sizing: border-box; }
|
| 48 |
+
html, body { margin: 0; }
|
| 49 |
+
body {
|
| 50 |
+
background: var(--paper);
|
| 51 |
+
color: var(--ink);
|
| 52 |
+
font-family: var(--font-sans);
|
| 53 |
+
font-size: 14px;
|
| 54 |
+
line-height: 1.5;
|
| 55 |
+
font-feature-settings: "tnum" 1;
|
| 56 |
+
-webkit-font-smoothing: antialiased;
|
| 57 |
+
}
|
| 58 |
+
a { color: inherit; }
|
| 59 |
+
a.plain { text-decoration: none; }
|
| 60 |
+
a.plain:hover { text-decoration: underline; text-underline-offset: 3px; }
|
| 61 |
+
a.pill.plain:hover { text-decoration: none; border-color: var(--karma); }
|
| 62 |
+
button, input, select, textarea { font: inherit; color: inherit; }
|
| 63 |
+
::selection { background: var(--lime); color: var(--dark); }
|
| 64 |
+
[hidden] { display: none !important; }
|
| 65 |
+
.i { width: 15px; height: 15px; flex: none; vertical-align: -2px; }
|
| 66 |
+
|
| 67 |
+
/* --- Shell ----------------------------------------------------------------------------------- */
|
| 68 |
+
.shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
|
| 69 |
+
.sidebar {
|
| 70 |
+
position: sticky; top: 0; height: 100vh;
|
| 71 |
+
display: flex; flex-direction: column;
|
| 72 |
+
background: var(--dark); color: var(--dark-text);
|
| 73 |
+
border-right: 1px solid var(--dark-line);
|
| 74 |
+
}
|
| 75 |
+
.brand {
|
| 76 |
+
display: flex; align-items: center; gap: 9px;
|
| 77 |
+
padding: 20px 20px 19px; border-bottom: 1px solid var(--dark-line);
|
| 78 |
+
text-decoration: none;
|
| 79 |
+
}
|
| 80 |
+
.brand img { width: 22px; height: 22px; }
|
| 81 |
+
.brand span { font-family: var(--font-mono); font-weight: 700; font-size: 17px; letter-spacing: -0.03em; color: #fff; }
|
| 82 |
+
.brand span b { color: var(--lime); font-weight: 700; }
|
| 83 |
+
.nav { display: grid; gap: 3px; padding: 16px 12px; }
|
| 84 |
+
.nav a {
|
| 85 |
+
display: flex; align-items: center; gap: 12px;
|
| 86 |
+
padding: 10px 12px; border-radius: 8px;
|
| 87 |
+
font-family: var(--font-mono); font-size: 13px; color: var(--dark-muted); text-decoration: none;
|
| 88 |
+
transition: background .12s, color .12s;
|
| 89 |
+
}
|
| 90 |
+
.nav a:hover { background: var(--dark-nav-hover); color: var(--dark-text); }
|
| 91 |
+
.nav a.active { background: var(--nav-active); color: var(--lime); }
|
| 92 |
+
.nav .badge { margin-left: auto; min-width: 18px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: var(--dark); font-size: 10px; font-weight: 700; line-height: 18px; text-align: center; }
|
| 93 |
+
.side-bottom { margin-top: auto; padding: 12px; border-top: 1px solid var(--dark-line); display: grid; gap: 8px; }
|
| 94 |
+
.side-karma { border: 1px solid var(--dark-line-soft); border-radius: 10px; padding: 11px 13px; background: #0c100b; }
|
| 95 |
+
.side-karma .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
| 96 |
+
.micro-dark { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--dark-soft); }
|
| 97 |
+
.side-karma .pts { font-family: var(--font-mono); font-weight: 600; font-size: 14px; color: #fff; }
|
| 98 |
+
.side-karma .to { font-family: var(--font-mono); font-size: 11px; color: var(--dark-soft); }
|
| 99 |
+
.chip-dark { font-family: var(--font-mono); font-size: 10px; padding: 1px 7px; border-radius: 999px; background: #1f2a17; color: var(--dark-muted); }
|
| 100 |
+
.bar-dark { height: 3px; margin-top: 9px; border-radius: 999px; background: var(--dark-track); overflow: hidden; }
|
| 101 |
+
.bar-dark span { display: block; height: 100%; background: var(--lime); }
|
| 102 |
+
.user-chip { display: flex; align-items: center; gap: 10px; padding: 6px 4px; }
|
| 103 |
+
.avatar {
|
| 104 |
+
width: 32px; height: 32px; flex: none; border-radius: 50%;
|
| 105 |
+
display: grid; place-items: center;
|
| 106 |
+
background: #161b13; border: 1px solid var(--dark-line-soft);
|
| 107 |
+
font-family: var(--font-mono); font-weight: 600; font-size: 13px; color: #fff;
|
| 108 |
+
}
|
| 109 |
+
.user-chip .who { min-width: 0; }
|
| 110 |
+
.user-chip b { display: block; font-family: var(--font-mono); font-size: 12.5px; font-weight: 600; color: #fff; }
|
| 111 |
+
.user-chip span { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--dark-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 112 |
+
.side-link { border: 0; background: none; padding: 4px; color: var(--dark-soft); font-family: var(--font-mono); font-size: 11.5px; text-align: left; cursor: pointer; }
|
| 113 |
+
.side-link:hover { color: var(--lime); }
|
| 114 |
+
.side-signin { display: grid; gap: 8px; padding: 4px; }
|
| 115 |
+
.side-signin p { margin: 0; font-size: 12px; color: var(--dark-soft); line-height: 1.45; }
|
| 116 |
+
.top-action { display: none; }
|
| 117 |
+
|
| 118 |
+
.main { min-width: 0; }
|
| 119 |
+
.page { max-width: 1120px; padding: 34px 40px 72px; animation: fade .18s ease-out; }
|
| 120 |
+
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
| 121 |
+
@media (prefers-reduced-motion: reduce) { .page { animation: none; } }
|
| 122 |
+
|
| 123 |
+
/* --- Type ------------------------------------------------------------------------------------- */
|
| 124 |
+
.page-head { margin-bottom: 22px; }
|
| 125 |
+
h1.title { margin: 0 0 6px; font-family: var(--font-mono); font-weight: 600; font-size: 23px; letter-spacing: -0.02em; overflow-wrap: anywhere; }
|
| 126 |
+
.sub { margin: 0; color: var(--ink-soft); font-size: 14px; }
|
| 127 |
+
h2.section { margin: 34px 0 4px; font-family: var(--font-mono); font-weight: 600; font-size: 16px; letter-spacing: -0.01em; }
|
| 128 |
+
.section-sub { margin: 0 0 14px; color: var(--ink-soft); font-size: 13px; }
|
| 129 |
+
.micro { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-soft); }
|
| 130 |
+
.mono { font-family: var(--font-mono); }
|
| 131 |
+
.small { font-size: 11.5px; }
|
| 132 |
+
.muted { color: var(--ink-soft); }
|
| 133 |
+
.strong { font-weight: 600; }
|
| 134 |
+
.karma-text { color: var(--karma); }
|
| 135 |
+
.back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 14px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); text-decoration: none; }
|
| 136 |
+
.back:hover { color: var(--ink); }
|
| 137 |
+
|
| 138 |
+
/* --- Surfaces ----------------------------------------------------------------------------------- */
|
| 139 |
+
.card { background: #fff; border: 1px solid var(--line); border-radius: 14px; }
|
| 140 |
+
.pad { padding: 20px 22px; }
|
| 141 |
+
.stack { display: grid; gap: 12px; }
|
| 142 |
+
.row { display: flex; align-items: center; gap: 10px; }
|
| 143 |
+
.between { justify-content: space-between; }
|
| 144 |
+
.wrap { flex-wrap: wrap; }
|
| 145 |
+
.grow { flex: 1; min-width: 0; }
|
| 146 |
+
|
| 147 |
+
.pill {
|
| 148 |
+
display: inline-flex; align-items: center; gap: 5px;
|
| 149 |
+
padding: 1px 9px; border-radius: 999px; border: 1px solid var(--line);
|
| 150 |
+
background: var(--panel); color: var(--ink-soft);
|
| 151 |
+
font-family: var(--font-mono); font-size: 11px; line-height: 1.7; white-space: nowrap;
|
| 152 |
+
}
|
| 153 |
+
.pill-karma, .pill-review { background: var(--karma-soft); border-color: var(--karma-border); color: var(--karma); }
|
| 154 |
+
.pill-ok { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
| 155 |
+
.pill-warn { background: var(--warn-soft); border-color: var(--warn-border); color: var(--warn); }
|
| 156 |
+
.pill-bad { background: var(--danger-soft); border-color: var(--danger-border); color: var(--danger); }
|
| 157 |
+
.pill-info { background: var(--info-soft); border-color: var(--info-border); color: var(--info); }
|
| 158 |
+
.pill-lime { background: var(--accent-soft); border-color: var(--accent-soft-border); color: var(--accent-strong); }
|
| 159 |
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; display: inline-block; }
|
| 160 |
+
|
| 161 |
+
.btn {
|
| 162 |
+
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
| 163 |
+
padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line-strong);
|
| 164 |
+
background: #fff; color: var(--ink); text-decoration: none; cursor: pointer;
|
| 165 |
+
font-family: var(--font-mono); font-size: 12.5px; font-weight: 500; white-space: nowrap;
|
| 166 |
+
transition: background .12s, border-color .12s, opacity .12s;
|
| 167 |
+
}
|
| 168 |
+
.btn:hover { border-color: var(--ink); }
|
| 169 |
+
.btn-primary { background: var(--ink); border-color: var(--ink); color: var(--lime); }
|
| 170 |
+
.btn-primary:hover { background: #000; border-color: #000; }
|
| 171 |
+
.btn-submit { background: var(--accent-strong); border-color: var(--accent-strong); color: #fff; }
|
| 172 |
+
.btn-submit:hover { background: var(--accent-strong-hover); border-color: var(--accent-strong-hover); }
|
| 173 |
+
.btn-lime { background: var(--lime); border-color: var(--lime); color: var(--dark); }
|
| 174 |
+
.btn-lime:hover { background: #b8ff3d; border-color: #b8ff3d; }
|
| 175 |
+
.btn-sm { padding: 6px 11px; font-size: 11.5px; }
|
| 176 |
+
.btn-wide { width: 100%; }
|
| 177 |
+
.btn[disabled], .btn[aria-disabled="true"] { opacity: .45; cursor: not-allowed; pointer-events: none; }
|
| 178 |
+
|
| 179 |
+
.input, .select, .textarea {
|
| 180 |
+
width: 100%; padding: 10px 12px; border-radius: 8px;
|
| 181 |
+
border: 1px solid var(--line); background: #fff; outline: none;
|
| 182 |
+
font-size: 14px; transition: border-color .12s, box-shadow .12s;
|
| 183 |
+
}
|
| 184 |
+
.input::placeholder, .textarea::placeholder { font-family: var(--font-mono); font-size: 12.5px; color: #9aa08d; }
|
| 185 |
+
.input:focus, .select:focus, .textarea:focus { border-color: var(--ink); box-shadow: 0 0 0 3px rgba(20, 23, 15, .06); }
|
| 186 |
+
.select { appearance: none; padding-right: 34px; font-family: var(--font-mono); font-size: 12.5px; cursor: pointer;
|
| 187 |
+
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7360' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 12px center; }
|
| 188 |
+
.textarea { resize: vertical; min-height: 92px; }
|
| 189 |
+
.textarea.code {
|
| 190 |
+
min-height: 170px; background: var(--panel); tab-size: 4;
|
| 191 |
+
font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; white-space: pre;
|
| 192 |
+
}
|
| 193 |
+
.search { position: relative; }
|
| 194 |
+
.search .i { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ink-soft); }
|
| 195 |
+
.search .input { padding-left: 34px; font-family: var(--font-mono); font-size: 12.5px; }
|
| 196 |
+
|
| 197 |
+
.progress { height: 4px; border-radius: 999px; background: var(--line-soft); overflow: hidden; }
|
| 198 |
+
.progress span { display: block; height: 100%; min-width: 3px; border-radius: inherit; background: var(--ink); }
|
| 199 |
+
|
| 200 |
+
/* --- Workspace ------------------------------------------------------------------------------------- */
|
| 201 |
+
.karma-card { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 22px; padding: 20px 22px; }
|
| 202 |
+
.karma-icon { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; background: var(--karma-soft); color: var(--karma); }
|
| 203 |
+
.karma-icon .i { width: 19px; height: 19px; }
|
| 204 |
+
.karma-num { display: flex; align-items: baseline; gap: 7px; }
|
| 205 |
+
.karma-num b { font-family: var(--font-mono); font-size: 26px; font-weight: 600; line-height: 1; }
|
| 206 |
+
.chip { padding: 1px 8px; border-radius: 6px; background: var(--line-soft); font-family: var(--font-mono); font-size: 11px; color: var(--ink); }
|
| 207 |
+
.karma-next .row { justify-content: space-between; margin-bottom: 7px; }
|
| 208 |
+
.stats { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 14px; overflow: hidden; }
|
| 209 |
+
.stat { padding: 14px 16px; border-left: 1px solid var(--line); border-bottom: 2px solid transparent; }
|
| 210 |
+
.stat:first-child { border-left: 0; }
|
| 211 |
+
.stat .micro { display: flex; align-items: center; gap: 6px; }
|
| 212 |
+
.stat .micro .i { width: 12px; height: 12px; }
|
| 213 |
+
.stat b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 21px; font-weight: 600; line-height: 1.1; }
|
| 214 |
+
.stat-ok { border-bottom-color: #8fbb3a; } .stat-ok b { color: var(--accent-strong); }
|
| 215 |
+
.stat-warn { border-bottom-color: #d9a43a; } .stat-warn b { color: var(--warn); }
|
| 216 |
+
|
| 217 |
+
.signin-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 20px; padding: 22px 24px; }
|
| 218 |
+
.signin-card img { width: 40px; height: 40px; }
|
| 219 |
+
.signin-card h2 { margin: 0 0 3px; font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
|
| 220 |
+
.signin-card p { margin: 0; color: var(--ink-soft); font-size: 13.5px; }
|
| 221 |
+
.signin-actions { display: grid; gap: 6px; justify-items: end; }
|
| 222 |
+
.signin-actions .note { font-size: 11.5px; color: var(--ink-soft); max-width: 260px; text-align: right; }
|
| 223 |
+
.setup-card { padding: 20px 22px; }
|
| 224 |
+
.setup-card h2 { margin: 0 0 4px; font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
|
| 225 |
+
.setup-card p { margin: 0 0 14px; color: var(--ink-soft); font-size: 13.5px; }
|
| 226 |
+
.setup-card .row .input { max-width: 280px; }
|
| 227 |
+
|
| 228 |
+
.filters { display: grid; grid-template-columns: minmax(200px, 320px) 200px 1fr; gap: 8px; align-items: center; margin-bottom: 12px; }
|
| 229 |
+
.filters .count { justify-self: end; font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-soft); }
|
| 230 |
+
.rows { display: grid; gap: 12px; }
|
| 231 |
+
.pool-row, .sub-row {
|
| 232 |
+
position: relative; display: grid; gap: 9px; padding: 18px 50px 18px 20px;
|
| 233 |
+
text-decoration: none; transition: border-color .12s, box-shadow .12s;
|
| 234 |
+
}
|
| 235 |
+
.pool-row:hover, .sub-row:hover { border-color: #cfd3c6; box-shadow: 0 1px 2px rgba(20, 23, 15, .04); }
|
| 236 |
+
.pool-row .head, .sub-row .head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
| 237 |
+
.pool-title, .sub-title { font-weight: 600; font-size: 14.5px; }
|
| 238 |
+
.pool-row .cap { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; }
|
| 239 |
+
.pool-row .cap .progress { height: 3px; }
|
| 240 |
+
.row-arrow { position: absolute; right: 20px; top: 20px; color: var(--ink-soft); }
|
| 241 |
+
.tabs-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
| 242 |
+
.tabs-pills button {
|
| 243 |
+
padding: 5px 13px; border-radius: 999px; border: 1px solid var(--line); background: #fff;
|
| 244 |
+
font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); cursor: pointer;
|
| 245 |
+
}
|
| 246 |
+
.tabs-pills button.on { background: var(--ink); border-color: var(--ink); color: var(--lime); }
|
| 247 |
+
.empty { padding: 26px; text-align: center; color: var(--ink-soft); font-size: 13.5px; }
|
| 248 |
+
.btn.more { justify-self: center; margin-top: 2px; }
|
| 249 |
+
|
| 250 |
+
/* --- Pool page ----------------------------------------------------------------------------------------- */
|
| 251 |
+
.cap-card { padding: 18px 22px; }
|
| 252 |
+
.cap-card .row { justify-content: space-between; flex-wrap: wrap; }
|
| 253 |
+
.contract { margin-top: 14px; padding: 22px; }
|
| 254 |
+
.contract h3 { margin: 4px 0 10px; font-size: 16px; font-weight: 600; }
|
| 255 |
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
| 256 |
+
.tip { margin: 12px 0 0; color: var(--ink-soft); font-size: 13.5px; }
|
| 257 |
+
.contract-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 16px; }
|
| 258 |
+
.mini { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; background: var(--panel); }
|
| 259 |
+
.mini .micro { margin-bottom: 8px; }
|
| 260 |
+
.kv { display: flex; justify-content: space-between; gap: 14px; padding: 4px 0; font-size: 13.5px; }
|
| 261 |
+
.kv span:last-child { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-soft); text-align: right; }
|
| 262 |
+
.steps { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 13px; color: var(--ink-soft); }
|
| 263 |
+
.steps b { color: var(--ink); font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
|
| 264 |
+
details.disclose { margin-top: 12px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
|
| 265 |
+
details.disclose > summary {
|
| 266 |
+
display: flex; align-items: center; gap: 8px; padding: 11px 14px; cursor: pointer; list-style: none;
|
| 267 |
+
font-family: var(--font-mono); font-size: 12.5px;
|
| 268 |
+
}
|
| 269 |
+
details.disclose > summary::-webkit-details-marker { display: none; }
|
| 270 |
+
details.disclose > summary::after { content: "+"; margin-left: auto; color: var(--ink-soft); }
|
| 271 |
+
details.disclose[open] > summary::after { content: "−"; }
|
| 272 |
+
details.disclose > .body { padding: 2px 14px 14px; border-top: 1px solid var(--line-soft); }
|
| 273 |
+
.spec { margin-top: 12px; }
|
| 274 |
+
.spec .micro { margin-bottom: 4px; }
|
| 275 |
+
.spec p { margin: 0; font-size: 13px; color: var(--ink-soft); line-height: 1.6; }
|
| 276 |
+
pre.block {
|
| 277 |
+
margin: 4px 0 0; padding: 11px 13px; border-radius: 8px; border: 1px solid var(--line-soft);
|
| 278 |
+
background: var(--panel); font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
|
| 279 |
+
white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow: auto;
|
| 280 |
+
}
|
| 281 |
+
.seg { display: inline-flex; gap: 3px; padding: 3px; margin: 22px 0 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
| 282 |
+
.seg button { padding: 6px 14px; border: 0; border-radius: 7px; background: transparent; font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-soft); cursor: pointer; }
|
| 283 |
+
.seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 2px rgba(20, 23, 15, .1); }
|
| 284 |
+
.seg-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
| 285 |
+
.form { display: grid; gap: 18px; padding: 22px; }
|
| 286 |
+
.field { display: grid; gap: 7px; }
|
| 287 |
+
.field-label { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
| 288 |
+
.req { color: var(--danger); font-weight: 700; }
|
| 289 |
+
.opt { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-soft); }
|
| 290 |
+
.key-chip, .lang-chip { padding: 0 7px; border-radius: 5px; border: 1px solid var(--line); background: var(--panel); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-soft); line-height: 1.8; }
|
| 291 |
+
.lang-chip { border-color: var(--accent-soft-border); background: var(--accent-soft); color: var(--accent-strong); }
|
| 292 |
+
.hint { font-size: 12.5px; color: var(--ink-soft); }
|
| 293 |
+
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
| 294 |
+
.attest { display: grid; gap: 9px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
| 295 |
+
.attest label { display: flex; align-items: flex-start; gap: 10px; font-size: 13.5px; cursor: pointer; }
|
| 296 |
+
.attest input { width: 15px; height: 15px; margin-top: 2px; accent-color: var(--accent-strong); }
|
| 297 |
+
.stage-bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 10px; background: var(--ink); color: #dfe5d8; font-family: var(--font-mono); font-size: 12px; }
|
| 298 |
+
.stage-bar .i { color: var(--lime); }
|
| 299 |
+
.form-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 4px; border-top: 1px solid var(--line-soft); }
|
| 300 |
+
.dropzone {
|
| 301 |
+
display: grid; place-items: center; gap: 6px; padding: 28px; margin-top: 12px;
|
| 302 |
+
border: 1.5px dashed var(--line-strong); border-radius: 12px; background: var(--panel);
|
| 303 |
+
color: var(--ink-soft); font-family: var(--font-mono); font-size: 12.5px; cursor: pointer; text-align: center;
|
| 304 |
+
}
|
| 305 |
+
.dropzone:hover, .dropzone.over { border-color: var(--ink); color: var(--ink); }
|
| 306 |
+
.dropzone input { display: none; }
|
| 307 |
+
table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 308 |
+
table.list th { padding: 10px 14px; text-align: left; font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-soft); border-bottom: 1px solid var(--line); background: var(--panel); }
|
| 309 |
+
table.list td { padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
| 310 |
+
table.list tr:last-child td { border-bottom: 0; }
|
| 311 |
+
table.list tr.click { cursor: pointer; }
|
| 312 |
+
table.list tr.click:hover td { background: var(--panel); }
|
| 313 |
+
.table-wrap { overflow: auto; max-height: 420px; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
|
| 314 |
+
.ok-text { color: var(--success); } .bad-text { color: var(--danger); }
|
| 315 |
+
|
| 316 |
+
/* --- Notices, checks, toast ----------------------------------------------------------------------------------- */
|
| 317 |
+
.notice { padding: 12px 16px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); font-size: 13.5px; }
|
| 318 |
+
.notice b { font-family: var(--font-mono); font-size: 12.5px; }
|
| 319 |
+
.notice-ok { background: var(--success-soft); border-color: var(--success-border); }
|
| 320 |
+
.notice-warn { background: var(--warn-soft); border-color: var(--warn-border); }
|
| 321 |
+
.notice-bad { background: var(--danger-soft); border-color: var(--danger-border); }
|
| 322 |
+
.checks { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 4px; }
|
| 323 |
+
.check { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-top: 1px solid var(--line-soft); }
|
| 324 |
+
.check:first-child { border-top: 0; }
|
| 325 |
+
.check-icon { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; }
|
| 326 |
+
.check-icon .i { width: 12px; height: 12px; }
|
| 327 |
+
.check-pass .check-icon { background: var(--success-soft); color: var(--success); }
|
| 328 |
+
.check-fail .check-icon { background: var(--danger-soft); color: var(--danger); }
|
| 329 |
+
.check-wait .check-icon { background: var(--line-soft); color: var(--ink-soft); }
|
| 330 |
+
.check-note .check-icon { background: var(--warn-soft); color: var(--warn); }
|
| 331 |
+
.check b { display: block; font-size: 13.5px; font-weight: 600; }
|
| 332 |
+
.check span { font-size: 12.5px; color: var(--ink-soft); }
|
| 333 |
+
.toast {
|
| 334 |
+
position: fixed; right: 20px; bottom: 20px; z-index: 50; max-width: 380px;
|
| 335 |
+
padding: 12px 16px; border-radius: 10px; background: var(--ink); color: #fff; font-size: 13px;
|
| 336 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, .18);
|
| 337 |
+
}
|
| 338 |
+
.skeleton { height: 92px; border-radius: 14px; background: linear-gradient(90deg, #fff 0%, #f6f7f2 50%, #fff 100%); background-size: 200% 100%; border: 1px solid var(--line); animation: shimmer 1.2s infinite; }
|
| 339 |
+
@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
| 340 |
+
|
| 341 |
+
/* --- Other pages ------------------------------------------------------------------------------------------------ */
|
| 342 |
+
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
|
| 343 |
+
.tile { padding: 16px 18px; }
|
| 344 |
+
.tile b { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 22px; font-weight: 600; }
|
| 345 |
+
.tiers { display: flex; flex-wrap: wrap; gap: 8px; }
|
| 346 |
+
.how { margin: 0; padding: 0; list-style: none; counter-reset: how; display: grid; gap: 10px; }
|
| 347 |
+
.how li { counter-increment: how; display: grid; grid-template-columns: 30px 1fr; gap: 12px; align-items: start; font-size: 14px; }
|
| 348 |
+
.how li::before { content: counter(how); width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--ink); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
|
| 349 |
+
.code-box { position: relative; }
|
| 350 |
+
.code-box pre.block { padding-right: 76px; }
|
| 351 |
+
.code-box .btn { position: absolute; right: 8px; top: 8px; }
|
| 352 |
+
.profile-head { display: flex; align-items: center; gap: 16px; }
|
| 353 |
+
.profile-head .avatar { width: 52px; height: 52px; font-size: 18px; background: var(--ink); color: var(--lime); border: 0; }
|
| 354 |
+
|
| 355 |
+
/* --- Reviewing ------------------------------------------------------------------------------------------------- */
|
| 356 |
+
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
| 357 |
+
.btn-danger:hover { background: #a82a42; border-color: #a82a42; }
|
| 358 |
+
.pool-row.has-action { padding-right: 104px; }
|
| 359 |
+
.row-action { position: absolute; right: 18px; top: 15px; }
|
| 360 |
+
.audit-grid { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 14px; margin-top: 14px; align-items: start; }
|
| 361 |
+
.item-list { display: grid; gap: 2px; padding: 8px; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
|
| 362 |
+
.item-list button {
|
| 363 |
+
display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 9px; border: 0; border-radius: 8px;
|
| 364 |
+
background: none; color: var(--ink-soft); font-size: 13px; text-align: left; cursor: pointer;
|
| 365 |
+
}
|
| 366 |
+
.item-list button:hover { background: var(--panel); color: var(--ink); }
|
| 367 |
+
.item-list button.on { background: var(--nav-active); color: var(--lime); }
|
| 368 |
+
.item-list .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 369 |
+
.mark { width: 22px; height: 22px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--line-soft); color: var(--ink-soft); font-family: var(--font-mono); font-size: 10.5px; }
|
| 370 |
+
.mark .i { width: 12px; height: 12px; }
|
| 371 |
+
.mark.ok { background: var(--success-soft); color: var(--success); }
|
| 372 |
+
.mark.flagged { background: var(--danger-soft); color: var(--danger); }
|
| 373 |
+
.item-card { display: grid; gap: 16px; padding: 22px; }
|
| 374 |
+
.item-card h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
| 375 |
+
.item-card pre.block { margin-top: 6px; }
|
| 376 |
+
.files { list-style: none; margin: 6px 0 8px; padding: 0; display: grid; gap: 4px; font-size: 13px; }
|
| 377 |
+
.files li { display: flex; align-items: center; gap: 7px; }
|
| 378 |
+
.decide { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
| 379 |
+
.decide .reject { display: grid; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
|
| 380 |
+
.item-nav { display: flex; justify-content: space-between; gap: 8px; }
|
| 381 |
+
.sponsor-card { margin-top: 14px; padding: 20px 22px; border-color: var(--karma-border); }
|
| 382 |
+
.sponsor-card details.disclose > summary { gap: 10px; }
|
| 383 |
+
|
| 384 |
+
/* --- Small screens ------------------------------------------------------------------------------------------------ */
|
| 385 |
+
@media (max-width: 600px) { .hide-sm { display: none; } }
|
| 386 |
+
@media (max-width: 900px) {
|
| 387 |
+
.shell { grid-template-columns: minmax(0, 1fr); }
|
| 388 |
+
.sidebar { position: sticky; top: 0; z-index: 20; min-width: 0; height: auto; flex-direction: row; align-items: center; border-right: 0; border-bottom: 1px solid var(--dark-line); }
|
| 389 |
+
.brand { border-bottom: 0; padding: 12px 14px; }
|
| 390 |
+
.brand span { font-size: 15px; }
|
| 391 |
+
.nav { display: flex; overflow-x: auto; padding: 8px; gap: 2px; flex: 1; min-width: 0; scrollbar-width: none; }
|
| 392 |
+
.nav a { padding: 8px 10px; font-size: 12px; white-space: nowrap; }
|
| 393 |
+
.nav a .i { display: none; }
|
| 394 |
+
.side-bottom { display: none; }
|
| 395 |
+
.top-action {
|
| 396 |
+
display: inline-flex; align-items: center; flex: none; margin-right: 10px; padding: 7px 10px;
|
| 397 |
+
border: 1px solid var(--dark-line-soft); border-radius: 8px; background: none; cursor: pointer;
|
| 398 |
+
color: var(--lime); font-family: var(--font-mono); font-size: 12px;
|
| 399 |
+
}
|
| 400 |
+
.page { padding: 22px 16px 60px; }
|
| 401 |
+
.karma-card { grid-template-columns: auto 1fr; }
|
| 402 |
+
.karma-next { grid-column: 1 / -1; }
|
| 403 |
+
.stats { grid-template-columns: repeat(2, 1fr); }
|
| 404 |
+
.stat:nth-child(3) { border-left: 0; }
|
| 405 |
+
.filters { grid-template-columns: 1fr 1fr; }
|
| 406 |
+
.filters .search { grid-column: 1 / -1; }
|
| 407 |
+
.signin-card { grid-template-columns: auto 1fr; }
|
| 408 |
+
.signin-actions { grid-column: 1 / -1; justify-items: stretch; }
|
| 409 |
+
.signin-actions .note { text-align: left; max-width: none; }
|
| 410 |
+
.contract-grid, .two { grid-template-columns: 1fr; }
|
| 411 |
+
.pool-row .cap { grid-template-columns: 1fr auto; }
|
| 412 |
+
.pool-row .cap .progress { grid-column: 1 / -1; grid-row: 2; }
|
| 413 |
+
h1.title { font-size: 20px; }
|
| 414 |
+
.kv { flex-direction: column; gap: 0; }
|
| 415 |
+
.kv span:last-child { text-align: left; }
|
| 416 |
+
.form, .contract { padding: 18px 16px; }
|
| 417 |
+
.form-foot { flex-direction: column; align-items: stretch; }
|
| 418 |
+
.form-foot .btn { width: 100%; }
|
| 419 |
+
.audit-grid { grid-template-columns: minmax(0, 1fr); }
|
| 420 |
+
.item-list { position: static; max-height: none; display: flex; overflow-x: auto; }
|
| 421 |
+
.item-list button { width: auto; flex: none; }
|
| 422 |
+
.item-list .t { display: none; }
|
| 423 |
+
.item-card { padding: 18px 16px; }
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/* Requesting a dataset (Sponsor) */
|
| 427 |
+
.request-card { display: grid; gap: 10px; padding: 16px 18px; }
|
| 428 |
+
.request-card .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
| 429 |
+
.request-card .head .pool-title { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
|
| 430 |
+
.request-card .row { gap: 8px; }
|
| 431 |
+
.admin-note { border-left: 3px solid var(--warn-border); background: var(--warn-soft); border-radius: 0 8px 8px 0; padding: 8px 12px; }
|
| 432 |
+
.admin-note p { margin: 4px 0 0; font-size: 13px; }
|
| 433 |
+
.sample-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
| 434 |
+
.slots { display: inline-flex; gap: 5px; }
|
| 435 |
+
.slot { width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid var(--line-strong); background: transparent; }
|
| 436 |
+
.slot-ok { background: var(--success); border-color: var(--success); }
|
| 437 |
+
.slot-wait { background: var(--warn-soft); border-color: var(--warn); }
|
| 438 |
+
label.btn input[type=file] { display: none; }
|
| 439 |
+
label.btn.busy { opacity: 0.6; pointer-events: none; }
|
web/app.js
CHANGED
|
@@ -1138,15 +1138,57 @@ function drawSponsorPanel(c, data, filter, live) {
|
|
| 1138 |
}
|
| 1139 |
|
| 1140 |
// --- Sponsoring ----------------------------------------------------------------------------------------
|
| 1141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1142 |
|
| 1143 |
async function pageSponsor() {
|
| 1144 |
const live = ticket();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1145 |
const data = await overview();
|
| 1146 |
if (!live()) return;
|
| 1147 |
const app = safeUrl(data.appUrl || "https://console.databounty.io");
|
| 1148 |
mount($("#page"), html`
|
| 1149 |
-
|
| 1150 |
<div class="card signin-card">
|
| 1151 |
<img src="/static/logo.svg" alt="">
|
| 1152 |
<div><h2>Request a dataset</h2><p>Describe what each item needs, how many you want and how hard they should be. Requests are made on databounty.io.</p></div>
|
|
@@ -1168,6 +1210,179 @@ async function pageSponsor() {
|
|
| 1168 |
</div>`);
|
| 1169 |
}
|
| 1170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1171 |
// --- Datasets, leaderboard, profiles ------------------------------------------------------------------
|
| 1172 |
|
| 1173 |
const codeBox = (text) => html`<div class="code-box"><pre class="block">${text}</pre><button class="btn btn-sm" data-action="copy" data-copy="${text}">${icon("copy")} copy</button></div>`;
|
|
|
|
| 1138 |
}
|
| 1139 |
|
| 1140 |
// --- Sponsoring ----------------------------------------------------------------------------------------
|
| 1141 |
+
|
| 1142 |
+
const sponsorHead = () => html`<header class="page-head"><h1 class="title">Sponsor a dataset</h1><p class="sub">Ask the community for the dataset you need. Contributors build it, validators check it, and it's published openly.</p></header>`;
|
| 1143 |
+
|
| 1144 |
+
function howSponsoring(min) {
|
| 1145 |
+
return html`<h2 class="section">How it works</h2>
|
| 1146 |
+
<div class="card pad"><ol class="how">
|
| 1147 |
+
<li><span><b>You describe the dataset:</b> its type, how many items, the difficulty and the licence.</span></li>
|
| 1148 |
+
<li><span><b>You add up to ${min} example items</b>, so contributors can see what a good one looks like.</span></li>
|
| 1149 |
+
<li><span><b>DataBounty's team reviews the request</b> and its examples, then opens a pool for it.</span></li>
|
| 1150 |
+
<li><span><b>Contributors submit items.</b> Each one is checked automatically, then reviewed by validators.</span></li>
|
| 1151 |
+
<li><span><b>You can review items in your pool</b>, and dispute an accepted one while its dispute window is open.</span></li>
|
| 1152 |
+
<li><span><b>The finished dataset is published</b> on the Hugging Face Hub, crediting every contributor.</span></li>
|
| 1153 |
+
</ol></div>`;
|
| 1154 |
+
}
|
| 1155 |
|
| 1156 |
async function pageSponsor() {
|
| 1157 |
const live = ticket();
|
| 1158 |
+
let opts = null;
|
| 1159 |
+
try { opts = await api("/api/requesting"); } catch { opts = null; }
|
| 1160 |
+
if (!live()) return;
|
| 1161 |
+
// Until DataBounty takes requests from other apps, they're made on databounty.io.
|
| 1162 |
+
if (!opts || !opts.open) { await pageSponsorLink(live); return; }
|
| 1163 |
+
const page = $("#page");
|
| 1164 |
+
const me = state.me;
|
| 1165 |
+
if (!me) {
|
| 1166 |
+
mount(page, html`${sponsorHead()}${signInCard("Sign in to request a dataset", "Describe the dataset you need. DataBounty's team reviews your request, and the community builds it.")}${howSponsoring(opts.sampleMin)}`);
|
| 1167 |
+
return;
|
| 1168 |
+
}
|
| 1169 |
+
if (me.step !== "ready") { mount(page, html`${sponsorHead()}${setupCard(me)}`); wireSetup(); return; }
|
| 1170 |
+
if (!me.canSponsor) {
|
| 1171 |
+
mount(page, html`${sponsorHead()}<div class="card setup-card"><h2>Allow sponsoring</h2><p>You signed in before the Space could request datasets for you. Sign out, sign in again, and keep <b>sponsor</b> ticked.</p><button class="btn" data-action="sign-out">sign out</button></div>`);
|
| 1172 |
+
return;
|
| 1173 |
+
}
|
| 1174 |
+
mount(page, html`${sponsorHead()}
|
| 1175 |
+
<h2 class="section">Your requests</h2>
|
| 1176 |
+
<p class="section-sub">An admin reviews each request with its example items. Its pool can open once one example is approved.</p>
|
| 1177 |
+
<div id="my-requests"><div class="skeleton"></div></div>
|
| 1178 |
+
<h2 class="section">Request a dataset</h2>
|
| 1179 |
+
<p class="section-sub">Pick the kind of items you need, then say how many and how hard.</p>
|
| 1180 |
+
${requestForm(opts)}
|
| 1181 |
+
${howSponsoring(opts.sampleMin)}`);
|
| 1182 |
+
wireRequestForm(opts, live);
|
| 1183 |
+
renderMyRequests(opts, live);
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
+
async function pageSponsorLink(live) {
|
| 1187 |
const data = await overview();
|
| 1188 |
if (!live()) return;
|
| 1189 |
const app = safeUrl(data.appUrl || "https://console.databounty.io");
|
| 1190 |
mount($("#page"), html`
|
| 1191 |
+
${sponsorHead()}
|
| 1192 |
<div class="card signin-card">
|
| 1193 |
<img src="/static/logo.svg" alt="">
|
| 1194 |
<div><h2>Request a dataset</h2><p>Describe what each item needs, how many you want and how hard they should be. Requests are made on databounty.io.</p></div>
|
|
|
|
| 1210 |
</div>`);
|
| 1211 |
}
|
| 1212 |
|
| 1213 |
+
const TYPE_HINT = "What kind of items contributors make. Each type comes with its own automated checks.";
|
| 1214 |
+
|
| 1215 |
+
function requestForm(opts) {
|
| 1216 |
+
const groups = {};
|
| 1217 |
+
opts.types.forEach((t) => { (groups[t.category] = groups[t.category] || []).push(t); });
|
| 1218 |
+
const d = opts.defaults;
|
| 1219 |
+
const label = (name, mark) => html`<div class="field-label"><span class="micro">${name}</span>${mark === "req" ? html`<span class="req">*</span>` : mark === "opt" ? html`<span class="opt">optional</span>` : ""}</div>`;
|
| 1220 |
+
return html`<form class="card form" id="request-form" novalidate>
|
| 1221 |
+
<div class="field">${label("dataset type", "req")}
|
| 1222 |
+
<select class="select" id="rq-type"><option value="">Pick a dataset type…</option>${Object.entries(groups).map(([category, types]) => html`<optgroup label="${cap(category)}">${types.map((t) => html`<option value="${t.id}">${t.name}</option>`)}</optgroup>`)}</select>
|
| 1223 |
+
<div class="hint" id="rq-type-hint">${TYPE_HINT}</div></div>
|
| 1224 |
+
<div class="field">${label("title", "req")}<input class="input" id="rq-title" maxlength="120" placeholder="e.g. Python CLI parsers with tricky flags"></div>
|
| 1225 |
+
<div class="field">${label("description", "req")}<textarea class="textarea" id="rq-description" maxlength="2000" placeholder="What the dataset is for, and what a good item looks like."></textarea></div>
|
| 1226 |
+
<div class="two">
|
| 1227 |
+
<div class="field" id="rq-language-field"></div>
|
| 1228 |
+
<div class="field">${label("framework", "opt")}<input class="input" id="rq-framework" maxlength="60" placeholder="e.g. Django"></div>
|
| 1229 |
+
</div>
|
| 1230 |
+
<div class="two">
|
| 1231 |
+
<div class="field">${label("items wanted", "req")}<input class="input" id="rq-items" type="number" min="10" max="100000" step="10" value="${d.targetItems}"></div>
|
| 1232 |
+
<div class="field">${label("difficulty", "req")}<select class="select" id="rq-mix" disabled><option value="">Pick a type first</option></select></div>
|
| 1233 |
+
</div>
|
| 1234 |
+
<div class="two">
|
| 1235 |
+
<div class="field">${label("hand-checked", "")}<select class="select" id="rq-audit" disabled><option value="">Pick a type first</option></select><div class="hint">The share of accepted items a validator also checks by hand.</div></div>
|
| 1236 |
+
<div class="field">${label("licence", "req")}<select class="select" id="rq-licence">${opts.licences.map((l) => html`<option value="${l.value}" ${l.value === d.proposedLicense ? raw("selected") : ""}>${l.label}</option>`)}</select><div class="hint" id="rq-licence-hint"></div></div>
|
| 1237 |
+
</div>
|
| 1238 |
+
<div class="form-foot"><span class="mono small muted" id="rq-missing"></span><button class="btn btn-submit" id="rq-submit" type="submit" disabled>${icon("send")} Send request</button></div>
|
| 1239 |
+
<div id="rq-result"></div>
|
| 1240 |
+
</form>`;
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
function languageField(t) {
|
| 1244 |
+
const label = (mark) => html`<div class="field-label"><span class="micro">language</span>${mark}</div>`;
|
| 1245 |
+
if (!t) return html`${label("")}<input class="input" id="rq-language" disabled placeholder="Pick a type first">`;
|
| 1246 |
+
if (t.languageMode === "none") return html`${label("")}<input class="input" id="rq-language" disabled value="None needed"><div class="hint">Items of this type have no code to run.</div>`;
|
| 1247 |
+
if (t.languageMode === "fixed") return html`${label("")}<input class="input" id="rq-language" disabled value="${t.languages[0] || ""}"><div class="hint">This type checks items in ${t.languages[0] || "one language"} only.</div>`;
|
| 1248 |
+
if (t.languageMode === "choice") return html`${label(html`<span class="req">*</span>`)}<select class="select" id="rq-language"><option value="">Pick one…</option>${t.languages.map((l) => html`<option value="${l}">${l}</option>`)}</select><div class="hint">The languages this type can run and check.</div>`;
|
| 1249 |
+
return html`${label(html`<span class="opt">optional</span>`)}<input class="input" id="rq-language" maxlength="60" placeholder="e.g. Python">`;
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
const newRequestKey = () => (window.crypto && crypto.randomUUID ? crypto.randomUUID() : `rq-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`);
|
| 1253 |
+
|
| 1254 |
+
function wireRequestForm(opts, live) {
|
| 1255 |
+
const form = $("#request-form");
|
| 1256 |
+
const button = $("#rq-submit");
|
| 1257 |
+
const out = $("#rq-result");
|
| 1258 |
+
const types = Object.fromEntries(opts.types.map((t) => [t.id, t]));
|
| 1259 |
+
// One key per request: sending the same form twice (a double click, a retry) files it once.
|
| 1260 |
+
let key = newRequestKey();
|
| 1261 |
+
let busy = false;
|
| 1262 |
+
const current = () => types[$("#rq-type").value];
|
| 1263 |
+
const pickType = () => {
|
| 1264 |
+
const t = current();
|
| 1265 |
+
$("#rq-type-hint").textContent = t ? t.description || TYPE_HINT : TYPE_HINT;
|
| 1266 |
+
mount($("#rq-language-field"), languageField(t));
|
| 1267 |
+
const mix = $("#rq-mix");
|
| 1268 |
+
mount(mix, t ? t.mixes.map((m) => html`<option value="${m.value}" ${m.value === opts.defaults.difficultyMix ? raw("selected") : ""}>${m.label}</option>`) : html`<option value="">Pick a type first</option>`);
|
| 1269 |
+
mix.disabled = !t;
|
| 1270 |
+
const audit = $("#rq-audit");
|
| 1271 |
+
const preferred = t && t.auditOptions.includes(opts.defaults.auditCoveragePct) ? opts.defaults.auditCoveragePct : t ? t.auditOptions[0] : null;
|
| 1272 |
+
mount(audit, t ? t.auditOptions.map((n) => html`<option value="${n}" ${n === preferred ? raw("selected") : ""}>${n}%</option>`) : html`<option value="">Pick a type first</option>`);
|
| 1273 |
+
audit.disabled = !t;
|
| 1274 |
+
};
|
| 1275 |
+
const licenceHint = () => {
|
| 1276 |
+
const l = opts.licences.find((x) => x.value === $("#rq-licence").value);
|
| 1277 |
+
$("#rq-licence-hint").textContent = l ? l.hint : "";
|
| 1278 |
+
};
|
| 1279 |
+
const missing = () => {
|
| 1280 |
+
const t = current();
|
| 1281 |
+
if (!t) return "Choose a dataset type.";
|
| 1282 |
+
if ($("#rq-title").value.trim().length < 5) return "Give it a title of 5 characters or more.";
|
| 1283 |
+
if ($("#rq-description").value.trim().length < 20) return "Describe the dataset in 20 characters or more.";
|
| 1284 |
+
if (t.languageMode === "choice" && !$("#rq-language").value) return "Choose the language.";
|
| 1285 |
+
const n = Number($("#rq-items").value);
|
| 1286 |
+
if (!Number.isInteger(n) || n < 10 || n > 100000) return "Ask for 10 to 100,000 items.";
|
| 1287 |
+
return "";
|
| 1288 |
+
};
|
| 1289 |
+
const update = () => {
|
| 1290 |
+
const m = missing();
|
| 1291 |
+
$("#rq-missing").textContent = m || "ready to send";
|
| 1292 |
+
button.disabled = busy || Boolean(m);
|
| 1293 |
+
};
|
| 1294 |
+
$("#rq-type").addEventListener("change", pickType);
|
| 1295 |
+
$("#rq-licence").addEventListener("change", licenceHint);
|
| 1296 |
+
form.addEventListener("input", update);
|
| 1297 |
+
form.addEventListener("change", update);
|
| 1298 |
+
form.addEventListener("submit", async (e) => {
|
| 1299 |
+
e.preventDefault();
|
| 1300 |
+
if (busy || missing()) return;
|
| 1301 |
+
const body = {
|
| 1302 |
+
datasetTypeId: current().id,
|
| 1303 |
+
title: $("#rq-title").value,
|
| 1304 |
+
description: $("#rq-description").value,
|
| 1305 |
+
language: $("#rq-language").value,
|
| 1306 |
+
framework: $("#rq-framework").value,
|
| 1307 |
+
targetItems: Number($("#rq-items").value),
|
| 1308 |
+
difficultyMix: $("#rq-mix").value,
|
| 1309 |
+
auditCoveragePct: Number($("#rq-audit").value),
|
| 1310 |
+
proposedLicense: $("#rq-licence").value,
|
| 1311 |
+
idempotencyKey: key,
|
| 1312 |
+
};
|
| 1313 |
+
busy = true;
|
| 1314 |
+
button.disabled = true;
|
| 1315 |
+
mount(button, html`${icon("refresh")} Sending…`);
|
| 1316 |
+
try {
|
| 1317 |
+
await api("/api/requests", { method: "POST", body });
|
| 1318 |
+
mount(out, notice("ok", html`<b>Request sent ✓</b> Now add example items to it under Your requests, so the team can see what a good item looks like.`));
|
| 1319 |
+
form.reset();
|
| 1320 |
+
key = newRequestKey();
|
| 1321 |
+
pickType();
|
| 1322 |
+
licenceHint();
|
| 1323 |
+
await renderMyRequests(opts, live);
|
| 1324 |
+
} catch (err) {
|
| 1325 |
+
mount(out, notice("bad", html`<b>Not sent.</b> ${err.message}`));
|
| 1326 |
+
} finally {
|
| 1327 |
+
busy = false;
|
| 1328 |
+
mount(button, html`${icon("send")} Send request`);
|
| 1329 |
+
update();
|
| 1330 |
+
out.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
| 1331 |
+
}
|
| 1332 |
+
});
|
| 1333 |
+
pickType();
|
| 1334 |
+
licenceHint();
|
| 1335 |
+
update();
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
async function renderMyRequests(opts, live) {
|
| 1339 |
+
let data;
|
| 1340 |
+
try { data = await api("/api/requests/mine"); } catch (e) {
|
| 1341 |
+
if (live() && $("#my-requests")) mount($("#my-requests"), notice("warn", e.message));
|
| 1342 |
+
return;
|
| 1343 |
+
}
|
| 1344 |
+
const box = $("#my-requests");
|
| 1345 |
+
if (!live() || !box) return;
|
| 1346 |
+
if (!data.requests.length) { mount(box, html`<div class="card empty">No requests yet. Start one below.</div>`); return; }
|
| 1347 |
+
mount(box, html`<div class="rows">${data.requests.map(requestCard)}</div>${data.more ? html`<p class="hint" style="margin:10px 2px 0">Showing your newest 50.</p>` : ""}`);
|
| 1348 |
+
$$("[data-sample]", box).forEach((input) => input.addEventListener("change", () => addSample(input, opts, live)));
|
| 1349 |
+
}
|
| 1350 |
+
|
| 1351 |
+
function requestCard(r) {
|
| 1352 |
+
const s = r.samples;
|
| 1353 |
+
const dots = Array.from({ length: s.wanted }, (_, i) => html`<span class="slot ${i < s.approved ? "slot-ok" : i < s.approved + s.pending ? "slot-wait" : ""}"></span>`);
|
| 1354 |
+
const facts = [r.type, r.targetItems ? `${fmt(r.targetItems)} items` : "", r.difficulty, r.language, r.date].filter(Boolean).join(" · ");
|
| 1355 |
+
return html`<div class="card request-card" data-request="${r.id}">
|
| 1356 |
+
<div class="head"><span class="pool-title">${r.title}</span><span class="pill pill-${r.statusTone}">${r.statusLabel}</span></div>
|
| 1357 |
+
<div class="mono small muted">${facts}</div>
|
| 1358 |
+
${r.adminNote ? html`<div class="admin-note"><div class="micro">from the reviewer</div><p>${r.adminNote}</p></div>` : ""}
|
| 1359 |
+
<div class="sample-line"><span class="slots" aria-hidden="true">${dots}</span><span class="small">${r.sampleText}</span></div>
|
| 1360 |
+
${r.canAddSample || r.poolId ? html`<div class="row wrap">
|
| 1361 |
+
${r.canAddSample ? html`<label class="btn btn-sm btn-primary">${icon("upload")} add an example item<input type="file" accept=".json,.jsonl,.ndjson" data-sample="${r.id}" hidden></label>
|
| 1362 |
+
<a class="btn btn-sm" href="/api/requesting/${encodeURIComponent(r.typeId)}/example.json" download>${icon("download")} example template</a>` : ""}
|
| 1363 |
+
${r.poolId ? html`<a class="btn btn-sm" href="#/pool/${encodeURIComponent(r.poolId)}">open your pool ${icon("arrow")}</a>` : ""}
|
| 1364 |
+
</div>` : ""}
|
| 1365 |
+
</div>`;
|
| 1366 |
+
}
|
| 1367 |
+
|
| 1368 |
+
async function addSample(input, opts, live) {
|
| 1369 |
+
const file = input.files && input.files[0];
|
| 1370 |
+
if (!file) return;
|
| 1371 |
+
const label = input.closest("label");
|
| 1372 |
+
if (label) label.classList.add("busy");
|
| 1373 |
+
const form = new FormData();
|
| 1374 |
+
form.append("file", file);
|
| 1375 |
+
try {
|
| 1376 |
+
await api(`/api/requests/${encodeURIComponent(input.dataset.sample)}/samples`, { method: "POST", form });
|
| 1377 |
+
toast("Example added. DataBounty checks the file, then an admin reviews it.");
|
| 1378 |
+
await renderMyRequests(opts, live);
|
| 1379 |
+
} catch (e) {
|
| 1380 |
+
toast(e.message);
|
| 1381 |
+
if (label) label.classList.remove("busy");
|
| 1382 |
+
input.value = "";
|
| 1383 |
+
}
|
| 1384 |
+
}
|
| 1385 |
+
|
| 1386 |
// --- Datasets, leaderboard, profiles ------------------------------------------------------------------
|
| 1387 |
|
| 1388 |
const codeBox = (text) => html`<div class="code-box"><pre class="block">${text}</pre><button class="btn btn-sm" data-action="copy" data-copy="${text}">${icon("copy")} copy</button></div>`;
|
web/index.html
CHANGED
|
@@ -10,7 +10,7 @@
|
|
| 10 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 11 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 12 |
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 13 |
-
<link rel="stylesheet" href="/static/app.css?v=
|
| 14 |
</head>
|
| 15 |
<body>
|
| 16 |
<div class="shell">
|
|
@@ -19,6 +19,6 @@
|
|
| 19 |
</div>
|
| 20 |
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
| 21 |
<noscript><p style="padding:24px;font-family:sans-serif">This page needs JavaScript. You can also contribute at <a href="https://databounty.io">databounty.io</a>.</p></noscript>
|
| 22 |
-
<script src="/static/app.js?v=
|
| 23 |
</body>
|
| 24 |
</html>
|
|
|
|
| 10 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 11 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 12 |
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 13 |
+
<link rel="stylesheet" href="/static/app.css?v=8">
|
| 14 |
</head>
|
| 15 |
<body>
|
| 16 |
<div class="shell">
|
|
|
|
| 19 |
</div>
|
| 20 |
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
| 21 |
<noscript><p style="padding:24px;font-family:sans-serif">This page needs JavaScript. You can also contribute at <a href="https://databounty.io">databounty.io</a>.</p></noscript>
|
| 22 |
+
<script src="/static/app.js?v=8" defer></script>
|
| 23 |
</body>
|
| 24 |
</html>
|
webapi.py
CHANGED
|
@@ -1,536 +1,619 @@
|
|
| 1 |
-
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
-
"""The JSON API behind the Space's web page (web/app.js): contributing, reviewing, and public reads.
|
| 3 |
-
|
| 4 |
-
Public reads come straight from databounty.py. Anything done as a person goes through account.py
|
| 5 |
-
with the session handle the page sends as `Authorization: Bearer <handle>`. The page keeps that
|
| 6 |
-
handle in its own storage (account.py explains why not a cookie), and a handle only ever reaches
|
| 7 |
-
the page through claim_sign_in, which only the page that started the sign-in can complete.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
import json
|
| 13 |
-
import re
|
| 14 |
-
import tempfile
|
| 15 |
-
from pathlib import Path
|
| 16 |
-
from typing import Any
|
| 17 |
-
|
| 18 |
-
from fastapi import APIRouter, Request
|
| 19 |
-
from fastapi.responses import JSONResponse, Response
|
| 20 |
-
|
| 21 |
-
import account
|
| 22 |
-
import contribute
|
| 23 |
-
import databounty as db
|
| 24 |
-
import items as checks
|
| 25 |
-
import
|
| 26 |
-
import
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
self.
|
| 41 |
-
self.
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
if
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
else "
|
| 157 |
-
else "
|
| 158 |
-
else "
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
"
|
| 170 |
-
"
|
| 171 |
-
"
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
"
|
| 178 |
-
"
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
session
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
"
|
| 353 |
-
"
|
| 354 |
-
"
|
| 355 |
-
"
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
if
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
#
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
"
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
#
|
| 491 |
-
#
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
"""The JSON API behind the Space's web page (web/app.js): contributing, reviewing, and public reads.
|
| 3 |
+
|
| 4 |
+
Public reads come straight from databounty.py. Anything done as a person goes through account.py
|
| 5 |
+
with the session handle the page sends as `Authorization: Bearer <handle>`. The page keeps that
|
| 6 |
+
handle in its own storage (account.py explains why not a cookie), and a handle only ever reaches
|
| 7 |
+
the page through claim_sign_in, which only the page that started the sign-in can complete.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
import tempfile
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from fastapi import APIRouter, Request
|
| 19 |
+
from fastapi.responses import JSONResponse, Response
|
| 20 |
+
|
| 21 |
+
import account
|
| 22 |
+
import contribute
|
| 23 |
+
import databounty as db
|
| 24 |
+
import items as checks
|
| 25 |
+
import requesting
|
| 26 |
+
import sponsor
|
| 27 |
+
import validate
|
| 28 |
+
|
| 29 |
+
NO_STORE = {"Cache-Control": "no-store"}
|
| 30 |
+
MAX_FILE_BYTES = 100 * 1024 * 1024
|
| 31 |
+
|
| 32 |
+
router = APIRouter(prefix="/api")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ApiError(Exception):
|
| 36 |
+
"""An answer for the page: an HTTP status and a message written for people."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, status: int, message: str, *, needs: str = ""):
|
| 39 |
+
super().__init__(message)
|
| 40 |
+
self.status = status
|
| 41 |
+
self.message = message
|
| 42 |
+
self.needs = needs # what the page should offer next, e.g. "claim"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def api_error(_request: Request, exc: ApiError) -> JSONResponse:
|
| 46 |
+
body = {"error": exc.message, **({"needs": exc.needs} if exc.needs else {})}
|
| 47 |
+
return JSONResponse(body, status_code=exc.status, headers=NO_STORE)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _ok(data: Any) -> JSONResponse:
|
| 51 |
+
return JSONResponse(data, headers=NO_STORE)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _public(load):
|
| 55 |
+
try:
|
| 56 |
+
return load()
|
| 57 |
+
except db.UpstreamError as exc:
|
| 58 |
+
raise ApiError(502, str(exc)) from exc
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _bearer(request: Request) -> str:
|
| 62 |
+
header = request.headers.get("authorization", "")
|
| 63 |
+
return header[7:].strip() if header.lower().startswith("bearer ") else ""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _session(request: Request) -> tuple[str, account.Session]:
|
| 67 |
+
session_id = _bearer(request)
|
| 68 |
+
session = account.session_for(session_id) if session_id else None
|
| 69 |
+
if session is None:
|
| 70 |
+
raise ApiError(401, "Sign in to continue.")
|
| 71 |
+
return session_id, session
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _call(session: account.Session, name: str, arguments: dict[str, Any] | None = None) -> Any:
|
| 75 |
+
try:
|
| 76 |
+
return account.call_tool(session, name, arguments)
|
| 77 |
+
except account.NotSignedIn as exc:
|
| 78 |
+
raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
|
| 79 |
+
except account.ToolError as exc:
|
| 80 |
+
raise ApiError(400, str(exc)) from exc
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def _json(request: Request) -> dict[str, Any]:
|
| 84 |
+
try:
|
| 85 |
+
body = await request.json()
|
| 86 |
+
except ValueError as exc:
|
| 87 |
+
raise ApiError(400, "Send a JSON body.") from exc
|
| 88 |
+
return body if isinstance(body, dict) else {}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# --- Public ---------------------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@router.get("/overview")
|
| 95 |
+
def overview():
|
| 96 |
+
return _ok({"stats": _public(db.stats), "tiers": _public(db.tiers), "hfOrg": db.HF_ORG, "siteUrl": db.SITE_URL, "appUrl": db.APP_URL})
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.get("/pools")
|
| 100 |
+
def pools(q: str = "", domain: str = "", difficulty: str = ""):
|
| 101 |
+
return _ok({"pools": _public(lambda: db.open_pools(q, domain.lower(), difficulty.lower()))})
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.get("/datasets")
|
| 105 |
+
def datasets():
|
| 106 |
+
return _ok({"datasets": _public(db.published_datasets)})
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.get("/leaderboard")
|
| 110 |
+
def leaderboard(tier: str = "", q: str = ""):
|
| 111 |
+
return _ok({"rows": _public(lambda: db.leaderboard(limit=100, tier=tier.lower(), query=q))})
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.get("/profiles/{handle}")
|
| 115 |
+
def profile(handle: str):
|
| 116 |
+
try:
|
| 117 |
+
person = _public(lambda: db.contributor(handle))
|
| 118 |
+
except ValueError as exc:
|
| 119 |
+
raise ApiError(400, str(exc)) from exc
|
| 120 |
+
if person is None:
|
| 121 |
+
raise ApiError(404, "There's no public profile with that handle. Members can keep their profile private.")
|
| 122 |
+
return _ok(person)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.get("/tools")
|
| 126 |
+
def tools():
|
| 127 |
+
return _ok({"tools": _public(db.platform_mcp_tools), "mcpUrl": db.MCP_URL})
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# --- Session and account ------------------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.post("/session/claim")
|
| 134 |
+
async def claim_session(request: Request):
|
| 135 |
+
nonce = str((await _json(request)).get("nonce") or "")
|
| 136 |
+
session_id = account.claim_sign_in(nonce)
|
| 137 |
+
if not session_id:
|
| 138 |
+
raise ApiError(404, "That sign-in isn't ready, or it was already used.")
|
| 139 |
+
return _ok({"session": session_id})
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@router.post("/session/sign-out")
|
| 143 |
+
def sign_out(request: Request):
|
| 144 |
+
account.sign_out(_bearer(request) or None)
|
| 145 |
+
return _ok({"ok": True})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.get("/me")
|
| 149 |
+
def me(request: Request):
|
| 150 |
+
_, session = _session(request)
|
| 151 |
+
who = _call(session, "whoami")
|
| 152 |
+
session.me = who
|
| 153 |
+
scopes = set(session.scope.split())
|
| 154 |
+
step = (
|
| 155 |
+
"permission" if "contribute" not in scopes
|
| 156 |
+
else "verify_email" if not who.get("emailVerified")
|
| 157 |
+
else "choose_handle" if not who.get("handle")
|
| 158 |
+
else "finish_setup" if not who.get("onboarded")
|
| 159 |
+
else "ready"
|
| 160 |
+
)
|
| 161 |
+
suggestion = ""
|
| 162 |
+
if step == "choose_handle" and "account" in scopes:
|
| 163 |
+
try:
|
| 164 |
+
suggested = account.call_tool(session, "suggest_handles", {"baseName": who.get("displayName") or "", "count": 3})
|
| 165 |
+
suggestion = suggested[0] if isinstance(suggested, list) and suggested else ""
|
| 166 |
+
except (account.ToolError, account.NotSignedIn):
|
| 167 |
+
suggestion = ""
|
| 168 |
+
return _ok({
|
| 169 |
+
"displayName": who.get("displayName") or "",
|
| 170 |
+
"handle": who.get("handle") or "",
|
| 171 |
+
"email": who.get("email") or "",
|
| 172 |
+
"karma": who.get("karmaTotal") if isinstance(who.get("karmaTotal"), (int, float)) else 0,
|
| 173 |
+
"step": step,
|
| 174 |
+
"suggestedHandle": suggestion,
|
| 175 |
+
"profileUrl": f"{db.SITE_URL}/{who.get('handle')}/" if who.get("handle") else "",
|
| 176 |
+
# Sign-ins from before the Space asked for `validate` or `sponsor` can't use those screens.
|
| 177 |
+
"canReview": "validate" in scopes,
|
| 178 |
+
"canSponsor": "sponsor" in scopes,
|
| 179 |
+
"reviewer": validate.rank_view(who),
|
| 180 |
+
})
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@router.post("/me/handle-check")
|
| 184 |
+
async def handle_check(request: Request):
|
| 185 |
+
_, session = _session(request)
|
| 186 |
+
handle = str((await _json(request)).get("handle") or "").strip().lower().lstrip("@")
|
| 187 |
+
if not handle:
|
| 188 |
+
raise ApiError(400, "Type a username first.")
|
| 189 |
+
result = _call(session, "get_handle_availability", {"handle": handle})
|
| 190 |
+
return _ok({"handle": handle, "available": bool(result.get("available")), "reason": result.get("reason") or ""})
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@router.post("/me/finish-setup")
|
| 194 |
+
async def finish_setup(request: Request):
|
| 195 |
+
_, session = _session(request)
|
| 196 |
+
handle = str((await _json(request)).get("handle") or "").strip().lower().lstrip("@")
|
| 197 |
+
who = session.me or _call(session, "whoami")
|
| 198 |
+
if not who.get("handle"):
|
| 199 |
+
if not handle:
|
| 200 |
+
raise ApiError(400, "Type the username you want first.")
|
| 201 |
+
result = _call(session, "get_handle_availability", {"handle": handle})
|
| 202 |
+
if not result.get("available"):
|
| 203 |
+
raise ApiError(409, f"@{handle} isn't available: {result.get('reason') or 'it is taken'}.")
|
| 204 |
+
_call(session, "claim_handle", {"handle": handle})
|
| 205 |
+
_call(session, "complete_onboarding", {"persona": "contributor"})
|
| 206 |
+
session.me = None
|
| 207 |
+
return _ok({"ok": True})
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@router.post("/me/resend-verification")
|
| 211 |
+
def resend_verification(request: Request):
|
| 212 |
+
_, session = _session(request)
|
| 213 |
+
result = _call(session, "resend_email_verification") or {}
|
| 214 |
+
return _ok({"alreadyVerified": result.get("status") == "already_verified"})
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# --- Pools ---------------------------------------------------------------------------------------------
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _contract(session: account.Session, pool_id: str) -> dict[str, Any]:
|
| 221 |
+
return contribute.contract_view(_call(session, "get_pool_contract", {"bountyId": pool_id}), pool_id)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@router.get("/pools/{pool_id}/contract")
|
| 225 |
+
def contract(pool_id: str, request: Request):
|
| 226 |
+
session = account.session_for(_bearer(request)) if _bearer(request) else None
|
| 227 |
+
if session is not None:
|
| 228 |
+
return _ok(_contract(session, pool_id))
|
| 229 |
+
try:
|
| 230 |
+
details = _public(lambda: db.pool_details(pool_id))
|
| 231 |
+
except ValueError as exc:
|
| 232 |
+
raise ApiError(400, str(exc)) from exc
|
| 233 |
+
if details is None:
|
| 234 |
+
raise ApiError(404, "That pool is no longer listed.")
|
| 235 |
+
return _ok(contribute.public_contract_view(details))
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@router.get("/pools/{pool_id}/template.csv")
|
| 239 |
+
def template(pool_id: str):
|
| 240 |
+
try:
|
| 241 |
+
details = _public(lambda: db.pool_details(pool_id))
|
| 242 |
+
except ValueError as exc:
|
| 243 |
+
raise ApiError(400, str(exc)) from exc
|
| 244 |
+
if details is None:
|
| 245 |
+
raise ApiError(404, "That pool is no longer listed.")
|
| 246 |
+
text = contribute.template_csv(details.get("fields") or [])
|
| 247 |
+
if not text:
|
| 248 |
+
raise ApiError(404, "This pool takes files, so add its items one at a time.")
|
| 249 |
+
slug = re.sub(r"[^a-z0-9]+", "-", str(details.get("title") or "pool").lower()).strip("-")[:48] or "pool"
|
| 250 |
+
return Response(
|
| 251 |
+
text,
|
| 252 |
+
media_type="text/csv; charset=utf-8",
|
| 253 |
+
headers={"Content-Disposition": f'attachment; filename="{slug}-template.csv"', **NO_STORE},
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def _save(upload: Any) -> tuple[str, str, str]:
|
| 258 |
+
"""An uploaded file, to a temporary path: (path, filename, content type)."""
|
| 259 |
+
data = await upload.read()
|
| 260 |
+
if len(data) > MAX_FILE_BYTES:
|
| 261 |
+
raise ApiError(413, f"{upload.filename} is larger than {MAX_FILE_BYTES // (1024 * 1024)} MB.")
|
| 262 |
+
suffix = Path(upload.filename or "upload").suffix[:16]
|
| 263 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix="databounty-space-") as handle:
|
| 264 |
+
handle.write(data)
|
| 265 |
+
return handle.name, Path(upload.filename or "upload").name, upload.content_type or "application/octet-stream"
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@router.post("/pools/{pool_id}/items")
|
| 269 |
+
async def submit_item(pool_id: str, request: Request):
|
| 270 |
+
_, session = _session(request)
|
| 271 |
+
# Closing the form deletes the uploads' temporary files.
|
| 272 |
+
async with request.form() as form:
|
| 273 |
+
return await _submit_item(session, pool_id, form)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
async def _submit_item(session: account.Session, pool_id: str, form: Any) -> JSONResponse:
|
| 277 |
+
method = str(form.get("method") or "")
|
| 278 |
+
if method not in contribute.GENERATION_METHODS:
|
| 279 |
+
raise ApiError(400, "Choose how you made this item.")
|
| 280 |
+
try:
|
| 281 |
+
values = json.loads(str(form.get("values") or "{}"))
|
| 282 |
+
except ValueError as exc:
|
| 283 |
+
raise ApiError(400, "The form's values didn't arrive in one piece. Try again.") from exc
|
| 284 |
+
files: dict[str, list[Any]] = {}
|
| 285 |
+
for name, value in form.multi_items():
|
| 286 |
+
if name.startswith("file:") and getattr(value, "filename", None):
|
| 287 |
+
files.setdefault(name[5:], []).append(value)
|
| 288 |
+
|
| 289 |
+
contract_data = _contract(session, pool_id)
|
| 290 |
+
if not contract_data["open"]:
|
| 291 |
+
raise ApiError(409, "This pool isn't taking items right now.")
|
| 292 |
+
payload, problems = contribute.item_payload(contract_data["fields"], values if isinstance(values, dict) else {}, {k: len(v) for k, v in files.items()})
|
| 293 |
+
if problems:
|
| 294 |
+
raise ApiError(400, " ".join(problems))
|
| 295 |
+
|
| 296 |
+
saved: list[str] = []
|
| 297 |
+
try:
|
| 298 |
+
for spec in contract_data["fields"]:
|
| 299 |
+
uploads = files.get(spec["key"]) or []
|
| 300 |
+
if spec["role"] != "file" or not uploads:
|
| 301 |
+
continue
|
| 302 |
+
ids = []
|
| 303 |
+
for upload in uploads:
|
| 304 |
+
path, filename, content_type = await _save(upload)
|
| 305 |
+
saved.append(path)
|
| 306 |
+
try:
|
| 307 |
+
ids.append(account.upload_file(session, pool_id, path, filename, content_type))
|
| 308 |
+
except account.NotSignedIn as exc:
|
| 309 |
+
raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
|
| 310 |
+
except account.ToolError as exc:
|
| 311 |
+
raise ApiError(400, str(exc)) from exc
|
| 312 |
+
payload[spec["key"]] = ids if spec["maxCount"] > 1 else ids[0]
|
| 313 |
+
finally:
|
| 314 |
+
for path in saved:
|
| 315 |
+
Path(path).unlink(missing_ok=True)
|
| 316 |
+
|
| 317 |
+
title = " ".join(str(form.get("title") or "").split())[:120] or contribute.title_for(payload)
|
| 318 |
+
if len(title) < 3:
|
| 319 |
+
title = f"{title} item".strip()
|
| 320 |
+
result = _call(
|
| 321 |
+
session,
|
| 322 |
+
"submit_pool_items",
|
| 323 |
+
{"bountyId": pool_id, "items": [{"title": title, "payloadJson": payload, "generationMethod": method}]},
|
| 324 |
+
)
|
| 325 |
+
subs = result.get("submissions") or []
|
| 326 |
+
duplicate = bool(subs) and all(s.get("status") == "rejected" and s.get("duplicateOfSubmissionId") for s in subs)
|
| 327 |
+
return _ok({"duplicate": duplicate, "submissions": [{"id": s.get("id"), "status": s.get("status")} for s in subs], "karma": contract_data["karma"]})
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
@router.post("/pools/{pool_id}/bulk/preview")
|
| 331 |
+
async def bulk_preview(pool_id: str, request: Request):
|
| 332 |
+
_, session = _session(request)
|
| 333 |
+
async with request.form() as form:
|
| 334 |
+
upload = form.get("file")
|
| 335 |
+
if not getattr(upload, "filename", None):
|
| 336 |
+
raise ApiError(400, "Choose a file first.")
|
| 337 |
+
data = await upload.read()
|
| 338 |
+
fields = _contract(session, pool_id)["fields"]
|
| 339 |
+
try:
|
| 340 |
+
rows = checks.parse_bulk(data, upload.filename)
|
| 341 |
+
except ValueError as exc:
|
| 342 |
+
raise ApiError(400, str(exc)) from exc
|
| 343 |
+
if len(rows) > contribute.MAX_BULK_ROWS:
|
| 344 |
+
raise ApiError(400, f"That file has {len(rows):,} rows. Upload up to {contribute.MAX_BULK_ROWS} at a time.")
|
| 345 |
+
for row in rows:
|
| 346 |
+
if row.payload is not None:
|
| 347 |
+
row.payload = checks.coerce_for_contract(fields, row.payload)
|
| 348 |
+
checks.check_rows(rows, fields)
|
| 349 |
+
return _ok({
|
| 350 |
+
"rows": [
|
| 351 |
+
{
|
| 352 |
+
"number": row.number,
|
| 353 |
+
"ok": row.ok,
|
| 354 |
+
"problem": "" if row.ok else (row.error or "; ".join(row.problems)),
|
| 355 |
+
"title": contribute.title_for(row.payload, row.number),
|
| 356 |
+
"payload": row.payload,
|
| 357 |
+
}
|
| 358 |
+
for row in rows
|
| 359 |
+
],
|
| 360 |
+
"hasFileFields": any(spec["role"] == "file" for spec in fields),
|
| 361 |
+
})
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
@router.post("/pools/{pool_id}/bulk/send")
|
| 365 |
+
async def bulk_send(pool_id: str, request: Request):
|
| 366 |
+
_, session = _session(request)
|
| 367 |
+
body = await _json(request)
|
| 368 |
+
method = str(body.get("method") or "")
|
| 369 |
+
if method not in contribute.GENERATION_METHODS:
|
| 370 |
+
raise ApiError(400, "Choose how you made these items.")
|
| 371 |
+
contract_data = _contract(session, pool_id)
|
| 372 |
+
if not contract_data["open"]:
|
| 373 |
+
raise ApiError(409, "This pool isn't taking items right now.")
|
| 374 |
+
# Every row is checked again here: the page's copy is only a preview.
|
| 375 |
+
items, skipped = [], 0
|
| 376 |
+
for row in body.get("rows") or []:
|
| 377 |
+
payload = row.get("payload") if isinstance(row, dict) else None
|
| 378 |
+
if not isinstance(payload, dict) or checks.item_problems(contract_data["fields"], payload):
|
| 379 |
+
skipped += 1
|
| 380 |
+
continue
|
| 381 |
+
number = int(row.get("number") or 0)
|
| 382 |
+
title = " ".join(str(row.get("title") or "").split())[:120] or contribute.title_for(payload, number)
|
| 383 |
+
items.append({"title": title, "payloadJson": payload, "generationMethod": method})
|
| 384 |
+
if not items:
|
| 385 |
+
raise ApiError(400, "None of those rows are ready to send.")
|
| 386 |
+
sent = duplicates = 0
|
| 387 |
+
problem = ""
|
| 388 |
+
size = max(1, contract_data["maxItems"])
|
| 389 |
+
for start in range(0, len(items), size):
|
| 390 |
+
try:
|
| 391 |
+
result = account.call_tool(session, "submit_pool_items", {"bountyId": pool_id, "items": items[start : start + size]})
|
| 392 |
+
except account.NotSignedIn:
|
| 393 |
+
problem = "Your sign-in ended, so the rest weren't sent. Sign in again to continue."
|
| 394 |
+
break
|
| 395 |
+
except account.ToolError as exc:
|
| 396 |
+
problem = f"Items {start + 1} onwards weren't sent: {exc}"
|
| 397 |
+
break
|
| 398 |
+
created = result.get("submissions") or []
|
| 399 |
+
sent += len(created)
|
| 400 |
+
duplicates += sum(1 for s in created if s.get("status") == "rejected" and s.get("duplicateOfSubmissionId"))
|
| 401 |
+
if result.get("stoppedAtCapacity"):
|
| 402 |
+
problem = "The pool filled up, so the rest weren't sent."
|
| 403 |
+
break
|
| 404 |
+
return _ok({"sent": sent, "duplicates": duplicates, "skipped": skipped, "problem": problem, "karma": contract_data["karma"]})
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# --- Submissions ------------------------------------------------------------------------------------------
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
@router.get("/submissions")
|
| 411 |
+
def submissions(request: Request):
|
| 412 |
+
_, session = _session(request)
|
| 413 |
+
data = _call(session, "list_my_submissions", {"limit": 50}) or {}
|
| 414 |
+
rows = [contribute.submission_row(item) for item in data.get("items") or []]
|
| 415 |
+
return _ok({"items": rows, "total": data.get("total") if isinstance(data.get("total"), int) else len(rows)})
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
@router.get("/submissions/{submission_id}")
|
| 419 |
+
def submission(submission_id: str, request: Request):
|
| 420 |
+
_, session = _session(request)
|
| 421 |
+
return _ok(contribute.submission_view(_call(session, "check_submission", {"submissionId": submission_id}), db.APP_URL))
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# --- Reviewing --------------------------------------------------------------------------------------
|
| 425 |
+
# A review is an audit: a few items from one pool, held by one reviewer for 24 hours.
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def _reviewer(request: Request) -> account.Session:
|
| 429 |
+
_, session = _session(request)
|
| 430 |
+
if "validate" not in session.scope.split():
|
| 431 |
+
raise ApiError(403, "The Space isn't allowed to review for you. Sign out, then sign in again and keep validate ticked.")
|
| 432 |
+
return session
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@router.get("/audits")
|
| 436 |
+
def audits(request: Request, q: str = ""):
|
| 437 |
+
session = _reviewer(request)
|
| 438 |
+
search = " ".join(q.split())[:120]
|
| 439 |
+
data = _call(session, "list_audits", {"limit": 50, **({"search": search} if search else {})}) or {}
|
| 440 |
+
rows = [validate.audit_row(a) for a in data.get("audits") or []]
|
| 441 |
+
return _ok({
|
| 442 |
+
"audits": rows,
|
| 443 |
+
"total": data.get("total") if isinstance(data.get("total"), int) else len(rows),
|
| 444 |
+
# Audits holding the reviewer's own work, which they can't review.
|
| 445 |
+
"hidden": data.get("conflictExcluded") if isinstance(data.get("conflictExcluded"), int) else 0,
|
| 446 |
+
})
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
@router.get("/audits/mine")
|
| 450 |
+
def my_audits(request: Request, status: str = "claimed"):
|
| 451 |
+
session = _reviewer(request)
|
| 452 |
+
status = status if status in ("claimed", "overdue_review", "completed") else "claimed"
|
| 453 |
+
data = _call(session, "list_my_audits", {"status": status, "limit": 50}) or {}
|
| 454 |
+
rows = [validate.audit_row(a) for a in data.get("audits") or []]
|
| 455 |
+
return _ok({"audits": rows, "total": data.get("total") if isinstance(data.get("total"), int) else len(rows)})
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
@router.post("/audits/{audit_id}/claim")
|
| 459 |
+
def claim_audit(audit_id: str, request: Request):
|
| 460 |
+
session = _reviewer(request)
|
| 461 |
+
window = (_call(session, "claim_audit", {"windowId": audit_id}) or {}).get("window") or {}
|
| 462 |
+
return _ok({"id": window.get("id") or audit_id, "deadline": window.get("claimExpiresAt")})
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
@router.get("/audits/{audit_id}")
|
| 466 |
+
def audit(audit_id: str, request: Request):
|
| 467 |
+
session = _reviewer(request)
|
| 468 |
+
try:
|
| 469 |
+
detail = account.call_tool(session, "get_audit", {"windowId": audit_id})
|
| 470 |
+
except account.NotSignedIn as exc:
|
| 471 |
+
raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
|
| 472 |
+
except account.ToolError as exc:
|
| 473 |
+
# The items stay hidden until the reviewer holds the audit (DataBounty's rule).
|
| 474 |
+
if "claim_audit" in str(exc) or "Claim this audit" in str(exc):
|
| 475 |
+
raise ApiError(409, "Claim this audit to see its items. It's yours for 24 hours.", needs="claim") from exc
|
| 476 |
+
raise ApiError(400, str(exc)) from exc
|
| 477 |
+
return _ok(validate.audit_view(detail or {}, db.APP_URL))
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
@router.post("/audits/{audit_id}/decisions")
|
| 481 |
+
async def decide(audit_id: str, request: Request):
|
| 482 |
+
session = _reviewer(request)
|
| 483 |
+
decision, problem = validate.decision(await _json(request))
|
| 484 |
+
if problem:
|
| 485 |
+
raise ApiError(400, problem)
|
| 486 |
+
result = _call(session, "submit_decisions", {"windowId": audit_id, "decisions": [decision]}) or {}
|
| 487 |
+
return _ok({"ok": True, "decided": result.get("decisionsCount", 1)})
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
# --- Sponsoring ----------------------------------------------------------------------------------------
|
| 491 |
+
# Only a pool's requester can see its items (DataBounty's rule), so the page asks on every pool and
|
| 492 |
+
# shows the panel only when DataBounty says yes. Requesting a dataset stays on databounty.io.
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def _sponsor(request: Request) -> account.Session:
|
| 496 |
+
_, session = _session(request)
|
| 497 |
+
if "sponsor" not in session.scope.split():
|
| 498 |
+
raise ApiError(403, "Sign out, then sign in again and keep sponsor ticked, to see your pools here.", needs="sponsor_scope")
|
| 499 |
+
return session
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def _sponsor_call(session: account.Session, name: str, arguments: dict[str, Any]) -> Any:
|
| 503 |
+
try:
|
| 504 |
+
return account.call_tool(session, name, arguments)
|
| 505 |
+
except account.NotSignedIn as exc:
|
| 506 |
+
raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
|
| 507 |
+
except account.ToolError as exc:
|
| 508 |
+
message = str(exc)
|
| 509 |
+
if "owner" in message.lower() or "forbidden" in message.lower():
|
| 510 |
+
raise ApiError(403, "Only the person who requested this pool can see its items.", needs="not_sponsor") from exc
|
| 511 |
+
raise ApiError(400, message) from exc
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
@router.get("/pools/{pool_id}/sponsor")
|
| 515 |
+
def sponsor_pool(pool_id: str, request: Request, status: str = "", q: str = ""):
|
| 516 |
+
session = _sponsor(request)
|
| 517 |
+
arguments: dict[str, Any] = {"bountyId": pool_id, "limit": 50}
|
| 518 |
+
if status.strip():
|
| 519 |
+
arguments["status"] = status.strip()
|
| 520 |
+
if q.strip():
|
| 521 |
+
arguments["search"] = " ".join(q.split())[:120]
|
| 522 |
+
data = _sponsor_call(session, "get_sponsor_submission_evidence", arguments) or {}
|
| 523 |
+
try:
|
| 524 |
+
specs = (db.pool_details(pool_id) or {}).get("fields") or []
|
| 525 |
+
except (ValueError, db.UpstreamError):
|
| 526 |
+
specs = [] # labels are a nicety; the items still show under their keys
|
| 527 |
+
return _ok(sponsor.pool_view(data, specs))
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
@router.post("/sponsor/submissions/{submission_id}/dispute")
|
| 531 |
+
async def sponsor_dispute(submission_id: str, request: Request):
|
| 532 |
+
session = _sponsor(request)
|
| 533 |
+
arguments, problem = sponsor.dispute(await _json(request))
|
| 534 |
+
if problem:
|
| 535 |
+
raise ApiError(400, problem)
|
| 536 |
+
_sponsor_call(session, "dispute_accepted_submission", {"submissionId": submission_id, **arguments})
|
| 537 |
+
return _ok({"ok": True})
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
# --- Requesting a dataset (sponsors) -------------------------------------------------------------------
|
| 541 |
+
# The request goes to DataBounty's request_dataset tool, which checks it again; an admin reviews it,
|
| 542 |
+
# and its reference samples, before a pool opens. Until DataBounty has that tool, the page links to
|
| 543 |
+
# databounty.io instead (`open` below).
|
| 544 |
+
|
| 545 |
+
SAMPLE_TYPES = {".json": "application/json", ".jsonl": "application/x-ndjson", ".ndjson": "application/x-ndjson"}
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def _request_options() -> dict[str, Any]:
|
| 549 |
+
return requesting.catalog_view(_public(db.request_catalog))
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
@router.get("/requesting")
|
| 553 |
+
def requesting_options():
|
| 554 |
+
view = _request_options()
|
| 555 |
+
try:
|
| 556 |
+
view["open"] = db.requesting_open()
|
| 557 |
+
except db.UpstreamError:
|
| 558 |
+
view["open"] = False # can't tell: keep the link to databounty.io
|
| 559 |
+
return _ok(view)
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
@router.get("/requesting/{type_id}/example.json")
|
| 563 |
+
def example_template(type_id: str):
|
| 564 |
+
"""An empty example item for a dataset type, to fill in and attach to a request."""
|
| 565 |
+
t = next((t for t in _request_options()["types"] if t["id"] == type_id), None)
|
| 566 |
+
if not t:
|
| 567 |
+
raise ApiError(404, "That dataset type isn't open for requests.")
|
| 568 |
+
body = json.dumps({key: "" for key in t["fields"]}, indent=2) + "\n"
|
| 569 |
+
slug = re.sub(r"[^a-z0-9_-]+", "-", type_id.lower()).strip("-") or "dataset"
|
| 570 |
+
return Response(
|
| 571 |
+
body,
|
| 572 |
+
media_type="application/json",
|
| 573 |
+
headers={"Content-Disposition": f'attachment; filename="{slug}-example.json"', **NO_STORE},
|
| 574 |
+
)
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
@router.post("/requests")
|
| 578 |
+
async def file_request(request: Request):
|
| 579 |
+
session = _sponsor(request)
|
| 580 |
+
types = {t["id"]: t for t in _request_options()["types"]}
|
| 581 |
+
arguments, problem = requesting.request_arguments(await _json(request), types)
|
| 582 |
+
if problem:
|
| 583 |
+
raise ApiError(400, problem)
|
| 584 |
+
result = _sponsor_call(session, "request_dataset", arguments) or {}
|
| 585 |
+
filed = result.get("request") or {}
|
| 586 |
+
return _ok({"id": filed.get("id"), "alreadyFiled": bool(result.get("alreadyFiled"))})
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
@router.get("/requests/mine")
|
| 590 |
+
def my_requests(request: Request):
|
| 591 |
+
session = _sponsor(request)
|
| 592 |
+
data = _sponsor_call(session, "list_my_dataset_requests", {"limit": 50}) or {}
|
| 593 |
+
try:
|
| 594 |
+
names = {t["id"]: t["name"] for t in _request_options()["types"]}
|
| 595 |
+
except ApiError:
|
| 596 |
+
names = {} # names are a nicety; the requests still show
|
| 597 |
+
rows = [requesting.request_row(r, names) for r in data.get("requests") or [] if isinstance(r, dict)]
|
| 598 |
+
return _ok({"requests": rows, "more": bool(data.get("nextCursor"))})
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
@router.post("/requests/{request_id}/samples")
|
| 602 |
+
async def add_sample(request_id: str, request: Request):
|
| 603 |
+
session = _sponsor(request)
|
| 604 |
+
async with request.form() as form:
|
| 605 |
+
upload = form.get("file")
|
| 606 |
+
if upload is None or not getattr(upload, "filename", ""):
|
| 607 |
+
raise ApiError(400, "Choose a file with an example item.")
|
| 608 |
+
path, filename, content_type = await _save(upload)
|
| 609 |
+
try:
|
| 610 |
+
# Browsers rarely name a JSON Lines file's type; DataBounty checks the pair.
|
| 611 |
+
content_type = SAMPLE_TYPES.get(Path(filename).suffix.lower(), content_type)
|
| 612 |
+
account.upload_sample(session, request_id, path, filename, content_type)
|
| 613 |
+
except account.NotSignedIn as exc:
|
| 614 |
+
raise ApiError(401, "Your sign-in ended. Sign in again to continue.") from exc
|
| 615 |
+
except account.ToolError as exc:
|
| 616 |
+
raise ApiError(400, f"{filename}: {exc}") from exc
|
| 617 |
+
finally:
|
| 618 |
+
Path(path).unlink(missing_ok=True)
|
| 619 |
+
return _ok({"ok": True})
|