Skip to content

Commit

Permalink
Merge pull request #724 from euphorie/support-scaled-answers
Browse files Browse the repository at this point in the history
Support scaled answers.
  • Loading branch information
ale-rt committed Apr 19, 2024
2 parents e0bf764 + caba1f3 commit a048b44
Show file tree
Hide file tree
Showing 12 changed files with 304 additions and 25 deletions.
4 changes: 4 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Changelog
16.1.3 (unreleased)
-------------------

- Support scaled answers.
These are answers on a scale from usually 1-5, instead of only yes/no.
[ale-rt, maurits]

- Do not do linkintegrity checks when removing contents
(Fix regression introduced in https://github.com/euphorie/Euphorie/pull/692)
[ale-rt]
Expand Down
80 changes: 74 additions & 6 deletions src/euphorie/client/browser/risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from euphorie.content.survey import get_tool_type
from euphorie.content.survey import ISurvey
from euphorie.content.utils import IToolTypesInfo
from euphorie.content.utils import parse_scaled_answers
from htmllaundry import StripMarkup
from io import BytesIO
from plone import api
Expand Down Expand Up @@ -387,6 +388,17 @@ def notes_placeholder(self):
"anything else you might want to write about this risk.",
)

@property
@memoize
def scaled_answers(self):
"""Get values and answers if the scaled_answers field is used.
This returns a list of dictionaries.
"""
if not getattr(self.risk, "use_scaled_answer", False):
return []
return parse_scaled_answers(self.risk.scaled_answers)


class IdentificationView(RiskBase):
"""A view for displaying a question in the identification phase."""
Expand All @@ -409,6 +421,7 @@ class IdentificationView(RiskBase):
always_present_answer = "no"

monitored_properties = {
"scaled_answer": None,
"identification": None,
"postponed": None,
"frequency": None,
Expand Down Expand Up @@ -474,7 +487,11 @@ def skip_evaluation(self):
@memoize
def evaluation_condition(self):
"""In what circumstances will the Evaluation panel be shown, provided
that evaluation is not skipped in general?"""
that evaluation is not skipped in general?
If you are using scaled answers instead of yes/no, you likely want to
override this.
"""
condition = "condition: answer=no"
if self.italy_special and not self.skip_evaluation:
condition = "condition: answer=no or answer=yes"
Expand Down Expand Up @@ -598,19 +615,55 @@ def set_parameter_values(self):
and self.my_tool_type in self.tti.types_existing_measures
)

def get_identification_from_scaled_answer(self, scaled_answer):
"""Determine the yes/no identification based on the scaled answer.
A simplistic implementation could be:
return "no" if scaled_answer in ("1", "2") else "yes"
You likely want to override this if you actually use scaled answers,
so by default we return nothing, making the identification empty.
"""
pass

def set_answer_data(self, reply):
"""Set answer data from the reply.
For years the only answer possibilities were yes, no, n/a or postponed.
Now we may have an extra field scaled_answer, for answers in the
range of (usually) 1-5.
We might want to merge these two possibilities, but for now they are
separate.
Currently, when scaled_answer is in the reply, we also get
'postponed' as answer. We can ignore this: scaled_answer is filled
in, so the answer is not postponed.
We make sure that either 'identification' is set (yes/no) or
'scaled_answer' is set (1-5), and the other None.
Or both None in the case the answer is really postponed.
"""
answer = reply.get("answer", None)
# If answer is not present in the request, do not attempt to set
# any answer-related data, since the request might have come
# from a sub-form.
if not answer:
return
self.context.comment = self.webhelpers.get_safe_html(reply.get("comment"))
self.context.postponed = answer == "postponed"
if self.context.postponed:
self.context.identification = None
return
self.context.identification = answer
scaled_answer = reply.get("scaled_answer", None)
if scaled_answer:
# We have an answer on the scale of 1-5 (or similar).
self.context.scaled_answer = scaled_answer
self.context.postponed = False
self.context.identification = self.get_identification_from_scaled_answer(
scaled_answer
)
else:
self.context.scaled_answer = None
self.context.postponed = answer == "postponed"
if self.context.postponed:
self.context.identification = None
return
self.context.identification = answer
if getattr(self.risk, "type", "") in ("top5", "policy"):
self.context.priority = "high"
elif getattr(self.risk, "evaluation_method", "") == "calculated":
Expand Down Expand Up @@ -1203,6 +1256,21 @@ def use_problem_description(self):
text = self.risk.problem_description or ""
return bool(text.strip())

@property
def scaled_answer_chosen(self):
if not self.risk.use_scaled_answer:
return ""
if self.context.scaled_answer is None:
return ""
answer = self.context.scaled_answer
# answer is a string like '1'.
# Use it to find the textual representation of the answer.
for info in self.scaled_answers:
# Note: currently both info value and answer are strings ('1', '2', etc).
if info["value"] == answer:
return info
return answer

def _extractViewData(self):
"""Extract the data from the current context and build a data structure
that is usable by the view."""
Expand Down
8 changes: 8 additions & 0 deletions src/euphorie/client/browser/templates/risk_actionplan.pt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@
<h2 tal:condition="not:use_problem_description"
tal:content="risk/title"
>The fridges are checked daily.</h2>
<p tal:condition="risk/use_scaled_answer|nothing">
<tal:chosen define="
answer view/scaled_answer_chosen;
">
<label i18n:translate="">Answer:</label>
${answer/text} (${answer/value})
</tal:chosen>
</p>
</tal:block>
<tal:priority tal:define="
show_statement python:True;
Expand Down
66 changes: 48 additions & 18 deletions src/euphorie/client/browser/templates/webhelpers.pt
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@
</div>
<!-- END Existing measures -->

<!-- Yes / No -->
<!-- Yes / No / Multiple -->
<fieldset class="pat-checklist radio pat-rich">
<tal:existing condition="python:view.show_existing_measures and not always_present">
<p tal:condition="view/intro_questions"
Expand All @@ -285,23 +285,53 @@
type="hidden"
value="postponed"
/>
<label class="yes"><input checked="${python:'checked' if context.identification=='yes' else None}"
name="answer"
type="radio"
value="yes"
/><tal:answer replace="structure view/answer_yes" /></label>
<label class="no"><input checked="${python:'checked' if context.identification=='no' else None}"
name="answer"
type="radio"
value="no"
/><tal:answer replace="structure view/answer_no" /></label>
<label class="not-applicable"
tal:condition="risk/show_notapplicable|nothing"
><input checked="${python:'checked' if context.identification=='n/a' else None}"
name="answer"
type="radio"
value="n/a"
/><tal:answer replace="structure view/answer_na" /></label>

<!-- scaled answers -->
<tal:scaled_answers condition="risk/use_scaled_answer|nothing">
<label tal:repeat="answer view/scaled_answers">
<input checked="${python:'checked' if context.scaled_answer==value else None}"
name="scaled_answer"
type="radio"
value="${value}"
tal:define="
value answer/value;
"
/>
${answer/text}
</label>
<label class="not-applicable"
tal:condition="risk/show_notapplicable|nothing"
><input checked="${python:'checked' if context.scaled_answer=='n/a' else None}"
name="scaled_answer"
type="radio"
value="n/a"
/><tal:answer replace="structure view/answer_na" /></label>

</tal:scaled_answers>

<!-- Yes/No -->
<tal:yes_no condition="not:risk/use_scaled_answer|nothing">

<label class="yes"><input checked="${python:'checked' if context.identification=='yes' else None}"
name="answer"
type="radio"
value="yes"
/><tal:answer replace="structure view/answer_yes" /></label>
<label class="no"><input checked="${python:'checked' if context.identification=='no' else None}"
name="answer"
type="radio"
value="no"
/><tal:answer replace="structure view/answer_no" /></label>
<label class="not-applicable"
tal:condition="risk/show_notapplicable|nothing"
><input checked="${python:'checked' if context.identification=='n/a' else None}"
name="answer"
type="radio"
value="n/a"
/><tal:answer replace="structure view/answer_na" /></label>

</tal:yes_no>

</tal:answers>
<tal:always_present condition="always_present"><input name="answer"
type="hidden"
Expand Down
6 changes: 5 additions & 1 deletion src/euphorie/client/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,7 @@ class Risk(SurveyTreeItem):
image_data = schema.Column(types.LargeBinary())
image_data_scaled = schema.Column(types.LargeBinary())
image_filename = schema.Column(types.UnicodeText())
scaled_answer = schema.Column(types.UnicodeText())

@memoize
def measures_of_type(self, plan_type):
Expand Down Expand Up @@ -1855,7 +1856,10 @@ def _RISK_PRESENT_OR_TOP5_FILTER_factory():
sql.select([Risk_.sql_risk_id]).where(
sql.and_(
Risk_.sql_risk_id == SurveyTreeItem.id,
sql.or_(Risk_.identification == "no", Risk_.risk_type == "top5"),
sql.or_(
Risk_.identification == "no",
Risk_.risk_type == "top5",
),
)
)
),
Expand Down
13 changes: 13 additions & 0 deletions src/euphorie/content/browser/risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from Acquisition.interfaces import IAcquirer
from euphorie.content.survey import get_tool_type
from euphorie.content.utils import IToolTypesInfo
from euphorie.content.utils import parse_scaled_answers
from plone import api
from plone.dexterity.browser.add import DefaultAddForm
from plone.dexterity.browser.add import DefaultAddView
Expand Down Expand Up @@ -112,6 +113,18 @@ def get_safe_html(self, text):
)
return data.getData()

@property
@memoize
def scaled_answers(self):
"""Get values and answers if the scaled_answers field is used.
This returns a list of dictionaries.
"""
context = self.my_context
if not getattr(context, "use_scaled_answer", False):
return []
return parse_scaled_answers(context.scaled_answers)


class AddForm(DefaultAddForm):
portal_type = "euphorie.risk"
Expand Down
6 changes: 6 additions & 0 deletions src/euphorie/content/browser/templates/risk_view.pt
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@
<dd tal:content="view/default_effect">Low</dd>
</tal:kinney>
</tal:block>
<tal:scaled_answers condition="context/use_scaled_answer|nothing">
<dt i18n:translate="">Use scaled answers instead of Yes/No</dt>
<dd>
<ul><li tal:repeat="answer view/scaled_answers">${answer/text} (${answer/value})</li></ul>
</dd>
</tal:scaled_answers>
</tal:block>
</dl>
<hr class="clear" />
Expand Down
58 changes: 58 additions & 0 deletions src/euphorie/content/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from euphorie.content.utils import getTermTitleByToken
from euphorie.content.utils import getTermTitleByValue
from euphorie.content.utils import parse_scaled_answers
from euphorie.content.utils import StripMarkup
from zope.schema.vocabulary import SimpleTerm
from zope.schema.vocabulary import SimpleVocabulary
Expand Down Expand Up @@ -52,6 +53,63 @@ def testKnownToken(self):
self.assertEqual(getTermTitleByToken(field, "token"), "Title")


class TestParseScaledAnswers(unittest.TestCase):
def testEmpty(self):
self.assertEqual(parse_scaled_answers(""), [])

def testSimple(self):
self.assertEqual(
parse_scaled_answers("a\nb"),
[{"text": "a", "value": "1"}, {"text": "b", "value": "2"}],
)
self.assertEqual(
parse_scaled_answers("a\nb\nc\nd\ne\nf"),
[
{"text": "a", "value": "1"},
{"text": "b", "value": "2"},
{"text": "c", "value": "3"},
{"text": "d", "value": "4"},
{"text": "e", "value": "5"},
{"text": "f", "value": "6"},
],
)

def testExtended(self):
self.assertEqual(
parse_scaled_answers("a|7\nb|3"),
[{"text": "a", "value": "7"}, {"text": "b", "value": "3"}],
)

def testMixed(self):
self.assertEqual(
parse_scaled_answers("a|7\nb"),
[{"text": "a", "value": "7"}, {"text": "b", "value": "2"}],
)
self.assertEqual(
parse_scaled_answers("a\nb|42"),
[{"text": "a", "value": "1"}, {"text": "b", "value": "42"}],
)

def testUgly(self):
self.assertEqual(
parse_scaled_answers("\n\n hello world | 7\n\n\n b \n\n\nc|\n\n\n"),
[
{"text": "hello world", "value": "7"},
{"text": "b", "value": "2"},
{"text": "c", "value": "3"},
],
)
# If you really want, an answer can contain a literal pipe.
self.assertEqual(
parse_scaled_answers("a|b|7"),
[{"text": "a|b", "value": "7"}],
)
self.assertEqual(
parse_scaled_answers("a||7"),
[{"text": "a|", "value": "7"}],
)


class Mock:
pass

Expand Down
Loading

0 comments on commit a048b44

Please sign in to comment.