"""Group API: create, analyze (sales kit + personas), read, edit, report.""" from __future__ import annotations import threading from pathlib import Path from flask import Blueprint, jsonify, request from ..config import Config from ..llm import LLMClient, LLMError from ..services.groups import GroupStore from ..services.store import ensure_persona_shape, revealable_view from .helpers import ApiError, current_user, require_auth, require_roles groups_bp = Blueprint("groups", __name__) _ANALYZE_LOCKS: dict[str, threading.Lock] = {} _ANALYZE_GUARD = threading.Lock() def _stores(): from flask import current_app return { "groups": current_app.extensions.get("group_store"), "users": current_app.extensions["user_store"], "session_store": current_app.extensions.get("session_store"), "llm": current_app.extensions["llm"], } def _authorize_group(group: dict) -> None: """Enforce org-scoped access (IDOR defense). super_admin may access any org. Private/personal groups (owner_user_id set) are only accessible by their owner (or super_admin), even within the same org. """ actor = current_user() if actor.get("role") == "super_admin": return owner = group.get("owner_user_id") if owner and owner != actor["id"]: raise ApiError("permission denied", 403) if group.get("org_id") != actor.get("org_id"): raise ApiError("permission denied", 403) def _get_owned_group(s, gid: str) -> dict: group = s["groups"].get_or_none(gid) if not group: raise ApiError("group not found", 404) _authorize_group(group) return group def _upload_dir(): d = Config.DATA_DIR / "uploads" d.mkdir(parents=True, exist_ok=True) return d @groups_bp.post("") @require_auth @require_roles("admin") def create_group(): """Create a persona group from a setup form + optional files.""" s = _stores() file_text = "" saved_files = [] if request.files: for file in request.files.getlist("files"): raw_name = file.filename or "" # Path traversal defense: take only the basename, drop any directory # segments and reject empty/unsafe names. Never trust client filename as a path. safe_name = Path(raw_name).name if not safe_name or safe_name in (".", "..", "/", "\\") or "/" in raw_name or "\\" in raw_name: raise ApiError("invalid file name") ext = safe_name.rsplit(".", 1)[-1].lower() if ext not in Config.ALLOWED_UPLOAD_EXTS: raise ApiError(f"unsupported file type: {ext}") dest = _upload_dir() / f"{current_user()['id'].replace('@','_')}__{safe_name}" # Ensure resolved path stays inside the upload dir (defense in depth). try: dest.resolve(strict=False).relative_to(_upload_dir().resolve(strict=True)) except ValueError: raise ApiError("invalid file path") file.save(dest) saved_files.append(dest.name) if request.content_type and "multipart/form-data" in request.content_type: data = request.form.to_dict() else: data = request.get_json(silent=True) or {} from ..services.file_parser import parse_document for name in saved_files: try: file_text += "\n\n" + parse_document(_upload_dir() / name) except Exception as exc: raise ApiError(f"could not parse file {name}: {exc}") product = (data.get("product") or "").strip() if product == "" and not file_text.strip(): raise ApiError("provide product info in the form or via file upload") group = s["groups"].create( org_id=current_user().get("org_id") or "org-default", creator_id=current_user()["id"], title=(product or file_text[:80] or "Untitled group").strip()[:200], ) s["groups"].update( group["id"], input={ "product": product, "segment": (data.get("segment") or ""), "description": (data.get("description") or ""), "channel": (data.get("channel") or "facebook"), "language": (data.get("language") or "th"), "files": saved_files, "file_text": file_text[:60000], }, ) return jsonify({"group": s["groups"].get(group["id"])}), 201 @groups_bp.get("") @require_auth def list_groups(): s = _stores() actor = current_user() visible = s["groups"].list_visible_to( role=actor.get("role"), org_id=actor.get("org_id") ) # Expose personal/private groups only to their owner (IDOR defense in listing). if actor.get("role") != "super_admin": visible = [ g for g in visible if not g.get("owner_user_id") or g.get("owner_user_id") == actor["id"] ] return jsonify({"groups": visible}) @groups_bp.post("//analyze") @require_auth @require_roles("admin") def analyze_group(gid: str): """Run analysis: sales kit + 15 personas. Synchronous for v1 (replaces gen).""" s = _stores() group = _get_owned_group(s, gid) inp = group.get("input", {}) if not s["llm"]: raise ApiError("LLM not configured", 500) from ..services.analyzer import Analyzer from ..services.persona_generator import PersonaGenerator s["groups"].update(gid, status="analyzing", error=None) try: sales_kit = Analyzer(s["llm"]).analyze( product=inp.get("product", ""), segment=inp.get("segment", ""), description=inp.get("description", ""), file_text=inp.get("file_text", ""), channel=inp.get("channel", "facebook"), ) personas = PersonaGenerator(s["llm"]).generate( sales_kit=sales_kit, language=inp.get("language", "th"), channel=inp.get("channel", "facebook"), ) except Exception as exc: s["groups"].update(gid, status="failed", error=str(exc)) raise ApiError(f"analysis failed: {exc}", 500) from ..services.report import build_report report = build_report(sales_kit=sales_kit, personas=personas, language=inp.get("language", "th")) s["groups"].update(gid, sales_kit=sales_kit, status="ready", error=None) s["groups"].set_personas(gid, personas) s["groups"].update(gid, report=report) return jsonify({ "group": s["groups"].get(gid), "sales_kit": sales_kit, "personas": s["groups"].get(gid)["personas"], }) @groups_bp.get("/") @require_auth def get_group(gid: str): s = _stores() group = _get_owned_group(s, gid) actor = current_user() view = dict(group) if actor.get("role") == "user": # Trainee: hide latent persona fields + sales kit + report (they contain # pain analysis / latent data a real seller wouldn't know before a result). view["personas"] = [revealable_view(p) for p in group.get("personas", [])] view["sales_kit"] = None view["report"] = None return jsonify({"group": view}) @groups_bp.get("//personas") @require_auth def list_personas(gid: str): s = _stores() group = _get_owned_group(s, gid) actor = current_user() if actor.get("role") == "user": if group.get("status") != "ready": raise ApiError("group not ready", 403) personas = [revealable_view(p) for p in group.get("personas", [])] else: personas = group.get("personas", []) # attach per-user status (won/lost/not-tried) for trainees if actor.get("role") == "user": sess = _stores().get("session_store") store = sess.sessions if sess else None mine = store.where(lambda r: r.get("user_id") == actor["id"] and r.get("group_id") == gid) if store else [] outcome_by_pid = {r.get("persona_id"): r.get("outcome") for r in mine} for p in personas: p["my_outcome"] = outcome_by_pid.get(p.get("id"), "not_tried") return jsonify({"personas": personas, "tiers": ["A", "B", "C"]}) @groups_bp.get("//personas/") @require_auth def get_persona(gid: str, pid: str): s = _stores() group = _get_owned_group(s, gid) p = s["groups"].get_persona(gid, pid) if not p: raise ApiError("persona not found", 404) actor = current_user() # Trainees may only view personas from ready groups (parity with list_personas). if actor.get("role") == "user" and group.get("status") != "ready": raise ApiError("group not ready", 403) ensure = ensure_persona_shape(p) if actor.get("role") == "user": return jsonify({"persona": revealable_view(ensure)}) return jsonify({"persona": ensure}) @groups_bp.put("//personas/") @require_auth @require_roles("admin") def update_persona(gid: str, pid: str): s = _stores() _get_owned_group(s, gid) data = request.get_json(silent=True) or {} try: updated = s["groups"].update_persona(gid, pid, data) except ValueError as exc: raise ApiError(str(exc), 404) return jsonify({"persona": ensure_persona_shape(updated["personas"][ next(i for i, p in enumerate(updated["personas"]) if p["id"] == pid) ])}) @groups_bp.post("//reanalyze") @require_auth @require_roles("admin") def reanalyze_group(gid: str): return analyze_group(gid)