File size: 9,065 Bytes
b83ff8a
 
 
 
d8a3fad
b83ff8a
 
53d19be
b83ff8a
 
 
 
 
 
 
 
 
 
 
 
 
 
d8a3fad
 
 
 
53d19be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b83ff8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8183844
 
 
 
b83ff8a
 
 
8183844
b83ff8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8183844
b83ff8a
8183844
 
 
b83ff8a
 
 
 
 
 
 
 
 
8183844
b83ff8a
 
d8a3fad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b83ff8a
 
 
 
 
 
 
 
 
d8a3fad
 
 
 
53d19be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b83ff8a
 
 
 
 
 
d8a3fad
b83ff8a
 
53d19be
 
d8a3fad
 
b83ff8a
 
 
 
 
 
d8a3fad
 
b83ff8a
 
 
 
 
 
d8a3fad
 
 
 
b83ff8a
 
 
53d19be
 
 
b83ff8a
 
 
53d19be
593e0a3
b83ff8a
53d19be
b83ff8a
53d19be
 
 
b83ff8a
 
 
 
 
 
53d19be
b83ff8a
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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
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
157
158
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
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
"""Vote payload validation and commit logic for the climbing-holds dataset."""

from __future__ import annotations

import hashlib
import os
import re
from collections import Counter
from typing import Any

from huggingface_hub import HfApi

import config
import hf_repo

RATING_MIN = 1
RATING_MAX = 5
ISO8601_PATTERN = re.compile(
    r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"
)


class DuplicateVoteError(Exception):
    """Raised when the same voter has already voted for a given hold."""


def infer_dominant_values(votes: list[dict[str, Any]]) -> tuple[str | None, str | None]:
    """Return the most-voted manufacturer and model from a list of vote entries.

    Only non-empty, non-whitespace values are counted. Ties are broken by
    picking the lexicographically smallest value so the result is deterministic.
    Returns (None, None) when no valid votes exist.
    """
    manufacturers: list[str] = []
    models: list[str] = []
    for vote in votes:
        if not isinstance(vote, dict):
            continue
        m = vote.get("hold_manufacturer")
        if isinstance(m, str) and m.strip():
            manufacturers.append(m.strip())
        mo = vote.get("hold_model")
        if isinstance(mo, str) and mo.strip():
            models.append(mo.strip())

    def _dominant(values: list[str]) -> str | None:
        if not values:
            return None
        counts = Counter(values)
        max_count = max(counts.values())
        candidates = sorted(k for k, v in counts.items() if v == max_count)
        return candidates[0]

    return _dominant(manufacturers), _dominant(models)


def _validate_rating(value: Any) -> int:
    if not isinstance(value, (int, float)):
        raise ValueError("hold_3d_file_rating must be a number")
    r = int(value) if isinstance(value, float) else value
    if r != value or r < RATING_MIN or r > RATING_MAX:
        raise ValueError(f"hold_3d_file_rating must be an integer between {RATING_MIN} and {RATING_MAX}")
    return r


def _validate_vote_datetime(value: Any) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ValueError("vote_datetime must be a non-empty string")
    s = value.strip()
    if not ISO8601_PATTERN.match(s):
        raise ValueError("vote_datetime must be ISO 8601 format (e.g. 2025-03-12T14:30:00.000Z)")
    return s


def validate_vote_payload(
    body: dict[str, Any],
    hf_token: str | None = None,
) -> tuple[dict[str, Any], str | None]:
    """
    Validate vote payload and build the stored vote entry (no token).
    Returns (vote_entry, hf_token_or_none). Raises ValueError on validation error.
    The user's hf_token is passed via the Authorization header and must never be persisted.
    """
    required = ("hold_id", "hold_manufacturer", "hold_model", "hold_3d_file_rating", "vote_datetime", "anonymous")
    for key in required:
        if key not in body:
            raise ValueError(f"Missing required field: {key}")

    hold_id = body["hold_id"]
    if not isinstance(hold_id, str) or not hold_id.strip():
        raise ValueError("hold_id must be a non-empty string")
    hold_id = hold_id.strip()

    hold_manufacturer = body["hold_manufacturer"]
    if not isinstance(hold_manufacturer, str):
        raise ValueError("hold_manufacturer must be a string")
    hold_manufacturer = hold_manufacturer.strip()

    hold_model = body["hold_model"]
    if not isinstance(hold_model, str):
        raise ValueError("hold_model must be a string")
    hold_model = hold_model.strip()

    rating = _validate_rating(body["hold_3d_file_rating"])
    vote_datetime = _validate_vote_datetime(body["vote_datetime"])
    anonymous = body["anonymous"]
    if not isinstance(anonymous, bool):
        raise ValueError("anonymous must be a boolean")

    resolved_token: str | None = None
    if not anonymous:
        if not hf_token or not hf_token.strip():
            raise ValueError("Authorization header with Bearer token is required when anonymous is false")
        resolved_token = hf_token.strip()

    entry = {
        "hold_id": hold_id,
        "hold_manufacturer": hold_manufacturer,
        "hold_model": hold_model,
        "hold_3d_file_rating": rating,
        "vote_datetime": vote_datetime,
        "anonymous": anonymous,
    }
    return entry, resolved_token


def compute_voter_fingerprint(
    client_ip: str,
    anonymous: bool,
    user_token: str | None,
) -> str:
    """Build a unique, stable fingerprint for a voter.

    - Non-anonymous with a valid HF token: resolve the HF username via whoami.
    - Anonymous or token resolution fails: SHA-256 of the client IP.
    """
    if not anonymous and user_token:
        try:
            info = HfApi().whoami(token=user_token)
            username = info.get("name") or info.get("user")
            if username:
                return f"hf:{username}"
        except Exception:
            pass
    return f"ip:{hashlib.sha256(client_ip.encode()).hexdigest()}"


def _resolve_commit_token(anonymous: bool, user_token: str | None) -> str:
    hf_token = os.environ.get("HF_TOKEN")
    if anonymous or not user_token:
        if not hf_token:
            raise RuntimeError("HF_TOKEN is not set (required for anonymous votes or when user token is missing)")
        return hf_token
    return user_token


def _has_existing_vote(hold_votes: list[dict[str, Any]], fingerprint: str) -> bool:
    return any(v.get("voter_fingerprint") == fingerprint for v in hold_votes)


def _build_metadata_update(
    repo_id: str,
    token: str,
    revision: str | None,
    hold_id: str,
    hold_votes: list[dict[str, Any]],
) -> tuple[str, dict[str, Any]] | None:
    """Load metadata.json for the hold and apply dominant manufacturer/model from votes.

    Returns (metadata_path, updated_metadata) if any field changed, else None.
    """
    metadata_path = f"{hold_id}/{config.METADATA_FILENAME}"
    metadata = hf_repo.load_json_file_optional(
        repo_id, metadata_path, token, revision, default=None
    )
    if not isinstance(metadata, dict):
        config.logger.warning(
            "Could not load metadata for hold '%s'; skipping metadata update from votes.", hold_id
        )
        return None

    dominant_manufacturer, dominant_model = infer_dominant_values(hold_votes)
    updated = dict(metadata)
    changed = False

    if dominant_manufacturer and updated.get("manufacturer") != dominant_manufacturer:
        updated["manufacturer"] = dominant_manufacturer
        changed = True

    if dominant_model and updated.get("model") != dominant_model:
        updated["model"] = dominant_model
        changed = True

    if not changed:
        return None

    config.logger.info(
        "Updating hold '%s' metadata from votes: manufacturer=%r, model=%r",
        hold_id,
        dominant_manufacturer,
        dominant_model,
    )
    return metadata_path, updated


def process_vote(
    api: HfApi,
    repo_id: str,
    revision: str | None,
    vote_entry: dict[str, Any],
    user_token: str | None,
    client_ip: str,
) -> dict[str, Any]:
    """
    Load hold votes, check for duplicates, append entry, commit votes.json and
    metadata.json (with dominant manufacturer/model from all votes) together.
    On commit failure with user token, retries with HF_TOKEN.
    Raises DuplicateVoteError if the same voter already voted for this hold.
    """
    anonymous = vote_entry.get("anonymous", True)
    token = _resolve_commit_token(anonymous, user_token)
    hold_id = vote_entry["hold_id"]
    hold_votes_path = f"{hold_id}/{config.VOTES_FILENAME}"

    fingerprint = compute_voter_fingerprint(client_ip, anonymous, user_token)

    hold_votes: list[Any] = hf_repo.load_json_file_optional(
        repo_id, hold_votes_path, token, revision, default=[]
    )
    if not isinstance(hold_votes, list):
        hold_votes = []

    if _has_existing_vote(hold_votes, fingerprint):
        raise DuplicateVoteError("You have already voted for this hold")

    vote_entry["voter_fingerprint"] = fingerprint
    hold_votes.append(vote_entry)
    hold_votes_map = {hold_votes_path: hold_votes}

    metadata_update = _build_metadata_update(repo_id, token, revision, hold_id, hold_votes)

    def _do_commit(commit_token: str) -> None:
        hf_repo.commit_vote_updates(
            api,
            repo_id=repo_id,
            token=commit_token,
            revision=revision,
            hold_votes=hold_votes_map,
            metadata_update=metadata_update,
        )

    try:
        _do_commit(token)
        return {"status": "success", "message": "Vote recorded"}
    except Exception as exc:
        if not anonymous and user_token and token == user_token:
            hf_token = os.environ.get("HF_TOKEN")
            if hf_token and hf_token != user_token:
                config.logger.warning("Commit with user token failed, retrying with HF_TOKEN: %s", exc)
                _do_commit(hf_token)
                return {"status": "success", "message": "Vote recorded"}
        raise