feat: introduce electronic invoicing access control
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+13
-4
@@ -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",
|
||||
|
||||
+25
-4
@@ -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
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 l’application."""
|
||||
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user