Refactor structure modules pages/compte, petits changements de texte
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._compte_shell import account_guard, account_shell
|
||||
from src.pages.a_propos.abonnement import abonnement_features
|
||||
from src.subscriptions import db, plans
|
||||
from src.utils.frontend import format_date_french
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/compte/abonnement",
|
||||
title="Abonnement | colibre",
|
||||
name="Abonnement",
|
||||
description="Gestion de votre abonnement colibre.",
|
||||
)
|
||||
|
||||
_PLAN_KEYS = ("simple", "soutien")
|
||||
|
||||
|
||||
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):
|
||||
from src.utils import TOUS_ABONNES
|
||||
|
||||
if trial_used:
|
||||
badge = html.Div(
|
||||
"Sans période d'essai (déjà utilisée) — débit immédiat",
|
||||
className="text-muted mb-2",
|
||||
)
|
||||
elif trial:
|
||||
badge = html.Div(f"{trial} jours d'essai gratuit", className="mb-3")
|
||||
else:
|
||||
badge = None
|
||||
return dbc.Card(
|
||||
dbc.CardBody(
|
||||
[
|
||||
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.A(
|
||||
"S'abonner",
|
||||
href=(
|
||||
"#"
|
||||
if TOUS_ABONNES
|
||||
else f"/compte/abonnement/mes-infos?plan={meta['key']}"
|
||||
),
|
||||
className=(
|
||||
"btn btn-secondary disabled"
|
||||
if TOUS_ABONNES
|
||||
else "btn btn-primary"
|
||||
),
|
||||
style={"width": "fit-content"},
|
||||
),
|
||||
],
|
||||
className="p-4",
|
||||
),
|
||||
className="h-100",
|
||||
)
|
||||
|
||||
|
||||
def _plan_cards(trial_used=False, trial_for=plans.trial_days):
|
||||
cards = []
|
||||
for key in _PLAN_KEYS:
|
||||
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], className="g-4 mb-5")
|
||||
|
||||
|
||||
def _explainer():
|
||||
col_left = dbc.Col(
|
||||
[html.H4("Fonctionnalités réservées aux abonné•es :"), abonnement_features],
|
||||
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(
|
||||
"Rédaction d'études à partir des données, par exemple sur les "
|
||||
"acheteurs dont les données sont introuvables et les raisons de "
|
||||
"cette non-publication."
|
||||
),
|
||||
html.Li(
|
||||
"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):
|
||||
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
||||
end = format_date_french(row["current_period_end"])
|
||||
blocks = [html.H3(meta["label"], className="mb-3")]
|
||||
|
||||
if row["status"] == "pending":
|
||||
blocks.extend(
|
||||
[
|
||||
dbc.Alert(
|
||||
"Sans méthode de paiement enregistrée, votre abonnement sera "
|
||||
"résilié à la fin de la période d'essai.",
|
||||
color="warning",
|
||||
className="mb-3",
|
||||
),
|
||||
html.Form(
|
||||
method="POST",
|
||||
action="/subscriptions/add-payment",
|
||||
children=[
|
||||
_csrf_input(),
|
||||
html.Button(
|
||||
"Ajouter une méthode de paiement",
|
||||
type="submit",
|
||||
className="btn btn-primary mb-3",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
elif row["status"] == "trial":
|
||||
blocks.append(
|
||||
dbc.Alert(
|
||||
f"Essai gratuit jusqu'au {end}, puis facturation et débit automatique à chaque date anniversaire.",
|
||||
color="info",
|
||||
)
|
||||
)
|
||||
elif row["status"] == "cancelled":
|
||||
blocks.append(
|
||||
dbc.Alert(f"Abonnement résilié, actif jusqu'au {end}.", color="warning")
|
||||
)
|
||||
else:
|
||||
blocks.append(html.P(f"Prochaine facturation : {end}"))
|
||||
|
||||
if row["status"] in ("pending", "trial", "active"):
|
||||
blocks.append(
|
||||
html.Button(
|
||||
"Me désabonner",
|
||||
id="resiliation-trigger",
|
||||
n_clicks=0,
|
||||
className="btn btn-outline-danger mt-3",
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(blocks)
|
||||
|
||||
|
||||
def _resiliation_modal(end_raw):
|
||||
end = format_date_french(end_raw) if end_raw else None
|
||||
if end:
|
||||
body_text = (
|
||||
f"Êtes-vous sûr de vouloir mettre fin à votre abonnement ? "
|
||||
f"Vous resterez abonné jusqu'à la date anniversaire, le {end}."
|
||||
)
|
||||
else:
|
||||
body_text = "Êtes-vous sûr de vouloir mettre fin à votre abonnement ?"
|
||||
return dbc.Modal(
|
||||
[
|
||||
dbc.ModalHeader(dbc.ModalTitle("Résilier l'abonnement")),
|
||||
dbc.ModalBody(body_text),
|
||||
dbc.ModalFooter(
|
||||
[
|
||||
dbc.Button(
|
||||
"Annuler",
|
||||
id="resiliation-annuler",
|
||||
color="secondary",
|
||||
className="me-2",
|
||||
n_clicks=0,
|
||||
),
|
||||
html.Form(
|
||||
method="POST",
|
||||
action="/subscriptions/cancel",
|
||||
children=[
|
||||
_csrf_input(),
|
||||
html.Button(
|
||||
"Je suis sûr",
|
||||
type="submit",
|
||||
className="btn btn-danger",
|
||||
),
|
||||
],
|
||||
style={"display": "inline"},
|
||||
),
|
||||
]
|
||||
),
|
||||
],
|
||||
id="resiliation-modal",
|
||||
is_open=False,
|
||||
)
|
||||
|
||||
|
||||
def _feedback(query):
|
||||
msgs = {
|
||||
"succes": (
|
||||
"Merci, votre abonnement est activé. Bonne exploration !",
|
||||
"success",
|
||||
),
|
||||
"annule": ("Paiement annulé.", "secondary"),
|
||||
}
|
||||
out = []
|
||||
if query.get("paiement") in msgs:
|
||||
text, color = msgs[query["paiement"]]
|
||||
out.append(dbc.Alert(text, color=color))
|
||||
if query.get("resiliation") == "ok":
|
||||
out.append(dbc.Alert("Votre abonnement a été résilié.", color="info"))
|
||||
if query.get("error") == "frisbii":
|
||||
out.append(
|
||||
dbc.Alert(
|
||||
"Une erreur est survenue avec le service de paiement.", color="primary"
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@callback(
|
||||
Output("resiliation-modal", "is_open"),
|
||||
Input("resiliation-trigger", "n_clicks"),
|
||||
Input("resiliation-annuler", "n_clicks"),
|
||||
State("resiliation-modal", "is_open"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _toggle_resiliation(_, __, is_open):
|
||||
return not is_open
|
||||
|
||||
|
||||
def _tous_abonnes_banner():
|
||||
from src.utils import TOUS_ABONNES
|
||||
|
||||
if not TOUS_ABONNES:
|
||||
return None
|
||||
return dbc.Alert(
|
||||
"Les fonctionnalités normalement accessibles contre un abonnement de "
|
||||
"20 € HT par mois sont accessibles à tous et toutes en attendant la "
|
||||
"validation de mon dossier pour recevoir des paiements.",
|
||||
color="info",
|
||||
)
|
||||
|
||||
|
||||
def _show_active_view(row) -> bool:
|
||||
return row is not None and row["status"] != "failed"
|
||||
|
||||
|
||||
def layout(**query):
|
||||
guard = account_guard("/compte/abonnement", require_subscription=False)
|
||||
if guard is not None:
|
||||
return guard
|
||||
|
||||
row = db.get_current(current_user.id) if current_user.is_authenticated else None
|
||||
trial_used = (
|
||||
db.has_used_trial(current_user.id) if current_user.is_authenticated else False
|
||||
)
|
||||
|
||||
body = [html.H2("Abonnement", className="mb-4")]
|
||||
banner = _tous_abonnes_banner()
|
||||
if banner is not None:
|
||||
body.append(banner)
|
||||
body.extend(_feedback(query))
|
||||
|
||||
if _show_active_view(row):
|
||||
body.append(_active_view(row))
|
||||
body.append(_resiliation_modal(row["current_period_end"]))
|
||||
else:
|
||||
body.extend([_plan_cards(trial_used=trial_used), _explainer()])
|
||||
|
||||
body.append(_salaire_modal)
|
||||
return account_shell("abonnement", html.Div(body))
|
||||
@@ -0,0 +1,375 @@
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||
from flask_login import current_user
|
||||
|
||||
from src.auth import db as auth_db
|
||||
from src.pages._compte_shell import account_guard, account_shell
|
||||
from src.subscriptions import client as frisbii_client
|
||||
from src.utils.data import get_annuaire_data
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/compte/abonnement/mes-infos",
|
||||
title="Mes informations | Abonnement | colibre",
|
||||
name="Mes informations de facturation",
|
||||
description="Informations de facturation pour votre abonnement colibre.",
|
||||
)
|
||||
|
||||
_CGU_MARKDOWN = """
|
||||
L'accès aux fonctionnalités de base de colibre est gratuit et sans inscription.
|
||||
Un abonnement payant donne accès à des fonctionnalités supplémentaires qui nécessitent la création d'un compte.
|
||||
|
||||
#### Fonctionnalités incluses
|
||||
|
||||
Les fonctionnalités accessibles aux abonnés évoluent au fil du développement du service.
|
||||
La liste à jour est visible depuis la [page d'abonnement](/compte/abonnement).
|
||||
|
||||
#### Tarifs
|
||||
|
||||
Deux formules sont proposées :
|
||||
|
||||
- **Abonnement** — 20 € HT / mois (soit 24 € TTC)
|
||||
- **Abonnement de soutien** — 50 € HT / mois (soit 60 € TTC) — mêmes fonctionnalités, contribution renforcée au projet
|
||||
|
||||
La TVA applicable est de 20 %. Les prix TTC sont affichés lors de la souscription.
|
||||
|
||||
#### Période d'essai
|
||||
|
||||
Une période d'essai gratuite peut être proposée lors de la souscription.
|
||||
Sa durée est indiquée avant validation. Aucun prélèvement n'est effectué pendant cette période.
|
||||
À son terme, l'abonnement est activé et facturé automatiquement.
|
||||
|
||||
La période d'essai est accordée une seule fois par utilisateur.
|
||||
|
||||
#### Facturation et paiement
|
||||
|
||||
L'abonnement est facturé mensuellement, à la date anniversaire de la souscription.
|
||||
Le paiement est traité par [Frisbii](https://www.frisbii.com), prestataire européen de paiement en ligne.
|
||||
Les coordonnées bancaires sont conservées exclusivement par Frisbii et ne sont pas transmises à colibre.
|
||||
|
||||
#### Résiliation
|
||||
|
||||
L'abonnement peut être résilié à tout moment depuis l'espace [Mon compte](/compte/abonnement).
|
||||
La résiliation prend effet à la fin de la période mensuelle en cours : l'accès aux fonctionnalités payantes est maintenu jusqu'à cette date, sans remboursement au prorata.
|
||||
|
||||
#### Données stockées
|
||||
|
||||
La gestion de l'abonnement implique le traitement de données personnelles, réparties entre colibre et Frisbii (prestataire de paiement).
|
||||
|
||||
**Données stockées par colibre :**
|
||||
|
||||
- Adresse e-mail (identification du compte)
|
||||
- Numéro SIRET (si renseigné, conservé pour pré-remplir les futures souscriptions et alimenter des fonctionnalités)
|
||||
|
||||
**Données stockées par Frisbii :**
|
||||
|
||||
- Informations de facturation : prénom, nom, adresse postale, code postal, ville, pays, nom de l'entreprise
|
||||
- Informations de paiement : coordonnées bancaires (accessibles uniquement par Frisbii, jamais transmises à colibre)
|
||||
- Historique des factures
|
||||
|
||||
Ces données sont utilisées uniquement pour la gestion de votre abonnement et ne sont pas transmises à des tiers à des fins commerciales.
|
||||
Conformément au RGPD, vous pouvez demander l'accès, la rectification ou la suppression de vos données en [me contactant](/a-propos/contact).
|
||||
|
||||
#### Contact
|
||||
|
||||
Pour toute question relative à votre abonnement : [page de contact](/a-propos/contact).
|
||||
"""
|
||||
|
||||
|
||||
def _csrf_input():
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
|
||||
return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf())
|
||||
|
||||
|
||||
def _cgu_modal():
|
||||
return dbc.Modal(
|
||||
[
|
||||
dbc.ModalHeader(dbc.ModalTitle("Conditions générales d'utilisation")),
|
||||
dbc.ModalBody(
|
||||
dcc.Markdown(_CGU_MARKDOWN),
|
||||
style={"maxHeight": "60vh", "overflowY": "auto"},
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
dbc.Button(
|
||||
"Fermer", id="inf-cgu-close", className="ms-auto", color="secondary"
|
||||
)
|
||||
),
|
||||
],
|
||||
id="inf-cgu-modal",
|
||||
size="lg",
|
||||
is_open=False,
|
||||
)
|
||||
|
||||
|
||||
def layout(**query):
|
||||
guard = account_guard("/compte/abonnement/mes-infos", require_subscription=False)
|
||||
if guard is not None:
|
||||
return guard
|
||||
|
||||
plan = query.get("plan", "")
|
||||
if not plan:
|
||||
return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect")
|
||||
|
||||
prefill: dict = {}
|
||||
try:
|
||||
prefill = frisbii_client.get_customer(f"colibre-{current_user.id}")
|
||||
except frisbii_client.FrisbiiError:
|
||||
prefill = {}
|
||||
stored_siret = auth_db.get_siret(current_user.id) or ""
|
||||
|
||||
col1 = dbc.Col(
|
||||
[
|
||||
dbc.Label("Prénom *"),
|
||||
dbc.Input(
|
||||
id="inf-prenom",
|
||||
name="first_name",
|
||||
type="text",
|
||||
required=True,
|
||||
value=prefill.get("first_name", ""),
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Label("Nom *"),
|
||||
dbc.Input(
|
||||
id="inf-nom",
|
||||
name="last_name",
|
||||
type="text",
|
||||
required=True,
|
||||
value=prefill.get("last_name", ""),
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Label("SIRET"),
|
||||
dbc.InputGroup(
|
||||
[
|
||||
dbc.Input(
|
||||
id="inf-siret",
|
||||
name="siret",
|
||||
type="text",
|
||||
maxLength=14,
|
||||
placeholder="14 chiffres",
|
||||
value=stored_siret,
|
||||
),
|
||||
dbc.Button(
|
||||
"Récupérer les autres infos",
|
||||
id="inf-siret-btn",
|
||||
color="secondary",
|
||||
type="button",
|
||||
n_clicks=0,
|
||||
),
|
||||
],
|
||||
className="mb-1",
|
||||
),
|
||||
html.Div(id="inf-siret-msg", className="small mb-3"),
|
||||
dbc.Label("Nom de l'entreprise"),
|
||||
dbc.Input(
|
||||
id="inf-entreprise",
|
||||
name="company",
|
||||
type="text",
|
||||
value=prefill.get("company", ""),
|
||||
className="mb-3",
|
||||
),
|
||||
],
|
||||
md=6,
|
||||
)
|
||||
|
||||
col2 = dbc.Col(
|
||||
[
|
||||
dbc.Label("Adresse ligne 1 *"),
|
||||
dbc.Input(
|
||||
id="inf-adresse1",
|
||||
name="address",
|
||||
type="text",
|
||||
required=True,
|
||||
value=prefill.get("address", ""),
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Label("Adresse ligne 2"),
|
||||
dbc.Input(
|
||||
id="inf-adresse2",
|
||||
name="address2",
|
||||
type="text",
|
||||
value=prefill.get("address2", ""),
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Label("Code postal *"),
|
||||
dbc.Input(
|
||||
id="inf-cp",
|
||||
name="postal_code",
|
||||
type="text",
|
||||
required=True,
|
||||
value=prefill.get("postal_code", ""),
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Label("Ville *"),
|
||||
dbc.Input(
|
||||
id="inf-ville",
|
||||
name="city",
|
||||
type="text",
|
||||
required=True,
|
||||
value=prefill.get("city", ""),
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Label("Pays *"),
|
||||
dbc.Select(
|
||||
id="inf-pays",
|
||||
name="country",
|
||||
value=prefill.get("country", "FR"),
|
||||
options=[
|
||||
{"label": "France", "value": "FR"},
|
||||
{"label": "Allemagne", "value": "DE"},
|
||||
{"label": "Autriche", "value": "AT"},
|
||||
{"label": "Belgique", "value": "BE"},
|
||||
{"label": "Chypre", "value": "CY"},
|
||||
{"label": "Croatie", "value": "HR"},
|
||||
{"label": "Espagne", "value": "ES"},
|
||||
{"label": "Estonie", "value": "EE"},
|
||||
{"label": "Finlande", "value": "FI"},
|
||||
{"label": "Grèce", "value": "GR"},
|
||||
{"label": "Irlande", "value": "IE"},
|
||||
{"label": "Italie", "value": "IT"},
|
||||
{"label": "Lettonie", "value": "LV"},
|
||||
{"label": "Lituanie", "value": "LT"},
|
||||
{"label": "Luxembourg", "value": "LU"},
|
||||
{"label": "Malte", "value": "MT"},
|
||||
{"label": "Pays-Bas", "value": "NL"},
|
||||
{"label": "Portugal", "value": "PT"},
|
||||
{"label": "Slovaquie", "value": "SK"},
|
||||
{"label": "Slovénie", "value": "SI"},
|
||||
],
|
||||
className="mb-3",
|
||||
),
|
||||
],
|
||||
md=6,
|
||||
)
|
||||
|
||||
checkboxes = html.Div(
|
||||
[
|
||||
dcc.Checklist(
|
||||
id="inf-cb-retractation",
|
||||
options=[
|
||||
{
|
||||
"label": "Je renonce à mon droit de rétractation légal de 14 jours.",
|
||||
"value": "ok",
|
||||
}
|
||||
],
|
||||
value=[],
|
||||
className="mb-2",
|
||||
),
|
||||
dcc.Checklist(
|
||||
id="inf-cb-cgu",
|
||||
options=[
|
||||
{
|
||||
"label": [
|
||||
"J'ai lu et accepte les ",
|
||||
html.A(
|
||||
"conditions générales d'utilisation du service",
|
||||
href="#",
|
||||
id="inf-cgu-link",
|
||||
style={"cursor": "pointer"},
|
||||
),
|
||||
".",
|
||||
],
|
||||
"value": "ok",
|
||||
}
|
||||
],
|
||||
value=[],
|
||||
className="mb-4",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
form = html.Form(
|
||||
method="POST",
|
||||
action="/subscriptions/subscribe",
|
||||
children=[
|
||||
_csrf_input(),
|
||||
dcc.Input(type="hidden", name="plan", value=plan),
|
||||
dbc.Row([col1, col2], className="g-4 mb-4"),
|
||||
checkboxes,
|
||||
html.Button(
|
||||
"Suivant",
|
||||
id="inf-submit",
|
||||
type="submit",
|
||||
className="btn btn-primary",
|
||||
disabled=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return account_shell(
|
||||
"abonnement",
|
||||
html.Div(
|
||||
[
|
||||
html.H2("Mes informations de facturation", className="mb-4"),
|
||||
dbc.Alert(
|
||||
"Informations récupérées depuis le prestataire de paiement, vous pouvez les modifier si besoin.",
|
||||
color="info",
|
||||
className="mb-4",
|
||||
)
|
||||
if prefill
|
||||
else None,
|
||||
form,
|
||||
_cgu_modal(),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("inf-entreprise", "value"),
|
||||
Output("inf-adresse1", "value"),
|
||||
Output("inf-cp", "value"),
|
||||
Output("inf-ville", "value"),
|
||||
Output("inf-siret-msg", "children"),
|
||||
Output("inf-siret-msg", "className"),
|
||||
Input("inf-siret-btn", "n_clicks"),
|
||||
State("inf-siret", "value"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _lookup_siret(_, siret):
|
||||
empty = ("", "", "", "")
|
||||
if not siret or not siret.strip():
|
||||
return *empty, "Veuillez saisir un SIRET.", "small text-danger mb-3"
|
||||
data = get_annuaire_data(siret.strip())
|
||||
if data is None:
|
||||
return (
|
||||
*empty,
|
||||
"SIRET introuvable dans l'annuaire des entreprises.",
|
||||
"small text-danger mb-3",
|
||||
)
|
||||
etablissement = data.get("matching_etablissements")
|
||||
if not etablissement:
|
||||
return (
|
||||
*empty,
|
||||
"Aucun établissement trouvé pour ce SIRET.",
|
||||
"small text-danger mb-3",
|
||||
)
|
||||
etab = etablissement[0]
|
||||
return (
|
||||
data.get("nom_raison_sociale", ""),
|
||||
etab.get("adresse", ""),
|
||||
etab.get("code_postal", ""),
|
||||
etab.get("libelle_commune", ""),
|
||||
"Informations récupérées.",
|
||||
"small text-success mb-3",
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("inf-submit", "disabled"),
|
||||
Input("inf-cb-retractation", "value"),
|
||||
Input("inf-cb-cgu", "value"),
|
||||
)
|
||||
def _toggle_submit(retractation, cgu):
|
||||
return not (retractation and cgu)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("inf-cgu-modal", "is_open"),
|
||||
Input("inf-cgu-link", "n_clicks"),
|
||||
Input("inf-cgu-close", "n_clicks"),
|
||||
State("inf-cgu-modal", "is_open"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _toggle_cgu(_, __, is_open):
|
||||
return not is_open
|
||||
@@ -0,0 +1,216 @@
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._compte_shell import account_guard, account_shell
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/compte/admin",
|
||||
title="Mon compte | colibre",
|
||||
name="Mon compte",
|
||||
description="Gestion de votre compte colibre.",
|
||||
)
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
"invalid_current_password": "Le mot de passe actuel est incorrect.",
|
||||
"no_password_set": "Votre compte n'a pas de mot de passe (connexion via LinkedIn). Utilisez la réinitialisation de mot de passe pour en définir un.",
|
||||
"password_too_short": "Le nouveau mot de passe doit faire au moins 8 caractères.",
|
||||
"password_mismatch": "Les nouveaux mots de passe ne correspondent pas.",
|
||||
"invalid_email": "L'adresse email n'est pas valide.",
|
||||
"email_taken": "Cette adresse email est déjà utilisée.",
|
||||
"email_send_failed": "L'envoi de l'email de confirmation a échoué.",
|
||||
"invalid_token": "Le lien de confirmation est invalide ou expiré.",
|
||||
}
|
||||
|
||||
SUCCESS_MESSAGES = {
|
||||
"password_changed": "Mot de passe mis à jour.",
|
||||
"email_pending": "Un email de confirmation a été envoyé à la nouvelle adresse.",
|
||||
"email_changed": "Votre adresse email a été mise à jour.",
|
||||
}
|
||||
|
||||
|
||||
def _csrf(index: str):
|
||||
return dcc.Input(
|
||||
type="hidden",
|
||||
id={"type": "csrf-input", "index": index},
|
||||
name="csrf_token",
|
||||
)
|
||||
|
||||
|
||||
def _email_section():
|
||||
return html.Div(
|
||||
[
|
||||
html.H4("Adresse email", className="mt-2"),
|
||||
html.P([html.Strong("Email actuel : "), current_user.email]),
|
||||
html.Form(
|
||||
method="POST",
|
||||
action="/auth/change-email",
|
||||
children=[
|
||||
_csrf("change-email"),
|
||||
dbc.Label("Nouvelle adresse email"),
|
||||
dbc.Input(
|
||||
type="email", name="email", required=True, className="mb-3"
|
||||
),
|
||||
dbc.Button("Mettre à jour l'email", type="submit", color="primary"),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _password_section():
|
||||
return html.Div(
|
||||
[
|
||||
html.H4("Mot de passe", className="mt-4"),
|
||||
html.Form(
|
||||
method="POST",
|
||||
action="/auth/change-password",
|
||||
children=[
|
||||
_csrf("change-password"),
|
||||
dbc.Label("Mot de passe actuel"),
|
||||
dbc.Input(
|
||||
type="password",
|
||||
name="current_password",
|
||||
required=True,
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Label("Nouveau mot de passe (8 caractères minimum)"),
|
||||
dbc.Input(
|
||||
type="password",
|
||||
name="password",
|
||||
required=True,
|
||||
minLength=8,
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Label("Confirmer le nouveau mot de passe"),
|
||||
dbc.Input(
|
||||
type="password",
|
||||
name="password_confirm",
|
||||
required=True,
|
||||
minLength=8,
|
||||
className="mb-3",
|
||||
),
|
||||
dbc.Button(
|
||||
"Changer le mot de passe", type="submit", color="primary"
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _danger_section():
|
||||
return html.Div(
|
||||
[
|
||||
html.H4(
|
||||
"Zone danger",
|
||||
className="mt-3",
|
||||
style={"color": "#d9230f"},
|
||||
),
|
||||
html.P("La suppression de votre compte est définitive."),
|
||||
dbc.Button(
|
||||
"Supprimer mon compte",
|
||||
id="delete-open",
|
||||
color="danger",
|
||||
outline=True,
|
||||
),
|
||||
dbc.Modal(
|
||||
id="delete-modal",
|
||||
is_open=False,
|
||||
children=[
|
||||
dbc.ModalHeader(dbc.ModalTitle("Supprimer le compte")),
|
||||
html.Form(
|
||||
method="POST",
|
||||
action="/auth/delete-account",
|
||||
children=[
|
||||
dbc.ModalBody(
|
||||
[
|
||||
_csrf("delete-account"),
|
||||
html.P(
|
||||
"Cette action est définitive. "
|
||||
"Saisissez votre mot de passe pour confirmer."
|
||||
),
|
||||
dbc.Input(
|
||||
type="password",
|
||||
name="current_password",
|
||||
required=True,
|
||||
placeholder="Mot de passe",
|
||||
),
|
||||
]
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
[
|
||||
dbc.Button(
|
||||
"Annuler", id="delete-cancel", color="secondary"
|
||||
),
|
||||
dbc.Button(
|
||||
"Supprimer définitivement",
|
||||
type="submit",
|
||||
color="danger",
|
||||
),
|
||||
]
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
className="rounded p-3 mt-4",
|
||||
style={"border": "1px solid #d9230f"},
|
||||
)
|
||||
|
||||
|
||||
def _logout_section():
|
||||
return html.Form(
|
||||
method="POST",
|
||||
action="/auth/logout",
|
||||
className="mt-4",
|
||||
children=[
|
||||
_csrf("logout"),
|
||||
dbc.Button("Déconnexion", type="submit", color="secondary", outline=True),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def layout(
|
||||
error=None, password_changed=None, email_pending=None, email_changed=None, **_
|
||||
):
|
||||
guard = account_guard("/compte/admin", require_subscription=False)
|
||||
if guard is not None:
|
||||
return guard
|
||||
|
||||
alerts = []
|
||||
if error in ERROR_MESSAGES:
|
||||
alerts.append(dbc.Alert(ERROR_MESSAGES[error], color="danger"))
|
||||
for flag, value in (
|
||||
("password_changed", password_changed),
|
||||
("email_pending", email_pending),
|
||||
("email_changed", email_changed),
|
||||
):
|
||||
if value == "1":
|
||||
alerts.append(dbc.Alert(SUCCESS_MESSAGES[flag], color="success"))
|
||||
|
||||
contenu = html.Div(
|
||||
[
|
||||
html.H2("Compte"),
|
||||
*alerts,
|
||||
_email_section(),
|
||||
html.Hr(className="mt-4"),
|
||||
_password_section(),
|
||||
html.Hr(className="mt-4"),
|
||||
_danger_section(),
|
||||
]
|
||||
)
|
||||
return account_shell("admin", contenu)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("delete-modal", "is_open"),
|
||||
Input("delete-open", "n_clicks"),
|
||||
Input("delete-cancel", "n_clicks"),
|
||||
State("delete-modal", "is_open"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _toggle_delete_modal(_open, _cancel, is_open):
|
||||
return not is_open
|
||||
@@ -0,0 +1,13 @@
|
||||
from dash import dcc, register_page
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/compte",
|
||||
title="Mon compte | colibre",
|
||||
name="Mon compte",
|
||||
description="Redirection vers la gestion de compte.",
|
||||
)
|
||||
|
||||
|
||||
def layout(**_):
|
||||
return dcc.Location(href="/compte/admin", id="compte-root-redirect")
|
||||
@@ -0,0 +1,57 @@
|
||||
from dash import ALL, Input, Output, callback, ctx, no_update, register_page
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._compte_shell import account_guard, account_shell
|
||||
from src.roadmap import db as roadmap_db
|
||||
from src.roadmap import github
|
||||
from src.roadmap import ui as roadmap_ui
|
||||
from src.subscriptions import db as subs_db
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/compte/roadmap",
|
||||
title="Roadmap | colibre",
|
||||
name="Roadmap",
|
||||
description="Votez pour les prochaines fonctionnalités de colibre.",
|
||||
)
|
||||
|
||||
|
||||
def layout(**_):
|
||||
guard = account_guard("/compte/roadmap", require_subscription=True)
|
||||
if guard is not None:
|
||||
return guard
|
||||
balance = subs_db.credit_pending(current_user.id)
|
||||
next_recharge = subs_db.next_recharge_at(current_user.id)
|
||||
return account_shell(
|
||||
"roadmap",
|
||||
roadmap_ui.roadmap_content(
|
||||
editable=True, balance=balance, next_recharge=next_recharge
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("roadmap-vote-list", "children"),
|
||||
Input({"type": "roadmap-vote", "index": ALL}, "n_clicks"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def cast_vote(n_clicks):
|
||||
if not current_user.is_authenticated:
|
||||
return no_update
|
||||
if not ctx.triggered_id or not any(n_clicks):
|
||||
return no_update
|
||||
issue_number = ctx.triggered_id["index"]
|
||||
if subs_db.spend_vote(current_user.id):
|
||||
roadmap_db.record_vote(current_user.id, issue_number)
|
||||
balance = subs_db.credit_pending(current_user.id)
|
||||
next_recharge = subs_db.next_recharge_at(current_user.id)
|
||||
issues = github.fetch_roadmap_issues()
|
||||
counts = roadmap_db.vote_counts()
|
||||
return roadmap_ui.vote_items(
|
||||
issues["au_vote"],
|
||||
counts,
|
||||
editable=True,
|
||||
can_vote=balance > 0,
|
||||
balance=balance,
|
||||
next_recharge=next_recharge,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import (
|
||||
ALL,
|
||||
Input,
|
||||
Output,
|
||||
State,
|
||||
callback,
|
||||
ctx,
|
||||
html,
|
||||
no_update,
|
||||
register_page,
|
||||
)
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._compte_shell import account_guard, account_shell
|
||||
from src.saved_views import db as saved_views_db
|
||||
from src.saved_views import ui as saved_views_ui
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/compte/vues",
|
||||
title="Mes vues | colibre",
|
||||
name="Mes vues",
|
||||
description="Gérez vos vues enregistrées du tableau des marchés.",
|
||||
)
|
||||
|
||||
|
||||
def _content():
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return html.Div(
|
||||
[
|
||||
html.H2("Mes vues"),
|
||||
html.P(
|
||||
"Les vues que vous enregistrez depuis le Tableau apparaissent ici. "
|
||||
"Cliquez sur « Ouvrir » pour appliquer une vue."
|
||||
),
|
||||
html.Div(saved_views_ui.views_table(views), id="vues-list"),
|
||||
dbc.Modal(
|
||||
id="vue-rename-modal",
|
||||
is_open=False,
|
||||
children=[
|
||||
dbc.ModalHeader(dbc.ModalTitle("Renommer la vue")),
|
||||
dbc.ModalBody(
|
||||
dbc.Input(id="vue-rename-input", type="text"),
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
dbc.Button("Renommer", id="vue-rename-confirm", color="primary")
|
||||
),
|
||||
],
|
||||
),
|
||||
dbc.Input(id="vue-rename-id", type="hidden"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def layout(**_):
|
||||
guard = account_guard("/compte/vues", require_subscription=True)
|
||||
if guard is not None:
|
||||
return guard
|
||||
return account_shell("vues", _content())
|
||||
|
||||
|
||||
@callback(
|
||||
Output("vues-list", "children"),
|
||||
Input({"type": "vue-delete", "index": ALL}, "n_clicks"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def delete_view(n_clicks):
|
||||
if not ctx.triggered_id or not any(n_clicks):
|
||||
return no_update
|
||||
saved_views_db.delete(ctx.triggered_id["index"], current_user.id)
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return saved_views_ui.views_table(views)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("vue-rename-modal", "is_open"),
|
||||
Output("vue-rename-id", "value"),
|
||||
Input({"type": "vue-rename-open", "index": ALL}, "n_clicks"),
|
||||
Input("vue-rename-confirm", "n_clicks"),
|
||||
State("vue-rename-modal", "is_open"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def toggle_rename_modal(_open, _confirm, is_open):
|
||||
if isinstance(ctx.triggered_id, dict) and any(_open):
|
||||
return True, str(ctx.triggered_id["index"])
|
||||
return False, no_update
|
||||
|
||||
|
||||
@callback(
|
||||
Output("vues-list", "children", allow_duplicate=True),
|
||||
Input("vue-rename-confirm", "n_clicks"),
|
||||
State("vue-rename-id", "value"),
|
||||
State("vue-rename-input", "value"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def rename_view(_n, view_id, new_name):
|
||||
clean = saved_views_ui.clean_view_name(new_name)
|
||||
if not view_id or not clean:
|
||||
return no_update
|
||||
try:
|
||||
saved_views_db.rename(int(view_id), current_user.id, clean)
|
||||
except Exception:
|
||||
return no_update
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return saved_views_ui.views_table(views)
|
||||
Reference in New Issue
Block a user