kink-discovery / backend /users.py
Perplexed7675's picture
Sync from kink_cli (Docker Space)
6ff91d6 verified
Raw
History Blame Contribute Delete
14.6 kB
"""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