Corrections des bugs de la connexion à Frisbii #90
This commit is contained in:
+3
-3
@@ -59,7 +59,7 @@ BACKUP_ENCRYPTION_KEY=
|
|||||||
|
|
||||||
# Frisbii — gestion des abonnements (https://docs.frisbii.com)
|
# Frisbii — gestion des abonnements (https://docs.frisbii.com)
|
||||||
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
|
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
|
||||||
FRISBII_API_BASE_URL= # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer)
|
FRISBII_API_BASE_URL=https://api.frisbii.com # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer)
|
||||||
FRISBII_PLAN_SIMPLE= # handle du plan "abonnement simple" (20 € HT/mois)
|
FRISBII_PLAN_SIMPLE=abonnement-decp.info # handle du plan "abonnement simple" (20 € HT/mois)
|
||||||
FRISBII_PLAN_SOUTIEN= # handle du plan "abonnement de soutien" (50 € HT/mois)
|
FRISBII_PLAN_SOUTIEN=soutien-decp.info # handle du plan "abonnement de soutien" (50 € HT/mois)
|
||||||
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
|
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
+16
-4
@@ -13,6 +13,18 @@ from src.utils import logger
|
|||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_login_url(user_id: int) -> str:
|
||||||
|
try:
|
||||||
|
from src.subscriptions import db as sub_db
|
||||||
|
|
||||||
|
if sub_db.has_active_subscription(user_id):
|
||||||
|
return "/compte/admin"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "/compte/abonnement"
|
||||||
|
|
||||||
|
|
||||||
MIN_PASSWORD_LENGTH = 8
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
# Hash bidon pré-calculé pour uniformiser le timing login
|
# Hash bidon pré-calculé pour uniformiser le timing login
|
||||||
@@ -93,7 +105,7 @@ def verify_email():
|
|||||||
def login():
|
def login():
|
||||||
email = (request.form.get("email") or "").strip().lower()
|
email = (request.form.get("email") or "").strip().lower()
|
||||||
password = request.form.get("password") or ""
|
password = request.form.get("password") or ""
|
||||||
next_url = safe_next(request.form.get("next"), fallback="/compte/admin")
|
next_url = request.form.get("next")
|
||||||
|
|
||||||
row = db.get_user_by_email(email)
|
row = db.get_user_by_email(email)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -109,7 +121,7 @@ def login():
|
|||||||
return _redirect_with_error("/connexion", "email_not_verified", email)
|
return _redirect_with_error("/connexion", "email_not_verified", email)
|
||||||
|
|
||||||
login_user(User(row), remember=True)
|
login_user(User(row), remember=True)
|
||||||
return redirect(next_url)
|
return redirect(safe_next(next_url, fallback=_post_login_url(row["id"])))
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/logout", methods=["POST"])
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
@@ -259,7 +271,7 @@ def linkedin_login():
|
|||||||
|
|
||||||
@auth_bp.route("/linkedin/callback", methods=["GET"])
|
@auth_bp.route("/linkedin/callback", methods=["GET"])
|
||||||
def linkedin_callback():
|
def linkedin_callback():
|
||||||
next_url = safe_next(session.pop("oauth_next", None), fallback="/compte/admin")
|
oauth_next = session.pop("oauth_next", None)
|
||||||
if request.args.get("error"):
|
if request.args.get("error"):
|
||||||
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
|
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
|
||||||
return _redirect_with_error("/connexion", "oauth_cancelled")
|
return _redirect_with_error("/connexion", "oauth_cancelled")
|
||||||
@@ -290,4 +302,4 @@ def linkedin_callback():
|
|||||||
"linkedin", subject, email, bool(userinfo.get("email_verified"))
|
"linkedin", subject, email, bool(userinfo.get("email_verified"))
|
||||||
)
|
)
|
||||||
login_user(user, remember=True)
|
login_user(user, remember=True)
|
||||||
return redirect(next_url)
|
return redirect(safe_next(oauth_next, fallback=_post_login_url(user.id)))
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ from flask_login import current_user
|
|||||||
# Définition centralisée des sections de l'espace compte.
|
# Définition centralisée des sections de l'espace compte.
|
||||||
# Ajouter une section future = ajouter une ligne ici (+ créer sa page).
|
# Ajouter une section future = ajouter une ligne ici (+ créer sa page).
|
||||||
SECTIONS = [
|
SECTIONS = [
|
||||||
{
|
|
||||||
"key": "admin",
|
|
||||||
"label": "Compte",
|
|
||||||
"href": "/compte/admin",
|
|
||||||
"require_subscription": False,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"key": "abonnement",
|
"key": "abonnement",
|
||||||
"label": "Abonnement",
|
"label": "Abonnement",
|
||||||
"href": "/compte/abonnement",
|
"href": "/compte/abonnement",
|
||||||
"require_subscription": False,
|
"require_subscription": False,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "admin",
|
||||||
|
"label": "Compte",
|
||||||
|
"href": "/compte/admin",
|
||||||
|
"require_subscription": False,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "archives",
|
"key": "archives",
|
||||||
"label": "Mes archives",
|
"label": "Mes archives",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import dcc, html, register_page
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from src.pages._compte_shell import account_guard, account_shell
|
from src.pages._compte_shell import account_guard, account_shell
|
||||||
@@ -16,10 +16,10 @@ register_page(
|
|||||||
_PLAN_KEYS = ("simple", "soutien")
|
_PLAN_KEYS = ("simple", "soutien")
|
||||||
|
|
||||||
|
|
||||||
def _csrf_input(index: str):
|
def _csrf_input():
|
||||||
return dcc.Input(
|
from flask_wtf.csrf import generate_csrf
|
||||||
type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token"
|
|
||||||
)
|
return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf())
|
||||||
|
|
||||||
|
|
||||||
def _plan_card(meta: dict, trial: int | None, trial_used: bool):
|
def _plan_card(meta: dict, trial: int | None, trial_used: bool):
|
||||||
@@ -37,24 +37,25 @@ def _plan_card(meta: dict, trial: int | None, trial_used: bool):
|
|||||||
return dbc.Card(
|
return dbc.Card(
|
||||||
dbc.CardBody(
|
dbc.CardBody(
|
||||||
[
|
[
|
||||||
html.H4(meta["label"]),
|
html.H4(meta["label"], className="mb-1"),
|
||||||
html.P(f"{meta['prix_ht']} € HT / mois"),
|
html.P(f"{meta['prix_ht']} € HT / mois", className="text-muted mb-3"),
|
||||||
html.P(meta["description"]),
|
html.P(meta["description"], className="mb-3"),
|
||||||
badge,
|
badge,
|
||||||
html.Form(
|
html.Form(
|
||||||
method="POST",
|
method="POST",
|
||||||
action="/subscriptions/subscribe",
|
action="/subscriptions/subscribe",
|
||||||
children=[
|
children=[
|
||||||
_csrf_input(f"subscribe-{meta['key']}"),
|
_csrf_input(),
|
||||||
dcc.Input(type="hidden", name="plan", value=meta["key"]),
|
dcc.Input(type="hidden", name="plan", value=meta["key"]),
|
||||||
html.Button(
|
html.Button(
|
||||||
"S'abonner", type="submit", className="btn btn-primary"
|
"S'abonner", type="submit", className="btn btn-primary"
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
|
className="p-4",
|
||||||
),
|
),
|
||||||
className="mb-3",
|
className="h-100",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,22 +65,45 @@ def _plan_cards(trial_used=False, trial_for=plans.trial_days):
|
|||||||
meta = plans.plan_meta(key)
|
meta = plans.plan_meta(key)
|
||||||
if meta:
|
if meta:
|
||||||
cards.append(_plan_card(meta, trial_for(key), trial_used))
|
cards.append(_plan_card(meta, trial_for(key), trial_used))
|
||||||
return dbc.Row([dbc.Col(c, md=6) for c in cards])
|
return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-5")
|
||||||
|
|
||||||
|
|
||||||
def _explainer():
|
def _explainer():
|
||||||
return html.Div(
|
col_left = dbc.Col(
|
||||||
[
|
[
|
||||||
html.H4("À quoi servent les abonnements"),
|
html.H4("Ce que les abonnements financent"),
|
||||||
html.Ul(
|
html.Ul(
|
||||||
[
|
[
|
||||||
html.Li("Abonnement Frisbii (solution de paiement) : 50 €"),
|
html.Li("Abonnement Frisbii (solution de paiement) : 50 €"),
|
||||||
html.Li("Serveur Scaleway : 40 €"),
|
html.Li("Serveur Scaleway : 40 €"),
|
||||||
html.Li("Espace de coworking : 250 €"),
|
html.Li("Espace de coworking : 250 €"),
|
||||||
html.Li("Salaire médian : 3 840 €"),
|
html.Li(
|
||||||
|
[
|
||||||
|
"Salaire médian (",
|
||||||
|
html.Span(
|
||||||
|
"coût employeur",
|
||||||
|
id="salaire-modal-trigger",
|
||||||
|
style={
|
||||||
|
"cursor": "pointer",
|
||||||
|
"textDecoration": "underline",
|
||||||
|
"color": "var(--bs-link-color)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
") : 3 840 €",
|
||||||
|
]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
html.H4("Ce que les abonnements de soutien permettraient"),
|
],
|
||||||
|
md=6,
|
||||||
|
style={
|
||||||
|
"borderRight": "1px solid var(--bs-border-color)",
|
||||||
|
"paddingRight": "2rem",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
col_right = dbc.Col(
|
||||||
|
[
|
||||||
|
html.H4("Ce que les abonnements de soutien permettent"),
|
||||||
html.Ul(
|
html.Ul(
|
||||||
[
|
[
|
||||||
html.Li(
|
html.Li(
|
||||||
@@ -88,14 +112,17 @@ def _explainer():
|
|||||||
"cette non-publication."
|
"cette non-publication."
|
||||||
),
|
),
|
||||||
html.Li(
|
html.Li(
|
||||||
"Coordination des bonnes volontés souhaitant militer pour une "
|
"Fédération des bonnes volontés souhaitant militer pour une "
|
||||||
"législation plus exigeante sur la transparence de la commande "
|
"législation plus ambitieuse sur la transparence de la commande "
|
||||||
"publique."
|
"publique."
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
|
md=6,
|
||||||
|
style={"paddingLeft": "2rem"},
|
||||||
)
|
)
|
||||||
|
return dbc.Row([col_left, col_right], className="align-items-start pt-2")
|
||||||
|
|
||||||
|
|
||||||
def _active_view(row):
|
def _active_view(row):
|
||||||
@@ -120,7 +147,7 @@ def _active_view(row):
|
|||||||
method="POST",
|
method="POST",
|
||||||
action="/subscriptions/cancel",
|
action="/subscriptions/cancel",
|
||||||
children=[
|
children=[
|
||||||
_csrf_input("cancel"),
|
_csrf_input(),
|
||||||
html.Button(
|
html.Button(
|
||||||
"Résilier", type="submit", className="btn btn-outline-danger"
|
"Résilier", type="submit", className="btn btn-outline-danger"
|
||||||
),
|
),
|
||||||
@@ -150,6 +177,26 @@ def _feedback(query):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_salaire_modal = dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(dbc.ModalTitle("Salaire médian — coût employeur")),
|
||||||
|
dbc.ModalBody(html.Img(src="/assets/salaire.png", style={"width": "100%"})),
|
||||||
|
],
|
||||||
|
id="salaire-modal",
|
||||||
|
size="lg",
|
||||||
|
is_open=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("salaire-modal", "is_open"),
|
||||||
|
Input("salaire-modal-trigger", "n_clicks"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def _open_salaire_modal(_):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def layout(**query):
|
def layout(**query):
|
||||||
guard = account_guard("/compte/abonnement", require_subscription=False)
|
guard = account_guard("/compte/abonnement", require_subscription=False)
|
||||||
if guard is not None:
|
if guard is not None:
|
||||||
@@ -166,10 +213,11 @@ def layout(**query):
|
|||||||
db.has_used_trial(current_user.id) if current_user.is_authenticated else False
|
db.has_used_trial(current_user.id) if current_user.is_authenticated else False
|
||||||
)
|
)
|
||||||
|
|
||||||
body = [html.H2("Abonnement"), *_feedback(query)]
|
body = [html.H2("Abonnement", className="mb-4"), *_feedback(query)]
|
||||||
if has_access and row is not None:
|
if has_access and row is not None:
|
||||||
body.append(_active_view(row))
|
body.append(_active_view(row))
|
||||||
else:
|
else:
|
||||||
body.extend([_plan_cards(trial_used=trial_used), html.Hr(), _explainer()])
|
body.extend([_plan_cards(trial_used=trial_used), _explainer()])
|
||||||
|
|
||||||
|
body.append(_salaire_modal)
|
||||||
return account_shell("abonnement", html.Div(body))
|
return account_shell("abonnement", html.Div(body))
|
||||||
|
|||||||
+10
-13
@@ -5,7 +5,7 @@ import httpx
|
|||||||
# NB : base URL et schémas d'endpoints à confirmer dans la doc Frisbii lors de la
|
# NB : base URL et schémas d'endpoints à confirmer dans la doc Frisbii lors de la
|
||||||
# première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur
|
# première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur
|
||||||
# l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin.
|
# l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin.
|
||||||
_DEFAULT_BASE_URL = "https://api.reepay.com"
|
_DEFAULT_BASE_URL = "https://api.frisbii.com"
|
||||||
_TIMEOUT = 15.0
|
_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
@@ -53,19 +53,16 @@ def create_subscription_session(
|
|||||||
cancel_url: str,
|
cancel_url: str,
|
||||||
no_trial: bool = False,
|
no_trial: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
prepare = {"plan": plan_handle, "customer": customer_handle}
|
body: dict = {
|
||||||
|
"plan": plan_handle,
|
||||||
|
"customer": customer_handle,
|
||||||
|
"signup_method": "link",
|
||||||
|
"generate_handle": True,
|
||||||
|
}
|
||||||
if no_trial:
|
if no_trial:
|
||||||
prepare["no_trial"] = True
|
body["no_trial"] = True
|
||||||
data = _call(
|
data = _call("POST", "/v1/subscription", json=body)
|
||||||
"POST",
|
return data["hosted_page_links"]["payment_info"]
|
||||||
"/v1/session/subscription",
|
|
||||||
json={
|
|
||||||
"accept_url": accept_url,
|
|
||||||
"cancel_url": cancel_url,
|
|
||||||
"prepare_subscription": prepare,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return data["url"]
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_subscription(subscription_handle: str) -> dict:
|
def cancel_subscription(subscription_handle: str) -> dict:
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ PLANS = {
|
|||||||
"env": "FRISBII_PLAN_SIMPLE",
|
"env": "FRISBII_PLAN_SIMPLE",
|
||||||
"label": "Abonnement simple",
|
"label": "Abonnement simple",
|
||||||
"prix_ht": 20,
|
"prix_ht": 20,
|
||||||
"description": "Accès aux fonctionnalités premium de decp.info.",
|
"description": "Accès à des fonctionnalités supplémentaires de decp.info.",
|
||||||
},
|
},
|
||||||
"soutien": {
|
"soutien": {
|
||||||
"env": "FRISBII_PLAN_SOUTIEN",
|
"env": "FRISBII_PLAN_SOUTIEN",
|
||||||
"label": "Abonnement de soutien",
|
"label": "Abonnement de soutien ✊",
|
||||||
"prix_ht": 50,
|
"prix_ht": 50,
|
||||||
"description": "Mêmes fonctionnalités, contribution renforcée au projet.",
|
"description": "Mêmes fonctionnalités, contribution renforcée au projet.",
|
||||||
},
|
},
|
||||||
@@ -74,6 +74,9 @@ def trial_days(key: str) -> int | None:
|
|||||||
except client.FrisbiiError:
|
except client.FrisbiiError:
|
||||||
logger.warning("Impossible de lire le plan Frisbii %s", handle)
|
logger.warning("Impossible de lire le plan Frisbii %s", handle)
|
||||||
return cached[1] if cached else None
|
return cached[1] if cached else None
|
||||||
|
# L'API Frisbii/Reepay renvoie une liste de versions ; on prend la dernière (la plus récente).
|
||||||
|
if isinstance(plan, list):
|
||||||
|
plan = plan[-1] if plan else {}
|
||||||
days = _parse_trial_interval(plan.get("trial_interval"))
|
days = _parse_trial_interval(plan.get("trial_interval"))
|
||||||
_trial_cache[handle] = (now, days)
|
_trial_cache[handle] = (now, days)
|
||||||
return days
|
return days
|
||||||
|
|||||||
@@ -29,29 +29,42 @@ def test_get_or_create_customer_creates_on_404(fake_httpx):
|
|||||||
|
|
||||||
def test_create_subscription_session_returns_url(fake_httpx):
|
def test_create_subscription_session_returns_url(fake_httpx):
|
||||||
fake_httpx["queue"].append(
|
fake_httpx["queue"].append(
|
||||||
fake_httpx["Response"](200, {"id": "cs_1", "url": "https://pay.test/cs_1"})
|
fake_httpx["Response"](
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"hosted_page_links": {
|
||||||
|
"payment_info": "https://checkout.reepay.com/#/sub-1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
url = client.create_subscription_session(
|
url = client.create_subscription_session(
|
||||||
"plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko"
|
"plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko"
|
||||||
)
|
)
|
||||||
assert url == "https://pay.test/cs_1"
|
assert url == "https://checkout.reepay.com/#/sub-1"
|
||||||
body = fake_httpx["calls"][0]["json"]
|
body = fake_httpx["calls"][0]["json"]
|
||||||
assert body["prepare_subscription"] == {
|
assert body["plan"] == "plan_simple"
|
||||||
"plan": "plan_simple",
|
assert body["customer"] == "decpinfo-1"
|
||||||
"customer": "decpinfo-1",
|
assert body["signup_method"] == "link"
|
||||||
}
|
assert "prepare_subscription" not in body
|
||||||
assert body["accept_url"] == "https://app/ok"
|
assert "accept_url" not in body
|
||||||
assert body["cancel_url"] == "https://app/ko"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_subscription_session_no_trial(fake_httpx):
|
def test_create_subscription_session_no_trial(fake_httpx):
|
||||||
fake_httpx["queue"].append(
|
fake_httpx["queue"].append(
|
||||||
fake_httpx["Response"](200, {"url": "https://pay.test/cs_2"})
|
fake_httpx["Response"](
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"hosted_page_links": {
|
||||||
|
"payment_info": "https://checkout.reepay.com/#/sub-2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
client.create_subscription_session(
|
client.create_subscription_session(
|
||||||
"plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True
|
"plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True
|
||||||
)
|
)
|
||||||
assert fake_httpx["calls"][0]["json"]["prepare_subscription"]["no_trial"] is True
|
assert fake_httpx["calls"][0]["json"]["no_trial"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_http_error_raises_frisbii_error(fake_httpx):
|
def test_http_error_raises_frisbii_error(fake_httpx):
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user