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_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_PLAN_SIMPLE= # handle du plan "abonnement simple" (20 € HT/mois)
|
||||
FRISBII_PLAN_SOUTIEN= # handle du plan "abonnement de soutien" (50 € HT/mois)
|
||||
FRISBII_API_BASE_URL=https://api.frisbii.com # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer)
|
||||
FRISBII_PLAN_SIMPLE=abonnement-decp.info # handle du plan "abonnement simple" (20 € 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
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Hash bidon pré-calculé pour uniformiser le timing login
|
||||
@@ -93,7 +105,7 @@ def verify_email():
|
||||
def login():
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
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)
|
||||
if row is None:
|
||||
@@ -109,7 +121,7 @@ def login():
|
||||
return _redirect_with_error("/connexion", "email_not_verified", email)
|
||||
|
||||
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"])
|
||||
@@ -259,7 +271,7 @@ def linkedin_login():
|
||||
|
||||
@auth_bp.route("/linkedin/callback", methods=["GET"])
|
||||
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"):
|
||||
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
|
||||
return _redirect_with_error("/connexion", "oauth_cancelled")
|
||||
@@ -290,4 +302,4 @@ def linkedin_callback():
|
||||
"linkedin", subject, email, bool(userinfo.get("email_verified"))
|
||||
)
|
||||
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.
|
||||
# Ajouter une section future = ajouter une ligne ici (+ créer sa page).
|
||||
SECTIONS = [
|
||||
{
|
||||
"key": "admin",
|
||||
"label": "Compte",
|
||||
"href": "/compte/admin",
|
||||
"require_subscription": False,
|
||||
},
|
||||
{
|
||||
"key": "abonnement",
|
||||
"label": "Abonnement",
|
||||
"href": "/compte/abonnement",
|
||||
"require_subscription": False,
|
||||
},
|
||||
{
|
||||
"key": "admin",
|
||||
"label": "Compte",
|
||||
"href": "/compte/admin",
|
||||
"require_subscription": False,
|
||||
},
|
||||
{
|
||||
"key": "archives",
|
||||
"label": "Mes archives",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 src.pages._compte_shell import account_guard, account_shell
|
||||
@@ -16,10 +16,10 @@ register_page(
|
||||
_PLAN_KEYS = ("simple", "soutien")
|
||||
|
||||
|
||||
def _csrf_input(index: str):
|
||||
return dcc.Input(
|
||||
type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token"
|
||||
)
|
||||
def _csrf_input():
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
|
||||
return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf())
|
||||
|
||||
|
||||
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(
|
||||
dbc.CardBody(
|
||||
[
|
||||
html.H4(meta["label"]),
|
||||
html.P(f"{meta['prix_ht']} € HT / mois"),
|
||||
html.P(meta["description"]),
|
||||
html.H4(meta["label"], className="mb-1"),
|
||||
html.P(f"{meta['prix_ht']} € HT / mois", className="text-muted mb-3"),
|
||||
html.P(meta["description"], className="mb-3"),
|
||||
badge,
|
||||
html.Form(
|
||||
method="POST",
|
||||
action="/subscriptions/subscribe",
|
||||
children=[
|
||||
_csrf_input(f"subscribe-{meta['key']}"),
|
||||
_csrf_input(),
|
||||
dcc.Input(type="hidden", name="plan", value=meta["key"]),
|
||||
html.Button(
|
||||
"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)
|
||||
if meta:
|
||||
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():
|
||||
return html.Div(
|
||||
col_left = dbc.Col(
|
||||
[
|
||||
html.H4("À quoi servent les abonnements"),
|
||||
html.H4("Ce que les abonnements financent"),
|
||||
html.Ul(
|
||||
[
|
||||
html.Li("Abonnement Frisbii (solution de paiement) : 50 €"),
|
||||
html.Li("Serveur Scaleway : 40 €"),
|
||||
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.Li(
|
||||
@@ -88,14 +112,17 @@ def _explainer():
|
||||
"cette non-publication."
|
||||
),
|
||||
html.Li(
|
||||
"Coordination des bonnes volontés souhaitant militer pour une "
|
||||
"législation plus exigeante sur la transparence de la commande "
|
||||
"Fédération des bonnes volontés souhaitant militer pour une "
|
||||
"législation plus ambitieuse sur la transparence de la commande "
|
||||
"publique."
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
],
|
||||
md=6,
|
||||
style={"paddingLeft": "2rem"},
|
||||
)
|
||||
return dbc.Row([col_left, col_right], className="align-items-start pt-2")
|
||||
|
||||
|
||||
def _active_view(row):
|
||||
@@ -120,7 +147,7 @@ def _active_view(row):
|
||||
method="POST",
|
||||
action="/subscriptions/cancel",
|
||||
children=[
|
||||
_csrf_input("cancel"),
|
||||
_csrf_input(),
|
||||
html.Button(
|
||||
"Résilier", type="submit", className="btn btn-outline-danger"
|
||||
),
|
||||
@@ -150,6 +177,26 @@ def _feedback(query):
|
||||
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):
|
||||
guard = account_guard("/compte/abonnement", require_subscription=False)
|
||||
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
|
||||
)
|
||||
|
||||
body = [html.H2("Abonnement"), *_feedback(query)]
|
||||
body = [html.H2("Abonnement", className="mb-4"), *_feedback(query)]
|
||||
if has_access and row is not None:
|
||||
body.append(_active_view(row))
|
||||
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))
|
||||
|
||||
+10
-13
@@ -5,7 +5,7 @@ import httpx
|
||||
# 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
|
||||
# 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
|
||||
|
||||
|
||||
@@ -53,19 +53,16 @@ def create_subscription_session(
|
||||
cancel_url: str,
|
||||
no_trial: bool = False,
|
||||
) -> 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:
|
||||
prepare["no_trial"] = True
|
||||
data = _call(
|
||||
"POST",
|
||||
"/v1/session/subscription",
|
||||
json={
|
||||
"accept_url": accept_url,
|
||||
"cancel_url": cancel_url,
|
||||
"prepare_subscription": prepare,
|
||||
},
|
||||
)
|
||||
return data["url"]
|
||||
body["no_trial"] = True
|
||||
data = _call("POST", "/v1/subscription", json=body)
|
||||
return data["hosted_page_links"]["payment_info"]
|
||||
|
||||
|
||||
def cancel_subscription(subscription_handle: str) -> dict:
|
||||
|
||||
@@ -17,11 +17,11 @@ PLANS = {
|
||||
"env": "FRISBII_PLAN_SIMPLE",
|
||||
"label": "Abonnement simple",
|
||||
"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": {
|
||||
"env": "FRISBII_PLAN_SOUTIEN",
|
||||
"label": "Abonnement de soutien",
|
||||
"label": "Abonnement de soutien ✊",
|
||||
"prix_ht": 50,
|
||||
"description": "Mêmes fonctionnalités, contribution renforcée au projet.",
|
||||
},
|
||||
@@ -74,6 +74,9 @@ def trial_days(key: str) -> int | None:
|
||||
except client.FrisbiiError:
|
||||
logger.warning("Impossible de lire le plan Frisbii %s", handle)
|
||||
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"))
|
||||
_trial_cache[handle] = (now, 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):
|
||||
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(
|
||||
"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"]
|
||||
assert body["prepare_subscription"] == {
|
||||
"plan": "plan_simple",
|
||||
"customer": "decpinfo-1",
|
||||
}
|
||||
assert body["accept_url"] == "https://app/ok"
|
||||
assert body["cancel_url"] == "https://app/ko"
|
||||
assert body["plan"] == "plan_simple"
|
||||
assert body["customer"] == "decpinfo-1"
|
||||
assert body["signup_method"] == "link"
|
||||
assert "prepare_subscription" not in body
|
||||
assert "accept_url" not in body
|
||||
|
||||
|
||||
def test_create_subscription_session_no_trial(fake_httpx):
|
||||
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(
|
||||
"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):
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user