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