diff --git a/src/euphorie/client/browser/choice.py b/src/euphorie/client/browser/choice.py new file mode 100644 index 0000000000..a5c99554f2 --- /dev/null +++ b/src/euphorie/client/browser/choice.py @@ -0,0 +1,151 @@ +from euphorie.client import utils +from euphorie.client.navigation import getTreeData +from plone import api +from plone.memoize.instance import memoize +from Products.Five import BrowserView + + +class IdentificationView(BrowserView): + """A view for displaying a choice in the identification phase.""" + + variation_class = "variation-risk-assessment" + + @property + @memoize + def webhelpers(self): + return api.content.get_view("webhelpers", self.context.aq_parent, self.request) + + def check_render_condition(self): + # Render the page only if the user can inspection rights, + # otherwise redirect to the start page of the session. + if not self.webhelpers.can_inspect_session: + return self.request.response.redirect( + "{session_url}/@@start".format( + session_url=self.webhelpers.traversed_session.absolute_url() + ) + ) + if self.webhelpers.redirectOnSurveyUpdate(): + return + + @property + @memoize + def navigation(self): + return api.content.get_view("navigation", self.context, self.request) + + def _get_next(self, reply): + _next = reply.get("next", None) + # In Safari browser we get a list + if isinstance(_next, list): + _next = _next.pop() + return _next + + @property + def tree(self): + return getTreeData(self.request, self.context) + + @property + @memoize + def session(self): + return self.webhelpers.traversed_session.session + + @property + @memoize + def survey(self): + """This is the survey dexterity object.""" + return self.webhelpers._survey + + @property + @memoize + def choice(self): + return self.webhelpers.traversed_session.restrictedTraverse( + self.context.zodb_path.split("/") + ) + + @property + @memoize + def selected(self): + return [option.zodb_path for option in self.context.options] + + def set_answer_data(self, reply): + """Save the selected options as indicated by the paths in the `answer` + field of `reply` (i.e. the request form). + If the choice allows multiple options, then selecting none of them counts as + a valid answer. In this case the `postponed` attribute is set to True. + + Note that this use of the `postponed` attribute does not exactly match + the use for risks in that we don't expect the user to come back and + answer later, but it is similar in that we record that the user has + been here and clicked “Save” rather than “Skip”. + """ + answer = reply.get("answer", []) + if self.choice.allow_multiple_options: + self.context.postponed = answer == "postponed" + if answer == "postponed": + answer = [] + if not isinstance(answer, (list, tuple)): + answer = [answer] + # XXX Check if paths are valid? + # for path in answer[:]: + # try: + # self.webhelpers.traversed_session.restrictedTraverse(path) + # except KeyError: + # answer.remove(path) + return self.context.set_options_by_zodb_path(answer) + + def __call__(self): + # Render the page only if the user has inspection rights, + # otherwise redirect to the start page of the session. + if not self.webhelpers.can_inspect_session: + return self.request.response.redirect( + self.context.aq_parent.absolute_url() + "/@@start" + ) + self.check_render_condition() + + utils.setLanguage(self.request, self.survey, self.survey.language) + + if self.request.method == "POST": + reply = self.request.form + if not self.webhelpers.can_edit_session: + return self.navigation.proceed_to_next(reply) + _next = self._get_next(reply) + # Don't persist anything if the user skipped the question + if _next == "skip": + return self.navigation.proceed_to_next(reply) + + changed = self.set_answer_data(reply) + + if changed: + self.session.touch() + + return self.navigation.proceed_to_next(reply) + return self.index() + + +class IdentificationFeedbackView(IdentificationView): + """A view for displaying feedback on a selected option in the identification + phase.""" + + variation_class = "variation-risk-assessment" + + def set_answer_data(self, reply): + # has already been done in @@identification + return False + + @property + @memoize + def recommendations(self): + zodb_options = [ + self.webhelpers.traversed_session.restrictedTraverse(option.zodb_path) + for option in self.context.options + ] + recommendation_texts = [] + for zodb_option in zodb_options: + for recommendation in zodb_option.values(): + if recommendation.portal_type == "euphorie.recommendation": + recommendation_texts.append(recommendation.text) + return recommendation_texts + + def __call__(self): + if not self.recommendations: + return self.navigation.proceed_to_next({"next": "next"}) + return super().__call__() diff --git a/src/euphorie/client/browser/configure.zcml b/src/euphorie/client/browser/configure.zcml index cc7c73e6ce..a5311b9be1 100644 --- a/src/euphorie/client/browser/configure.zcml +++ b/src/euphorie/client/browser/configure.zcml @@ -71,6 +71,14 @@ layer="euphorie.client.interfaces.IClientSkinLayer" /> + + + + + + + + + + + + + + + + + +
+ +
+ +
+

${here/title}

+ + Sed ut perspiciatis unde omnis iste natus error sit voluptatem + accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae + ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt + explicabo. +
+ + +
+
+
+ +
+
+ + diff --git a/src/euphorie/client/browser/templates/choice_identification_feedback.pt b/src/euphorie/client/browser/templates/choice_identification_feedback.pt new file mode 100644 index 0000000000..9ce43ebf03 --- /dev/null +++ b/src/euphorie/client/browser/templates/choice_identification_feedback.pt @@ -0,0 +1,60 @@ + + + + + + +
+ + +
+
+

${context/title}

+

+
+
+ + +
+
+ + diff --git a/src/euphorie/client/browser/templates/dsetool_cover.png b/src/euphorie/client/browser/templates/dsetool_cover.png new file mode 100644 index 0000000000..9461f77a24 Binary files /dev/null and b/src/euphorie/client/browser/templates/dsetool_cover.png differ diff --git a/src/euphorie/client/browser/templates/dsetool_report_logo.png b/src/euphorie/client/browser/templates/dsetool_report_logo.png new file mode 100644 index 0000000000..b980dc1a3e Binary files /dev/null and b/src/euphorie/client/browser/templates/dsetool_report_logo.png differ diff --git a/src/euphorie/client/browser/templates/report_inventory.pt b/src/euphorie/client/browser/templates/report_inventory.pt new file mode 100644 index 0000000000..1003b2901c --- /dev/null +++ b/src/euphorie/client/browser/templates/report_inventory.pt @@ -0,0 +1,59 @@ + + + + + +
+
+
+
+

Report

+ + Download + +
+
+
+ + + +
+

Good practices and guidance

+ + +

${option/aq_parent/title}

+ Your answer: + ${option/title} +

Measures:

+

+

+
+ +
+ + +
+
+
+ + diff --git a/src/euphorie/client/browser/templates/report_inventory_pdf.pt b/src/euphorie/client/browser/templates/report_inventory_pdf.pt new file mode 100644 index 0000000000..3dd03425c4 --- /dev/null +++ b/src/euphorie/client/browser/templates/report_inventory_pdf.pt @@ -0,0 +1,63 @@ + + + + +
+ + +
+ +
+ +
+
+ + diff --git a/src/euphorie/client/browser/webhelpers.py b/src/euphorie/client/browser/webhelpers.py index ef5721c83e..e149e38818 100644 --- a/src/euphorie/client/browser/webhelpers.py +++ b/src/euphorie/client/browser/webhelpers.py @@ -111,13 +111,6 @@ class WebHelpers(BrowserView): survey_session_model = SurveySession dashboard_tabs = ["surveys", "assessments", "certificates", "organisation"] - navigation_tree_legend = [ - {"class": "unvisited", "title": _("Unvisited")}, - {"class": "postponed", "title": _("Postponed")}, - {"class": "answered", "title": _("Risk not present")}, - {"class": "answered risk", "title": _("Risk present")}, - ] - # Inspection is meant for showing an edit-form in read-only/display mode. allow_inspecting_archived_sessions = False allow_inspecting_locked_sessions = False @@ -830,6 +823,13 @@ def integrated_action_plan(self): return False return getattr(self._survey, "integrated_action_plan", False) + @property + @memoize + def is_show_immediate_feedback(self): + if not self._survey.tool_type == "inventory": + return False + return getattr(self._survey, "show_immediate_feedback", False) + @property @memoize def use_action_plan_phase(self): @@ -1344,6 +1344,19 @@ def is_section_disabled(self, section): # Default to not disable unknown sections. return False + def navigation_tree_legend(self): + if self._survey.tool_type != "inventory": + return [ + {"class": "unvisited", "title": _("Unvisited")}, + {"class": "postponed", "title": _("Postponed")}, + {"class": "answered", "title": _("Risk not present")}, + {"class": "answered risk", "title": _("Risk present")}, + ] + return [ + {"class": "unvisited", "title": _("Not yet answered")}, + {"class": "answered", "title": _("Answered")}, + ] + def get_active_and_disabled_for_section(self, section): """Is the section active or disabled in this phase? diff --git a/src/euphorie/client/model.py b/src/euphorie/client/model.py index 65e388ddcb..70ba61a406 100644 --- a/src/euphorie/client/model.py +++ b/src/euphorie/client/model.py @@ -147,7 +147,7 @@ class SurveyTreeItem(BaseObject): index=True, ) type = schema.Column( - Enum(["risk", "module"]), + Enum(["risk", "choice", "module"]), nullable=False, index=True, ) @@ -1082,6 +1082,27 @@ def copySessionData(self, other): } session.execute(statement) + # Copy over answered options for choice items + statement = """\ + INSERT INTO option (choice_id, zodb_path) + SELECT choice.id, old_option.zodb_path + FROM tree AS old_tree, + choice AS old_choice, + option AS old_option, + tree, + choice + WHERE tree.session_id = %(new_sessionid)d AND + tree.id = choice.id AND + tree.zodb_path = old_tree.zodb_path AND + old_tree.session_id = %(old_sessionid)d AND + old_tree.id = old_choice.id AND + old_option.choice_id = old_choice.id; + """ % { + "old_sessionid": other.id, + "new_sessionid": self.id, + } + session.execute(statement) + # Copy over previous session metadata. Specifically, we don't want to # create a new modification timestamp, just because the underlying # survey was updated. @@ -1486,6 +1507,77 @@ def in_place_custom_measures(self): return self.measures_of_type("in_place_custom") +class Choice(SurveyTreeItem): + """User choice.""" + + __tablename__ = "choice" + __mapper_args__ = dict(polymorphic_identity="choice") + + id = schema.Column( + "id", + types.Integer(), + schema.ForeignKey(SurveyTreeItem.id, onupdate="CASCADE", ondelete="CASCADE"), + primary_key=True, + ) + condition = schema.Column(types.String(512), nullable=True) + + @property + def is_visible(self): + if not self.condition: + return True + options = self.condition.split("|") + if ( + Session.query(Option) + .join(Choice) + .filter(Choice.session_id == self.session_id) + .filter(Option.zodb_path.in_(options)) + .count() + <= 0 + ): + return False + return True + + def set_options_by_zodb_path(self, paths): + current = [option.zodb_path for option in self.options] + changed = False + for option in self.options[:]: + if option.zodb_path not in paths: + self.options.remove(option) + Session.delete(option) + changed = True + for path in paths: + if path not in current: + option = Option(choice=self, zodb_path=path) + Session.add(option) + self.options.append(option) + changed = True + return changed + + +class Option(BaseObject): + """An option that was selected for a particular choice.""" + + __tablename__ = "option" + + id = schema.Column(types.Integer(), primary_key=True, autoincrement=True) + choice_id = schema.Column( + types.Integer(), + schema.ForeignKey(Choice.id, onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + zodb_path = schema.Column(types.String(512), nullable=False) + + +Choice.options = orm.relationship( + "Option", + back_populates="choice", + cascade="all, delete", + passive_deletes=True, +) +Option.choice = orm.relationship("Choice", back_populates="options") + + class ActionPlan(BaseObject): """Action plans for a known risk.""" @@ -1650,6 +1742,8 @@ class NotificationSubscription(BaseObject): SessionRedirect, Module, Risk, + Choice, + Option, ActionPlan, Group, Account, @@ -2035,6 +2129,8 @@ def show_timezone(): "SurveySession", "Module", "Risk", + "Choice", + "Option", "ActionPlan", "SKIPPED_PARENTS", "MODULE_WITH_RISK_FILTER", diff --git a/src/euphorie/client/navigation.py b/src/euphorie/client/navigation.py index b0a03626ba..1f340b2bf4 100644 --- a/src/euphorie/client/navigation.py +++ b/src/euphorie/client/navigation.py @@ -29,11 +29,29 @@ def FindFirstQuestion(dbsession, filter=None): def FindNextQuestion(after, dbsession, filter=None): + # Filter out Choice objects where condition is not met, + # i.e. either tree item is not a choice, + # or it is a Choice and the condition is blank, + # or an option that is referred to in the condition has been picked + # XXX simplify? + condition = sql.or_( + ~sql.exists().where(model.Choice.id == model.SurveyTreeItem.id), + sql.exists().where( + sql.and_( + model.Choice.id == model.SurveyTreeItem.id, + sql.or_( + model.Choice.condition == None, # noqa: E711 + model.Choice.condition == model.Option.zodb_path, # noqa: E711 + ), + ), + ), + ) query = ( Session.query(model.SurveyTreeItem) .filter(model.SurveyTreeItem.session == dbsession) .filter(model.SurveyTreeItem.path > after.path) .filter(sql.not_(model.SKIPPED_PARENTS)) + .filter(condition) ) # Skip modules without a description. if filter is None: @@ -150,7 +168,10 @@ def morph(obj): cls.append(key) if obj.postponed: - cls.append("postponed") + if isinstance(obj, model.Choice): + cls.append("answered") + else: + cls.append("postponed") else: if isinstance(obj, model.Risk): if obj.identification or obj.scaled_answer: @@ -159,6 +180,9 @@ def morph(obj): cls.append("risk") if obj.scaled_answer: info["scaled_answer"] = obj.scaled_answer + if isinstance(obj, model.Choice): + if obj.options: + cls.append("answered") info["class"] = cls and " ".join(cls) or None return info @@ -175,6 +199,8 @@ def morph(obj): result["class"] = None children = [] for obj in element.siblings(filter=filter): + if not getattr(obj, "is_visible", True): + continue info = morph(obj) if obj.type != "risk" and obj.zodb_path.find("custom-risks") > -1: info["title"] = title_custom_risks @@ -204,6 +230,8 @@ def morph(obj): me = first(lambda x: x["current"], result["children"]) children = [] for obj in element.children(filter=filter): + if not getattr(obj, "is_visible", True): + continue info = morph(obj) # XXX: The check for SurveySession is due to Euphorie tests which # don't have a proper canonical ZODB survey object and don't test @@ -226,11 +254,13 @@ def morph(obj): types = {c["type"] for c in me["children"]} me["leaf_module"] = "risk" in types - elif isinstance(element, model.Risk): + elif isinstance(element, (model.Risk, model.Choice)): # For a risk we also want to include all siblings of its module parent parent = parents.pop() siblings = [] for obj in parent.siblings(model.Module, filter=filter): + if not getattr(obj, "is_visible", True): + continue info = morph(obj) if obj.zodb_path.find("custom-risks") > -1: info["title"] = title_custom_risks @@ -264,6 +294,8 @@ def morph(obj): info["type"] = "location" children = [] for child in sibling.children(filter=filter): + if not getattr(child, "is_visible", True): + continue child_info = morph(child) children.append(child_info) info["children"] = children diff --git a/src/euphorie/client/profile.py b/src/euphorie/client/profile.py index 9256057302..0b0af7c1c9 100644 --- a/src/euphorie/client/profile.py +++ b/src/euphorie/client/profile.py @@ -8,6 +8,7 @@ from euphorie.client import model from euphorie.client.utils import HasText +from euphorie.content.choice import IChoice from euphorie.content.interfaces import ICustomRisksModule from euphorie.content.interfaces import IQuestionContainer from euphorie.content.module import IModule @@ -87,6 +88,13 @@ def AddToTree( child.priority = "high" if node.risk_always_present and always_present_default: child.identification = always_present_default + elif IChoice.providedBy(node): + child = model.Choice(title=title) + child.postponed = False + + condition = node.get_client_condition() + if condition: + child.condition = condition else: return None # Should never happen diff --git a/src/euphorie/client/tests/test_update.py b/src/euphorie/client/tests/test_update.py index 62e3f1be6a..97193f73c7 100644 --- a/src/euphorie/client/tests/test_update.py +++ b/src/euphorie/client/tests/test_update.py @@ -45,11 +45,13 @@ def testSingleModule(self): [ { "optional": False, + "condition": None, "zodb_path": "1", "type": "module", "has_description": False, "always_present": False, "risk_type": None, + "allow_multiple_options": False, } ], ) @@ -63,19 +65,23 @@ def testModuleAndRisk(self): [ { "optional": False, + "condition": None, "zodb_path": "1", "type": "module", "has_description": False, "always_present": False, "risk_type": None, + "allow_multiple_options": False, }, { "optional": False, + "condition": None, "zodb_path": "1/2", "type": "risk", "has_description": False, "always_present": False, "risk_type": "risk", + "allow_multiple_options": False, }, ], ) diff --git a/src/euphorie/client/update.py b/src/euphorie/client/update.py index 4cd5d3cc8d..a08e9f6450 100644 --- a/src/euphorie/client/update.py +++ b/src/euphorie/client/update.py @@ -23,6 +23,9 @@ def getSurveyTree(survey, profile=None): "euphorie.profilequestion", "euphorie.module", "euphorie.risk", + "euphorie.choice", + "euphorie.option", + "euphorie.recommendation", ]: continue # Note that in profile.AddToTree, we pretend that an optional module @@ -44,6 +47,14 @@ def getSurveyTree(survey, profile=None): ), "risk_type": (node.portal_type[9:] == "risk" and node.type or None), "optional": node.optional, + "condition": ( + node.portal_type[9:] == "choice" and node.condition or None + ), + "allow_multiple_options": ( + node.portal_type[9:] == "choice" + and node.allow_multiple_options + or False + ), } ) if IQuestionContainer.providedBy(node): @@ -67,6 +78,7 @@ def __init__(self, item): self.has_description = item.has_description self.identification = item.type == "risk" and item.identification or None self.risk_type = item.type == "risk" and item.risk_type or None + self.condition = item.type == "choice" and item.condition or None def __repr__(self): return "".format( @@ -125,6 +137,9 @@ def treeChanges(session, survey, profile=None): results.add((entry["zodb_path"], node.type, "modified")) if entry["risk_type"] != node.risk_type: results.add((entry["zodb_path"], node.type, "modified")) + if node.type == entry["type"] == "choice": + if node.condition != entry["condition"]: + results.add((entry["zodb_path"], node.type, "modified")) if nodes[0].type == entry["type"] or ( nodes[0].type == "module" and entry["type"] == "profilequestion" ): diff --git a/src/euphorie/content/browser/choice.py b/src/euphorie/content/browser/choice.py new file mode 100644 index 0000000000..a3b1b65f86 --- /dev/null +++ b/src/euphorie/content/browser/choice.py @@ -0,0 +1,17 @@ +from ..option import IOption +from ..utils import DragDropHelper +from plone.dexterity.browser.view import DefaultView + + +class ChoiceView(DefaultView, DragDropHelper): + @property + def options(self): + return [ + { + "id": option.id, + "url": option.absolute_url(), + "title": option.title, + } + for option in self.context.values() + if IOption.providedBy(option) + ] diff --git a/src/euphorie/content/browser/configure.zcml b/src/euphorie/content/browser/configure.zcml index 6a5bb08ebe..82022c4219 100644 --- a/src/euphorie/content/browser/configure.zcml +++ b/src/euphorie/content/browser/configure.zcml @@ -432,6 +432,34 @@ layer="plonetheme.nuplone.skin.interfaces.NuPloneSkin" /> + + + + + + + + + + ${context/Title} + + + +
+ Default fieldset + +
+ + +
+ Group name + + + +
+
+ + +

Options

+ +
    +
    + ${view/sortable_explanation} +
    +
  1. Option + ${repeat/option/number}
    +

  2. +
+
+ +
+ + diff --git a/src/euphorie/content/browser/templates/option_view.pt b/src/euphorie/content/browser/templates/option_view.pt new file mode 100644 index 0000000000..bf78ea0aaa --- /dev/null +++ b/src/euphorie/content/browser/templates/option_view.pt @@ -0,0 +1,73 @@ + + + + ${context/Title} + + + +
+
Choice:
+
${context/aq_parent/Title}
+
+
+ Default fieldset + +
+ + +
+ Group name + + + +
+
+ + +

Recommendations

+ +
    +
    + + ${view/sortable_explanation} +
    +
  1. Recommendation + ${repeat/recommendation/number}
    +

  2. +
+
+ +
+ + diff --git a/src/euphorie/content/browser/templates/recommendation_view.pt b/src/euphorie/content/browser/templates/recommendation_view.pt new file mode 100644 index 0000000000..12de6e3440 --- /dev/null +++ b/src/euphorie/content/browser/templates/recommendation_view.pt @@ -0,0 +1,24 @@ + + + + ${context/Title} + + + + + + + diff --git a/src/euphorie/content/browser/upload.py b/src/euphorie/content/browser/upload.py index 67ec40e777..484784d946 100644 --- a/src/euphorie/content/browser/upload.py +++ b/src/euphorie/content/browser/upload.py @@ -20,6 +20,7 @@ from euphorie.content.utils import IToolTypesInfo from io import BytesIO from markdownify import markdownify +from plone import api from plone.autoform.form import AutoExtensibleForm from plone.base.utils import safe_bytes from plone.dexterity.utils import createContentInContainer @@ -215,6 +216,68 @@ class SurveyImporter: def __init__(self, context): self.context = context + self.options = {} + self.conditions = [] + + def ImportRecommendation(self, node, option): + """ + Create a new :obj:`euphorie.content.recommendation` object for a + :obj:`euphorie.content.module` given the details for a Recommendation as an XML + node. + + :returns: :obj:`euphorie.content.recommendation`. + """ + recommendation = createContentInContainer( + option, "euphorie.recommendation", title=str(node.title) + ) + recommendation.external_id = attr_unicode(node, "external-id") + recommendation.description = el_unicode( + node, "description", is_etranslate_compatible=self.is_etranslate_compatible + ) + recommendation.text = el_unicode(node, "text_html") + + def ImportOption(self, node, choice): + """ + Create a new :obj:`euphorie.content.option` object for a + :obj:`euphorie.content.module` given the details for a Option as an XML + node. + + :returns: :obj:`euphorie.content.option`. + """ + option = createContentInContainer( + choice, "euphorie.option", title=str(node.title) + ) + option.external_id = attr_unicode(node, "external-id") + option.description = el_unicode( + node, "description", is_etranslate_compatible=self.is_etranslate_compatible + ) + self.options[el_unicode(node, "condition-id")] = option + for child in node.iterchildren(tag=XMLNS + "recommendation"): + self.ImportRecommendation(child, option) + + def ImportChoice(self, node, module): + """ + Create a new :obj:`euphorie.content.choice` object for a + :obj:`euphorie.content.module` given the details for a Choice as an XML + node. + + :returns: :obj:`euphorie.content.choice`. + """ + choice = createContentInContainer( + module, "euphorie.choice", title=str(node.title) + ) + choice.external_id = attr_unicode(node, "external-id") + choice.description = el_unicode( + node, "description", is_etranslate_compatible=self.is_etranslate_compatible + ) + choice.allow_multiple_options = attr_bool( + node, "allow-multiple-options", "value" + ) + condition = el_unicode(node, "condition") + if condition: + self.conditions.append((choice, condition)) + for child in node.iterchildren(tag=XMLNS + "option"): + self.ImportOption(child, choice) def ImportImage(self, node): """Import a base64 encoded image from an XML node. @@ -350,6 +413,9 @@ def ImportModule(self, node, survey): for child in node.iterchildren(tag=XMLNS + "risk"): self.ImportRisk(child, module) + for child in node.iterchildren(tag=XMLNS + "choice"): + self.ImportChoice(child, module) + for child in node.iterchildren(tag=XMLNS + "module"): self.ImportModule(child, module) @@ -396,6 +462,9 @@ def ImportProfileQuestion(self, node, survey): for child in node.iterchildren(tag=XMLNS + "risk"): self.ImportRisk(child, profile) + for child in node.iterchildren(tag=XMLNS + "choice"): + self.ImportChoice(child, profile) + for child in node.iterchildren(tag=XMLNS + "module"): self.ImportModule(child, profile) return profile @@ -468,6 +537,10 @@ def ImportSurvey(self, node, group, version_title): x.replace(COMMA_REPLACEMENT, ",").strip() for x in el_unicode(node, "tool-category", "").split(",") ] + + self.options = {} + self.conditions = [] + for child in node.iterchildren(): if child.tag == XMLNS + "profile-question": self.ImportProfileQuestion(child, survey) @@ -475,6 +548,15 @@ def ImportSurvey(self, node, group, version_title): self.ImportModule(child, survey) elif child.tag == XMLNS + "training_question": self.ImportTrainingQuestion(child, survey) + + for choice, condition in self.conditions: + for option_id in condition.split("|"): + if option_id in self.options: + api.relation.create( + source=choice, + target=self.options[option_id], + relationship="condition", + ) return survey def __call__( diff --git a/src/euphorie/content/choice.py b/src/euphorie/content/choice.py new file mode 100644 index 0000000000..43f8a76bb2 --- /dev/null +++ b/src/euphorie/content/choice.py @@ -0,0 +1,81 @@ +from .. import MessageFactory as _ +from .behaviour.richdescription import IRichDescription +from .fti import ConditionalDexterityFTI +from .fti import IConstructionFilter +from Acquisition import aq_chain +from Acquisition import aq_inner +from euphorie.content.survey import ISurvey +from plone.app.dexterity.behaviors.metadata import IBasic +from plone.autoform import directives +from plone.dexterity.content import Container +from plone.supermodel import model +from z3c.form.browser.select import SelectFieldWidget +from z3c.relationfield.schema import RelationChoice +from z3c.relationfield.schema import RelationList +from zope import schema +from zope.component import adapter +from zope.interface import implementer +from zope.interface import Interface + + +class IChoice(model.Schema, IRichDescription, IBasic): + """ """ + + allow_multiple_options = schema.Bool( + title=_("label_allow_multiple_options", default="Allow multiple options"), + description=_( + "help_allow_multiple_options", + default="If active, checkboxes are shown to allow selecting more than one " + "option. Otherwise, radio buttons are used to force a single selection.", + ), + default=True, + ) + condition = RelationList( + title="Condition", + description="Only show this choice if the user selects certain options for " + "another choice. You can pick multiple options here. If the user selects one " + "or more of them, then this choice will be shown. Leave blank to always show " + "this choice.", + value_type=RelationChoice(vocabulary="euphorie.choice_conditions_vocabulary"), + required=False, + default=[], + ) + directives.widget("condition", SelectFieldWidget) + + +@implementer(IChoice) +class Choice(Container): + def get_client_condition(self): + if not self.condition: + return None + option_objs = [ + option.to_object for option in self.condition if option and option.to_object + ] + option_paths = ["/".join(obj.getPhysicalPath()[-3:]) for obj in option_objs] + return "|".join(option_paths) + + +@adapter(ConditionalDexterityFTI, Interface) +@implementer(IConstructionFilter) +class ConstructionFilter: + """FTI construction filter for :py:class:`Choice` objects. This filter makes sure + that Choice objects can only be added to tools of a `tool_type` that supports them. + + This multi adapter requires the use of the conditional FTI as implemented + by :py:class:`euphorie.content.fti.ConditionalDexterityFTI`. + """ + + def __init__(self, fti, container): + self.fti = fti + self.container = container + + def allowed(self): + """A choice is allowed to be created if the `tool_type` supports it. + + :rtype: bool + """ + for parent in aq_chain(aq_inner(self.container)): + if ISurvey.providedBy(parent): + return parent.get_tool_type_info().get("allow_choice", False) + # If we're not inside a survey we don't care what happens + return True diff --git a/src/euphorie/content/configure.zcml b/src/euphorie/content/configure.zcml index ae9c4f5803..47060569c4 100644 --- a/src/euphorie/content/configure.zcml +++ b/src/euphorie/content/configure.zcml @@ -139,6 +139,11 @@ name="SearchableText" /> + + + + + + + diff --git a/src/euphorie/content/profiles/default/types/euphorie.choice.xml b/src/euphorie/content/profiles/default/types/euphorie.choice.xml new file mode 100644 index 0000000000..1e9802f4dc --- /dev/null +++ b/src/euphorie/content/profiles/default/types/euphorie.choice.xml @@ -0,0 +1,75 @@ + + + + + Choice + A survey item that offers a choice between options. + document_icon.gif + False + False + + True + + + + + euphorie.content.choice.Choice + euphorie.content.AddNewRIEContent + + euphorie.content.choice.IChoice + + + + + + + + + + + + False + + + + + + + + + + + + + + + + + + diff --git a/src/euphorie/content/profiles/default/types/euphorie.module.xml b/src/euphorie/content/profiles/default/types/euphorie.module.xml index 4e12a5a95a..35a03a02f7 100644 --- a/src/euphorie/content/profiles/default/types/euphorie.module.xml +++ b/src/euphorie/content/profiles/default/types/euphorie.module.xml @@ -20,6 +20,7 @@ + euphorie.content.module.Module diff --git a/src/euphorie/content/profiles/default/types/euphorie.option.xml b/src/euphorie/content/profiles/default/types/euphorie.option.xml new file mode 100644 index 0000000000..11b764b586 --- /dev/null +++ b/src/euphorie/content/profiles/default/types/euphorie.option.xml @@ -0,0 +1,75 @@ + + + + + Option + An option that can be picked for a particular choice. + document_icon.gif + False + False + + True + + + + + plone.dexterity.content.Container + euphorie.content.AddNewRIEContent + + euphorie.content.option.IOption + + + + + + + + + + + + False + + + + + + + + + + + + + + + + + + diff --git a/src/euphorie/content/profiles/default/types/euphorie.recommendation.xml b/src/euphorie/content/profiles/default/types/euphorie.recommendation.xml new file mode 100644 index 0000000000..6371381394 --- /dev/null +++ b/src/euphorie/content/profiles/default/types/euphorie.recommendation.xml @@ -0,0 +1,74 @@ + + + + + Recommendation + A piece of text that can be included in a report. + document_icon.gif + False + False + + True + + + plone.dexterity.content.Container + euphorie.content.AddNewRIEContent + + euphorie.content.recommendation.IRecommendation + + + + + + + + + + + + + False + + + + + + + + + + + + + + + + + + diff --git a/src/euphorie/content/recommendation.py b/src/euphorie/content/recommendation.py new file mode 100644 index 0000000000..6c8b5b6944 --- /dev/null +++ b/src/euphorie/content/recommendation.py @@ -0,0 +1,19 @@ +from .. import MessageFactory as _ +from euphorie.htmllaundry.z3cform import HtmlText +from plone.autoform import directives +from plone.supermodel import model +from plonetheme.nuplone.z3cform.widget import WysiwygFieldWidget + + +class IRecommendation(model.Schema): + """ """ + + text = HtmlText( + title=_("label_recommendation_text", "Text"), + description=_( + "help_recommendation_text", + default="Text to be included in the report", + ), + required=False, + ) + directives.widget(text=WysiwygFieldWidget) diff --git a/src/euphorie/content/survey.py b/src/euphorie/content/survey.py index 2e8bb79a0c..e811e5219b 100644 --- a/src/euphorie/content/survey.py +++ b/src/euphorie/content/survey.py @@ -199,6 +199,21 @@ class ISurvey(model.Schema, IBasic): ) directives.widget(tool_notification_message=WysiwygFieldWidget) + depends("show_immediate_feedback", "tool_type", "==", "inventory") + show_immediate_feedback = schema.Bool( + title=_( + "label_show_immediate_feedback", + default="Show recommendations as immediate feedback", + ), + description=_( + "description_show_immediate_feedback", + default="After the user has selected an option for a choice, show any " + "associated recommendations before proceeding to the next choice", + ), + required=False, + default=False, + ) + class SurveyAttributeField(ParentAttributeField): parent_mapping = { @@ -262,14 +277,18 @@ def ProfileQuestions(self): """Return a list of all profile questions.""" return [child for child in self.values() if IProfileQuestion.providedBy(child)] - def get_tool_type_name(self): - """Returns the human readable name of the chosen tool type.""" + def get_tool_type_info(self): my_tool_type = get_tool_type(self) tti = getUtility(IToolTypesInfo) tool_types = tti() if my_tool_type not in tool_types: my_tool_type = tti.default_tool_type - return tool_types[my_tool_type]["title"] + return tool_types[my_tool_type] + + def get_tool_type_name(self): + """Returns the human readable name of the chosen tool type.""" + tool_type_info = self.get_tool_type_info() + return tool_type_info["title"] @indexer(ISurvey) diff --git a/src/euphorie/content/utils.py b/src/euphorie/content/utils.py index 0c69a53d1f..311ec746d5 100644 --- a/src/euphorie/content/utils.py +++ b/src/euphorie/content/utils.py @@ -91,6 +91,7 @@ default="Is this risk acceptable or under control?", ), "custom_button_add_extra": "", + "allow_choice": False, }, ), ( @@ -175,6 +176,15 @@ "custom_button_add_existing_measure", default="Add an already implemented measure", ), + "allow_choice": False, + }, + ), + ( + "inventory", + { + "title": "Inventory tool with generic choices instead of risks", + "description": "", + "allow_choice": True, }, ), ] diff --git a/src/euphorie/content/vocabularies.py b/src/euphorie/content/vocabularies.py index d6c1f3b80a..f409994ee5 100644 --- a/src/euphorie/content/vocabularies.py +++ b/src/euphorie/content/vocabularies.py @@ -1,4 +1,8 @@ +from Acquisition import aq_chain +from Acquisition import aq_inner +from euphorie.content.survey import ISurvey from plone import api +from plone.app.vocabularies.catalog import StaticCatalogVocabulary from plone.app.vocabularies.terms import safe_simplevocabulary_from_values from zope.interface import implementer from zope.schema.interfaces import IContextSourceBinder @@ -20,3 +24,15 @@ def values(self): def __call__(self, context): return safe_simplevocabulary_from_values(self.values) + + +def ChoiceConditionsVocabulary(context=None): + query = { + "portal_type": ["euphorie.option"], + "sort_on": "path", + } + if context is not None: + for parent in aq_chain(aq_inner(context)): + if ISurvey.providedBy(parent): + query["path"] = "/".join(parent.getPhysicalPath()) + return StaticCatalogVocabulary(query) diff --git a/src/euphorie/deployment/profiles/default/registry.xml b/src/euphorie/deployment/profiles/default/registry.xml index c333dcd491..15862d44da 100644 --- a/src/euphorie/deployment/profiles/default/registry.xml +++ b/src/euphorie/deployment/profiles/default/registry.xml @@ -309,11 +309,11 @@ Some json to configure the navigation tile { - "tiles": "\nnavtree [context.portal_type in ['euphorie.profilequestion', 'euphorie.module', 'euphorie.risk', 'euphorie.solution', 'euphorie.survey', 'euphorie.surveygroup', 'euphorie.folder', 'euphorie.documentation', 'euphorie.help', 'euphorie.page', 'euphorie.training_question'] ]\neuphorie.usermgmt.navtree [context.portal_type=='euphorie.country' and request.getURL().endswith('@@manage-users')]", + "tiles": "\nnavtree [context.portal_type in ['euphorie.profilequestion', 'euphorie.module', 'euphorie.risk', 'euphorie.solution', 'euphorie.survey', 'euphorie.surveygroup', 'euphorie.folder', 'euphorie.documentation', 'euphorie.help', 'euphorie.page', 'euphorie.training_question', 'euphorie.choice', 'euphorie.option'] ]\neuphorie.usermgmt.navtree [context.portal_type=='euphorie.country' and request.getURL().endswith('@@manage-users')]", "type": "group" } { - "tiles": "\nnavtree [context.portal_type in ['euphorie.profilequestion', 'euphorie.module', 'euphorie.risk', 'euphorie.solution', 'euphorie.survey', 'euphorie.surveygroup', 'euphorie.folder', 'euphorie.documentation', 'euphorie.help', 'euphorie.page', 'euphorie.training_question'] ]\neuphorie.usermgmt.navtree [context.portal_type=='euphorie.country' and request.getURL().endswith('@@manage-users')]", + "tiles": "\nnavtree [context.portal_type in ['euphorie.profilequestion', 'euphorie.module', 'euphorie.risk', 'euphorie.solution', 'euphorie.survey', 'euphorie.surveygroup', 'euphorie.folder', 'euphorie.documentation', 'euphorie.help', 'euphorie.page', 'euphorie.training_question', 'euphorie.choice', 'euphorie.option'] ]\neuphorie.usermgmt.navtree [context.portal_type=='euphorie.country' and request.getURL().endswith('@@manage-users')]", "type": "group" } diff --git a/src/euphorie/deployment/upgrade/alembic/versions/20250402124248_add_content_type_choice.py b/src/euphorie/deployment/upgrade/alembic/versions/20250402124248_add_content_type_choice.py new file mode 100644 index 0000000000..9ce7c4c37d --- /dev/null +++ b/src/euphorie/deployment/upgrade/alembic/versions/20250402124248_add_content_type_choice.py @@ -0,0 +1,50 @@ +"""Add content type: choice + +Revision ID: 20250402124248 +Revises: 20240419142030 +Create Date: 2025-04-02 16:39:06.474435 + +""" + +from alembic import op +from euphorie.deployment.upgrade.utils import has_table + +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20250402124248" +down_revision = "20240419142030" +branch_labels = None +depends_on = None + + +def upgrade(): + if not has_table("choice"): + op.create_table( + "choice", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("condition", sa.String(length=512), nullable=True), + sa.ForeignKeyConstraint( + ["id"], ["tree.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + if not has_table("option"): + op.create_table( + "option", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("choice_id", sa.Integer(), nullable=False), + sa.Column("zodb_path", sa.String(length=512), nullable=False), + sa.ForeignKeyConstraint( + ["choice_id"], ["choice.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + if has_table("choice"): + op.drop_table("choice") + if has_table("option"): + op.drop_table("option") diff --git a/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/__init__.py b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/registry.xml b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/registry.xml new file mode 100644 index 0000000000..aac1fe3380 --- /dev/null +++ b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/registry.xml @@ -0,0 +1,18 @@ + + + + + Configure the navigation tile + Some json to configure the navigation tile + + { + "tiles": "\nnavtree [context.portal_type in ['euphorie.profilequestion', 'euphorie.module', 'euphorie.risk', 'euphorie.solution', 'euphorie.survey', 'euphorie.surveygroup', 'euphorie.folder', 'euphorie.documentation', 'euphorie.help', 'euphorie.page', 'euphorie.training_question', 'euphorie.choice', 'euphorie.option'] ]\neuphorie.usermgmt.navtree [context.portal_type=='euphorie.country' and request.getURL().endswith('@@manage-users')]", + "type": "group" +} + { + "tiles": "\nnavtree [context.portal_type in ['euphorie.profilequestion', 'euphorie.module', 'euphorie.risk', 'euphorie.solution', 'euphorie.survey', 'euphorie.surveygroup', 'euphorie.folder', 'euphorie.documentation', 'euphorie.help', 'euphorie.page', 'euphorie.training_question', 'euphorie.choice', 'euphorie.option'] ]\neuphorie.usermgmt.navtree [context.portal_type=='euphorie.country' and request.getURL().endswith('@@manage-users')]", + "type": "group" +} + + + diff --git a/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types.xml b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types.xml new file mode 100644 index 0000000000..7a7d1c0729 --- /dev/null +++ b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.choice.xml b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.choice.xml new file mode 100644 index 0000000000..182abc2936 --- /dev/null +++ b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.choice.xml @@ -0,0 +1,75 @@ + + + + + Choice + A survey item that offers a choice between options. + document_icon.gif + False + False + + True + + + + + euphorie.content.choice.Choice + euphorie.content.AddNewRIEContent + + euphorie.content.choice.IChoice + + + + + + + + + + + + False + + + + + + + + + + + + + + + + + + diff --git a/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.module.xml b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.module.xml new file mode 100644 index 0000000000..66a3eb93ea --- /dev/null +++ b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.module.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.option.xml b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.option.xml new file mode 100644 index 0000000000..c3ccaae11f --- /dev/null +++ b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/types/euphorie.option.xml @@ -0,0 +1,73 @@ + + + + + Option + An option that can be picked for a particular choice. + document_icon.gif + False + False + + True + + + plone.dexterity.content.Container + euphorie.content.AddNewRIEContent + + euphorie.content.option.IOption + + + + + + + + + + + + False + + + + + + + + + + + + + + + + + + diff --git a/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/upgrade.py b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/upgrade.py new file mode 100644 index 0000000000..f2aade020c --- /dev/null +++ b/src/euphorie/upgrade/content/v1/20250402124248_add_content_type__choice/upgrade.py @@ -0,0 +1,10 @@ +from euphorie.deployment.upgrade.utils import alembic_upgrade_to +from ftw.upgrade import UpgradeStep + + +class AddContentType_choice(UpgradeStep): + """Add content type: choice.""" + + def __call__(self): + self.install_upgrade_profile() + alembic_upgrade_to(self.target_version)