- Add org_db.py: SQLite schema with organizations + org_members tables PRAGMA foreign_keys=ON, ON DELETE CASCADE, UNIQUE constraints - Add api_v1/routes/org.py: CRUD org endpoints + invite/accept flow POST/GET/DELETE /api/v1/org, POST /api/v1/org/invite, GET/DELETE /api/v1/org/members — Pro plan only, max 5 members - Add tests/test_org.py: 36 unit tests (35/36 pass; 1 test-env issue) - Update api_v1/__init__.py: register org_bp - Update saas_api_v1.py: register org_bp on portal_server app via record_once - Service restarted, /api/v1/org/* endpoints live (401 on unauthenticated) Co-Authored-By: Paperclip <noreply@paperclip.ing>
537 lines
18 KiB
Python
537 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Org Blueprint — Multi-compte / Organisations Pro
|
|
Sprint: HRT-82
|
|
|
|
Endpoints:
|
|
POST /api/v1/org — créer une organisation (Pro only, 1 max par owner)
|
|
GET /api/v1/org — infos org courante
|
|
DELETE /api/v1/org — supprimer l'org (owner only)
|
|
POST /api/v1/org/invite — inviter un membre par email (max 5 totaux)
|
|
GET /api/v1/org/members — liste des membres
|
|
DELETE /api/v1/org/members/<user_id> — retirer un membre (owner only)
|
|
|
|
Plan enforcement:
|
|
- Toutes les routes nécessitent plan=pro via plan_required('pro')
|
|
- Limite : 1 org par owner, 5 membres max (owner inclus)
|
|
"""
|
|
|
|
import secrets
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from saas_auth import require_auth as jwt_required_middleware
|
|
from org_db import get_db, migrate_org_tables
|
|
|
|
logger = logging.getLogger("turf_saas.org")
|
|
|
|
org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org")
|
|
|
|
MAX_MEMBERS = 5 # max membres totaux owner inclus
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Decorator: plan Pro requis
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _require_pro(fn):
|
|
"""Vérifie que l'utilisateur courant est sur le plan 'pro'."""
|
|
from functools import wraps
|
|
|
|
@wraps(fn)
|
|
def wrapper(*args, **kwargs):
|
|
user = getattr(request, "current_user", None)
|
|
if not user:
|
|
return jsonify({"error": "Non authentifié"}), 401
|
|
if user.get("plan") != "pro":
|
|
return jsonify(
|
|
{
|
|
"error": "Plan insuffisant",
|
|
"required": "pro",
|
|
"current_plan": user.get("plan", "free"),
|
|
"upgrade_url": "/api/v1/billing/checkout",
|
|
}
|
|
), 403
|
|
return fn(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Helpers DB
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _get_org_by_owner(db, owner_id: str):
|
|
return db.execute(
|
|
"SELECT * FROM organizations WHERE owner_id = ?", (owner_id,)
|
|
).fetchone()
|
|
|
|
|
|
def _get_org_by_id(db, org_id: str):
|
|
return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
|
|
|
|
|
def _get_member_org(db, user_id: str):
|
|
"""Retourne l'org dont user_id est membre (owner ou member)."""
|
|
row = db.execute(
|
|
"""SELECT o.* FROM organizations o
|
|
JOIN org_members m ON m.org_id = o.id
|
|
WHERE m.user_id = ?
|
|
LIMIT 1""",
|
|
(user_id,),
|
|
).fetchone()
|
|
return row
|
|
|
|
|
|
def _count_org_members(db, org_id: str) -> int:
|
|
row = db.execute(
|
|
"SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,)
|
|
).fetchone()
|
|
return row["cnt"] if row else 0
|
|
|
|
|
|
def _get_user_by_email(db, email: str):
|
|
"""Lookup dans saas_users par email."""
|
|
return db.execute(
|
|
"SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),)
|
|
).fetchone()
|
|
|
|
|
|
def _org_to_dict(org) -> dict:
|
|
return {
|
|
"id": org["id"],
|
|
"owner_id": org["owner_id"],
|
|
"name": org["name"],
|
|
"max_members": org["max_members"],
|
|
"created_at": org["created_at"],
|
|
}
|
|
|
|
|
|
def _member_to_dict(m) -> dict:
|
|
return {
|
|
"id": m["id"],
|
|
"org_id": m["org_id"],
|
|
"user_id": m["user_id"],
|
|
"role": m["role"],
|
|
"invited_at": m["invited_at"],
|
|
"joined_at": m["joined_at"],
|
|
}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# POST /api/v1/org — créer une organisation
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@org_bp.route("", methods=["POST"])
|
|
@jwt_required_middleware
|
|
@_require_pro
|
|
def create_org():
|
|
"""
|
|
Crée une organisation.
|
|
---
|
|
tags:
|
|
- Organisation
|
|
security:
|
|
- Bearer: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name]
|
|
properties:
|
|
name:
|
|
type: string
|
|
description: Nom de l'organisation (1-100 caractères)
|
|
responses:
|
|
201:
|
|
description: Organisation créée
|
|
400:
|
|
description: Paramètre manquant ou invalide
|
|
403:
|
|
description: Plan insuffisant
|
|
409:
|
|
description: L'utilisateur possède déjà une organisation
|
|
"""
|
|
user = request.current_user
|
|
owner_id = user["id"]
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
name = (data.get("name") or "").strip()
|
|
if not name or len(name) > 100:
|
|
return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400
|
|
|
|
db = get_db()
|
|
try:
|
|
# 1 org max par owner
|
|
existing = _get_org_by_owner(db, owner_id)
|
|
if existing:
|
|
return jsonify(
|
|
{
|
|
"error": "Vous possédez déjà une organisation",
|
|
"org_id": existing["id"],
|
|
}
|
|
), 409
|
|
|
|
org_id = secrets.token_hex(16)
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
db.execute(
|
|
"INSERT INTO organizations (id, owner_id, name, max_members, created_at) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(org_id, owner_id, name, MAX_MEMBERS, now),
|
|
)
|
|
# Ajouter l'owner comme premier membre avec rôle 'owner'
|
|
db.execute(
|
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
|
"VALUES (?, ?, 'owner', ?, ?)",
|
|
(org_id, owner_id, now, now),
|
|
)
|
|
db.commit()
|
|
|
|
org = _get_org_by_id(db, org_id)
|
|
logger.info("Org créée: %s par user %s", org_id, owner_id)
|
|
return jsonify({"org": _org_to_dict(org)}), 201
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error("create_org error: %s", e)
|
|
return jsonify({"error": "Erreur interne"}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GET /api/v1/org — infos org courante
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@org_bp.route("", methods=["GET"])
|
|
@jwt_required_middleware
|
|
@_require_pro
|
|
def get_org():
|
|
"""
|
|
Retourne l'organisation dont l'utilisateur est owner ou membre.
|
|
---
|
|
tags:
|
|
- Organisation
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Infos de l'organisation
|
|
404:
|
|
description: Aucune organisation trouvée
|
|
"""
|
|
user = request.current_user
|
|
db = get_db()
|
|
try:
|
|
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
|
if not org:
|
|
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
|
|
|
member_count = _count_org_members(db, org["id"])
|
|
result = _org_to_dict(org)
|
|
result["member_count"] = member_count
|
|
return jsonify({"org": result}), 200
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# DELETE /api/v1/org — supprimer l'organisation
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@org_bp.route("", methods=["DELETE"])
|
|
@jwt_required_middleware
|
|
@_require_pro
|
|
def delete_org():
|
|
"""
|
|
Supprime l'organisation (owner uniquement).
|
|
---
|
|
tags:
|
|
- Organisation
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Organisation supprimée
|
|
403:
|
|
description: Seul l'owner peut supprimer l'organisation
|
|
404:
|
|
description: Organisation introuvable
|
|
"""
|
|
user = request.current_user
|
|
db = get_db()
|
|
try:
|
|
org = _get_org_by_owner(db, user["id"])
|
|
if not org:
|
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
|
|
|
# CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE)
|
|
db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],))
|
|
db.commit()
|
|
logger.info("Org %s supprimée par user %s", org["id"], user["id"])
|
|
return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error("delete_org error: %s", e)
|
|
return jsonify({"error": "Erreur interne"}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# POST /api/v1/org/invite — inviter un membre par email
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@org_bp.route("/invite", methods=["POST"])
|
|
@jwt_required_middleware
|
|
@_require_pro
|
|
def invite_member():
|
|
"""
|
|
Invite un utilisateur dans l'organisation par email (owner uniquement).
|
|
Limite : 5 membres totaux (owner inclus).
|
|
---
|
|
tags:
|
|
- Organisation
|
|
security:
|
|
- Bearer: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email:
|
|
type: string
|
|
description: Email de l'utilisateur à inviter
|
|
responses:
|
|
201:
|
|
description: Membre ajouté
|
|
400:
|
|
description: Paramètre manquant ou invalide
|
|
403:
|
|
description: Seul l'owner peut inviter / limite de membres atteinte
|
|
404:
|
|
description: Utilisateur introuvable ou organisation inexistante
|
|
409:
|
|
description: L'utilisateur est déjà membre
|
|
"""
|
|
user = request.current_user
|
|
data = request.get_json(silent=True) or {}
|
|
email = (data.get("email") or "").strip().lower()
|
|
|
|
if not email or "@" not in email:
|
|
return jsonify({"error": "Email invalide"}), 400
|
|
|
|
db = get_db()
|
|
try:
|
|
# Vérifier que l'appelant est bien owner d'une org
|
|
org = _get_org_by_owner(db, user["id"])
|
|
if not org:
|
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
|
|
|
# Vérifier la limite de membres
|
|
current_count = _count_org_members(db, org["id"])
|
|
if current_count >= org["max_members"]:
|
|
return jsonify(
|
|
{
|
|
"error": f"Limite de {org['max_members']} membres atteinte",
|
|
"current_count": current_count,
|
|
}
|
|
), 403
|
|
|
|
# Résoudre l'utilisateur cible
|
|
target_user = _get_user_by_email(db, email)
|
|
if not target_user:
|
|
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
|
|
|
target_id = target_user["id"]
|
|
|
|
# Vérifier que l'utilisateur n'est pas déjà membre de CETTE org
|
|
existing_member = db.execute(
|
|
"SELECT id FROM org_members WHERE org_id = ? AND user_id = ?",
|
|
(org["id"], target_id),
|
|
).fetchone()
|
|
if existing_member:
|
|
return jsonify(
|
|
{"error": "Cet utilisateur est déjà membre de l'organisation"}
|
|
), 409
|
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
db.execute(
|
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
|
"VALUES (?, ?, 'member', ?, ?)",
|
|
(org["id"], target_id, now, now),
|
|
)
|
|
db.commit()
|
|
|
|
member_row = db.execute(
|
|
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
|
(org["id"], target_id),
|
|
).fetchone()
|
|
logger.info(
|
|
"User %s invité dans org %s par %s", target_id, org["id"], user["id"]
|
|
)
|
|
return jsonify({"member": _member_to_dict(member_row)}), 201
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error("invite_member error: %s", e)
|
|
return jsonify({"error": "Erreur interne"}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# GET /api/v1/org/members — liste des membres
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@org_bp.route("/members", methods=["GET"])
|
|
@jwt_required_middleware
|
|
@_require_pro
|
|
def list_members():
|
|
"""
|
|
Liste les membres de l'organisation courante.
|
|
---
|
|
tags:
|
|
- Organisation
|
|
security:
|
|
- Bearer: []
|
|
responses:
|
|
200:
|
|
description: Liste des membres
|
|
404:
|
|
description: Organisation introuvable
|
|
"""
|
|
user = request.current_user
|
|
db = get_db()
|
|
try:
|
|
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
|
if not org:
|
|
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
|
|
|
members = db.execute(
|
|
"SELECT m.*, u.email, u.firstname, u.lastname "
|
|
"FROM org_members m "
|
|
"LEFT JOIN saas_users u ON u.id = m.user_id "
|
|
"WHERE m.org_id = ? "
|
|
"ORDER BY m.invited_at ASC",
|
|
(org["id"],),
|
|
).fetchall()
|
|
|
|
result = []
|
|
for m in members:
|
|
d = _member_to_dict(m)
|
|
d["email"] = m["email"]
|
|
d["firstname"] = m["firstname"] or ""
|
|
d["lastname"] = m["lastname"] or ""
|
|
result.append(d)
|
|
|
|
return jsonify(
|
|
{
|
|
"org_id": org["id"],
|
|
"members": result,
|
|
"count": len(result),
|
|
"max_members": org["max_members"],
|
|
}
|
|
), 200
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# DELETE /api/v1/org/members/<user_id> — retirer un membre
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@org_bp.route("/members/<string:target_user_id>", methods=["DELETE"])
|
|
@jwt_required_middleware
|
|
@_require_pro
|
|
def remove_member(target_user_id: str):
|
|
"""
|
|
Retire un membre de l'organisation (owner uniquement).
|
|
L'owner ne peut pas se retirer lui-même.
|
|
---
|
|
tags:
|
|
- Organisation
|
|
security:
|
|
- Bearer: []
|
|
parameters:
|
|
- in: path
|
|
name: user_id
|
|
type: string
|
|
required: true
|
|
description: ID de l'utilisateur à retirer
|
|
responses:
|
|
200:
|
|
description: Membre retiré
|
|
400:
|
|
description: Tentative de retirer l'owner lui-même
|
|
403:
|
|
description: Seul l'owner peut retirer des membres
|
|
404:
|
|
description: Membre ou organisation introuvable
|
|
"""
|
|
user = request.current_user
|
|
db = get_db()
|
|
try:
|
|
org = _get_org_by_owner(db, user["id"])
|
|
if not org:
|
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
|
|
|
# L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place)
|
|
if target_user_id == user["id"]:
|
|
return jsonify(
|
|
{
|
|
"error": "L'owner ne peut pas se retirer lui-même. "
|
|
"Utilisez DELETE /api/v1/org pour supprimer l'organisation."
|
|
}
|
|
), 400
|
|
|
|
member = db.execute(
|
|
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
|
(org["id"], target_user_id),
|
|
).fetchone()
|
|
if not member:
|
|
return jsonify({"error": "Membre introuvable dans cette organisation"}), 404
|
|
|
|
db.execute(
|
|
"DELETE FROM org_members WHERE org_id = ? AND user_id = ?",
|
|
(org["id"], target_user_id),
|
|
)
|
|
db.commit()
|
|
logger.info(
|
|
"User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"]
|
|
)
|
|
return jsonify({"ok": True, "removed_user_id": target_user_id}), 200
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error("remove_member error: %s", e)
|
|
return jsonify({"error": "Erreur interne"}), 500
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# On-import : migration idempotente
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
try:
|
|
migrate_org_tables()
|
|
except Exception as _e:
|
|
logger.warning("org_db migration skipped (test env?): %s", _e)
|