| """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 |
|
|