Spaces:
Sleeping
Sleeping
| """Backend users service.""" | |
| from __future__ import annotations | |
| import hashlib | |
| from collections import defaultdict | |
| from typing import Any | |
| from sqlmodel import Session, delete, or_, select | |
| from models import (PartnerGroup, PartnerGroupMember, PartnerLink, PartnerLinkRequest, PlayPreference, | |
| PromptDismissal, RolePreference, ScenarioPreference, User, UserPreference) | |
| from backend.constants import BLOCKED_RATINGS, POSITIVE_RATINGS, SUPPRESSED_RATINGS | |
| def create_user(self) -> User: | |
| public_id = self._make_memorable_id() | |
| private_token = self._make_memorable_pass() | |
| with Session(self.engine) as session: | |
| while session.get(User, public_id): | |
| public_id = self._make_memorable_id() | |
| user = User(id=public_id, private_token=private_token) | |
| session.add(user) | |
| session.add(UserPreference(user_id=public_id, show_images=False)) | |
| session.commit() | |
| session.refresh(user) | |
| return user | |
| def _load_play_preferences(self, session: Session, user_id: str) -> dict[str, dict[str, Any]]: | |
| prefs = session.exec(select(PlayPreference).where(PlayPreference.user_id == user_id)).all() | |
| return { | |
| pref.kink_id: { | |
| "interest_state": pref.interest_state, | |
| "directions": self._deserialize_directions(pref.directions_csv), | |
| } | |
| for pref in prefs | |
| } | |
| def _load_role_preferences(self, session: Session, user_id: str) -> list[str]: | |
| prefs = session.exec(select(RolePreference).where(RolePreference.user_id == user_id)).all() | |
| return sorted(pref.kink_id for pref in prefs) | |
| def get_user(self, user_id: str) -> dict | None: | |
| with Session(self.engine) as session: | |
| user = session.get(User, user_id) | |
| if not user: | |
| return None | |
| play_map = self._load_play_preferences(session, user.id) | |
| scenario_map = self._load_scenario_preferences(session, user.id) | |
| role_ids = self._load_role_preferences(session, user.id) | |
| links = session.exec( | |
| select(PartnerLink).where( | |
| or_(PartnerLink.left_user_id == user.id, PartnerLink.right_user_id == user.id) | |
| ) | |
| ).all() | |
| incoming_req = session.exec( | |
| select(PartnerLinkRequest).where(PartnerLinkRequest.to_user_id == user.id) | |
| ).all() | |
| outgoing_req = session.exec( | |
| select(PartnerLinkRequest).where(PartnerLinkRequest.from_user_id == user.id) | |
| ).all() | |
| pref = session.get(UserPreference, user.id) | |
| partners = [ | |
| link.right_user_id if link.left_user_id == user.id else link.left_user_id | |
| for link in links | |
| ] | |
| return { | |
| "id": user.id, | |
| "private_token": user.private_token, | |
| "plays": play_map, | |
| "scenario_preferences": scenario_map, | |
| "roles": role_ids, | |
| "partners": sorted(partners), | |
| "incoming_partner_requests": sorted(r.from_user_id for r in incoming_req), | |
| "outgoing_partner_requests": sorted(r.to_user_id for r in outgoing_req), | |
| "partner_groups": self.list_partner_groups(user.id), | |
| "preferences": { | |
| "show_images": bool(pref.show_images) if pref else False, | |
| }, | |
| } | |
| def authenticate_user(self, user_id: str, private_token: str) -> dict | None: | |
| user = self.get_user(user_id) | |
| if not user: | |
| return None | |
| if user["private_token"] != private_token: | |
| return None | |
| return user | |
| def all_users(self) -> dict[str, dict]: | |
| with Session(self.engine) as session: | |
| users = session.exec(select(User).order_by(User.id)).all() | |
| return {user.id: self.get_user(user.id) for user in users} | |
| def delete_user(self, user_id: str) -> None: | |
| with Session(self.engine) as session: | |
| user = session.get(User, user_id) | |
| if not user: | |
| return | |
| session.exec(delete(PlayPreference).where(PlayPreference.user_id == user_id)) | |
| session.exec(delete(ScenarioPreference).where(ScenarioPreference.user_id == user_id)) | |
| session.exec(delete(RolePreference).where(RolePreference.user_id == user_id)) | |
| session.exec(delete(UserPreference).where(UserPreference.user_id == user_id)) | |
| session.exec(delete(PromptDismissal).where(PromptDismissal.user_id == user_id)) | |
| session.exec( | |
| delete(PartnerLinkRequest).where( | |
| or_(PartnerLinkRequest.from_user_id == user_id, PartnerLinkRequest.to_user_id == user_id) | |
| ) | |
| ) | |
| session.exec(delete(PartnerLink).where(or_(PartnerLink.left_user_id == user_id, PartnerLink.right_user_id == user_id))) | |
| session.exec(delete(PartnerGroupMember).where(PartnerGroupMember.member_user_id == user_id)) | |
| groups = session.exec(select(PartnerGroup).where(PartnerGroup.owner_user_id == user_id)).all() | |
| for group in groups: | |
| session.exec(delete(PartnerGroupMember).where(PartnerGroupMember.group_id == group.id)) | |
| session.delete(group) | |
| session.delete(user) | |
| session.commit() | |
| def export_user_data(self, user_id: str) -> dict[str, Any]: | |
| user = self.get_user(user_id) | |
| if not user: | |
| return {} | |
| board = self.play_board(user_id) | |
| groups = self.list_partner_groups(user_id) | |
| with Session(self.engine) as session: | |
| roles = session.exec(select(RolePreference).where(RolePreference.user_id == user_id)).all() | |
| return { | |
| "user_id": user_id, | |
| "plays": user.get("plays", {}), | |
| "scenario_preferences": user.get("scenario_preferences", {}), | |
| "roles": [r.kink_id for r in roles], | |
| "partners": user.get("partners", []), | |
| "partner_groups": groups, | |
| "board": board, | |
| "preferences": user.get("preferences", {}), | |
| } | |
| def partner_activity_hash(self, user_id: str) -> str: | |
| with Session(self.engine) as session: | |
| partner_ids = self._linked_partner_ids(session, user_id) | |
| if not partner_ids: | |
| return "" | |
| parts = [] | |
| for pid in sorted(partner_ids): | |
| plays = session.exec( | |
| select(PlayPreference).where(PlayPreference.user_id == pid).order_by(PlayPreference.kink_id) | |
| ).all() | |
| scenarios = session.exec( | |
| select(ScenarioPreference).where(ScenarioPreference.user_id == pid).order_by(ScenarioPreference.parent_kink_id, ScenarioPreference.scenario_kink_id) | |
| ).all() | |
| play_states = ",".join( | |
| f"{p.kink_id}={p.interest_state}:{p.directions_csv or ''}" | |
| for p in plays | |
| ) | |
| scenario_states = ",".join( | |
| f"{p.parent_kink_id}>{p.scenario_kink_id}={p.interest_state}:{p.directions_csv or ''}" | |
| for p in scenarios | |
| ) | |
| states = f"{play_states}|scenarios:{scenario_states}" | |
| parts.append(f"{pid}:{len(plays)}:{len(scenarios)}:{hashlib.md5(states.encode(), usedforsecurity=False).hexdigest()[:8]}") | |
| return hashlib.md5(":".join(parts).encode(), usedforsecurity=False).hexdigest()[:12] | |
| def save_preferences(self, user_id: str, show_images: bool | None = None) -> dict | None: | |
| with Session(self.engine) as session: | |
| if not session.get(User, user_id): | |
| return None | |
| pref = session.get(UserPreference, user_id) | |
| if not pref: | |
| pref = UserPreference(user_id=user_id, show_images=False) | |
| session.add(pref) | |
| if show_images is not None: | |
| pref.show_images = show_images | |
| session.commit() | |
| return self.get_user(user_id) | |
| def remove_play_preference(self, user_id: str, kink_id: str) -> None: | |
| changed = False | |
| with Session(self.engine) as session: | |
| pref = session.get(PlayPreference, (user_id, kink_id)) | |
| if pref: | |
| session.delete(pref) | |
| session.commit() | |
| changed = True | |
| if changed: | |
| self.invalidate_recommendation_cache() | |
| def save_play_preference( | |
| self, | |
| user_id: str, | |
| kink_id: str, | |
| interest_state: str, | |
| directions: list[str] | None = None, | |
| ) -> None: | |
| with Session(self.engine) as session: | |
| pref = session.get(PlayPreference, (user_id, kink_id)) | |
| kink = self._catalog()["detail_by_id"].get(kink_id) | |
| normalized_directions = self._normalize_directions(directions) | |
| if not normalized_directions and kink: | |
| normalized_directions = self._default_directions_for_kink(kink, interest_state) | |
| serialized = self._serialize_directions(normalized_directions) | |
| if pref: | |
| pref.interest_state = interest_state | |
| pref.directions_csv = serialized | |
| else: | |
| session.add( | |
| PlayPreference( | |
| user_id=user_id, | |
| kink_id=kink_id, | |
| interest_state=interest_state, | |
| directions_csv=serialized, | |
| ) | |
| ) | |
| session.commit() | |
| self.invalidate_recommendation_cache() | |
| def save_role_preference(self, user_id: str, kink_id: str, selected: bool) -> None: | |
| with Session(self.engine) as session: | |
| pref = session.get(RolePreference, (user_id, kink_id)) | |
| if selected: | |
| if not pref: | |
| session.add(RolePreference(user_id=user_id, kink_id=kink_id)) | |
| elif pref: | |
| session.delete(pref) | |
| session.commit() | |
| def role_cards(self, user_id: str) -> list[dict[str, Any]]: | |
| user = self.get_user(user_id) | |
| if not user: | |
| return [] | |
| detail_by_id = self._catalog()["detail_by_id"] | |
| items = [detail_by_id[kink_id] for kink_id in user.get("roles", []) if kink_id in detail_by_id] | |
| items.sort(key=lambda item: (-item["popularity"], item["name"].lower())) | |
| return items | |
| def link_users(self, left_id: str, right_id: str) -> bool: | |
| if left_id == right_id: | |
| return False | |
| ordered = tuple(sorted((left_id, right_id))) | |
| with Session(self.engine) as session: | |
| if not session.get(User, left_id) or not session.get(User, right_id): | |
| return False | |
| if not session.get(PartnerLink, ordered): | |
| session.add(PartnerLink(left_user_id=ordered[0], right_user_id=ordered[1])) | |
| session.commit() | |
| self.ensure_pair_partner_groups(left_id, right_id) | |
| return True | |
| def _clear_partner_requests_between(session: Session, a: str, b: str) -> None: | |
| session.exec(delete(PartnerLinkRequest).where(PartnerLinkRequest.from_user_id == a, PartnerLinkRequest.to_user_id == b)) | |
| session.exec(delete(PartnerLinkRequest).where(PartnerLinkRequest.from_user_id == b, PartnerLinkRequest.to_user_id == a)) | |
| def request_partner_link(self, from_id: str, to_id: str) -> tuple[bool, str]: | |
| """Create a pending link request, or link immediately if the other person already asked you.""" | |
| if from_id == to_id: | |
| return False, "Cannot link to yourself" | |
| with Session(self.engine) as session: | |
| if not session.get(User, from_id) or not session.get(User, to_id): | |
| return False, "Unknown user" | |
| ordered = tuple(sorted((from_id, to_id))) | |
| if session.get(PartnerLink, ordered): | |
| return False, "Already linked" | |
| reverse = session.get(PartnerLinkRequest, (to_id, from_id)) | |
| if reverse: | |
| _clear_partner_requests_between(session, from_id, to_id) | |
| session.commit() | |
| if not self.link_users(from_id, to_id): | |
| return False, "Could not complete link" | |
| return True, "linked" | |
| if session.get(PartnerLinkRequest, (from_id, to_id)): | |
| return True, "pending" | |
| session.add( | |
| PartnerLinkRequest(from_user_id=from_id, to_user_id=to_id, created_at=self._now_iso()) | |
| ) | |
| session.commit() | |
| return True, "sent" | |
| def accept_partner_request(self, accepter_id: str, from_user_id: str) -> bool: | |
| """Accept an incoming request from ``from_user_id``.""" | |
| if accepter_id == from_user_id: | |
| return False | |
| with Session(self.engine) as session: | |
| req = session.get(PartnerLinkRequest, (from_user_id, accepter_id)) | |
| if not req: | |
| return False | |
| session.delete(req) | |
| session.commit() | |
| return self.link_users(from_user_id, accepter_id) | |
| def decline_partner_request(self, decliner_id: str, from_user_id: str) -> bool: | |
| """Decline an incoming request from ``from_user_id``.""" | |
| with Session(self.engine) as session: | |
| req = session.get(PartnerLinkRequest, (from_user_id, decliner_id)) | |
| if not req: | |
| return False | |
| session.delete(req) | |
| session.commit() | |
| return True | |
| def cancel_partner_request(self, from_id: str, to_id: str) -> bool: | |
| """Withdraw an outgoing request.""" | |
| with Session(self.engine) as session: | |
| req = session.get(PartnerLinkRequest, (from_id, to_id)) | |
| if not req: | |
| return False | |
| session.delete(req) | |
| session.commit() | |
| return True | |
| def dismiss_prompt(self, user_id: str, kink_id: str) -> None: | |
| with Session(self.engine) as session: | |
| existing = session.get(PromptDismissal, (user_id, kink_id)) | |
| if not existing: | |
| session.add(PromptDismissal(user_id=user_id, kink_id=kink_id)) | |
| session.commit() | |
| def play_board(self, user_id: str) -> dict[str, list[dict[str, Any]]]: | |
| user = self.get_user(user_id) | |
| if not user: | |
| return {"to_me": [], "by_me": [], "together": [], "hidden": [], "no_go": []} | |
| detail_by_id = self._catalog()["detail_by_id"] | |
| board = {"to_me": [], "by_me": [], "together": [], "hidden": [], "no_go": []} | |
| for kink_id, state in user["plays"].items(): | |
| kink = detail_by_id.get(kink_id) | |
| if not kink: | |
| continue | |
| item = { | |
| "id": kink["id"], | |
| "name": kink["name"], | |
| "summary": kink["summary"], | |
| "popularity": kink["popularity"], | |
| "interest_state": state["interest_state"], | |
| "directions": state["directions"], | |
| } | |
| if state["interest_state"] in BLOCKED_RATINGS: | |
| board["no_go"].append(item) | |
| continue | |
| if state["interest_state"] in SUPPRESSED_RATINGS: | |
| board["hidden"].append(item) | |
| continue | |
| placed = False | |
| for direction in state["directions"]: | |
| if direction in board: | |
| board[direction].append(item) | |
| placed = True | |
| if not placed: | |
| board["together"].append(item) | |
| for key in board: | |
| board[key].sort(key=lambda item: (-item["popularity"], item["name"].lower())) | |
| return board | |