Commit 34fe5579 authored by Yohan Boniface's avatar Yohan Boniface

Add a reason message in the status

parent 05cc9dae
......@@ -6,6 +6,6 @@ prod:
username: yboniface
hostname: 167.114.237.170
qa:
version: master
version: export-conditions
username: root
hostname: 51.15.221.175
import pytest
from trefle.exceptions import WrongPointerError
from trefle.rules import LazyValue
from trefle.rules import Pointer
def test_lazyvalue_with_string_constant(patch_schema):
def test_pointer_with_string_constant(patch_schema):
patch_schema({}) # Make sure we have no labels.
lv = LazyValue('«CDI»')
lv = Pointer('«CDI»')
assert lv.get() == 'CDI'
def test_lazyvalue_with_int_constant():
lv = LazyValue('18')
def test_pointer_with_int_constant():
lv = Pointer('18')
assert lv.get() == 18
def test_lazyvalue_with_float_constant():
lv = LazyValue('27.45')
def test_pointer_with_float_constant():
lv = Pointer('27.45')
assert lv.get() == 27.45
def test_lazyvalue_with_int_variable(patch_schema):
def test_pointer_with_int_variable(patch_schema):
patch_schema({'a_key': {'type': 'integer', 'label': 'a label'}})
lv = LazyValue('a label')
lv = Pointer('a label')
assert lv.get(a_key=27) == 27
def test_lazyvalue_with_bool_variable(patch_schema):
def test_pointer_with_bool_variable(patch_schema):
patch_schema({'a_key': {'type': 'boolean', 'label': 'a label'}})
lv = LazyValue('a label')
lv = Pointer('a label')
assert lv.get(a_key=True) is True
def test_lazyvalue_raises_with_invalid_pointer():
def test_pointer_raises_with_invalid_pointer():
with pytest.raises(WrongPointerError):
LazyValue('invalid pointer')
Pointer('invalid pointer')
def test_lazyvalue_should_return_default_value_for_missing_key(patch_schema):
def test_pointer_should_return_default_value_for_missing_key(patch_schema):
patch_schema({'a_key': {'type': 'integer', 'label': 'a label',
'default': 123}})
lv = LazyValue('a label')
lv = Pointer('a label')
assert lv.get(wrong=27) == 123
def test_lazyvalue_should_return_default_value_for_none_value(patch_schema):
def test_pointer_should_return_default_value_for_none_value(patch_schema):
patch_schema({'a_key': {'type': 'integer', 'label': 'a label',
'default': 123}})
lv = LazyValue('a label')
lv = Pointer('a label')
assert lv.get(wrong=None) == 123
......@@ -30,9 +30,28 @@ def parse_args(args):
return data
def colorize(s, status, prefix='✓✗'):
func = red
char = f'{prefix[1]} ' if prefix else ''
if status is True:
func = green
char = f'{prefix[0]} ' if prefix else ''
return func(f'{char}{s}')
def render_status(status):
if status.get('terms'):
line = f' {status["connective"]} '.join(render_status(t)
for t in status['terms'])
else:
line = colorize(status['condition'], status['status'], prefix=None)
return line
@cli(name='simulate')
async def cli_simulate(*args, context: json.loads={}, url=None, trace=False,
output_scenario=False, show_context=False):
output_scenario=False, show_context=False,
reason=False, eligibilite=False):
"""Simulate a call to the API.
Pass context as args in the form key=value.
......@@ -41,6 +60,8 @@ async def cli_simulate(*args, context: json.loads={}, url=None, trace=False,
:trace: Display a trace of all checked conditions.
:output_scenario: Render a Gherkin scenario with given context.
:show_context: Render a table with used context.
:reason: Output reasons why a financement is not eligible.
:eligibilite: Show éligibilité rules for a financement non éligible.
"""
if 'context' in context:
context = context['context'] # Copy-paste from our logs.
......@@ -76,25 +97,33 @@ async def cli_simulate(*args, context: json.loads={}, url=None, trace=False,
else:
print('Aucun financement éligible')
for financement in eligibles:
if financement.get('eligible'):
print('- Nom:', financement['nom'])
print(' Description:', financement['description'][:150], '…')
print(' Démarches:', financement['demarches'][:150], '…')
print(' Organisme:')
print(' Nom:', financement['organisme']['nom'])
print(' Site web:', financement['organisme']['web'])
if financement['prise_en_charge']:
print(' Financement:', financement['prise_en_charge'], '€')
if financement['plafond_prix_horaire']:
print(' Plafond horaire:',
financement['plafond_prix_horaire'], '€')
if financement['heures']:
print(' Heures prises en charge:', financement['heures'])
if financement['plafond_prise_en_charge']:
print(' Plafond financement:',
financement['plafond_prise_en_charge'], '€')
print(' Rémunération:', financement['remuneration'], '€')
print('')
print('- Nom:', financement['nom'])
print(' Description:', financement['description'][:150], '…')
print(' Démarches:', financement['demarches'][:150], '…')
print(' Organisme:')
print(' Nom:', financement['organisme']['nom'])
print(' Site web:', financement['organisme']['web'])
if financement['prise_en_charge']:
print(' Financement:', financement['prise_en_charge'], '€')
if financement['plafond_prix_horaire']:
print(' Plafond horaire:',
financement['plafond_prix_horaire'], '€')
if financement['heures']:
print(' Heures prises en charge:', financement['heures'])
if financement['plafond_prise_en_charge']:
print(' Plafond financement:',
financement['plafond_prise_en_charge'], '€')
print(' Rémunération:', financement['remuneration'], '€')
print('')
print('Financements non éligibles')
non_eligibles = [f for f in financements if not f['eligible']]
for financement in non_eligibles:
print('- Nom:', financement['nom'])
if eligibilite:
print("- Règles d'éligibilité")
for status in financement['eligibilite']:
line = colorize(render_status(status), status['status'])
print(' '*4, line)
if output_scenario:
if url:
print(f'# {url}')
......
......@@ -5,28 +5,17 @@ Si c'est un bénéficiaire de droit privé
Et la durée en mois de la formation est inférieure ou égale à 12
Si le type de contrat du bénéficiaire est «CDI»
Si l'IDCC de l'établissement du bénéficiaire ne fait pas partie des codes IDCC de l'artisanat
Et l'ancienneté en mois du bénéficiaire dans son entreprise est supérieure ou égale à 12
Et l'expérience professionnelle du bénéficiaire dans les cinq dernières années est supérieure ou égale à 24
Alors définir le financement «CIF CDI sur son temps de travail» comme éligible
Si la durée en heures de la formation est supérieure ou égale à 120
Alors définir le financement «CIF CDI hors temps de travail» comme éligible
Si l'IDCC de l'établissement du bénéficiaire fait partie des codes IDCC de l'artisanat
Et l'ancienneté en mois du bénéficiaire dans son entreprise est supérieure à 12
Et l'expérience professionnelle du bénéficiaire dans les cinq dernières années est supérieure ou égale à 36
Et l'ancienneté en mois du bénéficiaire dans son entreprise est supérieure ou égale à 12
Si l'IDCC de l'établissement du bénéficiaire ne fait pas partie des codes IDCC de l'artisanat, et l'expérience professionnelle du bénéficiaire dans les cinq dernières années est supérieure ou égale à 24
Ou l'IDCC de l'établissement du bénéficiaire fait partie des codes IDCC de l'artisanat, et l'expérience professionnelle du bénéficiaire dans les cinq dernières années est supérieure ou égale à 36
Alors définir le financement «CIF CDI sur son temps de travail» comme éligible
Si la durée en heures de la formation est supérieure ou égale à 120
Alors définir le financement «CIF CDI hors temps de travail» comme éligible
Si le type de contrat du bénéficiaire est «CDD»
Et le nombre de mois travaillés par le bénéficiaire dans la dernière année est supérieur ou égal à 4
Si l'âge du bénéficiaire est inférieur à 26
Et l'expérience professionnelle du bénéficiaire dans les cinq dernières années est supérieure ou égale à 12
Alors définir le financement «CIF CDD sur son temps de travail» comme éligible
Si la durée en heures de la formation est supérieure ou égale à 120
Alors définir le financement «CIF CDD hors temps de travail» comme éligible
Si l'âge du bénéficiaire est supérieur ou égal à 26
Et l'expérience professionnelle du bénéficiaire dans les cinq dernières années est supérieure ou égale à 24
Si l'âge du bénéficiaire est inférieur à 26, et l'expérience professionnelle du bénéficiaire dans les cinq dernières années est supérieure ou égale à 12
Ou l'âge du bénéficiaire est supérieur ou égal à 26, et l'expérience professionnelle du bénéficiaire dans les cinq dernières années est supérieure ou égale à 24
Alors définir le financement «CIF CDD sur son temps de travail» comme éligible
Si la durée en heures de la formation est supérieure ou égale à 120
Alors définir le financement «CIF CDD hors temps de travail» comme éligible
......
......@@ -576,6 +576,13 @@ financement:
type: boolean
connective:
type: string
reason:
type: string
nullable: true
# params:
# type: object
# additionalProperties:
# type: string
terms:
# TODO dry me
type: array
......@@ -584,6 +591,7 @@ financement:
# See https://github.com/p1c2u/openapi-core/issues/33#issuecomment-403022629
additionalProperties:
type: string
nullable: true
description: Liste de conditions nécessaires pour rendre ce financement éligible
organisme:
nom:
......
......@@ -184,15 +184,16 @@ input[type=submit]:hover,
padding: 1em;
position: absolute;
left: 0;
bottom: calc(100% + 20px);
bottom: calc(100% + 10px);
min-width: 150px;
z-index: 10;
color: #222;
}
.tooltip .tooltip-content:after {
content: "";
position: absolute;
left: 0;
bottom: -27px;
left: -1px;
bottom: -11px;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid #68c3a3;
......
......@@ -48,6 +48,10 @@
display: grid;
grid-template-columns: 2em auto;
}
.tooltip .tooltip-content:after {
/* Why? */
bottom: -27px;
}
</style>
<script>
this.raw = opts.content.replace(/Si /g, '<strong>Si </strong>')
......
......@@ -2,7 +2,7 @@
<section>
<h2>Depuis une URL LBF</h2>
<form onsubmit={ this.submit }>
<input type="url" ref="url" placeholder="Entrer une URL LBF" name="lbfurl" href="#">
<input type="url" ref="url" placeholder="Entrer une URL LBF" name="lbfurl" href="#" value={ this.opts.url }>
<input type="submit" value="Simuler">
</form>
</section>
......@@ -44,6 +44,12 @@
this.errors = {}
this.mixin(View)
this.on('mount', () => this.load())
this.load = () => {
if (this.opts.url) this.simulate_url(this.opts.url)
}
this.dataErrors = (data) => {
this.errors = data
this.update()
......@@ -80,6 +86,10 @@
e.preventDefault();
const url = this.refs.url.value
if (!url) return
route(`simulate/${url}`)
}
this.simulate_url = (url) => {
this.decodeLBFURL(url, this.simulate)
}
......@@ -128,25 +138,25 @@
</simulate>
<eligibilite>
<ul style={ style }>
<li each={ this.opts.conditions } class={ passed: status, failed: !status }>
<virtual>{ condition }</virtual>
<eligibilite if={ !status && terms } conditions={ terms } level={ this.level + 1 }></eligibilite>
<ul>
<li each={ props, i in this.opts.conditions } class={ passed: props.status, failed: !props.status }>
<span if={ i && this.opts.connective }> { this.opts.connective } </span>
<span class=tooltip if={ !props.terms && !props.status }><span>{ props.condition }</span><span class=tooltip-content>{ props.reason }</span></span>
<span if={ (!props.terms && props.status) || (props.status && props.terms) }>{ props.condition }</span>
<eligibilite if={ !props.status && props.terms } conditions={ props.terms } connective={ props.connective }></eligibilite>
</li>
</ul>
<script>
this.level = this.opts.level || 0
this.style = {
display: 'block',
'padding-left': (this.level * 10) + 'px'
}
</script>
<style scoped>
.failed {
color: #c0392b;
}
li .passed:before, li .failed:before {
content: '';
}
li li, li ul {
display: inline;
}
</style>
</eligibilite>
......@@ -32,6 +32,7 @@
route('/rules/(.+)', (id) => riot.mount('rules', {id: id}))
route('/rules', () => riot.mount('rules'))
route('/simulate', () => riot.mount('simulate'))
route('/simulate/(.+)', (url) => riot.mount('simulate', {url: url}))
route('/tools', () => riot.mount('tools'))
route('/glossary', () => riot.mount('glossary'))
......
......@@ -17,7 +17,7 @@ def Label(v):
raise WrongPointerError(v)
class LazyValue:
class Pointer:
def __init__(self, raw):
self.raw = raw
......@@ -27,7 +27,7 @@ class LazyValue:
self.compile()
def __repr__(self):
return f'<LazyValue: {self.raw}>'
return f'<Pointer: {self.raw}>'
def compile(self):
value = self.parse_value(self.raw)
......@@ -38,7 +38,7 @@ class LazyValue:
self.key = LABELS[self.raw]
except KeyError:
raise WrongPointerError(self.raw)
self.get = lambda **d: self._get(**d)
self.get = lambda **d: self._get_from_context(**d)
self.default = self.compute_default()
def parse_value(self, value):
......@@ -58,7 +58,7 @@ class LazyValue:
value = ...
return value
def _get(self, **context):
def _get_from_context(self, **context):
try:
value = context[self.key]
except KeyError:
......@@ -78,6 +78,16 @@ class LazyValue:
return ...
class Value:
def __init__(self, pointer, context):
self.value = pointer.get(**context)
self.pointer = pointer
def __str__(self):
return str(self.value or 'aucun(e)')
def action(pattern):
def wrapper(func):
......@@ -92,6 +102,16 @@ def condition(pattern):
def wrapper(func):
Condition.PATTERNS[re.compile(pattern)] = func
func.reason = None
return func
return wrapper
def reason(msg):
def wrapper(func):
func.reason = msg
return func
return wrapper
......@@ -114,6 +134,8 @@ class Status:
self.terms = []
self.children = []
self.parent = parent
self.params = {}
self.reason = None
def __bool__(self):
return self.status
......@@ -127,6 +149,8 @@ class Status:
if self.terms:
out['terms'] = [t.json for t in self.terms]
out['connective'] = self.condition.connective
elif not self.status:
out['reason'] = self.reason
return out
......@@ -184,12 +208,12 @@ class Action:
@action(r"(l'|les? |la )(?P<key>.+) (vaut|est) (?P<value>[\w«» +\-'\.]+)$")
@action(r"(l'|les? |la )(?P<key>.+) est égale? (à la|à|aux?)? (?P<value>[\w«» +\-\.']+)$")
def set_value(context, key: Label, value: LazyValue):
def set_value(context, key: Label, value: Pointer):
context[key] = value.get(**context)
@action(r"(l'|les? |la )(?P<key>.+) est égale? à (?P<rate>[\d\.]+)% (de la|du) (?P<value>[\w«» +\-\.']+)$")
def set_percent(context, key: Label, rate: float, value: LazyValue):
def set_percent(context, key: Label, rate: float, value: Pointer):
context[key] = value.get(**context) * rate / 100
......@@ -276,7 +300,7 @@ class Condition:
for name in list(spec.parameters.keys())[1:]: # Skip context.
value = data[name]
try:
value = LazyValue(value)
value = Pointer(value)
except Exception as err:
# Give more context.
err.args = (f'{err} (from `{self.raw}`)',)
......@@ -284,92 +308,107 @@ class Condition:
self.params[name] = value
def get_params(self, context):
return {n: v.get(**context) for n, v in self.params.items()}
return {n: Value(v, context) for n, v in self.params.items()}
def assess(self, context):
status = Status(self)
if self.terms:
status.terms = [c.assess(context) for c in self.terms]
if self.connective == self.OR:
current = any(status.terms)
status.status = any(status.terms)
else:
current = all(status.terms)
status.status = all(status.terms)
else:
try:
current = self.func(context, **self.get_params(context))
except NoDataError:
current = False
status.params = self.get_params(context)
except NoDataError as err:
status.status = False
status.reason = f"Donnée manquante pour «{err}»"
except Exception as err:
# Give more context.
params = ' AND '.join(f'{value.raw}={value.get(**context)}'
for value in self.params.values())
params = ' AND '.join(f'{key}={value}'
for key, value in status.params.items())
err.args = (f'{err} (in `{self.raw}`, where {params})',)
raise
status.status = current
else:
status.status = self.func(context, **status.params)
if not status and self.func.reason:
status.reason = self.func.reason.format(**status.params)
return status
@reason("ce n'est pas «{key.pointer.raw}»")
@condition(r"c'est une? (?P<key>.+)")
def check_true(context, key):
return key is True
return key.value is True
@reason("c'est «{key.pointer.raw}»")
@condition(r"ce n'est pas une? (?P<key>.+)")
def check_false(context, key):
return key is False
return key.value is False
@reason("le financement n'est pas de type «{tag}»")
@condition(r"le financement est de type (?P<tag>.+)")
def check_type(context, tag):
return tag in context['financement.tags']
return tag.value in context['financement.tags']
@reason("{left.pointer.raw} vaut {left}, c'est inférieur ou égal au seuil de {right}")
@condition(r"(l'|les? |la )(?P<left>.+) est supérieure? à (?P<right>[\w ]+)")
def check_gt(context, left, right):
return left > right
return left.value > right.value
@reason("{left.pointer.raw} trop faible: {left}, au lieu de {right} au moins")
@condition(r"(l'|les? |la )(?P<left>.+) est supérieure? ou égale? à (?P<right>[\w ]+)")
def check_ge(context, left, right):
return left >= right
return left.value >= right.value
@reason("{left.pointer.raw} vaut {left}, c'est supérieur ou égal au seuil ({right})")
@condition(r"(l'|les? |la )(?P<left>.+) est inférieure? à (?P<right>[\w ]+)")
def check_lt(context, left, right):
return left < right
return left.value < right.value
@reason("{left.pointer.raw} trop grande: {left} (le maximum est {right})")
@condition(r"(l'|les? |la )(?P<left>.+) est inférieure? ou égale? à (?P<right>[\w ]+)")
def check_le(context, left, right):
return left <= right
return left.value <= right.value
@reason("«{left}» ne contient pas {right}")
@condition(r"(l'|les? |la )(?P<left>.+) contien(nen)?t au moins une? ([^ ]+ )?(parmi|des) (?P<right>[ \[\],\w«»]+)")
def check_share_one(context, left, right):
return bool(set(left or []) & set(right or []))
return bool(set(left.value or []) & set(right.value or []))
# @not_match('{left & right}')
@reason("«{right}» contient {left}")
@condition(r"(l'|les? |la )(?P<left>.+) ne contien(nen)?t aucun des (?P<right>[ \w«»]+)")
def check_not_share_one(context, left, right):
return not bool(set(left or []) & set(right or []))
return not bool(set(left.value or []) & set(right.value or []))
@reason("{left.pointer.raw} ({left}) ne fait pas partie de «{right.pointer.raw}» ({right})")
@condition(r"(l'|les? |la )(?P<left>.+) fait partie (de l'|de la |des? |du )(?P<right>.+)")
@condition(r"(l'|les? |la )(?P<right>.+) contient (l'|les? |la )?(?P<left>[ \w«»]+)")
def check_contain(context, left, right):
return left in right
return left.value in right.value
@reason("{left.pointer.raw} («{left}») fait partie de «{right}»")
@condition(r"(l'|les? |la )(?P<left>.+) ne fait pas partie (de l'|des? |de la |du )(?P<right>.+)")
@condition(r"(l'|les? |la )(?P<right>.+) ne contient pas (l'|les? |la )?(?P<left>[ \w«»]+)")
def check_not_contain(context, left, right):
return left not in right
return left.value not in right.value
@reason("{left.pointer.raw} vaut «{left}» au lieu de «{right}»")
@condition(r"(l'|les? |la )(?P<left>.+) (est|vaut) (?P<right>[\w«» +\-\.']+)")
def check_equal(context, left, right):
return left == right
return left.value == right.value
Line = namedtuple('Line', ['index', 'indent', 'keyword', 'sentence'])
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment