feat: introduce electronic invoicing access control

This commit is contained in:
Julien Denizot
2026-06-21 14:58:41 +00:00
parent 410437ffcd
commit 866bf4b213
11 changed files with 347 additions and 33 deletions
+52 -9
View File
@@ -13,6 +13,12 @@ from enuxia_einvoice.customer import (
FrenchIdentifierError,
validate_company_number,
)
from enuxia_einvoice.permissions import (
ROLE_OPERATOR,
has_company_access,
require_company_access,
require_einvoice_role,
)
from enuxia_einvoice.providers.superpdp.client import (
SuperPDPClient,
SuperPDPError,
@@ -170,33 +176,56 @@ def _get_customer(customer_name: str):
def _get_enabled_account(
account_name: str | None = None,
):
"""Charger un compte SuperPDP accessible."""
if account_name:
account = frappe.get_doc(
"Electronic Invoicing Account",
account_name,
)
if account.provider != "SuperPDP":
frappe.throw(
_("Le compte sélectionné n'utilise pas SuperPDP.")
)
if not account.enabled:
frappe.throw(
_("Le compte SuperPDP sélectionné est désactivé.")
)
require_company_access(
account.company
)
return account
accounts = frappe.get_all(
candidates = frappe.get_all(
"Electronic Invoicing Account",
filters={
"enabled": 1,
"provider": "SuperPDP",
},
pluck="name",
limit=2,
fields=[
"name",
"company",
],
limit_page_length=0,
)
accounts = [
candidate
for candidate in candidates
if has_company_access(
candidate.get("company")
)
]
if not accounts:
frappe.throw(
_(
"Aucun compte SuperPDP actif n'est configuré."
"Aucun compte SuperPDP actif "
"et accessible n'est configuré."
),
title=_("Compte SuperPDP manquant"),
)
@@ -204,18 +233,24 @@ def _get_enabled_account(
if len(accounts) > 1:
frappe.throw(
_(
"Plusieurs comptes SuperPDP sont actifs. "
"Le choix de la société émettrice sera ajouté "
"lors du workflow de facture."
"Plusieurs comptes SuperPDP accessibles "
"sont actifs. Sélectionnez le compte "
"correspondant à la société concernée."
),
title=_("Plusieurs comptes disponibles"),
)
return frappe.get_doc(
account = frappe.get_doc(
"Electronic Invoicing Account",
accounts[0],
accounts[0]["name"],
)
require_company_access(
account.company
)
return account
def _get_authenticated_client(account):
if account.authentication_flow != "Client Credentials":
@@ -362,6 +397,10 @@ def search_customer_directory(
) -> dict[str, Any]:
"""Rechercher et éventuellement sélectionner une adresse."""
require_einvoice_role(
ROLE_OPERATOR
)
customer, siren = _get_customer(customer_name)
account = _get_enabled_account(account_name)
@@ -507,6 +546,10 @@ def select_customer_directory_entry(
) -> dict[str, Any]:
"""Valider puis enregistrer une adresse choisie."""
require_einvoice_role(
ROLE_OPERATOR
)
customer, siren = _get_customer(customer_name)
account = _get_enabled_account(account_name)
@@ -478,12 +478,21 @@
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"role": "Facturation électronique - Lecture",
"read": 1
},
{
"role": "Facturation électronique - Opérateur",
"read": 1
},
{
"role": "Facturation électronique - Responsable",
"read": 1
},
{
"role": "System Manager",
"read": 1,
"write": 1,
"create": 1,
"delete": 1
"read": 1
}
],
"row_format": "Dynamic",
@@ -95,12 +95,14 @@
{
"fieldname": "client_id",
"fieldtype": "Data",
"label": "Client ID"
"label": "Client ID",
"permlevel": 1
},
{
"fieldname": "client_secret",
"fieldtype": "Password",
"label": "Client Secret"
"label": "Client Secret",
"permlevel": 1
},
{
"fieldname": "access_token",
@@ -238,10 +240,29 @@
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"role": "Facturation électronique - Responsable",
"permlevel": 0,
"read": 1,
"write": 1,
"create": 1
},
{
"role": "Facturation électronique - Responsable",
"permlevel": 1,
"read": 1,
"write": 1
},
{
"role": "System Manager",
"permlevel": 0,
"read": 1,
"write": 1,
"create": 1
},
{
"role": "System Manager",
"permlevel": 1,
"read": 1,
"write": 1
}
],
+5 -1
View File
@@ -11,6 +11,10 @@ from typing import Any
import frappe
from frappe import _
from enuxia_einvoice.permissions import (
require_exchange_access,
)
from frappe.utils import get_datetime, now_datetime
from frappe.utils.file_manager import save_file
@@ -452,7 +456,7 @@ def _get_inbound_exchange(
exchange_name,
)
exchange.check_permission("write")
require_exchange_access(exchange)
if exchange.direction != "Inbound":
frappe.throw(
+148
View File
@@ -0,0 +1,148 @@
# Copyright (c) 2026, Enuxia and contributors
# For license information, please see license.txt
from __future__ import annotations
import frappe
from frappe import _
ROLE_READER = "Facturation électronique - Lecture"
ROLE_OPERATOR = "Facturation électronique - Opérateur"
ROLE_MANAGER = "Facturation électronique - Responsable"
EINVOICE_ROLES = (
ROLE_READER,
ROLE_OPERATOR,
ROLE_MANAGER,
)
ROLE_LEVELS = {
ROLE_READER: 1,
ROLE_OPERATOR: 2,
ROLE_MANAGER: 3,
}
def get_einvoice_access_level() -> int:
"""Retourner le niveau d'accès Enuxia de l'utilisateur courant."""
if frappe.session.user == "Administrator":
return 99
roles = set(frappe.get_roles())
if "System Manager" in roles:
return 99
return max(
(
level
for role, level in ROLE_LEVELS.items()
if role in roles
),
default=0,
)
def require_einvoice_role(
minimum_role: str,
) -> None:
"""Exiger un niveau minimal de facturation électronique."""
required_level = ROLE_LEVELS.get(
minimum_role
)
if required_level is None:
raise ValueError(
f"Rôle Enuxia inconnu : {minimum_role}"
)
if get_einvoice_access_level() >= required_level:
return
frappe.throw(
_(
"Vous n’êtes pas autorisé à effectuer "
"cette opération de facturation électronique."
),
exc=frappe.PermissionError,
title=_("Accès refusé"),
)
def has_company_access(
company: str,
) -> bool:
"""Indiquer si l'utilisateur peut consulter une société."""
company_name = str(
company or ""
).strip()
if not company_name:
return False
if get_einvoice_access_level() == 99:
return True
try:
company_doc = frappe.get_doc(
"Company",
company_name,
)
company_doc.check_permission("read")
except (
frappe.PermissionError,
frappe.DoesNotExistError,
):
return False
return True
def require_company_access(
company: str,
) -> None:
"""Exiger l'accès à une société ERPNext."""
company_name = str(
company or ""
).strip()
if (
company_name
and has_company_access(company_name)
):
return
frappe.throw(
_(
"Vous n’êtes pas autorisé à utiliser "
"la société associée à cette opération."
),
exc=frappe.PermissionError,
title=_("Accès à la société refusé"),
)
def require_exchange_access(
exchange,
*,
minimum_role: str = ROLE_OPERATOR,
) -> None:
"""Autoriser une action contrôlée sur un échange."""
require_einvoice_role(
minimum_role
)
exchange.check_permission(
"read"
)
require_company_access(
exchange.company
)
+9 -5
View File
@@ -8,6 +8,10 @@ from typing import Any
import frappe
from frappe import _
from enuxia_einvoice.permissions import (
require_exchange_access,
)
from frappe.utils import getdate
@@ -194,7 +198,7 @@ def set_purchase_posting_date(
exchange_name,
)
exchange.check_permission("write")
require_exchange_access(exchange)
if exchange.direction != "Inbound":
frappe.throw(
@@ -218,12 +222,12 @@ def set_purchase_posting_date(
posting_date,
)
exchange.purchase_posting_date = (
result["posting_date"]
exchange.db_set(
"purchase_posting_date",
result["posting_date"],
update_modified=True,
)
exchange.save()
return {
"ok": True,
"exchange": exchange.name,
+5 -1
View File
@@ -8,6 +8,10 @@ from typing import Any
import frappe
from frappe import _
from enuxia_einvoice.permissions import (
require_exchange_access,
)
from enuxia_einvoice.purchase_calculation import (
build_purchase_invoice_document,
calculate_purchase_invoice_in_memory,
@@ -385,7 +389,7 @@ def create_purchase_invoice_draft(
exchange_name,
)
exchange.check_permission("write")
require_exchange_access(exchange)
if exchange.direction != "Inbound":
frappe.throw(
+6 -2
View File
@@ -10,6 +10,10 @@ from typing import Any
import frappe
from frappe import _
from enuxia_einvoice.permissions import (
require_exchange_access,
)
AMOUNT_TOLERANCE = Decimal("0.01")
@@ -1313,7 +1317,7 @@ def prepare_purchase_line_mappings(
exchange_name,
)
exchange.check_permission("write")
require_exchange_access(exchange)
if exchange.direction != "Inbound":
frappe.throw(
@@ -1532,7 +1536,7 @@ def validate_purchase_line_mappings(
exchange_name,
)
exchange.check_permission("write")
require_exchange_access(exchange)
result = preview_purchase_invoice(
exchange_name
+39 -7
View File
@@ -8,41 +8,73 @@ from typing import Any
import frappe
from frappe import _
from enuxia_einvoice.permissions import (
ROLE_MANAGER,
has_company_access,
require_company_access,
require_einvoice_role,
)
from enuxia_einvoice.providers.superpdp.client import (
SuperPDPClient,
)
def _get_sandbox_account():
accounts = frappe.get_all(
"""Charger l'unique compte Sandbox accessible."""
require_einvoice_role(
ROLE_MANAGER
)
candidates = frappe.get_all(
"Electronic Invoicing Account",
filters={
"enabled": 1,
"provider": "SuperPDP",
"environment": "Sandbox",
},
pluck="name",
limit=2,
fields=[
"name",
"company",
],
limit_page_length=0,
)
accounts = [
candidate
for candidate in candidates
if has_company_access(
candidate.get("company")
)
]
if not accounts:
frappe.throw(
_("Aucun compte SuperPDP Sandbox actif.")
_(
"Aucun compte SuperPDP Sandbox actif "
"et accessible n'est configuré."
)
)
if len(accounts) > 1:
frappe.throw(
_(
"Plusieurs comptes SuperPDP Sandbox "
"sont actifs."
"accessibles sont actifs."
)
)
return frappe.get_doc(
account = frappe.get_doc(
"Electronic Invoicing Account",
accounts[0],
accounts[0]["name"],
)
require_company_access(
account.company
)
return account
@frappe.whitelist()
def get_superpdp_test_invoice() -> dict[str, Any]:
+5
View File
@@ -9,13 +9,18 @@ from enuxia_einvoice.setup.custom_fields import (
from enuxia_einvoice.setup.sync_navigation import (
sync_navigation,
)
from enuxia_einvoice.setup.roles import (
sync_roles,
)
def after_install() -> None:
sync_roles()
create_enuxia_custom_fields()
sync_navigation()
def after_migrate() -> None:
sync_roles()
create_enuxia_custom_fields()
sync_navigation()
+40
View File
@@ -0,0 +1,40 @@
# Copyright (c) 2026, Enuxia and contributors
# For license information, please see license.txt
from __future__ import annotations
import frappe
from enuxia_einvoice.permissions import (
EINVOICE_ROLES,
)
def sync_roles() -> None:
"""Créer et réactiver les rôles de lapplication."""
for role_name in EINVOICE_ROLES:
if frappe.db.exists(
"Role",
role_name,
):
frappe.db.set_value(
"Role",
role_name,
"disabled",
0,
update_modified=False,
)
continue
role = frappe.get_doc(
{
"doctype": "Role",
"role_name": role_name,
"desk_access": 1,
}
)
role.insert(
ignore_permissions=True
)