[FIX] anonymize : passer la liste noire entière sans échec ni omission
Le SQL de la liste noire dépasse MAX_ARG_STRLEN, les 131 072 octets que Linux impose à UN argument ; il passe par un fichier avec -f, qui garde --single-transaction que l'entrée standard aurait perdu. Les colonnes texte à longueur déclarée sont tronquées par left(..., n), l'identifiant en TÊTE sur une colonne unique. Tous les identifiants sont cités : Odoo laisse nommer un champ user ou order. Écarter toute colonne sous CHECK laissait res_partner.name intact ; sur du texte, seules les contraintes de FORME sont hors de portée. Vérifié sur les quatre cas ; un UPDATE qui échoue n'écrit rien. --- EN --- The blacklist SQL exceeds MAX_ARG_STRLEN, the 131 072 bytes Linux allows for ONE argument; it travels through a file with -f, which keeps --single-transaction that stdin would have dropped. Text columns with a declared length are truncated by left(..., n), the id FIRST on a unique column. Every identifier is quoted: Odoo allows a field named user or order. Skipping every column under a CHECK left res_partner.name untouched; on text, only FORM constraints are out of reach. Checked on the four cases; an UPDATE that fails writes nothing. Assisted-by: Claude Opus 5 (cherry picked from commit 610f1d30414d578b49bff5649dbda82f4a2eb19f)
This commit is contained in:
parent
fe52ad9974
commit
13a7bca972
2 changed files with 350 additions and 38 deletions
|
|
@ -198,11 +198,16 @@ def champ_retenu(champ, inclure_connexion=False):
|
|||
# Une relation qui aurait échappé au filtre de ttype.
|
||||
return False
|
||||
if champ.get("checked"):
|
||||
# Une contrainte CHECK dit ce que la colonne a le droit de valoir.
|
||||
# Mesuré : crm_lead.probability doit rester entre 0 et 100, et un
|
||||
# tirage à 1000 fait échouer l'UPDATE — donc, transaction unique
|
||||
# oblige, TOUTE l'anonymisation. Lire l'expression du CHECK pour
|
||||
# tirer dedans serait deviner ; on s'abstient et on le dit.
|
||||
# Une contrainte CHECK hors de portée. Mesuré, et la distinction
|
||||
# compte : sur un NOMBRE toute contrainte borne la valeur —
|
||||
# crm_lead.probability entre 0 et 100, credit * debit = 0 — et un
|
||||
# tirage à 1000 fait échouer l'UPDATE, donc toute l'anonymisation.
|
||||
# Sur du TEXTE, presque toutes ne garantissent que la non-nullité :
|
||||
# `res_partner.name` n'exige d'être non nul que pour un contact, ce
|
||||
# qu'un mot satisfait. Les écarter TOUTES laissait le champ le plus
|
||||
# important de la base intact — une anonymisation qui n'anonymisait
|
||||
# pas les noms. La requête ne lève donc ce drapeau, pour du texte,
|
||||
# que sur les contraintes de FORME.
|
||||
return False
|
||||
if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb":
|
||||
# Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut
|
||||
|
|
@ -233,22 +238,46 @@ def litteral(texte):
|
|||
return "'" + str(texte).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def ident(nom):
|
||||
"""Un identifiant SQL, cité.
|
||||
|
||||
Odoo laisse nommer un champ `user`, `order` ou `group` : ce sont des
|
||||
mots réservés de PostgreSQL, et un identifiant nu fait échouer
|
||||
l'analyse syntaxique — donc, transaction unique oblige, TOUTE
|
||||
l'anonymisation. Mesuré en liste noire sur une base réelle :
|
||||
« syntax error at or near "user" ». Les citer coûte deux caractères
|
||||
et ferme la question pour tous les noms à venir.
|
||||
"""
|
||||
return '"' + str(nom).replace('"', '""') + '"'
|
||||
|
||||
|
||||
def expression_texte(champ, mots):
|
||||
"""Le SQL qui remplace un champ texte, en préservant les NULL.
|
||||
|
||||
Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en
|
||||
avait pas : la copie mentirait dans l'autre sens.
|
||||
"""
|
||||
nom = champ["name"]
|
||||
liste = mots_pour(nom, mots)
|
||||
nom = ident(champ["name"])
|
||||
liste = mots_pour(champ["name"], mots)
|
||||
# Les parenthèses ne sont pas décoratives : PostgreSQL refuse
|
||||
# d'indexer un constructeur ARRAY[...] directement.
|
||||
tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])"
|
||||
tirage = f"{tableau}[(id % {len(liste)}) + 1]"
|
||||
tirage = f'{tableau}[("id" % {len(liste)}) + 1]'
|
||||
borne = champ.get("max_len")
|
||||
if champ.get("unique"):
|
||||
# Deux lignes qui reçoivent le même mot feraient échouer TOUT
|
||||
# l'UPDATE sur une colonne unique.
|
||||
tirage = f"{tirage} || '-' || id::text"
|
||||
# l'UPDATE sur une colonne unique. L'identifiant vient EN TÊTE
|
||||
# quand la colonne est bornée : c'est lui qui porte l'unicité, et
|
||||
# une troncature par la droite doit le laisser intact.
|
||||
if borne:
|
||||
tirage = f"\"id\"::text || '-' || {tirage}"
|
||||
else:
|
||||
tirage = f"{tirage} || '-' || \"id\"::text"
|
||||
if borne:
|
||||
# varchar(n) : mesuré, 13 colonnes sont bornées sur une base
|
||||
# réelle, dont des codes à 1, 2 et 3 caractères. « jonquille »
|
||||
# dans un varchar(3) fait échouer l'UPDATE entier.
|
||||
tirage = f"left({tirage}, {borne})"
|
||||
if champ.get("pg_type") == "jsonb":
|
||||
# Un objet par langue depuis Odoo 17 : on le reconstruit clé à
|
||||
# clé. Écrire une chaîne par-dessus détruirait la colonne.
|
||||
|
|
@ -262,7 +291,7 @@ def expression_texte(champ, mots):
|
|||
|
||||
def expression_nombre(champ):
|
||||
"""Le SQL qui remplace un nombre : au hasard, entre 0 et 1000."""
|
||||
nom = champ["name"]
|
||||
nom = ident(champ["name"])
|
||||
if champ["ttype"] == "integer":
|
||||
tirage = "floor(random() * 1001)::integer"
|
||||
else:
|
||||
|
|
@ -275,14 +304,13 @@ def sql_pour_table(table, champs, mots):
|
|||
morceaux = []
|
||||
for champ in champs:
|
||||
if champ["ttype"] in TYPES_TEXTE:
|
||||
morceaux.append(
|
||||
f"{champ['name']} = {expression_texte(champ, mots)}"
|
||||
)
|
||||
valeur = expression_texte(champ, mots)
|
||||
else:
|
||||
morceaux.append(f"{champ['name']} = {expression_nombre(champ)}")
|
||||
valeur = expression_nombre(champ)
|
||||
morceaux.append(f"{ident(champ['name'])} = {valeur}")
|
||||
if not morceaux:
|
||||
return None
|
||||
return f"UPDATE {table} SET " + ", ".join(morceaux) + ";"
|
||||
return f"UPDATE {ident(table)} SET " + ", ".join(morceaux) + ";"
|
||||
|
||||
|
||||
def table_de(modele):
|
||||
|
|
@ -311,7 +339,19 @@ SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f'
|
|||
WHERE k.conrelid = c.oid
|
||||
AND k.contype = 'c'
|
||||
AND a.attnum = ANY(k.conkey)
|
||||
) THEN '1' ELSE '0' END
|
||||
AND (
|
||||
-- Sur un NOMBRE, toute contrainte borne la valeur :
|
||||
-- `credit * debit = 0`, `amount >= 0`. On s'abstient.
|
||||
f.ttype IN ('integer','float','monetary')
|
||||
-- Sur du TEXTE, presque toutes ne garantissent que la
|
||||
-- non-nullité, ce qu'un mot satisfait. Seules celles
|
||||
-- qui contraignent la FORME sont hors de portée.
|
||||
OR pg_get_constraintdef(k.oid) ~
|
||||
'char_length|~~|jsonb_typeof|similar to'
|
||||
)
|
||||
) THEN '1' ELSE '0' END || '\x1f'
|
||||
|| CASE WHEN a.atttypmod > 4
|
||||
THEN (a.atttypmod - 4)::text ELSE '' END
|
||||
FROM ir_model_fields f
|
||||
JOIN pg_class c ON c.relname = replace(f.model, '.', '_')
|
||||
AND c.relkind = 'r'
|
||||
|
|
@ -333,7 +373,7 @@ def inspect(database, config_path=None):
|
|||
champs = []
|
||||
for ligne in brut.splitlines():
|
||||
parts = ligne.split(SEP)
|
||||
if len(parts) != 6:
|
||||
if len(parts) != 7:
|
||||
continue
|
||||
champs.append(
|
||||
{
|
||||
|
|
@ -343,6 +383,11 @@ def inspect(database, config_path=None):
|
|||
"pg_type": parts[3],
|
||||
"unique": parts[4] == "1",
|
||||
"checked": parts[5] == "1",
|
||||
# varchar(n) : n, sinon None. Mesuré sur une base réelle,
|
||||
# 13 colonnes sont bornées — dont des codes à 1, 2 et 3
|
||||
# caractères. Y écrire « jonquille » fait échouer tout
|
||||
# l'UPDATE, et donc toute l'anonymisation.
|
||||
"max_len": int(parts[6]) if parts[6].isdigit() else None,
|
||||
}
|
||||
)
|
||||
return champs
|
||||
|
|
@ -446,29 +491,56 @@ def ecrire(database, etapes, config_path=None, timeout=900):
|
|||
redéclarer ici, ce serait accepter qu'elle diverge un jour.
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
env = lib_analyse.pg_env(config_path, timeout=timeout)
|
||||
env["PGOPTIONS"] = f"-c statement_timeout={timeout}s"
|
||||
sql = "\n".join(etape["sql"] for etape in etapes)
|
||||
done = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-X",
|
||||
"-w",
|
||||
"-1",
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-d",
|
||||
database,
|
||||
"-tA",
|
||||
"-c",
|
||||
sql,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=timeout + 60,
|
||||
|
||||
# PAR FICHIER, jamais par `-c`. Linux plafonne un seul argument à
|
||||
# MAX_ARG_STRLEN — 32 pages, soit 131 072 octets. Mesuré sur une base
|
||||
# réelle : le mode hybride tient dans 58 Ko et passait, la liste noire
|
||||
# produit 342 Ko sur 410 modèles et rendait « OSError: [Errno 7]
|
||||
# Argument list too long ». Le mode qui couvre le plus est justement
|
||||
# celui qui cassait.
|
||||
#
|
||||
# `-f` plutôt que l'entrée standard : `--single-transaction` n'est
|
||||
# documenté qu'avec `-c` ou `-f`, et c'est lui qui garantit le tout
|
||||
# ou rien. Le perdre en silence serait pire que le message d'erreur.
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".sql",
|
||||
prefix="el_anonymize_",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
)
|
||||
try:
|
||||
handle.write(sql)
|
||||
handle.close()
|
||||
done = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-X",
|
||||
"-w",
|
||||
"-1",
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-d",
|
||||
database,
|
||||
"-tA",
|
||||
"-f",
|
||||
handle.name,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=timeout + 60,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(handle.name)
|
||||
except OSError:
|
||||
pass
|
||||
if done.returncode:
|
||||
detail = (done.stderr or "").strip().splitlines()
|
||||
return detail[0][:200] if detail else "psql"
|
||||
|
|
|
|||
|
|
@ -178,9 +178,9 @@ class TestTheSqlItWrites(unittest.TestCase):
|
|||
def test_a_unique_column_gets_the_id_appended(self):
|
||||
"""Deux lignes au même mot feraient échouer TOUT l'UPDATE."""
|
||||
sql = anon.expression_texte(champ("ref", unique=True), ["a"])
|
||||
self.assertIn("id::text", sql)
|
||||
self.assertIn('"id"::text', sql)
|
||||
self.assertNotIn(
|
||||
"id::text", anon.expression_texte(champ("ref"), ["a"])
|
||||
'"id"::text', anon.expression_texte(champ("ref"), ["a"])
|
||||
)
|
||||
|
||||
def test_a_translated_column_is_rebuilt_key_by_key(self):
|
||||
|
|
@ -366,3 +366,243 @@ class TestTheRefusalToWrite(unittest.TestCase):
|
|||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase):
|
||||
"""La panne signalée : « OSError: [Errno 7] Argument list too long ».
|
||||
|
||||
Linux plafonne UN SEUL argument à MAX_ARG_STRLEN — 32 pages, soit
|
||||
131 072 octets. Mesuré sur une base réelle : le mode hybride produit
|
||||
58 Ko de SQL et passait, la liste noire en produit 342 Ko sur 410
|
||||
modèles et cassait. Le mode qui couvre le plus était celui qui
|
||||
échouait, donc celui qu'aucun de mes essais n'exerçait.
|
||||
|
||||
Le rendu de `render` reste borné, lui ; c'est bien l'exécution qu'il
|
||||
faut regarder, et pas seulement le plan.
|
||||
"""
|
||||
|
||||
def _executer(self, etapes):
|
||||
"""Lancer `ecrire` avec un faux psql, et rendre ce qu'il a reçu."""
|
||||
import script.analyse.anonymize as module
|
||||
|
||||
vu = {}
|
||||
vrai_run = module.__dict__.get("subprocess")
|
||||
|
||||
class FauxFait:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
import subprocess as vrai_subprocess
|
||||
|
||||
def espion(cmd, **kwargs):
|
||||
vu["cmd"] = list(cmd)
|
||||
chemin = cmd[cmd.index("-f") + 1] if "-f" in cmd else None
|
||||
if chemin:
|
||||
with open(chemin, encoding="utf-8") as handle:
|
||||
vu["fichier"] = handle.read()
|
||||
vu["chemin"] = chemin
|
||||
return FauxFait()
|
||||
|
||||
vrai_env = anon.lib_analyse.pg_env
|
||||
anon.lib_analyse.pg_env = lambda *a, **k: {"PATH": "/usr/bin"}
|
||||
vrai_subprocess_run = vrai_subprocess.run
|
||||
vrai_subprocess.run = espion
|
||||
try:
|
||||
erreur = anon.ecrire("une_base", etapes)
|
||||
finally:
|
||||
vrai_subprocess.run = vrai_subprocess_run
|
||||
anon.lib_analyse.pg_env = vrai_env
|
||||
del vrai_run
|
||||
vu["erreur"] = erreur
|
||||
return vu
|
||||
|
||||
def _gros_plan(self, combien=400):
|
||||
"""Un plan de la taille de celui qui cassait."""
|
||||
etapes = []
|
||||
for index in range(combien):
|
||||
champ = {
|
||||
"model": "m.%d" % index,
|
||||
"name": "name",
|
||||
"ttype": "char",
|
||||
"pg_type": "character varying",
|
||||
"unique": False,
|
||||
"checked": False,
|
||||
}
|
||||
etapes.append(
|
||||
{
|
||||
"model": champ["model"],
|
||||
"fields": [champ],
|
||||
"sql": anon.sql_pour_table("m_%d" % index, [champ], None),
|
||||
}
|
||||
)
|
||||
return etapes
|
||||
|
||||
def test_no_single_argument_comes_close_to_the_kernel_limit(self):
|
||||
vu = self._executer(self._gros_plan())
|
||||
plus_gros = max(len(a) for a in vu["cmd"])
|
||||
self.assertLess(
|
||||
plus_gros,
|
||||
4096,
|
||||
"un argument porte le SQL : c'est ce qui rendait E2BIG",
|
||||
)
|
||||
|
||||
def test_the_sql_goes_through_a_file_not_through_c(self):
|
||||
vu = self._executer(self._gros_plan(3))
|
||||
self.assertIn("-f", vu["cmd"])
|
||||
self.assertNotIn("-c", vu["cmd"])
|
||||
self.assertIn('UPDATE "m_0"', vu["fichier"])
|
||||
|
||||
def test_the_single_transaction_survives_the_change(self):
|
||||
"""`--single-transaction` n'est documenté qu'avec -c ou -f : passer
|
||||
par l'entrée standard l'aurait perdu en silence."""
|
||||
vu = self._executer(self._gros_plan(2))
|
||||
self.assertIn("-1", vu["cmd"])
|
||||
self.assertIn("ON_ERROR_STOP=1", vu["cmd"])
|
||||
|
||||
def test_the_temporary_file_does_not_survive(self):
|
||||
import os as vrai_os
|
||||
|
||||
vu = self._executer(self._gros_plan(2))
|
||||
self.assertFalse(vrai_os.path.exists(vu["chemin"]))
|
||||
|
||||
|
||||
class TestABoundedColumnIsNeverOverflowed(unittest.TestCase):
|
||||
"""`value too long for type character varying(3)`.
|
||||
|
||||
Mesuré sur une base réelle : 13 colonnes texte portent une longueur
|
||||
déclarée, dont des codes à 1, 2 et 3 caractères — `res.country.code`,
|
||||
`account.journal.code`. Y écrire « jonquille » fait échouer l'UPDATE,
|
||||
et comme l'écriture est transactionnelle, TOUTE l'anonymisation.
|
||||
|
||||
Le mode hybride ne touchait aucune de ces colonnes ; la liste noire,
|
||||
si. Le mode qui couvre le plus est celui qui cassait.
|
||||
"""
|
||||
|
||||
def _champ(self, **kw):
|
||||
base = {
|
||||
"model": "m",
|
||||
"name": "code",
|
||||
"ttype": "char",
|
||||
"pg_type": "character varying",
|
||||
"unique": False,
|
||||
"checked": False,
|
||||
"max_len": None,
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
def test_a_bounded_column_is_truncated(self):
|
||||
sql = anon.expression_texte(self._champ(max_len=3), ["jonquille"])
|
||||
self.assertIn("left(", sql)
|
||||
self.assertIn(", 3)", sql)
|
||||
|
||||
def test_an_unbounded_column_is_left_alone(self):
|
||||
sql = anon.expression_texte(self._champ(), ["jonquille"])
|
||||
self.assertNotIn("left(", sql)
|
||||
|
||||
def test_a_bounded_unique_column_keeps_the_id_in_front(self):
|
||||
"""Tronquer par la droite doit laisser l'identifiant intact :
|
||||
c'est lui qui porte l'unicité."""
|
||||
sql = anon.expression_texte(
|
||||
self._champ(max_len=8, unique=True), ["jonquille"]
|
||||
)
|
||||
self.assertIn("left(\"id\"::text || '-'", sql)
|
||||
|
||||
def test_an_unbounded_unique_column_keeps_the_old_shape(self):
|
||||
sql = anon.expression_texte(self._champ(unique=True), ["jonquille"])
|
||||
self.assertTrue(sql.rstrip().endswith("|| '-' || \"id\"::text END"))
|
||||
|
||||
def test_the_length_is_read_from_the_database_not_guessed(self):
|
||||
"""`atttypmod` est la seule source : une longueur devinée serait
|
||||
fausse dès qu'un module en change une."""
|
||||
self.assertIn("atttypmod", anon.REQUETE_CHAMPS)
|
||||
|
||||
|
||||
class TestReservedWordsCannotBreakTheStatement(unittest.TestCase):
|
||||
"""`syntax error at or near "user"`.
|
||||
|
||||
Odoo laisse nommer un champ `user`, `order` ou `group`. Un identifiant
|
||||
nu fait alors échouer l'analyse syntaxique — et l'écriture étant
|
||||
transactionnelle, c'est toute l'anonymisation qui tombe. Trouvé en
|
||||
liste noire sur une base réelle, jamais en mode hybride : les quinze
|
||||
modèles par défaut n'en portent aucun.
|
||||
"""
|
||||
|
||||
def _champ(self, nom):
|
||||
return {
|
||||
"model": "m",
|
||||
"name": nom,
|
||||
"ttype": "char",
|
||||
"pg_type": "character varying",
|
||||
"unique": False,
|
||||
"checked": False,
|
||||
"max_len": None,
|
||||
}
|
||||
|
||||
def test_a_column_named_like_a_keyword_is_quoted(self):
|
||||
for nom in ("user", "order", "group", "check", "references", "limit"):
|
||||
sql = anon.sql_pour_table("t", [self._champ(nom)], ["a"])
|
||||
self.assertIn(f'"{nom}" =', sql, nom)
|
||||
self.assertNotIn(f" {nom} =", sql, nom)
|
||||
|
||||
def test_a_table_named_like_a_keyword_is_quoted(self):
|
||||
sql = anon.sql_pour_table("order", [self._champ("name")], ["a"])
|
||||
self.assertTrue(sql.startswith('UPDATE "order" SET'))
|
||||
|
||||
def test_the_row_identifier_is_quoted_too(self):
|
||||
"""Une seule règle vaut mieux que deux : tout identifiant est cité."""
|
||||
sql = anon.expression_texte(self._champ("name"), ["a", "b"])
|
||||
self.assertIn('"id"', sql)
|
||||
|
||||
def test_a_quote_inside_an_identifier_cannot_escape(self):
|
||||
self.assertEqual(anon.ident('a"b'), '"a""b"')
|
||||
|
||||
|
||||
class TestACheckDoesNotSilenceTheMainField(unittest.TestCase):
|
||||
"""La règle « écarter toute colonne sous CHECK » était trop large.
|
||||
|
||||
Mesuré : `res_partner.name` porte
|
||||
CHECK ((type='contact' AND name IS NOT NULL) OR type<>'contact')
|
||||
— une garantie de non-nullité, qu'un mot satisfait. L'écarter rendait
|
||||
une anonymisation qui n'anonymisait pas les noms, en annonçant 255
|
||||
colonnes écrites. Le pire des deux mondes : silencieux et faux.
|
||||
|
||||
Sur un NOMBRE la distinction s'inverse : `credit * debit = 0` et
|
||||
`amount >= 0` bornent la valeur, et un tirage à 1000 les viole.
|
||||
"""
|
||||
|
||||
def test_the_query_tells_numbers_from_text(self):
|
||||
"""La règle vit dans le SQL : c'est là qu'elle se vérifie."""
|
||||
requete = anon.REQUETE_CHAMPS
|
||||
self.assertIn("f.ttype IN ('integer','float','monetary')", requete)
|
||||
self.assertIn("pg_get_constraintdef", requete)
|
||||
|
||||
def test_only_shape_constraints_disqualify_text(self):
|
||||
for motif in ("char_length", "~~", "jsonb_typeof"):
|
||||
self.assertIn(motif, anon.REQUETE_CHAMPS, motif)
|
||||
|
||||
def test_a_field_the_query_cleared_is_anonymised(self):
|
||||
"""`checked=False` doit suffire : aucune seconde barrière cachée."""
|
||||
champ = {
|
||||
"model": "res.partner",
|
||||
"name": "name",
|
||||
"ttype": "char",
|
||||
"pg_type": "character varying",
|
||||
"unique": False,
|
||||
"checked": False,
|
||||
"max_len": None,
|
||||
}
|
||||
self.assertTrue(anon.champ_retenu(champ))
|
||||
|
||||
def test_a_field_the_query_flagged_is_left_alone(self):
|
||||
champ = {
|
||||
"model": "account.move.line",
|
||||
"name": "credit",
|
||||
"ttype": "monetary",
|
||||
"pg_type": "numeric",
|
||||
"unique": False,
|
||||
"checked": True,
|
||||
"max_len": None,
|
||||
}
|
||||
self.assertFalse(anon.champ_retenu(champ))
|
||||
|
|
|
|||
Loading…
Reference in a new issue